En uppläsningsknapp kan demonstreras på en eftermiddag och sedan sluka en hel vecka. Eftermiddagsversionen extraherar sidtexten, överlämnar den till SAPI och får ljud. Veckan går åt till det som gör funktionen användbar: rösten får inte frysa fönstret, det talade ordet måste lysa upp på sidan i takt med ljudet, och mellanslagstangenten måste pausa alltsammans. Den här artikeln bygger den pipelinen i Delphi mot det råa PDFium text-API:et och Windows Speech API, med fungerande kod för de tre delarna som den snabba versionen hoppar över: COM-livscykel utförd en gång istället för per yttrande, riktiga ordgränshändelser och koordinatmatematiken som förvandlar en ordboundary-box i PDF-rymd till en rektangel som du kan måla
Det regulatoriska sammanhanget ryms i en mening: synkroniserad uppläsning är visarsidans hälft av vad WCAG 2.1 kräver av dokumentprogramvara, och ISO 14289-1 (PDF/UA) definierar den taggade filens hälft som det fungerar bäst mot. Om du bygger på PDFium Component kanske du inte alls behöver denna pipeline: visaren levereras med en inbyggd spårningsmarkör som mappar en teckenförskjutning till en målad ordmarkering i ett enda anrop, vilket behandlas i artikeln om ord-för-ord TTS-markering. Det som följer är till för när du äger hela visarapplikationen och vill ha själva pipelinen
En tråd renderar, en tråd talar
Arkitekturen består av två trådar och ett kontrakt. UI-tråden renderar sidans bitmapp, äger zoom- och rullningsstatus och målar markeringsöverlägget. En dedikerad taltråd äger SAPI-rösten, och inget annat rör den. Kontraktet är tunt: taltråden rapporterar framsteg som teckenförskjutningar, och UI-tråden förvandlar förskjutningar till rektanglar
De flesta SAPI-exempel slår in varje yttrande i CoInitialize och CoUninitialize, och en visare visar genast varför det är fel. Speak med SVSFlagsAsync returnerar så fort texten har lagts i kö, så ett CoUninitialize i samma procedurs finally-block körs medan rösten fortfarande talar, och river ner COM-lägenheten som äger den. Beroende på tajming får du tystnad, ett stympat yttrande eller en åtkomstöverträdelse (access violation) minuter senare. Den korrekta livscykeln är tråkig: CoInitialize en gång när taltråden startar, skapa rösten inuti den lägenheten och CoUninitialize en gång när tråden avslutas, efter att rösten har frigjorts. Aldrig per yttrande
Rösten behöver också en meddelandepump (message pump), vilket avgör var den kan bo. Automationsobjektet SpVoice levererar sina händelser genom meddelandekön för tråden som skapade det. Skapa den på UI-tråden och händelser anländer, eftersom VCL pumpar meddelanden, men varje långsam uppritning försenar då dina ordgränser; skapa den på en arbetstråd utan pump och händelserna anländer aldrig överhuvudtaget. En dedikerad tråd med en egen GetMessage-loop håller gränslatensen platt oavsett vad användargränssnittet 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 postar WM_QUIT så att pumpen avblockeras när visaren stängs ner. SpeakPage, anropad från UI-tråden, lagrar texten i ett låsskyddat fält och postar WM_SPEAK_PAGE, eftersom att anropa en metod på FVoice direkt från en annan tråd skulle vara ett COM-anrop över lägenheter (cross-apartment) på ett omarshalerat gränssnitt. Den enda raden PeekMessage innan loopen tvingar Windows att skapa trådens meddelandekö, och stänger startkapplöpningen (startup race) där en tidig postning från UI-tråden skulle misslyckas
Ordgränser anländer som teckenförskjutningar
Importera Microsoft Speech Object Library en gång genom IDE:ns typbiblioteksimporterare (type library importer) så får du SpeechLib_TLB med TSpVoice-omslaget och dess typade händelser. Två inställningar är viktiga. EventInterests bör begränsas till de händelser du faktiskt konsumerar, eftersom varje intresse som lämnas påslaget är händelsetrafik över trådarna för varje ord på varje sida; SVEWordBoundary driver markeringen och SVEEndInputStream talar om för dig att yttrandet avslutats. Och händelsehanteraren OnWord tar emot CharacterPosition och en längd, som indexerar till exakt den sträng du skickade till Speak — en förskjutning in i talbufferten, inte in i något annat
Den sista satsen är den invariant som funktionen hänger på: förskjutningar (offsets) är endast meningsfulla mot den sträng som rösten läser, så läs upp exakt den text du extraherade, tecken för tecken. Trimma blanksteg, fäll ihop radbrytningar eller expandera en förkortning för trevligare uttal, så landar varje markering efter den första redigeringen ett ord fel. Om användargränssnittet måste injicera talat material — sidmeddelanden, rubrikprefix — registrera varje infognings position och längd, och subtrahera den ackumulerade förskjutningen från varje offset innan du mappar den
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 är rätt marshaller (marshal) här, inte Synchronize: hanteraren får inte parkera taltråden medan användargränssnittet ritas om, och om gränshändelser anländer snabbare än skärmen hinner rita, är en inaktuell markeringsuppdatering ofarlig eftersom nästa skriver över den. Koppla OnEndStream på samma sätt för att rensa markeringen, och i ett läge med kontinuerlig läsning, för att ladda nästa sidas text och posta nästa yttrande
Från teckenförskjutningar till pixlar på skärmen
PDFium rapporterar geometri per tecken. FPDFText_GetCharBox fyller fyra doubles i en ordning som har orsakat fler tysta buggar än något annat i text-API:et — vänster, höger, botten, topp, inte Windows vänster, topp, höger, botten — och den rapporterar dem i sidrymd (page space): PDF-punkter, 72 per tum, ursprung i det nedre vänstra hörnet med Y som växer uppåt. Ett ords box är unionen av dess teckens boxar, och transformationen till enhetspixlar sker i tre steg: flytta utifrån sidans ursprung, skala med zoom gånger skärmens DPI över 72, och vänd Y-axeln (flip)
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 är sidhöjden i punkter från FPDF_GetPageHeight, och FPageLeft är noll för de flesta dokument men kommer från beskärningsrutan (crop box) när sidan definierar en, så läs båda från FPDF_GetPageBoundingBox istället för att anta något. Y-vändningen (Y flip) är där handrullade versioner går sönder: toppen av enhetsrektangeln kommer från toppen av PDF-boxen mätt nedåt från sidans topp. Får du det baklänges målas varje markering spegelvänd till fel halva av sidan
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;
Ritningshanteraren ritar sidans bitmapp först och markeringen därefter, varje gång, så överlägget behöver aldrig sudda ut sig självt; att ogiltigförklara (invalidate) den gamla och den nya rektangeln håller omritningsområdet litet även vid snabba talhastigheter. FHighlightBrush är en ett-gånger-ett TBitmap som fylls i en gång vid start med markeringsfärgen — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF för bärnsten — som AlphaBlend sträcker ut över målrektangeln, så att inget allokeras per bildruta, och SourceConstantAlpha på 96 håller ordet läsligt genom färgtonen. Testa färgen under inverterade och högkontrast-visningslägen; ett överlägg som en användare med nedsatt syn inte kan se existerar inte för exakt den person det byggdes för
Läsordning är den del som text-API:et inte kommer att lösa
FPDFText_GetText returnerar tecken i en ordning som härleds från innehållsströmmen med viss rumslig rensning, och för en enspaltsrapport är den ordningen bra. Den har ingen skyldighet att vara rätt någon annanstans. Ett nyhetsbrev i två spalter kan läsas rakt över båda spalterna, en sidorubrik kan avbryta en mening mitt i satsen, och en sidfot kan dyka upp mitt på sidan. Informationen som fixar detta — det logiska strukturträdet i ISO 32000-1 §14.8, som taggade PDF-filer bär och som PDF/UA gör obligatoriskt — konsulteras inte alls av de råa textsidanropen. Om du behöver strukturmedveten ordning med en uttrycklig signal om dess ursprung, är det ett löst problem en hylla upp: PDFium Components läs-API returnerar innehåll med ett Source-fält på rosStructure eller rosHeuristic, och artikeln om tillgänglig PDF-läsare går igenom det. På den råa API-nivån är den försvarbara hållningen att behandla extraheringsordningen som en uppskattning, säga detta i användargränssnittet och behålla ett dokument med flera kolumner och en bild-endast-skanning i regressionssetet så att båda fellägena förblir synliga
Själva visaren måste kunna styras med tangentbordet
Talutmatning befriar inte visaren från tangentbordsåtkomst; de personer som mest sannolikt använder uppläsning är de som minst sannolikt sträcker sig efter en mus. Ge sidpanelen TabStop := True och en synlig fokusrektangel, hantera sedan tre tangenter: Mellanslag växlar mellan FVoice.Pause och FVoice.Resume, och Vänster och Höger hoppar igenom FVoice.Skip('Sentence', 1) med ett negativt antal för att gå tillbaka. SAPI:s Skip förstår bara meningsgranularitet, så hopp på ordnivå innebär att rensa uppspelningen med SVSFPurgeBeforeSpeak och läsa upp igen från offseten för det ord du senast spårade — billigt, eftersom markeringskoden redan lagrar exakt den offseten. Håll varje transportkontroll som en riktig TButton med en bildtext så att skärmläsare tillkännager den
Det är hela pipelinen, alltihop mot det råa PDFium text-API:et: en taltråd som äger COM och rösten under hela appens livslängd, gränshändelser marshalerade till användargränssnittet som teckenförskjutningar, och rymdboxar per tecken i sidorutan omvandlade till en blandad rektangel på skärmen. Om du hellre slipper äga geometrin och spårningen själv, levereras PDFium Component med ordbaserade rutor, spårningsmarkör, följning med automatisk rullning och läsenheter på meningsnivå som komponentegenskaper, och dess uppläsningsdemo är den här artikelns pipeline reducerad till en handfull anrop