技術記事

Delphiで読み込み済みPDFのフォームフィールドに値を設定する

ほとんどすべての PDF ビューアのフィールドリストで、ドイツ語の請求フォームの street フィールドは Straße と読めます。その /T エントリの裏のバイト列は別の物語を語ります。<FEFF005300740072006100DF0065>、手前にバイトオーダーマークを付けた 16 進の UTF-16BE 文字列です。フィールド名をこの生のバイト列と照合するコードは、ユーザーがまっすぐクリックして入れることができるのに、フィールドが見つからないと報告します。同じフォームは、チェックボックスの on 状態を Yes の代わりに 2 で格納したり、国リストを [export display] ペアで格納したりするかもしれません。つまり、あなたが書く値とビューアが描く値が食い違う道が 3 つあるのです

3 つの変種はみな同じ場所から来ます。何年も前に、顧客が手にしていた何かのオーサリングツールで組み立てられたフォームであり、いまあなたの Delphi アプリケーションがデータベースから値を入れて、すべてのリーダーで同じように描画されるファイルとして返さなければなりません。フォームを作り直すことはできないので、値を入れるコードは、フォームが書かれたときの規則に従わなければなりません。ISO 32000-1 には 3 つのケースそれぞれに規則があります。テキスト文字列は §7.9.2.2、ボタンの外観状態は §12.7.4.2.3、選択フィールドの選択は §12.7.4.4 の Table 231 です。まだ存在しないフィールドの作成は別の仕事で、Delphi で読み込んだ PDF へ AcroForm フィールドを追加するで扱います

フィールドを探す: HotPDF は名前を照合する前に /T を復号する

THotPDF.SetFormFieldValue には 2 つのオーバーロードがあります。ゼロ始まりのフィールドインデックスを受け取るものと、完全修飾名を受け取るもので、どちらもコンポーネントが最初の使用時に AcroForm ツリーから組み立てる名前テーブルに対して解決します。テーブルが組み立てられる間、すべての部分名はデコーダーを通ります。16 進の /T 文字列はバイト列へ戻され、そのバイト列が FE FF で始まり偶数長なら、残りは UTF-16BE として読まれ、UTF-8 として再符号化されます。復号された各部分名は、§12.7.3.1 が記述するとおり、ピリオドで親と結合されます。Address という名前の親の下の City という子フィールドは Address.City として登録されるのです。復号されるのは検索鍵だけです。ファイルの中の /T オブジェクトは 16 進の表記を保ち、文書の保存は、値を入れただけのフィールドの同一性に手を触れません

鍵はテーブルに入る前に小文字化されるので、'address.city' が Address.City を見つけます。これは、フィールド名を大文字小文字を区別するものとして扱う標準を超えた挙動で、大文字小文字だけが違う 2 つのフィールドは検索で衝突することを意味します。誰かが手で打ち込んだ設定ファイルからフィールド名を読むコードにとって、打ち間違えられた大文字のほうが、大文字小文字だけが違う 2 フィールドという状況よりずっとよくある失敗です

HotPDF が非 ASCII の AcroForm 名を解決する方法: 16 進の /T 文字列がバイト列へ戻され、FE FF のバイトオーダーマークが UTF-16BE として復号され UTF-8 として再符号化され、修飾名が親と結合されて、Applicant.FullName と Straße という名前のフィールドがどちらも検索テーブルに落ち着く
変わるのはキャッシュ鍵だけです。フィールド辞書は 16 進符号化を保ち、検索は標準を超えた便宜として小文字へ正規化され、文書の保存は値を入れただけのフィールドの同一性を決して書き直しません

復号された名前は、AnsiString に載った UTF-8 バイト列です。そしてここで Delphi 自身の文字列変換が邪魔をします。'Applicant.Straße' のようなリテラルを AnsiString パラメータへ渡すと、システムの ANSI コードページを通して変換されるので、ß は UTF-8 のペア C3 9F ではなく 1 バイトの DF として到着し、検索は外れます。ASCII 名は影響を受けません。それ以外の名前では、フィールドを一度列挙し、各名前を UTF8ToString で復号し、インデックスオーバーロードで書き込んでください:

