Технічна стаття

Перенесення 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 несе листя тієї структури. Перенесення (grafting) несе саму структуру

Чому копіювання масиву /Fields ніколи не достатньо

Копіювання /Fields з одного документа в інший дає форму, зламану всіма цікавими способами, бо масив тримає непрямі посилання і більше нічого. ISO 32000-1 §7.3.10 робить непрямий обʼект адресованим за номером обʼєкта плюс поколінням, а ті числа мають сенс лише всередині файла, з якого прийшли. Вклейте масив через — і кожне посилання в ньому або висне в повітрі, або, гірше, мовчки розвʼязується в сторонній обʼєкт, що випадково займає той слот у цільовому файлі. Під кожним посиланням сидить граф, що одночасно спільний і циклічний. Словник поля вказує на свої дітей, кожна дитина вказує назад на свій /Parent, віджет вказує на свої потоки вигляду і на сторінку, що несе його, через /P, потоки вигляду вказують на шрифти в типовому ресурсному словнику форми, а словники additional-action під /AA вказують на ще більше обʼєктів. Два віджети на різних сторінках регулярно ділять один шрифт і один appearance XObject. Тож правильне перенесення мусить обійти той граф, клонувати кожен досяжний обʼєкт рівно один раз, перенаправити /P кожного віджета на замаплену сторінку призначення і додати склонований віджет до масиву /Annots тієї сторінки — інакше поле існує у формі і невидиме на сторінці. Якщо ви ганялися за різницею між полем, його віджетом і анотацією сторінки, що його показує, наша замітка про індекс віджета проти індекса анотації покриває рівно той поділ

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

Що GraftPdfAcroForm потребує від вас?

Він потребує трьох окремих потоків і явного мапування сторінок. GraftPdfAcroForm бере Source, Destination і Output як окремі примірники TStream, масив TPdfGraftPageMappings, запис TPdfAcroFormGraftOptions, необовʼязковий TPdfCrossDocumentGraftMap і вихідний TPdfAcroFormGraftReport. Він повертає Boolean, а не кидає виняток, і при невдачі звіт несе причину в ErrorMessage. Мапування сторінок з одиниці з обох боків і не виводиться: кожна вихідна сторінка, що несе віджет, який ви збираєтеся перенести, мусить у ньому фігурувати. Передати nil замість graft map — легітимно: функція тоді створює і звільняє приватну на час виклику, а 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;

Як graft map уникає подвійного клонування спільного шрифту?

TPdfCrossDocumentGraftMap тримає таблицю посилань джерело-призначення, чиї ключі несуть і номер обʼєкта, і покоління, і рекурсивний клонер звіряється з нею, перш ніж спуститися. Порядок операцій — те, що робить цикли безпечними: клонер виділяє номер обʼєкта призначення і реєструє мапування спершу, а потім обходить дочірні посилання вихідного обʼєкта. Батько, що дістався дитини, яка вказує назад на свого батька, знаходить батька вже зареєстрованим і повертає наявне посилання призначення замість рекурсії. Той самий пошук — те, що змушує шрифт, потік вигляду або дію, спільні для шести віджетів, клонуватися один раз і посилатися шість разів. Мапа привʼязана до вихідного документа SHA-256 хешем вихідних байтів, виставленим як SourceIdentity. Якщо ви подасте GraftPdfAcroForm мапу, чия ідентичність не збігається з переданим джерелом, вона відмовить виклик, замість перевикористовувати посилання, які ніколи не були валідні для цього файла. Мапування сторінок засіваються в ту саму мапу до початку клонування — рівно тому /P віджета опиняється вказуючи на сторінку призначення: вихідний обʼєкт сторінки вже розвʼязується в замаплений обʼєкт сторінки призначення, тож звичайний прохід переписування посилань обробляє це без особливого випадку

Cross-document graft map 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 трактує подану викликачем мапу транзакційно: невдалий graft відкидає записи, які додав той виклик, і тримає кожне мапування, що існувало заздалегідь, тож одна відмова ніколи не лишає по собі кеш посилань на обʼєкти, які ніколи не були записані. Тримайте одну мапу на документ призначення, щоправда — сторона призначення кожного запису є номером обʼєкта в тому конкретному файлі, і в іншому вона не означає нічого

