Teknisk artikel

PDF/UA-strukturträdsvalidering i Delphi med PDFium

Din förhandskontroll rapporterar att filen är PDF/UA-ren. veraPDF öppnar samma fil och flaggar en Figure utan alternativtext enligt klausul 7.3. Båda verktygen har rätt, och glappet mellan dem är hela problemet med att kontrollera tillgänglighet genom att skanna byte för byte. En kontroll på byte-nivå bekräftar att filen säger att den är taggad: den hittar /StructTreeRoot, /MarkInfo /Marked true, pdfuaid:part i XMP-paketet, dokumenttiteln och språket. Dessa är formatmarkörer, och de är nödvändiga. De säger dig ingenting om huruvida den faktiska figuren på sida fyra har en beskrivning som en skärmläsare kan läsa upp. Svaret finns i taggträdet, och för att få fram det måste du gå igenom trädet

PDFium Component är ett inbyggt VCL-PDF-bibliotek för Delphi och C++Builder, och dess ValidatePdfUa gör båda passen. Byte-nivåpasset hanterar formatmarkörerna. Ovanpå det ligger ett strukturträds-pass som läser in det levande taggträdet, går igenom varje element och kontrollerar den lilla uppsättningen innehållsregler med hög säkerhet där ett saknat attribut betyder en verklig tillgänglighetsbrist snarare än en stilpreferens. Den här artikeln handlar om det andra passet: vad det kontrollerar, varför regel-logiken är en ren funktion utan någon DLL under sig, och var den medvetet stannar kort

Varför en bytegenomsökning inte kan se ett saknat Alt

ISO 14289-1 (PDF/UA-1) är ett lager av krav ovanpå ISO 32000. Vissa av de kraven är strukturella och synliga i den råa filen: katalogen måste deklarera ett strukturträd, visningsinställningarna måste sätta DisplayDocTitle, teckensnitt måste vara inbäddade. En token-skanner som tar bort ströminnehåll och matchar namntoken med avgränsare kan verifiera allt detta, och PDFiums ValidatePdfUaCompliance gör just det för klausuler som 7.1, 7.18 och 7.21

Men "att varje Figure har alternativtext" är inte en egenskap hos filens syntax. Det är en egenskap hos den logiska strukturen, alltså trädet av taggade element som mappar innehåll till betydelse. En Figures Alt-post kan ligga i struktur-elementets ordbok, tillhandahållas via ett /ActualText span, eller komma från en rollmappad anpassad typ. Du kan inte tillförlitligt hitta den genom att söka efter /Alt i byteflödet, eftersom den strängen förekommer i orelaterade sammanhang, kan vara komprimerad i en objektström och inte säger dig något om vilket struktur-element den hör till. Det ärliga sättet att besvara frågan är att fråga dokumentets eget strukturträd, element för element, samma yta som veraPDF och PAC utvärderar. Det är den linje som PDFiums Tier-1-kontroller byggs kring: bytegenomsökning för format, trädpromenad för innehåll

Läser det levande taggträdet

Råmaterialet är TPdf.GetStructureElements (även exponerat som StructureElements egenskapen), som returnerar en TPdfStructureElements - en platt array av TPdfStructureElement poster i dokumentordning. Varje post är projektionen av ett struktur-element genom PDFiums åtkomstfunktioner, med de fält som tillgänglighetsreglerna faktiskt behöver:

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;

Fältet TypeName är det som valideraren utgår från. Det kommer från FPDF_StructElement_GetType, som returnerar elementets standardstrukturtyp, dess /S-namn, efter att PDFium har löst rollmappen. AlternateText kommer från FPDF_StructElement_GetAltText, ActualText från FPDF_StructElement_GetActualText, och ID från FPDF_StructElement_GetID. Eftersom arrayen är platt och ordnad kan valideraren resonera om hela dokumentet på en gång i stället för att gå rekursivt, vilket spelar roll för den enda regel som är global snarare än per element

