Tehnični članak

Izdelava PDF pregledovalnika z branjem na glas v Delphiju s SAPI TTS

Gumb za branje na glas se lahko predstavi v enem popoldnevu, nato pa vzame cel teden. Popoldanska različica izlušči besedilo strani, ga preda v SAPI in vrne zvok. Teden pa se porabi za tisto, kar naredi to funkcijo uporabno: glas ne sme zamrzniti okna, izgovorjena beseda mora na strani zasvetiti sinhrono z zvokom in preslednica mora omogočati zaustavitev celotnega procesa. Ta članek opisuje gradnjo takšnega cevovoda v Delphiju z uporabo surovega API-ja za besedilo PDFium in Windows Speech API-ja. Vključuje delujočo kodo za tri dele, ki jih hitra različica preskoči: življenjski cikel COM, izveden enkrat in ne za vsako izjavo posebej, resnične dogodke mej besed in matematične izračune koordinat, ki pretvorijo polje besede v PDF prostoru v pravokotnik, ki ga lahko narišete

Zakonodajni kontekst je mogoče strniti v en stavek: sinhronizirano branje na glas je na strani pregledovalnika tisto, kar WCAG 2.1 zahteva od programske opreme za dokumente, medtem ko ISO 14289-1 (PDF/UA) definira stran označene datoteke, s katero to najbolje deluje. Če gradite na osnovi PDFium Component, morda sploh ne boste potrebovali tega cevovoda: pregledovalnik ima vgrajen kazalec za sledenje, ki preslika zamik znakov v narisano osvetlitev besede z enim klicem, kar je zajeto v članku o označevanju TTS po posameznih besedah. Kar sledi, je namenjeno primerom, ko imate v lasti celotno aplikacijo za pregledovanje in želite zgraditi sam cevovod

Ena nit za upodabljanje, ena nit za govor

Arhitektura vključuje dve niti in eno pogodbo. Nit uporabniškega vmesnika (UI) upodablja bitno sliko strani, ima nadzor nad stanjem povečave in drsenja ter riše prekritje osvetlitve. Posebna govorna nit ima nadzor nad glasom SAPI, in se je nič drugega ne dotika. Pogodba je preprosta: govorna nit poroča o napredku kot zamikih znakov, UI nit pa pretvarja te zamike v pravokotnike

Večina primerov SAPI zavije vsako izjavo v CoInitialize in CoUninitialize, pregledovalnik pa takoj pokaže, zakaj je to narobe. Metoda Speak z zastavico SVSFlagsAsync se vrne takoj, ko je besedilo v čakalni vrsti, zato se CoUninitialize v bloku finally iste procedure izvede medtem, ko glas še govori, in podre COM strukturo, ki ga ima v lasti. Odvisno od časovnega usklajevanja dobite tišino, okrnjeno izjavo ali kršitev dostopa (access violation) nekaj minut kasneje. Pravilen življenjski cikel je dolgočasen: CoInitialize enkrat ob zagonu niti, ustvarjanje glasu znotraj te strukture, in CoUninitialize enkrat ob izhodu niti, ko je bil glas že sproščen. Nikoli za vsako izjavo posebej

Glas potrebuje tudi zanko za sporočila (message pump), kar določa, kje lahko obstaja. Avtomatizacijski objekt SpVoice dostavlja svoje dogodke prek čakalne vrste sporočil niti, v kateri je bil ustvarjen. Če ga ustvarite v UI niti, dogodki prispejo, ker VCL obdela sporočila, a vsako počasno izrisovanje zakasni meje besed. Če ga ustvarite v delovni niti brez zanke, dogodki sploh ne prispejo. Zato namenska nit z lastno GetMessage zanko ohranja latenco mej konstantno, ne glede na to, kaj počne uporabniški vmesnik

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;

Metoda TerminatedSet pošlje WM_QUIT, da se zanka sprosti ob zaprtju pregledovalnika. SpeakPage, ki se kliče iz UI niti, shrani besedilo v polje, zaščiteno s ključavnico (lock), in objavi WM_SPEAK_PAGE. Neposredni klic metode na FVoice iz druge niti bi predstavljal klic COM med različnimi strukturami na nemaršaliranem vmesniku. Enovrstični klic PeekMessage pred zanko prisili sistem Windows, da ustvari čakalno vrsto sporočil niti, kar odpravi napako pri zagonu, kjer bi zgodnja objava iz UI niti spodletela

