Gumb za čitanje naglas demonstrira se u jedno poslijepodne, a zatim uzme tjedan dana posla. Poslijepodnevna verzija izdvaja tekst stranice, predaje ga SAPI-ju i dobiva zvuk. Tjedan dana odlazi na ono što značajku čini upotrebljivom: glas ne smije zamrznuti prozor, izgovorena riječ mora se na stranici označiti u skladu sa zvukom, a tipka razmaknice mora pauzirati cijelu stvar. Ovaj članak gradi taj cjevovod (pipeline) u Delphiju nasuprot sirovog PDFium API-ja za tekst i Windows Speech API-ja, s radnim kodom za tri dijela koja brza verzija preskače: COM životni ciklus odrađen jednom umjesto po izgovoru, stvarni događaji o granicama riječi i koordinatna matematika koja pretvara okvir riječi u PDF-prostoru u pravokutnik koji možete iscrtati
Regulatorni kontekst stane u jednu rečenicu: sinkronizirano čitanje naglas je polovica onoga što WCAG 2.1 zahtijeva od softvera za dokumente sa strane preglednika, a ISO 14289-1 (PDF/UA) definira polovicu za označene datoteke prema kojoj on najbolje radi. Ako gradite na PDFium komponenti (PDFium Component), možda vam ovaj cjevovod uopće ne treba: preglednik se isporučuje s ugrađenim pokazivačem za praćenje koji jednim pozivom preslikava pomak znaka (character offset) u iscrtano označavanje riječi, što je pokriveno u članku o označavanju riječi pri pretvaranju teksta u govor. Ono što slijedi je za slučaj kada posjedujete cijelu aplikaciju preglednika i želite sam cjevovod
Jedna nit iscrtava, jedna nit govori
Arhitektura se sastoji od dvije niti i jednog ugovora. UI nit iscrtava bitmapu stranice, upravlja stanjem zumiranja i pomicanja, te slika preklapanje za označavanje. Namjenska nit za govor upravlja SAPI glasom i ništa je drugo ne dodiruje. Ugovor je tanak: nit za govor izvještava o napretku kao pomacima znakova (character offsets), a UI nit pretvara pomake u pravokutnike
Većina SAPI primjera omotava svaki izgovor u CoInitialize i CoUninitialize, a preglednik odmah pokazuje zašto je to pogrešno. Speak s SVSFlagsAsync vraća se čim je tekst stavljen u red čekanja, pa se CoUninitialize u bloku finally iste procedure izvršava dok glas još uvijek govori, rušeći COM stan (apartment) koji ga posjeduje. Ovisno o tempiranju, dobit ćete tišinu, odrezani izgovor ili prekršaj pristupa (access violation) minutu kasnije. Ispravan životni ciklus je dosadan: CoInitialize jednom kada se nit za govor pokrene, stvorite glas unutar tog stana i CoUninitialize jednom kada nit izađe, nakon što je glas oslobođen. Nikad po izgovoru
Glas također treba petlju za poruke (message pump), što određuje gdje može živjeti. COM objekt za automatizaciju SpVoice isporučuje svoje događaje kroz red čekanja poruka niti koja ga je stvorila. Stvorite ga na UI niti i događaji pristižu, jer VCL pumpa poruke, ali svako sporo iscrtavanje tada odgađa vaše granice riječi; stvorite ga na radnoj niti (worker thread) bez petlje i događaji nikada ne pristižu. Namjenska nit sa vlastitom GetMessage petljom održava kašnjenje granica ravnim, bez obzira na to što 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 šalje WM_QUIT kako bi se petlja odblokirala kada se preglednik gasi. SpeakPage, pozvan s UI niti, sprema tekst u polje zaštićeno bravom i šalje WM_SPEAK_PAGE, jer bi pozivanje metode na FVoice izravno s druge niti bio među-stambeni COM poziv na nemaršaliranom sučelju. Jedna linija PeekMessage prije petlje prisiljava Windowse da stvore red čekanja poruka niti, zatvarajući početnu utrku (startup race) gdje bi rani poziv s UI niti propao
Granice riječi pristižu kao pomaci znakova
Uvezite Microsoftovu biblioteku govornih objekata (Microsoft Speech Object Library) jednom putem uvoznika biblioteke tipova IDE-a i dobit ćete SpeechLib_TLB s omotom TSpVoice i njegovim tipiziranim događajima. Važne su dvije postavke. EventInterests treba suziti na događaje koje zapravo trošite, jer je svaki interes ostavljen uključenim promet događaja između niti za svaku riječ svake stranice; SVEWordBoundary pokreće označavanje, a SVEEndInputStream vam govori da je izgovor završio. I rukovatelj OnWord prima CharacterPosition i duljinu, koji indeksiraju točan niz koji ste proslijedili u Speak — pomak (offset) u međuspremnik govora, a ne u bilo što drugo
Ta zadnja klauzula je invarijanta o kojoj značajka ovisi: pomaci su značajni samo u odnosu na niz koji glas čita, stoga izgovorite točno onaj tekst koji ste izdvojili, znak po znak. Obrežite praznine, sažmite prijelome redaka ili proširite kraticu za ljepši izgovor, i svako označavanje nakon prvog uređivanja promašit će za jednu riječ. Ako UI mora ubaciti izgovoreni materijal — najave stranica, prefikse naslova — zabilježite poziciju i duljinu svakog umetanja i oduzmite akumulirani pomak od svakog pomaka prije nego što ga preslikate
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 ovdje pravi maršal, a ne Synchronize: rukovatelj ne smije parkirati nit za govor dok UI ponovno iscrtava, a ako događaji o granicama pristižu brže nego što zaslon crta, zastarjelo ažuriranje označavanja je bezopasno jer ga sljedeće prebriše. Povežite OnEndStream na isti način da biste očistili označavanje, i u načinu neprekidnog čitanja, da biste učitali tekst sljedeće stranice i objavili sljedeći izgovor
Od pomaka znakova do piksela na zaslonu
PDFium izvještava o geometriji po znaku. FPDFText_GetCharBox popunjava četiri double-a u redoslijedu koji je uzrokovao više tihih pogrešaka nego bilo što drugo u tekstualnom API-ju — lijevo, desno, dno, vrh, a ne po Windowsima lijevo, vrh, desno, dno — i izvještava ih u prostoru stranice: PDF točke, 72 po inču, ishodište u donjem lijevom kutu s rastom Y prema gore. Okvir riječi je unija okvira njezinih znakova, a transformacija u piksele uređaja odvija se u tri koraka: translacija za ishodište stranice, skaliranje pomoću zumiranja pomnoženog s DPI-jem zaslona kroz 72 i okretanje Y osi
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 točkama iz FPDF_GetPageHeight, a FPageLeft je nula za većinu dokumenata, ali dolazi iz crop okvira kada ga stranica definira, stoga pročitajte oboje iz FPDF_GetPageBoundingBox umjesto da pretpostavljate. Okretanje Y osi je mjesto gdje se ručno napisane verzije lome: vrh pravokutnika uređaja dolazi od vrha PDF okvira mjerenog prema dolje od vrha stranice. Shvatite to naopako i svako označavanje slika se zrcalno u pogrešnu polovicu 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;
Rukovatelj iscrtavanjem prvo crta bitmapu stranice, a zatim označavanje, svaki put, tako da preklapanje nikada ne mora brisati samo sebe; poništavanje (invalidating) starog i novog pravokutnika održava regiju ponovnog iscrtavanja malom čak i pri brzim stopama govora. FHighlightBrush je jednim-po-jednim TBitmap ispunjen jednom pri pokretanju bojom označavanja — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF za jantarnu (amber) — koju AlphaBlend rasteže preko ciljanog pravokutnika, tako da se ništa ne dodjeljuje po okviru (frame), a SourceConstantAlpha na 96 održava riječ čitljivom kroz boju. Testirajte boju pod obrnutim i visokokontrastnim načinima prikaza; preklapanje koje slabovidni korisnik ne može vidjeti ne postoji upravo za osobu za koju je izgrađeno
Redoslijed čitanja je dio koji tekstualni API neće riješiti
FPDFText_GetText vraća znakove u redoslijedu izvedenom iz toka sadržaja (content stream) s nešto prostornog čišćenja, i za izvješće u jednom stupcu taj je redoslijed u redu. Nema obvezu biti točan nigdje drugdje. Bilten u dva stupca može čitati ravno preko oba stupca, bočna traka može prekinuti rečenicu usred klauzule, a podnožje može stići usred stranice. Informacije koje to popravljaju — logičko stablo strukture iz ISO 32000-1 §14.8, koje nose označeni PDF-ovi (tagged PDFs) i koje PDF/UA čini obaveznim — ne konzultiraju se sirovim pozivima tekstualne stranice uopće. Ako trebate redoslijed svjestan strukture s eksplicitnim signalom o njegovu podrijetlu, to je riješen problem na višoj razini: API za čitanje PDFium komponente vraća sadržaj s poljem Source kao rosStructure ili rosHeuristic, a članak o pristupačnom PDF čitaču prolazi kroz to. Na razini sirovog API-ja, obranjiv položaj je tretirati redoslijed izdvajanja kao procjenu, navesti to u UI-ju, i držati jedan dokument u više stupaca i jedno skeniranje samo sa slikama u setu za regresiju kako bi oba načina kvara ostala vidljiva
Sam preglednik mora biti operativan tipkovnicom
Govorni izlaz ne opravdava preglednik od pristupa tipkovnicom; ljudi koji će najvjerojatnije koristiti čitanje naglas najmanje će vjerojatno posegnuti za mišem. Dajte ploči stranice TabStop := True i vidljiv pravokutnik fokusa, a zatim rukujte s tri tipke: Razmaknica uključuje FVoice.Pause i FVoice.Resume, a Lijevo i Desno preskaču putem FVoice.Skip('Sentence', 1) s negativnim brojem za povratak unatrag. SAPI-jev Skip razumije samo granularnost rečenice, pa preskakanje na razini riječi znači čišćenje reprodukcije s SVSFPurgeBeforeSpeak i ponovno govorenje od pomaka riječi koju ste zadnju pratili — jeftino, budući da kod za označavanje već sprema točno taj pomak. Zadržite svaku kontrolu prijenosa pravim TButtonom s natpisom kako bi je čitači zaslona najavili
To je cijeli cjevovod, sve to protiv sirovog PDFium API-ja za tekst: nit za govor koja posjeduje COM i glas za vrijeme trajanja aplikacije, događaji granica maršalirani u UI kao pomaci znakova i per-znakovni okviri u prostoru stranice pretvoreni u jedan preklopljeni pravokutnik na zaslonu. Ako radije ne biste sami posjedovali geometriju i praćenje, PDFium komponenta isporučuje okvire za svaku riječ, pokazivač za praćenje, praćenje s automatskim pomicanjem i jedinice čitanja na razini rečenice kao svojstva komponente, a njezin demo za čitanje naglas je cjevovod iz ovog članka sveden na pregršt poziva