Teknisk artikel

Byg en tilgængelig PDF-læser i Delphi med PDFium

En blind bruger åbner en kvartalsrapport i din skinnende nye Delphi-fremviser, slår NVDA til og hører sidefoden, derefter en kolonne af tal, derefter titlen, som enhver seende læser ville have læst først. Eller hører slet ingenting. Siden ser perfekt ud på skærmen, og det er præcis fælden: rendering og læsning er forskellige problemer løst af forskellig kode. Rækkefølgen, en PDF maler sine glyffer i, har ingen forpligtelse til at matche den rækkefølge, en person bør høre dem i, så en fremviser bygget udelukkende på renderingskald producerer et fejlfrit billede og en ubrugelig oplæsning. PDFium Component, VCL/LCL-wrapperen omkring PDFium-motoren til Delphi, C++Builder og Lazarus, bærer et separat sæt læse-API'er af netop denne grund. Tegne-API'erne kan ikke genskabe en læserækkefølge, de aldrig fik givet

En tilgængelig fremviser står eller falder på tre ting. Den skal udtrække en rækkefølge, en skærmlæser kan tale, holde en synlig ordmarkør fastgjort til det, stemmen siger, og indrømme, når et dokument aldrig blev tagget, i stedet for at gætte og lade som om. Hver eneste har et klart API at gribe fat i og en fejl, der bider, hvis du springer detaljen over

Læserækkefølgen bor i strukturtræet, ikke i maleordenen

ISO 32000-1 §14.8 definerer logisk struktur som et træ af elementer lagt oven på sideindholdet. PDF/UA (ISO 14289-1) går videre og gør det træ obligatorisk: hvert eneste stykke reelt indhold skal kunne nås gennem det i læserækkefølge, med sideartefakter markeret som sådan og sprunget over. En korrekt tagget rapport ved, at "Quarterly Results" er en niveau-to-overskrift, og at totalgitteret er en tabel med overskriftsceller. En utagget rapport er en bunke positionerede glyfforløb, der tilfældigvis ligner et dokument

ReadablePageContent gennemløber det træ, når det er til stede, og afleverer fragmenter tagget med en semantisk Kind, værdier som cfHeading og cfParagraph, så UI'et kan sige "heading" før ordene i stedet for at læse en fed linje som almindelig brødtekst. Uden noget brugbart træ falder det samme kald tilbage på heuristisk layoutanalyse: registrer kolonner, klynge baselines, sorter fra venstre mod højre og top til bund. Den fallback er fin til et enkeltkolonne-memo og skrøbelig til et nyhedsbrev, en formular med flere kolonner, noget med en sidebjælke eller et udtrukket citat. Det, der betyder noget, er at vide, hvilket resultat du fik, og API'et fortæller dig det ligeud. Recorden TPdfReadableContent bærer et Source-felt sat til rosStructure, når rækkefølgen kom fra det taggede træ, eller rosHeuristic, når den blev udledt fra geometri. Vis en gættet rækkefølge, som om den var verificeret, og du har afsendt tilgængelighedsversionen af et bestået-badge på en build, ingen har kørt

En PDFium tilgængelig læser i Delphi tager læserækkefølgen fra det taggede strukturtræ og falder tilbage til heuristisk layoutanalyse for utaggede PDF'er, med TPdfReadableContent Source-feltet, der adskiller rosStructure fra rosHeuristic
Det taggede træ annoncerer overskrifter og rækkefølge, mens den heuristiske fallback gætter ud fra geometri, og Source-feltet holder verificeret adskilt fra anslået

Det billige træk ved åbningstid er at læse IsTagged og kalde ValidatePdfUa én gang og så cache svaret. Et mislykket PDF/UA-tjek er ikke grund til at afvise filen. Det er grund til at sætte "estimated reading order" i statuslinjen, så når en kunde mailer ind en klage over forvrænget oplæsning, ved supporten allerede, om de kigger på et taggingproblem i filen eller en bug i din kode

Fra side til talekø med ReadingUnits

Til tekst-til-tale klarer ReadingUnits det tunge løft. Det returnerer et array af TPdfReadingUnit-records for den aktive side, hver holdende teksten, der skal tales, dens semantiske rolle og de rektangler, der lokaliserer den på siden. Der er en dokumentbred fælle, DocumentReadingUnits, når du vil have kontinuerlig læsning på tværs af sider. Én enhed dropper lige ned i én plads i en talekø:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits virker på den aktive side
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // tekst + semantik + fremhævningsrektangler
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

To ting i den løkke er lette at få galt. Hold køen pr. side, og genopbyg den, hver gang brugeren navigerer, fordi læseenheder bærer siderum-rektangler; en kø, der er levn fra side tre, vil male sine fremhævninger på side fire. Og behandl et tomt Units-array på en side, der tydeligvis har indhold, som din image-only-detektor. En scannet side er pixels uden noget tekstlag under, og det rigtige svar er at tale en advarsel ("this page has no extractable text") frem for at forstumme på en måde, lytteren ikke kan skelne fra en hængning

PDFium ReadingUnits i Delphi omdanner den aktive side til tekst, semantisk rolle og page space-rektangler, der fylder en skærmlæsertalekø én enhed pr. slot, med et tomt units-array, der flagger en scannet side til en talt advarsel
Læseenheder taber én pr. plads ned i talekøen, og et tomt array på en indholdsbærende side er den skannet-side-detektor

En ordmarkør, der følger stemmen

