Tlačidlo na hlasové čítanie sa dá predviesť za jedno popoludnie a potom zožerie celý týždeň. Popoludňajšia verzia len vyextrahuje text stránky, odovzdá ho SAPI a získa späť zvuk. Ten týždeň padne na to, čo túto funkciu robí reálne použiteľnou: hlas nesmie zamraziť okno, vyslovené slovo sa musí rozsvietiť na stránke synchrónne so zvukom a kláves Medzerník musí to celé okamžite pozastaviť. Tento článok stavia toto prepojenie v Delphi nad surovým textovým API PDFia a nad Windows Speech API, s funkčným kódom pre tri časti, ktoré rýchla verzia vynecháva: životnosť COM riešenú raz namiesto pri každej vete, reálne udalosti hraníc slov (word-boundary events) a matematiku súradníc, ktorá premení box slova v priestore PDF na obdĺžnik, ktorý viete vykresliť
Regulačný kontext sa zmestí do jednej vety: synchronizované čítanie nahlas je tá polovica, ktorú od softvéru na dokumenty na strane prehliadača žiada WCAG 2.1, a ISO 14289-1 (PDF/UA) definuje polovicu na strane oštítkovaného súboru, proti ktorej to funguje najlepšie. Ak staviate na PDFium Component, možno toto prepojenie vôbec nepotrebujete: prehliadač už dodáva vstavaný sledovací kurzor, ktorý jediným volaním namapuje posun znaku na vykreslené zvýraznenie slova, čo pokrýva článok o zvýrazňovaní slovo za slovom pre TTS. Nasledujúci text je pre prípad, keď vlastníte celú aplikáciu prehliadača a chcete samotné toto prepojenie
Jedno vlákno vykresľuje, jedno vlákno rozpráva
Architektúra sú dve vlákna a jedna zmluva. UI vlákno vykresľuje bitmapu stránky, spravuje stav priblíženia a posúvania a maľuje prekrytie zvýraznenia. Vyhradené vlákno na reč vlastní hlas SAPI a nič iné sa ho nedotýka. Zmluva medzi nimi je tenká: vlákno na reč hlási postup ako posuny znakov (character offsets) a UI vlákno premieňa tieto posuny na obdĺžniky
Väčšina ukážok SAPI zabalí každú vetu do CoInitialize a CoUninitialize, a prehliadač okamžite ukáže, prečo je to nesprávne. Speak s SVSFlagsAsync sa vráti hneď, ako je text zaradený do fronty, takže CoUninitialize v bloku finally tej istej procedúry sa spustí ešte počas toho, ako hlas stále hovorí, a strhne tak COM apartment, ktorý ho vlastní. V závislosti od časovania dostanete ticho, orezanú vetu alebo access violation o pár minút neskôr. Správna životnosť je nudná: CoInitialize raz, keď sa vlákno na reč spustí, hlas vytvorte vo vnútri tohto apartmentu a CoUninitialize raz, keď sa vlákno ukončí, po tom, čo bol hlas uvoľnený. Nikdy nie pri každej vete
Hlas zároveň potrebuje pumpu na správy (message pump), ktorá rozhoduje, kde môže žiť. Automatizačný objekt SpVoice doručuje svoje udalosti cez frontu správ vlákna, ktoré ho vytvorilo. Vytvorte ho na UI vlákne a udalosti prídu, pretože VCL pumpuje správy, ale každé pomalé prekreslenie potom oneskorí vaše hranice slov; vytvorte ho na worker vlákne bez pumpy a udalosti neprídu vôbec. Vyhradené vlákno s vlastnou slučkou GetMessage udržuje latenciu hraníc konštantnú bez ohľadu na to, čo práve robí UI
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; // číta FText pod 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); // bezpečné volanie z UI vlákna
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // raz, keď sa vlákno spustí
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Vynúti vytvorenie frontu správ tohto vlákna skôr, než doň niekto niečo pošle
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // skončí, keď príde WM_QUIT
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // doručí callbacky udalostí SAPI
finally
FVoice.Free;
end;
finally
CoUninitialize; // raz, keď sa vlákno ukončí
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // odblokuje GetMessage
end;
TerminatedSet pošle WM_QUIT, aby sa pumpa odblokovala, keď sa prehliadač zatvára. SpeakPage, volané z UI vlákna, uloží text do poľa chráneného zámkom a pošle WM_SPEAK_PAGE, pretože priame volanie metódy na FVoice z iného vlákna by bolo cross-apartment COM volanie na nemaršalovanom rozhraní. Jednoriadkové PeekMessage pred slučkou donúti Windows vytvoriť frontu správ vlákna, čím uzavrie štartovací pretek, pri ktorom by skoré odoslanie z UI vlákna zlyhalo
Hranice slov prichádzajú ako posuny znakov
Naimportujte knižnicu Microsoft Speech Object Library raz cez importér knižníc typov v IDE a získate SpeechLib_TLB s wrapperom TSpVoice a jeho typovanými udalosťami. Záležia na dvoch nastaveniach. EventInterests by malo byť zúžené len na udalosti, ktoré skutočne spracúvate, pretože každý ponechaný záujem je cross-thread prenos udalostí pre každé slovo každej stránky; SVEWordBoundary pohání zvýraznenie a SVEEndInputStream vám povie, že veta skončila. A obsluha OnWord dostáva CharacterPosition a dĺžku, ktoré indexujú presne do reťazca, ktorý ste odovzdali Speak — posun do bufferu reči, nie do niečoho iného
Posledná klauzula je invariant, na ktorom táto funkcia stojí: posuny majú zmysel iba voči reťazcu, ktorý hlas práve číta, takže hovorte presne ten text, ktorý ste extrahovali, znak po znaku. Orežte biele znaky, zlúčte zalomenia riadkov alebo rozpíšte skratku kvôli krajšej výslovnosti a každé zvýraznenie po prvej takejto úprave dopadne o slovo vedľa. Ak UI musí vkladať vyslovovaný materiál — oznámenia o stránke, prefixy nadpisov — zaznamenajte si pozíciu a dĺžku každého vloženia a od každého posunu pred jeho namapovaním odpočítajte nahromadený posun
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
// Beží na vlákne pre reč; posuny odovzdá UI bez blokovania
TThread.Queue(nil,
procedure
begin
ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
end);
end;
TThread.Queue je tu správny spôsob maršalovania, nie Synchronize: obsluha nesmie zaparkovať vlákno pre reč, kým sa UI prekresľuje, a ak udalosti hraníc prichádzajú rýchlejšie, než sa obrazovka prekresľuje, zastarané zvýraznenie je neškodné, pretože ho prepíše ďalšie. Napojte rovnakým spôsobom aj OnEndStream, aby vyčistil zvýraznenie, a v režime plynulého čítania aj to, aby načítal text ďalšej stránky a poslal ďalšiu vetu
Z posunov znakov na pixely na obrazovke
PDFium hlási geometriu za každý znak zvlášť. FPDFText_GetCharBox naplní štyri hodnoty typu double v poradí, ktoré spôsobilo viac tichých chýb než čokoľvek iné v textovom API — vľavo, vpravo, dole, hore, nie windowsovské vľavo, hore, vpravo, dole — a hlási ich v priestore stránky: body PDF, 72 na palec, počiatok v ľavom dolnom rohu s Y rastúcim nahor. Box slova je zjednotením boxov jeho znakov a transformácia na pixely zariadenia sú tri kroky: posun o počiatok stránky, zmena mierky priblížením krát DPI obrazovky lomeno 72 a prevrátenie osi Y
uses
System.Math;
type
TPdfRectF = record
Left, Top, Right, Bottom: Double; // body PDF, počiatok vľavo dole
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
// Poradie parametrov je vľavo, vpravo, dole, hore - nie windowsovské poradie
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 bodov PDF na palec; FZoom je mierka priblíženia prehliadača
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// Y v PDF rastie nahor od dolného okraja; Y zariadenia rastie nadol
Result.Top := Round((FPageTop - W.Top) * Scale) - FScrollY;
Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;
FPageTop je výška stránky v bodoch z FPDF_GetPageHeight a FPageLeft je pri väčšine dokumentov nula, ale pochádza z orezávacieho boxu (crop box), ak ho stránka definuje, preto obe hodnoty radšej načítajte z FPDF_GetPageBoundingBox, než aby ste to predpokladali. Prevrátenie osi Y je miesto, kde ručne písané verzie zlyhávajú: horná hrana obdĺžnika zariadenia vychádza z hornej hrany boxu PDF, meranej smerom nadol od vrchu stránky. Pomýlite si to obrátene a každé zvýraznenie sa vykreslí zrkadlovo prevrátené do nesprávnej polovice stránky
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); // zmaže staré slovo
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // vykreslí nové
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // vždy najprv vykreslená stránka
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // približne 38 % nepriehľadnosti
Blend.AlphaFormat := 0; // konštantná alfa, žiadne dáta na pixel
Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
FHighlightRect.Left, FHighlightRect.Top,
FHighlightRect.Width, FHighlightRect.Height,
FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;
Obsluha vykresľovania zakaždým najprv nakreslí bitmapu stránky a až potom zvýraznenie, takže prekrytie sa nikdy nemusí samo mazať; zneplatnenie starého aj nového obdĺžnika udržuje oblasť na prekreslenie malú aj pri rýchlom tempe reči. FHighlightBrush je TBitmap s rozmerom jeden krát jeden pixel, naplnená pri štarte farbou zvýraznenia — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF pre jantárovú — ktorú AlphaBlend naťahuje cez cieľový obdĺžnik, takže sa nič nealokuje na snímku, a SourceConstantAlpha na hodnote 96 udržuje slovo pod odtieňom čitateľné. Vyskúšajte túto farbu aj v invertovaných a vysokokontrastných zobrazovacích režimoch; prekrytie, ktoré slabozraký používateľ nevidí, pre presne tú osobu, pre ktorú bolo vytvorené, neexistuje
Poradie čítania je to, čo textové API nevyrieši
FPDFText_GetText vracia znaky v poradí odvodenom z content streamu s určitým priestorovým čistením, a pre jednostĺpcový report je toto poradie v poriadku. Nemá žiadnu povinnosť byť správne kdekoľvek inde. Dvojstĺpcový spravodaj sa dá čítať naprieč oboma stĺpcami naraz, bočný panel môže prerušiť vetu uprostred klauzuly a päta môže priletieť doprostred stránky. Informácie, ktoré toto riešia — logický strom štruktúry podľa ISO 32000-1 §14.8, ktorý nesú oštítkované PDF (tagged PDFs) a PDF/UA ho robí povinným — surové volania na textovú stránku vôbec nekonzultujú. Ak potrebujete poradie s vedomím štruktúry a s explicitným signálom o jeho pôvode, ide o problém vyriešený o poschodie vyššie: API na čítanie v PDFium Component vracia obsah s poľom Source nastaveným na rosStructure alebo rosHeuristic, čo prechádza článok o prístupnom čítačke PDF. Na úrovni surového API je obhájiteľná pozícia brať poradie extrakcie ako odhad, povedať to aj v UI a v regresnej sade si ponechať jeden viacstĺpcový dokument aj jeden čisto skenovaný obraz, aby oba tieto typy zlyhania zostali viditeľné
Samotný prehliadač musí byť ovládateľný z klávesnice
Hlasový výstup neospravedlňuje prehliadač od prístupu z klávesnice; ľudia, ktorí najskôr siahnu po čítaní nahlas, najmenej pravdepodobne siahnu po myši. Dajte panelu stránky TabStop := True a viditeľný obdĺžnik fokusu, potom obslúžte tri klávesy: Medzerník prepína FVoice.Pause a FVoice.Resume, a Doľava a Doprava preskakujú cez FVoice.Skip('Sentence', 1) so záporným počtom pre krok späť. Skip v SAPI rozumie iba granularite viet, takže preskakovanie na úrovni slov znamená vyčistiť prehrávanie pomocou SVSFPurgeBeforeSpeak a znova hovoriť od posunu naposledy sledovaného slova — lacné riešenie, keďže kód zvýraznenia už presne tento posun ukladá. Nechajte každý ovládací prvok transportu ako reálny TButton s popiskom, aby ho čítačky obrazovky ohlásili
To je celé prepojenie, celé postavené nad surovým textovým API PDFia: vlákno pre reč, ktoré vlastní COM a hlas po celú dobu behu aplikácie, udalosti hraníc maršalované do UI ako posuny znakov a boxy v priestore stránky za jednotlivé znaky premenené na jeden zmiešaný obdĺžnik na obrazovke. Ak nechcete vlastniť geometriu a sledovanie sami, PDFium Component dodáva boxy pre jednotlivé slová, sledovací kurzor, automatické sledovanie posúvaním a čítacie jednotky na úrovni viet ako vlastnosti komponentu, a jeho demo na čítanie nahlas je prepojenie z tohto článku zredukované na pár volaní