Tehnički članak

Izrada pristupačnih PDF preglednika s pretvaranjem teksta u govor u Delphiju

Gumb za čitanje naglas demonstrira se u jedno poslijepodne, a zatim uzme tjedan dana posla. Poslijepodnevna verzija izdvaja tekst stranice, predaje ga SAPI-ju i dobiva zvuk. Tjedan dana odlazi na ono što značajku čini upotrebljivom: glas ne smije zamrznuti prozor, izgovorena riječ mora se na stranici označiti u skladu sa zvukom, a tipka razmaknice mora pauzirati cijelu stvar. Ovaj članak gradi taj cjevovod (pipeline) u Delphiju nasuprot sirovog PDFium API-ja za tekst i Windows Speech API-ja, s radnim kodom za tri dijela koja brza verzija preskače: COM životni ciklus odrađen jednom umjesto po izgovoru, stvarni događaji o granicama riječi i koordinatna matematika koja pretvara okvir riječi u PDF-prostoru u pravokutnik koji možete iscrtati

Regulatorni kontekst stane u jednu rečenicu: sinkronizirano čitanje naglas je polovica onoga što WCAG 2.1 zahtijeva od softvera za dokumente sa strane preglednika, a ISO 14289-1 (PDF/UA) definira polovicu za označene datoteke prema kojoj on najbolje radi. Ako gradite na PDFium komponenti (PDFium Component), možda vam ovaj cjevovod uopće ne treba: preglednik se isporučuje s ugrađenim pokazivačem za praćenje koji jednim pozivom preslikava pomak znaka (character offset) u iscrtano označavanje riječi, što je pokriveno u članku o označavanju riječi pri pretvaranju teksta u govor. Ono što slijedi je za slučaj kada posjedujete cijelu aplikaciju preglednika i želite sam cjevovod

Jedna nit iscrtava, jedna nit govori

Arhitektura se sastoji od dvije niti i jednog ugovora. UI nit iscrtava bitmapu stranice, upravlja stanjem zumiranja i pomicanja, te slika preklapanje za označavanje. Namjenska nit za govor upravlja SAPI glasom i ništa je drugo ne dodiruje. Ugovor je tanak: nit za govor izvještava o napretku kao pomacima znakova (character offsets), a UI nit pretvara pomake u pravokutnike

Većina SAPI primjera omotava svaki izgovor u CoInitialize i CoUninitialize, a preglednik odmah pokazuje zašto je to pogrešno. Speak s SVSFlagsAsync vraća se čim je tekst stavljen u red čekanja, pa se CoUninitialize u bloku finally iste procedure izvršava dok glas još uvijek govori, rušeći COM stan (apartment) koji ga posjeduje. Ovisno o tempiranju, dobit ćete tišinu, odrezani izgovor ili prekršaj pristupa (access violation) minutu kasnije. Ispravan životni ciklus je dosadan: CoInitialize jednom kada se nit za govor pokrene, stvorite glas unutar tog stana i CoUninitialize jednom kada nit izađe, nakon što je glas oslobođen. Nikad po izgovoru

Glas također treba petlju za poruke (message pump), što određuje gdje može živjeti. COM objekt za automatizaciju SpVoice isporučuje svoje događaje kroz red čekanja poruka niti koja ga je stvorila. Stvorite ga na UI niti i događaji pristižu, jer VCL pumpa poruke, ali svako sporo iscrtavanje tada odgađa vaše granice riječi; stvorite ga na radnoj niti (worker thread) bez petlje i događaji nikada ne pristižu. Namjenska nit sa vlastitom GetMessage petljom održava kašnjenje granica ravnim, bez obzira na to što UI radi

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 šalje WM_QUIT kako bi se petlja odblokirala kada se preglednik gasi. SpeakPage, pozvan s UI niti, sprema tekst u polje zaštićeno bravom i šalje WM_SPEAK_PAGE, jer bi pozivanje metode na FVoice izravno s druge niti bio među-stambeni COM poziv na nemaršaliranom sučelju. Jedna linija PeekMessage prije petlje prisiljava Windowse da stvore red čekanja poruka niti, zatvarajući početnu utrku (startup race) gdje bi rani poziv s UI niti propao

Granice riječi pristižu kao pomaci znakova

Uvezite Microsoftovu biblioteku govornih objekata (Microsoft Speech Object Library) jednom putem uvoznika biblioteke tipova IDE-a i dobit ćete SpeechLib_TLB s omotom TSpVoice i njegovim tipiziranim događajima. Važne su dvije postavke. EventInterests treba suziti na događaje koje zapravo trošite, jer je svaki interes ostavljen uključenim promet događaja između niti za svaku riječ svake stranice; SVEWordBoundary pokreće označavanje, a SVEEndInputStream vam govori da je izgovor završio. I rukovatelj OnWord prima CharacterPosition i duljinu, koji indeksiraju točan niz koji ste proslijedili u Speak — pomak (offset) u međuspremnik govora, a ne u bilo što drugo

Ta zadnja klauzula je invarijanta o kojoj značajka ovisi: pomaci su značajni samo u odnosu na niz koji glas čita, stoga izgovorite točno onaj tekst koji ste izdvojili, znak po znak. Obrežite praznine, sažmite prijelome redaka ili proširite kraticu za ljepši izgovor, i svako označavanje nakon prvog uređivanja promašit će za jednu riječ. Ako UI mora ubaciti izgovoreni materijal — najave stranica, prefikse naslova — zabilježite poziciju i duljinu svakog umetanja i oduzmite akumulirani pomak od svakog pomaka prije nego što 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;