// FindFieldIndex は自分で足すヘルパー: 復号された UTF-8 の名前を
// Unicode 文字列と比較するので、リテラルと検索の間に
// ANSI コードページが割り込まない
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldCount - 1 do
    if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
      Exit(I);
  Result := -1;
end;

var
  Pdf: THotPDF;
  Street: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ASCII 名は名前指定オーバーロードへそのまま渡せる
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');

    Street := FindFieldIndex(Pdf, 'Applicant.Straße');
    // 値も AnsiString なので、リテラルの 'ß' はシステムコードページ次第。
    // FEFF 16 進(Hauptstraße 12)ならそれを避けられる
    if Street >= 0 then
      Pdf.SetFormFieldValue(Street,
        'FEFF00480061007500700074007300740072006100DF0065002000310032');

    // 同じ FEFF 付き UTF-16BE 16 進は名前指定オーバーロードでも
    // 働く(これは München)。16 進文字列として書かれる
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValue が書くもの、手順を追って

両オーバーロードは、フィールドが見つかった後、同じ手順を走らせます。/V を書き、選択フィールドの選択インデックスを再構築し、フィールドを変更済みに印付けし、ボタンの外観状態を切り替え、テキストか選択の外観を再生成し、フィールドをダーティーセットに記録します。最後の工程が、引数なしの RecalculateLoadedFormFieldsIncremental の糧になります。これは、変更されたフィールドを直接か、明細、小計、合計のようなチェーンを通して読む計算スクリプトだけを再実行します

新しい /V のオブジェクトタイプはフィールドに依存します。チェックボックスとラジオボタンに対して、SetFormFieldValue は常に名前オブジェクトを書きます。§12.7.4.2.3 は選択された状態を名前として格納し、PDF の名前はテキスト文字列の符号化を持たない素のバイト列だからです。それ以外のフィールドはすべて文字列オブジェクトを受け取り、渡された値はまず検査されます。FEFF で始まり、偶数長で、16 進数字だけを含む文字列は、§7.9.2.2 の UTF-16BE の形と見なされ、<FEFF...> として直列化されます。上の City 行が頼っているのはこれです。それ以外のものは、供給されたバイト列を正確にそのまま持つリテラル文字列として格納されるので、素のラテン文字のテキストは素のテキストとして入ります

値を変えた後にチェックボックスが古いチェックを保つのはなぜか

ボタンフィールドでは、値は何が描かれるかを決めません。チェックボックスのウィジェットは、/AP /N のどのストリームがいま表示されているかを名指す /AS エントリを運び、ビューアは /V からではなく /AS から描きます。/AS が Off を告げているまま /V を Yes に変えると、ファイルは自己矛盾します。フラット化ツールは、フォームデータがチェック済みを主張している間、 unchecked の箱をページに焼き付けるでしょう。だから SetFormFieldValue はボタンフィールドとその /Kids 配列のすべてのエントリを訪れ、各ウィジェットの /AP /N から on 状態の名前を読み、フィールド値と一致すれば /AS をその名前に、一致しなければ Off に設定します

/V だけが変わったとき HotPDF のチェックボックスが古いチェックを保つ理由: ビューアは /AS の外観状態から /AP /N へ描くため、SetFormFieldValue はフィールドとすべての子を訪れ、on 状態の名前を Off 以外の最初の鍵として読み、一致すれば /AS を書き直し、そうでなければ Off にする
ラジオグループは、各子を /Parent チェーンを通して親フィールドから継承された値と比較するので、グループを 1 つの export 値に設定すると、正確にそのウィジェットだけが on になり、すべての兄弟が off になります

