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

Ограничения реализации PDF/A и проверки кодировок шрифтов

Архив счетов может чисто рендериться, печататься, встраивать все шрифты и нести валидный output intent — и валидатор PDF/A всё равно отвергнет его, потому что один словарь ресурсов держит 4 096 записей. Потолок — 4 095. PDF/A наследует из PDF Reference небольшую таблицу лимитов реализации, и файл, превысивший любой из них на единицу, проваливает соответствие, хотя ни один вьювер не жалуется

Другой провал, попадающий в ту же корзину «но выглядит же нормально», — символический TrueType-шрифт, несущий запись /Encoding. Чек-боксы, штрих-коды и Dingbats выходят на экран правильно, а валидатор всё равно мечет метку на словарь шрифта. Обе проблемы живут в графе объектов, а не в чём-то видимом человеком на странице, поэтому стоит проверять их механически, прежде чем файл отправится в архив

Числа, которые PDF/A наследует, и где проходит граница

PDFium Component проверяет четыре лимита контейнеров на каждой валидации PDF/A: токен имени может быть длиной не более 127 байт, массив может держать не более 8 191 элемента, словарь — не более 4 095 записей, а массивы и словари могут вкладываться не глубже 28 уровней. Все четыре границы включительные. Имя в 127 байт проходит, в 128 — проваливается; массив ровно из 8 191 элемента проходит, а ещё один элемент валит его

Лимиты имён, массивов и словарей родом из эпохи, когда соответствующий читатель обязан был обрабатывать структуры лишь до этих размеров. Современный настольный вьювер может открыть куда больший массив без жалоб, но архивный файл должен открываться в читателях, с которыми никто ещё не тестировал, — включая старые, которые система документооборота может всё ещё гонять. Потому PDF/A и держит исторические потолки вместо того, чтобы их ослаблять

Включительность против исключительности — место, где самодельные проверки обычно ошибаются. Тест, написанный как Count >= 8191 вместо Count > 8191, отвергает соответствующие файлы, а валидатору, отвергающему соответствующие файлы, всё равно почему-то верят, поэтому генератор «чинят» от проблемы, которой у него не было. Функция ValidatePdfACompliance в юните FPdfPdfa считает строго за пределом и читает байты файла напрямую, так что проверка идёт без загрузки DLL PDFium

Диаграмма четырёх включительных лимитов контейнеров, которые PDFium Component проверяет байтовым сканом из Delphi: токены имён в 127 байт, 8191 элемент массива, 4095 записей словаря и 28 уровней вложенности массивов и словарей
Все четыре предела включительны — граничное значение само валидно, и лишь следующая единица проваливается
uses
  System.Classes, FPdfPdfa;

procedure CheckContainerLimits(const FileName: string; Log: TStrings);
var
  Src: TFileStream;
  Res: TPdfAValidationResult;
begin
  Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Res := ValidatePdfACompliance(Src);   // байтовый скан, DLL PDFium не нужна
    if pvaiNameOverLimit in Res.Issues then
      Log.Add('A name token is longer than 127 bytes');
    if pvaiArrayOverLimit in Res.Issues then
      Log.Add('An array holds more than 8191 elements');
    if pvaiDictOverLimit in Res.Issues then
      Log.Add('A dictionary holds more than 4095 entries');
    if pvaiNestingOverLimit in Res.Issues then
      Log.Add('Arrays and dictionaries nest deeper than 28 levels');
    if Res.IsCompliant then
      Log.Add('No PDF/A findings');
  finally
    Src.Free;
  end;
end;

Четыре флага — члены множества Issues на TPdfAValidationResult, рядом со всеми прочими находками PDF/A, а IsCompliant возвращает True, только когда найдена PDF/A-идентификация и множество пусто. Файл, не несущий PDF/A-идентификации вовсе, проверяется по строгому набору правил PDF/A-1, так что лимиты контейнеров к нему тоже применяются

Какие генераторы упираются в эти лимиты?

Почти всегда те, что строят структуру в цикле. Форма с несколькими тысячами полей порождает массив AcroForm /Fields или массив /Annots страницы, уходящий за 8 191 записей. Генератор отчётов, регистрирующий по одному ресурсу шрифта или изображения на каждый сгенерированный объект и никогда не переиспользующий существующую запись, растит суб-словарь /Resources за 4 095 ключей. Размеченный документ, чьё дерево структуры построено рекурсией по вложенной модели данных, может пройти 28 уровней, пока никто не смотрит

