Ваш preflight сообщает, что файл чист по PDF/UA. Тот же файл открывается в veraPDF и получает флаг Figure без alternate text по пункту 7.3. Оба инструмента правы, и разрыв между ними как раз и описывает всю проблему проверки accessibility простым сканированием байтов. Byte-level pass подтверждает лишь то, что файл говорит , будто он тегирован: находит /StructTreeRoot , /MarkInfo /Marked true , pdfuaid:part в XMP packet, заголовок документа и язык. Это marker формата, и они необходимы. Но они ничего не говорят о том, есть ли у реальной Figure на четвертой странице описание, которое screen reader сможет прочитать вслух. Ответ на этот вопрос живет в tag tree, и, чтобы получить его, нужно обходить дерево
PDFium Component - нативная VCL PDF-библиотека для Delphi и C++Builder, и ее ValidatePdfUa выполняет оба прохода. Byte-level pass занимается marker формата. Поверх него стоит проход по structure tree, который загружает живое дерево тегов, обходит каждый элемент и проверяет небольшой набор high-confidence правил содержимого, где отсутствие атрибута означает реальный accessibility defect, а не вкусовую стилистику. Эта статья посвящена именно второму проходу: что он проверяет, почему логика rules оформлена как pure function без DLL под ней и где именно она намеренно останавливается
Почему byte scan не видит отсутствующий Alt
ISO 14289-1, PDF/UA-1, накладывает слой требований поверх ISO 32000. Некоторые из этих требований структурные и видны в сырых байтах файла: catalog обязан объявлять structure tree, viewer preferences должны выставлять DisplayDocTitle , шрифты должны быть встроены. Token scanner, который сначала вычищает body stream и затем ищет name token с корректными delimiter boundary, может проверять все это, и ValidatePdfUaCompliance у PDFium именно так и делает для пунктов вроде 7.1, 7.18 и 7.21
Но требование "у каждой Figure есть alternate text" - это уже не свойство синтаксиса файла. Это свойство логической структуры , то есть дерева tagged element, которое связывает контент со смыслом. Запись Alt у Figure может находиться в словаре structure element, может приходить через span /ActualText или через custom type с role mapping. Надежно ответить на вопрос простым grep по /Alt в byte stream нельзя, потому что эта строка встречается и в посторонних контекстах, может оказаться сжатой внутри object stream и ничего не говорит о том, какому именно structure element она принадлежит. Честный способ ответить - спрашивать собственное structure tree документа, element by element, тем же способом, каким работают veraPDF и PAC. На этом и держится Tier-1 набор проверок PDFium: byte scan для формата, tree walk для контента
Чтение живого дерева тегов
Сырьем здесь служит TPdf.GetStructureElements , также доступный как свойство StructureElements , которое возвращает TPdfStructureElements - плоский массив записей TPdfStructureElement в порядке документа. Каждая запись является проекцией одного 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;
Поле TypeName - это именно то, на чем строится validator. Оно приходит из FPDF_StructElement_GetType , который возвращает стандартный structure type элемента, то есть его имя /S , уже после того, как PDFium разрешил role map. AlternateText приходит из FPDF_StructElement_GetAltText , ActualText из FPDF_StructElement_GetActualText , а ID из FPDF_StructElement_GetID . Поскольку массив плоский и упорядоченный, validator может рассуждать обо всем документе целиком без рекурсии, а это важно для одного правила, которое является не локальным по элементу, а глобальным
Проверяющая функция чистая, и это сделано намеренно
Логика правил не живет внутри метода, который разговаривает с DLL. Это отдельная, публичная, pure function:
function ValidatePdfUaStructureElements(
const Elements: TPdfStructureElements): TPdfUaValidationIssues;
Она принимает плоский массив element и возвращает набор issues. Она не вызывает ни одной функции PDFium, не открывает документов и не трогает global state. Такое разделение выбрано сознательно, и оно окупается дважды. Во-первых, тестируемость: в unit test можно собрать синтетический массив TPdfStructureElements - Figure без Alt, Formula, у которой единственный доступный текст сидит в ActualText, две Note с одним и тем же ID - и проверить набор результатов без какого-либо pdfium.dll вообще. Логика rules проверяется офлайн, а обход DLL отдельно подтверждается живым smoke test, который просто пропускается, если библиотеки нет
Во-вторых, ясность ответственности. TPdf.ValidatePdfUa владеет грязной частью работы: загрузкой каждой страницы, извлечением ее элементов и накоплением их в общий массив. После этого он передает чистый массив pure checker. "Получить данные", DLL, side effect, lifetime, и "оценить правила", pure и deterministic, никогда не перемешиваются. Когда какое-то правило нужно изменить, вы правите функцию, в которой нет I/O
Что именно проверяют три правила
Проход по structure tree поднимает три значения issues, которые были добавлены в конец TPdfUaValidationIssues , чтобы enum оставался ABI-stable для существующих вызывающих сторон: 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 регулирует Figure: элемент Figure обязан предоставлять текстовую альтернативу. Ранняя версия этой проверки смотрела только на запись Alt, из-за чего получалась строже эталонных validator. PDF/UA допускает figure, у которой доступный текст подан через ActualText - replacement text тоже является допустимым альтернативным представлением. Поэтому правило помечает Figure только тогда, когда пусты оба поля, Alt и ActualText. Пункт 7.7 относится к Formula, и после того же исправления он использует тот же тест Alt-or-ActualText. Образец из conformance corpus, где Formula имела доступный текст только через ActualText, раньше ложноположительно отклонялся, пока ветку Formula не привели к той же логике, что и ветку Figure
Пункт 7.9 устроен иначе. Note обязан иметь /ID , и этот ID должен быть уникален в пределах всего документа. Отсутствующий ID - это локальная ошибка элемента. Дублирующийся ID - это уже связь между двумя элементами. Именно поэтому и важен плоский массив: для каждой Note checker проходит назад по уже увиденным элементам и помечает столкновение с любой предыдущей Note с тем же ID. Стоимость здесь очевидная, O(n²) по числу Note, но для любого реального документа это не имеет значения и позволяет оставить функцию одним читаемым циклом без вспомогательного индекса, который еще нужно было бы поддерживать в согласованности
Накопление по страницам, чтобы уникальность была глобальной
PDFium открывает structure element по страницам, а не по документу, поэтому orchestration в ValidatePdfUa обязано сначала собрать их вместе. Оно проходит каждую страницу через FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage независимо от того, какая страница у компонента сейчас открыта, и дописывает элементы каждой страницы в один общий массив. Лишь после этого вызывается 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 корректной. Две Note на разных страницах вполне могут разделять один ID. Если валидировать страницу за страницей, столкновение никогда не станет видимым, потому что каждый page-level набор элементов будет выглядеть внутренне согласованным. Сборка единого массива по всему документу - единственный способ увидеть дубликат. Стоит отметить и guard в начале: tree walk запускается только если byte-level pass не сообщил pvuaiMissingStructTreeRoot . У нетегированного документа просто нет дерева, которое можно обходить, и он уже помечен за отсутствие structure root, поэтому page-level load полностью пропускаются. Глубокий проход ничего не стоит на документах, которым он все равно не поможет
Консервативно по замыслу: молчаливо пропустить, но никогда не кричать зря
Самое важное свойство этого validator заключается именно в том, чего он отказывается делать. Он сопоставляет только стандартные имена типа /S , которые FPDF_StructElement_GetType возвращает напрямую: Figure , Formula , Note . Документ, определяющий custom type и role-mapping его в Figure, в зависимости от того, как PDFium разрешит тип, может сообщить собственное имя. Когда это происходит, checker не узнает его и молчит. Это false negative, и именно такого поведения тут и добиваются. Дизайнерское правило - лучше недосообщить, чем хоть раз выдать ложноположительный результат , потому что preflight tool, который поднимает тревогу на conformant файлах, быстро приучает пользователей его игнорировать, а игнорируемый validator хуже, чем никакой. Декоративные изображения живут в artifact stream, а не в structure tree, поэтому как Figure они вообще не появляются. Жалобу на "missing Alt" для фоновой линии, корректно помеченной как artifact, вы здесь не получите
По этой же причине область ограничена всего тремя правилами. Вложенность уровней heading, пункт 7.4, scope у table header, 7.5, и обнаружение cycle в role map, 7.1, все это вполне реальные требования PDF/UA, но их качественная проверка требует серьезного graph и attribute analysis, а наивная проверка дает именно те false positive, которые этот дизайн запрещает. PDF/UA допускает шаблоны heading вроде H1, H2, H3, H3, а простое правило "уровень должен строго расти" ошибочно их отвергло бы. Такие проверки оставлены специализированным conformance tool. Tier-1 ограничен тем подмножеством, где отсутствие атрибута трактуется однозначно
Граница, сформулированная прямо
Два ограничения стоит знать до того, как встраивать это в release gate. Во-первых, checker настолько хорош, насколько хороши данные, которые PDFium способен прочитать из structure element. Некоторое количество файлов из conformance corpus, успешно проходящих у reference validator, используют механизм alternate text, который PDFium просто не выводит наружу, поэтому FPDF_StructElement_GetAltText возвращает пустое значение, хотя файл в действительности conformant. Тогда pure checker "правильно" поднимает missing Alt на неполных данных - это false positive, возникающий не в rule logic, а из-за неполноты coverage у accessor в DLL. Ослаблять правило ради таких случаев нельзя, потому что тогда оно ослепнет и к реальным ошибкам. Поэтому этот сценарий зафиксирован как известное ограничение PDFium, а не замазан поверх
Во-вторых, это preflight, а не сертификация. Tier-1 ловит high-confidence ошибки содержимого, которые byte scan структурно поймать не способен, и делает это без ложной тревоги. Но полная conformance PDF/UA, включая heading semantics, table structure и correctness reading order, все равно принадлежит полноценному validator и, в конце концов, человеческому reviewer. Используйте ValidatePdfUa , чтобы быстро и дешево отбрасывать очевидные дефекты в собственном pipeline, а последнее слово оставляйте veraPDF или PAC. Тот же обход structure tree лежит в основе построения accessible PDF reader in Delphi , где tag tree управляет reading order и произносимым текстом, и дополняет работу на уровне metadata в reviewing PDF annotations from Delphi
Описанные здесь API для structure tree и validator ValidatePdfUa поставляются вместе с PDFium Component для Delphi и C++Builder, VCL, и Lazarus/FPC, LCL. На странице продукта есть ссылка на полный API reference, включая полную структуру записи TPdfStructureElement и перечисление issues, стоящее за этими проверками