Twój preflight raportuje plik jako czysty względem PDF/UA. veraPDF otwiera ten sam plik i oznacza Figure bez tekstu alternatywnego zgodnie z klauzulą 7.3. Oba narzędzia mają rację, a luka między nimi pokazuje cały problem sprawdzania dostępności przez skanowanie bajtów. Przebieg na poziomie bajtów potwierdza, że plik mówi, że jest otagowany: znajduje /StructTreeRoot, /MarkInfo /Marked true, pdfuaid:part w pakiecie XMP, tytuł dokumentu i język. To znaczniki formatu i są one konieczne. Nie mówią jednak nic o tym, czy rzeczywista figura na stronie czwartej ma opis, który screen reader potrafi odczytać na głos. Ta odpowiedź żyje w drzewie tagów i żeby ją dostać, trzeba po tym drzewie przejść
PDFium Component to natywna biblioteka PDF dla VCL w Delphi i C++Builder, a jej ValidatePdfUa wykonuje oba przebiegi. Przebieg na poziomie bajtów obsługuje znaczniki formatu. Nad nim działa przebieg po drzewie struktury, który ładuje żywe otagowane drzewo, odwiedza każdy element i sprawdza niewielki zestaw reguł treści o wysokiej pewności, gdzie brak atrybutu oznacza rzeczywisty defekt dostępności, a nie preferencję stylistyczną. Ten artykuł dotyczy właśnie tego drugiego przebiegu: co sprawdza, dlaczego logika reguł jest pure function bez DLL pod spodem i gdzie celowo kończy zakres
Dlaczego skan bajtów nie widzi brakującego Alt
ISO 14289-1, czyli PDF/UA-1, jest warstwą wymagań nałożonych na ISO 32000. Część z tych wymagań ma charakter strukturalny i jest widoczna w surowym pliku: katalog musi deklarować drzewo struktury, preferencje viewer muszą ustawiać DisplayDocTitle, fonty muszą być osadzone. Skaner tokenów, który usuwa ciała strumieni i dopasowuje tokeny nazw z uwzględnieniem granic separatorów, potrafi to wszystko zweryfikować, a ValidatePdfUaCompliance w PDFium robi dokładnie to dla klauzul takich jak 7.1, 7.18 i 7.21
Ale „każda Figure ma tekst alternatywny” nie jest własnością składni pliku. To własność struktury logicznej, czyli drzewa otagowanych elementów, które mapuje treść na znaczenie. Wpis Alt dla Figure może siedzieć w słowniku elementu struktury, może być dostarczony przez /ActualText span albo pochodzić z niestandardowego typu zmapowanego przez role map. Nie da się go niezawodnie znaleźć przez grep po /Alt w strumieniu bajtów, bo ten string pojawia się w niepowiązanych kontekstach, może być skompresowany wewnątrz strumienia obiektów i nic nie mówi o tym, któremu elementowi struktury on przynależy. Uczciwym sposobem odpowiedzi na to pytanie jest zapytanie własnego drzewa struktury dokumentu, element po elemencie, czyli tej samej powierzchni, którą oceniają veraPDF i PAC. To właśnie linia, wokół której zbudowano kontrole Tier-1 w PDFium: skan bajtów dla formatu, przejście po drzewie dla treści
Odczyt żywego drzewa tagów
Surowym materiałem jest TPdf.GetStructureElements, wystawione również jako właściwość StructureElements, które zwraca TPdfStructureElements, czyli płaską tablicę TPdfStructureElement rekordów w kolejności dokumentu. Każdy rekord jest projekcją jednego elementu struktury przez akcesory PDFium, z polami, których reguły dostępności rzeczywiście potrzebują:
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;
Pole TypeName jest tym, wokół którego obraca się walidator. Pochodzi z FPDF_StructElement_GetType, które zwraca standardowy typ struktury elementu, czyli jego /S name, po tym jak PDFium rozwiąże role map. AlternateText pochodzi z FPDF_StructElement_GetAltText, ActualText z FPDF_StructElement_GetActualText, a ID z FPDF_StructElement_GetID. Ponieważ tablica jest płaska i uporządkowana, walidator może rozumować o całym dokumencie naraz, zamiast rekurować, co ma znaczenie dla tej jednej reguły, która jest globalna, a nie przypisana do pojedynczego elementu
Checker jest pure function i tak ma być
Logika reguł nie żyje wewnątrz metody rozmawiającej z DLL. To samodzielna, publiczna pure function:
function ValidatePdfUaStructureElements(
const Elements: TPdfStructureElements): TPdfUaValidationIssues;
Przyjmuje płaską tablicę elementów i zwraca zbiór problemów. Nie wywołuje żadnej funkcji PDFium, nie otwiera dokumentu i nie dotyka stanu globalnego. To rozdzielenie jest celowe i opłaca się podwójnie. Po pierwsze, testowalność: możesz zbudować syntetyczną tablicę TPdfStructureElements w teście jednostkowym, Figure bez Alt, Formula, której jedyny tekst dostępny siedzi w ActualText, dwa Notes współdzielące to samo ID, i wykonać asercje na zbiorze wyników bez pdfium.dll obecnego w ogóle. Logika reguł jest weryfikowana offline, a przejście po DLL jest weryfikowane osobno przez smoke test na żywym dokumencie, który pomija wykonanie, gdy biblioteki brakuje
Po drugie, przejrzystość odpowiedzialności. TPdf.ValidatePdfUa jest właścicielem brudniejszej części, czyli ładowania każdej strony, pobierania jej elementów i ich akumulowania, po czym przekazuje czystą tablicę do pure checkera. „Pobierz dane”, DLL, efekty uboczne, lifetime, oraz „oceń reguły”, pure i deterministic, nigdy się nie plączą. Gdy reguła wymaga zmiany, zmieniasz funkcję, w której nie ma żadnego I/O
Co faktycznie sprawdzają trzy reguły
Przebieg po drzewie struktury podnosi trzy wartości problemów, dopisane na końcu TPdfUaValidationIssues, aby enum pozostał ABI-stable dla istniejących wywołujących: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt oraz pvuaiNoteMissingId. Ciało jest na tyle małe, że da się je zrozumieć w całości:
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;
Klauzula 7.3 reguluje figury: element Figure musi dostarczać alternatywę tekstową. Wczesna wersja tej kontroli patrzyła tylko na wpis Alt, przez co była surowsza niż walidatory referencyjne. PDF/UA akceptuje figurę, której dostępny tekst jest dostarczony przez ActualText zamiast tego, bo replacement text jest poprawną reprezentacją alternatywną, więc reguła oznacza Figure tylko wtedy, gdy oba, Alt i ActualText, są puste. Klauzula 7.7 dotyczy formuł i po tej samej korekcie używa identycznego testu Alt-or-ActualText; próbka z conformance corpus, która dostarczała dostępny tekst Formula wyłącznie przez ActualText, była fałszywie odrzucana, dopóki gałąź Formula nie została zrównana z gałęzią Figure
Klauzula 7.9 jest innego rodzaju. Note musi mieć /ID, a to ID musi być unikalne w całym dokumencie. Brakujący identyfikator to błąd pojedynczego elementu. Zduplikowane powtórzone ID jest relacją między dwoma elementami, dlatego płaska tablica ma znaczenie: dla każdego Note checker skanuje wstecz elementy już widziane i oznacza kolizję z każdym wcześniejszym Note mającym to samo ID. Koszt to oczywiste O(n²) względem liczby Note, co dla realnych dokumentów nie ma znaczenia i utrzymuje funkcję jako jedną czytelną pętlę bez pomocniczego indeksu do synchronizacji
Akumulacja między stronami, aby unikalność była globalna
PDFium wystawia elementy struktury per page, a nie per document, więc orkiestracja w ValidatePdfUa musi je zebrać, zanim uruchomi reguły. Przechodzi każdą stronę przy użyciu FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, niezależnie od tego, którą stronę komponent ma aktualnie otwartą, i dopisuje elementy każdej strony do jednej tablicy. Dopiero wtedy wywołuje 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;
To właśnie akumulacja sprawia, że kontrola unikalności z 7.9 jest poprawna. Dwa Notes na różnych stronach mogą współdzielić to samo ID; gdybyś walidował stronę po stronie, nigdy nie zobaczyłbyś kolizji, bo zbiór elementów każdej strony wyglądałby na wewnętrznie spójny. Zbudowanie jednej tablicy dla całego dokumentu to jedyny sposób, by duplikat stał się widoczny. Warto też zauważyć osłonę z przodu: przejście po drzewie uruchamia się tylko wtedy, gdy przebieg na poziomie bajtów nie zgłosił nie pvuaiMissingStructTreeRoot. Dokument bez tagów nie ma drzewa do przejścia i został już oznaczony za brakujący korzeń struktury, więc ładowania per-page są całkowicie pomijane. Głęboki przebieg nic nie kosztuje na dokumentach, które nie mogą na nim skorzystać
Konserwatywne z założenia: lepiej cicho coś przeoczyć niż podnosić fałszywy alarm
Najważniejszą cechą tego walidatora jest to, czego odmawia zrobienia. Dopasowuje wyłącznie standardowe nazwy typów /S, które FPDF_StructElement_GetType zwraca bezpośrednio, czyli Figure, Formula, Note. Dokument definiujący własny typ i mapujący go przez role map na Figure może, zależnie od sposobu rozwiązania typu przez PDFium, raportować własną nazwę. Gdy tak się stanie, checker go nie rozpozna i pozostanie cichy. To false negative i dokładnie taki jest zamierzony efekt. Reguła projektowa mówi, by zaniżać raportowanie zamiast kiedykolwiek produkować false positive, ponieważ narzędzie preflight, które podnosi alarm na zgodnych plikach, uczy użytkowników ignorować jego sygnały, a ignorowany walidator jest gorszy niż żaden. Obrazy dekoracyjne żyją w strumieniu artifact, a nie w drzewie struktury, więc w ogóle nie pojawiają się jako Figures; nie dostaniesz skargi „missing Alt” dla linii tła poprawnie oznaczonej jako artifact
To również dlatego zakres utrzymano przy trzech regułach. Zagnieżdżanie poziomów nagłówków, klauzula 7.4, zakres nagłówków tabel, 7.5, oraz wykrywanie cykli w role map, 7.1, są jak najbardziej prawidłowymi wymaganiami PDF/UA, ale ich dobre sprawdzanie wymaga prawdziwej analizy grafu i atrybutów, a sprawdzanie ich naiwnie produkuje dokładnie te false positive, których projekt zakazuje. PDF/UA dopuszcza układy nagłówków takie jak H1, H2, H3, H3, które prosta reguła „musi ściśle rosnąć” błędnie by odrzuciła. Te kontrole zostawiono wyspecjalizowanym narzędziom zgodności. Zestaw Tier-1 to podzbiór, w którym brakujący atrybut jest jednoznaczny
Granica powiedziana wprost
Warto znać dwa ograniczenia, zanim włączysz to do release gate. Po pierwsze, checker jest tylko tak dobry, jak dobry jest odczyt elementów struktury przez PDFium. Kilka plików z conformance corpus, które przechodzą walidatory referencyjne, używa mechanizmu tekstu alternatywnego, którego PDFium nie wystawia, więc FPDF_StructElement_GetAltText zwraca pustą wartość mimo rzeczywistej zgodności pliku. Pure checker wtedy „poprawnie” oznacza brakujący Alt na niepełnych danych, co daje false positive wynikające z pokrycia accessorów DLL, a nie z logiki reguł. Rozluźnienie reguły, żeby wchłonąć te przypadki, oślepiłoby ją też na prawdziwe awarie, które ma wyłapywać, więc zostały udokumentowane jako znane ograniczenie PDFium zamiast zamiecione pod dywan
Po drugie, to jest preflight, a nie certyfikacja. Tier-1 wychwytuje błędy treści o wysokiej pewności, których skan bajtów strukturalnie nie potrafi zobaczyć, i robi to bez fałszywych alarmów, ale pełna zgodność PDF/UA, obejmująca semantykę nagłówków, strukturę tabel i poprawność kolejności czytania, nadal należy do pełnego walidatora i ostatecznie do człowieka recenzującego. Używaj ValidatePdfUa, aby szybko i tanio wycinać oczywiste defekty we własnym pipeline, a ostateczne słowo zostaw veraPDF albo PAC. To samo przejście po drzewie struktury stanowi podstawę budowy dostępnego czytnika PDF w Delphi, gdzie drzewo tagów steruje kolejnością czytania i tekstem wypowiadanym na głos, oraz uzupełnia pracę na poziomie metadanych opisaną w przeglądaniu adnotacji PDF z Delphi
API drzewa struktury oraz walidator ValidatePdfUa pokazany tutaj są dostarczane razem z PDFium Component dla Delphi i C++Builder, VCL, oraz Lazarus/FPC, LCL. Strona produktu linkuje pełne API reference, w tym kompletny układ rekordu TPdfStructureElement oraz enum problemów stojący za tymi kontrolami