Technical Article

Kreiranje pristupačnog PDF čitača u Delphi-ju pomoću PDFium-a

Slepa osoba otvara kvartalni izveštaj u vašem novom Delphi čitaču, uključuje NVDA i čuje podnožje stranice, zatim kolonu sa ciframa, pa tek onda naslov koji bi svaki videći čitalac pročitao prvi. Ili ne čuje apsolutno ništa. Stranica izgleda savršeno na ekranu, i upravo u tome leži zamka: renderovanje i čitanje su različiti problemi koje rešava različit kod. Redosled kojim PDF iscrtava svoje glifove nema nikakvu obavezu da odgovara redosledu kojim osoba treba da ih čuje, tako da čitač izgrađen isključivo na pozivima za renderovanje daje besprekornu sliku, ali potpuno neupotrebljivu naraciju. Iz tog razloga, PDFium Component, VCL/LCL omotač oko PDFium biblioteke za Delphi, C++Builder i Lazarus, sadrži poseban set API-ja za čitanje. API-ji za crtanje ne mogu da povrate redosled čitanja koji im nikada nije ni prosleđen.

Pristupačan čitač stoji ili pada na tri stvari. Mora da izdvoji redosled koji čitač ekrana može da izgovori, da drži vidljivi kursor reči fokusiran na ono što glas trenutno izgovara i da prizna kada dokument uopšte nije tagovan, umesto da nagađa i pretvara se. Za svaku od ovih stvari postoji jasan API, ali i zamka koja vas može koštati ako preskočite detalje.

Redosled čitanja se nalazi u stablu strukture, a ne u redosledu iscrtavanja

ISO 32000-1 §14.8 definiše logičku strukturu kao stablo elemenata postavljenih preko sadržaja stranice. PDF/UA (ISO 14289-1) ide korak dalje i čini to stablo obaveznim: svaki deo stvarnog sadržaja mora biti dostupan kroz njega u redosledu čitanja, dok se artefakti stranice označavaju kao takvi i preskaču. Pravilno tagovan izveštaj zna da je „Kvartalni rezultati” (Quarterly Results) naslov drugog nivoa i da je tabela sa ukupnim vrednostima zapravo tabela sa ćelijama zaglavlja. Netagovani izveštaj je samo gomila pozicioniranih nizova glifova koji slučajno izgledaju kao dokument.

Metoda ReadablePageContent prolazi kroz tu strukturu kada je ona prisutna i vraća fragmente označene semantičkim tipom (Kind), kao što su vrednosti cfHeading i cfParagraph, tako da korisnički interfejs može da izgovori „naslov” pre samih reči, umesto da čita podebljanu liniju kao običan tekst pasusa. Kada nema upotrebljivog stabla, isti poziv se oslanja na heurističku analizu rasporeda: detekcija kolona, grupisanje baznih linija, ređanje sleva nadesno i odozgo nadole. To rezervno rešenje (fallback) je sasvim dovoljno za memorandum u jednoj koloni, ali je nesigurno za biltene, obrasce sa više kolona, ili bilo šta sa bočnom trakom ili izdvojenim citatom. Ono što je važno jeste da znate koji ste rezultat dobili, a API vam to jasno govori. Zapis TPdfReadableContent sadrži polje Source koje je postavljeno na rosStructure kada redosled potiče iz tagovanog stabla strukture, ili na rosHeuristic kada je izveden iz geometrije. Prikazivanje pretpostavljenog redosleda kao da je verifikovan je isto kao i objavljivanje verzije pristupačnosti sa zelenim bedžom za build koji niko nije zapravo pokrenuo.

Najjednostavniji potez pri otvaranju datoteke je očitavanje svojstva IsTagged i jednokratni poziv metode ValidatePdfUa, a zatim keširanje tog odgovora. Neuspešna PDF/UA provera nije razlog za odbijanje datoteke. To je razlog da u statusnu liniju postavite obaveštenje „procenjeni redosled čitanja”, tako da kada korisnik pošalje žalbu na nerazumljivu naraciju, tehnička podrška odmah zna da li je problem u nedostatku tagova u datoteci ili u grešci u vašem kodu.

Od stranice do reda za govor pomoću metode ReadingUnits

Za funkciju pretvaranja teksta u govor (TTS), metoda ReadingUnits obavlja najveći deo posla. Ona vraća niz zapisa TPdfReadingUnit za aktivnu stranicu, pri čemu svaki od njih sadrži tekst koji treba izgovoriti, njegovu semantičku ulogu i pravougaonike koji ga pozicioniraju na stranici. Postoji i pandan na nivou celog dokumenta, DocumentReadingUnits, kada želite neprekidno čitanje kroz više stranica. Jedna jedinica se smešta direktno u jedno mesto u redu 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;

Dve stvari u ovoj petlji je lako uraditi pogrešno. Držite red za govor vezan za pojedinačnu stranicu i ponovo ga izgradite kad god korisnik promeni stranicu, korak koji je neophodan jer jedinice za čitanje nose pravougaonike u koordinatnom sistemu te konkretne stranice; red zaostao sa treće stranice će iscrtati svoja isticanja na četvrtoj stranici. Takođe, tretirajte prazan niz Units na stranici koja očigledno ima vizuelni sadržaj kao detektor skeniranog dokumenta (image-only). Skenirana stranica se sastoji od piksela bez tekstualnog sloja ispod, a ispravan odgovor je emitovanje glasovnog upozorenja („ova stranica nema tekst koji se može izdvojiti”), umesto da čitač utihne na način koji slušalac ne može razlikovati od zamrzavanja aplikacije.

Kursor reči koji prati glas

