Technisch artikel

PDF/UA-structuurboombewaking in Delphi met PDFium

Je preflight meldt dat het bestand PDF/UA-schoon is. veraPDF opent hetzelfde bestand en markeert een Figure zonder alternate text onder clausule 7.3. Beide tools hebben gelijk, en de kloof ertussen is precies het probleem met toegankelijkheid controleren door bytes te scannen. Een byte-level pass bevestigt dat het bestand zegt dat het getagd is: hij vindt de /StructTreeRoot, de /MarkInfo /Marked true, de pdfuaid:part in het XMP-pakket, de documenttitel, de taal. Dat zijn formatmarkers, en ze zijn nodig. Ze vertellen je niets over de vraag of de feitelijke figuur op pagina vier een beschrijving heeft die een screenreader hardop kan voorlezen. Dat antwoord leeft in de tag tree, en om het te krijgen moet je de tree lopen

PDFium Component is een native VCL PDF-library voor Delphi en C++Builder, en ValidatePdfUa doet beide passes. De byte-level pass behandelt de formatmarkers. Daarbovenop zit een structure-tree-pass die de levende getagde boom laadt, elk element doorloopt en de kleine set hoogbetrouwbare contentregels controleert waar een ontbrekende attribute een echte toegankelijkheidsfout is in plaats van een stijlvoorkeur. Dit artikel gaat over die tweede pass: wat hij controleert, waarom de regellogica een pure functie is zonder DLL eronder, en waar hij bewust stopt

Waarom een bytescan een ontbrekende Alt niet kan zien

ISO 14289-1, PDF/UA-1, is een laag eisen bovenop ISO 32000. Sommige van die eisen zijn structureel en zichtbaar in het ruwe bestand: de catalog moet een structuurboom declareren, de viewer preferences moeten DisplayDocTitle zetten, fonts moeten embedded zijn. Een tokenscanner die stream bodies verwijdert en naamtokens met delimitergrenzen matcht, kan al dat soort zaken verifiëren, en PDFium's ValidatePdfUaCompliance doet dat voor clausules als 7.1, 7.18 en 7.21

Maar "elke Figure heeft alternate text" is geen eigenschap van de syntaxis van het bestand. Het is een eigenschap van de logische structuur, de boom van getagde elementen die content aan betekenis koppelt. Een Figure's Alt-entry kan in de structuur-elementdictionary staan, via een /ActualText-span worden geleverd, of uit een role-mapped custom type komen. Je kunt dat niet betrouwbaar vinden door op /Alt in de bytes te greppen, omdat die string ook in andere contexten voorkomt, misschien gecomprimeerd is in een objectstream, en niets zegt over welk structuur-element erbij hoort. De eerlijke manier om de vraag te beantwoorden is de eigen structuurboom van het document te vragen, element voor element, hetzelfde oppervlak dat veraPDF en PAC evalueren. Dat is de lijn waarop de Tier-1 checks van PDFium zijn gebouwd: bytescan voor formaat, tree walk voor content

De live tag tree lezen

De ruwe grondstof is TPdf.GetStructureElements (ook als StructureElements-property beschikbaar), die een TPdfStructureElements teruggeeft, een vlakke array van TPdfStructureElement-records in documentvolgorde. Elk record is de projectie van één structuur-element via de accessorfuncties van PDFium, met de velden die de toegankelijkheidsregels echt nodig hebben:

Het TypeName-veld is het draaipunt van de validator. Het komt uit FPDF_StructElement_GetType, die het standaard-structuurtype van het element teruggeeft, de /S-naam, nadat PDFium de role map heeft opgelost. AlternateText komt uit FPDF_StructElement_GetAltText, ActualText uit FPDF_StructElement_GetActualText, en ID uit FPDF_StructElement_GetID. Omdat de array vlak en geordend is, kan de validator over het hele document tegelijk redeneren in plaats van recursief, en dat is belangrijk voor de ene regel die globaal is in plaats van per element

De checker is een pure functie, en dat is expres

De regellogica leeft niet in de methode die met de DLL praat. Het is een zelfstandige, publieke, pure functie:

Hij neemt een vlakke elementarray en geeft een set issues terug. Hij roept geen enkele PDFium-functie aan, opent geen document, raakt geen globale staat. Die scheiding is bewust, en ze levert twee voordelen op. Ten eerste testbaarheid: je kunt in een unit test een synthetische TPdfStructureElements-array bouwen, bijvoorbeeld een Figure zonder Alt, een Formula waarvan de enige toegankelijke tekst in ActualText zit, twee Notes met dezelfde ID, en op de resulset assert zonder dat pdfium.dll aanwezig is. De regellogica wordt offline geverifieerd; het doorlopen van de DLL wordt apart geverifieerd door een smoke test met een live document die overslaat wanneer de bibliotheek ontbreekt

