Ваш попередній прогін повідомляє, що файл PDF/UA clean. veraPDF відкриває той самий файл і позначає Figure без alternate text за пунктом 7.3. Обидва інструменти мають рацію, а проміжок між ними і є справжньою проблемою перевірки доступності шляхом сканування байтів. Байтовий прохід підтверджує, що файл стверджує що він має теги: він знаходить /StructTreeRoot, /MarkInfo /Marked true, pdfuaid:part у пакеті XMP, заголовок документа, мову. Це формальні позначки, і вони необхідні. Вони нічого не кажуть про те, чи справжня фігура на четвертій сторінці має опис, який екранний читач може озвучити. Відповідь живе в дереві тегів, і щоб дістати її, треба пройти дерево
PDFium Component є нативною VCL PDF-бібліотекою для Delphi і C++, а його ValidatePdfUa виконує обидва проходи. Байтовий прохід обробляє формальні позначки. Над ним працює прохід по дереву структури, який завантажує живе теговане дерево, обходить кожен елемент і перевіряє невеликий набір правил із високою впевненістю щодо вмісту, де відсутній атрибут означає реальний дефект доступності, а не стильову перевагу. Ця стаття про той другий прохід: що саме він перевіряє, чому логіка правил є чистою функцією без DLL під нею і де вона свідомо зупиняється
Чому байтове сканування не бачить відсутній Alt
ISO 14289-1 (PDF/UA-1) - це шар вимог поверх ISO 32000. Деякі з цих вимог є структурними й видимими в сирому файлі: каталог має оголошувати дерево структури, а параметри перегляду мають встановлювати DisplayDocTitle, шрифти мають бути вбудовані. Сканер токенів, який відкидає тіла потоків і зіставляє іменні токени з межами роздільників, може перевірити все це, а сканер PDFium ValidatePdfUaCompliance саме це й робить для пунктів на кшталт 7.1, 7.18 і 7.21
Але "кожен Figure має alternate text" не є властивістю синтаксису файлу. Це властивість логічної структури - дерева тегованих елементів, яке зіставляє вміст зі змістом. Запис Alt для Figure може міститися в словнику елемента структури, надходити через /ActualText span, або походити з custom type, зіставленого через role map. Ви не можете надійно знайти його, шукаючи /Alt у байтовому потоці, бо цей рядок трапляється в непов’язаних контекстах, може бути стиснений всередині object stream і нічого не каже про якому належить цей елемент структури. Чесний спосіб відповісти на це питання - запитати власне дерево структури документа, елемент за елементом, так само як це роблять veraPDF і PAC. Саме навколо цього побудовані перевірки Tier-1 у PDFium: байтове сканування для формату, обхід дерева для вмісту
Читання живого дерева тегів
Сирий матеріал - це TPdf.GetStructureElements (також відображається як StructureElements властивість), який повертає TPdfStructureElements - плоский масив TPdfStructureElement записів у порядку документа. Кожен запис - це проєкція одного елемента структури через функції доступу PDFium, з полями, які справді потрібні правилам доступності:
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;
Поле TypeName є тим, на якому валідатор зосереджується. Воно походить від FPDF_StructElement_GetType, яка повертає стандартний тип структури елемента - його /S ім'я - після того, як PDFium розв'язав role map. AlternateText надходить із FPDF_StructElement_GetAltText, ActualText - із FPDF_StructElement_GetActualText, а ID - із FPDF_StructElement_GetID. Оскільки масив плоский і впорядкований, валідатор може аналізувати весь документ одразу замість рекурсії, а це важливо для одного правила, яке є глобальним, а не на рівні окремого елемента
Перевірка є чистою функцією, і це зроблено навмисно
Логіка правил не живе всередині методу, який спілкується з DLL. Це окрема, публічна, чиста функція:
function ValidatePdfUaStructureElements(
const Elements: TPdfStructureElements): TPdfUaValidationIssues;
Вона приймає плоский масив елементів і повертає набір проблем. Вона не викликає жодної функції PDFium, не відкриває жодного документа, не торкається глобального стану. Таке розділення навмисне, і воно дає подвійний виграш. По-перше, тестованість: у модульному тесті можна зібрати синтетичний TPdfStructureElements масив, наприклад Figure без Alt, Formula, у якої єдиний доступний текст міститься в ActualText, або два Note, що мають однаковий ID, і перевірити набір результатів без pdfium.dll DLL взагалі. Логіка правил перевіряється офлайн, а обхід DLL окремо перевіряється live-document smoke test, який пропускається, якщо бібліотека відсутня
По-друге, чіткість відповідальності. TPdf.ValidatePdfUa бере на себе брудну частину - завантажує кожну сторінку, витягує її елементи, накопичує їх - а потім передає чистий масив чистій перевірці. "Get the data" (DLL, побічні ефекти, життєвий цикл) і "judge the rules" (чисте, детерміноване) ніколи не змішуються. Коли правило треба змінити, ви змінюєте функцію, у якій немає I/O
Що саме перевіряють три правила
Перевірка дерева структури додає три значення проблем, які приєднуються до кінця TPdfUaValidationIssues тож enum лишається ABI-стабільним для наявних викликів: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt, і pvuaiNoteMissingId. Тіло достатньо мале, щоб повністю його осягнути:
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;
Клаузула 7.3 стосується фігур: a Figure елемент повинен надавати текстову альтернативу. Рання версія цієї перевірки дивилася лише на запис Alt, що робило її суворішою за еталонні валідатори. PDF/UA приймає figure, чий доступний текст надано через ActualText замість цього, бо замінний текст є допустимим альтернативним поданням, тож правило позначає Figure лише тоді, коли обидва Alt і ActualText порожні. Клаузула 7.7 стосується формул, і після тієї самої корекції вона використовує той самий тест Alt-or-ActualText; зразок із conformance-corpus, де Formula отримувала доступний текст лише через ActualText, помилково відхиляли, аж доки гілку Formula не привели у відповідність до гілки Figure
Клаузула 7.9 відрізняється за природою. A Note має мати /ID, і цей ID має бути унікальним у межах документа. Відсутній ID - це збій на рівні елемента. A дублікатний ID є зв’язком між двома елементами, тому плоский масив тут важливий: для кожного Note перевірка переглядає назад уже побачені елементи і позначає збіг з будь-яким ранішим Note, що має той самий ID. Вартість тут очевидна O(n²) за кількістю Note, що не має значення для жодного реального документа і залишає функцію одним читабельним циклом без допоміжного індексу, який треба було б тримати в синхронізації
Накопичення між сторінками, щоб унікальність була глобальною
PDFium надає елементи структури на рівні сторінки, а не документа, тож координація в ValidatePdfUa має зібрати їх до запуску правил. Вона обходить кожну сторінку за допомогою FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, незалежно від того, яка сторінка зараз відкрита в компоненті, і додає елементи кожної сторінки в один масив. Лише тоді вона викликає чисту перевірку:
// 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 правильною. Два Note на різних сторінках можуть ділити один ID; якщо ви перевіряли б сторінка за сторінкою, ви ніколи не побачили б збіг, бо набір елементів кожної сторінки виглядає внутрішньо узгодженим. Зібрати один масив на весь документ - єдиний спосіб зробити дубль видимим. Варто також звернути увагу на цю запобіжну перевірку: обхід дерева запускається лише тоді, коли байтовий прохід не повідомив pvuaiMissingStructTreeRoot. Нетегований документ не має дерева для обходу і вже був позначений як такий, що не має кореня структури, тож завантаження сторінок повністю пропускаються. Глибокий прохід нічого не коштує для документів, які не можуть отримати від нього користь
Обережність за задумом: пропускай тихо, ніколи не подавай хибну тривогу
Найважливіша властивість цього валідатора - те, від чого він відмовляється. Він зіставляє лише стандартні /S назви типів, які FPDF_StructElement_GetType повертає напряму - Figure, Formula, Note. Документ, який визначає власний тип і зіставляє його за роллю з Figure, залежно від того, як PDFium розв’язує тип, повідомить власну назву. Коли це трапляється, перевірка його не розпізнає і мовчить. Це хибнонегативний результат, і це задумана поведінка. Правило дизайну полягає в тому, щоб краще недоповідомити, ніж дати хибнопозитивний результат, тому що інструмент попередньої перевірки, який даремно б’є на сполох на сумісних файлах, привчає користувачів ігнорувати його, а проігнорований валідатор гірший за його відсутність. Декоративні зображення живуть у потоці артефактів, а не в дереві структури, тож вони взагалі не з’являються як Figure; ви не отримаєте скарги на "missing Alt" щодо фонового правила, яке правильно позначене як артефакт
Це також причина, чому scope тримають лише в межах трьох правил. Вкладеність рівнів заголовків (пункт 7.4), область дії заголовків таблиць (7.5) та виявлення циклів у role-map (7.1) - усе це справжні вимоги PDF/UA, але їх якісна перевірка потребує справжнього аналізу графа й атрибутів, а наївна перевірка дає саме ті хибнопозитивні результати, які забороняє дизайн: PDF/UA дозволяє шаблони заголовків на кшталт H1, H2, H3, H3, які просте правило "має строго зростати" помилково відхилило б. Такі перевірки залишають спеціалізованим інструментам відповідності. Набір Tier-1 - це підмножина, де відсутній атрибут однозначний
Чітко окреслена межа
Два обмеження варто знати, перш ніж вбудовувати це в релізну перевірку. По-перше, якість перевірки залежить лише від того, що PDFium може прочитати з елемента структури. Кілька файлів із корпусу відповідності, які проходять еталонні валідатори, використовують механізм альтернативного тексту, який PDFium не показує, тож FPDF_StructElement_GetAltText повертає порожній рядок, хоча файл справді відповідає вимогам. Тоді чистий перевірник "коректно" позначає відсутній Alt на неповних даних як хибнопозитивний результат, який походить із покриття доступу в DLL, а не з логіки правила. Послабити правило, щоб поглинути ці випадки, означало б також осліпити його до реальних збоїв, які воно має знаходити, тож їх документують як відоме обмеження PDFium, а не замазують
По-друге, це попередня перевірка, а не сертифікація. Tier-1 виявляє помилки в контенті з високою впевненістю, які байтове сканування структурно не здатне знайти, і робить це без хибних спрацьовувань, але повна відповідність PDF/UA, включно з семантикою заголовків, структурою таблиць і правильністю порядку читання, все ще належить повноцінному валідатору та, зрештою, людині-рецензенту. Використовуйте ValidatePdfUa щоб швидко й дешево провалювати очевидні дефекти у власному конвеєрі, а потім дати останнє слово veraPDF або PAC. Те саме проходження дерева структури лежить в основі побудови доступного PDF-читача в Delphi, де дерево тегів визначає порядок читання та озвучений текст, а також доповнює роботу на рівні метаданих у перегляді PDF-анотацій у Delphi
API дерева структури та ValidatePdfUaвалідатор, показаний тут, постачаються разом із PDFium Component для Delphi та C++Builder (VCL) і Lazarus/FPC (LCL). На сторінці продукту є посилання на повний довідник API, включно з повним TPdfStructureElementмакетом record та переліком проблем, на яких ґрунтуються ці перевірки