実在のフォームはこの規則を 2 つの方向に曲げます。off の外観は標準では Off という名前ですが、オーサリングツールはそのストリームをまるごと省き、ビューアに何も描かせないことがよくあります。だから 1 エントリだけの normal appearance 辞書は完全に合法です。on 状態の名前は作者が選んだ何かです。Yes、On、2、フォーム自身の言語の単語。そこで on 状態は、/AP /N の中の Off でない最初の鍵として取られ、あなたの値との比較は大文字小文字を無視します。ラジオボタンは §12.7.4.2.4 の構成を足します。選択された値は親フィールドの /V に住み、各子ウィジェットは通常、自分の /V を持ちません。各子は、/Parent チェーンを通して継承された値と比較されるのです

// チェックボックス: 値は /AP /N の on 状態の鍵と等しくなければならない
// (ここでは 'Yes'。フォーム作者は '2' や 'On' を選んだかもしれない)
Pdf.SetFormFieldValue('Consent', 'Yes');

// ラジオグループ: /V は名前として親に載る。各子ウィジェットは
// /AS を自分の on 状態名か Off に設定される
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// チェックボックスを消す: どの on 状態にも一致しない値は /AS Off になる
Pdf.SetFormFieldValue('Newsletter', 'Off');

裏の面は、タイプミスが例外を生まないことです。末尾に空白の付いた 'yes ' は何にも一致せず、箱は Off に落ち着き、ファイルはそれでも完全に妥当です。値がデータベースの列から来るなら、呼び出しの前にトリムしてください

選択フィールド: /I を /V と歩調合わせに保つ

コンボボックスとリストボックスは選択を 2 つの場所に記録します。/V の隣で、Table 231 は /I を、/Opt へのゼロ始まりインデックスの配列と定義します。/I がオプション 0 を指しながら /V がオプション 3 を名指しているのを見つけたビューアは、間違った行をハイライトするかもしれません。SetFormFieldValue は選択フィールドへの呼び出しごとに /I を再構築します。フィールド自身の /I エントリを何より先に削除し、古い配列をその場で編集することは決してありません。間接配列は別のフィールドと共有され得て、それを書き換えるとそのフィールドの選択も変わってしまうからです。それから /Opt を /Parent チェーンを通して解決し(オプションは継承され得ます)、エントリを歩みます。素の文字列オプションは直接比較され、[export display] ペアはその export 要素の上で比較され、要素が 2 個未満のペアはスキップされます

HotPDF が選択フィールドを一貫させたままにする方法: SetFormFieldValue は手を付ける前にローカルの /I 配列を削除し、/Parent チェーンを通して /Opt を解決し、各オプションの export 半分を復号されたテキストとして比較し、最初の一致で 1 要素の /I を書き、インデックスを持たない編集可能コンボの値には何も書かない
素の文字列オプションは直接比較され、export display ペアはその export 要素の上で比較されます。一方、/Opt の外の値は正しくインデックスを残しません — 間違った行を指す古い /I は、ないより悪いからです

比較は復号されたテキストの上で走るので、PDFDocEncoding で格納されたオプションは、UTF-16BE 16 進で書かれた値と一致し、その逆もまた然りです。スカラーの値は最大 1 つのインデックスを生み、フィールドが以前に持っていたマルチセレクションは置き換えられます。何も一致しないときは、/I はまったく書かれません。編集可能なコンボボックスにとってそれが正しい帰結です。§12.7.4.4 はユーザーにリストの外の値を打たせており、そういう値には指せるインデックスがないのです

コンボボックスについて最もよく聞かれる不満の裏には、同じ規則があります。呼び出しは成功し、/V は新しいテキストを保持し、ビューアは選択されたものを何も示しません。たいてい、呼び出し側は [CA Canada] ペアから組み立てられたリストに、表示ラベル(Canada)を渡したのです。コンポーネントは、編集可能コンボを壊さない限り、それをエラーとして扱えません。だから検査はあなたのコードに属します。GetFormFieldOptionValue は各オプションの export 要素を返すので、ガードは短く済みます:

