En høytlesingsknapp kan demonstreres på en ettermiddag og deretter sluke en hel uke. Ettermiddagsversjonen trekker ut sideteksten, overleverer den til SAPI, og får lyd. Uken går med til det som gjør funksjonen brukbar: stemmen må ikke fryse vinduet, det talte ordet må lyse opp på siden i takt med lyden, og mellomromstasten må kunne sette det hele på pause. Denne artikkelen bygger denne pipelinen i Delphi mot det rå PDFium-tekst-API-et og Windows Speech API, med fungerende kode for de tre delene den raske versjonen hopper over: COM-livssyklus utført én gang i stedet for per ytring, ekte ordgrensehendelser, og koordinatmatematikken som gjør en ord-boks i PDF-rom om til et rektangel du kan tegne
Den regulatoriske konteksten kan oppsummeres i én setning: synkronisert høytlesing er leser-siden av det WCAG 2.1 krever av dokumentprogramvare, og ISO 14289-1 (PDF/UA) definerer fil-siden den fungerer best med (tagged-filer). Hvis du bygger på PDFium Component, trenger du kanskje ikke denne pipelinen i det hele tatt: leseren leveres med en innebygd sporingsmarkør som tilordner en tegnforskyvning til en malt ordutheving i ett kall, dekket i artikkelen om ord-for-ord TTS-utheving. Det som følger er for når du eier hele leserapplikasjonen og vil ha selve pipelinen
Én tråd gjengir, én tråd snakker
Arkitekturen består av to tråder og én kontrakt. UI-tråden gjengir side-punktgrafikken, eier zoom- og rulletilstand og tegner uthevingsoverlegget. En dedikert taletråd eier SAPI-stemmen, og ingenting annet berører den. Kontrakten er tynn: taletråden rapporterer fremdriften som tegnforskyvninger, og UI-tråden gjør forskyvningene om til rektangler
De fleste SAPI-eksempler pakker hver ytring i CoInitialize og CoUninitialize, og en leser viser hvorfor dette er feil umiddelbart. Speak med SVSFlagsAsync returnerer så snart teksten er i kø, slik at en CoUninitialize i samme prosedyres finally-blokk kjøres mens stemmen fortsatt snakker, og bryter ned COM-leiligheten (apartment) som eier den. Avhengig av timingen får du stillhet, en avkortet ytring eller et tilgangsbrudd (access violation) minutter senere. Den riktige levetiden er kjedelig: CoInitialize én gang når taletråden starter, opprett stemmen i den COM-leiligheten, og CoUninitialize én gang når tråden avsluttes, etter at stemmen er frigjort. Aldri per ytring
Stemmen trenger også en meldingspumpe, som bestemmer hvor den kan leve. Automasjonsobjektet SpVoice leverer hendelsene sine gjennom meldingskøen til tråden som opprettet det. Opprett det på UI-tråden, og hendelsene ankommer, fordi VCL pumper meldinger, men hver langsomme opptegning forsinker da ordgrensene dine; opprett det på en arbeidstråd uten pumpe, og hendelsene ankommer aldri i det hele tatt. En dedikert tråd med sin egen GetMessage-løkke holder grenseforsinkelsen flat uansett hva UI-et gjø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 sender WM_QUIT slik at pumpen frigjøres når leseren slås av. SpeakPage, kalt fra UI-tråden, lagrer teksten i et låst beskyttet felt og sender WM_SPEAK_PAGE, fordi det å kalle en metode på FVoice direkte fra en annen tråd ville være et COM-kall på tvers av leiligheter (cross-apartment) på et u-marshalled grensesnitt. Den ene linjen med PeekMessage før løkken tvinger Windows til å opprette trådens meldingskø, og lukker dermed oppstartskappløpet der en tidlig post fra UI-tråden ville ha mislyktes
Ordgrenser ankommer som tegnforskyvninger
Importer Microsoft Speech Object Library én gang gjennom IDE-ens typebibliotekimportør, og du får SpeechLib_TLB med TSpVoice-innpakningen og dens typede hendelser. To innstillinger er viktige. EventInterests bør snevres inn til de hendelsene du faktisk bruker, fordi hver interesse som forblir slått på, er hendelsestrafikk på tvers av tråder for hvert ord på hver side; SVEWordBoundary driver uthevingen og SVEEndInputStream forteller deg at ytringen er ferdig. Og OnWord-håndtereren mottar CharacterPosition og en lengde, som indekserer inn i den nøyaktige strengen du sendte til Speak — en forskyvning inn i talebufferen, ikke inn i noe annet
Den siste setningen er invarianten som funksjonen avhenger av: forskyvninger er kun meningsfulle mot strengen stemmen leser, så les nøyaktig den teksten du trakk ut, tegn for tegn. Trimme mellomrom, slå sammen linjeskift, eller utvide en forkortelse for finere uttale, fører til at hver utheving etter den første redigeringen havner ett ord feil. Hvis UI-et må injisere talt materiale — sidekunngjøringer, overskriftsprefikser — registrer posisjonen og lengden for hver innsetting, og trekk fra det akkumulerte skiftet fra hver forskyvning før du tilordner det
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 riktig marshalering her, ikke Synchronize: håndtereren må ikke parkere taletråden mens UI-et tegnes på nytt, og hvis grensehendelser ankommer raskere enn skjermen tegner, er en gammel uthevingsoppdatering ufarlig fordi den neste overskriver den. Koble OnEndStream på samme måte for å fjerne uthevingen, og i en modus for kontinuerlig lesing, for å laste inn neste sides tekst og poste neste ytring
Fra tegnforskyvninger til piksler på skjermen
PDFium rapporterer geometri per tegn. FPDFText_GetCharBox fyller fire doubles i en rekkefølge som har forårsaket flere stille feil enn noe annet i tekst-API-et — venstre, høyre, bunn, topp, ikke Windows-måten venstre, topp, høyre, bunn — og den rapporterer dem i siderom: PDF-punkter, 72 per tomme, origo i nedre venstre hjørne med Y-aksen voksende oppover. En ord-boks er foreningen av tegnenes bokser, og transformasjonen til enhetspiksler skjer i tre trinn: oversett (translate) med sideorigo, skaler med zoom ganger skjerm-DPI delt på 72, og vend Y-aksen
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øyden i punkter fra FPDF_GetPageHeight, og FPageLeft er null for de fleste dokumenter, men kommer fra beskjæringsboksen (crop box) når siden definerer en, så les begge fra FPDF_GetPageBoundingBox i stedet for å anta. Y-vendingen er der håndskrevne versjoner knekker: toppen av enhetsrektangelet kommer fra toppen av PDF-boksen målt nedover fra sidetop. Gjør du det bakvendt, vil hver utheving males speilvendt inn i feil halvdel av 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;
Tegne-håndtereren tegner side-punktgrafikken først og uthevingen etterpå, hver gang, slik at overlegget aldri trenger å slette seg selv; ugyldiggjøring av de gamle og nye rektanglene holder det opptegnede området lite selv ved raske talehastigheter. FHighlightBrush er et én-til-én TBitmap fylt én gang ved oppstart med uthevingsfargen — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF for en ravfarge (amber) — som AlphaBlend strekker over målrektangelet, slik at ingenting allokeres per ramme, og SourceConstantAlpha på 96 holder ordet lesbart gjennom fargetonen. Test fargen under inverterte og høykontrast visningsmoduser; et overlegg en svaksynt bruker ikke kan se, eksisterer ikke for nøyaktig den personen det ble bygget for
Leserekkefølge er delen tekst-API-et ikke vil løse
FPDFText_GetText returnerer tegn i en rekkefølge utledet fra innholdsstrømmen med litt romlig opprydding, og for en enkolonners rapport er den rekkefølgen grei. Den har ingen forpliktelse til å være riktig noe annet sted. Et nyhetsbrev med to kolonner kan leses rett over begge kolonnene, en sidefelt kan avbryte en setning midt i en setningsdel, og en bunntekst kan dukke opp midt på siden. Informasjonen som fikser dette — det logiske strukturtreet i ISO 32000-1 §14.8, som tagged-PDF-er bærer og PDF/UA gjør obligatorisk — blir ikke konsultert av de rå tekst-side-kallene i det hele tatt. Hvis du trenger strukturbevisst rekkefølge med et eksplisitt signal om dens opprinnelse, er det et løst problem en hylle opp: PDFium Components lese-API returnerer innhold med et Source-felt på enten rosStructure eller rosHeuristic, og artikkelen om tilgjengelig PDF-leser går gjennom det. På det rå API-nivået er den forsvarlige posisjonen å behandle uttrekkingsrekkefølgen som et estimat, si dette i UI-et, og beholde ett dokument med flere kolonner og én skanning kun bestående av bilde i regresjonssettet, slik at begge feilmoduser forblir synlige
Selve leseren må kunne betjenes med tastatur
Taleutdata unnskylder ikke leseren fra tastaturtilgang; personene som mest sannsynlig vil bruke høytlesing, er de som minst sannsynlig griper etter en mus. Gi sidepanelet TabStop := True og et synlig fokusrektangel, og håndter deretter tre taster: Mellomromstasten (Space) bytter mellom FVoice.Pause og FVoice.Resume, og Venstre og Høyre hopper gjennom med FVoice.Skip('Sentence', 1) med et negativt tall for å gå tilbake. SAPIs Skip forstår bare setningsgranularitet, så hopping på ordnivå betyr å tømme avspillingen med SVSFPurgeBeforeSpeak og snakke på nytt fra forskyvningen til ordet du sist sporet — noe som er billig, ettersom uthevingskoden allerede lagrer nøyaktig denne forskyvningen. Hold hver transportkontroll som en ekte TButton med en bildetekst (caption) slik at skjermlesere kunngjør det
Dette er hele pipelinen, alt sammen mot det rå PDFium-tekst-API-et: en taletråd som eier COM og stemmen gjennom hele appens levetid, grensehendelser marshalert til UI-et som tegnforskyvninger, og tegnbaserte siderom-bokser omgjort til ett blandet rektangel på skjermen. Hvis du heller ikke vil eie geometrien og sporingen selv, leveres PDFium Component med ord-for-ord-bokser, sporingsmarkøren, automatisk rulling som følger med, og setningsnivå-leseenheter som komponentegenskaper, og høytlesingsdemoen er denne artikkelens pipeline redusert til en håndfull kall