Isticanje celog pasusa odjednom deluje tromo slabovidom korisniku koji očima prati reči dok mu se čitaju naglas. Isticanje na nivou reči, takozvani karaoke efekat, zahteva dva dela: geometriju svake pojedinačne reči i način za mapiranje izveštaja o napretku TTS motora na tu geometriju. Svojstvo PageWordBoxes pruža geometriju u vidu zapisa TPdfWordBox, od kojih svaki sadrži tekst reči, njen karakter-offset, broj karaktera i pravougaonik na stranici. Funkcija TrackReadingWordAt obezbeđuje mapiranje. Prosledite joj poziciju karaktera koju SAPI-jev događaj granice reči (word-boundary event) već prijavljuje, a ona će u jednom pozivu razrešiti taj offset u indeks unutar niza okvira reči i iscrtati kursor na odgovarajućoj reči.

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;

Ovaj ugovor je velikodušan po jednom pitanju, a nemilosrdan po drugom. Velikodušan deo: TrackReadingWordAt zadržava sopstveni keš okvira reči za stranicu koju prati, tako da nema potrebe za prethodnim učitavanjem, a renderovanje se uopšte ne dešava jer okviri reči dolaze iz tekstualnog sloja. Servis za govor bez korisničkog interfejsa (headless) i dalje može pratiti pozicije. Nemilosrdan deo: indeks karaktera mora da pokazuje na tekst koji je komponenta ekstrahovala, a ne na neki očišćeni string koji ste sami napravili. Kada CharIndex pređe kraj teksta stranice, funkcija vraća -1 umesto da podigne izuzetak, što se često dešava kada TTS motor aktivira poslednji događaj granice za prateće znakove interpunkcije. Tretirajte vrednost -1 kao znak da treba očistiti kursor, nikada kao grešku.

Što se tiče prikaza, ReadingWordColor postavlja boju kursora. Podrazumevana ćilibarska boja (amber) se dobro vidi na većini pozadina stranica, ali je testirajte pod svakim filterom prikaza koji vaš čitač nudi. Ćilibarski kursor može potpuno nestati pod inverzijom boja, a inverzija koja radi uporedo sa govorom je upravo način na koji slabovidi korisnik radi, tako da je kombinacija koju najviše morate ispravno implementirati ona koju brza demonstracija nikada ne pokriva. Postavite ReadingWordFollow na True i prikaz će samostalno skrolovati izgovorenu reč u vidno polje, što je neophodno na uvećanoj stranici koja prevazilazi okvire ekrana. Imajte na umu jedno pravilo opsega: SetReadingWord crta samo na aktivnoj TPdfView stranici. Odlučite unapred da li ručno skrolovanje pauzira govor ili ga ponašanje praćenja (follow) nadjačava, jer ako ne izaberete nijedno, glas će nastaviti da čita dok kursor stoji negde van ekrana.

Dokumenti koji kvare vaš čitač

Nekoliko specifičnih oblika ulaznih dokumenata pouzdano ruši naivnu implementaciju, pa oni treba da budu stalni uzorci u regresionom kompletu testova, a ne regresione greške koje popravite i zaboravite.

  • Netagovane datoteke bogate tekstom. Heuristički redosled obično bude tačan za linearne izveštaje, ali greši čim se pojavi bočna traka ili izdvojeni citat. Označite redosled kao procenjen, kako u korisničkom interfejsu tako i u logu dijagnostike, kako bi problem kasnije bio lakše uočljiv.
  • Skenirani dokumenti koji sadrže samo slike. Nemaju nikakav tekstualni sloj. Detektujte ih preko praznih jedinica za čitanje i usmerite korisnika na OCR obradu pre uvoza, umesto da dozvolite čitaču da čita praznu stranicu.
  • Kombinovani znakovi i mešovita pisma. Unicode kombinujući znakovi se ne stapaju uvek jedan-prema-jedan u vizuelne reči, pa broj okvira reči (word-box) može da odstupa od onoga što vaš sopstveni tokenizator očekuje. Nemojte indeksirati niz okvira reči pomoću offseta koje ste sami izračunali deljenjem teksta; koristite isključivo indekse koje vraća funkcija TrackReadingWordAt.

Testirajte kao revizor, a ne kao demonstrator

Rečenica „Pročitao je moj uzorak naglas” ne dokazuje ništa. Uspešan test koji možete odbraniti podrazumeva pokretanje tri datoteke kroz gotov build sa uključenim NVDA čitačem: jednu provereno tagovanu datoteku, gde se naslovi najavljuju kao naslovi a tabela se čita po redovima; jednu provereno netagovanu datoteku, gde je vidljiv indikator procenjenog redosleda čitanja; i jedan skeniran dokument, gde se zaista izgovara upozorenje o nedostatku teksta. Svaki od ovih primera testira putanju koju idealni slučajevi preskaču.

Nakon toga, potvrdite da kursor reči ostaje zaključan i pri dvostrukoj i pri prepolovljenoj brzini govora, kao i da skrolovanje pomoću opcije ReadingWordFollow ne ometa korisnikovo ručno skrolovanje. Zatim pokrenite čitanje dok prolazite kroz svaki filter boja i proverite da li kursor ikada nestaje. Članak o filterima boja za slabovide korisnike detaljno pokriva tu putanju renderovanja, dok se članak o detaljnom prikazu kursora govora reči bavi TTS sinhronizacijom.

API-ji za jedinice čitanja (reading units) i okvire reči (word boxes) koji su korišćeni iznad isporučuju se sa PDFium komponentom za Delphi i C++Builder (VCL) i Lazarus/FPC (LCL). Stranica proizvoda vodi do kompletne API reference, uključujući rasporede zapisa za jedinice čitanja i okvire reči koji stoje iza ovih primera.