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

Перенос полей AcroForm между PDF в Delphi с PDFiumPas

Перенос блока полей формы из шаблона прошлого года в макет этого года — тот случай, когда FDF и XFDF перестают справляться: значения приходят, а потоки внешнего вида, действия вычислений и ресурсы по умолчанию — нет. PDFiumPas отвечает на этот случай методом GraftPdfAcroForm, который клонирует весь граф объектов полей из одного PDF и записывает его в другой

Причина, по которой экспорт на уровне данных этого не может, структурна. Поле — не запись, а подграф. ISO 32000-1 §12.7 определяет словарь интерактивной формы, содержащий /Fields, /CO, /DR и /DA, §12.7.3 определяет словари полей, подвешенные под ним, а §12.5.6.19 определяет аннотации-виджеты, дающие этим полям видимый прямоугольник на странице. XFDF переносит листья этой структуры. Перенос графа несёт саму структуру

Почему одного копирования массива /Fields никогда не бывает достаточно

Копирование /Fields из одного документа в другой даёт форму, сломанную всеми возможными способами, потому что массив хранит косвенные ссылки и ничего больше. ISO 32000-1 §7.3.10 делает косвенный объект адресуемым по номеру объекта плюс поколение, и эти числа имеют смысл только внутри файла, откуда они пришли. Вставьте массив в другой документ — и каждая ссылка в нём либо висит, либо, что хуже, молча разрешается в посторонний объект, которому случилось занимать тот слот в получателе. Под каждой ссылкой лежит граф одновременно общий и циклический. Словарь поля указывает на своих потомков, каждый потомок указывает обратно на /Parent, виджет указывает на свои потоки внешнего вида и на несущую его страницу через /P, потоки внешнего вида указывают на шрифты в словаре ресурсов формы по умолчанию, а словари дополнительных действий под /AA указывают на ещё большее число объектов. Два виджета на разных страницах обычно делят один шрифт и один XObject внешнего вида. Поэтому правильный перенос должен обойти этот граф, клонировать каждый достижимый объект ровно один раз, перенаправить /P каждого виджета на отображённую страницу получателя и добавить клонированный виджет в массив /Annots той страницы — иначе поле существует в форме и невидимо на странице. Если вы уже разбирались в разнице между полем, его виджетом и аннотацией страницы, которая его показывает, наша заметка индекс виджета против индекса аннотации описывает именно это разделение

Граф объектов за одним полем PDF-формы при переносе в PDFiumPas в Delphi: словарь формы, поле, аннотации-виджеты, массивы аннотаций страниц получателя, поток внешнего вида и шрифт, которые делят оба виджета, плюс обратная ссылка на родителя, замыкающая цикл
Поле — это общий циклический подграф, поэтому копирование массива /Fields между документами оставляет каждую ссылку висящей

Что требуется от вас методу GraftPdfAcroForm?

Ему нужны три отдельных потока и явное отображение страниц. GraftPdfAcroForm принимает Source, Destination и Output как отдельные экземпляры TStream, массив TPdfGraftPageMappings, запись TPdfAcroFormGraftOptions, необязательную TPdfCrossDocumentGraftMap и выходной TPdfAcroFormGraftReport. Он возвращает Boolean, а не вызывает исключение, и при отказе отчёт несёт причину в ErrorMessage. Отображение страниц единично с обеих сторон и не выводится само: каждая исходная страница, несущая виджет, который вы намерены перенести, должна в нём присутствовать. Передать nil вместо карты переноса легально — функция тогда создаёт и освобождает собственную на время вызова, — а TPdfAcroFormGraftOptions.Default даёт CollisionPolicy со значением pagcpReject, RenamePrefix со значением Imported_, MaxObjects равным 100000, MaxDepth равным 128 и AllowSignedDestination равным False. Последние три — это бюджеты, и существуют они потому, что граф объектов, который вы собираетесь обойти, пришёл из файла, который писали не вы

uses
  Classes, SysUtils, FPdfCompress;

var
  Source, Destination, Output: TMemoryStream;
  Options: TPdfAcroFormGraftOptions;
  Mappings: TPdfGraftPageMappings;
  Report: TPdfAcroFormGraftReport;
