技術記事

PDF フォームの値が元に戻る理由:Delphi での直接フィールド

PDF フォームのフィールド値が「元に戻る」のは、フィールドが直接辞書として保存されているときです。PDFium はページの /Annots 配列内の複製を間接オブジェクトへ昇格する一方、/Fields は 2 つ目の直接複製を保持し続けます。PDFium Component for Delphi は、型付きのフィールド書き込みを、書く前に両参照を 1 つの間接所有者へ統一することで処理します

症状はお化け屋敷のように読めます。書き込みは True を返し、その直後の読み取りは旧値を手渡します。保存して開き直しても、旧値はまだそこにいます。何も投げておらず、PDF は手持ちのどのビューアでも完全に普通に見えます

症状:確定しない成功した書き込み

失敗パターンは狭いのに、極めて再現性があります。フィールド辞書が番号付きの間接オブジェクトではなくインライン(直接オブジェクト)で書かれたフォームでのみ姿を現します。ほとんどのオーサリングツールは間接フィールドを出力するため、問題は数か月隠れ、それから特定の 1 つのジェネレータのファイルに現れます。たいていは /AcroForm 全体をインラインで書く手作りエクスポータです

2 つの観察が揃うと原因を指します。書き込みが成功を報告することと、フォーム層を通じたフィールドのネイティブ読み取りが前の値を返すことです。両方見たら、文字列エンコーディングを疑うのをやめて、オブジェクトの所有権を見てください

成功した書き込みの後に PDF フォームの値が元に戻るのはなぜか

PDF フォームのフィールド値が元に戻るのは、2 つの別々の辞書がどちらも同じフィールドであると主張し、書き込みが、フィールドツリーが参照しない複製を更新したからです。フィールドの値は /V エントリに住み、ISO 32000-1 §12.7.3.1 はそのエントリがどこで権威を持つかを定義します。/AcroForm /Fields から辿れるフィールド辞書上で、/Kids を下ってターミナルフィールドへ至り、継承可能なエントリは /Parent から解決する、という形です

同じ条項が、すべての厄介の種になる便宜を認めています。フィールドがちょうど 1 つのウィジェット注釈を持つとき、フィールド辞書とウィジェット注釈辞書は 1 つの辞書へ併合できます。整形式のファイルでは、併合された辞書は 1 つの間接オブジェクトで、2 つの場所から参照されます。/Fields(または親の /Kids)からと、ページの /Annots 配列からです。2 つの参照、1 つのオブジェクト、1 つの /V

1 つの辞書の 2 つの複製

ISO 32000-1 §7.3.10 は、共有可能な同一性を持つのは間接オブジェクトだけだと明示します。直接辞書にはオブジェクト番号がないため、ジェネレータが併合済みフィールド・ウィジェット辞書を /Fields と /Annots の両方にインラインで書くと、ファイルは 2 度参照される 1 つのオブジェクトを含みません。同じ鍵と値をたまたま持つ、見た目の等しい 2 つの辞書を含むのです

PDFium はこれを許容し、そのことで分割を恒久化します。注釈へのハンドルが必要になると、PDFium はページの /Annots 配列内の直接ウィジェット辞書を間接オブジェクトへ昇格できます。しかし等価な直接複製は、/AcroForm /Fields の内部(または親の /Kids の内部)に、そのまま残ります。その瞬間から、文書は 1 つの論理フィールドに 2 つの所有者を抱えます。ページ注釈側の /V を編集するライタは、辞書レベルでは本物の成功を得ます。フォーム層とそれ以降のすべてのリーダーが真実の源として扱うフィールドツリーは、その変更を一度も見ません

旧値が保存と開き直しを生き延びるのはこのためです。新しい値は、フィールド階層が所有しないオブジェクトへ、忠実にシリアライズされたのです

Delphi の PDFium Component。2 つの直接辞書が 1 つのフィールドを主張し、書き込みは昇格された注釈に当たり、フィールドツリーは旧 /Fields 複製を依然として読む
書き込みはフィールド階層が所有しないオブジェクトへ忠実にシリアライズされたため、旧値は保存と開き直しを生き延びます

PDFium Component はどう単一のフィールド所有者を復元するのか

PDFium Component はすべての型付き書き込みの前に、直接のフィールド所有権を正規化します。直接の /AcroForm とフィールド辞書を間接オブジェクトへ昇格し、各直接ウィジェットをそのマッチするページ注釈へ結合し、/Fields、/Kids、/Parent、/Annots を書き直して、すべての参照が同じ間接オブジェクトを指すようにします。そのうえで /V を変更するため、フィールド値、選択されたウィジェット、保存された文書が 1 つの所有者を共有します

