Tekninen artikkeli

Rakenna saavutettava PDF-lukija Delphissä PDFiumin avulla

Sokea käyttäjä avaa vuosineljännesraportin (quarterly report) kiiltävässä uudessa Delphi-katseluohjelmassasi, laittaa NVDA:n päälle ja kuulee sivun alatunnisteen, sitten sarakkeen numeroita, ja sitten otsikon, jonka kuka tahansa näkevä lukija olisi lukenut ensimmäisenä. Tai ei kuule yhtään mitään. Sivu näyttää täydelliseltä ruudulla, ja se on juuri se ansa: renderöinti (rendering) ja lukeminen ovat eri ongelmia, jotka ratkaistaan eri koodilla. Järjestyksellä (order), jolla PDF maalaa (paints) glyyfinsä (glyphs), ei ole mitään velvollisuutta vastata järjestystä, jolla ihmisen tulisi kuulla ne, joten pelkkien renderöintikutsujen (rendering calls) varaan rakennettu katseluohjelma (viewer) tuottaa virheettömän kuvan ja käyttökelvottoman kerronnan (narration). PDFium Component, VCL/LCL-kääre (wrapper) PDFium-moottorin (engine) ympärillä Delphiä, C++Builderia ja Lazarusta varten, kantaa tästä syystä erillistä joukkoa luku-API:ja. Piirto-API:t eivät voi palauttaa (recover) lukujärjestystä (reading order), jota niille ei koskaan annettu

Saavutettava (accessible) lukija seisoo tai kaatuu kolmen asian varassa. Sen on poimittava järjestys, jonka ruudunlukija (screen reader) voi puhua (speak), pidettävä näkyvä sanakohdistin (word cursor) kiinnitettynä siihen, mitä ääni sanoo, ja myönnettävä, kun asiakirjaa ei ole koskaan koodattu (tagged), arvaamisen ja teeskentelyn sijaan. Jokaiseen on olemassa selkeä API kurotettavaksi ja virhe (failure), joka puraisee, jos ohitat (skip) yksityiskohdan

Lukujärjestys elää rakennepuussa (structure tree), ei piirtojärjestyksessä (paint order)

ISO 32000-1 §14.8 määrittelee loogisen rakenteen (logical structure) sivun sisällön ylle kerrostetuksi elementtien puuksi. PDF/UA (ISO 14289-1) menee pidemmälle ja tekee tuosta puusta pakollisen: jokaisen todellisen sisällön palan (piece of real content) on oltava saavutettavissa (reachable) sen kautta lukujärjestyksessä (reading order), siten, että sivu-artefaktit (page artifacts) on merkitty sellaisiksi ja ohitetaan (skipped). Oikein koodattu (tagged) raportti tietää, että "Vuosineljänneksen tulokset" on kakkostason otsikko (level-two heading) ja että summien ruudukko on taulukko (table), jolla on otsikkosoluja (header cells). Koodaamaton (untagged) raportti on vain kasa paikoitettuja (positioned) glyyfivetoja (glyph runs), jotka sattuvat näyttämään asiakirjalta