Ten tweede, duidelijkheid van verantwoordelijkheid. TPdf.ValidatePdfUa bezit het rommelige deel, elk pagina laden, de elementen ophalen, accumuleren, en geeft daarna een schone array aan de pure checker. "Gegevens ophalen" en "regels beoordelen" raken nooit verstrikt. Als een regel moet veranderen, verander je een functie zonder I/O erin

Wat de drie regels echt controleren

De structure-tree-pass geeft drie issuewaarden terug, toegevoegd aan het eind van TPdfUaValidationIssues zodat de enum ABI-stabiel blijft voor bestaande aanroepen: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt en pvuaiNoteMissingId. De body is klein genoeg om volledig te overzien:

Clausule 7.3 gaat over figures: een Figure-element moet een tekstalternatief leveren. De vroege versie van deze check keek alleen naar de Alt-entry, en was daardoor strenger dan de referentievalidators. PDF/UA accepteert ook een figure waarvan de toegankelijke tekst via ActualText wordt geleverd, replacement text is een geldige alternatieve representatie, dus de regel markeert een Figure alleen wanneer beide Alt en ActualText leeg zijn. Clausule 7.7 behandelt formulas, en na dezelfde correctie gebruikt die exact dezelfde Alt-of-ActualText-test; een sample uit de conformiteitscorpus dat zijn toegankelijke tekst alleen via ActualText aan een Formula gaf, werd eerst onterecht geweigerd totdat de Formula-tak in lijn werd gebracht met de Figure-tak

Clausule 7.9 is anders van aard. Een Note moet een /ID hebben, en die ID moet uniek zijn over het document. Een ontbrekende ID is een fout per element. Een dubbele ID is een relatie tussen twee elementen, en daarom is de vlakke array belangrijk: voor elke Note scant de checker terug over de al geziene elementen en markeert een collision met elke eerdere Note die dezelfde ID draagt. De kost is de voor de hand liggende O(n²) over het aantal Notes, en dat is irrelevant voor elk echt document; het houdt de functie wel in één leesbare lus zonder extra index die je in sync moet houden

Over pagina's accumuleren zodat uniciteit globaal is

PDFium geeft structuur-elementen per pagina bloot, niet per document, dus de orchestration in ValidatePdfUa moet ze eerst verzamelen voordat de regels draaien. Het loopt elke pagina langs met FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, los van welke pagina de component momenteel open heeft, en plakt de elementen van elke pagina in één array. Pas daarna roept het de pure checker aan:

Die accumulatie is wat de uniciteitscheck van 7.9 correct maakt. Twee Notes op verschillende pagina's kunnen dezelfde ID delen; als je pagina voor pagina zou valideren, zou je de collision nooit zien, omdat elke pageset intern consistent lijkt. Eén documentbrede array bouwen is de enige manier waarop de duplicate zichtbaar wordt. De guard vooraan is ook het noemen waard: de tree walk draait alleen wanneer de byte-level pass niet pvuaiMissingStructTreeRoot rapporteerde. Een niet-getagd document heeft geen tree om te lopen en is al gemarkeerd voor de ontbrekende structure root, dus de per-paginaloads worden helemaal overgeslagen. De diepe pass kost niets op documenten die er niet van kunnen profiteren

Conservatief uit ontwerp: liever stil missen dan vals alarm

De belangrijkste eigenschap van deze validator is wat hij weigert te doen. Hij matcht alleen de standaard /S-typen die FPDF_StructElement_GetType rechtstreeks teruggeeft, Figure, Formula, Note. Een document dat een custom type definieert en dat role-mapt naar Figure, zal, afhankelijk van hoe PDFium het type teruggeeft, zijn eigen naam rapporteren. Wanneer dat gebeurt herkent de checker het niet en blijft stil. Dat is een false negative, en dat is de bedoeling. De ontwerpregel is om eerder te weinig te rapporteren dan ooit een false positive te produceren, omdat een preflighttool die op conforme bestanden blijft roepen, zijn gebruikers leert hem te negeren, en een genegeerde validator erger is dan geen. Decoratieve afbeeldingen leven in de artifact stream, niet in de structuurboom, dus ze verschijnen sowieso niet als Figures; je krijgt geen "missing Alt"-melding over een achtergrondlijn die terecht als artifact is gemarkeerd