// SetChoiceByExport は自分で書くガードで、コンポーネントの一部ではない:
// /Opt のどの export 要素にも一致しない値を拒む
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
  ExportValue: AnsiString);
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
    if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
    begin
      // /Opt は [[US United States] [CA Canada] [MX Mexico]]:
      // 'CA' は export 要素に一致し、/I は [1] になる
      Pdf.SetFormFieldValue(FieldName, ExportValue);
      Exit;
    end;
  raise Exception.CreateFmt('%s is not an export value of %s',
    [string(ExportValue), string(FieldName)]);
end;

// 自由テキストの編集可能コンボ: 直接書く。/V が設定され、
// /I は削除され、インデックスは捏造されない
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

/NeedAppearances なしで新しい値は表示されるのか

テキストと選択のフィールドについては、はい。ビューアは格納された /AP ストリームを描き、/V を見ません。だから古い外観を持つ新しい値は、AcroForm 辞書が /NeedAppearances true(§12.7.3.3)を運び、ビューアがそれを尊重することを選ばない限り、画面にも印刷にも古いテキストを残します。フラット化ツールとサムネイル生成器はそのフラグを無視することがよくあります。SetFormFieldValue は、呼び出しの一部として、テキストと選択のフィールドの外観ストリームを再生成します。公開の EnsureLoadedFieldAppearanceStream と同じビルダーを使って。ストリームは、継承された /DA 文字列、/Q の配置、comb フィールドのための /MaxLen、そして値そのものから組み立てられます。/DA が名指すフォントは AcroForm の /DR リソースを通して解決されるので、Type0 フォントは Helvetica へフォールバックする代わりに自分のグリフを保ちます。ボタンはデザイナーが描いた状態の外観を保ち、それに対して動くのは /AS だけです

SetFormFieldValue は procedure であり、何かが描かれたかどうかを告げません。使える /Rect を持たないウィジェットはストリームを得ず、孤立した、あるいは矩形のないウィジェットを持つ壊れたフォームは実在します。描画結果が重要なときは、値を設定した後、自分で EnsureLoadedFieldAppearanceStream を呼んでください。少なくとも 1 つのウィジェットがストリームを受け取ったとき True を返します。GetFormField は THPDFLoadedFormField を手渡し、その Index プロパティが呼び出しが期待するものです。そのオブジェクトはあなたの所有物で、解放しなければなりません

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // False は、このフィールドのどのウィジェットにも描き込める
    // 矩形がなかったことを意味する。値は格納されるが、それを示すものはない
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

/V が変わったことの確認は、ユーザーが見るものについてほとんど何も証明しません。意味のあるテストは、前後でページを描画し、ウィジェット矩形の内側のピクセルが変わって外側が変わっていないことを確認します。私たちがその検査を保っているのは、外観のバイト列だけに裏切られたことがあるからです。あるストリームは正しいバイト列をすべて保持していて、ビューアが /NeedAppearances true を尊重して表示を /V から再構築した結果、そのどれも示しませんでした

値を読み戻す、そして次に来ること

GetFormFieldValue はフィールドの継承された /V を返します。FEFF 付き 16 進として格納された値は UTF-8 へ復号されて戻るので、上の City フィールドは München の UTF-8 バイト列として読み戻され、UTF8ToString がそれを Delphi の文字列に変えます。リテラル文字列は、格納されたときのバイト列とともに戻ります。ラウンドトリップの検査では、渡した表記ではなく、両側で復号されたテキストを比較してください

フォームと外部システムの間でフィールドデータを一括して動かすこと、フィールドごとに 1 呼び出しではなく、はDelphi における XFDF のインポートとエクスポートの主題です。完成したフォームを誰にも編集できないように封じることは、Delphi で AcroForm と XFA フィールドをフラット化するであり、まさにここで記述した /AS 状態と外観ストリームを静的なページコンテンツへ変えます。その時点で一貫していないものは永久に一貫しないままなので、入れて、確認して、からフラット化してください

SetFormFieldValue、EnsureLoadedFieldAppearanceStream、インクリメンタル再計算 API は、HotPDF Delphi Component の一部で、Delphi や C++Builder から読み込んだ文書の中でフィールドの名前変更、削除、フラット化を行う呼び出しと並んでいます