feat: autoscroll standard, visualizzazione tag/date e OCR spaziale avanzato
- Aggiunto autoscroll standard con controllo velocità (V1-V10) e relativo toggle in settings. - Implementata la visualizzazione opzionale di tag e data aggiornamento nella lista canti. - Rinnovata la pagina 'Proponi Canto' con tastiera accordi (fondamentale + variazioni) e OCR spaziale potenziato per l'allineamento automatico degli accordi con il testo. - Ottimizzata la persistenza della modalità schermo intero.
This commit is contained in:
@@ -5,6 +5,7 @@ import { IonicModule, ToastController, IonTextarea, PopoverController, NavContro
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { CantiService } from '../../services/canti.service';
|
||||
import { MyCantiService } from '../../services/my-canti.service';
|
||||
import { ThemeService } from '../../services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-propose-canto',
|
||||
@@ -16,11 +17,11 @@ import { MyCantiService } from '../../services/my-canti.service';
|
||||
export class ProposeCantoPage implements OnInit {
|
||||
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
|
||||
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
|
||||
@ViewChild('docInput', { static: false }) docInput!: ElementRef;
|
||||
|
||||
public cantiService = inject(CantiService);
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private navCtrl = inject(NavController);
|
||||
public themeService = inject(ThemeService);
|
||||
|
||||
title: string = '';
|
||||
author: string = '';
|
||||
@@ -40,20 +41,55 @@ export class ProposeCantoPage implements OnInit {
|
||||
undoStack: string[] = [];
|
||||
isProcessingOCR: boolean = false;
|
||||
ocrProgress: number = 0;
|
||||
isHighContrast: boolean = false;
|
||||
get isHighContrast(): boolean { return this.themeService.highContrast(); }
|
||||
|
||||
commonChords = [
|
||||
'DO', 'RE', 'MI', 'FA', 'SOL', 'LA', 'SI',
|
||||
'DO-', 'RE-', 'MI-', 'FA-', 'SOL-', 'LA-', 'SI-',
|
||||
'DO#', 'RE#', 'FA#', 'SOL#', 'LA#', 'DO#-', 'RE#-', 'FA#-', 'SOL#-', 'LA#-',
|
||||
'DO7', 'RE7', 'MI7', 'FA7', 'SOL7', 'LA7', 'SI7', 'DO-7', 'RE-7', 'MI-7', 'LA-7', 'SI-7',
|
||||
'DOmaj7', 'REmaj7', 'MImaj7', 'FAmaj7', 'SOLmaj7', 'LAmaj7', 'SImaj7',
|
||||
'DO4', 'RE4', 'MI4', 'FA4', 'SOL4', 'LA4', 'SI4',
|
||||
'DOdim', 'REdim', 'MIdim', 'FAdim', 'SOLdim', 'LAdim', 'SIdim',
|
||||
'DOaug', 'REaug', 'MIaug', 'FAaug', 'SOLaug', 'LAaug', 'SIaug',
|
||||
'DOm7', 'REm7', 'FAm7', 'SOLm7', 'LAm7'
|
||||
groupedChords = [
|
||||
{
|
||||
root: 'DO',
|
||||
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7']
|
||||
},
|
||||
{
|
||||
root: 'RE',
|
||||
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7']
|
||||
},
|
||||
{
|
||||
root: 'MI',
|
||||
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim']
|
||||
},
|
||||
{
|
||||
root: 'FA',
|
||||
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7']
|
||||
},
|
||||
{
|
||||
root: 'SOL',
|
||||
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7']
|
||||
},
|
||||
{
|
||||
root: 'LA',
|
||||
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LAmaj7', 'LA4', 'LAdim', 'LAm7']
|
||||
},
|
||||
{
|
||||
root: 'SI',
|
||||
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim']
|
||||
}
|
||||
];
|
||||
|
||||
selectedRootChord: string | null = null;
|
||||
|
||||
selectRoot(root: string) {
|
||||
if (this.selectedRootChord === root) {
|
||||
this.selectedRootChord = null;
|
||||
} else {
|
||||
this.selectedRootChord = root;
|
||||
}
|
||||
}
|
||||
|
||||
getVariations(): string[] {
|
||||
if (!this.selectedRootChord) return [];
|
||||
const group = this.groupedChords.find(g => g.root === this.selectedRootChord);
|
||||
return group ? group.chords : [];
|
||||
}
|
||||
|
||||
commonTags = [
|
||||
{ label: 'Ritornello', start: '{start_chorus}', end: '{end_chorus}' },
|
||||
{ label: 'Strofa', start: '{start_verse}', end: '{end_verse}' },
|
||||
@@ -65,8 +101,6 @@ export class ProposeCantoPage implements OnInit {
|
||||
{ label: 'ChordPro Rit.', start: '{soc}', end: '{eoc}' }
|
||||
];
|
||||
|
||||
isChordPopoverOpen = false;
|
||||
|
||||
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
|
||||
|
||||
ngOnInit() {
|
||||
@@ -109,46 +143,47 @@ export class ProposeCantoPage implements OnInit {
|
||||
this.cameraInput.nativeElement.click();
|
||||
}
|
||||
|
||||
uploadDoc() {
|
||||
this.docInput.nativeElement.click();
|
||||
}
|
||||
|
||||
async onFileSelected(event: any, isCamera: boolean) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
console.log('[OCR-Capture] Nessun file selezionato.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
|
||||
|
||||
this.isProcessingOCR = true;
|
||||
this.ocrProgress = 0;
|
||||
|
||||
try {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
let extractedText = '';
|
||||
|
||||
if (extension === 'pdf') {
|
||||
extractedText = await this.processPdf(file);
|
||||
} else if (extension === 'docx') {
|
||||
const mammoth = await import('mammoth');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
extractedText = result.value;
|
||||
} else if (['jpg', 'jpeg', 'png', 'webp'].includes(extension) || isCamera) {
|
||||
extractedText = await this.processImageOCR(file);
|
||||
} else {
|
||||
extractedText = await file.text();
|
||||
}
|
||||
console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
|
||||
const compressedBlob = await this.resizeImage(file);
|
||||
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
|
||||
console.log(`[OCR-Capture] Dimensioni dopo compressione: ${(compressedFile.size / 1024).toFixed(1)} KB`);
|
||||
|
||||
console.log('[OCR-Capture] Avvio motore Tesseract OCR local...');
|
||||
const extractedText = await this.processImageOCR(compressedFile);
|
||||
|
||||
if (extractedText) {
|
||||
const processed = this.smartProcessOCR(extractedText);
|
||||
this.content += (this.content ? '\n\n' : '') + processed;
|
||||
console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
|
||||
this.content += (this.content ? '\n\n' : '') + extractedText;
|
||||
const toast = await this.toastController.create({
|
||||
message: 'Documento elaborato!',
|
||||
message: 'Scansione completata!',
|
||||
duration: 2000,
|
||||
color: 'success'
|
||||
});
|
||||
toast.present();
|
||||
} else {
|
||||
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File Processing Error:', error);
|
||||
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
|
||||
const errorToast = await this.toastController.create({
|
||||
message: 'Errore durante la scansione dell\'immagine.',
|
||||
duration: 3000,
|
||||
color: 'danger'
|
||||
});
|
||||
errorToast.present();
|
||||
} finally {
|
||||
this.isProcessingOCR = false;
|
||||
this.ocrProgress = 0;
|
||||
@@ -156,36 +191,268 @@ export class ProposeCantoPage implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async resizeImage(file: File): Promise<Blob> {
|
||||
console.log('[OCR-Capture] Caricamento immagine in memoria...');
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
console.log(`[OCR-Capture] Immagine caricata in memoria. Dimensioni originali: ${img.width}x${img.height}`);
|
||||
const canvas = document.createElement('canvas');
|
||||
const maxDim = 1200;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > maxDim || height > maxDim) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxDim) / width);
|
||||
width = maxDim;
|
||||
} else {
|
||||
width = Math.round((width * maxDim) / height);
|
||||
height = maxDim;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[OCR-Capture] Ridimensionamento a: ${width}x${height}`);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
console.log('[OCR-Capture] Compressione in JPEG completata con successo.');
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed'));
|
||||
}
|
||||
}, 'image/jpeg', 0.85);
|
||||
} else {
|
||||
reject(new Error('Canvas getContext 2d failed'));
|
||||
}
|
||||
};
|
||||
img.onerror = (err) => {
|
||||
console.error('[OCR-Capture] Impossibile caricare l\'immagine in memoria:', err);
|
||||
reject(err);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async processImageOCR(file: File): Promise<string> {
|
||||
console.log('[OCR-Capture] Inizializzazione Worker Tesseract.js...');
|
||||
const worker = await createWorker('ita', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text') this.ocrProgress = m.progress;
|
||||
if (m.status === 'recognizing text') {
|
||||
this.ocrProgress = m.progress;
|
||||
console.log(`[OCR-Capture] Progresso OCR: ${(m.progress * 100).toFixed(0)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const { data: { text } } = await worker.recognize(file);
|
||||
console.log('[OCR-Capture] Avvio riconoscimento caratteri (OCR) con blocks abilitato...');
|
||||
const { data } = await worker.recognize(file, {}, { blocks: true });
|
||||
console.log('[OCR-Capture] Riconoscimento caratteri terminato. Spegnimento worker...');
|
||||
await worker.terminate();
|
||||
return text;
|
||||
}
|
||||
console.log('[OCR-Capture] Spegnimento worker completato. Estrazione parole...');
|
||||
|
||||
async processPdf(file: File): Promise<string> {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
// Set worker src from CDN for PWA compatibility
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
let fullText = '';
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items.map((item: any) => item.str).join(' ');
|
||||
fullText += pageText + '\n';
|
||||
// Flatten blocks hierarchy to get a flat words list
|
||||
const words: any[] = [];
|
||||
if (data && (data as any).blocks) {
|
||||
const blocks = (data as any).blocks;
|
||||
blocks.forEach((block: any) => {
|
||||
if (block.paragraphs) {
|
||||
block.paragraphs.forEach((paragraph: any) => {
|
||||
if (paragraph.lines) {
|
||||
paragraph.lines.forEach((line: any) => {
|
||||
if (line.words) {
|
||||
line.words.forEach((word: any) => {
|
||||
words.push(word);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return fullText;
|
||||
|
||||
console.log(`[OCR-Capture] Parole estratte dal blocco gerarchico: ${words.length}`);
|
||||
return this.parseSongSpatially(words);
|
||||
}
|
||||
|
||||
parseSongSpatially(words: any[]): string {
|
||||
if (!words || words.length === 0) {
|
||||
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log(`[OCR-Capture] Parole totali ricevute dall'OCR: ${words.length}`);
|
||||
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
|
||||
console.log(`[OCR-Capture] Parole valide dopo filtraggio: ${validWords.length}`);
|
||||
if (validWords.length === 0) return '';
|
||||
|
||||
// Calculate average word height to set vertical tolerance
|
||||
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
|
||||
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
||||
const verticalTolerance = avgHeight * 0.6;
|
||||
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
|
||||
|
||||
// 2. Group words into horizontal lines
|
||||
const lines: any[][] = [];
|
||||
validWords.forEach(word => {
|
||||
let added = false;
|
||||
for (const line of lines) {
|
||||
const avgLineY0 = line.reduce((sum, w) => sum + w.bbox.y0, 0) / line.length;
|
||||
if (Math.abs(word.bbox.y0 - avgLineY0) < verticalTolerance) {
|
||||
line.push(word);
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!added) {
|
||||
lines.push([word]);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort words horizontally in each line
|
||||
lines.forEach(line => line.sort((a, b) => a.bbox.x0 - b.bbox.x0));
|
||||
|
||||
// Sort all lines vertically by average y0
|
||||
lines.sort((a, b) => {
|
||||
const avgA = a.reduce((sum, w) => sum + w.bbox.y0, 0) / a.length;
|
||||
const avgB = b.reduce((sum, w) => sum + w.bbox.y0, 0) / b.length;
|
||||
return avgA - avgB;
|
||||
});
|
||||
|
||||
// 3. Classify lines as Chords vs. Text
|
||||
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?$/i;
|
||||
const isChordWord = (text: string): boolean => {
|
||||
const clean = text.replace(/[\[\]\(\)\.\,\-\+]/g, '').trim().toUpperCase();
|
||||
return chordRegex.test(clean);
|
||||
};
|
||||
|
||||
const classifiedLines = lines.map(line => {
|
||||
const chordCount = line.filter(w => isChordWord(w.text)).length;
|
||||
const ratio = line.length > 0 ? chordCount / line.length : 0;
|
||||
const isChords = ratio >= 0.4 && line.length <= 10;
|
||||
|
||||
return {
|
||||
words: line,
|
||||
isChords: isChords,
|
||||
yCenter: line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1)/2, 0) / line.length
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Merge chords and text lines
|
||||
const processedLines: string[] = [];
|
||||
let inChorus = false;
|
||||
let inVerse = false;
|
||||
const chorusStartRegex = /^(R:|Rit\.|Rit|Ritornello|Coro)/i;
|
||||
|
||||
for (let i = 0; i < classifiedLines.length; i++) {
|
||||
const current = classifiedLines[i];
|
||||
|
||||
if (current.isChords) {
|
||||
const next = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
||||
if (next && !next.isChords) {
|
||||
// Merge spatially!
|
||||
const merged = this.mergeChordsAndLyrics(current.words, next.words);
|
||||
const lineText = next.words.map(w => w.text).join(' ');
|
||||
const isChorus = chorusStartRegex.test(lineText);
|
||||
|
||||
if (isChorus) {
|
||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
|
||||
} else if (!inChorus && !inVerse && lineText.length > 5) {
|
||||
processedLines.push('{start_verse}');
|
||||
inVerse = true;
|
||||
}
|
||||
|
||||
processedLines.push(merged);
|
||||
i++; // Skip next line because we consumed it!
|
||||
} else {
|
||||
// Chord line but no text below it: just wrap and print
|
||||
const wrapped = current.words.map(w => `[${w.text.replace(/[\(\)\[\]]/g, '').toUpperCase()}]`).join(' ');
|
||||
processedLines.push(wrapped);
|
||||
}
|
||||
} else {
|
||||
const lineText = current.words.map(w => w.text).join(' ');
|
||||
const isChorus = chorusStartRegex.test(lineText);
|
||||
|
||||
if (isChorus) {
|
||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
|
||||
} else if (!inChorus && !inVerse && lineText.length > 5) {
|
||||
processedLines.push('{start_verse}');
|
||||
inVerse = true;
|
||||
}
|
||||
|
||||
processedLines.push(lineText);
|
||||
}
|
||||
|
||||
// If we see a large vertical gap, close open blocks
|
||||
const currentNext = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
||||
if (currentNext) {
|
||||
const gap = currentNext.yCenter - current.yCenter;
|
||||
if (gap > avgHeight * 2.5) {
|
||||
if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; }
|
||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||
processedLines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inChorus) processedLines.push('{end_chorus}');
|
||||
if (inVerse) processedLines.push('{end_verse}');
|
||||
|
||||
return processedLines.join('\n');
|
||||
}
|
||||
|
||||
mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
|
||||
let result = '';
|
||||
const chordAssignments = new Map<any, any[]>();
|
||||
|
||||
chordWords.forEach(chord => {
|
||||
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
||||
let closestWord: any = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
textWords.forEach(textWord => {
|
||||
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
|
||||
const dist = Math.abs(chordX - wordXCenter);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestWord = textWord;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestWord) {
|
||||
if (!chordAssignments.has(closestWord)) {
|
||||
chordAssignments.set(closestWord, []);
|
||||
}
|
||||
chordAssignments.get(closestWord)!.push(chord);
|
||||
}
|
||||
});
|
||||
|
||||
textWords.forEach((textWord, index) => {
|
||||
const assignedChords = chordAssignments.get(textWord) || [];
|
||||
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||
|
||||
assignedChords.forEach(chord => {
|
||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||
result += `[${cleanChord}]`;
|
||||
});
|
||||
|
||||
result += textWord.text;
|
||||
if (index < textWords.length - 1) {
|
||||
result += ' ';
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
smartProcessOCR(text: string): string {
|
||||
let lines = text.split('\n');
|
||||
let processedLines: string[] = [];
|
||||
@@ -230,9 +497,7 @@ export class ProposeCantoPage implements OnInit {
|
||||
return line;
|
||||
}
|
||||
|
||||
async openQuickMenu(event: any) {
|
||||
this.isChordPopoverOpen = true;
|
||||
}
|
||||
|
||||
|
||||
async saveToMyCanti() {
|
||||
if (!this.title || !this.content) return;
|
||||
|
||||
Reference in New Issue
Block a user