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

Плейсхолдеры шаблонов PDF/VCR-1 в Delphi с PDFlibPas

PDFlibPas реализует PDF/VCR-1 как профиль тегированной структуры: определение замены живёт под StructTreeRoot как дочерняя ветвь верхнего уровня, где корень шаблона несёт /O /GTS_Template, чтобы объявить набор полей, а каждый плейсхолдер несёт /O /GTS_Replacement, чтобы привязать одно поле данных. Ничего из этого не является словарём уровня страницы

Это одно предложение — та часть, которую большинство реализаций делают неправильно, и ошибка здесь стоит дороже, чем провал валидации. Задание с переменными данными, чей план замены прочитан неверно, не падает. Оно печатается, тиражом, с не тем полем в не той рамке, и первым это замечает клиент, держащий пятьдесят тысяч писем

Где на самом деле живёт определение замены

PDF/VCR-1 помещает свою модель замены в иерархию логической структуры, описанную в ISO 32000-1 §14.7, а не в приватный словарь страницы. Корень шаблона — это элемент структуры, висящий прямо на StructTreeRoot, отличающийся владельцем атрибута GTS_Template, и он объявляет весь словарь полей в одном месте через GTS_Fields плюс необязательный GTS_Pages, называющий поле, которое управляет выбором страниц

Плейсхолдеры — это элементы структуры, вложенные внутрь этого корня, с владельцем атрибута GTS_Replacement. Каждый несёт GTS_Generator (который PDFlibPas требует равным PassThrough), значение GTS_Data, называющее ровно одно объявленное поле, и необязательный GTS_BBox. Привязка к странице — тоже не аннотация страницы: элемент указывает на свою страницу через /Pg и на последовательность marked-content через /K. Выбор структуры вместо словаря страницы влечёт следствие, которое легко сформулировать и легко недооценить: дерево структуры — это общий граф, достижимый из каталога, так что любой потребитель профиля обязан быть обходчиком дерева со всеми вытекающими опасностями, а не читателем словаря. Если вы раньше не работали с деревом структуры, механика разложена в статье про тегированную структуру и доступность PDF

Где PDFlibPas размещает модель замены PDF/VCR-1: корень шаблона висит прямо на StructTreeRoot с владельцем атрибута GTS_Template, объявляя GTS_Fields и необязательный GTS_Pages, тогда как каждый вложенный плейсхолдер несёт GTS_Replacement, GTS_Generator равный PassThrough, одно поле GTS_Data, необязательный GTS_BBox и указывает на свою страницу через /Pg и на своё содержимое через /K
Определение замены живёт в иерархии логической структуры, а не в словаре страницы, что делает каждого потребителя обходчиком дерева и превращает элемент в узел графа, который может формировать атакующий

Почему проверка только первой верхней ветви пропускает битые файлы?

Потому что у StructTreeRoot может быть много прямых детей, и валидатор, остановившийся после нахождения корня шаблона, никогда не увидит остальных. Это конкретный отказ, от которого PDFlibPas построен, чтобы защититься, и он даёт ровно тот ложный проход, который хуже отсутствия проверки: файл, заявляющий конформность и содержащий второй корень шаблона, или плейсхолдер, сидящий вне ветви шаблона, где его не признает ни один потребитель

Поэтому ValidatePDFVCRStructure в PDFlibPas делает два прохода по одному дереву. Первый проход перечисляет каждого прямого ребёнка StructTreeRoot и считает тех, кто несёт GTS_Template; всё, кроме ровно одного, отклоняется до чтения хоть одного плейсхолдера. Второй проход спускается во все верхние ветви, а не только в ветвь шаблона, и отклоняет сразу две вещи: вложенный элемент с GTS_Template, который не является корнем, и любой элемент GTS_Replacement, найденный вне ветви шаблона

Обход ограничен 64 уровнями глубины и 1 000 000 узлов, и он записывает номер объекта и номер поколения каждого посещённого элемента, чтобы цикл или повторная ссылка сообщались, а не обходились. Эти числа — не тюнинг производительности. Дерево структуры — это объектный граф, которым управляет атакующий, и валидатор, рекурсирующий без потолка, — это переполнение стека с расширением файла

