Technický článek

Vytvoření přístupného prohlížeče PDF v Delphi s PDFium

Nevidomý uživatel otevře čtvrtletní report ve vaší nové lesklé prohlížečce v Delphi, zapne NVDA a uslyší patičku stránky, pak sloupec čísel, pak nadpis, který by jakýkoli vidící čtenář přečetl jako první. Nebo neuslyší vůbec nic. Stránka na obrazovce vypadá dokonale, a přesně to je past: vykreslování a čtení jsou různé problémy řešené různým kódem. Pořadí, ve kterém PDF maluje své glyfy, nemá žádnou povinnost odpovídat pořadí, v jakém by je člověk měl slyšet, takže prohlížečka postavená jen na vykreslovacích voláních vyprodukuje dokonalý obrázek a nepoužitelnou naraci. PDFium Component, wrapper VCL/LCL kolem enginu PDFium pro Delphi, C++Builder a Lazarus, proto nese samostatnou sadu API pro čtení. Kreslicí API nedokáže obnovit pořadí čtení, které nikdy nedostalo

Přístupná čtečka stojí nebo padá na třech věcech. Musí umět extrahovat pořadí, které dokáže odříkat čtečka obrazovky, udržet viditelný kurzor slova připnutý na to, co hlas právě říká, a přiznat, když dokument nikdy nebyl otagovaný, místo aby hádala a předstírala. Ke každé z nich existuje jasné API, po kterém sáhnout, a selhání, které kousne, pokud přeskočíte detail

Pořadí čtení žije ve stromu struktury, ne v pořadí vykreslování

ISO 32000-1 §14.8 definuje logickou strukturu jako strom prvků vrstvený nad obsahem stránky. PDF/UA (ISO 14289-1) jde ještě dál a dělá z tohoto stromu povinnost: každý kousek skutečného obsahu musí být přes něj dosažitelný v pořadí čtení, přičemž artefakty stránky jsou takto označené a přeskočené. Správně otagovaný report ví, že „Quarterly Results" je nadpis druhé úrovně a že mřížka součtů je tabulka s hlavičkovými buňkami. Neotagovaný report je hromada polohovaných běhů glyfů, které náhodou vypadají jako dokument

ReadablePageContent projde tuto strukturu, pokud je přítomná, a vrátí fragmenty otagované sémantickým Kind, hodnotami jako cfHeading a cfParagraph, takže UI může říct „nadpis" ještě před slovy, místo aby přečetlo tučný řádek jako obyčejný text těla. Bez použitelného stromu stejné volání spadne zpět na heuristickou analýzu rozvržení: detekuje sloupce, seskupuje účaří, řadí zleva doprava a shora dolů. Tento fallback funguje dobře pro jednosloupcový dopis a je vratký u newsletteru, vícesloupcového formuláře, čehokoli s postranním panelem nebo pull quote. Na čem záleží, je vědět, který výsledek jste dostali, a API vám to řekne přímo. Záznam TPdfReadableContent nese pole Source nastavené na rosStructure, když pořadí pochází z otagovaného stromu, nebo na rosHeuristic, když bylo odvozeno z geometrie. Zobrazit odhadnuté pořadí, jako by bylo ověřené, je jako doručit verzi přístupnosti s odznakem „prošlo" na buildu, který nikdo nespustil

Přístupná čtečka PDFium v Delphi bere čtecí pořadí ze stromu tagované struktury a spadne na heuristickou analýzu rozvržení u netagovaných PDF, přičemž pole Source TPdfReadableContent rozezná rosStructure od rosHeuristic
Tagovaný strom ohlašuje nadpisy a pořadí řádků, zatímco heuristická záloha hádá z geometrie, a pole Source drží ověřené odděleně od odhadnutých

Levný krok při otevření je přečíst IsTagged a jednou zavolat ValidatePdfUa, pak si odpověď uložit do mezipaměti. Neúspěšná kontrola PDF/UA není důvod soubor odmítnout. Je to důvod dát do stavového řádku „odhadované pořadí čtení", aby support, když zákazník napíše stížnost na zmatenou naraci, už věděl, jestli se dívá na problém s tagováním v souboru, nebo na chybu ve vašem kódu

