技術記事

PDFium でアトミックに PDF フォームへ一括投入する(Delphi)

PDFium Component for Delphi は TPdf.SetFormFieldValues で AcroForm をアトミックに埋めます。ページとウィジェットで宛先を指定する 1 から 256 件の型付きリクエストを渡すと、コンポーネントはすべての値を 1 回の文書置換で適用するか、何も適用しないかのどちらかです。リクエストの 1 つが無効なとき、TPdfFormValuesWriteReport.FailedRequest がバッチを沈めたリクエストを名指します

この保証が最も効くのは、誰も見ていないジョブです。夜間インポートが CRM の行をクレームフォームへ押し込んだり、印刷サービスが保険データを先刷りテンプレートへ重ねたりします。フィールドごとに実行すると、最初の悪い値、つまり有効な radio export でないコードやフィールドの MaxLen より長い文字列が、古いデータ半分・新しいデータ半分の PDF を手元に残します。クラッシュはしていないので、誰にも警報は鳴りません。バッチ API があるのは、失敗が半書き込みの文書ではなく、きれいな拒否になるためです

フィールドごとのフォーム投入は、なぜデータインポートにとって危険なのか

フィールドごとの投入が危険なのは、すべての書き込みが単独で確定するからです。ループの途中のエラーは、ソースのレコードとも元のテンプレートとも一致しない文書を残します。定番パターンは FormField[Index] 代入のループで、各代入が PDFium のネイティブなフォーム投入環境を 1 ウィジェットずつ通ります。値 1 から 17 は着地し、値 18 が投げると、呼び出し元は元に戻すために、どの値が入ったのかを正確に知らなければならなくなります

その取り消しコードを書く呼び出し元はほとんどなく、書く側もたいてい微妙に間違えます。共有フィールドは現れるすべてのページで、すでに変わり済みです。チェックボックスの状態も既に反転済みです。保存が finally ブロックで走ると、半分だけ埋まったファイルが完成品の顔をしてディスクへ落ちます。重ね刷りジョブではファイルがないより悪く、印刷され、誰かが署名します

もう 1 つの静かな問題はフォーカスです。定番のセッタは PDFium のインタラクティブエディタを駆動します。既存のフォーカスを殺し、対象ウィジェットへフォーカスし、全テキストを選択し、置き換え、再びフォーカスを殺します。ヘッドレスジョブなら構いませんが、ビューアでは、ユーザーが別フィールドで開いていた編集セッションまで終わります。PDFium Component 3.231.0 で届いた型付き書き込み経路はネイティブのフィールドフォーカスを一切通らず、3.233.0 で単一フィールドからページ横断バッチへ広がりました

SetFormFieldValues はどう 1 回の置換でバッチを適用するのか

TPdf.SetFormFieldValues はすべてのリクエストを、文書の 1 つの捕捉済みスナップショットに対してステージングし、全フィールド辞書を 1 パスで書き、完成した結果を単一の置換で差し込みます。元の文書は最後の最後まで生きており、その前の失敗はすべて文書を無傷のまま残します

Delphi の PDFium Component における 5 段階のアトミックバッチフォーム投入シーケンス。プリフライトから検証と差し替えまで、結果は全か無か
すべてのリクエストは 1 つのスナップショットに対してステージングされ、生きている文書に触れるのは最後の検証済み差し替えだけです

この順序は、保証とコストの両方を説明します

  1. プリフライト検査が最初に走ります。文書がアクティブであること、リクエスト数が 1..256 であること、ページオブジェクトや編集ジャーナルのトランザクションが開いていないこと、ネイティブのフィールドがフォーカスを保持していないこと、文書がフォーム入力か変更の権限を認めること
  2. 各リクエストはページ範囲と入力予算について検証され、FailedRequest が検査中のインデックスを追跡します
  3. コンポーネントは編集スナップショットを捕捉し、その複製内で AcroForm を正規化し、すべてのリクエストを順に適用します。/V、選択用の /I、そしてフィールドを共有する全ウィジェットへの /AS を書きます
  4. 変更は ISO 32000-1 §7.5.6 の意味での 1 つのスパース増分更新としてスナップショットへ追記されます。無関係なオブジェクトはバイト単位でそのまま運ばれます
  5. 独立した検証インスタンスがまず候補バイトをロードし、きれいに開けた場合にのみ、コンポーネントは生きている文書をアンロードして新しいものをロードします。そのロードが失敗すれば元のスナップショットへフォールバックします

内部の置換ステップは、コンポーネントが編集ジャーナルのスナップショットに使うのと同じ機構です。そのため取り消しとクラッシュ復旧のジャーナルとバッチ書き込みは重なれません。先にジャーナルトランザクションを完了か取り消ししてください。文書の差し替えの間は自動ライフサイクルアクションが抑制されるため、文書オープンスクリプトが置換の最中にフィールドを書き換えたりフォーカスを奪ったりすることはありません

