En højtlæsningsknap kan demonstreres på en eftermiddag og opsluger derefter en hel uge. Eftermiddagsversionen udtrækker sideteksten, overdrager den til SAPI og får lyd. Ugen går med det, der gør funktionen brugbar: stemmen må ikke fryse vinduet, det talte ord skal lyse op på siden i takt med lyden, og mellemrumstasten skal kunne sætte det hele på pause. Denne artikel bygger den pipeline i Delphi op mod den rå PDFium-tekst-API og Windows Speech-API'en, med fungerende kode til de tre dele, den hurtige version udelader: COM-livscyklus udført én gang i stedet for pr. ytring, virkelige ordgrænsehændelser og den koordinatmatematik, der forvandler en ord-boks i PDF-rummet til et rektangel, du kan tegne
Den lovgivningsmæssige kontekst passer i én sætning: synkroniseret højtlæsning er fremvisersidens halvdel af, hvad WCAG 2.1 kræver af dokumentsoftware, og ISO 14289-1 (PDF/UA) definerer den taggede fil-halvdel, den fungerer bedst op imod. Hvis du bygger på PDFium Component, har du måske slet ikke brug for denne pipeline: fremviseren leveres med en indbygget sporingmarkør, der kortlægger en tegnafstand til en malet ord-fremhævning i ét kald, dækket i artiklen om ord-for-ord TTS-fremhævning. Det følgende er til når du ejer hele fremviserapplikationen og ønsker selve pipelinen
Én tråd renderer, én tråd taler
Arkitekturen er to tråde og én kontrakt. UI-tråden renderer side-bitmappen, ejer zoom- og rulletilstand, og tegner fremhævningsoverlejringen. En dedikeret taletråd ejer SAPI-stemmen, og intet andet rører den. Kontrakten er tynd: taletråden rapporterer fremskridt som tegnafstande, og UI-tråden forvandler afstande til rektangler
De fleste SAPI-eksempler indpakker hver ytring i CoInitialize og CoUninitialize, og en fremviser viser omgående, hvorfor det er forkert. Speak med SVSFlagsAsync vender tilbage, så snart teksten er i kø, så en CoUninitialize i den samme procedures finally-blok kører, mens stemmen stadig taler, hvilket nedriver COM-lejligheden, der ejer den. Afhængigt af timingen får du stilhed, en afkortet ytring eller en adgangskrænkelse minutter senere. Den korrekte livscyklus er kedelig: CoInitialize én gang når taletråden starter, opret stemmen inde i den lejlighed, og CoUninitialize én gang når tråden afsluttes, efter at stemmen er blevet frigivet. Aldrig pr. ytring
Stemmen har også brug for en beskedpumpe, hvilket bestemmer, hvor den kan leve. Automatiserings-objektet SpVoice leverer sine hændelser gennem beskedkøen på den tråd, der oprettede det. Opret den på UI-tråden og hændelserne ankommer, fordi VCL pumper beskeder, men hver langsom tegning forsinker derefter dine ordgrænser; opret den på en arbejder-tråd uden pumpe, og hændelserne ankommer slet ikke. En dedikeret tråd med sin egen GetMessage-løkke holder grænseforsinkelsen flad, uanset hvad UI'en gør
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 poster WM_QUIT så pumpen låses op, når fremviseren lukker ned. SpeakPage, kaldet fra UI-tråden, gemmer teksten i et låsebeskyttet felt og poster WM_SPEAK_PAGE, fordi et opkald til en metode på FVoice direkte fra en anden tråd ville være et tværgående COM-kald på en u-marshaled grænseflade. Én-linje PeekMessage før løkken tvinger Windows til at oprette trådens beskedkø, hvilket lukker opstartsræset, hvor en tidlig post fra UI-tråden ville mislykkes
Ordgrænser ankommer som tegnafstande
Importer Microsoft Speech Object Library én gang gennem IDE'ens typebibliotek-importer, og du får SpeechLib_TLB med TSpVoice-indpakningen og dens typede hændelser. To indstillinger betyder noget. EventInterests bør indsnævres til de hændelser, du faktisk bruger, for hver interesse der lades være tændt, er tværgående hændelsestrafik for hvert ord på hver side; SVEWordBoundary driver fremhævningen, og SVEEndInputStream fortæller dig, at ytringen er afsluttet. Og OnWord-håndtereren modtager CharacterPosition og en længde, som indekserer ind i den nøjagtige streng, du sendte til Speak — en afstand ind i talebufferen, ikke ind i noget som helst andet
Den sidste klausul er den invariant, funktionen hviler på: afstande er kun meningsfulde i forhold til den streng, stemmen læser, så tal nøjagtigt den tekst du uddrog, tegn for tegn. Beskær mellemrum, fold linjeskift sammen, eller udvid en forkortelse for pænere udtale, og hver fremhævning efter den første redigering lander ét ord ved siden af. Hvis brugergrænsefladen skal injicere talt materiale — sidebekendtgørelser, overskriftspræfikser — skal du registrere hver indsættelses position og længde, og trække det akkumulerede skift fra hver afstand, før det kortlægges
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 er den rigtige marshal her, ikke Synchronize: håndtereren må ikke parkere taletråden, mens UI'en tegner om, og hvis grænsehændelser ankommer hurtigere end skærmen tegner, er en forældet fremhævningsopdatering harmløs, fordi den næste overskriver den. Forbind OnEndStream på samme måde for at rydde fremhævningen, og i en kontinuerlig-læsningstilstand, for at indlæse den næste sides tekst og poste den næste ytring
Fra tegnafstande til pixels på skærmen
PDFium rapporterer geometri pr. tegn. FPDFText_GetCharBox udfylder fire doubles i en rækkefølge, der har forårsaget flere stille fejl end noget andet i tekst-API'en — venstre, højre, bund, top, ikke Windows' venstre, top, højre, bund — og det rapporterer dem i siderummet: PDF-punkter, 72 til tommen, oprindelse i nederste venstre hjørne med Y voksende opad. Et ords boks er foreningen af dets tegns bokse, og transformationen til enhedspixels foregår i tre trin: oversæt ved sidens oprindelse, skaler med zoom gange skærmens DPI divideret med 72, og vend Y-aksen om
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 er sidehøjden i punkter fra FPDF_GetPageHeight, og FPageLeft er nul for de fleste dokumenter, men kommer fra beskæringsboksen (crop box), når siden definerer en, så læs begge fra FPDF_GetPageBoundingBox frem for at antage noget. Y-vendingen er dér, hvor håndrullede versioner går i stykker: toppen af enhedsrektanglet kommer fra toppen af PDF-boksen målt ned fra sidens top. Gør du det baglæns, vil hver fremhævning males spejlvendt i den forkerte halvdel af siden
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;
Tegnehåndtereren tegner side-bitmappen først og fremhævningen bagefter, hver gang, så overlejringen aldrig behøver at slette sig selv; validering af de gamle og nye rektangler holder gentegningsregionen lille selv ved hurtige talehastigheder. FHighlightBrush er et én-til-én TBitmap, der udfyldes én gang ved opstart med fremhævningsfarven — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF for en ravgul — som AlphaBlend strækker over målrektanglet, så der allokeres intet pr. frame, og SourceConstantAlpha på 96 holder ordet læseligt gennem farvetonen. Test farven under omvendte (inverted) og højkontrast-visningstilstande; en overlejring som en svagsynet bruger ikke kan se, eksisterer ikke for præcis den person, den blev bygget til
Læserækkefølgen er den del, tekst-API'en ikke vil løse
FPDFText_GetText returnerer tegn i en rækkefølge udledt fra indholdsstrømmen med noget rumlig oprydning, og for en enspaltet rapport er den rækkefølge fin. Den har ingen forpligtelse til at være rigtig andre steder. Et to-spaltet nyhedsbrev kan læses lige over begge spalter, en sidebjælke kan afbryde en sætning midt i en bisætning, og en sidefod kan ankomme midt på siden. Den information, der retter dette — det logiske strukturtræ ifølge ISO 32000-1 §14.8, som taggede PDF'er bærer, og som PDF/UA gør obligatorisk — konsulteres slet ikke af de rå tekst-side-kald. Hvis du har brug for strukturbevidst rækkefølge med et eksplicit signal om dens oprindelse, er det et løst problem en hylde højere oppe: PDFium Components læse-API returnerer indhold med et Source-felt på rosStructure eller rosHeuristic, og artiklen om den tilgængelige PDF-læser gennemgår det. På det rå API-niveau er den forsvarlige position at betragte udtrækningsrækkefølgen som et skøn, angive dette i brugergrænsefladen, og beholde ét flerspaltet dokument og én billede-kun scanning i regressionssættet, så begge fejltyper forbliver synlige
Selve fremviseren skal kunne betjenes med tastaturet
Taleuddata undskylder ikke fremviseren fra tastaturadgang; de mennesker, der mest sandsynligt vil bruge højtlæsning, er de mindst sandsynlige til at gribe efter en mus. Giv sidepanelet TabStop := True og et synligt fokusrektangel, og håndtér derefter tre taster: Mellemrum skifter mellem FVoice.Pause og FVoice.Resume, og Venstre og Højre springer gennem FVoice.Skip('Sentence', 1) med et negativt antal for at gå tilbage. SAPI's Skip forstår kun sætningsgranularitet, så ordniveau-springning betyder sletning af afspilning med SVSFPurgeBeforeSpeak og gentalelse fra forskydningen for det ord, du sidst sporede — billigt, da fremhævningskoden allerede lagrer præcis denne afstand. Hold enhver transportkontrol som en rigtig TButton med en overskrift, så skærmlæsere annoncerer det
Det er hele pipelinen, alt sammen imod den rå PDFium-tekst-API: en taletråd, der ejer COM og stemmen i appens levetid, grænsehændelser marshalles til UI'en som tegnafstande, og pr.-tegn sidetilstands-bokse forvandles til ét blandet rektangel på skærmen. Hvis du hellere ikke vil eje geometrien og sporingen selv, leveres PDFium Component med pr.-ord-bokse, sporingmarkøren, automatisk rulle-følge og sætningsniveau læseenheder som komponentegenskaber, og dens højtlæsnings-demo er denne artikels pipeline reduceret til en håndfuld opkald