Odborný článok

Vytvorte si v Delphi prehliadač PDF s prístupnosťou pre hlasové čítanie pomocou Text-to-Speech

Tlačidlo na hlasové čítanie sa dá predviesť za jedno popoludnie a potom zožerie celý týždeň. Popoludňajšia verzia len vyextrahuje text stránky, odovzdá ho SAPI a získa späť zvuk. Ten týždeň padne na to, čo túto funkciu robí reálne použiteľnou: hlas nesmie zamraziť okno, vyslovené slovo sa musí rozsvietiť na stránke synchrónne so zvukom a kláves Medzerník musí to celé okamžite pozastaviť. Tento článok stavia toto prepojenie v Delphi nad surovým textovým API PDFia a nad Windows Speech API, s funkčným kódom pre tri časti, ktoré rýchla verzia vynecháva: životnosť COM riešenú raz namiesto pri každej vete, reálne udalosti hraníc slov (word-boundary events) a matematiku súradníc, ktorá premení box slova v priestore PDF na obdĺžnik, ktorý viete vykresliť

Regulačný kontext sa zmestí do jednej vety: synchronizované čítanie nahlas je tá polovica, ktorú od softvéru na dokumenty na strane prehliadača žiada WCAG 2.1, a ISO 14289-1 (PDF/UA) definuje polovicu na strane oštítkovaného súboru, proti ktorej to funguje najlepšie. Ak staviate na PDFium Component, možno toto prepojenie vôbec nepotrebujete: prehliadač už dodáva vstavaný sledovací kurzor, ktorý jediným volaním namapuje posun znaku na vykreslené zvýraznenie slova, čo pokrýva článok o zvýrazňovaní slovo za slovom pre TTS. Nasledujúci text je pre prípad, keď vlastníte celú aplikáciu prehliadača a chcete samotné toto prepojenie

Jedno vlákno vykresľuje, jedno vlákno rozpráva

Architektúra sú dve vlákna a jedna zmluva. UI vlákno vykresľuje bitmapu stránky, spravuje stav priblíženia a posúvania a maľuje prekrytie zvýraznenia. Vyhradené vlákno na reč vlastní hlas SAPI a nič iné sa ho nedotýka. Zmluva medzi nimi je tenká: vlákno na reč hlási postup ako posuny znakov (character offsets) a UI vlákno premieňa tieto posuny na obdĺžniky

Väčšina ukážok SAPI zabalí každú vetu do CoInitialize a CoUninitialize, a prehliadač okamžite ukáže, prečo je to nesprávne. Speak s SVSFlagsAsync sa vráti hneď, ako je text zaradený do fronty, takže CoUninitialize v bloku finally tej istej procedúry sa spustí ešte počas toho, ako hlas stále hovorí, a strhne tak COM apartment, ktorý ho vlastní. V závislosti od časovania dostanete ticho, orezanú vetu alebo access violation o pár minút neskôr. Správna životnosť je nudná: CoInitialize raz, keď sa vlákno na reč spustí, hlas vytvorte vo vnútri tohto apartmentu a CoUninitialize raz, keď sa vlákno ukončí, po tom, čo bol hlas uvoľnený. Nikdy nie pri každej vete

Hlas zároveň potrebuje pumpu na správy (message pump), ktorá rozhoduje, kde môže žiť. Automatizačný objekt SpVoice doručuje svoje udalosti cez frontu správ vlákna, ktoré ho vytvorilo. Vytvorte ho na UI vlákne a udalosti prídu, pretože VCL pumpuje správy, ale každé pomalé prekreslenie potom oneskorí vaše hranice slov; vytvorte ho na worker vlákne bez pumpy a udalosti neprídu vôbec. Vyhradené vlákno s vlastnou slučkou GetMessage udržuje latenciu hraníc konštantnú bez ohľadu na to, čo práve robí UI

Prehliadač PDF Delphi s nahlas čítaním s vyhradeným vláknom textu na reč SAPI vymieňajúcim ofsety hraníc slov s vláknom UI
Rečové vlákno vlastní COM a SpVoice po dobu života aplikácie a podáva offsety znakov UI vláknu, ktoré samo maľuje synchronizované zvýraznenie
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;   // číta FText pod 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);   // bezpečné volanie z UI vlákna
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // raz, keď sa vlákno spustí
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Vynúti vytvorenie frontu správ tohto vlákna skôr, než doň niekto niečo pošle
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // skončí, keď príde WM_QUIT
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // doručí callbacky udalostí SAPI
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // raz, keď sa vlákno ukončí
  end;
end;

procedure TSpeechThread.TerminatedSet;
begin
  inherited;
  PostThreadMessage(ThreadID, WM_QUIT, 0, 0);   // odblokuje GetMessage
end;

