技術記事

DelphiのPDFiumPasでPDF間へAcroFormフィールドを移植

昨年のテンプレートから今年のレイアウトへフォームフィールドのブロックを移すのは、FDFやXFDFの往復が物足りなくなる場所です。値は届きますが、アピアランスストリーム、計算アクション、既定リソースは届きません。PDFiumPasはこのケースにGraftPdfAcroFormで答えます。1つのPDFからフィールドのオブジェクトグラフ全体をクローンし、別の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の下の追加アクション辞書はさらに別のオブジェクトを指します。別々のページの2つのウィジェットが1つのフォントと1つのアピアランスXObjectを共有するのは日常です。だから正しいグラフトはこのグラフを歩き、到達可能な各オブジェクトをちょうど1回クローンし、各ウィジェットの/Pをマップ先の宛先ページへ向け、クローンしたウィジェットをそのページの/Annots配列へ追加しなければなりません。さもなければフィールドはフォームには存在するのにページには見えません。フィールド、そのウィジェット、それを表示するページアノテーションの違いを追ったことがあるなら、ウィジェットインデックスとアノテーションインデックスの記事がまさにその分離を扱っています

PDFiumPasがDelphiでグラフトする1つのPDFフォームフィールドの背後のオブジェクトグラフ。フォーム辞書、フィールド、ウィジェットアノテーション、宛先ページのアノテーション配列、両ウィジェットが共有するアピアランスストリームとフォント、循環を閉じる親への逆参照
フィールドは共有された循環サブグラフです。だから/Fields配列を文書間でコピーするとすべての参照がダングリングします

GraftPdfAcroFormは何を要求するか

3つの別個のストリームと明示的なページマッピングを要求します。GraftPdfAcroFormSourceDestinationOutputを別々のTStreamインスタンスとして、TPdfGraftPageMappings配列、TPdfAcroFormGraftOptionsレコード、任意のTPdfCrossDocumentGraftMap、outのTPdfAcroFormGraftReportを受け取ります。戻り値は例外を投げる代わりのBooleanで、失敗時はレポートがErrorMessageに理由を運びます。ページマッピングは両側で1始まりであり、推論されません。グラフトするつもりのウィジェットを運ぶすべてのソースページが含まれなければなりません。グラフトマップにnilを渡すのは正当です。その場合関数は呼び出しの間だけプライベートなものを作って解放します。TPdfAcroFormGraftOptions.DefaultCollisionPolicypagcpRejectに、RenamePrefixImported_に、MaxObjectsを100000に、MaxDepthを128に、AllowSignedDestinationFalseに設定してくれます。最後の3つは予算であり、これから歩こうとするオブジェクトグラフがあなたの書いていないファイルから来たことの帰結として存在します

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は、キーがオブジェクト番号とジェネレーションの両方を運ぶソースから宛先への参照テーブルを保持し、再帰クローナは降下する前にそれを参照します。操作の順序こそ循環を安全にするものです。クローナは宛先オブジェクト番号を割り当て、マッピングを先に登録してから、ソースオブジェクトの子参照を歩きます。子が親へ逆指す親に到達すると、親はすでに登録済みなので再帰する代わりに既存の宛先参照を返します。同じ検索が、6つのウィジェットに共有されるフォント、アピアランスストリーム、アクションを1回クローンして6回参照させます。マップはソースバイトのSHA-256ハッシュによってソース文書に結合され、SourceIdentityとして露出します。GraftPdfAcroFormに、渡したソースと一致しない識別子のマップを渡すと、このファイルには一度も有効でなかった参照の再利用ではなく、呼び出しを拒否します。ページマッピングはクローン開始前に同じマップへ種まきされます。ウィジェットの/Pが宛先ページを指して終わるのはまさにこの仕組みです。ソースページオブジェクトがマップ先の宛先ページオブジェクトへすでに解決するので、通常の参照書き換えパスが特別扱いなしで処理します

DelphiにおけるPDFiumPasのクロス文書グラフトマップ。各ソース参照をオブジェクト番号とジェネレーションでキー付けし、降下前に宛先マッピングを登録して親への逆参照を終端させ、既存エントリを返すことで共有フォントが1回だけクローンされる
子を歩く前にマッピングを登録することが、循環グラフを安全にし、共有オブジェクトをちょうど1回クローンさせます
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は呼び出し側提供のマップをトランザクション的に扱います。失敗したグラフトはその呼び出しが追加したエントリを破棄し、事前に存在したすべてのマッピングを保持します。1つの拒否が、一度も書かれなかったオブジェクトへの参照のキャッシュを残すことはありません。ただし宛先文書ごとに1つのマップを保ってください。各エントリの宛先側はその特定のファイル内のオブジェクト番号であり、別のファイルでは何の意味もありません

