Technical Article

Tvorba prístupnej čítačky PDF v Delphi pomocou PDFium

Nevidiaci používateľ otvorí štvrťročnú správu vo vašom novom prehliadači v Delphi, zapne čítačku NVDA a počuje najprv pätu stránky, potom stĺpec s číslami a nakoniec nadpis, ktorý by každý vidiaci človek prečítal ako prvý. Alebo nepočuje vôbec nič. Stránka vyzerá na obrazovke dokonale a to je presne tá pasca: vykresľovanie a čítanie sú dva odlišné problémy, ktoré rieši rôzny kód. Poradie, v akom PDF vykresľuje svoje glyfy, nemusí zodpovedať poradiu, v akom by ich mal človek počuť. Preto prehliadač postavený iba na volaniach vykresľovania vytvorí bezchybný obraz, ale nepoužiteľný hovorený text. Z tohto dôvodu obsahuje PDFium Component (knižnica pre VCL/LCL obaľujúca jadro PDFium pre Delphi, C++Builder a Lazarus) samostatnú sadu API pre čítanie. Vykresľovacie rozhrania nedokážu obnoviť poradie čítania, ktoré nikdy nedostali.

Prístupná čítačka stojí a padá na troch veciach. Musí extrahovať poradie, ktoré dokáže čítačka obrazovky vysloviť, udržiavať viditeľný kurzor na slove, ktoré hlas práve hovorí, a priznať, keď dokument nebol nikdy označený (tagged), namiesto toho, aby ho len odhadovala a tvárila sa, že je všetko v poriadku. Na každú z týchto úloh existuje jasné rozhranie API a detail, ktorý pri vynechaní spôsobí zlyhanie.

Poradie čítania leží v strome štruktúry, nie v poradí vykresľovania

Štandard ISO 32000-1 §14.8 definuje logickú štruktúru ako strom prvkov navrstvený nad obsahom stránky. Štandard PDF/UA (ISO 14289-1) ide ešte ďalej a robí tento strom povinným: každý kúsok skutočného obsahu musí byť cez neho dostupný v poradí čítania, pričom dekoratívne prvky stránky (artefakty) musia byť označené a preskočené. Správne označená správa vie, že text „Quarterly Results“ je nadpis druhej úrovne a že tabuľka s výsledkami je tabuľka s hlavičkovými bunkami. Neoznačená správa je len kopa umiestnených glyfov, ktoré náhodou vyzerajú ako dokument.

Metóda ReadablePageContent prechádza túto štruktúru, ak je prítomná, a vracia fragmenty označené sémantickým typom Kind (hodnoty ako cfHeading a cfParagraph), takže používateľské rozhranie môže vysloviť slovo „nadpis“ pred samotným textom, namiesto toho, aby čítalo hrubý riadok ako obyčajný text. Ak použiteľný strom chýba, rovnaké volanie sa vracia k heuristickej analýze rozloženia: detekuje stĺpce, zoskupuje riadky, usporadúva zľava doprava a zhora nadol. Tento odhad je v poriadku pre jednoslúpcové memorandum, ale zlyháva pri newslettroch, viacstĺpcových formulároch, bočných paneloch alebo citáciách. Dôležité je vedieť, ktorý výsledok ste dostali, a API vám to priamo povie. Záznam TPdfReadableContent nesie pole Source nastavené na rosStructure, keď poradie pochádza z označeného stromu, alebo na rosHeuristic, keď bolo odvodené z geometrie. Prezentovať odhadované poradie ako overené je ako tvrdiť, že kód prešiel testami, ktoré nikto nespustil.

Jednoduchým krokom pri otvorení je prečítanie vlastnosti IsTagged a jednorazové zavolanie ValidatePdfUa s následným uložením výsledku do vyrovnávacej pamäte. Zlyhanie kontroly PDF/UA nie je dôvodom na odmietnutie súboru. Je to však dôvod na zobrazenie textu „odhadované poradie čítania“ v stavovom riadku. Keď potom zákazník napíše sťažnosť na zmätené čítanie textu, podpora už vopred vie, či ide o problém s označením v súbore, alebo o chybu vo vašom kóde.

