Technisch artikel

Toegankelijke PDF-viewers bouwen met tekst-naar-spraak in Delphi

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