At fremhæve et helt afsnit ad gangen føles trægt for en synshæmmet bruger, der følger ordene med øjet, mens de læses højt. Ordniveau-fremhævning, karaoke-effekten, kræver to dele: geometrien for hvert ord og en måde at mappe TTS-motorens fremdriftsrapporter over på den geometri. PageWordBoxes giver dig geometrien som TPdfWordBox-records, hver med ordteksten, dets tegnoffset, dets tegnantal og et siderum-rektangel. TrackReadingWordAt giver dig mapningen. Fodrer du den med den tegnposition, SAPI's word-boundary-hændelse allerede rapporterer, løser den det offset til et indeks i word-box-arrayet og maler markøren på det matchende ord i ét kald

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // Viewets word boxes kommer fra den side, viewet viser.
  // At sætte Pdf.PageNumber alene ville ikke flytte viewet
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt mapper offsettet OG maler ordmarkøren
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // grænsen løb forbi sidens tekst
end;

Kontrakten er gavmild på ét punkt og ubarmhjertig på et andet. Det gavmilde: TrackReadingWordAt holder sin egen word-box-cache for den side, den sporer, så der er intet at forudindlæse, og der sker slet ingen rendering, fordi word boxene kommer fra tekstlaget. En headless taletjeneste uden noget synligt vindue kan stadig spore positioner. Det ubarmhjertige: tegnindekset skal pege ind i teksten, komponenten udtrak, ikke ind i en eller anden oprenset streng, du selv byggede. Når CharIndex løber forbi enden af sidens tekst, returnerer funktionen -1 i stedet for at rejse en fejl, hvilket sker hele tiden, når en TTS-motor affyrer én sidste grænsehændelse for efterfølgende tegnsætning. Læs -1 som "clear the cursor", aldrig som en fejl

På visningssiden sætter ReadingWordColor markørfarven. Standard-ravgul holder sig godt over de fleste sidebaggrunde, men test den under hvert visningsfilter, din fremviser tilbyder. En ravgul markør kan forsvinde helt under farveinversion, og inversion kørende sammen med tale er præcis, hvordan en synshæmmet bruger arbejder, så den ene kombination, du mest har brug for at få rigtig, er den, en hurtig demo aldrig afprøver. Sæt ReadingWordFollow til True, og viewet scroller selv det talte ord ind i syne, hvilket du ikke kan undvære på en zoomet side, der spreder sig over flere skærme. Husk én omfangsregel: SetReadingWord maler kun på den aktive TPdfView-side. Beslut på forhånd, om manuel scrolling sætter tale på pause, eller om følgeadfærden tilsidesætter den, for at vælge ingen af delene efterlader stemmen læsende videre, mens markøren sidder et sted uden for skærmen

SAPI word boundary-hændelser i en Delphi PDFium-læser mappes gennem TrackReadingWordAt på PageWordBoxes-geometrien for at male karaokewordmarkøren, med en -1-retur, der rydder markøren, når offset løber forbi sideteksten
TTS-boundary-offsettet opløses til en ord-boks og maler markøren, og en -1 forbi sideteksten rydder den i stedet for at udløse en exception

De dokumenter, der bryder din fremviser

En håndfuld inputformer besejrer en naiv implementering pålideligt nok til, at de hører hjemme som permanente eksempler i regressionssuiten, ikke som engangsbugs, du retter og glemmer

  • Utaggede, men tekstrige filer. Heuristisk rækkefølge har en tendens til at være rigtig for en lineær rapport og forkert i det øjeblik en sidebjælke eller et udtrukket citat kommer ind. Marker rækkefølgen som estimeret, både i UI'et og i din diagnostikslog, så fejlen er læselig senere
  • Rene billedscanninger. Intet tekstlag overhovedet. Fang dem gennem tomme læseenheder, og peg brugeren mod et OCR-trin opstrøms i stedet for at lade fremviseren berette en tom side
  • Kombinerende tegn og blandede skrifter. Unicode-kombinerende mærker kollapser ikke altid én-til-én til visuelle ord, så word-box-antallet kan drive væk fra, hvad din egen tokenizer forventer. Indekser ikke word-box-arrayet med offsets, du selv har beregnet ved at splitte tekst; brug kun de indekser, TrackReadingWordAt returnerer

Test det som en revisor, ikke som en demo

"It read my sample out loud" beviser ingenting. En godkendelse, du kan forsvare, kører tre filer gennem den færdige build med NVDA tilkoblet: én kendt-tagget fil, hvor overskrifter annonceres som overskrifter, og en tabel læses i rækkefølge; én kendt-utagget fil, hvor indikatoren for estimeret rækkefølge er synlig; og en scanning, hvor no-text-advarslen rent faktisk bliver talt. Hver eneste afprøver en sti, det lykkelige tilfælde springer over

Bekræft derfra, at ordmarkøren forbliver låst fast ved dobbelt talehastighed og ved halv, og at ReadingWordFollow-scrollingen ikke kæmper med brugerens egen scrolling. Kør derefter tale, mens du cykler gennem hvert farvefilter, og hold øje med, at markøren aldrig forsvinder. Artiklen om farvefiltre til synshæmmede dækker den renderingssti i detaljer, og dybdedykket i ordtale-markøren plukker TTS-timingen fra hinanden

Reading-unit- og word-box-API'erne, der er brugt ovenfor, følger med PDFium Component til Delphi og C++Builder (VCL) og Lazarus/FPC (LCL). Produktsiden linker til den fulde API-reference, inklusive record-layoutene for reading units og word boxes bag disse eksempler