Перенос блока полей формы из шаблона прошлого года в макет этого года — тот случай, когда 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 той страницы — иначе поле существует в форме и невидимо на странице. Если вы уже разбирались в разнице между полем, его виджетом и аннотацией страницы, которая его показывает, наша заметка индекс виджета против индекса аннотации описывает именно это разделение
Что требуется от вас методу 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 виджета в итоге указывает на страницу получателя: объект исходной страницы уже разрешается в отображённый объект страницы получателя, так что обычный проход перезаписи ссылок обрабатывает это без особых случаев
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 или подписанный получатель останавливает вас сразу, и разрешать это придётся самому, а не принимать объединённое приближение, — но альтернатива — форма, которая открывается нормально, а вычисляет неверно
Когда перенос — не тот инструмент
Перенос несёт структуру, поэтому применяйте его, когда вам не хватает именно структуры. Если оба документа уже несут один и тот же набор полей и нужно лишь переместить значения и аннотации между ними, путь экспорта и импорта из статьи о данных форм XFDF легче, стандартен и обратим. Берите GraftPdfAcroForm, когда в получателе полей нет вовсе или набор другой, а виджеты, потоки внешнего вида, действия и порядок вычислений должны перейти нетронутыми. Последнее практическое замечание об идентичности: поскольку карта переноса ключуется по номеру объекта плюс поколение и привязана к SHA-256 исходных байтов, повторное сохранение или оптимизация источника между запусками дают другую идентичность и карту, которая больше не применима. Сделайте снимок источника, с которого переносите, и держите его стабильным для всей партии; обращайтесь с ним как с входным артефактом, а не как с тем, что ночное задание может свободно переписать
GraftPdfAcroForm, TPdfCrossDocumentGraftMap и окружающий потоковый PDF-инструментарий поставляются в составе PDFiumPas Delphi PDFium Component для Delphi, C++Builder и Lazarus, где страница продукта содержит полную справку по API для опций переноса, полей отчёта и остальной поверхности редактирования документов