Dat is ook waarom de scope tot drie regels beperkt blijft. Heading-level nesting, tabel-header scope en role-map cyclussen zijn allemaal legitieme PDF/UA-eisen, maar ze goed controleren vraagt echte graf- en attribuutanalyse, en naïef controleren produceert precies de false positives die het ontwerp verbiedt. PDF/UA staat headingpatronen toe als H1, H2, H3, H3, die een simpele "moet strikt stijgen"-regel foutief zou afwijzen. Die checks laat je aan gespecialiseerde conformance-tools over. De Tier-1-set is de subset waar een ontbrekende attribute eenduidig is

De grens, expliciet geformuleerd

Er zijn twee grenzen die het waard zijn om te kennen voordat je dit in een releasegate hangt. Ten eerste is de checker alleen zo goed als wat PDFium uit het structuur-element kan lezen. Een handvol bestanden uit de conformance-corpus dat de referentievalidators doorlaten, gebruikt een alternatieve alt-textmechaniek die PDFium niet blootlegt, dus FPDF_StructElement_GetAltText geeft leeg terug terwijl het bestand echt conform is. De pure checker markeert dan "terecht" een ontbrekende Alt op onvolledige data, een false positive die uit de accessor-dekking van de DLL komt, niet uit de regellogica. De regel versoepelen om die gevallen te absorberen zou hem ook blind maken voor de echte fouten die hij moet vangen, dus ze worden gedocumenteerd als een bekende PDFium-beperking in plaats van weggepoetst

Ten tweede is dit preflight, geen certificering. Tier-1 vangt de hoogbetrouwbare contentfouten die een bytescan structureel niet kan zien, en doet dat zonder false alarms, maar volledige PDF/UA-conformiteit, inclusief heading-semantiek, tabelstructuur en leesvolgorde-correctheid, hoort nog steeds bij een complete validator en uiteindelijk bij een menselijke reviewer. Gebruik ValidatePdfUa om de voor de hand liggende defecten snel en goedkoop in je eigen pijplijn af te vangen, en laat veraPDF of PAC het laatste woord hebben. Dezelfde structure-tree-wandeling onderbouwt het bouwen van een accessible PDF reader in Delphi, waar de tag tree de leesvolgorde en uitgesproken tekst bepaalt, en het vult het metadata-niveau werk in reviewing PDF annotations from Delphi aan

De structure-tree-API's en de ValidatePdfUa-validator die hier zijn getoond, worden meegeleverd met de PDFium Component voor Delphi en C++Builder (VCL) en Lazarus/FPC (LCL). De productpagina linkt de volledige API-referentie, inclusief de complete TPdfStructureElement-recordlayout en de issue-enumeratie achter deze checks

type
  TPdfStructureElement = record
    Level: Integer;            // depth in the tag tree
    ParentIndex: Integer;      // index of parent element, or -1
    TypeName: WString;         // standard /S name: Figure, Formula, Note...
    Title: WString;            // /T
    AlternateText: WString;    // /Alt   (FPDF_StructElement_GetAltText)
    ActualText: WString;       // /ActualText
    Expansion: WString;        // /E
    ID: WString;               // /ID    (FPDF_StructElement_GetID)
    Language: WString;         // /Lang
    MarkedContentIDs: TPdfIntegerArray;
    // ... child bookkeeping fields
  end;
function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;
for I := 0 to High(Elements) do
begin
  T := string(Elements[I].TypeName);
  if T = 'Figure' then
  begin
    // §7.3 — a Figure needs an alternate representation:
    // an Alt entry OR ActualText. Flag only when BOTH are empty.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFigureMissingAlt);
  end
  else if T = 'Formula' then
  begin
    // §7.7 — same rule as Figure: Alt OR ActualText.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFormulaMissingAlt);
  end
  else if T = 'Note' then
  begin
    // §7.9 — every Note must have a unique ID.
    NoteId := string(Elements[I].ID);
    if NoteId = '' then
      Include(Result, pvuaiNoteMissingId)
    else
      for J := 0 to I - 1 do
        if (string(Elements[J].TypeName) = 'Note') and
           (string(Elements[J].ID) = NoteId) then
        begin
          Include(Result, pvuaiNoteMissingId);
          Break;
        end;
  end;
end;
// inside TPdf.ValidatePdfUa, after the byte-level pass
if (FDocument <> nil) and
   (not (pvuaiMissingStructTreeRoot in Result.Issues)) then
begin
  AllElems := nil;
  PageTotal := FPDF_GetPageCount(FDocument);
  for I := 0 to PageTotal - 1 do
  begin
    Page := FPDF_LoadPage(FDocument, I);
    if Page = nil then Continue;
    try
      PageElems := GetStructureElementsForPage(Page);
    finally
      FPDF_ClosePage(Page);
    end;
    // append PageElems into AllElems ...
  end;
  Result.Issues := Result.Issues + ValidatePdfUaStructureElements(AllElems);
end;