TThread.Queue je ovdje pravi maršal, a ne Synchronize: rukovatelj ne smije parkirati nit za govor dok UI ponovno iscrtava, a ako događaji o granicama pristižu brže nego što zaslon crta, zastarjelo ažuriranje označavanja je bezopasno jer ga sljedeće prebriše. Povežite OnEndStream na isti način da biste očistili označavanje, i u načinu neprekidnog čitanja, da biste učitali tekst sljedeće stranice i objavili sljedeći izgovor

Od pomaka znakova do piksela na zaslonu

PDFium izvještava o geometriji po znaku. FPDFText_GetCharBox popunjava četiri double-a u redoslijedu koji je uzrokovao više tihih pogrešaka nego bilo što drugo u tekstualnom API-ju — lijevo, desno, dno, vrh, a ne po Windowsima lijevo, vrh, desno, dno — i izvještava ih u prostoru stranice: PDF točke, 72 po inču, ishodište u donjem lijevom kutu s rastom Y prema gore. Okvir riječi je unija okvira njezinih znakova, a transformacija u piksele uređaja odvija se u tri koraka: translacija za ishodište stranice, skaliranje pomoću zumiranja pomnoženog s DPI-jem zaslona kroz 72 i okretanje 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;

FPageTop je visina stranice u točkama iz FPDF_GetPageHeight, a FPageLeft je nula za većinu dokumenata, ali dolazi iz crop okvira kada ga stranica definira, stoga pročitajte oboje iz FPDF_GetPageBoundingBox umjesto da pretpostavljate. Okretanje Y osi je mjesto gdje se ručno napisane verzije lome: vrh pravokutnika uređaja dolazi od vrha PDF okvira mjerenog prema dolje od vrha stranice. Shvatite to naopako i svako označavanje slika se zrcalno u pogrešnu polovicu stranice

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;

Rukovatelj iscrtavanjem prvo crta bitmapu stranice, a zatim označavanje, svaki put, tako da preklapanje nikada ne mora brisati samo sebe; poništavanje (invalidating) starog i novog pravokutnika održava regiju ponovnog iscrtavanja malom čak i pri brzim stopama govora. FHighlightBrush je jednim-po-jednim TBitmap ispunjen jednom pri pokretanju bojom označavanja — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF za jantarnu (amber) — koju AlphaBlend rasteže preko ciljanog pravokutnika, tako da se ništa ne dodjeljuje po okviru (frame), a SourceConstantAlpha na 96 održava riječ čitljivom kroz boju. Testirajte boju pod obrnutim i visokokontrastnim načinima prikaza; preklapanje koje slabovidni korisnik ne može vidjeti ne postoji upravo za osobu za koju je izgrađeno

Redoslijed čitanja je dio koji tekstualni API neće riješiti

FPDFText_GetText vraća znakove u redoslijedu izvedenom iz toka sadržaja (content stream) s nešto prostornog čišćenja, i za izvješće u jednom stupcu taj je redoslijed u redu. Nema obvezu biti točan nigdje drugdje. Bilten u dva stupca može čitati ravno preko oba stupca, bočna traka može prekinuti rečenicu usred klauzule, a podnožje može stići usred stranice. Informacije koje to popravljaju — logičko stablo strukture iz ISO 32000-1 §14.8, koje nose označeni PDF-ovi (tagged PDFs) i koje PDF/UA čini obaveznim — ne konzultiraju se sirovim pozivima tekstualne stranice uopće. Ako trebate redoslijed svjestan strukture s eksplicitnim signalom o njegovu podrijetlu, to je riješen problem na višoj razini: API za čitanje PDFium komponente vraća sadržaj s poljem Source kao rosStructure ili rosHeuristic, a članak o pristupačnom PDF čitaču prolazi kroz to. Na razini sirovog API-ja, obranjiv položaj je tretirati redoslijed izdvajanja kao procjenu, navesti to u UI-ju, i držati jedan dokument u više stupaca i jedno skeniranje samo sa slikama u setu za regresiju kako bi oba načina kvara ostala vidljiva

Sam preglednik mora biti operativan tipkovnicom

Govorni izlaz ne opravdava preglednik od pristupa tipkovnicom; ljudi koji će najvjerojatnije koristiti čitanje naglas najmanje će vjerojatno posegnuti za mišem. Dajte ploči stranice TabStop := True i vidljiv pravokutnik fokusa, a zatim rukujte s tri tipke: Razmaknica uključuje FVoice.Pause i FVoice.Resume, a Lijevo i Desno preskaču putem FVoice.Skip('Sentence', 1) s negativnim brojem za povratak unatrag. SAPI-jev Skip razumije samo granularnost rečenice, pa preskakanje na razini riječi znači čišćenje reprodukcije s SVSFPurgeBeforeSpeak i ponovno govorenje od pomaka riječi koju ste zadnju pratili — jeftino, budući da kod za označavanje već sprema točno taj pomak. Zadržite svaku kontrolu prijenosa pravim TButtonom s natpisom kako bi je čitači zaslona najavili

To je cijeli cjevovod, sve to protiv sirovog PDFium API-ja za tekst: nit za govor koja posjeduje COM i glas za vrijeme trajanja aplikacije, događaji granica maršalirani u UI kao pomaci znakova i per-znakovni okviri u prostoru stranice pretvoreni u jedan preklopljeni pravokutnik na zaslonu. Ako radije ne biste sami posjedovali geometriju i praćenje, PDFium komponenta isporučuje okvire za svaku riječ, pokazivač za praćenje, praćenje s automatskim pomicanjem i jedinice čitanja na razini rečenice kao svojstva komponente, a njezin demo za čitanje naglas je cjevovod iz ovog članka sveden na pregršt poziva