Tehnički članak

Izrada pristupačnih PDF pregledača sa čitanjem teksta u Delphiju

Dugme za čitanje naglas se demonstrira za jedno popodne, a zatim vam oduzme nedelju dana. Popodnevna verzija izdvaja tekst sa stranice, predaje ga SAPI-ju i dobija zvuk. Nedelja dana odlazi na ono što funkciju čini upotrebljivom: glas ne sme da zamrzne prozor, izgovorena reč mora da zasvetli na stranici usklađeno sa zvukom, a taster Space mora da pauzira celu stvar. Ovaj članak gradi taj cevovod u Delphiju u odnosu na sirovi PDFium tekst API i Windows Speech API, uz radni kod za tri dela koja brza verzija preskače: COM životni ciklus koji se obavlja jednom, a ne po izgovoru, stvarne događaje na granici reči i matematiku koordinata koja pretvara okvir reči u PDF prostoru u pravougaonik koji možete iscrtati

Regulatorni kontekst staje u jednu rečenicu: sinhronizovano čitanje naglas je polovina onoga što WCAG 2.1 traži od softvera za dokumente na strani pregledača, a ISO 14289-1 (PDF/UA) definiše polovinu obeležene datoteke (tagged-file) protiv koje najbolje radi. Ako gradite na PDFium Component-i, možda vam ovaj cevovod uopšte neće ni trebati: pregledač se isporučuje sa ugrađenim kursorom za praćenje koji mapira ofset znakova na obojeno isticanje reči u jednom pozivu, što je obrađeno u članku o isticanju TTS-a reč po reč. Ono što sledi je za slučaj kada posedujete celu aplikaciju pregledača i želite sam cevovod

Jedan thread renderuje, jedan thread govori

Arhitektura se sastoji od dva thread-a i jednog ugovora. UI thread renderuje bitmapu stranice, poseduje stanje zumiranja i pomeranja (scroll), i iscrtava sloj za isticanje. Namenski thread za govor poseduje SAPI glas, i ništa ga drugo ne dodiruje. Ugovor je jednostavan: thread za govor izveštava o napretku kao ofsetima znakova, a UI thread pretvara ofsete u pravougaonike

Većina SAPI primera omotava svaki izgovor u CoInitialize i CoUninitialize, a pregledač odmah pokazuje zašto je to pogrešno. Speak sa SVSFlagsAsync se vraća čim je tekst stavljen u red, tako da CoUninitialize u finally bloku iste procedure radi dok glas još uvek govori, rušeći COM apartman koji ga poseduje. U zavisnosti od tajminga, dobijate tišinu, odsečen izgovor ili prekršaj pristupa (access violation) nekoliko minuta kasnije. Ispravan životni ciklus je dosadan: CoInitialize jednom kada se thread za govor pokrene, napravite glas unutar tog apartmana, i CoUninitialize jednom kada se thread završi, nakon što je glas oslobođen. Nikada po izgovoru

Glas takođe zahteva pumpu za poruke, koja odlučuje gde može da živi. SpVoice objekat za automatizaciju isporučuje svoje događaje kroz red poruka thread-a koji ga je kreirao. Ako ga kreirate na UI thread-u, događaji će stizati jer VCL pumpa poruke, ali svako sporo iscrtavanje će tada odložiti vaše granice reči; ako ga kreirate na radnom thread-u (worker thread) bez pumpe, događaji nikada neće ni stići. Namenski thread sa sopstvenom GetMessage petljom održava latenciju granica ravnom, bez obzira na to šta 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 postavlja WM_QUIT tako da se pumpa odblokira kada se pregledač ugasi. SpeakPage, pozvan sa UI thread-a, skladišti tekst u polje zaštićeno bravom i postavlja WM_SPEAK_PAGE, jer bi pozivanje metode na FVoice direktno sa drugog thread-a bio COM poziv između apartmana na nemaršaliranom interfejsu. PeekMessage od jedne linije pre petlje primorava Windows da kreira red poruka thread-a, zatvarajući početnu trku gde bi rani post sa UI thread-a propao

Granice reči stižu kao ofseti znakova

Uvezite Microsoft Speech Object Library jednom kroz uvoznik biblioteke tipova (type library importer) u IDE-u i dobićete SpeechLib_TLB sa TSpVoice omotačem i njegovim tipiziranim događajima. Dva podešavanja su važna. EventInterests bi trebalo suziti na događaje koje zapravo konzumirate, jer svaki interes koji ostane uključen predstavlja saobraćaj događaja između thread-ova za svaku reč na svakoj stranici; SVEWordBoundary pokreće isticanje, a SVEEndInputStream vam govori da je izgovor završen. Pored toga, OnWord rukovalac prima CharacterPosition i dužinu, koji indeksiraju direktno u tačan string koji ste prosledili metodi Speak — ofset u bafer za govor, a ne u bilo šta drugo

Ta poslednja klauzula je invarijanta na kojoj funkcija zavisi: ofseti imaju smisla samo u odnosu na string koji glas čita, pa govorite tačno onaj tekst koji ste izdvojili, znak po znak. Ako trimujete razmake, sažmete prelome redova ili proširite skraćenicu radi lepšeg izgovora, svako isticanje nakon prvog izmenjenog mesta će promašiti za jednu reč. Ako UI mora da ubaci govorni materijal — najave stranica, prefikse naslova — zabeležite poziciju i dužinu svakog ubacivanja i oduzmite akumulirani pomak od svakog ofseta pre nego što ga mapirate

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 ovde pravi maršal, a ne Synchronize: rukovalac ne sme da parkira thread za govor dok se UI precrtava, a ako događaji granice stignu brže nego što se ekran iscrtava, bajato ažuriranje isticanja je bezopasno jer će ga sledeće prepisati. Povežite OnEndStream na isti način da biste obrisali isticanje i, u režimu neprekidnog čitanja, da biste učitali tekst sledeće stranice i objavili sledeći izgovor