begin
  Source := TMemoryStream.Create;
  Destination := TMemoryStream.Create;
  Output := TMemoryStream.Create;
  try
    Source.LoadFromFile('claim-template-2025.pdf');
    Destination.LoadFromFile('claim-layout-2026.pdf');
    Source.Position := 0;
    Destination.Position := 0;

    Options := TPdfAcroFormGraftOptions.Default;

    SetLength(Mappings, 2);
    Mappings[0].SourcePageNumber := 1;
    Mappings[0].DestinationPageNumber := 1;
    Mappings[1].SourcePageNumber := 2;
    Mappings[1].DestinationPageNumber := 3;

    if GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, nil, Report) then
      Output.SaveToFile('claim-2026-with-fields.pdf')
    else
      raise Exception.Create(Report.ErrorMessage);
  finally
    Output.Free;
    Destination.Free;
    Source.Free;
  end;
end;

Как карта переноса избегает двойного клонирования общего шрифта?

TPdfCrossDocumentGraftMap хранит таблицу ссылок «исходный объект → объект получателя», ключи которой несут и номер объекта, и поколение, и рекурсивная процедура клонирования сверяется с ней, прежде чем спускаться. Порядок операций делает циклы безопасными: процедура клонирования сначала выделяет номер объекта в получателе и регистрирует отображение, затем обходит дочерние ссылки исходного объекта. Родитель, дошедший до потомка, который указывает обратно на родителя, находит родителя уже зарегистрированным и возвращает существующую ссылку получателя вместо рекурсии. Тот же поиск делает так, что шрифт, поток внешнего вида или действие, разделяемые шестью виджетами, клонируются один раз и упоминаются шесть раз. Карта привязана к исходному документу хэшем SHA-256 исходных байтов, доступным как SourceIdentity. Если передать GraftPdfAcroForm карту, чья идентичность не совпадает с переданным источником, она отклонит вызов, а не станет переиспользовать ссылки, никогда не бывшие действительными для этого файла. Отображения страниц засеиваются в ту же карту до начала клонирования, и именно поэтому /P виджета в итоге указывает на страницу получателя: объект исходной страницы уже разрешается в отображённый объект страницы получателя, так что обычный проход перезаписи ссылок обрабатывает это без особых случаев

Карта междокументного переноса PDFiumPas в Delphi ключует каждую исходную ссылку по номеру объекта и поколению, регистрирует отображение получателя до спуска, чтобы обратная ссылка на родителя завершилась, и возвращает существующую запись, поэтому общий шрифт клонируется только один раз
Регистрация отображения до обхода потомков делает циклический граф безопасным, а общий объект клонируется ровно один раз
uses
  Classes, SysUtils, FPdfCompress, FPdfSha256;

var
  GraftMap: TPdfCrossDocumentGraftMap;
  SourceBytes: TBytes;
  EntriesBefore: Integer;
begin
  SetLength(SourceBytes, Source.Size);
  Source.Position := 0;
  if Length(SourceBytes) > 0 then
    Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));

  GraftMap := TPdfCrossDocumentGraftMap.Create(
    AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
  try
    EntriesBefore := GraftMap.Count;
    Source.Position := 0;
    if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, GraftMap, Report) then
    begin
      // Записи, добавленные этим вызовом, откачены;
      // всё, зарегистрированное до него, осталось нетронутым.
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

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

Конфликты имён полей: отклонять или переименовывать

Полные имена полей должны оставаться уникальными внутри формы, и PDFiumPas не станет угадывать, что вы имели в виду при конфликте. TPdfAcroFormCollisionPolicy предлагает ровно два ответа. При pagcpReject, значении по умолчанию, первое исходное поле, чьё имя уже существует в получателе, прерывает весь перенос с ошибкой и оставляет выходной поток пустым. При pagcpRename конфликтующее исходное поле переименовывается добавлением префикса RenamePrefix, и перенос продолжается, а Report.RenamedFieldCount сообщает, как часто это случилось

Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;

if GraftPdfAcroForm(Source, Destination, Output, Mappings,
  Options, nil, Report) then
begin
  WriteLn('source fields  : ', Report.SourceFieldCount);
  WriteLn('existing fields: ', Report.DestinationFieldCount);
  WriteLn('grafted fields : ', Report.GraftedFieldCount);
  WriteLn('renamed fields : ', Report.RenamedFieldCount);
  WriteLn('cloned objects : ', Report.GraftedObjectCount);
  WriteLn('reused objects : ', Report.ReusedObjectCount);
  WriteLn('mapped pages   : ', Report.MappedPageCount);
  WriteLn('output bytes   : ', Report.OutputByteCount);