ReadablePageContent kävelee tuon rakenteen läpi (walks that structure), kun se on läsnä, ja palauttaa pirstaleita (fragments), jotka on koodattu (tagged) semanttisella (semantic) lajilla (Kind), eli arvoilla kuten cfHeading ja cfParagraph, joten käyttöliittymä (UI) voi sanoa "otsikko" ennen sanoja sen sijaan, että se lukisi lihavoidun (bold) rivin tavallisena leipätekstinä (body text). Ilman käyttökelpoista puuta sama kutsu putoaa (falls back) heuristiseen (heuristic) asetteluanalyysiin (layout analysis): tunnista sarakkeet (detect columns), ryhmittele (cluster) perusviivat (baselines), järjestä (order) vasemmalta oikealle ja ylhäältä alas. Tuo varatoimi (fallback) on hieno yksisarakkeiselle (single-column) muistiolle (memo) ja horjuva (shaky) uutiskirjeelle (newsletter), monisarakkeiselle lomakkeelle (multi-column form), mille tahansa, jossa on sivupalkki (sidebar) tai nosto (pull quote). Merkitystä on sillä, että tiedät kumman tuloksen sait, ja API kertoo sen sinulle suoraan. TPdfReadableContent-tietue (record) kantaa Source-kenttää, joka on asetettu arvoon rosStructure, kun järjestys tuli koodatusta puusta (tagged tree), tai arvoon rosHeuristic, kun se pääteltiin (inferred) geometriasta. Näytä (show) arvattu (guessed) järjestys ikään kuin se olisi vahvistettu (verified), ja olet laivannut (shipped) saavutettavuusversion (accessibility version) läpipääsymerkistä (passing badge) käännökselle (build), jota kukaan ei ajanut

Halpa siirto (cheap move) avaushetkellä (open time) on lukea IsTagged ja kutsua ValidatePdfUa kerran, ja sitten laittaa vastaus välimuistiin (cache). Epäonnistunut PDF/UA-tarkistus ei ole peruste kieltäytyä (refuse) tiedostosta. Se on peruste laittaa "arvioitu lukujärjestys" (estimated reading order) tilapalkkiin (status bar), jotta kun asiakas lähettää (mails in) valituksen (complaint) sotketusta (garbled) kerronnasta (narration), tuki (support) tietää jo, katsovatko he koodausongelmaa (tagging problem) tiedostossa vai bugia koodissasi

Sivulta puhejonoon (speech queue) ReadingUnits-tietueiden (ReadingUnits) avulla

Teksti-puheeksi-ominaisuudelle (text-to-speech) raskaan työn tekee ReadingUnits. Se palauttaa aktiiviselle sivulle taulukon (array) TPdfReadingUnit-tietueita (records), joista jokainen pitää sisällään puhuttavan tekstin, sen semanttisen (semantic) roolin ja suorakulmiot (rectangles), jotka paikantavat sen sivulla. Olemassa on koko asiakirjan laajuinen (document-wide) kumppani (companion), DocumentReadingUnits, kun haluat jatkuvaa (continuous) lukemista sivujen yli. Yksi yksikkö (unit) putoaa (drops) suoraan yhteen koloon (slot) puhejonossa (speech queue):

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;

Kaksi asiaa tuossa silmukassa (loop) on helppo tehdä väärin. Pidä jono (queue) sivukohtaisena (per page) ja rakenna se uudelleen aina, kun käyttäjä navigoi, koska lukuyksiköt kantoisivat sivuavaruuden suorakulmioita (page-space rectangles); sivulta kolme yli jäänyt (left over) jono maalaa (paints) korostuksensa (highlights) sivulle neljä. Ja käsittele tyhjää Units-taulukkoa sivulla, jolla selvästi on sisältöä, vain kuvan sisältävän (image-only) tunnistimenasi (detector). Skannattu sivu on pikseleitä, joiden alla ei ole tekstikerrosta (text layer), ja oikea vastaus on puhua (speak) varoitus ("tällä sivulla ei ole poimittavaa tekstiä") sen sijaan, että vaipuisit hiljaisuuteen tavalla, jota kuuntelija (listener) ei erota jumittumisesta (hang)

Sanakohdistin (word cursor), joka seuraa ääntä