Длинные имена родом из другого места: данные, запихиваемые в токены имён. Краска сепарации, названная по клиентской ссылке, группа опционального содержимого, названная по пути файла, метка поля, собранная из шести уровней иерархии. Имена ничего не стоят в генерации, а 127 байт утекают быстро, стоит появиться UTF-8-метке с акцентированными символами: каждый из них занимает два-три байта. 127 байт считаются после вводного /, до следующего разделителя или пробела

Лекарство каждый раз структурное. Разбейте массив, разбейте словарь, сплющите рекурсию, укоротите имя. Впрыскивание PDF/A-маркеров в файл не поможет ни с одной из этих находок: проблема — форма графа объектов, а XMP-метаданные о ней молчат

Как скан считает элементы и записи

Правило счёта важно, когда вы пытаетесь воспроизвести находку в hex-редакторе. Вложенный массив или словарь считается одним элементом родителя — так и сформулирован лимит: массив из 10 массивов по 1 000 элементов в каждом — норм, потому что ни один отдельный массив не превышает 8 191. Внутри словаря считаются только ключи, поэтому значение, оказавшееся именем, не раздувает счёт записей. Hex-строки вроде <FEFF0041> пропускаются как одиночные токены, поэтому их угловые скобки никогда не путают с вложенностью словарей