Od stránky k hlasovej fronte pomocou ReadingUnits

Pre syntézu reči (text-to-speech) robí hlavnú prácu vlastnosť ReadingUnits. Vracia pole záznamov TPdfReadingUnit pre aktívnu stránku, pričom každý nesie text na prečítanie, jeho sémantickú rolu a obdĺžniky, ktoré ho lokalizujú na stránke. K dispozícii je aj dokumentový náprotivok DocumentReadingUnits, ak vyžadujete plynulé čítanie naprieč stránkami. Jedna jednotka sa dá priamo vložiť do fronty reči:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits works on the active page
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + semantics + highlight rects
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Dve veci v tomto cykle sa dajú ľahko urobiť nesprávne. Frontu držte na úrovni stránky a prebudujte ju pri každej navigácii, pretože jednotky čítania nesú obdĺžniky v súradniciach konkrétnej stránky; fronta zanechaná zo tretej stránky vykreslí svoje zvýraznenia na štvrtú stránku. A prázdne pole Units na stránke, ktorá zjavne obsahuje obsah, považujte za detekciu stránky obsahujúcej iba obrázok. Naskenovaná stránka sú len pixely bez textovej vrstvy a správnou reakciou je vysloviť upozornenie („táto stránka neobsahuje extrahovateľný text“) namiesto ticha, ktoré si používateľ môže pomýliť so zamrznutím aplikácie.

Kurzor na slove, ktorý sleduje hlas

Zvýrazňovanie celého odseku naraz pôsobí na slabozrakého používateľa, ktorý sleduje slová očami počas ich čítania, pomaly. Zvýrazňovanie po slovách (karaoke efekt) vyžaduje dve veci: geometriu každého slova a spôsob, ako mapovať hlásenia o pokroku rečového jadierka na túto geometriu. Vlastnosť PageWordBoxes poskytuje geometriu vo forme záznamov TPdfWordBox, z ktorých každý nesie text slova, jeho znakový posun (offset), počet znakov a obdĺžnik v súradniciach stránky. Metóda TrackReadingWordAt zabezpečuje samotné mapovanie. Odovzdajte jej pozíciu znaku, ktorú hlási udalosť rečového jadra SAPI, a metóda preloží tento posun na index v poli obdĺžnikov slov a v jednom volaní vykreslí kurzor na príslušnom slove.

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // The view's word boxes come from the page the view displays.
  // Setting Pdf.PageNumber alone would not move the view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt maps the offset AND paints the word cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary ran past the page text
end;

Táto spolupráca je v jednom smere veľkorysá a v druhom prísna. Tá veľkorysá časť: metóda TrackReadingWordAt si udržiava vlastnú vyrovnávaciu pamäť slov pre sledovanú stránku, takže netreba nič vopred načítavať, a neprebieha žiadne vykresľovanie, pretože rámce slov pochádzajú priamo z textovej vrstvy. Pozície tak môže sledovať aj rečová služba bežiaca na pozadí bez viditeľného okna. Prísna časť: index znaku musí smerovať do textu, ktorý komponent extrahoval, nie do nejakého vyčisteného reťazca, ktorý ste si zostavili sami. Keď CharIndex prebehne za koniec textu stránky, funkcia vráti hodnotu -1 namiesto vyvolania výnimky, čo sa stáva bežne, keď rečové jadro ohlási poslednú udalosť hranice pre koncové interpunkčné znamienko. Hodnotu -1 interpretujte ako pokyn na „vymazanie kurzora“, nikdy nie ako chybu.