Od ofseta znakova do piksela na ekranu

PDFium izveštava o geometriji po znaku. FPDFText_GetCharBox popunjava četiri double vrednosti u redosledu koji je izazvao više tihih grešaka od bilo čega drugog u tekstualnom API-ju — levo, desno, dole, gore, a ne Windows-ovo levo, gore, desno, dole — i izveštava o njima u prostoru stranice: PDF tačke, 72 po inču, početak u donjem levom uglu, pri čemu Y raste naviše. Okvir reči je unija okvira njenih znakova, a transformacija u piksele uređaja sastoji se od tri koraka: translacija za početak stranice, skaliranje za zumiranje puta DPI ekrana podeljeno sa 72, i obrtanje Y ose

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 tačkama iz FPDF_GetPageHeight, a FPageLeft je nula za većinu dokumenata, ali potiče iz okvira za isecanje (crop box) kada ga stranica definiše, pa iščitajte oba iz FPDF_GetPageBoundingBox umesto da pretpostavljate. Obrtanje Y ose je mesto gde ručno pisane verzije pucaju: vrh pravougaonika uređaja potiče od vrha PDF okvira mereno nadole od vrha stranice. Ako to uradite naopako, svako isticanje će biti nacrtano u ogledalu na pogrešnoj polovini 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;

Rukovalac iscrtavanjem uvek prvo crta bitmapu stranice, a isticanje posle nje, tako da sloj nikada ne mora sam da se briše; poništavanje starog i novog pravougaonika održava oblast ponovnog iscrtavanja malom, čak i pri brzim stopama govora. FHighlightBrush je TBitmap formata jedan sa jedan, koji se popunjava bojom za isticanje pri pokretanju — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF za ćilibar boju — i AlphaBlend ga razvlači preko ciljnog pravougaonika, tako da se ništa ne dodeljuje po kadru, a SourceConstantAlpha na 96 održava reč čitljivom kroz nijansu. Testirajte boju pod obrnutim i visoko kontrastnim režimima prikaza; sloj koji slabovidi korisnik ne može da vidi ne postoji upravo za osobu za koju je i napravljen

Redosled čitanja je deo koji tekstualni API neće rešiti

FPDFText_GetText vraća znakove redosledom izvedenim iz toka sadržaja (content stream) uz izvesno prostorno čišćenje, i za izveštaj sa jednom kolonom taj redosled je sasvim u redu. Nema obavezu da bude ispravan bilo gde drugde. Bilten u dve kolone može da se čita pravo preko obe kolone, bočna traka može da prekine rečenicu usred klauzule, a podnožje može da stigne usred stranice. Informacija koja ovo popravlja — logičko stablo strukture iz ISO 32000-1 §14.8, koje označeni (tagged) PDF-ovi nose i koje PDF/UA čini obaveznim — uopšte nije konsultovana od strane poziva sirovih tekstualnih stranica. Ako vam je potreban redosled svestan strukture sa eksplicitnim signalom svog porekla, to je problem rešen na nivou iznad: API za čitanje PDFium Component-e vraća sadržaj sa Source poljem postavljenim na rosStructure ili rosHeuristic, a članak o pristupačnom PDF čitaču prolazi kroz to. Na sirovom API nivou, branjiv stav je da se redosled ekstrakcije tretira kao procena, da se to naglasi u UI-u, i da se zadrži jedan dokument sa više kolona i jedno skeniranje samo sa slikama u setu za regresiono testiranje, kako bi oba načina otkazivanja ostala vidljiva

Sam pregledač mora biti upotrebljiv preko tastature

Govorni izlaz ne oslobađa pregledač potrebe za pristupom preko tastature; ljudi koji će najverovatnije koristiti čitanje naglas su oni koji će najređe posegnuti za mišem. Dodajte panelu stranice TabStop := True i vidljiv pravougaonik fokusa, a zatim obradite tri tastera: Space menja između FVoice.Pause i FVoice.Resume, dok Levo i Desno preskaču kroz FVoice.Skip('Sentence', 1) sa negativnim brojačem za vraćanje unazad. SAPI-jev Skip razume samo nivo rečenice, tako da preskakanje na nivou reči znači čišćenje reprodukcije sa SVSFPurgeBeforeSpeak i ponovno izgovaranje od ofseta reči koju ste poslednju pratili — što je jeftino, pošto kod za isticanje već skladišti upravo taj ofset. Zadržite svaku kontrolu transporta kao pravu TButton komponentu sa natpisom, kako bi je čitači ekrana najavljivali

To je ceo cevovod, u potpunosti u odnosu na sirovi PDFium tekstualni API: thread za govor koji poseduje COM i glas tokom celog životnog veka aplikacije, događaji granica prosleđeni u UI kao ofseti znakova, i okviri po znaku u prostoru stranice pretvoreni u jedan stopljeni pravougaonik na ekranu. Ako biste radije da sami ne upravljate geometrijom i praćenjem, PDFium Component isporučuje okvire za svaku reč, kursor za praćenje, automatsko pomeranje (auto-scroll) za praćenje i jedinice za čitanje na nivou rečenice kao svojstva komponente, a njena demonstracija čitanja naglas je zapravo cevovod iz ovog članka sveden na šačicu poziva