La prima demo della nostra funzione di lettura ad alta voce per un'app literacy andò bene per due paragrafi. Poi la pagina arrivò a un capolettera, la voce disse "Chapter" mentre l'evidenziazione rimase sulla riga precedente, e in fondo alla pagina il cursore inseguiva l'audio con tre parole di ritardo. La voce non era mai stata il problema: SAPI riportava i confini parola con precisione. Il problema era il livello di mapping tra offset carattere nel buffer vocale e rettangoli sulla pagina PDF renderizzata, e quel livello è dove ogni evidenziatore stile karaoke vive o muore. PDFium Component, word boxes da v1.53 e tracker più cursore di evidenziazione da v1.56, distribuisce quel mapping per Delphi, C++Builder e Lazarus come una piccola API deliberata: box parola, tracker da offset a parola e cursore di evidenziazione con auto-scroll. Usata nell'ordine giusto è robusta; usata nell'ordine sbagliato produce esattamente la deriva che abbiamo mostrato in demo.
I caratteri non sono parole, e i motori TTS parlano in caratteri
Un motore vocale consuma una stringa piatta e riporta il progresso come posizioni carattere dentro quella stringa. Una pagina PDF, invece, ha glifi posizionati nello spazio pagina, dove una "parola" è un cluster euristico di run di glifi. I due sistemi di coordinate non condividono nulla a meno che il testo passato al sintetizzatore sia byte-for-byte il testo da cui sono stati calcolati i word box. Questa è la prima regola, ed è inflessibile: normalizza whitespace, rimuovi soft hyphen o "pulisci" in altro modo il testo estratto prima di pronunciarlo, e ogni offset a valle diventa silenziosamente sbagliato. Pronuncia esattamente ciò che hai estratto, oppure mantieni una tabella esplicita di remapping degli offset: non esiste una terza opzione che sopravviva a documenti reali.
L'opzione remapping non è ipotetica. Se la UI inserisce annunci pagina pronunciati, ad esempio "pagina cinque", oppure espande abbreviazioni per il sintetizzatore, registra posizione e lunghezza di ogni inserimento e sottrai l'aggiustamento accumulato prima di ogni chiamata di tracking. Sono venti righe di contabilità, ed è la differenza tra un'evidenziazione che sopravvive alla crescita della feature e una che si rompe la prima volta che il prodotto chiede intestazioni pronunciate.
Cosa ti dà un word box
Ogni record TPdfWordBox contiene il testo della parola, il suo StartIndex e Count di caratteri nel testo della pagina, un Rect nello spazio pagina, e il numero Page a base 1. PageWordBoxes restituisce l'intero array per la pagina attiva:
procedure TReaderForm.PreparePage(PageNo: Integer);
begin
PdfView.PageNumber := PageNo; // the view's word boxes track its displayed page
FWords := PdfView.PageWordBoxes;
FPageText := BuildSpeechText(FWords); // concatenate Word.Text in order
if Length(FWords) = 0 then
HandleImageOnlyPage(PageNo); // a scan with no text layer
end;
Il commento sull'ordine regge tutto: PageWordBoxes del viewer tokenizza il layer testo della pagina che la vista mostra in quel momento, quindi naviga prima la vista e poi estrai; non serve rendering, solo un documento aperto. Il componente documento offre il proprio PageWordBoxes legato a Pdf.PageNumber per uso headless. Un risultato vuoto su una pagina che porta contenuto visibile significa scansione solo immagine: instradala a OCR oppure saltala udibilmente, ad esempio "la pagina 4 non contiene testo leggibile", invece di lasciare la voce muta senza spiegazione.
Collegare i confini parola SAPI al tracker
TrackReadingWordAt, sul viewer, è il cardine dell'intera funzione: passagli un numero pagina e un indice carattere, e trova il word box che contiene quel carattere, dipinge il cursore di lettura su di esso e restituisce l'indice parola, oppure −1. La notifica word-boundary di SAPI fornisce esattamente la posizione carattere necessaria:
procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
WordIdx: Integer;
begin
// Maps the offset to a word box and moves the highlight in one call
WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
if WordIdx < 0 then
Exit; // boundary fell outside any word: keep last highlight
end;
Due dettagli difensivi. TrackReadingWordAt mantiene la propria cache dei word box per la pagina tracciata, ricostruita automaticamente quando la pagina cambia, quindi il costo per boundary resta piatto; e non esegue bounds-check generosi: un indice pari o superiore al conteggio caratteri della pagina restituisce −1 invece di essere clampato all'ultima parola. Tratta −1 come "mantieni l'evidenziazione precedente", mai come errore, perché run di punteggiatura e whitespace tra parole producono legittimamente boundary che non appartengono a nessuna parola. Se logghi ogni −1 annegherai; contali per pagina invece, e indaga le pagine dove il rapporto sale: di solito segnala una mismatch di normalizzazione testo rispetto alla prima regola.
Il cursore: colore, follow e cleanup
SetReadingWord dipinge direttamente l'evidenziazione quando possiedi tu il word box, ReadingWordColor la stilizza, e ReadingWordFollow := True fa scorrere la vista quanto basta per tenere visibile la parola pronunciata. Quest'ultima proprietà conta più di quanto sembri: uno scroll fatto a mano per "centrare la parola corrente" fa sobbalzare la pagina a ogni a capo, e i lettori sensibili al movimento disattivano la funzione entro un minuto. L'evidenziazione viene renderizzata solo sulla pagina attualmente mostrata nel TPdfView attivo, quindi la lettura multi-pagina deve avanzare PageNumber in sincronia con la voce, e rieseguire lo step di preparazione per la nuova pagina prima che arrivi il suo primo evento boundary, così testo vocale e offset combaciano con la pagina fresca.
procedure TReaderForm.StopReading;
begin
FVoice.Stop; // halt SAPI playback first
PdfView.ClearReadingWord; // then remove the highlight; a stale cursor reads as a bug
end;
La simmetria conta in shutdown: ogni percorso di pausa, stop e cambio pagina deve finire in ClearReadingWord. Il "bug" più segnalato nella nostra beta era un rettangolo ambra rimasto su una pagina in pausa: innocuo, ma ogni tester lo ha registrato.
La velocità del parlato stressa questa pipeline più della dimensione del documento. A 300 parole al minuto gli eventi boundary arrivano ogni 200 ms; alle velocità SAPI più alte, più rapidamente di quanto l'occhio segua comodamente. Coalesci invece di accodare: se arriva un nuovo boundary mentre un aggiornamento di evidenziazione è ancora pendente, scarta quello stantio. Un cursore che visita ogni parola in ordine ma con mezzo secondo di ritardo sembra rotto; uno che salta occasionalmente una parola ma resta sincronizzato no.
Casi limite che separano demo e prodotti
Tre categorie ricorrono. Caratteri combinanti: sequenze Unicode come lettere base con diacritici combinanti possono occupare più indici carattere di quanto suggerisca la parola visuale, quindi aritmetica sugli offset che presume un indice per glifo visibile deriva, un motivo in più per lasciare che TrackReadingWordAt faccia il mapping invece di calcolare tu i numeri parola. Sillabazione: una parola spezzata a fine riga diventa due box; se la pronunci come un solo token, l'evento boundary per la sua seconda metà risolve al primo box, accettabile, ma decidilo di proposito. E documenti tagged contro untagged: il sequencing delle parole segue la struttura logica del documento quando esiste tagging corretto, territorio di ISO 14289, PDF/UA, e ripiega altrimenti su euristiche di layout, quindi una pagina a due colonne non tagged può essere letta attraversando entrambe le colonne. Le pagine ruotate aggiungono una quarta categoria: il Rect di ogni parola la delimita comunque correttamente nello spazio pagina, ma una policy viewport-follow tarata sul flusso orizzontale scorre in modo stridente quando il testo è verticale, quindi tieni almeno un documento ruotato nel set di regressione. Per la gestione del reading order, unità a livello frase tramite ReadingUnits e stack assistivo più ampio, vedi costruire un reader PDF accessibile in Delphi.
Una nota di piattaforma: SAPI è solo Windows. L'API di word box e tracking è identica sotto Lazarus/FPC, ma le build Linux e macOS hanno bisogno di un sintetizzatore diverso dietro gli stessi eventi boundary; le differenze di setup sono coperte in eseguire il viewer sotto Lazarus e FPC. Anche il costo di rendering dell'evidenziazione interagisce con la cache pagina ad alte velocità vocali; l'aritmetica del budget in render caching e prestazioni dello zoom si applica qui senza modifiche.
Domande frequenti
Perché TrackReadingWordAt restituisce sempre −1?
Di solito per una di tre cause: il numero pagina passato è fuori range oppure il documento non è attivo, il testo dato al motore TTS differisce dal testo pagina estratto quindi gli offset non si allineano, oppure l'indice carattere appartiene al whitespace tra parole. Controllale in quest'ordine.
Perché l'evidenziazione smette di aggiornarsi dopo un cambio pagina?
Il cursore di lettura disegna solo sulla pagina corrente della vista attiva. Avanza PageNumber e recupera di nuovo PageWordBoxes per il testo vocale prima di riprendere, così gli offset boundary si riferiscono alla pagina ora sullo schermo.
Posso evidenziare frasi intere invece di parole singole?
Sì: ReadingUnits restituisce unità a livello frase e blocco con i propri rettangoli di evidenziazione, dipingili con SetReadingHighlight, una scelta adatta ad ascoltatori più lenti e riduce il churn visuale ad alte velocità vocali.
Requisiti di versione, v1.53 o successiva per word box, v1.56 per il cursore di tracking, API completa di lettura e demo read-aloud funzionante sono nella pagina prodotto: PDFium Component.