Meje besed prispejo kot zamiki znakov

Uvozite Microsoft Speech Object Library enkrat preko uvoznika knjižnic tipov v IDE-ju in dobili boste SpeechLib_TLB z ovojem TSpVoice in njegovimi tipiziranimi dogodki. Pomembni sta dve nastavitvi. Nastavitev EventInterests je treba omejiti na dogodke, ki jih dejansko uporabljate, saj vsako zanimanje, ki ostane vklopljeno, povzroča dogodkovni promet med nitmi za vsako besedo vsake strani. Dogodek SVEWordBoundary poganja osvetlitev, SVEEndInputStream pa sporoča, da je izjava zaključena. Povezovalnik OnWord prejme CharacterPosition in dolžino, ki kažeta na točen niz, podan metodi Speak — zamik znotraj govornega medpomnilnika, ne pa kje drugje

Ta zadnji stavek je invariant, na katerem temelji ta funkcija: zamiki so smiselni samo za niz, ki ga glas trenutno bere, zato izgovorite natančno takšno besedilo, kot ste ga izluščili, znak za znakom. Obrežite presledke, združite prelome vrstic ali razširite okrajšavo za boljšo izgovarjavo in vsaka naslednja osvetlitev se bo premaknila za eno besedo. Če mora uporabniški vmesnik vstaviti govorjeno vsebino — naznanila strani, predpone naslovov — zabeležite položaj in dolžino vsakega vstavljanja ter odštejte nakopičeni premik od vsakega zamika preden ga preslikate

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;

Za preslikavo med nitmi je tukaj prava izbira TThread.Queue, in ne Synchronize: rutinski povezovalnik ne sme zaustaviti govorne niti, medtem ko se UI ponovno riše. Če dogodki mej prihajajo hitreje kot se zaslon osvežuje, zastarela posodobitev osvetlitve ni škodljiva, saj jo naslednja povozi. Dogodek OnEndStream povežite na enak način, da počistite osvetlitev, v načinu neprekinjenega branja pa, da naložite besedilo naslednje strani in zaženete naslednjo izjavo

Od zamikov znakov do pikslov na zaslonu

PDFium poroča o geometriji za vsak znak. Metoda FPDFText_GetCharBox vrne štiri dvojne vrednosti v vrstnem redu, ki je povzročil več tihih napak kot karkoli drugega v besedilnem API-ju — levo, desno, spodaj, zgoraj, in ne v vrstnem redu sistema Windows (levo, zgoraj, desno, spodaj). Območja poroča v prostoru strani: v PDF točkah, kjer 72 točk predstavlja en palec (inch), izhodišče pa je v spodnjem levem kotu, pri čemer se Y os povečuje navzgor. Območje besede predstavlja unijo območij njenih znakov, pretvorba v piksle naprave pa poteka v treh korakih: translacija s pomočjo izhodišča strani, skaliranje s faktorjem povečave pomnoženim z DPI zaslona, deljeno z 72, ter obrat Y osi

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;

Spremenljivka FPageTop je višina strani v točkah, ki jo dobimo z FPDF_GetPageHeight, vrednost FPageLeft pa je za večino dokumentov nič, vendar pride iz območja obrezovanja (crop box), če ga stran definira. Zato obe vrednosti raje preberite z uporabo FPDF_GetPageBoundingBox, kot da bi ju predpostavljali. Pri obračanju Y osi se pogosto pojavijo napake pri ročno napisanih implementacijah: zgornji del pravokotnika naprave izhaja iz vrha območja PDF, merjeno navzdol od vrha strani. Če se tukaj zmotite, bo vsaka osvetlitev zrcalno narisana na napačni polovici strani

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;