Ze stránky do fronty řeči pomocí ReadingUnits

Pro převod textu na řeč odvádí většinu práce ReadingUnits. Vrátí pole záznamů TPdfReadingUnit pro aktivní stránku, každý z nich nese text ke čtení, svou sémantickou roli a obdélníky, které ho lokalizují na stránce. K dispozici je i souputník pro celý dokument, DocumentReadingUnits, když chcete plynulé čtení napříč stránkami. Jedna jednotka zapadne rovnou do jednoho slotu fronty řeči:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits pracuje s aktivní stránkou
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + sémantika + obdélníky pro zvýraznění
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

V té smyčce se dají snadno pokazit dvě věci. Frontu držte pro každou stránku zvlášť a znovu ji sestavte pokaždé, když uživatel přejde jinam, protože jednotky čtení nesou obdélníky v prostoru stránky; fronta zůstavší po stránce tři vykreslí svá zvýraznění na stránku čtyři. A prázdné pole Units na stránce, která zjevně obsahuje obsah, berte jako svůj detektor jen-obrázkových stránek. Naskenovaná stránka jsou pixely bez podkladové textové vrstvy, a správnou odpovědí je přečíst varování („tato stránka neobsahuje extrahovatelný text"), místo abyste ztichli způsobem, který posluchač nedokáže odlišit od zaseknutí

PDFium ReadingUnits v Delphi promění aktivní stranu na text, sémantickou roli a obdélníky v prostoru strany, jež naplní frontu řeči čtečky obrazovky po jedné jednotce na slot, s prázdným polem units označujícím skenovanou stranu pro vyslovené varování
Čtenářské jednotky dopadají po jedné na slot do řečové fronty a prázdné pole na stránce nesoucí obsah je detektorem skenované stránky

Kurzor slova, který sleduje hlas

Zvýrazňování celého odstavce najednou působí na uživatele se zbytky zraku, který sleduje slova očima, zatímco jsou předčítána, těžkopádně. Zvýrazňování na úrovni slov, karaoke efekt, potřebuje dva kusy: geometrii každého slova a způsob, jak namapovat průběžná hlášení TTS enginu na tuto geometrii. PageWordBoxes vám dá geometrii jako záznamy TPdfWordBox, každý s textem slova, jeho znakovým offsetem, počtem znaků a obdélníkem v prostoru stránky. TrackReadingWordAt vám dá mapování. Nakrmte ho pozicí znaku, kterou už hlásí SAPI událost hranice slova, a on offset vyřeší na index v poli slovních boxů a jedním voláním vykreslí kurzor na odpovídajícím slově

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // Slovní boxy view pocházejí ze stránky, kterou view zobrazuje.
  // Samotné nastavení Pdf.PageNumber by view neposunulo
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt namapuje offset A zároveň vykreslí kurzor slova
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // hranice přeběhla za text stránky
end;

Smlouva je v jednom ohledu velkorysá a v druhém nesmlouvavá. Velkorysá část: TrackReadingWordAt si drží vlastní mezipaměť slovních boxů pro stránku, kterou sleduje, takže není třeba nic předem načítat a nedochází k žádnému vykreslování, protože slovní boxy pocházejí z textové vrstvy. I bezhlavá řečová služba bez viditelného okna dokáže sledovat pozice. Nesmlouvavá část: znakový index musí ukazovat do textu, který extrahovala komponenta, ne do nějakého vámi vyčištěného řetězce. Když CharIndex přeběhne za konec textu stránky, funkce vrátí -1 místo vyvolání výjimky, což se stává pořád, když TTS engine vypálí jednu poslední hraniční událost kvůli koncové interpunkci. Čtěte -1 jako „vymaž kurzor", nikdy jako chybu

Na straně zobrazení nastavuje barvu kurzoru ReadingWordColor. Výchozí jantarová barva funguje na většině pozadí stránky, ale otestujte ji pod každým zobrazovacím filtrem, který vaše prohlížečka nabízí. Jantarový kurzor může pod inverzí barev úplně zmizet, a inverze běžící souběžně s řečí je přesně to, jak pracuje uživatel se zbytky zraku, takže právě ta jedna kombinace, kterou nejvíc potřebujete mít v pořádku, je ta, kterou rychlé demo nikdy neprocvičí. Nastavte ReadingWordFollow na True a view samo odrolluje čtené slovo do zorného pole, což se neobejde bez toho na zoomované stránce, která se rozlévá přes obrazovky. Pamatujte na jedno pravidlo rozsahu: SetReadingWord vykresluje jen na aktivní stránce TPdfView. Rozhodněte se předem, jestli ruční scrollování řeč pozastaví, nebo jestli ho přebije chování sledování, protože nezvolení ani jednoho nechá hlas dál číst, zatímco kurzor sedí někde mimo obrazovku

Události hranic slov SAPI v čtečce PDFium v Delphi se mapují přes TrackReadingWordAt na geometrii PageWordBoxes, aby natřely karaoke kurzor slova, s návratem -1 mazajícím kurzor, když offset přeběhne za text strany
Posun hranice TTS se rozřeší na box slova a namaluje kurzor a -1 za textem stránky ho vymaže místo vyvolání výjimky

Dokumenty, které rozbijí vaši čtečku

Hrstka tvarů vstupu spolehlivě poráží naivní implementaci natolik, že patří jako trvalé vzorky do regresní sady, ne jako jednorázové chyby, které opravíte a zapomenete

  • Neotagované, ale textově bohaté soubory. Heuristické pořadí bývá správné u lineárního reportu a špatné ve chvíli, kdy se objeví postranní panel nebo pull quote. Označte pořadí jako odhadované, jak v UI, tak v diagnostickém logu, aby selhání zůstalo čitelné i později
  • Skeny jen s obrázky. Vůbec žádná textová vrstva. Zachyťte je pomocí prázdných jednotek čtení a nasměrujte uživatele na krok OCR o úroveň výš, místo abyste nechali čtečku narovat prázdnou stránku
  • Kombinující znaky a smíšená písma. Kombinující značky Unicode se ne vždy sbalí jedna ku jedné do vizuálních slov, takže se počet slovních boxů může rozejít s tím, co očekává váš vlastní tokenizér. Neindexujte pole slovních boxů offsety, které jste si spočítali vlastním rozdělením textu; používejte jen indexy, které vrací TrackReadingWordAt

Testujte to jako auditor, ne jako demo

„Přečetlo to nahlas můj vzorek" nic nedokazuje. Obhajitelný test spustí přes hotový build s připojeným NVDA tři soubory: jeden známě otagovaný soubor, kde jsou nadpisy ohlášeny jako nadpisy a tabulka se čte v pořadí řádků; jeden známě neotagovaný soubor, kde je viditelný indikátor odhadovaného pořadí; a sken, kde se skutečně vysloví varování o chybějícím textu. Každý z nich procvičí cestu, kterou happy case přeskočí

Dál ověřte, že kurzor slova zůstává přesně zamčený i při dvojnásobné rychlosti řeči a při poloviční, a že scrollování ReadingWordFollow nezápasí s vlastním scrollováním uživatele. Pak spusťte řeč, zatímco procházíte každý barevný filtr, a sledujte, že kurzor nikdy nezmizí. Článek o barevných filtrech pro uživatele se zbytky zraku pokrývá tuto vykreslovací cestu podrobně, a hloubkový rozbor kurzoru slova pro řeč rozebírá časování TTS

API pro jednotky čtení a slovní boxy použité výše jsou součástí PDFium Component pro Delphi a C++Builder (VCL) a Lazarus/FPC (LCL). Stránka produktu odkazuje na plnou referenci API, včetně rozvržení záznamů pro jednotky čtení a slovní boxy za těmito příklady