Техническа статия

Валидиране на структурата на PDF/UA в Delphi с PDFium

Вашият preflight отчита файла като чист по PDF/UA. veraPDF отваря същия файл и маркира Figure без alternate text по clause 7.3. И двата инструмента са прави, а разликата между тях е целият проблем при проверка на accessibility чрез сканиране на байтове. Byte-level pass-ът потвърждава, че файлът казва че е tagged: намира /StructTreeRoot, /MarkInfo /Marked true, pdfuaid:part в XMP packet-а, document title-а, language-а. Това са format markers и те са необходими. Те не ви казват нищо за това дали действителната фигура на страница четири има описание, което screen reader може да прочете на глас. Отговорът на този въпрос живее в tag tree-то и за да го получите, трябва да обхождате tree-то

PDFium Component е нативна VCL PDF библиотека за Delphi и C++Builder, а неговият ValidatePdfUa прави и двата pass-а. Byte-level pass-ът се грижи за format marker-ите. Над него стои structure-tree pass, който зарежда живото tagged tree, обхожда всеки елемент и проверява малкия набор от high-confidence content правила, при които липсващият attribute означава реален accessibility дефект, а не stylistic preference. Тази статия е за този втори pass: какво проверява, защо rule логиката е pure function без DLL под нея и къде умишлено спира

Защо byte scan не може да види липсващ Alt

ISO 14289-1 (PDF/UA-1) е слой от изисквания върху ISO 32000. Някои от тях са structural и видими в raw файла: catalog-ът трябва да декларира structure tree, viewer preferences трябва да задават DisplayDocTitle, fonts трябва да са embedded. Token scanner, който изчиства stream bodies и търси name tokens с delimiter граници, може да провери всички тези неща, а PDFium's ValidatePdfUaCompliance прави точно това за clauses като 7.1, 7.18 и 7.21

Но „всеки Figure има alternate text“ не е свойство на синтаксиса на файла. То е свойство на logical structure - дървото от tagged elements, което свързва content и meaning. Alt entry-то на Figure може да стои в structure element dictionary-а, да бъде подадено през /ActualText span или да идва от role-mapped custom type. Не можете надеждно да го намерите, като grep-вате за /Alt в byte stream-а, защото този низ се появява в несвързани контексти, може да е compressed вътре в object stream и не ви казва нищо за това кои structure element му принадлежи. Честният начин да отговорите на въпроса е да питате собственото tag tree на документа, елемент по елемент, същата повърхност, която veraPDF и PAC оценяват. Това е линията, около която са изградени Tier-1 проверките на PDFium: byte scan за format, tree walk за content

Четене на живото tag tree

Raw материалът е TPdf.GetStructureElements (също излагано като StructureElements property-то), което връща TPdfStructureElements - flat array от TPdfStructureElement records в document order. Всеки запис е проекция на един structure element през accessor функциите на PDFium, с полетата, които accessibility правилата реално имат нужда:

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;

field е този, върху който validator-ът се завърта. Той идва от TypeName, което връща standard structure type-а на елемента - неговото FPDF_StructElement_GetType name - след като PDFium е разрешил role map-а. /S идва от AlternateText, FPDF_StructElement_GetAltText от ActualText, и FPDF_StructElement_GetActualText от ID. Понеже array-ът е плосък и подреден, validator-ът може да разсъждава върху целия документ наведнъж, вместо да рекурсва - което е важно за единственото правило, което е global, а не по елемент.FPDF_StructElement_GetID. Because the array is flat and ordered, the validator can reason about the whole document at once instead of recursing — which matters for the one rule that is global rather than per-element

Самата checker логика е pure function и това е нарочно

Rule логиката не живее в метода, който говори с DLL-а. Тя е самостоятелна, public, pure function:

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

Тя приема плосък element array и връща set от issue-и. Не извиква PDFium функция, не отваря документ, не пипа global state. Това разделение е умишлено и носи полза два пъти. Първо, testability: можете да построите synthetic TPdfStructureElements array в unit test - Figure без Alt, Formula, чийто единствен accessible текст е в ActualText, две Note, които споделят ID - и да assert-нете върху result set-а без pdfium.dll изобщо да присъства. Rule логиката се проверява offline; DLL traversal-ът се проверява отделно от live-document smoke test, който се пропуска, когато библиотеката липсва

Второ, яснота на отговорността. TPdf.ValidatePdfUa притежава мръсната част - зареждането на всяка страница, изваждането на елементите ѝ, натрупването им - и после подава чист array на pure checker-а. „Вземи данните“ (DLL, side effects, lifetime) и „съди правилата“ (pure, deterministic) никога не се заплитат. Когато правило трябва да се промени, променяте функция без I/O вътре в нея

Какво реално проверяват трите правила

Structure-tree pass-ът връща три issue values, добавени в края на TPdfUaValidationIssues така че enum-ът да остане ABI-stable за съществуващите callers: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt, и pvuaiNoteMissingId. Body-то е достатъчно малко, за да може да се разсъждава върху него изцяло:

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;