TerminatedSet pošle WM_QUIT, aby sa pumpa odblokovala, keď sa prehliadač zatvára. SpeakPage, volané z UI vlákna, uloží text do poľa chráneného zámkom a pošle WM_SPEAK_PAGE, pretože priame volanie metódy na FVoice z iného vlákna by bolo cross-apartment COM volanie na nemaršalovanom rozhraní. Jednoriadkové PeekMessage pred slučkou donúti Windows vytvoriť frontu správ vlákna, čím uzavrie štartovací pretek, pri ktorom by skoré odoslanie z UI vlákna zlyhalo

Hranice slov prichádzajú ako posuny znakov

Naimportujte knižnicu Microsoft Speech Object Library raz cez importér knižníc typov v IDE a získate SpeechLib_TLB s wrapperom TSpVoice a jeho typovanými udalosťami. Záležia na dvoch nastaveniach. EventInterests by malo byť zúžené len na udalosti, ktoré skutočne spracúvate, pretože každý ponechaný záujem je cross-thread prenos udalostí pre každé slovo každej stránky; SVEWordBoundary pohání zvýraznenie a SVEEndInputStream vám povie, že veta skončila. A obsluha OnWord dostáva CharacterPosition a dĺžku, ktoré indexujú presne do reťazca, ktorý ste odovzdali Speak — posun do bufferu reči, nie do niečoho iného

Posledná klauzula je invariant, na ktorom táto funkcia stojí: posuny majú zmysel iba voči reťazcu, ktorý hlas práve číta, takže hovorte presne ten text, ktorý ste extrahovali, znak po znaku. Orežte biele znaky, zlúčte zalomenia riadkov alebo rozpíšte skratku kvôli krajšej výslovnosti a každé zvýraznenie po prvej takejto úprave dopadne o slovo vedľa. Ak UI musí vkladať vyslovovaný materiál — oznámenia o stránke, prefixy nadpisov — zaznamenajte si pozíciu a dĺžku každého vloženia a od každého posunu pred jeho namapovaním odpočítajte nahromadený posun

Ofsety hraníc slov SAPI indexujúce presný textový buffer vyslovený prehliadačom PDF Delphi s textom na reč
Boundary udalosti indexujú ten istý buffer odovzdaný Speak, takže orezávanie, rozvíjanie skratiek alebo vstreknutie prefixov posunie každé ďalšie zvýraznenie o tú istú sumu
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
  // Beží na vlákne pre reč; posuny odovzdá UI bez blokovania
  TThread.Queue(nil,
    procedure
    begin
      ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
    end);
end;

TThread.Queue je tu správny spôsob maršalovania, nie Synchronize: obsluha nesmie zaparkovať vlákno pre reč, kým sa UI prekresľuje, a ak udalosti hraníc prichádzajú rýchlejšie, než sa obrazovka prekresľuje, zastarané zvýraznenie je neškodné, pretože ho prepíše ďalšie. Napojte rovnakým spôsobom aj OnEndStream, aby vyčistil zvýraznenie, a v režime plynulého čítania aj to, aby načítal text ďalšej stránky a poslal ďalšiu vetu

Z posunov znakov na pixely na obrazovke

PDFium hlási geometriu za každý znak zvlášť. FPDFText_GetCharBox naplní štyri hodnoty typu double v poradí, ktoré spôsobilo viac tichých chýb než čokoľvek iné v textovom API — vľavo, vpravo, dole, hore, nie windowsovské vľavo, hore, vpravo, dole — a hlási ich v priestore stránky: body PDF, 72 na palec, počiatok v ľavom dolnom rohu s Y rastúcim nahor. Box slova je zjednotením boxov jeho znakov a transformácia na pixely zariadenia sú tri kroky: posun o počiatok stránky, zmena mierky priblížením krát DPI obrazovky lomeno 72 a prevrátenie osi Y

uses
  System.Math;

type
  TPdfRectF = record
    Left, Top, Right, Bottom: Double;    // body PDF, počiatok vľavo dole
  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
    // Poradie parametrov je vľavo, vpravo, dole, hore - nie windowsovské poradie
    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 bodov PDF na palec; FZoom je mierka priblíženia prehliadača
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // Y v PDF rastie nahor od dolného okraja; Y zariadenia rastie nadol
  Result.Top    := Round((FPageTop - W.Top)    * Scale) - FScrollY;
  Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;

FPageTop je výška stránky v bodoch z FPDF_GetPageHeight a FPageLeft je pri väčšine dokumentov nula, ale pochádza z orezávacieho boxu (crop box), ak ho stránka definuje, preto obe hodnoty radšej načítajte z FPDF_GetPageBoundingBox, než aby ste to predpokladali. Prevrátenie osi Y je miesto, kde ručne písané verzie zlyhávajú: horná hrana obdĺžnika zariadenia vychádza z hornej hrany boxu PDF, meranej smerom nadol od vrchu stránky. Pomýlite si to obrátene a každé zvýraznenie sa vykreslí zrkadlovo prevrátené do nesprávnej polovice stránky