難物は同一性です。内容が同一の 2 つの直接辞書は、同じウィジェットである証拠になりません。フォームは外見の似た 2 つのウィジェットを正当に含め得ます。そこで正規化装置は推測を拒み、各直接ウィジェットに明示的で一意な /NM 注釈名(ISO 32000-1 §12.5.2)を要求します。書き込み経路が適用する規則は厳密です

  • 直接ウィジェットは最大 1024 UTF-16 単位の空でない /NM を要し、同じ名前を運んでよいページ注釈はちょうど 1 つです
  • 直接複製とページ注釈は、/Parent と PDFium が再生成する /AP を除いて同一にシリアライズされなければなりません。それ以外の差異は書き込みを拒否します
  • 親リンクは両方とも別々に検査されるため、ページ注釈の正しい /Parent が、元の直接複製上の矛盾したものを隠すことはできません
  • 欠けた /Parent は、一意なフィールドツリーの所有権が答えを曖昧さなくするときだけ補われ、等価な直接親複製は昇格された所有者へ再結合されます
  • 重複した完全修飾フィールド名、循環するか複数所有のフィールドツリー、署名フィールドは、何かが公開される前に拒否されます
  • 同一ページ上のリンクのような無関係な注釈と、無関係な /AcroForm エントリは、手を加えず保持されます

正規化装置は厳しい予算の下でも走ります。フィールドツリー訪問、ページ、配列メンバーの 4096、ネスト深さ 64、新しい間接オブジェクト 256、昇格辞書あたり 1 MiB、昇格バイトの合計 4 MiB、検査した辞書バイトの 128 MiB、完全なフィールド名あたり 4096 UTF-16 単位とフォーム全体で 262144。敵意のある、あるいは壊れたファイルは予算に当たってエラーメッセージで失敗し、フォーム入力をサービス拒否へ変えたりしません

操作全体はトランザクショナルです。TPdf.SetFormFieldValues はスナップショットを捕捉し、正規化されたオブジェクトと新しい値を含む増分更新を組み立て、その候補をロードし、候補が検証を通ってからのみ生きている文書を置き換えます。正規化がファイルを拒んだときや、バッチ内の後続リクエストが落ちたときは、元の文書、その値、論理フィールドカーソルは、そのままの状態で置かれます

Delphi の PDFium Component が直接 AcroForm のフィールド所有権を統一する流れ。スナップショット、NM の同一性検査、参照の統一、値の書き込み、検証済み公開、明示的な拒否経路
直接フィールド辞書は /V を変更する前に昇格され 1 つの間接所有者へ再結合され、曖昧な所有権はきれいに拒否されます

Delphi で直接フィールドの値を書いて検証する

安全なパターンは、書いてから、ネイティブのフォーム層を通じて読み返すことです。食い違いは、書き込みが True を返していても失敗として扱います。TPdf.SetFormFieldValue は現在ページ上のインデックスでフィールドを宛先指定し、型付きの TPdfFormValue を受け取り TPdfFormValueWriteReport を埋めます。TPdf.FormField[Index] は PDFium のフォーム層を通じて値を読み返します。ウィジェットを信用せず、フィールドツリーを解決するほうです

Delphi と PDFium Component における PDF フォームの書き込みと検証パターン。型付き SetFormFieldValue、FormField 経由の読み返し、保存と開き直しの検査
True の戻り値だけでは辞書が変わったことしか証明しません。フィールドツリーを通じて読み返し、保存と開き直しも試します
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue の宛先は現在ページ
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // 検証の相手は自分の変数ではなくフィールドツリー
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

所有権が解決できないファイル、たとえば /NM のない直接ウィジェットでは、SetFormFieldValue は False を返し、Report.UpdatedWidgets はゼロで、Report.ErrorMessage が問題を名指します。"Direct widgets require an explicit unique NM identity" のような形です。これが求める挙動です。黙って半分だけ書く代わりの、はっきりした拒否です。もう 1 つの前提条件を知っておく価値があります。型付き書き込みは注釈のみであり、ネイティブのフォーカスを取得しません。ネイティブのフィールドエディタがまだフォーカスを保持していると、トランザクション全体がスナップショットの捕捉の前に拒否されます。ループでリトライするのではなく、先に Pdf.ClearFormFieldFocus を意図的に呼んでください

