mirror of
https://github.com/gabriele-cusato/HandTranscriptMd.git
synced 2026-07-22 06:14:06 +00:00
migliorato ocr, aggiungendo anche il supporto alle <keyword>
This commit is contained in:
parent
b1267debd5
commit
e94264e3cf
6 changed files with 755 additions and 52 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -215,12 +215,16 @@ cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; nod
|
|||
> **Storico sessioni, bug risolti e funzionalità completate** → vedi [`NOTES.md`](./NOTES.md)
|
||||
> I task completati vanno spostati in `NOTES.md`; in questa sezione restano solo i task ancora da fare.
|
||||
|
||||
## Regole di sviluppo
|
||||
|
||||
> **Keyword parser**: ogni volta che si aggiunge o rimuove una keyword accettata in `src/md-parser.ts`, va aggiornata **anche** la tabella `KEYWORDS` in `src/settings.ts` (sezione "Keyword riconosciute dal parser OCR"). Le due liste devono essere sempre sincronizzate.
|
||||
|
||||
## Prossimi passi
|
||||
|
||||
### Task aperti
|
||||
|
||||
- **Migliorare il riconoscimento OCR da Gemini** — `src/recognizer.ts` + `src/md-parser.ts`:
|
||||
- Migliorare la qualità complessiva del riconoscimento, non solo i simboli markdown
|
||||
- Affinare il prompt con few-shot examples per simboli (`#`, `-`, `>`, `**`, `==`, ecc.)
|
||||
- Valutare se `md-parser.ts` può coprire i casi che il prompt non gestisce
|
||||
- Valutare modelli Gemini alternativi o parametri diversi (temperatura, ecc.)
|
||||
- **Keyword personalizzate nelle impostazioni** — `src/settings.ts` + `src/md-parser.ts`:
|
||||
- Permettere all'utente di aggiungere keyword custom nella pagina impostazioni
|
||||
- Ogni keyword custom ha: nome (es. `FIRMA`), output markdown (es. `— Mario Rossi`)
|
||||
- Le keyword custom vengono caricate in `expandKeywords` insieme a quelle predefinite
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +1,421 @@
|
|||
/* =============================================
|
||||
md-parser — Parser prefissi Markdown
|
||||
Converte testo grezzo (output OCR) in markdown
|
||||
strutturato, analizzando riga per riga.
|
||||
md-parser — Parsing OCR → Markdown Obsidian
|
||||
|
||||
Pipeline in due step:
|
||||
1. normalizeMarkdownSymbols — corregge simboli approssimativi
|
||||
2. expandKeywords — espande _KEYWORD: in strutture markdown
|
||||
============================================= */
|
||||
|
||||
// Converte tutto il testo grezzo in markdown formattato
|
||||
// =============================================================================
|
||||
// STEP 1 — normalizeMarkdownSymbols
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Corregge simboli markdown scritti a mano in modo approssimativo (output OCR).
|
||||
* Opera riga per riga, saltando completamente il contenuto dei blocchi codice.
|
||||
*/
|
||||
export function normalizeMarkdownSymbols(rawText: string): string {
|
||||
// Rimuove BOM e caratteri zero-width Unicode che Gemini inserisce all'inizio
|
||||
// del testo o all'inizio di righe (U+FEFF, U+200B, U+200C, U+200D, U+2060)
|
||||
const cleaned = rawText.replace(/[\uFEFF\u200B\u200C\u200D\u2060]/g, '');
|
||||
|
||||
const lines = cleaned.split('\n');
|
||||
const out: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Delimitatore blocco codice (``` opzionalmente seguito da linguaggio)
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
// Dentro un blocco codice: non toccare nulla
|
||||
if (inCodeBlock) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
out.push(normalizeLine(line));
|
||||
}
|
||||
|
||||
let result = out.join('\n');
|
||||
|
||||
// Parola ripetuta consecutivamente: "ciao ciao" → "ciao"
|
||||
// \b(\w+)\s+\1\b cattura la stessa parola due volte separate da spazio
|
||||
result = result.replace(/\b(\w+)\s+\1\b/gi, '$1');
|
||||
|
||||
// Massimo una riga vuota tra paragrafi
|
||||
result = result.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Converti righe con separatori pipe (|) in tabelle markdown
|
||||
result = convertPipeTables(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applica le correzioni su una singola riga (non dentro blocchi codice).
|
||||
*/
|
||||
function normalizeLine(line: string): string {
|
||||
// Righe keyword <KEYWORD> — non modificare, le gestisce expandKeywords
|
||||
if (/^<[A-Za-z0-9_]+/i.test(line.trim())) return line;
|
||||
|
||||
// Rimuove spazi finali (non iniziali: servono per le liste annidate)
|
||||
line = line.trimEnd();
|
||||
|
||||
// Separatori alternativi: ===, ___, *** o già --- → normalizza a ---
|
||||
// Controlla PRIMA degli heading perché ___ potrebbe confondersi
|
||||
if (/^(={3,}|_{3,}|\*{3,}|-{3,})$/.test(line.trim())) return '---';
|
||||
|
||||
// Heading senza spazio: #Ciao → # Ciao, ##titolo → ## Titolo
|
||||
// Cattura #{1,6} + qualsiasi carattere non-spazio/non-# come primo carattere
|
||||
line = line.replace(/^(#{1,6})([^\s#].*)$/, (_, hashes: string, content: string) => {
|
||||
const text = content.trim();
|
||||
// Prima lettera maiuscola per H1/H2/H3 (regola di stile)
|
||||
const cap = hashes.length <= 3
|
||||
? text.charAt(0).toUpperCase() + text.slice(1)
|
||||
: text;
|
||||
return `${hashes} ${cap}`;
|
||||
});
|
||||
|
||||
// Bullet unicode → lista markdown: •, ·, ∙, ◦, ‣, ⁃, ➤, ●, ○, ▶, ►, ▸ ecc.
|
||||
line = line.replace(/^[•·∙◦‣⁃➤➢▶►▸▪▫●○]\s*(.+)$/, '- $1');
|
||||
|
||||
// Lista non ordinata: -elemento (senza spazio) → - elemento
|
||||
// [^\s\-] assicura che non sia già "- " né "--"
|
||||
line = line.replace(/^-([^\s\-].+)$/, '- $1');
|
||||
|
||||
// Lista numerata — tre varianti:
|
||||
// 1)elemento o 1.elemento (con o senza spazio) → 1. elemento
|
||||
line = line.replace(/^(\d+)[.)]\s*(\S.*)$/, '$1. $2');
|
||||
// 1elemento (numero attaccato a lettera) → 1. elemento
|
||||
line = line.replace(/^(\d+)([A-Za-zÀ-ÖØ-öø-ÿ].*)$/, '$1. $2');
|
||||
|
||||
// Checkbox: varianti scritte a mano
|
||||
// [x], [X], [v], [V], [✓] → - [x] testo
|
||||
line = line.replace(/^\[\s*[xXvV✓]\s*\]\s*(.*)$/, '- [x] $1');
|
||||
// [], [ ] → - [ ] testo
|
||||
line = line.replace(/^\[\s*\]\s*(.*)$/, '- [ ] $1');
|
||||
|
||||
// Blockquote senza spazio: >testo → > testo
|
||||
line = line.replace(/^>([^\s].*)$/, '> $1');
|
||||
|
||||
// Spazi multipli → spazio singolo, preservando l'indentazione iniziale
|
||||
const leading = line.match(/^(\s*)/)?.[1] ?? '';
|
||||
const body = line.slice(leading.length).replace(/ +/g, ' ');
|
||||
line = leading + body;
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte gruppi di 2+ righe consecutive separate da pipe (|) in tabelle markdown.
|
||||
* Evita righe già formattate come tabella (con riga separatore |---|).
|
||||
*/
|
||||
function convertPipeTables(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
let inCodeBlock = false;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Rispetta i blocchi codice anche in questa fase
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
out.push(line); i++; continue;
|
||||
}
|
||||
if (inCodeBlock) { out.push(line); i++; continue; }
|
||||
|
||||
// Una riga con | potrebbe essere l'inizio di una tabella grezza
|
||||
const hasPipe = line.includes('|');
|
||||
// Salta intestazioni, blockquote e righe separatore già in formato markdown
|
||||
const isSep = /^\|?\s*:?-+:?\s*\|/.test(line);
|
||||
const isSpecial = line.trim().startsWith('#') || line.trim().startsWith('>');
|
||||
|
||||
if (hasPipe && !isSep && !isSpecial) {
|
||||
// Raccoglie righe consecutive con pipe, saltando eventuali separatori già presenti
|
||||
const block: string[] = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].includes('|') && lines[j].trim() !== '') {
|
||||
if (!/^\|?\s*:?-+:?\s*\|/.test(lines[j])) block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
// Converte solo se ci sono almeno 2 righe (intestazione + almeno 1 dati)
|
||||
if (block.length >= 2) {
|
||||
// Divide le celle: rimuove celle vuote iniziali/finali create da | ai bordi
|
||||
const rows = block.map(r => {
|
||||
const parts = r.split('|').map(c => c.trim());
|
||||
const start = parts[0] === '' ? 1 : 0;
|
||||
const end = parts[parts.length - 1] === '' ? parts.length - 1 : parts.length;
|
||||
return parts.slice(start, end);
|
||||
});
|
||||
const cols = Math.max(...rows.map(r => r.length));
|
||||
const headerRow = '| ' + rows[0].join(' | ') + ' |';
|
||||
// Riga separatore compatta: |---|---|
|
||||
const sepRow = '|' + rows[0].map(() => '---|').join('');
|
||||
const dataRows = rows.slice(1).map(row => {
|
||||
const cells = Array.from({ length: cols }, (_, k) => row[k] ?? '');
|
||||
return '| ' + cells.join(' | ') + ' |';
|
||||
});
|
||||
out.push(headerRow, sepRow, ...dataRows);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP 2 — expandKeywords
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Espande le keyword _KEYWORD: in strutture markdown complete.
|
||||
* Tutte le keyword sono case-insensitive. Lo spazio dopo ':' è opzionale.
|
||||
*
|
||||
* @param text - testo già normalizzato
|
||||
* @param fnStart - numero di partenza per le footnote _FN: (default 1)
|
||||
*/
|
||||
export function expandKeywords(text: string, fnStart = 1): string {
|
||||
const lines = text.split('\n');
|
||||
const out: string[] = [];
|
||||
let fn = fnStart; // contatore footnote corrente
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Pattern: <KEYWORD> contenuto oppure <KEYWORD extra> contenuto (es. <CODEBLOCK js>)
|
||||
// Il parametro extra (linguaggio per CODEBLOCK) è dentro le <>: <CODEBLOCK js>
|
||||
// Il contenuto segue > con opzionale : e/o spazio (es. <H1> CIAO, <H1>:CIAO)
|
||||
const kw = trimmed.match(/^<([A-Za-z0-9_]+)(?:\s+([^>]*?))?\s*>[\s:]*(.*)/i);
|
||||
|
||||
if (!kw) {
|
||||
out.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyword = kw[1].toUpperCase(); // nome keyword normalizzato
|
||||
const extra = (kw[2] ?? '').trim(); // parametro aggiuntivo (es. linguaggio CODEBLOCK)
|
||||
const content = (kw[3] ?? '').trim(); // contenuto dopo ':'
|
||||
|
||||
switch (keyword) {
|
||||
|
||||
// --- Titoli ---
|
||||
case 'H1': out.push(`# ${capitalize(content)}`); break;
|
||||
case 'H2': out.push(`## ${capitalize(content)}`); break;
|
||||
case 'H3': out.push(`### ${capitalize(content)}`); break;
|
||||
case 'H4': out.push(`#### ${content}`); break;
|
||||
|
||||
// --- Inline style ---
|
||||
case 'B':
|
||||
case 'BOLD': out.push(`**${content}**`); break;
|
||||
case 'I': out.push(`*${content}*`); break;
|
||||
case 'BI': out.push(`***${content}***`); break;
|
||||
case 'S':
|
||||
case 'STRIKE': out.push(`~~${content}~~`); break;
|
||||
case 'HL': out.push(`==${content}==`); break;
|
||||
case 'CODE': out.push(`\`${content}\``); break;
|
||||
|
||||
// --- Liste ---
|
||||
case 'LIST': out.push(buildBulletList(content)); break;
|
||||
case 'NUMLIST': out.push(buildNumList(content)); break;
|
||||
case 'CHECK': out.push(buildChecklist(content)); break;
|
||||
|
||||
// --- Callout Obsidian ---
|
||||
case 'NOTE': out.push(buildCallout('NOTE', content)); break;
|
||||
case 'WARN': out.push(buildCallout('WARNING', content)); break;
|
||||
case 'TIP': out.push(buildCallout('TIP', content)); break;
|
||||
case 'INFO': out.push(buildCallout('INFO', content)); break;
|
||||
case 'ERROR': out.push(buildCallout('ERROR', content)); break;
|
||||
case 'IMPORTANT': out.push(buildCallout('IMPORTANT', content)); break;
|
||||
case 'QUOTE': out.push(`> ${content}`); break;
|
||||
|
||||
// --- Link e immagini ---
|
||||
case 'LINK': {
|
||||
const [label, url] = splitTwo(content);
|
||||
out.push(`[${label}](${url})`);
|
||||
break;
|
||||
}
|
||||
case 'IMG': {
|
||||
const [alt, url] = splitTwo(content);
|
||||
out.push(``);
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Separatori ---
|
||||
case 'HR':
|
||||
case 'SEP': out.push('---'); break;
|
||||
|
||||
// --- Footnote auto-numerata ---
|
||||
case 'FN': out.push(`[^${fn++}]: ${content}`); break;
|
||||
|
||||
// --- Math ---
|
||||
case 'MATH': out.push(`$${content}$`); break;
|
||||
|
||||
// --- Tag Obsidian (sostituisce spazi con underscore) ---
|
||||
case 'TAG': out.push(`#${content.replace(/\s+/g, '_')}`); break;
|
||||
|
||||
// --- Data / Ora (usa ora di sistema) ---
|
||||
case 'DATE': {
|
||||
const d = new Date();
|
||||
out.push(d.toISOString().slice(0, 10));
|
||||
break;
|
||||
}
|
||||
case 'TIME': {
|
||||
const d = new Date();
|
||||
out.push(d.toTimeString().slice(0, 5));
|
||||
break;
|
||||
}
|
||||
case 'DATETIME': {
|
||||
const d = new Date();
|
||||
out.push(`${d.toISOString().slice(0, 10)} ${d.toTimeString().slice(0, 5)}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Indent (aggiunge 2 spazi) ---
|
||||
case 'INDENT': out.push(` ${content}`); break;
|
||||
|
||||
// --- CODEBLOCK multi-riga (termina con riga vuota) ---
|
||||
// Sintassi: _CODEBLOCK js: (linguaggio nel parametro opzionale prima di ':')
|
||||
case 'CODEBLOCK': {
|
||||
const lang = extra; // es. "js", "python", ""
|
||||
i++;
|
||||
const codeLines: string[] = [];
|
||||
// Raccoglie righe fino alla prima riga vuota o fine testo
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
out.push(`\`\`\`${lang}\n${codeLines.join('\n')}\n\`\`\``);
|
||||
continue; // i già avanzato, salta l'i++ finale
|
||||
}
|
||||
|
||||
// --- MATHBLOCK multi-riga (termina con riga vuota) ---
|
||||
case 'MATHBLOCK': {
|
||||
i++;
|
||||
const mathLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
mathLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
out.push(`$$\n${mathLines.join('\n')}\n$$`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- TABLE multi-riga ---
|
||||
// Header: <TABLE> Col1, Col2, Col3
|
||||
// Righe: val1, val2, val3 (una per riga, virgola come separatore)
|
||||
// Fine: <TABLE> (tag di chiusura) oppure riga vuota / senza virgola
|
||||
case 'TABLE': {
|
||||
const headers = content.split(',').map(h => h.trim());
|
||||
const rows: string[][] = [];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const rowLine = lines[i].trim();
|
||||
// Tag di chiusura: "<TABLE>" senza contenuto
|
||||
if (/^<TABLE>\s*$/i.test(rowLine)) { i++; break; }
|
||||
// Fine implicita: riga vuota o riga senza virgola
|
||||
if (!rowLine || !rowLine.includes(',')) break;
|
||||
rows.push(rowLine.split(',').map(c => c.trim()));
|
||||
i++;
|
||||
}
|
||||
out.push(buildTable(headers, rows));
|
||||
continue;
|
||||
}
|
||||
|
||||
default:
|
||||
// Keyword non riconosciuta: lascia la riga invariata
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PIPELINE COMPLETA
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Pipeline completa: normalizzazione simboli → espansione keyword.
|
||||
* Questa è la funzione principale da usare per l'output OCR grezzo.
|
||||
*/
|
||||
export function parseHandwritingToMarkdown(rawOcrText: string): string {
|
||||
return expandKeywords(normalizeMarkdownSymbols(rawOcrText));
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias di compatibilità con il codice pre-esistente che chiamava parseMarkdown().
|
||||
*/
|
||||
export function parseMarkdown(raw: string): string {
|
||||
const lines = raw.split('\n');
|
||||
return lines.map(parseLine).join('\n');
|
||||
return parseHandwritingToMarkdown(raw);
|
||||
}
|
||||
|
||||
// Processa una singola riga, rilevando il prefisso e applicando la sintassi markdown
|
||||
function parseLine(line: string): string {
|
||||
const t = line.trim();
|
||||
if (!t) return '';
|
||||
// =============================================================================
|
||||
// HELPER PRIVATI
|
||||
// =============================================================================
|
||||
|
||||
// Separatore orizzontale: "---" o più trattini
|
||||
if (/^-{3,}$/.test(t)) return '---';
|
||||
|
||||
// Intestazioni H1 / H2 / H3 (es. "# Titolo", "## Sezione")
|
||||
if (/^#{1,3}\s+/.test(t)) return t;
|
||||
|
||||
// Checkbox spuntata: "- [x] testo" (case insensitive)
|
||||
if (/^-\s*\[x\]\s*/i.test(t)) {
|
||||
const content = t.replace(/^-\s*\[x\]\s*/i, '');
|
||||
return `- [x] ${content}`;
|
||||
}
|
||||
|
||||
// Checkbox vuota: "- [ ] testo"
|
||||
if (/^-\s*\[\s*\]\s*/.test(t)) {
|
||||
const content = t.replace(/^-\s*\[\s*\]\s*/, '');
|
||||
return `- [ ] ${content}`;
|
||||
}
|
||||
|
||||
// Lista non ordinata: "- testo" o "* testo"
|
||||
if (/^[-*]\s+/.test(t)) {
|
||||
const content = t.replace(/^[-*]\s+/, '');
|
||||
return `- ${content}`;
|
||||
}
|
||||
|
||||
// Lista ordinata: "1. testo", "2. testo", ecc.
|
||||
if (/^\d+\.\s+/.test(t)) return t;
|
||||
|
||||
// Blockquote: "> testo"
|
||||
if (/^>\s*/.test(t)) return t;
|
||||
|
||||
// Blocco codice: riga che inizia con ``` (anche con linguaggio, es. ```js)
|
||||
if (t.startsWith('```')) return t;
|
||||
|
||||
// Testo con inline code, grassetto, corsivo, highlight, barrato:
|
||||
// questi sono già nella forma giusta (es. **testo**), li passiamo invariati.
|
||||
// L'OCR dovrebbe produrli come scritti a mano con i simboli.
|
||||
return t;
|
||||
/** Prima lettera maiuscola, resto invariato */
|
||||
function capitalize(s: string): string {
|
||||
if (!s) return s;
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide "testo, resto" in ["testo", "resto"] usando la PRIMA virgola.
|
||||
* Usato da _LINK: e _IMG: per separare label/alt da url.
|
||||
*/
|
||||
function splitTwo(content: string): [string, string] {
|
||||
const idx = content.indexOf(',');
|
||||
if (idx === -1) return [content.trim(), ''];
|
||||
return [content.slice(0, idx).trim(), content.slice(idx + 1).trim()];
|
||||
}
|
||||
|
||||
/** Costruisce lista puntata da "a, b, c" → "- a\n- b\n- c" */
|
||||
function buildBulletList(content: string): string {
|
||||
return content.split(',').map(item => `- ${item.trim()}`).join('\n');
|
||||
}
|
||||
|
||||
/** Costruisce lista numerata da "a, b, c" → "1. a\n2. b\n3. c" */
|
||||
function buildNumList(content: string): string {
|
||||
return content.split(',').map((item, i) => `${i + 1}. ${item.trim()}`).join('\n');
|
||||
}
|
||||
|
||||
/** Costruisce checklist da "a, b, c" → "- [ ] a\n- [ ] b\n- [ ] c" */
|
||||
function buildChecklist(content: string): string {
|
||||
return content.split(',').map(item => `- [ ] ${item.trim()}`).join('\n');
|
||||
}
|
||||
|
||||
/** Costruisce un callout Obsidian: > [!TYPE]\n> contenuto */
|
||||
function buildCallout(type: string, content: string): string {
|
||||
return `> [!${type}]\n> ${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Costruisce una tabella markdown con intestazioni e righe opzionali.
|
||||
* Il separatore usa il formato compatto |---|---| (senza spazi interni).
|
||||
*/
|
||||
function buildTable(headers: string[], rows: string[][]): string {
|
||||
const headerRow = '| ' + headers.join(' | ') + ' |';
|
||||
// Separatore compatto: |---|---|---| (necessario per toContain nei test)
|
||||
const sepRow = '|' + headers.map(() => '---|').join('');
|
||||
// Righe dati: padda al numero di colonne dell'intestazione
|
||||
const dataRows = rows.map(row => {
|
||||
const cells = Array.from({ length: headers.length }, (_, k) => row[k] ?? '');
|
||||
return '| ' + cells.join(' | ') + ' |';
|
||||
});
|
||||
return [headerRow, sepRow, ...dataRows].join('\n');
|
||||
}
|
||||
|
|
|
|||
220
HandTranscriptMd/src/parser.test.ts
Normal file
220
HandTranscriptMd/src/parser.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/* =============================================
|
||||
parser.test.ts — Test autonomo per md-parser
|
||||
|
||||
Eseguibile senza framework:
|
||||
npx tsx src/parser.test.ts
|
||||
|
||||
Non richiede dipendenze esterne: usa solo
|
||||
un helper expect/toBe inline.
|
||||
============================================= */
|
||||
|
||||
import { normalizeMarkdownSymbols as normalize, expandKeywords as expand, parseHandwritingToMarkdown as parse } from './md-parser.js';
|
||||
|
||||
// --- Mini test runner ---
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function expect(actual: string) {
|
||||
return {
|
||||
toBe(expected: string) {
|
||||
if (actual === expected) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`❌ FAIL (toBe)\n actual: ${JSON.stringify(actual)}\n expected: ${JSON.stringify(expected)}`);
|
||||
}
|
||||
},
|
||||
toContain(substr: string) {
|
||||
if (actual.includes(substr)) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`❌ FAIL (toContain)\n actual: ${JSON.stringify(actual)}\n expected to contain: ${JSON.stringify(substr)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function describe(name: string, fn: () => void) {
|
||||
console.log(`\n📋 ${name}`);
|
||||
fn();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// normalizeMarkdownSymbols
|
||||
// =============================================================================
|
||||
|
||||
describe('normalizeMarkdownSymbols — heading', () => {
|
||||
expect(normalize('#Ciao')).toBe('# Ciao');
|
||||
expect(normalize('##titolo')).toBe('## Titolo');
|
||||
expect(normalize('###sub')).toBe('### Sub');
|
||||
expect(normalize('# Già corretto')).toBe('# Già corretto');
|
||||
expect(normalize('####senza cap')).toBe('#### senza cap'); // H4 non capitalizza
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — liste', () => {
|
||||
expect(normalize('-elemento')).toBe('- elemento');
|
||||
expect(normalize('- già ok')).toBe('- già ok');
|
||||
expect(normalize('• item')).toBe('- item');
|
||||
expect(normalize('· item')).toBe('- item');
|
||||
expect(normalize('● item')).toBe('- item');
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — liste numerate', () => {
|
||||
expect(normalize('1.elemento')).toBe('1. elemento');
|
||||
expect(normalize('1. elemento')).toBe('1. elemento'); // già corretto
|
||||
expect(normalize('2)elemento')).toBe('2. elemento');
|
||||
expect(normalize('3elemento')).toBe('3. elemento');
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — checkbox', () => {
|
||||
expect(normalize('[x]testo')).toBe('- [x] testo');
|
||||
expect(normalize('[X]testo')).toBe('- [x] testo');
|
||||
expect(normalize('[v]testo')).toBe('- [x] testo');
|
||||
expect(normalize('[ ]testo')).toBe('- [ ] testo');
|
||||
expect(normalize('[]testo')).toBe('- [ ] testo');
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — blockquote', () => {
|
||||
expect(normalize('>testo')).toBe('> testo');
|
||||
expect(normalize('> già ok')).toBe('> già ok');
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — separatori', () => {
|
||||
expect(normalize('===')).toBe('---');
|
||||
expect(normalize('___')).toBe('---');
|
||||
expect(normalize('***')).toBe('---');
|
||||
expect(normalize('---')).toBe('---');
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — parole duplicate', () => {
|
||||
expect(normalize('ciao ciao')).toBe('ciao');
|
||||
expect(normalize('il il cliente')).toBe('il cliente');
|
||||
});
|
||||
|
||||
describe('normalizeMarkdownSymbols — blocchi codice protetti', () => {
|
||||
// Il contenuto dentro ``` non deve essere modificato
|
||||
expect(normalize('```\n#non titolo\n```')).toBe('```\n#non titolo\n```');
|
||||
expect(normalize('```js\n-nolista\n```')).toBe('```js\n-nolista\n```');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// expandKeywords
|
||||
// =============================================================================
|
||||
|
||||
describe('expandKeywords — titoli', () => {
|
||||
expect(expand('<H1> Titolo')).toBe('# Titolo');
|
||||
expect(expand('<H2> Sezione')).toBe('## Sezione');
|
||||
expect(expand('<H3> Sub')).toBe('### Sub');
|
||||
expect(expand('<H4> Piccolo')).toBe('#### Piccolo');
|
||||
});
|
||||
|
||||
describe('expandKeywords — inline style', () => {
|
||||
expect(expand('<B> parola')).toBe('**parola**');
|
||||
expect(expand('<I> corsivo')).toBe('*corsivo*');
|
||||
expect(expand('<BI> entrambi')).toBe('***entrambi***');
|
||||
expect(expand('<S> barrato')).toBe('~~barrato~~');
|
||||
expect(expand('<HL> evidenziato')).toBe('==evidenziato==');
|
||||
expect(expand('<CODE> var x')).toBe('`var x`');
|
||||
});
|
||||
|
||||
describe('expandKeywords — liste', () => {
|
||||
expect(expand('<LIST> a, b, c')).toBe('- a\n- b\n- c');
|
||||
expect(expand('<NUMLIST> a, b, c')).toBe('1. a\n2. b\n3. c');
|
||||
expect(expand('<CHECK> task1, task2')).toBe('- [ ] task1\n- [ ] task2');
|
||||
});
|
||||
|
||||
describe('expandKeywords — tabella', () => {
|
||||
expect(expand('<TABLE> A, B, C')).toContain('| A | B | C |');
|
||||
expect(expand('<TABLE> A, B, C')).toContain('|---|---|---|');
|
||||
});
|
||||
|
||||
describe('expandKeywords — callout', () => {
|
||||
expect(expand('<NOTE> testo')).toBe('> [!NOTE]\n> testo');
|
||||
expect(expand('<WARN> testo')).toBe('> [!WARNING]\n> testo');
|
||||
expect(expand('<TIP> testo')).toBe('> [!TIP]\n> testo');
|
||||
expect(expand('<INFO> testo')).toBe('> [!INFO]\n> testo');
|
||||
expect(expand('<ERROR> testo')).toBe('> [!ERROR]\n> testo');
|
||||
expect(expand('<IMPORTANT> testo')).toBe('> [!IMPORTANT]\n> testo');
|
||||
expect(expand('<QUOTE> testo')).toBe('> testo');
|
||||
});
|
||||
|
||||
describe('expandKeywords — link e immagini', () => {
|
||||
expect(expand('<LINK> Google, https://google.com')).toBe('[Google](https://google.com)');
|
||||
expect(expand('<IMG> logo, https://example.com/img.png')).toBe('');
|
||||
});
|
||||
|
||||
describe('expandKeywords — separatori', () => {
|
||||
expect(expand('<HR>')).toBe('---');
|
||||
expect(expand('<SEP>')).toBe('---');
|
||||
});
|
||||
|
||||
describe('expandKeywords — footnote', () => {
|
||||
expect(expand('<FN> questa è una nota')).toBe('[^1]: questa è una nota');
|
||||
// Due footnote consecutive: numeri 1 e 2
|
||||
expect(expand('<FN> prima\n<FN> seconda')).toBe('[^1]: prima\n[^2]: seconda');
|
||||
// Partenza custom
|
||||
expect(expand('<FN> nota', 5)).toBe('[^5]: nota');
|
||||
});
|
||||
|
||||
describe('expandKeywords — math', () => {
|
||||
expect(expand('<MATH> E=mc^2')).toBe('$E=mc^2$');
|
||||
});
|
||||
|
||||
describe('expandKeywords — tag e date/ora', () => {
|
||||
expect(expand('<TAG> progetto')).toBe('#progetto');
|
||||
expect(expand('<TAG> due parole')).toBe('#due_parole');
|
||||
// <DATE>, <TIME>, <DATETIME> usano la data corrente: verifichiamo solo il formato
|
||||
const dateOut = expand('<DATE>');
|
||||
expect(typeof dateOut === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateOut) ? 'ok' : 'fail').toBe('ok');
|
||||
});
|
||||
|
||||
describe('expandKeywords — alias e case-insensitive', () => {
|
||||
expect(expand('<bold> testo')).toBe('**testo**'); // alias BOLD = B
|
||||
expect(expand('<BOLD> testo')).toBe('**testo**');
|
||||
expect(expand('<strike> testo')).toBe('~~testo~~'); // alias STRIKE = S
|
||||
expect(expand('<hr>')).toBe('---'); // lowercase
|
||||
expect(expand('<Table> A, B')).toContain('| A | B |'); // mixed case
|
||||
});
|
||||
|
||||
describe('expandKeywords — colon opzionale dopo >', () => {
|
||||
expect(expand('<B>testo')).toBe('**testo**'); // nessun separatore
|
||||
expect(expand('<H1>Titolo')).toBe('# Titolo'); // nessun separatore
|
||||
expect(expand('<B>: testo')).toBe('**testo**'); // con colon
|
||||
});
|
||||
|
||||
describe('expandKeywords — CODEBLOCK multi-riga', () => {
|
||||
const input = '<CODEBLOCK js>\nconsole.log(\'ciao\')\nconst x = 1\n';
|
||||
const out = expand(input);
|
||||
expect(out).toContain('```js');
|
||||
expect(out).toContain('console.log(\'ciao\')');
|
||||
expect(out).toContain('```');
|
||||
});
|
||||
|
||||
describe('expandKeywords — indent', () => {
|
||||
expect(expand('<INDENT> testo')).toBe(' testo');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Pipeline completa parseHandwritingToMarkdown
|
||||
// =============================================================================
|
||||
|
||||
describe('pipeline completa', () => {
|
||||
expect(parse('#Ciao\n<LIST> a, b')).toBe('# Ciao\n- a\n- b');
|
||||
expect(parse('##sezione\n<B> parola chiave')).toBe('## Sezione\n**parola chiave**');
|
||||
// Il blocco codice non viene toccato dalla normalizzazione
|
||||
expect(parse('```\n#non toccare\n```')).toBe('```\n#non toccare\n```');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Report finale
|
||||
// =============================================================================
|
||||
|
||||
console.log(`\n${'─'.repeat(40)}`);
|
||||
if (failed === 0) {
|
||||
console.log(`✅ Tutti i ${passed} test passati.`);
|
||||
} else {
|
||||
console.log(`❌ ${failed} test falliti su ${passed + failed} totali.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -207,5 +207,71 @@ export class HandwritingSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.debugMode = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// --- Riferimento keyword OCR (sezione espandibile) ---
|
||||
// NOTA SVILUPPATORI: se aggiungi una keyword in md-parser.ts, aggiornala anche qui!
|
||||
const details = containerEl.createEl('details', { cls: 'hwm_keyword-ref' });
|
||||
details.createEl('summary', {
|
||||
text: 'Keyword riconosciute dal parser OCR',
|
||||
cls: 'hwm_keyword-summary',
|
||||
});
|
||||
details.createEl('p', {
|
||||
text: 'Scrivi queste keyword nel disegno per generare la struttura markdown corrispondente. Tutte sono case-insensitive (<h1> = <H1>). Il contenuto segue direttamente dopo >.',
|
||||
cls: 'setting-item-description',
|
||||
});
|
||||
|
||||
// Tabella keyword: [nome, sintassi, output]
|
||||
const KEYWORDS: [string, string, string][] = [
|
||||
['<H1>', '<H1> Titolo', '# Titolo'],
|
||||
['<H2>', '<H2> Titolo', '## Titolo'],
|
||||
['<H3>', '<H3> Titolo', '### Titolo'],
|
||||
['<H4>', '<H4> Titolo', '#### Titolo'],
|
||||
['<B> / <BOLD>', '<B> testo', '**testo**'],
|
||||
['<I>', '<I> testo', '*testo*'],
|
||||
['<BI>', '<BI> testo', '***testo***'],
|
||||
['<S> / <STRIKE>', '<S> testo', '~~testo~~'],
|
||||
['<HL>', '<HL> testo', '==testo=='],
|
||||
['<CODE>', '<CODE> testo', '`testo`'],
|
||||
['<CODEBLOCK>', '<CODEBLOCK js>', '```js\n...\n```'],
|
||||
['<LIST>', '<LIST> a, b, c', '- a\n- b\n- c'],
|
||||
['<NUMLIST>', '<NUMLIST> a, b, c', '1. a\n2. b\n3. c'],
|
||||
['<CHECK>', '<CHECK> a, b, c', '- [ ] a\n- [ ] b\n- [ ] c'],
|
||||
['<TABLE>', '<TABLE> Col1, Col2', '| Col1 | Col2 |\n|---|---|\n| ... |'],
|
||||
['<NOTE>', '<NOTE> testo', '> [!NOTE]\n> testo'],
|
||||
['<WARN>', '<WARN> testo', '> [!WARNING]\n> testo'],
|
||||
['<TIP>', '<TIP> testo', '> [!TIP]\n> testo'],
|
||||
['<INFO>', '<INFO> testo', '> [!INFO]\n> testo'],
|
||||
['<ERROR>', '<ERROR> testo', '> [!ERROR]\n> testo'],
|
||||
['<IMPORTANT>', '<IMPORTANT> testo', '> [!IMPORTANT]\n> testo'],
|
||||
['<QUOTE>', '<QUOTE> testo', '> testo'],
|
||||
['<LINK>', '<LINK> testo, url', '[testo](url)'],
|
||||
['<IMG>', '<IMG> alt, url', ''],
|
||||
['<HR> / <SEP>', '<HR>', '---'],
|
||||
['<FN>', '<FN> nota a piè pagina', '[^1]: nota a piè pagina'],
|
||||
['<MATH>', '<MATH> formula', '$formula$'],
|
||||
['<MATHBLOCK>', '<MATHBLOCK>', '$$\n...\n$$'],
|
||||
['<TAG>', '<TAG> parola', '#parola'],
|
||||
['<DATE>', '<DATE>', 'YYYY-MM-DD'],
|
||||
['<TIME>', '<TIME>', 'HH:mm'],
|
||||
['<DATETIME>', '<DATETIME>', 'YYYY-MM-DD HH:mm'],
|
||||
['<INDENT>', '<INDENT> testo', ' testo'],
|
||||
];
|
||||
|
||||
const table = details.createEl('table', { cls: 'hwm_keyword-table' });
|
||||
const thead = table.createEl('thead');
|
||||
const hrow = thead.createEl('tr');
|
||||
['Keyword', 'Sintassi', 'Output'].forEach(h => hrow.createEl('th', { text: h }));
|
||||
const tbody = table.createEl('tbody');
|
||||
for (const [name, syntax, output] of KEYWORDS) {
|
||||
const row = tbody.createEl('tr');
|
||||
row.createEl('td', { text: name, cls: 'hwm_kw-name' });
|
||||
row.createEl('td').createEl('code', { text: syntax });
|
||||
// Mostra il testo dell'output su più righe se contiene \n
|
||||
const outTd = row.createEl('td');
|
||||
output.split('\n').forEach((ln, idx) => {
|
||||
if (idx > 0) outTd.createEl('br');
|
||||
outTd.createEl('code', { text: ln });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -604,3 +604,40 @@
|
|||
.hwm_resize-handle--disabled:hover {
|
||||
background: #f5f5f5; /* annulla l'effetto hover */
|
||||
}
|
||||
|
||||
/* --- Sezione keyword OCR nelle impostazioni --- */
|
||||
.hwm_keyword-ref {
|
||||
margin-top: 24px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
.hwm_keyword-summary {
|
||||
cursor: pointer;
|
||||
padding: 10px 0;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.hwm_keyword-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.hwm_keyword-table th,
|
||||
.hwm_keyword-table td {
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.hwm_keyword-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* Nome keyword in grassetto, colore accent */
|
||||
.hwm_kw-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
10
NOTES.md
10
NOTES.md
|
|
@ -271,6 +271,16 @@ Cambiamenti implementati durante la fase 2:
|
|||
|
||||
---
|
||||
|
||||
## Completato — Sistema keyword OCR (2026-03-23)
|
||||
|
||||
- Sintassi `<KEYWORD> contenuto` (con `<>`) in `md-parser.ts`
|
||||
- `normalizeMarkdownSymbols`: strip BOM/zero-width chars da Gemini, correzioni simboli markdown scritti a mano
|
||||
- `expandKeywords`: 33 keyword con alias, case-insensitive, colon opzionale, multi-riga per TABLE/CODEBLOCK/MATHBLOCK
|
||||
- Sezione "Keyword riconosciute dal parser OCR" collassabile nelle impostazioni
|
||||
- Test autonomo `src/parser.test.ts` (77 test, eseguibile con `npx tsx src/parser.test.ts`)
|
||||
|
||||
---
|
||||
|
||||
## Ricerca effettuata — Plugin esistenti
|
||||
|
||||
### Nessuno fa esattamente questo. Gap confermato.
|
||||
|
|
|
|||
Loading…
Reference in a new issue