Скан покрывает объектную структуру всего файла, включая объекты, хранящиеся внутри сжатых object-потоков, — они распаковываются до счёта. Тела самих потоков, вроде содержимого страниц и данных изображений, сперва замазываются, поэтому серия бинарных байтов, случайно содержащая / или [, не может вызвать ложную находку имени или массива

Есть смежный числовой лимит, который та же валидация сообщает под собственным флагом. Когда файл объявляет PDF/A-2 или более позднюю часть, pvaiIntegerImplementationLimit поднимается для любого целочисленного токена вне 32-битного знакового диапазона — то есть вне −2 147 483 648 … 2 147 483 647. Генераторы, пишущие байтовые смещения или большие счётчики простыми целыми, — обычный источник

Почему символический TrueType-шрифт обязан сбросить /Encoding?

ISO 19005-1, §6.3.7 допускает в качестве отображения символов символического TrueType-шрифта только встроенный cmap шрифтовой программы. Символический шрифт отображает коды в глифы на собственных условиях — именно это и объявляет флаг Symbolic. Добавьте запись /Encoding — и файл даёт два ответа на вопрос «какой глиф выбирает байт 0x41», и ничего в файле не говорит, кто побеждает. Читатели разрешают конфликт по-разному, и один и тот же чек-бокс может выйти галочкой в одном вьювере и буквой A в другом

ValidatePdfACompliance находит каждый словарь шрифта с /Subtype /TrueType, проверяет, есть ли у него ключ /Encoding, а затем читает бит 3 (значение 4) записи /Flags в дескрипторе шрифта. Дескриптор отслеживается и записанный инлайн, и указанный непрямым объектом. Несимволический TrueType-шрифт, хранящий /WinAnsiEncoding или /MacRomanEncoding, эта проверка не мечет: для несимволических шрифтов PDF/A ждёт именно такую кодировку

Диаграмма контраста: символический TrueType-шрифт, чья запись /Encoding создаёт два конкурирующих глифовых отображения, против не-символического шрифта, легитимно хранящего WinAnsiEncoding, в проверке PDFium Component в Delphi
Проверка читает символический флаг из дескриптора шрифта и срабатывает на противоречии, а не на присутствии кодировки
// Res приходит из ValidatePdfACompliance, как в первом примере
if pvaiSymbolicTrueTypeEncoding in Res.Issues then
  Log.Add('A symbolic TrueType font carries /Encoding; PDF/A admits only ' +
    'the font''s built-in cmap (ISO 19005-1 6.3.7)');

// Соседнее шрифтовое правило с другим триггером: НЕсимволический
// TrueType-шрифт, чей словарь Encoding несёт /Differences
if pvaiTrueTypeDifferencesForbidden in Res.Issues then
  Log.Add('A non-symbolic TrueType Encoding dictionary carries /Differences');

Шрифты, спотыкающие это правило, предсказуемы: Symbol, Wingdings, штрих-кодовые шрифты, иконочные шрифты. Они появляются в деловых документах как чек-боксы, звёзды рейтингов и штрих-коды, и шаг сабсетирования, обращающийся со всяким TrueType одинаково, пишет для них кодировку тоже. Они же — шрифты, на которые никто не смотрит, когда валидатор говорит «проблема со шрифтом», потому что сперва проверяют шрифты основного текста

Обратная ошибка тоже встречается в дикой природе: производитель ставит флаг Symbolic на обычный текстовый шрифт, который на деле закодирован WinAnsi. Проверка PDF/A следует пункту так, как он написан, и сообщает тот шрифт, и тут ремонт принадлежит дескриптору: исправьте флаг в дескрипторе, а не удаляйте кодировку. Удалить /Encoding из текстового шрифта, который неверно пометили, — значит оставить текст зависимым от того cmap, что случайно оказался в сабсете, а это файл хуже исходного. Посмотрите на набор глифов шрифта, прежде чем выбирать, какую половину противоречия убрать

Чтение находок в preflight-отчёте

Юнит preflight-отчёта поставляется с редакцией Enterprise, а ValidatePdfACompliance — в каждой редакции. Отчёт в юните FPdfPreflightReport раскладывает четыре лимита контейнеров в категорию Structure, а проблему кодировки символического TrueType — в категорию Content. Разделение нарочно. Находки структуры обычно возвращаются тому, кто обслуживает генератор, находки содержимого — тому, кто поставлял шрифты и шаблоны, а отчёт, смешивающий их, отправляет обоих гоняться за чужими проблемами

Каждая проблема несёт имя своего enum как Code и рекомендацию, называющую лекарство конкретно, например «Split dictionaries so none carries more than 4095 entries». Учтите, что категория Structure держит ещё две находки — отсутствие размеченной структуры при заявке Level A и cross-reference или object-потоки PDF 1.5 в файле PDF/A-1, — так что фильтруйте по коду, когда вам нужны только четыре лимита

uses
  System.SysUtils, System.Classes, PDFium, FPdfPreflightReport;

procedure ReportStructureFindings(const FileName: string; Log: TStrings);
var
  Pdf: TPdf;
  Report: TPdfPreflightReport;
  Issues: TPdfPreflightIssues;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;          // этот путь загружает DLL PDFium
    if not Pdf.Active then
      Exit;
    Report := BuildPdfPreflightReport(Pdf, [ppsPdfA]);
    Issues := Report.FilterByCategory('Structure').FlattenIssues;
    for I := 0 to High(Issues) do
      Log.Add(Issues[I].Code + ': ' + Issues[I].Recommendation);
    Report.SaveJsonToFile(ChangeFileExt(FileName, '.preflight.json'));
  finally
    Pdf.Free;
  end;
end;

Два пути отвечают на слегка разные вопросы. BuildPdfPreflightReport идёт через TPdf.ValidatePdfA, который сохраняет открытый документ во временную копию, гоняет по ней тот же байтовый скан и затем добавляет проверки, требующие движка PDFium, — например, шрифты, используемые на странице, но не встроенные. ValidatePdfACompliance судит точные байты на диске. Для лимитов реализации доверять стоит байтовому пути по исходному файлу: он рапортует о файле, который вы вот-вот заархивируете, и работает в шаге сборки или зажатой службе, куда нативную DLL не развернёшь. Путь отчёта берите, когда нужны ещё и проверки уровня движка и вывод JSON, SARIF или JUnit для конвейера

Куда двигаться дальше

Прогон этих проверок по папке, решение, какие находки блокируют релиз, и превращение результата в отчёт, который люди будут читать, разобраны в материале о preflight-валидации PDF/A в Delphi и в CLI пакетного preflight-отчёта. Какую часть PDF/A и уровень соответствия целить с самого начала — предмет заметок о архивном соответствии PDF/A; тот выбор решает, между прочим, применяется ли целочисленный лимит

Показанные здесь валидаторы поставляются с PDFium Component для Delphi, C++Builder и Lazarus — вместе с рендерингом, заполнением форм и редактированием поверх движка PDFium. Поддерживаемые стандарты соответствия, редакции и версии компиляторов перечислены на странице продукта PDFium Component