Een voorleesknop kun je in een middag demonstreren, en vervolgens kost het je een week. De middagversie extraheert de paginatekst, geeft deze door aan SAPI en krijgt audio terug. De week gaat zitten in wat de functie bruikbaar maakt: de stem mag het venster niet laten vastlopen, het gesproken woord moet synchroon met de audio op de pagina oplichten, en de spatiebalk moet het geheel kunnen pauzeren. Dit artikel bouwt die pijplijn op in Delphi met de ruwe PDFium-tekst-API en de Windows Speech-API, met werkende code voor de drie onderdelen die de snelle versie overslaat: een COM-levenscyclus die één keer wordt uitgevoerd in plaats van per uiting, echte woordgrensgebeurtenissen, en de coördinatenwiskunde die een PDF-ruimte woordkader omzet in een rechthoek die je kunt tekenen
De regelgevende context past in één zin: gesynchroniseerd voorlezen is de viewer-kant van wat WCAG 2.1 vraagt van documentsoftware, en ISO 14289-1 (PDF/UA) definieert de getagde-bestanden-kant waar het het beste mee werkt. Als u voortbouwt op PDFium Component, heeft u deze pijplijn mogelijk helemaal niet nodig: de viewer wordt geleverd met een ingebouwde trackingcursor die een tekenoffset met één aanroep toewijst aan een getekende woordmarkering, wat wordt behandeld in het artikel over woord-voor-woord TTS-markering. Wat volgt is voor wanneer u de volledige viewer-applicatie bezit en de pijplijn zelf wilt bouwen
Één thread rendert, één thread spreekt
De architectuur bestaat uit twee threads en één contract. De UI-thread rendert de paginabitmap, beheert de zoom- en scrollstatus en tekent de markerings-overlay. Een toegewijde spraakthread is eigenaar van de SAPI-stem, en niets anders raakt deze aan. Het contract is dun: de spraakthread rapporteert voortgang als tekenoffsets, en de UI-thread zet deze offsets om in rechthoeken
De meeste SAPI-voorbeelden verpakken elke uiting in CoInitialize en CoUninitialize, en een viewer laat direct zien waarom dat verkeerd is. Speak met SVSFlagsAsync keert terug zodra de tekst in de wachtrij is geplaatst, dus een CoUninitialize in het finally-blok van dezelfde procedure wordt uitgevoerd terwijl de stem nog spreekt, waardoor het COM-appartement dat de eigenaar is, wordt afgebroken. Afhankelijk van de timing krijg je stilte, een afgekapte uiting of minuten later een toegangsfout. De correcte levenscyclus is saai: één keer CoInitialize wanneer de spraakthread start, de stem in dat appartement creëren, en één keer CoUninitialize wanneer de thread afsluit, nadat de stem is vrijgegeven. Nooit per uiting
De stem heeft ook een message pump nodig, wat bepaalt waar deze kan leven. Het SpVoice automatiseringsobject levert zijn gebeurtenissen af via de berichtenwachtrij van de thread die het heeft gemaakt. Maak je het aan in de UI-thread, dan komen er wel gebeurtenissen binnen, omdat de VCL berichten pompt, maar elke trage verversing (paint) vertraagt dan je woordgrenzen; maak je het aan in een worker-thread zonder pump, dan komen de gebeurtenissen helemaal nooit aan. Een toegewijde thread met een eigen GetMessage-lus houdt de grens-latentie vlak, ongeacht wat de UI doet
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 plaatst WM_QUIT zodat de pump deblokkeert wanneer de viewer wordt afgesloten. SpeakPage, aangeroepen vanuit de UI-thread, slaat de tekst op in een door een lock beveiligd veld en plaatst WM_SPEAK_PAGE, omdat het direct aanroepen van een methode op FVoice vanuit een andere thread een cross-apartment COM-aanroep op een ongemarshalde interface zou zijn. De PeekMessage van één regel vóór de lus dwingt Windows om de berichtenwachtrij van de thread aan te maken, wat de opstartracevoorwaarde sluit waarbij een vroege post-aanroep vanuit de UI-thread zou falen
Woordgrenzen komen aan als tekenoffsets
Importeer de Microsoft Speech Object Library één keer via de type library importer van de IDE en je krijgt SpeechLib_TLB met de TSpVoice-wrapper en de bijbehorende getypeerde gebeurtenissen. Twee instellingen zijn van belang. EventInterests moet worden beperkt tot de gebeurtenissen die je daadwerkelijk gebruikt, omdat elke interesse die ingeschakeld blijft cross-thread eventverkeer is voor elk woord van elke pagina; SVEWordBoundary stuurt de markering aan en SVEEndInputStream vertelt je dat de uiting is voltooid. En de OnWord-handler ontvangt CharacterPosition en een lengte, die indexeren in precies de string die je aan Speak hebt doorgegeven — een offset in de spraakbuffer, niet in iets anders
Die laatste zin is de invariant waar de functie op leunt: offsets hebben alleen betekenis ten opzichte van de string die de stem aan het lezen is, dus spreek exact de tekst uit die je hebt geëxtraheerd, teken voor teken. Verwijder spaties, voeg regeleinden samen, of breid een afkorting uit voor een mooiere uitspraak, en elke markering na de eerste bewerking verschuift een woord. Als de UI gesproken materiaal moet injecteren — pagina-aankondigingen, kop-voorvoegsels — registreer dan de positie en lengte van elke invoeging en trek de opgebouwde verschuiving af van elke offset voordat je deze toewijst
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 is hier de juiste marshal, niet Synchronize: de handler mag de spraakthread niet pauzeren terwijl de UI zichzelf opnieuw tekent, en als grensgebeurtenissen sneller aankomen dan het scherm kan tekenen, is een verouderde markeringsupdate ongevaarlijk omdat de volgende deze overschrijft. Koppel OnEndStream op dezelfde manier om de markering te wissen, en om in een continue leesmodus de tekst van de volgende pagina te laden en de volgende uiting te plaatsen
Van tekenoffsets naar pixels op het scherm
PDFium rapporteert geometrie per teken. FPDFText_GetCharBox vult vier doubles in een volgorde die meer stille bugs heeft veroorzaakt dan wat dan ook in de text API — links, rechts, onder, boven, niet de Windows links, boven, rechts, onder — en het rapporteert deze in de pagina-ruimte: PDF-punten, 72 per inch, oorsprong in de hoek linksonder, waarbij Y naar boven groeit. Het kader van een woord is de samenvoeging van de kaders van de tekens, en de transformatie naar apparaatpixels bestaat uit drie stappen: vertalen naar de paginakorsprong, schalen door de zoom te vermenigvuldigen met de scherm-DPI gedeeld door 72, en de Y-as omkeren
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 is de paginahoogte in punten via FPDF_GetPageHeight, en FPageLeft is nul voor de meeste documenten maar is afkomstig van de crop box als de pagina er een definieert, dus lees ze beide uit met FPDF_GetPageBoundingBox in plaats van aannames te doen. De Y-omkering is waar zelfgeschreven versies de mist in gaan: de bovenkant van de apparaatrechthoek komt van de bovenkant van het PDF-kader gemeten vanaf de bovenkant van de pagina naar beneden. Doe het verkeerd om en elke markering wordt gespiegeld in de verkeerde helft van de pagina getekend
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;
De paint-handler tekent altijd eerst de paginabitmap en daarna de markering, zodat de overlay zichzelf nooit hoeft te wissen; het ongeldig maken van de oude en nieuwe rechthoeken houdt de hertekeningsregio klein, zelfs bij snelle spraaksnelheden. FHighlightBrush is een één-op-één TBitmap die één keer bij het opstarten wordt gevuld met de markeringskleur — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF voor amber — die door AlphaBlend over de doelrechthoek wordt uitgerekt, zodat er niets per frame wordt toegewezen, en SourceConstantAlpha op 96 houdt het woord leesbaar door de tint heen. Test de kleur onder omgekeerde en hoogcontrast weergavemodi; een overlay die een slechtziende gebruiker niet kan zien, bestaat niet voor precies de persoon voor wie deze is gebouwd
Leesvolgorde is het onderdeel dat de text API niet zal oplossen
FPDFText_GetText geeft tekens terug in een volgorde die is afgeleid van de content stream met enige ruimtelijke opschoning, en voor een enkelkoloms rapport is die volgorde prima. Het heeft geen enkele verplichting om ergens anders correct te zijn. Een nieuwsbrief met twee kolommen kan recht over beide kolommen worden voorgelezen, een zijbalk kan een zin halverwege onderbreken, en een voettekst kan in het midden van de pagina verschijnen. De informatie die dit oplost — de logische structuurboom van ISO 32000-1 §14.8, die getagde PDF's bevatten en die door PDF/UA verplicht wordt gesteld — wordt door de ruwe tekst-pagina-aanroepen helemaal niet geraadpleegd. Als u structuurbewuste volgorde nodig heeft met een expliciet signaal van de oorsprong ervan, is dat een opgelost probleem een trede hoger: de lees-API van PDFium Component retourneert inhoud met een Source-veld van rosStructure of rosHeuristic, en het artikel over toegankelijke PDF-readers neemt dit door. Op het ruwe API-niveau is het te verdedigen standpunt om de extractievolgorde als een schatting te behandelen, dit in de UI te vermelden, en één document met meerdere kolommen en één alleen-afbeelding-scan in de regressieset te houden, zodat beide storingsmodi zichtbaar blijven
De viewer zelf moet via het toetsenbord te bedienen zijn
Spraakuitvoer stelt de viewer niet vrij van toetsenbordtoegang; de mensen die het meest waarschijnlijk voorlezen zullen gebruiken, zijn het minst geneigd om naar een muis te grijpen. Geef het paginapaneel TabStop := True en een zichtbare focusrechthoek, en handel dan drie toetsen af: de spatiebalk schakelt tussen FVoice.Pause en FVoice.Resume, en de pijlen naar links en rechts bladeren door middel van FVoice.Skip('Sentence', 1) met een negatieve telling om terug te gaan. De Skip-functie van SAPI begrijpt alleen zinsgranulariteit, dus overslaan op woordniveau betekent het afspelen wissen met SVSFPurgeBeforeSpeak en opnieuw spreken vanaf de offset van het woord dat u als laatste hebt gevolgd — wat goedkoop is, aangezien de markeringscode precies die offset al opslaat. Houd elk besturingselement voor transport een echte TButton met een bijschrift zodat schermlezers het aankondigen
Dat is de hele pijplijn, volledig tegen de ruwe PDFium-tekst-API: een spraakthread die de eigenaar is van COM en de stem gedurende de levensduur van de app, grensgebeurtenissen die als tekenoffsets naar de UI worden gemarshald, en paginaruimte-kaders per teken die in één samengevoegde rechthoek op het scherm worden omgezet. Als je liever de geometrie en tracking niet zelf wilt beheren, PDFium Component levert woord-voor-woord kaders, de trackingcursor, auto-scroll volgen, en leeseenheden op zinsniveau als componenteigenschappen, en de voorleesdemo ervan is de pijplijn uit dit artikel gereduceerd tot een handvol aanroepen