Mapovanie súradníc bodov PDF FPDFText_GetCharBox na pixely zariadenia pre zvýraznenia textu na reč v prehliadači Delphi
GetCharBox vracia left, right, bottom, top v PDF bodoch od počiatku vľavo dole; preloženie, škálovanie zoom krát DPI delené 72 a prevrátenie Y zasadí obdĺžnik na obrazovku
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);             // zmaže staré slovo
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // vykreslí nové
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // vždy najprv vykreslená stránka
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // približne 38 % nepriehľadnosti
  Blend.AlphaFormat := 0;                      // konštantná alfa, žiadne dáta na pixel
  Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
    FHighlightRect.Left, FHighlightRect.Top,
    FHighlightRect.Width, FHighlightRect.Height,
    FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;

Obsluha vykresľovania zakaždým najprv nakreslí bitmapu stránky a až potom zvýraznenie, takže prekrytie sa nikdy nemusí samo mazať; zneplatnenie starého aj nového obdĺžnika udržuje oblasť na prekreslenie malú aj pri rýchlom tempe reči. FHighlightBrush je TBitmap s rozmerom jeden krát jeden pixel, naplnená pri štarte farbou zvýraznenia — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF pre jantárovú — ktorú AlphaBlend naťahuje cez cieľový obdĺžnik, takže sa nič nealokuje na snímku, a SourceConstantAlpha na hodnote 96 udržuje slovo pod odtieňom čitateľné. Vyskúšajte túto farbu aj v invertovaných a vysokokontrastných zobrazovacích režimoch; prekrytie, ktoré slabozraký používateľ nevidí, pre presne tú osobu, pre ktorú bolo vytvorené, neexistuje

Poradie čítania je to, čo textové API nevyrieši

FPDFText_GetText vracia znaky v poradí odvodenom z content streamu s určitým priestorovým čistením, a pre jednostĺpcový report je toto poradie v poriadku. Nemá žiadnu povinnosť byť správne kdekoľvek inde. Dvojstĺpcový spravodaj sa dá čítať naprieč oboma stĺpcami naraz, bočný panel môže prerušiť vetu uprostred klauzuly a päta môže priletieť doprostred stránky. Informácie, ktoré toto riešia — logický strom štruktúry podľa ISO 32000-1 §14.8, ktorý nesú oštítkované PDF (tagged PDFs) a PDF/UA ho robí povinným — surové volania na textovú stránku vôbec nekonzultujú. Ak potrebujete poradie s vedomím štruktúry a s explicitným signálom o jeho pôvode, ide o problém vyriešený o poschodie vyššie: API na čítanie v PDFium Component vracia obsah s poľom Source nastaveným na rosStructure alebo rosHeuristic, čo prechádza článok o prístupnom čítačke PDF. Na úrovni surového API je obhájiteľná pozícia brať poradie extrakcie ako odhad, povedať to aj v UI a v regresnej sade si ponechať jeden viacstĺpcový dokument aj jeden čisto skenovaný obraz, aby oba tieto typy zlyhania zostali viditeľné

Samotný prehliadač musí byť ovládateľný z klávesnice

Hlasový výstup neospravedlňuje prehliadač od prístupu z klávesnice; ľudia, ktorí najskôr siahnu po čítaní nahlas, najmenej pravdepodobne siahnu po myši. Dajte panelu stránky TabStop := True a viditeľný obdĺžnik fokusu, potom obslúžte tri klávesy: Medzerník prepína FVoice.Pause a FVoice.Resume, a Doľava a Doprava preskakujú cez FVoice.Skip('Sentence', 1) so záporným počtom pre krok späť. Skip v SAPI rozumie iba granularite viet, takže preskakovanie na úrovni slov znamená vyčistiť prehrávanie pomocou SVSFPurgeBeforeSpeak a znova hovoriť od posunu naposledy sledovaného slova — lacné riešenie, keďže kód zvýraznenia už presne tento posun ukladá. Nechajte každý ovládací prvok transportu ako reálny TButton s popiskom, aby ho čítačky obrazovky ohlásili

To je celé prepojenie, celé postavené nad surovým textovým API PDFia: vlákno pre reč, ktoré vlastní COM a hlas po celú dobu behu aplikácie, udalosti hraníc maršalované do UI ako posuny znakov a boxy v priestore stránky za jednotlivé znaky premenené na jeden zmiešaný obdĺžnik na obrazovke. Ak nechcete vlastniť geometriu a sledovanie sami, PDFium Component dodáva boxy pre jednotlivé slová, sledovací kurzor, automatické sledovanie posúvaním a čítacie jednotky na úrovni viet ako vlastnosti komponentu, a jeho demo na čítanie nahlas je prepojenie z tohto článku zredukované na pár volaní