Articolo tecnico

Creare un lettore PDF accessibile in Delphi con PDFium

Punta NVDA su un viewer PDF Delphi appena compilato e di solito ottieni uno di due risultati: silenzio, oppure testo letto nell'ordine in cui lo stream di contenuto lo ha memorizzato, prima il piè di pagina, poi la colonna di destra, poi il titolo che visivamente apre la pagina. Il rendering è impeccabile; l'esperienza di ascolto è inutilizzabile. Il divario esiste perché rasterizzazione e lettura sono pipeline separate: l'ordine di disegno dentro uno stream di contenuto PDF non è obbligato a corrispondere all'ordine in cui un essere umano dovrebbe ascoltare. PDFium Component, il wrapper VCL/LCL intorno al motore PDFium per Delphi, C++Builder e Lazarus, include una famiglia dedicata di API di lettura proprio perché le API di rendering non possono svolgere questo lavoro.

Tre problemi decidono se un progetto di lettore accessibile riesce: estrarre un ordine di lettura pronunciabile, mantenere un cursore di parola visibile sincronizzato con l'output vocale e degradare onestamente quando il documento non è mai stato taggato. Ognuno ha un percorso API concreto e una modalità di errore altrettanto concreta da conoscere prima di scrivere il primo event handler.

L'ordine di lettura vive nell'albero di struttura, non nell'ordine di disegno

ISO 32000-1 §14.8 definisce la struttura logica come un albero di elementi strutturali sovrapposto al contenuto della pagina, e PDF/UA (ISO 14289-1) rende quell'albero obbligatorio: ogni contenuto reale deve essere raggiungibile attraverso di esso in ordine di lettura, con gli artefatti esclusi. Un report taggato correttamente sa che "Quarterly Results" è un'intestazione di livello due e che la griglia dei totali è una tabella con celle di intestazione. Un report non taggato è solo una serie di run di glifi posizionati.

ReadablePageContent percorre questa struttura quando esiste e restituisce frammenti di contenuto etichettati con un Kind semantico, cfHeading, cfParagraph e valori correlati, così l'interfaccia del lettore può annunciare "intestazione" prima del testo invece di leggere una riga in grassetto come se fosse corpo normale. Quando l'albero di struttura è assente o inutilizzabile, la stessa chiamata passa all'analisi euristica del layout: rilevamento delle colonne, clustering delle baseline, ordinamento da sinistra a destra. L'output è spesso adeguato per documenti a colonna singola e inaffidabile per newsletter, moduli multicolonna e tutto ciò che contiene sidebar. La disciplina essenziale è dire all'utente in quale caso si trova, e l'API fornisce direttamente questo dato: il record TPdfReadableContent restituito contiene un campo Source che vale rosStructure quando l'ordine proviene dall'albero taggato e rosHeuristic quando è stato stimato dal layout. Presentare un ordine stimato come ordine verificato è l'equivalente accessibilità di un segno di spunta verde su una build non testata.

Un modo pratico per classificare un file all'apertura è controllare IsTagged ed eseguire una volta ValidatePdfUa, memorizzando il verdetto. Un controllo PDF/UA fallito non significa rifiutare il documento: significa che la barra di stato mostra "ordine di lettura stimato" e il team di supporto sa esattamente cosa sta guardando quando un cliente segnala una narrazione senza senso su un file specifico.

Dalla pagina alla coda vocale con ReadingUnits

Per il text-to-speech lo strumento principale è ReadingUnits: restituisce un array di record TPdfReadingUnit per la pagina attiva, ciascuno con il testo da pronunciare, il ruolo semantico e i rettangoli di evidenziazione che lo localizzano sulla pagina. Esiste una variante a livello documento, DocumentReadingUnits, per la lettura continua. Una unità corrisponde naturalmente a una voce della coda vocale:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits works on the active page
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + semantics + highlight rects
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Due dettagli in questo ciclo meritano attenzione. Primo, mantieni la coda rigorosamente per pagina e ricostruiscila alla navigazione: le reading unit contengono rettangoli nello spazio pagina, quindi una coda obsoleta dipinge evidenziazioni sulla pagina sbagliata dopo un salto in avanti dell'utente. Secondo, un array Units vuoto su una pagina che contiene visibilmente contenuto è il tuo rilevatore di pagina solo immagine. Una pagina scansionata ha pixel ma non layer di testo, e la risposta corretta è un avviso parlato, "questa pagina non contiene testo estraibile", non un silenzio che l'utente non può distinguere da un crash.

Un cursore di parola che segue la voce

L'evidenziazione a livello di blocco risulta lenta per gli utenti ipovedenti che seguono visivamente mentre ascoltano. L'evidenziazione parola per parola, in stile "karaoke", richiede due ingredienti: geometria delle parole e una mappatura dai callback di avanzamento del motore TTS a quella geometria. PageWordBoxes fornisce la geometria come record TPdfWordBox: testo della parola, offset di carattere, conteggio caratteri e rettangolo nello spazio pagina. TrackReadingWordAt fornisce la mappatura: converte una posizione di carattere, esattamente ciò che la notifica word-boundary di SAPI consegna, in un indice nell'array delle word-box, ed evidenzia nella stessa chiamata la parola che la contiene.

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // The view's word boxes come from the page the view displays —
  // setting Pdf.PageNumber alone would not move the view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt maps the offset AND paints the word cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary ran past the page text