複数フィールド、特にページ横断なら、バッチ形を使います。TPdf.SetFormFieldValues は 1 から 256 リクエストを受け付け、すべてを 1 つの候補へ適用し、全部か無かで公開します。TPdfFormValuesWriteReport.FailedRequest が、どのリクエストがバッチを壊したかを教えます

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2。Batch.UpdatedWidgets は同期された全ウィジェットを数える
end;

同じターミナルフィールドを宛先とする 2 つのリクエストは、最後の書き込み勝ちにする代わりに、重複所有者として拒否され、ソース文書は保存されます。チェックボックスと radio の値も同じ規則に従います。ボタンの /V 対 /AS の分離は、PDFium でのチェックボックスのフィールド値対ウィジェット状態で扱っています

値が保存と開き直しを生き延びることの証明

同じセッション内の読み返しは、生きている文書の整合性を証明します。保存と開き直しはファイルの整合性を証明します。検査は自前の統合テストに置いておける程度に安上がりです

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

さらに 2 つのバイトレベルの罠:Trim とトレーラの /ID

直接フィールドの正規化は、生の辞書バイトの再読み取りを伴うため、PDF 構文に触れる Delphi コードならどれでも関係する、小さな罠が 2 つ顔を出します

1 つ目は、AnsiString に保持した生の PDF バイトへの SysUtils.Trim です。選択のオプションのようなリテラル文字列は、FE FF のバイト順マークと埋め込み NUL バイトを持つ UTF-16BE テキストを正当に含め得ます。Unicode の Delphi では、SysUtils.Trim は string を受け取るため、呼び出しは AnsiString をアクティブな ANSI コードページで UTF-16 へ黙って変換し、結果をまた戻します。そのコードページ次第で、往復は上位バイトを書き換え、BOM を壊し、続く区切りまで乱しかねません。安全なやり方は、字句解析をバイトのままに保つことです。PDF の空白を位置でスキップし(ISO 32000-1 §7.2.2 が NUL、タブ、LF、FF、CR、空白を列挙します)、Copy でスライスし、値が PDF テキスト文字列だと分かったときにだけデコードします

// 生の PDF バイトでは危険:AnsiString -> string -> AnsiString(W1057/W1058)
Token := Trim(Token);

// バイト安全:PDF 空白を位置でスキップしてからスライス
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

2 つ目の罠は増分更新自体に座っています。ISO 32000-1 §7.5.5(表 15)と §14.4 は、トレーラの /ID を 2 つのバイト文字列の配列であると要求します。スパース更新識別子を組み立てる内部ヘルパーは、/ID 鍵も配列括弧もない、2 つの文字列の中身だけを返すため、トレーラはそれを明示的に /ID [ ... ] とくるまなければなりません。PDFium の寛容なローダーも、後の完全再保存も、欠けたくるみを隠します。信頼できる検査は、候補ファイルと保存ファイルを独立した厳密パーサで解析し、2 つの 16 バイト文字列を探すことです。自分で増分更新を書くなら同じ罠が当たります。暗号化 PDF の増分更新の記事がトレーラチェーンをより深く扱います

自分の Delphi フォームコードで何をすべきか

  • 書き込みの後は必ず FormField[Index] で値を読み返し、テストではさらに保存し、開き直して、もう一度読む
  • False と ErrorMessage は情報として扱います。NM を名指す拒否、親の矛盾、重複名は、ファイルの所有権が本当に曖昧であることを意味します
  • PDF を生成するなら、フィールドとウィジェットを間接オブジェクトとして出力し、全ウィジェットへ一意な /NM を与えます。曖昧さが源で取り除かれます
  • 複数フィールドやページ横断の投入には SetFormFieldValues を使い、遅い失敗がフォームの半分だけ更新された状態を残せないようにする
  • AnsiString に保持した生の PDF バイトへ、Trim や他の string 系 RTL ヘルパーを決して走らせない

境界についても明確にしましょう。署名済み文書、XFA フォーム、暗号化スナップショット、ウィジェットの所有権が一意に解決できないファイルは、宣言されたプロファイルの外に留まり、押し通される代わりに拒否されます。ワークフローがフィールドを文書間で動かすなら、所有権の問いはさらに難しくなります。AcroForm フィールドを PDF 文書間で移植するがそのケースを扱います

PDFium Component は、レンダリング、テキスト抽出、文書編集と並んで、この型付きでトランザクショナルなフォーム書き込みを Delphi、C++Builder、Lazarus アプリケーションへ届けます。バージョン履歴、ライセンス、ダウンロードはPDFium Component for Delphi 製品ページにあります