end
else
  WriteLn('graft refused  : ', Report.ErrorMessage);

Переименование не бесплатно, и решать его стоит осознанно, а не тянуться к нему, чтобы убрать ошибку. Переименованное поле — другое поле: любой JavaScript в получателе, адресующий его по имени, любая запись вычислений в /CO, написанная человеком под старым именем, и любой последующий потребитель, ключующийся по имени поля, должны узнать о префиксе. Если оба документа действительно описывают одно и то же поле, честное исправление обычно в том, чтобы согласовать имена выше по потоку, а не в момент переноса. Когда перенос состоялся, естественный следующий шаг — обойти объединённую форму и убедиться, что вы действительно получили; навигация по полям формы в PDFiumPas описывает этот обход

Где перенос сознательно завершается отказом

Каждое неоднозначное условие — ошибка, а не результат «как получится», и это проектное решение стоит понять прежде, чем оно удивит вас в продакшене. GraftPdfAcroForm возвращает False, сбрасывает выходной поток и сообщает причину, если встречает любое из перечисленного

  • Исходная форма несёт запись /XFA — пакеты XFA представляют собой параллельную модель форм и не сводятся к словарям полей AcroForm
  • Виджет находится на исходной странице, которой нет в отображении страниц, — иначе поле было бы молча потеряно или прикреплено к чужой странице
  • Отображения страниц вне диапазона или два отображения используют одну и ту же исходную либо конечную страницу
  • Обе формы определяют словарь ресурсов по умолчанию /DR, потому что слияние двух пространств имён ресурсов рискует перенаправить существующее имя на другой шрифт
  • Граф объектов превышает MaxObjects или рекурсия превышает MaxDepth
  • Получатель содержит подпись, а AllowSignedDestination равно False
  • Переданная карта переноса принадлежит другому исходному документу или исходная ссылка висит

Путь записи столь же консервативен. PDFiumPas выдаёт результат как разреженную инкрементную ревизию, добавленную к получателю, затем заново материализует записанный вывод и перечитывает его форму: если число полей результата не равно исходному числу полей получателя плюс числу полей источника, весь перенос отклоняется, а вывод очищается. Вы никогда не получите частично перенесённый файл. Цена такой политики реальна — конфликт /DR или подписанный получатель останавливает вас сразу, и разрешать это придётся самому, а не принимать объединённое приближение, — но альтернатива — форма, которая открывается нормально, а вычисляет неверно

Как GraftPdfAcroForm в PDFiumPas завершается отказом в Delphi: записанная ревизия перечитывается и проверяется по числу полей, любое неоднозначное условие вроде XFA или неотображённой страницы отклоняет вызов, а отказ отбрасывает только записи карты, добавленные этим вызовом
Проверяемый путь записи и транзакционная карта — вот почему отклонённый перенос никогда не оставляет после себя частично объединённого файла

Когда перенос — не тот инструмент

Перенос несёт структуру, поэтому применяйте его, когда вам не хватает именно структуры. Если оба документа уже несут один и тот же набор полей и нужно лишь переместить значения и аннотации между ними, путь экспорта и импорта из статьи о данных форм XFDF легче, стандартен и обратим. Берите GraftPdfAcroForm, когда в получателе полей нет вовсе или набор другой, а виджеты, потоки внешнего вида, действия и порядок вычислений должны перейти нетронутыми. Последнее практическое замечание об идентичности: поскольку карта переноса ключуется по номеру объекта плюс поколение и привязана к SHA-256 исходных байтов, повторное сохранение или оптимизация источника между запусками дают другую идентичность и карту, которая больше не применима. Сделайте снимок источника, с которого переносите, и держите его стабильным для всей партии; обращайтесь с ним как с входным артефактом, а не как с тем, что ночное задание может свободно переписать

GraftPdfAcroForm, TPdfCrossDocumentGraftMap и окружающий потоковый PDF-инструментарий поставляются в составе PDFiumPas Delphi PDFium Component для Delphi, C++Builder и Lazarus, где страница продукта содержит полную справку по API для опций переноса, полей отчёта и остальной поверхности редактирования документов