Kontrollen är en ren funktion, och det är medvetet

Regellogiken lever inte inne i metoden som pratar med DLL:en. Den är en fristående, offentlig, ren funktion:

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

Den tar en platt array av element och returnerar en uppsättning problem. Den anropar ingen PDFium-funktion, öppnar inget dokument, rör ingen global state. Den uppdelningen är medveten, och den ger utdelning på två sätt. För det första, testbarhet: du kan bygga en syntetisk TPdfStructureElements array i ett enhetstest - en Figure utan Alt, en Formula vars enda tillgängliga text ligger i ActualText, två Notes som delar ett ID - och hävda på resultatmängden utan att pdfium.dll ens finns på plats. Regellogiken verifieras offline; DLL-genomgången verifieras separat med ett smoke-test mot ett live-dokument som hoppas över när biblioteket saknas

För det andra, tydlig ansvarsfördelning. TPdf.ValidatePdfUa äger den röriga delen, att ladda varje sida, hämta dess element, samla ihop dem, och lämnar sedan en ren array till den rena kontrollen. "Hämta data" (DLL, sidoeffekter, livscykel) och "bedöm reglerna" (ren, deterministisk) blandas aldrig ihop. När en regel behöver ändras ändrar du en funktion som inte har någon I/O i sig

Vad de tre reglerna faktiskt kontrollerar

Strukturträdsgenomgången ger tre issue-värden, som läggs sist i TPdfUaValidationIssues så att enumen förblir ABI-stabil för befintliga anropare: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt, och pvuaiNoteMissingId. Brödtexten är tillräckligt liten för att kunna överblickas helt:

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;

Klausul 7.3 gäller Figures: ett Figure element måste ge en textalternativ. Den tidiga versionen av den här kontrollen tittade bara på Alt-fältet, vilket gjorde den striktare än referensvaliderarna. PDF/UA accepterar en Figure vars tillgängliga text tillhandahålls via ActualText i stället, ersättningstext är en giltig alternativ representation, så regeln markerar bara en Figure när båda Alt och ActualText är tomma. Klausul 7.7 gäller Formulas, och efter samma korrigering använder den identiska Alt-eller-ActualText-kontrollen; ett prov i överensstämmelsekorpuset som gav en Formula sin tillgängliga text enbart via ActualText avvisades felaktigt tills Formula-grenen anpassades till Figure-grenen

Klausul 7.9 är annorlunda till sin karaktär. En NoteNote/ID måste ha ett duplicerat ID:t måste vara unikt över hela dokumentet. Ett saknat ID är ett fel på elementnivå. Ett

ID är en relation mellan två element, vilket är anledningen till att den platta arrayen spelar roll: för varje Note skannar kontrollen bakåt över de element som redan setts och flaggar en kollision med varje tidigare Note som har samma ID. Kostnaden är den uppenbara O(n²) över antalet Note, vilket är irrelevant för alla verkliga dokument och håller funktionen till en enda läsbar loop utan något hjälpindex att hålla synkroniserat

Samlar över sidor så att unikheten blir globalValidatePdfUaPDFium exponerar struktur-element per sida, inte per dokument, så samordningen i FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, oberoende av vilken sida komponenten råkar ha öppen, och lägger till varje sidas element i en enda array. Först därefter anropar den den rena kontrollen:

// 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;

Sammanslagningen är det som gör 7.9-kontrollen för unikhet korrekt. Två Notes på olika sidor kan dela ett ID; om du validerade sida för sida skulle du aldrig se kollisionen, eftersom varje sidas elementuppsättning ser internkonsekvent ut. Att bygga en enda dokumentomfattande array är det enda sättet att göra dupliceringen synlig. Skyddet i början är också värt att notera: trädpromenaden körs bara när bytepassagen inte rapporterade inte rapporterade pvuaiMissingStructTreeRoot. Ett otaggat dokument har inget träd att gå igenom och har redan flaggats för saknad strukturrot, så sidladdningarna hoppas över helt. Det djupare passet kostar inget på dokument som inte kan dra nytta av det