Clause 7.3 управлява фигурите: Figure element трябва да предоставя text alternative. Ранната версия на тази проверка гледаше само Alt entry-то, което я правеше по-строга от reference validator-ите. PDF/UA приема и фигура, чийто accessible текст идва чрез ActualText вместо това - replacement text е валидно алтернативно представяне - така че правилото маркира Figure само когато и двете Alt и ActualText са празни. Clause 7.7 покрива формулите и след същата корекция използва идентичния Alt-or-ActualText test; sample от conformance corpus, който даваше на Formula своя accessible текст само чрез ActualText, беше отхвърлян фалшиво, докато Formula клонът не беше приведен в съответствие с Figure клона

Clause 7.9 е различен по вид. Note трябва да има /ID, и този ID трябва да е уникален за целия документ. Липсващ ID е per-element failure. duplicate ID е връзка между два елемента, което е причината плоският array да има значение: за всяка Note checker-ът сканира назад през вече видените елементи и маркира collision с всяка по-ранна Note, която носи същия ID. Цената е очевидното O(n²) спрямо броя Note-и, което е без значение за всеки реален документ и запазва функцията като един четим loop без помощен index за синхронизация

Натрупване през страници, така че уникалността да е глобална

PDFium излага структура elements по страница, не по документ, така че orchestration-ът в ValidatePdfUa трябва да ги събере преди да тръгнат правилата. Той обхожда всяка страница с FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, независимо коя страница компонентът в момента има отворена, и добавя елементите на всяка страница в един array. Едва тогава вика pure checker-а:

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

Натрупването е това, което прави 7.9 uniqueness проверката правилна. Две Notes на различни страници могат да споделят ID; ако валидирате страница по страница, никога няма да видите collision-а, защото set-ът от елементи на всяка страница изглежда вътрешно последователен. Изграждането на един документ-широк array е единственият начин duplicate-ът да стане видим. Guard-ът отпред също си струва да се отбележи: tree walk-ът се стартира само когато byte-level pass-ът не е отчел pvuaiMissingStructTreeRoot. Untagged документ няма tree за обхождане и вече е бил маркиран за липсващ structure root, така че per-page load-овете се пропускат изцяло. Deep pass-ът не струва нищо на документите, които не могат да спечелят от него

Консервативно по дизайн: пропускай тихо, никога не викай вълк

The single most important property of this validator is what it refuses to do. It matches only the standard /S type names that FPDF_StructElement_GetType returns directly — Figure, Formula, Note. A document that defines a custom type and role-maps it to Figure will, depending on how PDFium resolves the type, report its own name. When that happens the checker does not recognize it and stays silent. That is a false negative, and it is the intended behavior. The design rule is to под-отчита, вместо някога да произвежда false positive, защото preflight инструмент, който вика вълк върху conformant файлове, учи потребителите си да го игнорират - а игнориран validator е по-лош от никакъв. Декоративните изображения живеят в artifact stream-а, не в structure tree-то, така че изобщо не се появяват като Figures; няма да получите оплакване за "missing Alt" за background rule, която правилно е маркирана като artifact

Това е и причината обхватът да е ограничен до три правила. Heading-level nesting (clause 7.4), table header scope (7.5) и role-map cycle detection (7.1) са напълно легитимни PDF/UA изисквания, но проверката им добре изисква истински graph и attribute analysis, а проверката им наивно произвежда точно false positive-ите, които design-ът забранява - PDF/UA позволява heading patterns като H1, H2, H3, H3, които едно просто правило „трябва строго да растат“ би отхвърлило погрешно. Тези проверки се оставят на dedicated conformance инструменти. Tier-1 наборът е подмножеството, при което липсващият attribute е недвусмислен

Границата, казана ясно

Две ограничения си струва да знаете, преди да вържете това към release gate. Първо, checker-ът е толкова добър, колкото това, което PDFium може да прочете от structure element-а. Няколко файла от conformance corpus-а, които reference validator-ите приемат, използват alternate-text механизъм, който PDFium не излага, така че FPDF_StructElement_GetAltText връща empty, въпреки че файлът наистина е conformant. Pure checker-ът тогава „правилно“ маркира missing Alt върху непълни данни - false positive, който произхожда от coverage-а на DLL accessors, а не от rule логиката. Да разхлабите правилото, за да погълне тези случаи, би го направило сляпо за реалните failure-и, които трябва да хваща, така че те се документират като known PDFium limitation, вместо да се замазват

Второ, това е preflight, не certification. Tier-1 хваща high-confidence content грешките, които byte scan structural не може да хване, и го прави без false alarms - но пълното PDF/UA съответствие, включително heading semantics, table structure и reading-order correctness, все още принадлежи на complete validator и в крайна сметка на human reviewer. Използвайте ValidatePdfUa за да проваляте очевидните дефекти бързо и евтино в собствения си pipeline, а после оставете veraPDF или PAC да дадат последната дума. Същото structure-tree traversal стои и зад изграждането на accessible PDF reader in Delphi, където tag tree-то управлява reading order и spoken text, и допълва работата на metadata-level при reviewing PDF annotations from Delphi

Structure-tree API-тата и ValidatePdfUa validator-ът, показани тук, идват с PDFium Component за Delphi и C++Builder (VCL) и Lazarus/FPC (LCL). Product page-ът линква пълната API reference, включително пълния TPdfStructureElement record layout и issue enumeration-а зад тези проверки