Колізії назв полів: відхилити чи перейменувати

Повністю кваліфіковані назви полів мусять лишатися унікальними всередині форми, і PDFiumPas не буде вгадувати, що ви мали на увазі, коли вони стикаються. TPdfAcroFormCollisionPolicy пропонує рівно дві відповіді. За pagcpReject, типовим, перше вихідне поле, чия назва вже існує в призначенні, перериває весь graft з помилкою і лишає вихідний потік порожнім. За pagcpRename, поле, що зіткнулося, перейменовується додаванням префікса RenamePrefix, і graft триває, а 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, який людина написала під стару назву, і будь-який нижчий за течією споживач, що ключується на назву поля, муситимуть дізнатися про префікс. Якщо два документа справді описують те саме поле, чесне виправлення зазвичай — звірити назви вище за течією, а не в момент graft. Коли graft приземлився, обійти злиту форму, щоб підтвердити, що ви справді отримали, — природний наступний крок, і навігація полями форми в PDFiumPas покриває той обхід

Де graft свідомо відмовляє закрито

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

  • Вихідна форма несе запис /XFA — пакети XFA є паралельною моделлю форм і не зводяться до словників полів AcroForm
  • Віджет живе на вихідній сторінці, якої немає в мапуванні сторінок, — інакше поле було б мовчки втрачено або прикріплено до неправильної сторінки
  • Мапування сторінок поза діапазоном, або два мапування перевикористовують ту саму вихідну чи цільову сторінку
  • Обидві форми визначають типовий ресурсний словник /DR, бо злиття двох просторів ресурсних назв ризикувало б перенаправити наявну назву на інший шрифт
  • Граф обʼєктів перевищує MaxObjects, або рекурсія перевищує MaxDepth
  • Призначення містить підпис, а AllowSignedDestinationFalse
  • Подана graft map належить іншому вихідному документу, або вихідне посилання висне

Шлях запису так само консервативний. PDFiumPas випромінює результат як розріджену інкрементну ревізію, додану до призначення, потім рематеріалізує записаний вихід і перечитує його форму: якщо кількість полів результату не дорівнює початковій кількості полів призначення плюс вихідній, весь graft відхилено, а вихід очищено. Ви ніколи не отримаєте частково перенесеного файла. Ціна тієї політики реальна — колізія /DR чи підписане призначення зупиняють вас начисто, і розвʼязати це треба самому, замість прийняти злите наближення, — але альтернатива — форма, що відкривається гаразд і обчислює неправильно

Як PDFiumPas GraftPdfAcroForm відмовляє закрито в Delphi: записана ревізія перечитується і перевіряється на кількість полів, будь-яка неоднозначна умова на кшталт XFA або незамапленої сторінки відмовляє виклик, а відмова відкидає лише записи мапи, які додав той виклик
Перевірений шлях запису і транзакційна мапа — причина, чому відхилений graft ніколи не лишає по собі частково злитого файла

Коли перенесення — неправильний інструмент

Перенесення рухає структуру, тож застосовуйте його, коли структури вам і бракує. Якщо обидва документи вже несуть той самий набір полів, а вам треба лише перемістити значення та анотації між ними, шлях експорту та імпорту в статті про XFDF-дані форм легший, стандартний і оборотний. Тягніться до GraftPdfAcroForm, коли призначення взагалі не має полів або має інший набір, і вам потрібно, щоб віджети, потоки вигляду, дії та порядок обчислень перейшли неушкодженими. Остання практична примітка про ідентичність: оскільки graft map ключує за номером обʼєкта плюс поколінням і привʼязана до SHA-256 вихідних байтів, повторне збереження чи оптимізація джерела між прогонами дає іншу ідентичність і мапу, що більше не застосовується. Знімайте знімок джерела, з якого переносите, і тримайте його сталим для партії; ставтеся до нього як до вхідного артефакту, а не як до того, що нічний жоб вільний переписати

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