Technical Article

Izrada pristupačnog PDF čitača u Delphiju pomoću PDFiuma

Slijepi korisnik otvara tromjesečno izvješće u vašem novom Delphi pregledniku, uključuje NVDA i čuje podnožje stranice, zatim stupac brojki, a tek onda naslov koji bi svaki videći čitatelj pročitao prvi. Ili ne čuje apsolutno ništa. Stranica izgleda savršeno na zaslonu, a to je upravo zamka: iscrtavanje i čitanje su različiti problemi koje rješava različit kod. Redoslijed kojim PDF crta svoje glifove ne mora se podudarati s redoslijedom kojim bi ih osoba trebala čuti, pa preglednik izgrađen samo na pozivima za iscrtavanje stvara besprijekornu sliku i neupotrebljivo čitanje. Iz tog razloga PDFium Component, VCL/LCL omotač oko PDFium biblioteke za Delphi, C++Builder i Lazarus, sadrži poseban skup API-ja za čitanje. API-ji za crtanje ne mogu vratiti redoslijed čitanja koji im nikada nije ni predan.

Pristupačan čitač stoji ili pada na tri stvari. Mora izdvojiti redoslijed koji čitač zaslona može izgovoriti, držati vidljivi kursor riječi sinkroniziranim s onim što glas govori i priznati kada dokument uopće nije označen, umjesto da nagađa i pretvara se. Svaki od ovih zahtjeva ima jasan API i potencijalnu pogrešku ako previdite detalje.

Redoslijed čitanja nalazi se u stablu strukture, a ne u redoslijedu crtanja

ISO 32000-1 §14.8 definira logičku strukturu kao stablo elemenata postavljenih preko sadržaja stranice. PDF/UA (ISO 14289-1) ide korak dalje i čini to stablo obveznim: svaki dio stvarnog sadržaja mora biti dostupan kroz stablo u redoslijedu čitanja, dok se artefakti stranice označavaju kao takvi i preskaču. Ispravno označeno izvješće zna da je "Tromjesečni rezultati" (Quarterly Results) naslov druge razine i da je tablica s ukupnim iznosima zapravo tablica sa zaglavljima ćelija. Neoznačeno izvješće samo je hrpa pozicioniranih glifova koji slučajno izgledaju kao dokument.

ReadablePageContent prolazi kroz tu strukturu kada je prisutna i vraća fragmente označene semantičkim tipom Kind (vrijednosti poput cfHeading i cfParagraph), tako da korisničko sučelje može najaviti "naslov" prije samih riječi, umjesto da čita podebljanu liniju kao običan tekst. Bez upotrebljivog stabla, isti poziv se oslanja na heurističku analizu rasporeda: detektira stupce, grupiraju se osnovne linije teksta, te ih sortira slijeva nadesno i odozgo prema dolje. Taj je pričuvni mehanizam u redu za dopise u jednom stupcu, ali je nesiguran za biltene, obrasce s više stupaca, ili bilo što s bočnom trakom i istaknutim citatima. Važno je znati koji ste rezultat dobili, a API vam to izravno govori. Zapis TPdfReadableContent sadrži polje Source postavljeno na rosStructure kada redoslijed dolazi iz označenog stabla, ili rosHeuristic kada je izveden iz geometrije rasporeda. Ako prikazujete nagađani redoslijed kao provjeren, isporučili ste pristupačnost koja je ekvivalent zelenog prolaznog statusa na testu koji nitko nije pokrenuo.

Jednostavan potez pri otvaranju je pročitati IsTagged i jednom pozvati ValidatePdfUa, a zatim spremiti odgovor u predmemoriju. Neuspjela provjera PDF/UA standarda nije razlog za odbijanje datoteke. To je razlog da u statusnu traku postavite obavijest "procijenjeni redoslijed čitanja", tako da kada korisnik pošalje prigovor o nerazumljivom čitanju, podrška već zna radi li se o problemu s označavanjem u datoteci ili o programskoj pogrešci u vašem kodu.

Od stranice do reda čekanja za govor pomoću ReadingUnits

Za pretvaranje teksta u govor (TTS), ReadingUnits obavlja glavni posao. Vraća niz zapisa TPdfReadingUnit za aktivnu stranicu, pri čemu svaki sadrži tekst za izgovor, njegovu semantičku ulogu i pravokutnike koji ga lociraju na stranici. Postoji i pandan na razini cijelog dokumenta, DocumentReadingUnits, kada želite kontinuirano čitanje kroz stranice. Jedna jedinica ide izravno u jedno mjesto reda čekanja za govor:

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;

Dvije stvari u toj petlji lako je pogriješiti. Držite red čekanja po stranici i ponovno ga izgradite kad god korisnik navigira, cuz jedinice čitanja sadrže pravokutnike u koordinatama stranice; red čekanja preostao s treće stranice iscrtat će svoja isticanja na četvrtoj stranici. Također, prazan niz Units na stranici koja očito ima sadržaj tretirajte kao detektor skeniranog sadržaja (samo slika). Skenirana stranica sastoji se od piksela bez tekstualnog sloja ispod, a ispravan odgovor je izgovoriti upozorenje ("ova stranica nema tekst koji se može izdvojiti"), umjesto da ušutite na način koji slušatelj ne može razlikovati od zamrzavanja aplikacije.

Kursor riječi koji prati glas

