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; // čita FText pod zaštitom FLock-a
procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
protected
procedure Execute; override;
procedure TerminatedSet; override;
public
procedure SpeakPage(const AText: string); // bezbedno pozivanje sa UI niti
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // jednom, kada nit krene
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Prisiljava stvaranje reda poruka ove niti pre nego što iko pošalje poruku u nju
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // izlazi kada stigne WM_QUIT
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // isporučuje SAPI povratne pozive događaja
finally
FVoice.Free;
end;
finally
CoUninitialize; // jednom, kada se nit završi
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // deblokira 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
// Izvršava se na niti za govor; prosleđuje pomake ka UI-ju bez blokiranja
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 tačke, ishodište u donjem levom uglu
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
// Redosled parametara je left, right, bottom, top - ne Windows redosled
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 tačke po inču; FZoom je faktor razmere prikazivača
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// PDF Y raste naviše od donje ivice; Y uređaja raste naniže
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); // briše staru reč
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // crta novu
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // uvek prvo renderovana stranica
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // otprilike 38 procenata neprozirnosti
Blend.AlphaFormat := 0; // konstantna alfa, bez podataka po pikselu
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