Koko kappaleen (paragraph) korostaminen (highlighting) kerrallaan tuntuu hitaalta (sluggish) heikkonäköisestä käyttäjästä, joka seuraa (tracking) sanoja silmällä samalla kun niitä luetaan ääneen. Sanatason (word-level) korostus (karaoke-efekti) vaatii kaksi osaa: jokaisen sanan geometrian ja tavan kartoittaa (map) TTS-moottorin (engine) edistymisraportit (progress reports) tuohon geometriaan. PageWordBoxes antaa sinulle geometrian TPdfWordBox-tietueina (records), joista jokaisessa on sanan teksti (word text), sen merkkisiirtymä (character offset), sen merkkien lukumäärä (character count) ja sivuavaruuden (page-space) suorakulmio. TrackReadingWordAt antaa sinulle kartoituksen (mapping). Syötä sille merkkisijainti (character position), josta SAPI:n sanarajatapahtuma (word-boundary event) jo raportoi, ja se ratkaisee (resolves) tuon siirtymän (offset) sanalaatikkotaulukon (word-box array) indeksiksi ja maalaa kohdistimen (cursor) vastaavaan sanaan yhdellä kutsulla

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;

Sopimus (contract) on antelias (generous) yhdellä tapaa (on one count) ja anteeksiantamaton (unforgiving) toisella. Antelias puoli: TrackReadingWordAt pitää yllä omaa sanalaatikkovälimuistiaan (word-box cache) sille sivulle, jota se seuraa, joten mitään ei tarvitse esiladata (pre-load), eikä renderöintiä tapahdu lainkaan, koska sanalaatikot (word boxes) tulevat tekstikerroksesta (text layer). Päälaite-erotettu (headless) puhepalvelu (speech service) ilman näkyvää ikkunaa voi silti seurata sijainteja. Anteeksiantamaton puoli: merkki-indeksin (character index) on osoitettava komponentin poimimaan (extracted) tekstiin, ei johonkin siivottuun (cleaned-up) merkkijonoon (string), jonka itse rakensit (built). Kun CharIndex juoksee (runs) sivun tekstin lopun ohi, funktio palauttaa -1 sen sijaan, että se nostaisi (raising) poikkeuksen, mitä tapahtuu koko ajan, kun TTS-moottori (engine) laukaisee (fires) yhden viimeisen rajatapahtuman (boundary event) perässä laahaaville (trailing) välimerkeille (punctuation). Lue -1 muodossa "tyhjennä kohdistin" (clear the cursor), älä koskaan virheenä

Näyttöpuolella (display side) ReadingWordColor asettaa kohdistimen värin. Oletusarvoinen meripihka (amber) kestää useimpien sivutaustojen (page backgrounds) päällä, mutta testaa sitä jokaisella näyttösuodattimella (display filter), jota katseluohjelmasi tarjoaa. Meripihkanvärinen (amber) kohdistin voi kadota (vanish) kokonaan värin kääntämisen (color inversion) alla, ja kääntäminen puheen (speech) rinnalla on juuri se tapa, jolla heikkonäköinen käyttäjä (low-vision user) työskentelee, joten se yksi yhdistelmä (combination), jonka saaminen oikein on sinulle kaikkein tärkeintä, on se, jota nopea demo (quick demo) ei koskaan harjoita (exercises). Aseta ReadingWordFollow arvoon True, ja näkymä (view) vierittää (scrolls) puhutun sanan näkyviin itsestään, ilman mitä et voi tulla toimeen (which you cannot do without) zoomatulla sivulla, joka vuotaa (spills) näyttöjen (screens) yli. Pidä mielessä yksi soveltamisalasääntö (scope rule): SetReadingWord maalaa (paints) vain aktiiviselle TPdfView-sivulle. Päätä etukäteen (up front), keskeyttääkö manuaalinen vieritys (manual scrolling) puheen vai kumoako (overrides) seuraamiskäyttäytyminen (follow behavior) sen, koska valitsematta kumpaakaan ääni jatkaa lukemistaan, kun kohdistin istuu jossain ruudun ulkopuolella (off screen)

Asiakirjat, jotka rikkovat lukijasi