V programu za risanje se najprej vedno izriše bitna slika strani in nato osvetlitev, tako da prekritju nikoli ni treba izbrisati samega sebe. Z razveljavitvijo starih in novih pravokotnikov območje za ponovno risanje ostane majhno tudi pri visokih hitrostih govora. Spremenljivka FHighlightBrush je element TBitmap v velikosti ena krat ena, ki je ob zagonu zapolnjen z barvo osvetlitve — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF za jantarno barvo — katero AlphaBlend raztegne preko ciljnega pravokotnika, tako da se na posamezen okvir (frame) ne dodeli nič novega. Vrednost SourceConstantAlpha, nastavljena na 96, pa ohranja besedo čitljivo kljub obarvanju. Preverite barvo pri obrnjenih in visoko kontrastnih načinih prikaza zaslona; prekritje, ki ga slaboviden uporabnik ne vidi, za osebo, kateri je bilo namenjeno, ne obstaja

Vrstni red branja je del, ki ga besedilni API ne bo rešil

Metoda FPDFText_GetText vrne znake v vrstnem redu, pridobljenem iz podatkovnega toka vsebine in z določenim prostorskim čiščenjem, za enostolpčno poročilo pa je ta vrstni red primeren. Kjerkoli drugje pa ni nujno, da je pravilen. Glasilo v dveh stolpcih se lahko prebere ravno čez oba stolpca, stranska vrstica lahko prekine stavek v sredini, noga pa se lahko pojavi sredi strani. Podatek, ki to popravi — logično drevo strukture (logical structure tree) po ISO 32000-1 §14.8, ki ga nosijo označeni dokumenti PDF in ga PDF/UA določa kot obveznega — s strani surovih klicev za besedilo strani sploh ni upoštevan. Če potrebujete vrstni red, ki upošteva strukturo, z eksplicitnim signalom njegovega izvora, je to že rešen problem na višji ravni: API za branje v PDFium Component vrne vsebino s poljem Source kot rosStructure ali rosHeuristic, članek o dostopnem bralniku PDF pa se sprehodi skozi njega. Na ravni surovega API-ja pa se priporoča, da red ekstrakcije obravnavate kot oceno, to v uporabniškem vmesniku tudi omenite, poleg tega pa obdržite en večstolpčni dokument in en optično prebran dokument zgolj s sliko v regresijskem setu, da bosta oba načina napak ostala vidna

Sam pregledovalnik mora biti upravljan s tipkovnico

Govorni izhod ne opravičuje, da pregledovalnik nima dostopa s tipkovnico; ljudje, pri katerih je najbolj verjetno, da bodo uporabili funkcijo branja na glas, bodo najmanj verjetno posegli po miški. Stranski plošči dodelite vrednost TabStop := True in ji dajte viden pravokotnik za fokusiranje, nato pa upravljajte s tremi tipkami: preslednica preklaplja med FVoice.Pause in FVoice.Resume, leva in desna tipka pa preskočita z metodo FVoice.Skip('Sentence', 1) in njenim negativnim številcem za nazaj. SAPI-jeva metoda Skip razume zgolj ločljivost na ravni stavkov, zato preskakovanje besed pomeni brisanje predvajanja prek SVSFPurgeBeforeSpeak in ponovno izgovarjanje od zamika besede, ki ste mu nazadnje sledili — kar pa je poceni funkcija, saj koda za označevanje ta zamik že shranjuje. Poskrbite, da je vsak nadzor premikanja pravi gumb (TButton) z napisom, da ga bodo bralniki zaslona lahko oznanili

To je celoten cevovod in ves je zasnovan na surovem besedilnem API-ju PDFium: govorna nit, ki obravnava COM in ima nadzor nad glasom do konca življenjske dobe aplikacije, dogodki meja, preslikani v uporabniški vmesnik kot zamiki znakov, in območja prostora strani na ravni znakov, spremenjena v en zlit pravokotnik na zaslonu. Če ne želite sami upravljati z geometrijo in sledenjem, PDFium Component ponuja lastnosti komponent z območji posameznih besed, kazalec za sledenje, samodejno drsenje ob branju (auto-scroll) in bralne enote na ravni stavkov. Poleg tega njena demonstracijska aplikacija za branje na glas preoblikuje cevovod iz tega članka v zgolj peščico klicev