Delphi で型付きリクエストバッチを組み立てる

バッチは TPdfFormValueRequest レコードの動的配列で、各レコードは 1 基点の Page、0 基点のページローカルな FieldIndex、型付きの TPdfFormValue を持ちます。値レコードは fvkText、fvkChoice、fvkCheckBox、fvkRadioButton のいずれかの Kind を運び、その種類に応じて Text、ChoiceIndices、Checked の各メンバーを使います

インポートデータはたいていフィールド名を鍵に届くため、最初の仕事は名前をページとウィジェットの宛先へ翻訳することです。FieldIndex はページ上のウィジェット注釈を、FormFieldCount と FormFieldInfo と同じ順序で数えるため、単純な走査で済みます。ヘルパーは名前に対して最初に見つかったウィジェットを受け取ります。共有フィールドではこれが効いてきます。次の節で説明します

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // 最初のウィジェットのみ:フィールドごとに 1 リクエスト
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

AddRequest は未知の名前をスキップせずに投げる点に注意してください。バッチが検証するのは宛先であって、あなたのマッピングではありません。テンプレートにないフィールドはここで失敗する必要があります。さもないと決して失敗しません。宛先が解決できれば、値を組み立ててコミットするのは短い作業です

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // 選択するボタンの export 値
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // フィールドの Opt 配列への 0 基点インデックス
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

型付きの種類は、何かが書かれる前にフィールド型に対して検査されます。チェックボックスへ向けたテキスト値、プレーンなチェックボックスへ向けた radio 値、プッシュボタンへの書き込みは、いずれも拒否されます。選択のインデックスは有効、一意、昇順でなければならず、複数のインデックスには MultiSelect フラグが要り、選択フィールドでの自由テキストは編集可能なコンボボックスだけが受け付けます。フィールドの MaxLen を超えるテキスト値は切り詰めではなく拒否され、読み取り専用フィールドは丸ごと拒否します。NoToggleToOff フラグを持つ radio グループでは、空の export 値(グループをクリアする意味)も拒否されます

共有フィールドへの 2 度の書き込みが、なぜバッチ全体を落とすのか

TPdf.SetFormFieldValues は、同じターミナルフィールドへ解決する 2 つのリクエストを拒否します。どちらも単一の値を書くことになり、コンポーネントは 1 つのリクエストが別を黙って上書きするのを許さないからです。検査はウィジェットではなくフィールドの所有者に対して走るため、別々のページに置かれた 1 つのフィールドの 2 つの別ウィジェットも、重複として数えられます

理由は ISO 32000-1 §12.7.3.1 のフィールドモデルです。ターミナルフィールドが値を所有し、そのウィジェット注釈はその値が表示される場所にすぎません。すべてのページのヘッダに現れる PolicyNumber という名のフィールドは、複数ウィジェットを持つ 1 つのフィールドであり、その全ウィジェットが同じ /V を表示します。radio グループも同じ仕組みです。各ボタンはウィジェットで、グループがフィールドです。PolicyNumber をページ 1 のウィジェット経由で、さらにページ 9 のウィジェット経由で書くのは、1 つのフィールドへの 2 度の書き込みであり、勝者を選ぶどんな規則も、マッピング内のバグを隠します

そこでバッチはこれをエラーとして扱い、2 番目のリクエストを FailedRequest に名指します。実務での修正は上流にあります。各フィールド名を、FindFieldAddress のようにちょうど 1 つの宛先へ解決し、他のウィジェットへの値の伝播はコンポーネントに任せるのです。コンポーネントはそれを書き込みの一部として行い、共有チェックボックスや radio グループの全ウィジェットへ /AS を設定して、見た目の状態をフィールド値と一致させます。この分離はチェックボックスのフィールド値とウィジェット状態に関する記事で扱ったものと同じです

1 つのターミナルフィールドが値を所有し、2 ページ上のウィジェットがそれを表示する。重複リクエストは Delphi の PDFium Component でバッチを落とす
ターミナルフィールドごとに 1 リクエスト。コンポーネントは /V を 1 度だけ書き、それを共有する全ウィジェットへ /AS を設定します

失敗レポートを読む

失敗したバッチは False を返し、投げず、元の文書とすべてのフィールド値をそのままの状態で残します。TPdfFormValuesWriteReport は続いて、Succeeded = False、UpdatedFields と UpdatedWidgets はゼロ、FailedRequest に問題のリクエストの 0 基点インデックス、そして ErrorMessage に人間可読な理由を報告します

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