Konservativ som designval: missa tyst, ropa aldrig varg

Den enskilt viktigaste egenskapen hos den här valideraren är vad den vägrar att göra. Den matchar bara de standardiserade /S typnamn som FPDF_StructElement_GetType returnerar direkt, Figure, Formula, Note. Ett dokument som definierar en egen typ och rollmappar den till Figure kommer, beroende på hur PDFium löser typen, att rapportera sitt eget namn. När det händer känner kontrollen inte igen det och förblir tyst. Det är en falsk negativ, och det är avsiktligt beteende. Designregeln är att underrapportera i stället för att någonsin ge en falsk positiv, eftersom ett preflight-verktyg som ropar varg på korrekta filer lär sina användare att ignorera det, och en ignorerad validerare är sämre än ingen alls. Dekorativa bilder ligger i artefaktströmmen, inte i strukturträdet, så de dyker aldrig upp som Figures från början, och du får ingen "saknat Alt"-anmärkning för en bakgrundslinje som är korrekt markerad som artefakt

Det är också därför omfånget hålls till tre regler. Rubriknivånästning (klausul 7.4), tabellhuvudets omfång (7.5) och detektering av rollmapscykler (7.1) är alla legitima PDF/UA-krav, men att kontrollera dem väl kräver riktig graf- och attributanalys, och att kontrollera dem naivt ger exakt de falska positiva som designen förbjuder. PDF/UA tillåter rubrikmönster som H1, H2, H3, H3 som en enkel regel om att nivåerna måste öka strikt skulle förkasta felaktigt. De kontrollerna lämnas till särskilda överensstämmelseverktyg. Tier-1-uppsättningen är den del där ett saknat attribut är entydigt

Gränsen, uttryckt rakt ut

Två begränsningar är värda att känna till innan du kopplar in det här i en release-grind. För det första är kontrollen bara så bra som det PDFium kan läsa från struktur-elementet. Några filer i överensstämmelsekorpuset som referensvaliderarna klarar använder en mekanism för alternativtext som PDFium inte exponerar, så FPDF_StructElement_GetAltText returnerar tomt trots att filen faktiskt är konform. Den rena kontrollen markerar då "korrekt" ett saknat Alt på ofullständiga data, en falsk positiv som kommer från DLL:ens åtkomsttäckning, inte från regellogiken. Att mjuka upp regeln för att sluka de fallen skulle också göra den blind för de verkliga fel den är tänkt att fånga, så de dokumenteras som en känd PDFium-begränsning i stället för att döljas

För det andra är det här en förhandskontroll, inte en certifiering. Tier-1 fångar de innehållsfel med hög säkerhet som en bytegenomsökning strukturellt sett inte kan hitta, och gör det utan falsklarm, men full PDF/UA-överensstämmelse, inklusive huvudsemantik, tabellstruktur och korrekt läsordning, hör fortfarande hemma hos en komplett validerare och i slutänden hos en mänsklig granskare. Använd ValidatePdfUa för att stoppa de uppenbara felen snabbt och billigt i din egen pipeline, och låt sedan veraPDF eller PAC få sista ordet. Samma strukturträdsgenomgång ligger också bakom att bygga en tillgänglig PDF-läsare i Delphi, där taggträdet styr läsordningen och det upplästa innehållet, och den kompletterar arbetet på metadatanivå i granskning av PDF-annoteringar i Delphi

Strukturträds-API:erna och ValidatePdfUa valideraren som visas här levereras med PDFium Component för Delphi och C++Builder (VCL) samt Lazarus/FPC (LCL). Produktsidan länkar till hela API-referensen, inklusive den fullständiga TPdfStructureElement-recordlayouten och issue-enumerationen bakom dessa kontroller