end;

Il contratto è permissivo in un aspetto e rigoroso in un altro. Permissivo: TrackReadingWordAt mantiene una propria cache di word-box per la pagina tracciata, quindi non devi prealimentarlo; e non è coinvolto alcun rendering, perché le word-box derivano dal layer di testo della pagina, quindi anche un servizio vocale headless può tracciare le posizioni. Rigoroso: l'indice di carattere deve riferirsi al testo estratto dal componente. La funzione restituisce inoltre -1 invece di sollevare un'eccezione quando CharIndex punta oltre la fine del testo pagina, evento comune quando un motore TTS emette un'ultima boundary per la punteggiatura finale. Tratta -1 come "pulisci il cursore", non come condizione di errore.

Sul lato visualizzazione, ReadingWordColor controlla l'evidenziazione del cursore; l'ambra predefinito resiste alla maggior parte degli sfondi pagina, ma controllalo sotto ogni filtro di visualizzazione offerto dal viewer, perché un cursore ambra può scomparire completamente con l'inversione colori, e inversione più voce è proprio la combinazione usata dagli utenti ipovedenti. Impostare ReadingWordFollow a True fa scorrere automaticamente la vista fino alla parola pronunciata, cosa essenziale su pagine zoomate su più schermate. Una regola di ambito: SetReadingWord dipinge solo sulla pagina attiva di TPdfView, quindi decidi se lo scorrimento utente sospende la voce oppure se prevale il comportamento di follow; non scegliere nessuna delle due opzioni lascia la voce in esecuzione contro un cursore invisibile.

Documenti che reagiscono male

Tre classi di input rompono implementazioni ingenue abbastanza spesso da meritare campioni permanenti di regressione nella suite di test.

  • File non taggati ma ricchi di testo. L'ordine euristico è di solito corretto per report lineari e sbagliato per layout con sidebar o pull quote. Etichetta l'ordine come stimato nell'interfaccia e nel log diagnostico.
  • Scansioni solo immagine. Nessun layer di testo. Rilevale tramite reading unit vuote e indirizza l'utente verso una fase OCR a monte invece di lasciare che il lettore non dica nulla.
  • Caratteri combinanti e script misti. I segni combinanti Unicode non sempre mappano uno-a-uno sulle parole visive, quindi il conteggio delle word-box può differire da quello previsto dal tuo tokenizer. Non indicizzare mai l'array di word-box con aritmetica derivata dal tuo split; usa solo indici restituiti da TrackReadingWordAt.

Accettazione: testa come un auditor, non come una demo

"Ha letto ad alta voce il mio esempio" non è accettazione. Un passaggio difendibile esegue tre documenti nella build finita con NVDA collegato: un file noto e taggato, con intestazioni annunciate come intestazioni e tabella letta in ordine di riga; un file noto e non taggato, con indicatore di ordine stimato visibile; e una scansione, con avviso esplicito di assenza testo pronunciato.

Poi verifica che il cursore di parola resti agganciato a velocità vocale doppia e dimezzata, e che lo scorrimento di ReadingWordFollow non combatta lo scorrimento manuale. Infine attiva ogni filtro colore mentre la voce è in esecuzione e conferma che il cursore resti visibile: l'articolo sui filtri colore per ipovisione copre quel percorso di rendering, e l'approfondimento sul cursore vocale parola per parola entra più nel dettaglio del timing TTS.

FAQ

Il lettore richiede un PDF taggato per funzionare?

No. ReadablePageContent e ReadingUnits ricadono sull'analisi euristica del layout per i file non taggati, e il campo Source del contenuto leggibile indica quale percorso ha prodotto l'ordine. L'onere ricade sull'interfaccia: distingue tra ordine verificato dall'albero di struttura e ordine stimato, perché i due falliscono in modi diversi e il supporto deve sapere quale caso riguarda un reclamo.

Perché TrackReadingWordAt restituisce -1 in mezzo a una pagina?

Di solito l'indice di carattere proveniente dal motore TTS si riferisce a testo preelaborato prima dell'inserimento in coda, oppure è finito su spazio tra parole. Gli offset devono puntare nel testo estratto dal componente, lo stesso testo tokenizzato da PageWordBoxes, non in una copia ripulita.

Posso controllare la conformità di accessibilità via codice?

Sì: ValidatePdfUa restituisce il livello di conformità rilevato più un insieme di violazioni PDF/UA per documento, e BuildPdfPreflightReport integra lo stesso controllo in un report multi-standard. È un rilevatore, non uno strumento di riparazione: usa il verdetto per impostare le aspettative dell'utente all'apertura e per triagiare i file in ingresso.

Le API di reading unit e word-box mostrate qui fanno parte di PDFium Component per Delphi e C++Builder (VCL) e Lazarus/FPC (LCL). La pagina prodotto collega il riferimento API completo, compresi i layout dei record per reading unit e word-box usati negli esempi precedenti.