Двухпроходная валидация ValidatePDFVCRStructure в PDFlibPas: первый проход считает прямых детей StructTreeRoot с GTS_Template и отклоняет всё, кроме ровно одного, второй проход спускается в каждую верхнюю ветвь, чтобы отвергнуть вложенный корень шаблона и любой GTS_Replacement вне ветви шаблона, с ограничением в 64 уровня и миллион узлов
Валидатор, остановившийся после нахождения первого корня шаблона, никогда не увидит второй, поэтому обход покрывает все верхние ветви и записывает номера объектов, чтобы цикл сообщался, а не обходился

Создание шаблона и ограничения, которые API отказывается гнуть

PDFlibPas выставляет создание через bracketing API, который принудительно применяет структурные правила в момент записи, а не обнаруживает их при сохранении. SetPDFVCRMode переключает документ в профиль, что внутренне требует базового режима PDF/X-4 из ISO 15930-7 и включает флаг mark-info, потому что PDF/VCR-1 файлу без marked content не на что указывать плейсхолдеру. Оттуда BeginPDFVCRTemplate принимает список полей и поле выбора страницы, и он строг в вещах, о которых стоит знать до проектирования вашей схемы данных. Имена полей проходят через PDFVCRValidFieldName, который принимает только ASCII-буквы, цифры, подчёркивание, дефис и точку, потому что это символы, сериализующиеся в PDF-имя без неоднозначности экранирования. PDFVCRNormalizeFieldList держит список регистрозависимым и безоговорочно отклоняет дубликаты, а поле выбора страницы обязано само присутствовать в объявленном списке

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;

    if Lib.SetPDFVCRMode(1) <> 1 then
      raise Exception.Create('PDF/VCR mode was refused');

    Lib.NewPage;
    if Lib.BeginPDFVCRTemplate('Salutation,FullName,AccountNo',
                               'AccountNo') <> 1 then
      raise Exception.Create('Field list rejected');

    // Один плейсхолдер привязывает ровно одно объявленное поле.
    Lib.BeginPDFVCRPlaceholder('FullName', 72, 690, 320, 712);
    Lib.DrawText(72, 694, 'SAMPLE NAME');
    Lib.EndPDFVCRPlaceholder;

    Lib.EndPDFVCRTemplate;

    Diag := Lib.GetPDFVCRDiagnostics;
    if Diag <> '' then
    begin
      Writeln('PDF/VCR blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('campaign-template.pdf');
  finally
    Lib.Free;
  end;
end;

Заметьте, что даёт скобочный подход. BeginPDFVCRPlaceholder отказывается открываться, если шаблон не активен и стек тегов ровно один уровень в глубину, — так PDFlibPas гарантирует, что плейсхолдеры — прямые дети корня шаблона и никогда не вложены друг в друга. Координаты тоже валидируются, с отклонением NaN, бесконечности и инвертированных прямоугольников, так что плохой bounding box падает на вызове, а не в префлайт-отчёте неделю спустя

Почему плейсхолдеру нужен свой путь сериализации MCID?

Потому что PDF/VCR-1 хочет, чтобы /K был голым целым, а так общий писатель тегированного PDF связь между элементом структуры и его содержимым не сериализует. По ISO 32000-1 §14.6 элемент структуры обычно ссылается на marked content либо прямым целым MCID, когда элемент уже идентифицирует свою страницу через /Pg, либо словарём ссылки на marked content, который явно называет страницу. Библиотека, выпускающая тегированный вывод для многих целей, обычно стандартизуется на словарной форме, потому что она лучше компонуется через страницы

PDFlibPas взял здесь узкий вариант вместо общего. SetCurrentStructElemDirectMCIDKid помечает текущий элемент структуры, и только элементы с этим флагом сериализуют /K как прямой MCID со страницей на /Pg; всё остальное сохраняет существующий путь MCR-словаря нетронутым. Это осознанный размен, и он из тех, что стоит назвать вслух: добавление профиля не должно менять байты вывода, который уже производит каждый другой тегированный документ, поэтому профиль получает собственный путь вместо глобального переключателя. Каждый существующий тегированный файл сохраняет прежнюю сериализацию, а поверхность регрессий остаётся размером с новую фичу

Один обход, два потребителя

PDFlibPas выводит манифест замены и вердикт о соответствии из того же двухпроходного обхода, и именно поэтому манифесту можно доверять как плану задания. Обход заполняет запись TPDFVCRManifestData, пока валидирует: Fields из корня шаблона, PageSelectionField, когда он объявлен, и массив Placeholders из записей TPDFVCRPlaceholderInfo, несущих FieldName, PageNumber, MCID, HasBBox и BBox. Точка входа для проверки соответствия — CheckCompliancePDFVCR(InputStream, Password, Options, ResultList), и она гоняет свои проверки в порядке зависимостей, а не все сразу: сначала полная проверка PDF/X, затем GTS_PDFXVersion против PDF/X-4, затем GTS_PDFVCRVersion, читаемый из пространства имён http://www.npes.org/pdfvcr/ns/id/, и только потом обход структуры. Передача Options как 1 заставляет остановиться на первой проблеме — это то, что нужно в пакетном гейте; передача 0 собирает всё — это то, что нужно в отчёте

Один обход, два потребителя в PDFlibPas: тот же ограниченный обход заполняет TPDFVCRManifestData полями Fields, PageSelectionField и массивом Placeholders из записей TPDFVCRPlaceholderInfo, тогда как CheckCompliancePDFVCR гоняет полную проверку PDF/X, GTS_PDFXVersion, GTS_PDFVCRVersion из пространства имён npes.org и только потом обход структуры, возвращая 100 только когда файл проходит
Манифест — это контракт, поэтому BuildPDFVCRManifest возвращает JSON только когда список проблем приходит пустым, иначе — ошибку 608, не позволяя неконформному файлу притворяться достоверным планом задания
// Проверьте шаблон, который вы получили, а не произвели
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('supplier-template.pdf',
                               fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFVCR(Stream, '', 0, Issues) <> 100 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/VCR: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

Манифест — это контракт, поэтому он существует только для конформных файлов

BuildPDFVCRManifest возвращает JSON только когда список проблем приходит пустым, и пустую строку иначе. Это не защитное кодирование ради самого себя. Манифест потребляет компоновочный движок, который будет гнать из него реальный вывод, и отдать частичный список полей, извлечённый из файла с двумя корнями шаблонов, — значит пригласить движок относиться к неконформной структуре как к достоверному плану замены. Через документный API GetPDFVCRManifest(InputFileName, Password) оборачивает тот же вызов и ставит последний код ошибки в PDFLIB_ERROR_PDFVCR_COMPLIANCE (608), когда файл не проходит, так что провал извлечения и пустой шаблон различимы. JSON сообщает fields, pageSelectionField как строку или null и массив placeholders, чьи записи дают field, page, mcid и либо четырёхчисловой bbox, либо null

var
  Lib: TPDFlib;
  Manifest: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
    if Manifest = '' then
      // 608 означает, что файл не соответствует PDF/VCR-1,
      // 401 означает, что файл не удалось открыть
      Writeln('No manifest, error ', Lib.LastErrorCode)
    else
      Writeln(Manifest);
  finally
    Lib.Free;
  end;
end;

Одна деталь, которую стоит пронести в ваш потребитель: значение page следует принятой во всей библиотеке нулевой нумерации страниц, а не нумерации с единицы, которую видит человек во вьюере. Перепутать их — тихий off-by-one, который кладёт контент не на тот лист, и его не поймает ни один валидатор, потому что оба числа структурно валидны

Где границы

Поддержка PDF/VCR-1 в PDFlibPas покрывает создание шаблонов, гейт сохранения, автономный префлайт и извлечение манифеста. Она не выполняет замену. Подстановка данных в плейсхолдеры и производство персонализированных экземпляров — это шаг компоновки, и он живёт вне библиотеки намеренно, потому что манифест — именно тот интерфейс, который позволяет писать компоновочный движок независимо. Гейт сохранения тоже отказывает, а не чинит. В отличие от профилей, где неверный флаг аннотации можно молча нормализовать, каждое ограничение PDF/VCR-1, которое принудительно применяет PDFVCRReadyForSave, относится к тем, где угадывание меняло бы смысл документа: выдумать корень шаблона или выбрать, к какому полю относится непривязанный плейсхолдер, — значит произвести файл, который валидируется и лжёт. Та же линия «чинить или отказывать» обсуждается для другого профиля в статье про авторский режим PDF/E-1 и ограниченный префлайт, а рабочий процесс прогона нескольких проверок соответствия по одному документу покрыт в статье про верстак соответствия и подписи

Создание, ограниченный обход структуры, гейт сохранения и экстрактор манифеста — всё это поставляется в PDFlibPas Delphi PDF library, а это значит, что шаблон можно произвести под профилем, а затем проверить отдельным путём кода, который никогда не видел состояние писателя, и именно это разделение — единственная причина, по которой заявление о соответствии вообще стоит чего-то