Un pulsante di lettura ad alta voce si dimostra in un pomeriggio e poi consuma una settimana. La versione del pomeriggio estrae il testo della pagina, lo passa a SAPI e ottiene l'audio. La settimana se ne va in ciò che rende la funzionalità utilizzabile: la voce non deve bloccare la finestra, la parola pronunciata deve illuminarsi sulla pagina a tempo con l'audio e il tasto Spazio deve mettere in pausa il tutto. Questo articolo costruisce quella pipeline in Delphi rispetto alle API testuali raw di PDFium e alle API Windows Speech, con codice funzionante per i tre pezzi che la versione rapida salta: ciclo di vita COM eseguito una volta anziché per enunciazione, eventi reali di confine delle parole e la matematica delle coordinate che trasforma una casella di parola nello spazio PDF in un rettangolo che puoi disegnare
Il contesto normativo si adatta in una frase: la lettura ad alta voce sincronizzata è la metà dal lato del visualizzatore di ciò che WCAG 2.1 chiede al software documentale, e ISO 14289-1 (PDF/UA) definisce la metà del file taggato contro cui funziona meglio. Se stai costruendo su PDFium Component potresti non aver affatto bisogno di questa pipeline: il visualizzatore include un cursore di tracciamento integrato che mappa un offset di carattere a un'evidenziazione dipinta della parola in una sola chiamata, coperto nell'articolo sull'evidenziazione TTS parola per parola. Quello che segue è per quando possiedi l'intera applicazione del visualizzatore e desideri la pipeline stessa
Un thread disegna, un thread parla
L'architettura è due thread e un contratto. Il thread dell'interfaccia utente disegna la bitmap della pagina, possiede lo stato di zoom e scorrimento e dipinge la sovrapposizione dell'evidenziazione. Un thread vocale dedicato possiede la voce SAPI e nient'altro lo tocca. Il contratto è sottile: il thread vocale riporta il progresso come offset di caratteri, e il thread dell'interfaccia utente trasforma gli offset in rettangoli
La maggior parte degli esempi SAPI avvolge ogni enunciazione in CoInitialize e CoUninitialize, e un visualizzatore mostra immediatamente perché ciò è sbagliato. Speak con SVSFlagsAsync ritorna non appena il testo è in coda, quindi un CoUninitialize nel blocco finally della stessa procedura viene eseguito mentre la voce sta ancora parlando, abbattendo l'apartment COM che la possiede. A seconda delle tempistiche si ottiene silenzio, un'enunciazione troncata o una violazione di accesso minuti dopo. Il ciclo di vita corretto è noioso: CoInitialize una volta quando il thread vocale inizia, creare la voce all'interno di quell'apartment, e CoUninitialize una volta quando il thread esce, dopo che la voce è stata liberata. Mai per enunciazione
La voce necessita anche di un loop di messaggi (message pump), che decide dove può vivere. L'oggetto di automazione SpVoice consegna i suoi eventi attraverso la coda dei messaggi del thread che lo ha creato. Crearlo sul thread dell'interfaccia utente fa arrivare gli eventi, perché la VCL elabora i messaggi, ma ogni disegno lento ritarda poi i confini delle tue parole; crearlo su un thread di lavoro senza pump e gli eventi non arrivano affatto. Un thread dedicato con il proprio loop GetMessage mantiene piatta la latenza dei confini indipendentemente da ciò che fa l'interfaccia utente
uses
System.Classes, System.SyncObjs, Winapi.Windows, Winapi.Messages,
Winapi.ActiveX, SpeechLib_TLB;
const
WM_SPEAK_PAGE = WM_APP + 1;
type
TSpeechThread = class(TThread)
private
FVoice: TSpVoice;
FLock: TCriticalSection;
FText: string;
function NextUtterance: string; // reads FText under FLock
procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
protected
procedure Execute; override;
procedure TerminatedSet; override;
public
procedure SpeakPage(const AText: string); // safe from the UI thread
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // once, when the thread starts
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Force creation of this thread's message queue before anyone posts to it
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // exits when WM_QUIT arrives
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // delivers the SAPI event callbacks
finally
FVoice.Free;
end;
finally
CoUninitialize; // once, when the thread exits
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // unblock GetMessage
end;
TerminatedSet invia WM_QUIT in modo che il loop si sblocchi quando il visualizzatore si chiude. SpeakPage, chiamato dal thread dell'interfaccia utente, memorizza il testo in un campo protetto da blocco e invia WM_SPEAK_PAGE, perché chiamare un metodo su FVoice direttamente da un altro thread sarebbe una chiamata COM cross-apartment su un'interfaccia non de-serializzata (unmarshaled). Il PeekMessage di una riga prima del loop forza Windows a creare la coda dei messaggi del thread, chiudendo la race condition all'avvio in cui un post anticipato dal thread dell'interfaccia utente fallirebbe
I confini delle parole arrivano come offset di caratteri
Importa la Microsoft Speech Object Library una volta tramite l'importatore di librerie di tipi dell'IDE e ottieni SpeechLib_TLB con il wrapper TSpVoice e i suoi eventi tipizzati. Due impostazioni sono importanti. EventInterests dovrebbe essere ristretto agli eventi che consumi effettivamente, perché ogni interesse lasciato attivo è traffico di eventi cross-thread per ogni parola di ogni pagina; SVEWordBoundary guida l'evidenziazione e SVEEndInputStream ti dice che l'enunciazione è terminata. E il gestore OnWord riceve CharacterPosition e una lunghezza, che indicizzano nell'esatta stringa che hai passato a Speak — un offset nel buffer vocale, non in nient'altro
Quest'ultima clausola è l'invariante su cui si basa la funzionalità: gli offset sono significativi solo rispetto alla stringa che la voce sta leggendo, quindi fai pronunciare esattamente il testo che hai estratto, carattere per carattere. Rimuovi gli spazi bianchi, comprimi le interruzioni di riga o espandi un'abbreviazione per una pronuncia migliore, e ogni evidenziazione dopo la prima modifica risulterà sfasata di una parola. Se l'interfaccia utente deve iniettare materiale parlato — annunci di pagina, prefissi di intestazione — registra la posizione e la lunghezza di ciascuna inserzione e sottrai lo spostamento accumulato da ogni offset prima di mapparlo
procedure TSpeechThread.SpeakPage(const AText: string);
begin
FLock.Enter;
try
FText := AText;
finally
FLock.Leave;
end;
PostThreadMessage(ThreadID, WM_SPEAK_PAGE, 0, 0);
end;
procedure TSpeechThread.VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
begin
// Runs on the speech thread; hand the offsets to the UI without blocking
TThread.Queue(nil,
procedure
begin
ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
end);
end;
TThread.Queue è il giusto marshal (ordinatore) qui, non Synchronize: il gestore non deve parcheggiare il thread vocale mentre l'interfaccia utente viene ridisegnata, e se gli eventi di confine arrivano più velocemente di quanto lo schermo disegni, un aggiornamento obsoleto dell'evidenziazione è innocuo perché il successivo lo sovrascrive. Collega OnEndStream allo stesso modo per cancellare l'evidenziazione e, in una modalità di lettura continua, per caricare il testo della pagina successiva e inviare la successiva enunciazione
Dagli offset di caratteri ai pixel sullo schermo
PDFium riporta la geometria per carattere. FPDFText_GetCharBox riempie quattro double in un ordine che ha causato più bug silenziosi di qualsiasi altra cosa nelle API testuali — left, right, bottom, top (sinistra, destra, basso, alto), non l'ordine di Windows left, top, right, bottom — e li riporta nello spazio della pagina: punti PDF, 72 per pollice, origine nell'angolo in basso a sinistra con la Y che cresce verso l'alto. Il riquadro di una parola è l'unione dei riquadri dei suoi caratteri, e la trasformazione in pixel del dispositivo è di tre passaggi: tradurre per l'origine della pagina, scalare per lo zoom moltiplicato per i DPI dello schermo diviso 72, e invertire l'asse Y
uses
System.Math;
type
TPdfRectF = record
Left, Top, Right, Bottom: Double; // PDF points, origin bottom-left
end;
function TViewerForm.WordBox(CharIndex, CharCount: Integer): TPdfRectF;
var
i, LastChar: Integer;
L, T, R, B: Double;
begin
Result.Left := MaxDouble; Result.Bottom := MaxDouble;
Result.Right := -MaxDouble; Result.Top := -MaxDouble;
LastChar := Min(CharIndex + CharCount, FPDFText_CountChars(FTextPage)) - 1;
for i := CharIndex to LastChar do
begin
// Parameter order is left, right, bottom, top - not the Windows order
FPDFText_GetCharBox(FTextPage, i, @L, @R, @B, @T);
Result.Left := Min(Result.Left, L);
Result.Right := Max(Result.Right, R);
Result.Bottom := Min(Result.Bottom, B);
Result.Top := Max(Result.Top, T);
end;
end;
function TViewerForm.PdfToDevice(const W: TPdfRectF): TRect;
var
Scale: Double;
begin
// 72 PDF points per inch; FZoom is the viewer scale factor
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// PDF Y grows upward from the bottom edge; device Y grows downward
Result.Top := Round((FPageTop - W.Top) * Scale) - FScrollY;
Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;
FPageTop è l'altezza della pagina in punti da FPDF_GetPageHeight, e FPageLeft è zero per la maggior parte dei documenti ma proviene dalla crop box (casella di ritaglio) quando la pagina ne definisce una, quindi leggi entrambi da FPDF_GetPageBoundingBox piuttosto che presumere. L'inversione dell'asse Y è dove le versioni scritte a mano si rompono: la parte superiore del rettangolo del dispositivo deriva dalla parte superiore del riquadro PDF misurata verso il basso dalla parte superiore della pagina. Sbaglialo al contrario e ogni evidenziazione verrà disegnata a specchio nella metà sbagliata della pagina
procedure TViewerForm.HighlightWordAt(CharIndex, CharCount: Integer);
var
Old: TRect;
begin
if CharCount <= 0 then Exit;
Old := FHighlightRect;
FHighlightRect := PdfToDevice(WordBox(CharIndex, CharCount));
InvalidateRect(PageBox.Handle, @Old, False); // erase the old word
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // draw the new one
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // rendered page first, always
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // about 38 percent opacity
Blend.AlphaFormat := 0; // constant alpha, no per-pixel data
Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
FHighlightRect.Left, FHighlightRect.Top,
FHighlightRect.Width, FHighlightRect.Height,
FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;
Il gestore del disegno dipinge prima la bitmap della pagina e l'evidenziazione dopo di essa, ogni volta, così l'overlay non deve mai cancellarsi da solo; invalidare i rettangoli vecchi e nuovi mantiene piccola la regione di ridisegno anche a velocità di parlata elevate. FHighlightBrush è una TBitmap uno per uno riempita una volta all'avvio con il colore dell'evidenziazione — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF per un color ambra — che AlphaBlend estende sul rettangolo di destinazione, così nulla viene allocato per fotogramma, e SourceConstantAlpha a 96 mantiene la parola leggibile attraverso la tinta. Testa il colore in modalità di visualizzazione invertite e ad alto contrasto; un overlay che un utente ipovedente non può vedere non esiste per la persona esatta per cui è stato costruito
L'ordine di lettura è la parte che le API testuali non risolveranno
FPDFText_GetText restituisce i caratteri in un ordine derivato dal flusso di contenuti con un po' di pulizia spaziale, e per un report a colonna singola quell'ordine va bene. Non ha alcun obbligo di essere corretto altrove. Una newsletter a due colonne può essere letta direttamente attraverso entrambe le colonne, una barra laterale può interrompere una frase a metà di una proposizione e un piè di pagina può arrivare al centro della pagina. L'informazione che risolve questo problema — l'albero della struttura logica della norma ISO 32000-1 §14.8, che i PDF taggati portano con sé e che PDF/UA rende obbligatorio — non viene affatto consultata dalle chiamate raw della pagina di testo. Se hai bisogno di un ordine consapevole della struttura con un segnale esplicito della sua origine, questo è un problema risolto un gradino più in alto: l'API di lettura di PDFium Component restituisce il contenuto con un campo Source pari a rosStructure o rosHeuristic, e l'articolo sul lettore PDF accessibile lo illustra. Al livello raw delle API, la posizione difendibile è trattare l'ordine di estrazione come una stima, dirlo nell'interfaccia utente, e mantenere un documento multi-colonna e una scansione di sola immagine nel set di regressione in modo che entrambe le modalità di errore rimangano visibili
Il visualizzatore stesso deve essere operabile da tastiera
L'output vocale non scusa il visualizzatore dall'accesso tramite tastiera; le persone più propense a usare la lettura ad alta voce sono le meno propense a prendere in mano un mouse. Dai al pannello della pagina TabStop := True e un rettangolo di focus visibile, quindi gestisci tre tasti: lo Spazio alterna FVoice.Pause e FVoice.Resume, e Sinistra e Destra saltano attraverso FVoice.Skip('Sentence', 1) con un conteggio negativo per tornare indietro. Lo Skip di SAPI capisce solo la granularità della frase, quindi il salto a livello di parola significa eliminare la riproduzione con SVSFPurgeBeforeSpeak e ri-pronunciare dall'offset della parola che hai tracciato per ultima — operazione economica, poiché il codice di evidenziazione sta già memorizzando esattamente quell'offset. Mantieni ogni controllo di trasporto un vero TButton con una didascalia in modo che gli screen reader lo annuncino
Questa è l'intera pipeline, tutta interamente rispetto alle API testuali raw di PDFium: un thread vocale che possiede COM e la voce per tutta la vita dell'app, eventi di confine passati (marshaled) all'interfaccia utente come offset di caratteri, e riquadri nello spazio pagina per carattere trasformati in un unico rettangolo fuso sullo schermo. Se preferisci non gestire tu stesso la geometria e il tracciamento, PDFium Component fornisce riquadri per parola, il cursore di tracciamento, lo scorrimento automatico e unità di lettura a livello di frase come proprietà del componente, e la sua demo di lettura ad alta voce è la pipeline di questo articolo ridotta a una manciata di chiamate