Isticanje cijelog odlomka odjednom djeluje tromo slabovidnom korisniku koji očima prati riječi dok se čitaju naglas. Isticanje na razini pojedinačne riječi (efekt karaoke) zahtijeva dva dijela: geometriju svake riječi i način mapiranja izvješća o napretku TTS motora na tu geometriju. PageWordBoxes vam daje geometriju kao zapise TPdfWordBox, od kojih svaki sadrži tekst riječi, njezin pomak znaka (offset), broj znakova i pravokutnik u koordinatama stranice. TrackReadingWordAt omogućuje to mapiranje. Proslijedite mu poziciju znaka koju javlja SAPI-jev događaj granice riječi (word boundary), a on razrješava taj pomak u indeks unutar niza pravokutnika riječi te iscrtava kursor na odgovarajućoj riječi u jednom pozivu.

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;

Ugovor ove metode je velikodušan po jednom pitanju, a neumoljiv po drugom. Velikodušan dio: TrackReadingWordAt čuva vlastitu predmemoriju okvira riječi za stranicu koju prati, tako da nema potrebe za prethodnim učitavanjem, a iscrtavanje se uopće ne događa jer okviri riječi dolaze iz tekstualnog sloja. Pozadinska govorna usluga bez vidljivog prozora i dalje može pratiti pozicije. Neumoljiv dio: indeks znaka mora pokazivati na tekst koji je komponenta izdvojila, a ne na neki pročišćeni niz koji ste sami izgradili. Kada CharIndex prijeđe kraj teksta stranice, funkcija vraća -1 umjesto da podigne iznimku, što se stalno događa kada TTS motor ispali posljednji događaj granice riječi za završne znakove interpunkcije. Tretirajte -1 kao naredbu "ukloni kursor", nikada kao pogrešku.

Što se tiče prikaza, ReadingWordColor postavlja boju kursora. Zadana jantarna boja (amber) dobro se vidi na većini pozadina stranica, ali je testirajte pod svakim filtrom prikaza koji vaš preglednik nudi. Jantarni kursor može potpuno nestati pod inverzijom boja, a inverzija koja radi istovremeno s govorom upravo je način na koji slabovidni korisnici rade, pa je ta kombinacija najvažnija za ispravan rad, a brzi demo je nikada ne testira. Postavite ReadingWordFollow na True i preglednik će samostalno pomaknuti izgovorenu riječ u vidno polje, što je neophodno na zumiranoj stranici koja prelazi granice zaslona. Pripazite na pravilo opsega: SetReadingWord crta samo na aktivnoj stranici komponente TPdfView. Unaprijed odlučite hoće li ručno pomicanje pauzirati govor li će ga ponašanje praćenja nadjačati, jer odabir nijednog ostavlja glas da čita dalje dok kursor stoji negdje izvan zaslona.

Dokumenti koji kvare rad vašeg čitača

Nekoliko specifičnih oblika ulaznih podataka pouzdano kvari jednostavne implementacije, pa bi trebali biti trajni primjeri u vašim testovima, a ne jednokratne pogreške koje popravite i zaboravite.

  • Neoznačene datoteke s puno teksta. Heuristički redoslijed obično je točan za linearne izvještaje, a pogrešan čim se pojavi bočna traka ili istaknuti citat. Označete redoslijed kao procijenjen, kako u korisničkom sučelju tako i u dijagnostičkom dnevniku, kako bi neuspjeh bio vidljiv i jasan kasnije.
  • Skenirani dokumenti koji sadrže samo slike. Nema nikakvog tekstualnog sloja. Uhvatite ih preko praznih jedinica čitanja i usmjerite korisnika na OCR korak, umjesto da dopustite čitaču da čita praznu stranicu.
  • Kombinirajući znakovi i miješana pisma. Unicode oznake za kombiniranje ne sažimaju se uvijek jedan-na-jedan u vizualne riječi, pa broj okvira riječi može odstupati od onoga što očekuje vaš vlastiti tokenizer. Nemojte indeksirati niz okvira riječi s pomacima koje ste sami izračunali dijeljenjem teksta; koristite isključivo indekse koje vraća funkcija TrackReadingWordAt.

Testirajte kao revizor, a ne kao običan demo

"Činjenica da je \"pročitao moj uzorak naglas\" ne dokazuje ništa. Test koji možete braniti zahtijeva prolazak triju datoteka kroz gotov proizvod s aktivnim NVDA čitačem zaslona: jednu provjereno označenu datoteku, gdje se naslovi najavljuju kao naslovi, a tablica se čita po redovima; jednu provjereno neoznačenu datoteku, gdje je vidljiv indikator procijenjenog redoslijeda; i skeniranu datoteku, gdje se doista izgovara upozorenje o nedostatku teksta. Svaki od ovih slučajeva pokreće stazu koju uobičajeni sretni scenarij preskače.

Nakon toga potvrdite da kursor riječi ostaje zaključan na dvostrukoj i polovičnoj brzini govora, te da se automatsko pomicanje ReadingWordFollow ne sukobljava s korisničkim ručnim pomicanjem. Zatim pokrenite govor dok prolazite kroz svaki filtar boja i pratite da kursor nikada ne nestane. Članak o filtrima boja za slabovidne detaljno pokriva taj renderirajući put, a članak o dubinskoj analizi kursora govora riječi bavi se TTS vremenskim usklađivanjem.

API-ji za jedinice čitanja i okvire riječi koji se koriste gore isporučuju se s komponentom PDFium Component za Delphi i C++Builder (VCL) te Lazarus/FPC (LCL). Stranica proizvoda vodi do potpune dokumentacije API-ja, uključujući rasporede zapisa za jedinice čitanja i okvire riječi iza ovih primjera.