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:
David Frassi
2026-05-20 11:26:59 +02:00
parent af12a1f2da
commit 21c9c206e1
15 changed files with 970 additions and 225 deletions
+326 -61
View File
@@ -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;