FailedRequest の -1 は、拒否が個々のリクエストではなくトランザクション側に属することを意味します。ネイティブのフォーカスがアクティブだった、文書が署名されている、フォームが XFA である、権限検査に落ちた、といった具合です。非負の値は 1 つのエントリを指します。集計予算の超過では、バッチを上限へ押し越えたリクエストを指します。必ずしも間違っているリクエストではありません。Requests の隣にソース列名の平行配列を持っておけば、インポートログの中でインデックス 14 をそのまま「2087 行目、ShipDate 列」へ翻訳できます

成功時には、UpdatedFields がリクエスト数と等しく、UpdatedWidgets は触れた全ウィジェット、共有ウィジェット込みを数えます。テンプレートに対する手軽な健全性検査です

当たる前提条件と上限

ビューアアプリケーションで最も多い拒否はネイティブフォーカスです。ユーザーがフィールドに入力していると、SetFormFieldValues はスナップショットの捕捉すら行う前に拒み、待機中のエディタバッファは無傷で生き残ります。コンポーネントは背後でフォーカスを落としません。それは、ユーザーが書き終えていないテキストを確定させるか失わせるかのどちらかになるからです。ClearFormFieldFocus を意図的に呼んでください。フィールド外をクリックしたのと同じ形でエディタセッションを終え、その後にリトライします

予算は固定され文書化されているため、インポートをそれに対して見積もれます

  • バッチごとに 1 から 256 リクエスト。大きなレコードは複数バッチへ分割し、アトミシティはバッチごとに成立すると受け入れる
  • リクエストごとにテキスト最大 262,144 UTF-16 単位、選択インデックス最大 4,096
  • バッチ全体でテキスト最大 1,048,576 UTF-16 単位、選択インデックス最大 16,384
  • スナップショットパーサの通常の文書予算も適用されます。64 MiB のソースと 65,536 オブジェクトを含みます

成功した置換は、呼び出し前に得たネイティブハンドル、検索、選択も無効化します。下にある文書が新しいものになるからです。ページとビューのアクティベーションと論理フィールドカーソルは保持されます。それ以外にキャッシュしたもの、注釈やページオブジェクトのハンドルなどは、取り直しが必要です

XFDF インポートとの違いは何か

TPdf.ImportXFDF と TPdf.SetFormFieldValues はどちらもデータをフィールドへ入れますが、XFDF インポートは寛容なマージであり、バッチは厳密なトランザクションです。XFDF インポートは値を名前でフィールドへマッチさせ、見つからない名前をスキップし、重複を無視し、適用できたエントリ数を返します。レビューアのコメントのマージには、部分結果でも有用なので、まさに適切です

バッチはすべての点で正反対です。ウィジェットを明示的に宛先指定し、未知や誤型の宛先を拒否し、重複をエラーとして扱い、部分的な結果を持ちません。データが別ツールからの交換ファイルなら XFDF、自分のコードがマッピングを所有し、半適用のレコードが欠陥になるなら SetFormFieldValues を使います

Delphi の PDFium Component で XFDF インポートとアトミックな SetFormFieldValues バッチを比較する判断マトリクス。宛先指定、未知と重複の宛先、結果、最適な用途
XFDF は交換ファイルを寛容にマージし、バッチは未知の名前、重複、部分結果を拒みます

バッチ書き込みがカバーしないもの

バッチは境界付きの AcroForm プロファイルであり、その外では推測ではなく「否」と言います。XFA フォームは拒否され、専用のエディタを要します。署名済み文書は拒否されます。既存の署名の下でフィールド辞書を書き直すのは署名ワークフローであって、投入操作ではないからです。暗号化されたスナップショットもプロファイルの外です。奇形や曖昧なボタン export 配列は拒否され、複数のボタンに一致する radio export 値は、グループが RadiosInUnison を設定しない限り拒まれます。トランザクションは書き込みの前に直接フィールド辞書も正規化します。値が元に戻って見えるフィールドの背後にあるケースです

見た目には 1 文の注意が要ります。変更されたテキストと選択のウィジェットに対して、バッチは古い値を表示する見た目を送り出す代わりに、古くなった /AP を落とし、PDFium がフォーム内の無関係な全ウィジェットを再構築しないよう NeedAppearances を切ります。PDFium は描画時に欠けた見た目を再生成します。投入後の PDF が、見た目を合成しない厳密な印刷パイプラインへ行くなら、まず PDFium 経由でレンダリングかフラット化を行い、その経路を自前のテンプレートでテストしてください

SetFormFieldValues と型付きの値レコードは、PDFium Component for Delphi and Lazarus に同梱されています。製品ページは、レンダリング、編集、ビューアコントロールと並んで、フォーム投入と AcroForm API の全一覧を載せています