Kourallinen syötemuotoja (input shapes) päihittää naiivin toteutuksen (naive implementation) riittävän luotettavasti, jotta ne kuuluvat pysyviksi (permanent) näytteiksi regressiosarjaan (regression suite), ei yksittäisiksi (one-off) bugeiksi, jotka korjaat ja unohdat

  • Koodaamattomat (untagged) mutta tekstirikkaat tiedostot. Heuristinen (heuristic) järjestys pyrkii olemaan (tends to be) oikein lineaariselle raportille ja väärin sillä hetkellä, kun sivupalkki (sidebar) tai nosto (pull quote) astuu kuvaan (enters). Liputa (flag) järjestys arvioiduksi (estimated), sekä käyttöliittymässä (UI) että diagnostiikkalokissasi (diagnostics log), jotta vika (failure) on myöhemmin luettavissa (legible)
  • Vain kuvan sisältävät (image-only) skannaukset. Ei lainkaan tekstikerrosta (text layer). Ota ne kiinni (catch) tyhjien lukuyksiköiden (reading units) kautta ja osoita käyttäjä kohti OCR-vaihetta ylävirtaan (upstream) sen sijaan, että antaisit lukijan kertoa (narrate) tyhjän sivun
  • Yhdistävät merkit (combining characters) ja sekoitetut kirjoitusjärjestelmät (mixed scripts). Unicode-yhdistelmämerkit (combining marks) eivät aina romahda (collapse) yksi yhteen (one-to-one) visuaalisiksi sanoiksi, joten sanalaatikoiden (word-box) määrä (count) voi ajautua (drift) poikkeamaan siitä, mitä oma tokenoijasi (tokenizer) odottaa. Älä indeksoi sanalaatikkotaulukkoa (word-box array) siirtymillä (offsets), jotka laskit pilkkomalla (splitting) tekstin itse; käytä vain niitä indeksejä (indices), jotka TrackReadingWordAt palauttaa

Testaa sitä kuin tarkastaja (auditor), älä kuten demoa (demo)

"Se luki näytteeni ääneen" ei todista mitään. Läpimeno (pass), jota voit puolustaa (defend), ajaa (runs) kolme tiedostoa valmiin käännöksen (finished build) läpi NVDA liitettynä: yksi tiedetty koodatuksi (known-tagged file), jossa otsikot (headings) ilmoitetaan otsikoina ja taulukko (table) luetaan rivijärjestyksessä (row order); yksi tiedetty koodaamattomaksi (known-untagged file), jossa arvioidun järjestyksen indikaattori (estimated-order indicator) on näkyvissä; ja skannaus (scan), jossa varoitus tekstin puuttumisesta (no-text warning) oikeasti puhutaan (spoken). Jokainen niistä (each one) harjoittaa (exercises) polkua, jonka onnellinen tapaus (happy case) ohittaa (skips)

Varmista (confirm) sieltä (from there), että sanakohdistin (word cursor) pysyy lukittuna (stays locked on) kaksinkertaisella (double) ja puolikkaalla puhenopeudella (speech rate), ja ettei ReadingWordFollow -vieritys (scrolling) paini (wrestle) käyttäjän oman vierityksen kanssa. Aja (run) sitten puhetta samalla, kun käyt läpi (cycle through) kaikki värisuodattimet (color filters), ja katso, ettei kohdistin koskaan katoa. Artikkeli pdfium-component-low-vision-color-filters.html, heikkonäköisten värisuodattimet (low-vision color filters), kattaa (covers) tuon renderöintipolun (rendering path) yksityiskohtaisesti, ja artikkelin pdfium-component-word-speech-cursor-delphi.html, sanapuhekohdistimen syväsukellus (word speech cursor deep dive), purkaa TTS:n ajoituksen (timing)

Yllä käytetyt lukuyksikkö- (reading-unit) ja sanalaatikko-API:t (word-box APIs) toimitetaan PDFium Component -komponentin mukana Delphiä ja C++Builderia (VCL) sekä Lazarusta/FPC:tä (LCL) varten. Tuotesivu linkittää täydelliseen API-viitteeseen (API reference), mukaan lukien näiden esimerkkien (examples) taustalla (behind) olevat tietue-asettelut (record layouts) lukuyksiköille (reading units) ja sanalaatikoille (word boxes)