フィールド名の衝突。拒否か名前変更か

完全修飾フィールド名はフォーム内で一意に保たれなければならず、衝突したときに何を意図したかをPDFiumPasは推測しません。TPdfAcroFormCollisionPolicyが提供する答えは正確に2つです。デフォルトの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の計算エントリがあれば、フィールド名をキーにする下流消費者がいれば、前置詞を知る必要があります。2つの文書が本当に同じフィールドを記述しているなら、正直な修正は通常、グラフト時点ではなく上流で名前を突き合わせることです。グラフトが着地したら、実際に何を得たか確認するためにマージされたフォームを歩くのが自然な次の一手です。PDFiumPasでのフォームフィールドナビゲーションがその走査を扱っています

グラフトが意図的にフェイルクローズする場所

曖昧な条件はすべてエラーであり、ベストエフォートの結果ではありません。本番で驚かされる前に理解する価値のある設計決定です。GraftPdfAcroFormは以下のいずれかに当たるとFalseを返し、出力ストリームをリセットし、理由を報告します

  • ソースフォームが/XFAエントリを運んでいる——XFAパケットは並行するフォームモデルであり、AcroFormフィールド辞書へは還元できない
  • ウィジェットがページマッピングにエントリのないソースページに存在する——さもなければフィールドを静かに落とすか間違ったページに付けることになる
  • ページマッピングが範囲外、または2つのマッピングが同じソースまたは宛先ページを再利用している
  • 両フォームが既定リソース辞書/DRを定義している——2つのリソース名前空間をマージすると既存の名前が別のフォントへ向き直る危険がある
  • オブジェクトグラフがMaxObjectsを超えるか、再帰がMaxDepthを超える
  • 宛先が署名を含み、AllowSignedDestinationFalseである
  • 提供されたグラフトマップが別のソース文書に属する、またはソース参照がダングリングしている

書き込みパスも同じく保守的です。PDFiumPasは結果を、宛先に追加される疎なインクリメンタルリビジョンとして出力し、その後書かれた出力を再実体化してフォームを再読み込みします。結果のフィールド数が宛先の元のフィールド数とソースの和と等しくなければ、グラフト全体が拒否され出力はクリアされます。部分的にグラフトされたファイルを手にすることは決してありません。この方針のコストは現実です。/DRの衝突や署名付き宛先は完全に止められ、マージされた近似を受け入れるのではなく自分で解決しなければなりません。しかし代替案は、普通に開くのに計算が間違っているフォームです

DelphiでPDFiumPasのGraftPdfAcroFormがフェイルクローズする方法。書かれたリビジョンは再読み込みされフィールド数が検証され、XFAや未マップページのような曖昧な条件は呼び出しを拒否し、拒否はその呼び出しが追加したマップエントリだけを破棄する
検証される書き込みパスとトランザクション的なマップがあるから、拒否されたグラフトが部分的にマージされたファイルを残すことは決してありません

グラフトが間違った道具であるとき

グラフトは構造を運ぶので、足りないのが構造であるときに使ってください。両文書がすでに同じフィールドセットを持ち、値とアノテーションだけを移せばよいなら、XFDFフォームデータの記事のエクスポートとインポートのパスがより軽く、標準的で、可逆です。GraftPdfAcroFormに手を伸ばすのは、宛先にフィールドがまったくないとき、または別のセットがあり、ウィジェット、アピアランスストリーム、アクション、計算順序を無傷で渡ってきてほしいときです。識別子についての最後の実務メモ。グラフトマップはオブジェクト番号とジェネレーションをキーにし、ソースバイトのSHA-256に結合されるので、実行の間でソースを再保存または最適化すると、識別子が変わりマップはもう適用されません。グラフト元のソースをスナップショットしてバッチの間安定させてください。夜間ジョブが自由に書き換えてよいものではなく、入力アーティファクトとして扱います

GraftPdfAcroFormTPdfCrossDocumentGraftMapと周辺のストリームレベルPDFツールキットは、Delphi、C++Builder、Lazarus向けのPDFiumPas Delphi PDFium Componentに付属して出荷されます。製品ページにグラフトオプション、レポートフィールド、文書編集面の残り全体の完全なAPIリファレンスがあります