Gumb za branje na glas se lahko predstavi v enem popoldnevu, nato pa vzame cel teden. Popoldanska različica izlušči besedilo strani, ga preda v SAPI in vrne zvok. Teden pa se porabi za tisto, kar naredi to funkcijo uporabno: glas ne sme zamrzniti okna, izgovorjena beseda mora na strani zasvetiti sinhrono z zvokom in preslednica mora omogočati zaustavitev celotnega procesa. Ta članek opisuje gradnjo takšnega cevovoda v Delphiju z uporabo surovega API-ja za besedilo PDFium in Windows Speech API-ja. Vključuje delujočo kodo za tri dele, ki jih hitra različica preskoči: življenjski cikel COM, izveden enkrat in ne za vsako izjavo posebej, resnične dogodke mej besed in matematične izračune koordinat, ki pretvorijo polje besede v PDF prostoru v pravokotnik, ki ga lahko narišete
Zakonodajni kontekst je mogoče strniti v en stavek: sinhronizirano branje na glas je na strani pregledovalnika tisto, kar WCAG 2.1 zahteva od programske opreme za dokumente, medtem ko ISO 14289-1 (PDF/UA) definira stran označene datoteke, s katero to najbolje deluje. Če gradite na osnovi PDFium Component, morda sploh ne boste potrebovali tega cevovoda: pregledovalnik ima vgrajen kazalec za sledenje, ki preslika zamik znakov v narisano osvetlitev besede z enim klicem, kar je zajeto v članku o označevanju TTS po posameznih besedah. Kar sledi, je namenjeno primerom, ko imate v lasti celotno aplikacijo za pregledovanje in želite zgraditi sam cevovod
Ena nit za upodabljanje, ena nit za govor
Arhitektura vključuje dve niti in eno pogodbo. Nit uporabniškega vmesnika (UI) upodablja bitno sliko strani, ima nadzor nad stanjem povečave in drsenja ter riše prekritje osvetlitve. Posebna govorna nit ima nadzor nad glasom SAPI, in se je nič drugega ne dotika. Pogodba je preprosta: govorna nit poroča o napredku kot zamikih znakov, UI nit pa pretvarja te zamike v pravokotnike
Večina primerov SAPI zavije vsako izjavo v CoInitialize in CoUninitialize, pregledovalnik pa takoj pokaže, zakaj je to narobe. Metoda Speak z zastavico SVSFlagsAsync se vrne takoj, ko je besedilo v čakalni vrsti, zato se CoUninitialize v bloku finally iste procedure izvede medtem, ko glas še govori, in podre COM strukturo, ki ga ima v lasti. Odvisno od časovnega usklajevanja dobite tišino, okrnjeno izjavo ali kršitev dostopa (access violation) nekaj minut kasneje. Pravilen življenjski cikel je dolgočasen: CoInitialize enkrat ob zagonu niti, ustvarjanje glasu znotraj te strukture, in CoUninitialize enkrat ob izhodu niti, ko je bil glas že sproščen. Nikoli za vsako izjavo posebej
Glas potrebuje tudi zanko za sporočila (message pump), kar določa, kje lahko obstaja. Avtomatizacijski objekt SpVoice dostavlja svoje dogodke prek čakalne vrste sporočil niti, v kateri je bil ustvarjen. Če ga ustvarite v UI niti, dogodki prispejo, ker VCL obdela sporočila, a vsako počasno izrisovanje zakasni meje besed. Če ga ustvarite v delovni niti brez zanke, dogodki sploh ne prispejo. Zato namenska nit z lastno GetMessage zanko ohranja latenco mej konstantno, ne glede na to, kaj počne uporabniški vmesnik
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;
Metoda TerminatedSet pošlje WM_QUIT, da se zanka sprosti ob zaprtju pregledovalnika. SpeakPage, ki se kliče iz UI niti, shrani besedilo v polje, zaščiteno s ključavnico (lock), in objavi WM_SPEAK_PAGE. Neposredni klic metode na FVoice iz druge niti bi predstavljal klic COM med različnimi strukturami na nemaršaliranem vmesniku. Enovrstični klic PeekMessage pred zanko prisili sistem Windows, da ustvari čakalno vrsto sporočil niti, kar odpravi napako pri zagonu, kjer bi zgodnja objava iz UI niti spodletela
Meje besed prispejo kot zamiki znakov
Uvozite Microsoft Speech Object Library enkrat preko uvoznika knjižnic tipov v IDE-ju in dobili boste SpeechLib_TLB z ovojem TSpVoice in njegovimi tipiziranimi dogodki. Pomembni sta dve nastavitvi. Nastavitev EventInterests je treba omejiti na dogodke, ki jih dejansko uporabljate, saj vsako zanimanje, ki ostane vklopljeno, povzroča dogodkovni promet med nitmi za vsako besedo vsake strani. Dogodek SVEWordBoundary poganja osvetlitev, SVEEndInputStream pa sporoča, da je izjava zaključena. Povezovalnik OnWord prejme CharacterPosition in dolžino, ki kažeta na točen niz, podan metodi Speak — zamik znotraj govornega medpomnilnika, ne pa kje drugje
Ta zadnji stavek je invariant, na katerem temelji ta funkcija: zamiki so smiselni samo za niz, ki ga glas trenutno bere, zato izgovorite natančno takšno besedilo, kot ste ga izluščili, znak za znakom. Obrežite presledke, združite prelome vrstic ali razširite okrajšavo za boljšo izgovarjavo in vsaka naslednja osvetlitev se bo premaknila za eno besedo. Če mora uporabniški vmesnik vstaviti govorjeno vsebino — naznanila strani, predpone naslovov — zabeležite položaj in dolžino vsakega vstavljanja ter odštejte nakopičeni premik od vsakega zamika preden 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;
Za preslikavo med nitmi je tukaj prava izbira TThread.Queue, in ne Synchronize: rutinski povezovalnik ne sme zaustaviti govorne niti, medtem ko se UI ponovno riše. Če dogodki mej prihajajo hitreje kot se zaslon osvežuje, zastarela posodobitev osvetlitve ni škodljiva, saj jo naslednja povozi. Dogodek OnEndStream povežite na enak način, da počistite osvetlitev, v načinu neprekinjenega branja pa, da naložite besedilo naslednje strani in zaženete naslednjo izjavo
Od zamikov znakov do pikslov na zaslonu
PDFium poroča o geometriji za vsak znak. Metoda FPDFText_GetCharBox vrne štiri dvojne vrednosti v vrstnem redu, ki je povzročil več tihih napak kot karkoli drugega v besedilnem API-ju — levo, desno, spodaj, zgoraj, in ne v vrstnem redu sistema Windows (levo, zgoraj, desno, spodaj). Območja poroča v prostoru strani: v PDF točkah, kjer 72 točk predstavlja en palec (inch), izhodišče pa je v spodnjem levem kotu, pri čemer se Y os povečuje navzgor. Območje besede predstavlja unijo območij njenih znakov, pretvorba v piksle naprave pa poteka v treh korakih: translacija s pomočjo izhodišča strani, skaliranje s faktorjem povečave pomnoženim z DPI zaslona, deljeno z 72, ter obrat 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;
Spremenljivka FPageTop je višina strani v točkah, ki jo dobimo z FPDF_GetPageHeight, vrednost FPageLeft pa je za večino dokumentov nič, vendar pride iz območja obrezovanja (crop box), če ga stran definira. Zato obe vrednosti raje preberite z uporabo FPDF_GetPageBoundingBox, kot da bi ju predpostavljali. Pri obračanju Y osi se pogosto pojavijo napake pri ročno napisanih implementacijah: zgornji del pravokotnika naprave izhaja iz vrha območja PDF, merjeno navzdol od vrha strani. Če se tukaj zmotite, bo vsaka osvetlitev zrcalno narisana na napačni polovici strani
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;
V programu za risanje se najprej vedno izriše bitna slika strani in nato osvetlitev, tako da prekritju nikoli ni treba izbrisati samega sebe. Z razveljavitvijo starih in novih pravokotnikov območje za ponovno risanje ostane majhno tudi pri visokih hitrostih govora. Spremenljivka FHighlightBrush je element TBitmap v velikosti ena krat ena, ki je ob zagonu zapolnjen z barvo osvetlitve — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF za jantarno barvo — katero AlphaBlend raztegne preko ciljnega pravokotnika, tako da se na posamezen okvir (frame) ne dodeli nič novega. Vrednost SourceConstantAlpha, nastavljena na 96, pa ohranja besedo čitljivo kljub obarvanju. Preverite barvo pri obrnjenih in visoko kontrastnih načinih prikaza zaslona; prekritje, ki ga slaboviden uporabnik ne vidi, za osebo, kateri je bilo namenjeno, ne obstaja
Vrstni red branja je del, ki ga besedilni API ne bo rešil
Metoda FPDFText_GetText vrne znake v vrstnem redu, pridobljenem iz podatkovnega toka vsebine in z določenim prostorskim čiščenjem, za enostolpčno poročilo pa je ta vrstni red primeren. Kjerkoli drugje pa ni nujno, da je pravilen. Glasilo v dveh stolpcih se lahko prebere ravno čez oba stolpca, stranska vrstica lahko prekine stavek v sredini, noga pa se lahko pojavi sredi strani. Podatek, ki to popravi — logično drevo strukture (logical structure tree) po ISO 32000-1 §14.8, ki ga nosijo označeni dokumenti PDF in ga PDF/UA določa kot obveznega — s strani surovih klicev za besedilo strani sploh ni upoštevan. Če potrebujete vrstni red, ki upošteva strukturo, z eksplicitnim signalom njegovega izvora, je to že rešen problem na višji ravni: API za branje v PDFium Component vrne vsebino s poljem Source kot rosStructure ali rosHeuristic, članek o dostopnem bralniku PDF pa se sprehodi skozi njega. Na ravni surovega API-ja pa se priporoča, da red ekstrakcije obravnavate kot oceno, to v uporabniškem vmesniku tudi omenite, poleg tega pa obdržite en večstolpčni dokument in en optično prebran dokument zgolj s sliko v regresijskem setu, da bosta oba načina napak ostala vidna
Sam pregledovalnik mora biti upravljan s tipkovnico
Govorni izhod ne opravičuje, da pregledovalnik nima dostopa s tipkovnico; ljudje, pri katerih je najbolj verjetno, da bodo uporabili funkcijo branja na glas, bodo najmanj verjetno posegli po miški. Stranski plošči dodelite vrednost TabStop := True in ji dajte viden pravokotnik za fokusiranje, nato pa upravljajte s tremi tipkami: preslednica preklaplja med FVoice.Pause in FVoice.Resume, leva in desna tipka pa preskočita z metodo FVoice.Skip('Sentence', 1) in njenim negativnim številcem za nazaj. SAPI-jeva metoda Skip razume zgolj ločljivost na ravni stavkov, zato preskakovanje besed pomeni brisanje predvajanja prek SVSFPurgeBeforeSpeak in ponovno izgovarjanje od zamika besede, ki ste mu nazadnje sledili — kar pa je poceni funkcija, saj koda za označevanje ta zamik že shranjuje. Poskrbite, da je vsak nadzor premikanja pravi gumb (TButton) z napisom, da ga bodo bralniki zaslona lahko oznanili
To je celoten cevovod in ves je zasnovan na surovem besedilnem API-ju PDFium: govorna nit, ki obravnava COM in ima nadzor nad glasom do konca življenjske dobe aplikacije, dogodki meja, preslikani v uporabniški vmesnik kot zamiki znakov, in območja prostora strani na ravni znakov, spremenjena v en zlit pravokotnik na zaslonu. Če ne želite sami upravljati z geometrijo in sledenjem, PDFium Component ponuja lastnosti komponent z območji posameznih besed, kazalec za sledenje, samodejno drsenje ob branju (auto-scroll) in bralne enote na ravni stavkov. Poleg tega njena demonstracijska aplikacija za branje na glas preoblikuje cevovod iz tega članka v zgolj peščico klicev