Dugme za čitanje naglas se demonstrira za jedno popodne, a zatim vam oduzme nedelju dana. Popodnevna verzija izdvaja tekst sa stranice, predaje ga SAPI-ju i dobija zvuk. Nedelja dana odlazi na ono što funkciju čini upotrebljivom: glas ne sme da zamrzne prozor, izgovorena reč mora da zasvetli na stranici usklađeno sa zvukom, a taster Space mora da pauzira celu stvar. Ovaj članak gradi taj cevovod u Delphiju u odnosu na sirovi PDFium tekst API i Windows Speech API, uz radni kod za tri dela koja brza verzija preskače: COM životni ciklus koji se obavlja jednom, a ne po izgovoru, stvarne događaje na granici reči i matematiku koordinata koja pretvara okvir reči u PDF prostoru u pravougaonik koji možete iscrtati
Regulatorni kontekst staje u jednu rečenicu: sinhronizovano čitanje naglas je polovina onoga što WCAG 2.1 traži od softvera za dokumente na strani pregledača, a ISO 14289-1 (PDF/UA) definiše polovinu obeležene datoteke (tagged-file) protiv koje najbolje radi. Ako gradite na PDFium Component-i, možda vam ovaj cevovod uopšte neće ni trebati: pregledač se isporučuje sa ugrađenim kursorom za praćenje koji mapira ofset znakova na obojeno isticanje reči u jednom pozivu, što je obrađeno u članku o isticanju TTS-a reč po reč. Ono što sledi je za slučaj kada posedujete celu aplikaciju pregledača i želite sam cevovod
Jedan thread renderuje, jedan thread govori
Arhitektura se sastoji od dva thread-a i jednog ugovora. UI thread renderuje bitmapu stranice, poseduje stanje zumiranja i pomeranja (scroll), i iscrtava sloj za isticanje. Namenski thread za govor poseduje SAPI glas, i ništa ga drugo ne dodiruje. Ugovor je jednostavan: thread za govor izveštava o napretku kao ofsetima znakova, a UI thread pretvara ofsete u pravougaonike
Većina SAPI primera omotava svaki izgovor u CoInitialize i CoUninitialize, a pregledač odmah pokazuje zašto je to pogrešno. Speak sa SVSFlagsAsync se vraća čim je tekst stavljen u red, tako da CoUninitialize u finally bloku iste procedure radi dok glas još uvek govori, rušeći COM apartman koji ga poseduje. U zavisnosti od tajminga, dobijate tišinu, odsečen izgovor ili prekršaj pristupa (access violation) nekoliko minuta kasnije. Ispravan životni ciklus je dosadan: CoInitialize jednom kada se thread za govor pokrene, napravite glas unutar tog apartmana, i CoUninitialize jednom kada se thread završi, nakon što je glas oslobođen. Nikada po izgovoru
Glas takođe zahteva pumpu za poruke, koja odlučuje gde može da živi. SpVoice objekat za automatizaciju isporučuje svoje događaje kroz red poruka thread-a koji ga je kreirao. Ako ga kreirate na UI thread-u, događaji će stizati jer VCL pumpa poruke, ali svako sporo iscrtavanje će tada odložiti vaše granice reči; ako ga kreirate na radnom thread-u (worker thread) bez pumpe, događaji nikada neće ni stići. Namenski thread sa sopstvenom GetMessage petljom održava latenciju granica ravnom, bez obzira na to šta UI radi
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 postavlja WM_QUIT tako da se pumpa odblokira kada se pregledač ugasi. SpeakPage, pozvan sa UI thread-a, skladišti tekst u polje zaštićeno bravom i postavlja WM_SPEAK_PAGE, jer bi pozivanje metode na FVoice direktno sa drugog thread-a bio COM poziv između apartmana na nemaršaliranom interfejsu. PeekMessage od jedne linije pre petlje primorava Windows da kreira red poruka thread-a, zatvarajući početnu trku gde bi rani post sa UI thread-a propao
Granice reči stižu kao ofseti znakova
Uvezite Microsoft Speech Object Library jednom kroz uvoznik biblioteke tipova (type library importer) u IDE-u i dobićete SpeechLib_TLB sa TSpVoice omotačem i njegovim tipiziranim događajima. Dva podešavanja su važna. EventInterests bi trebalo suziti na događaje koje zapravo konzumirate, jer svaki interes koji ostane uključen predstavlja saobraćaj događaja između thread-ova za svaku reč na svakoj stranici; SVEWordBoundary pokreće isticanje, a SVEEndInputStream vam govori da je izgovor završen. Pored toga, OnWord rukovalac prima CharacterPosition i dužinu, koji indeksiraju direktno u tačan string koji ste prosledili metodi Speak — ofset u bafer za govor, a ne u bilo šta drugo
Ta poslednja klauzula je invarijanta na kojoj funkcija zavisi: ofseti imaju smisla samo u odnosu na string koji glas čita, pa govorite tačno onaj tekst koji ste izdvojili, znak po znak. Ako trimujete razmake, sažmete prelome redova ili proširite skraćenicu radi lepšeg izgovora, svako isticanje nakon prvog izmenjenog mesta će promašiti za jednu reč. Ako UI mora da ubaci govorni materijal — najave stranica, prefikse naslova — zabeležite poziciju i dužinu svakog ubacivanja i oduzmite akumulirani pomak od svakog ofseta pre nego što ga mapirate
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 je ovde pravi maršal, a ne Synchronize: rukovalac ne sme da parkira thread za govor dok se UI precrtava, a ako događaji granice stignu brže nego što se ekran iscrtava, bajato ažuriranje isticanja je bezopasno jer će ga sledeće prepisati. Povežite OnEndStream na isti način da biste obrisali isticanje i, u režimu neprekidnog čitanja, da biste učitali tekst sledeće stranice i objavili sledeći izgovor
Od ofseta znakova do piksela na ekranu
PDFium izveštava o geometriji po znaku. FPDFText_GetCharBox popunjava četiri double vrednosti u redosledu koji je izazvao više tihih grešaka od bilo čega drugog u tekstualnom API-ju — levo, desno, dole, gore, a ne Windows-ovo levo, gore, desno, dole — i izveštava o njima u prostoru stranice: PDF tačke, 72 po inču, početak u donjem levom uglu, pri čemu Y raste naviše. Okvir reči je unija okvira njenih znakova, a transformacija u piksele uređaja sastoji se od tri koraka: translacija za početak stranice, skaliranje za zumiranje puta DPI ekrana podeljeno sa 72, i obrtanje Y ose
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 je visina stranice u tačkama iz FPDF_GetPageHeight, a FPageLeft je nula za većinu dokumenata, ali potiče iz okvira za isecanje (crop box) kada ga stranica definiše, pa iščitajte oba iz FPDF_GetPageBoundingBox umesto da pretpostavljate. Obrtanje Y ose je mesto gde ručno pisane verzije pucaju: vrh pravougaonika uređaja potiče od vrha PDF okvira mereno nadole od vrha stranice. Ako to uradite naopako, svako isticanje će biti nacrtano u ogledalu na pogrešnoj polovini stranice
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;
Rukovalac iscrtavanjem uvek prvo crta bitmapu stranice, a isticanje posle nje, tako da sloj nikada ne mora sam da se briše; poništavanje starog i novog pravougaonika održava oblast ponovnog iscrtavanja malom, čak i pri brzim stopama govora. FHighlightBrush je TBitmap formata jedan sa jedan, koji se popunjava bojom za isticanje pri pokretanju — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF za ćilibar boju — i AlphaBlend ga razvlači preko ciljnog pravougaonika, tako da se ništa ne dodeljuje po kadru, a SourceConstantAlpha na 96 održava reč čitljivom kroz nijansu. Testirajte boju pod obrnutim i visoko kontrastnim režimima prikaza; sloj koji slabovidi korisnik ne može da vidi ne postoji upravo za osobu za koju je i napravljen
Redosled čitanja je deo koji tekstualni API neće rešiti
FPDFText_GetText vraća znakove redosledom izvedenim iz toka sadržaja (content stream) uz izvesno prostorno čišćenje, i za izveštaj sa jednom kolonom taj redosled je sasvim u redu. Nema obavezu da bude ispravan bilo gde drugde. Bilten u dve kolone može da se čita pravo preko obe kolone, bočna traka može da prekine rečenicu usred klauzule, a podnožje može da stigne usred stranice. Informacija koja ovo popravlja — logičko stablo strukture iz ISO 32000-1 §14.8, koje označeni (tagged) PDF-ovi nose i koje PDF/UA čini obaveznim — uopšte nije konsultovana od strane poziva sirovih tekstualnih stranica. Ako vam je potreban redosled svestan strukture sa eksplicitnim signalom svog porekla, to je problem rešen na nivou iznad: API za čitanje PDFium Component-e vraća sadržaj sa Source poljem postavljenim na rosStructure ili rosHeuristic, a članak o pristupačnom PDF čitaču prolazi kroz to. Na sirovom API nivou, branjiv stav je da se redosled ekstrakcije tretira kao procena, da se to naglasi u UI-u, i da se zadrži jedan dokument sa više kolona i jedno skeniranje samo sa slikama u setu za regresiono testiranje, kako bi oba načina otkazivanja ostala vidljiva
Sam pregledač mora biti upotrebljiv preko tastature
Govorni izlaz ne oslobađa pregledač potrebe za pristupom preko tastature; ljudi koji će najverovatnije koristiti čitanje naglas su oni koji će najređe posegnuti za mišem. Dodajte panelu stranice TabStop := True i vidljiv pravougaonik fokusa, a zatim obradite tri tastera: Space menja između FVoice.Pause i FVoice.Resume, dok Levo i Desno preskaču kroz FVoice.Skip('Sentence', 1) sa negativnim brojačem za vraćanje unazad. SAPI-jev Skip razume samo nivo rečenice, tako da preskakanje na nivou reči znači čišćenje reprodukcije sa SVSFPurgeBeforeSpeak i ponovno izgovaranje od ofseta reči koju ste poslednju pratili — što je jeftino, pošto kod za isticanje već skladišti upravo taj ofset. Zadržite svaku kontrolu transporta kao pravu TButton komponentu sa natpisom, kako bi je čitači ekrana najavljivali
To je ceo cevovod, u potpunosti u odnosu na sirovi PDFium tekstualni API: thread za govor koji poseduje COM i glas tokom celog životnog veka aplikacije, događaji granica prosleđeni u UI kao ofseti znakova, i okviri po znaku u prostoru stranice pretvoreni u jedan stopljeni pravougaonik na ekranu. Ako biste radije da sami ne upravljate geometrijom i praćenjem, PDFium Component isporučuje okvire za svaku reč, kursor za praćenje, automatsko pomeranje (auto-scroll) za praćenje i jedinice za čitanje na nivou rečenice kao svojstva komponente, a njena demonstracija čitanja naglas je zapravo cevovod iz ovog članka sveden na šačicu poziva