Na strane zobrazenia nastavuje farbu kurzora vlastnosť ReadingWordColor. Predvolená jantárová farba (amber) je dobre viditeľná na väčšine pozadí stránok, ale otestujte ju pod každým filtrom zobrazenia, ktorý váš prehliadač ponúka. Jantárový kurzor môže úplne zmiznúť pri inverzii farieb. Pritom inverzia spustená spoločne s čítaním textu je presne spôsob, akým slabozraký používateľ pracuje, takže táto kombinácia je tou najdôležitejšou, ktorú musíte vyladiť. Nastavte ReadingWordFollow na True a zobrazenie samo posunie hovorené slovo do zorného pola, čo je nevyhnutné na priblíženej stránke, ktorá presahuje veľkosť obrazovky. Pamätajte na pravidlo rozsahu platnosti: SetReadingWord kreslí iba na aktívnej stránke TPdfView. Rozhodnite sa vopred, či ručné posúvanie stránky pozastaví reč, alebo či ho správanie automatického sledovania (follow) prekoná, pretože inak bude hlas čítať ďalej, zatiaľ čo kurzor bude visieť niekde mimo obrazovky.

Dokumenty, ktoré vašu čítačku pokazia

Niekoľko typov vstupných dokumentov spoľahlivo porazí naivnú implementáciu čítačky, preto patria ako stále vzorky do vašej sady regresných testov:

  • Neoznačené, ale textovo bohaté súbory. Heuristické poradie býva správne pri lineárnej správe a zlyháva, akonáhle sa objaví bočný panel alebo citácia. Označte poradie ako odhadované v UI aj v diagnostickom logu, aby bolo zlyhanie neskôr zrejmé.
  • Skeny obsahujúce iba obrázok. Žiadna textová vrstva. Zachyťte ich prostredníctvom prázdnych jednotiek čítania a nasmerujte používateľa na krok OCR namiesto toho, aby ste čítali prázdnu stránku.
  • Kombinované znaky a zmiešané písma. Kombinačné značky Unicode sa nie vždy preložia jedna k jednej na vizuálne slová, takže počet rámcov slov sa môže rozchádzať s tým, čo očakáva váš vlastný tokenizátor. Neindexujte pole rámcov slov s posunmi, ktoré ste vypočítali vlastným rozdelením textu; používajte iba indexy, ktoré vracia metóda TrackReadingWordAt.

Použitie TPdfView vo formulárovej aplikácii

Pamäť a výkon pri hromadných úlohách

Testujte to ako audítor, nie ako na predvádzaní

To, že aplikácia prečítala vašu vzorku nahlas, nič nedokazuje. Test, ktorý obstojí, overí tri súbory so spustenou čítačkou NVDA: jeden známy označený (tagged) súbor, kde sú nadpisy ohlasované ako nadpisy a tabuľka sa číta po riadkoch; jeden známy neoznačený súbor, kde je viditeľný indikátor odhadovaného poradia; a sken, kde sa skutočne ozve upozornenie na chýbajúci text. Každý z nich preverí vetvu kódu, ktorú bezproblémový scénár vynecháva.

Následne overte, že kurzor na slovách zostáva presne uzamknutý pri dvojnásobnej aj polovičnej rýchlosti reči a že automatické posúvanie ReadingWordFollow nesúperí s vlastným posúvaním používateľa. Potom spustite reč počas striedania všetkých filtrov farieb a sledujte, či kurzor niekde nezmizne. Článok o filtroch farieb pre slabozrakých sa tejto téme venuje podrobne a deep dive článok o kurzore reči na slovách rozoberá časovanie TTS.

Rozhrania pre jednotky čítania a rámce slov použité vyššie sa dodávajú s komponentom PDFium Component pre Delphi a C++Builder (VCL) a Lazarus/FPC (LCL). Produktová stránka odkazuje na kompletnú referenčnú príručku API vrátane rozloženia záznamov pre jednotky čítania a rámce slov použité v týchto príkladoch.