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; // læser 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); // sikkert fra UI-tråden
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // én gang, når tråden starter
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Tving oprettelse af denne tråds meddelelseskø, før nogen poster til den
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // afsluttes, når WM_QUIT ankommer
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // leverer SAPI-hændelses-callbacks
finally
FVoice.Free;
end;
finally
CoUninitialize; // én gang, når tråden afsluttes
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // afbloker 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
// Kører på taletråden; giv forskydningerne til UI'et uden at blokere
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-punkter, oprindelse nederst til venstre
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
// Parameterrækkefølgen er venstre, højre, nederst, øverst - ikke Windows-rækkefølgen
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-punkter pr. tomme; FZoom er fremviserens skalafaktor
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// PDF Y vokser opad fra nederste kant; enheds-Y vokser nedad
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); // slet det gamle ord
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // tegn det nye
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // gengivet side først, altid
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // omkring 38 procents opacitet
Blend.AlphaFormat := 0; // konstant alfa, ingen 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