技術記事

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

HotPDF Delphi Componentは、読み込み済みPDF上の既存のAcroFormフィールドにTHotPDF.SetFormFieldValueで値を入れます。指定は0ベースのフィールドインデックスか、完全修飾のフィールド名で行います。新しい/Vエントリを書くのは簡単な部分です。この呼び出しを実世界のフォームで信頼できるものにしているのは、同じメソッドが、壊れるまで目に見えない3つの状態も一貫させてくれることです。非ASCIIの名前をそもそも見つけられるようにするためのフィールドのデコード済みの同一性、チェックボックスとラジオのウィジェット上の/AS外観状態、そして選択フィールドの/I選択インデックス配列です。見える外観ストリームは、EnsureLoadedFieldAppearanceStreamを通す別の明示的なステップです

シナリオはありふれたものです。顧客が自前のフォームを送ってきます。確定申告、保険金請求、何年も前に誰かがAcrobatで作った発注書。そしてDelphiアプリケーションがそれをデータベースから埋め、どこでも正しく開くファイルとして返さなければなりません。フォームがどう作られたかをこちらが制御することはできません。フィールド名はUTF-16で符号化されているかもしれませんし、チェックボックスのエクスポート値はYesではなく2かもしれませんし、コンボボックスは[export display]のオプション対を使っているかもしれません。これらの細部にはそれぞれISO 32000-1のルールがあり、そのルールはSetFormFieldValueがいまや代わりに処理してくれます。本記事は、それが何をし、なぜそうし、どこで止まるのかを扱います。まだ存在しないフィールドを作るという姉妹の問題については、Delphiで読み込み済みPDFにAcroFormフィールドを追加するを参照してください

SetFormFieldValueが非ASCII名のフィールドを見つけられないのはなぜか

v2.752.1より前、答えは符号化でした。フィールドはファイル内でUTF-16BEの16進名で存在しており、名前キャッシュがテキストではなく16進の綴りを格納していました。ISO 32000-1 §12.7.3.1は部分フィールド名/Tをテキスト文字列と定義し、§7.9.2.2はテキスト文字列が先頭にFE FFのバイト順マークを伴うUTF-16BEであってよいとしています。オーサリングツールはそのような名前を§7.3.4.3に従って16進文字列として直列化するのが普通なので、Straßeという名前のフィールドは<FEFF005300740072006100DF0065>として届きます。HotPDFの内部では、IsHexadecimalが立っているときは必ずTHPDFStringObject.Valueが生の16進テキストを保持します。これは元の辞書を可逆に往復させるにはまさに望ましいことであり、ルックアップのキーとしてはまさに望ましくないことです。HPDFLoadedFormTextNameがこの2つの関心事を分けます。リレーションシップキャッシュを組み立てるとき、すべての/Tの値がこれを通ります。文字列オブジェクトが16進であればHPDFHexToBytesがバイト列を復元し、そのバイト列がFE FFで始まり長さが偶数であれば、ペイロードはUTF-16BEとしてデコードされUTF-8に再符号化されます。その結果はピリオドで親の名前と連結され、§12.7.3.1が記述する完全修飾名になります。したがってAddressという親の下のCityという子はAddress.Cityとして登録されます。キャッシュのキーは小文字に正規化されるので、SetFormFieldValue('address.city', ...)も成功します。これは規格を超えた便利機能です。仕様は名前を大文字小文字を区別するものとして扱うからです。重要なのは、変わるのがキャッシュのキーだけだということです。フィールド辞書の/Tオブジェクトは16進の符号化を保つので、文書を保存しても、値を入れただけのフィールドの同一性が書き換わることはありません

HotPDFが非ASCIIのAcroForm名をどう解決するか。HPDFHexToBytesが16進の/T文字列の背後にあるUTF-16BEペイロードを復元し、FE FFのバイト順マークがデコードされてUTF-8に再符号化され、修飾名が親と連結されるので、Applicant.FullNameもStraßeという名前のフィールドもルックアップキャッシュに載ります
変わるのはキャッシュのキーだけです。フィールド辞書は16進の符号化を保ち、ルックアップは規格を超えた便利機能として小文字に正規化され、文書を保存しても値を入れただけのフィールドの同一性は書き換わりません
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // 修飾名はUTF-16BEの/T文字列からデコードされ、
    // ピリオドで連結されるので、入れ子や非ASCIIの名前も解決できる
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Latin-1で表せない値はFEFF付きのUTF-16BE hexとして渡し、
    // PDFの16進文字列として書き出される
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

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

SetFormFieldValueは実際に何を書くのか

どちらのオーバーロードも同じ5つの段階を踏みます。フィールド辞書を探し、HPDFSetDictFormValueを通して/Vを書き、選択フィールドの選択インデックスを整合させ、辞書をダーティにし、ボタンの外観状態を整合させ、最後にNoteLoadedFormFieldDirtyでフィールドインデックスを記録します。最後の段階は、フォームが計算スクリプトを持つ場合に効いてきます。引数なしのRecalculateLoadedFormFieldsIncrementalオーバーロードが消費するのはこのダーティ集合であり、変更されたフィールドを推移的に読む計算だけを再実行するからです。HPDFSetDictFormValue自体は、置き換えるオブジェクトの型に注意を払います。既存の/Vが名前オブジェクトである場合――チェックボックスとラジオのフィールドがエクスポート値に使うのはこれです――新しい値は名前として書かれ、文字列として書かれることはありません。PDFの名前は構造上ASCIIのみだからです。そうでなければ文字列オブジェクトを書き、渡された値を検査します。FEFFで始まり、長さが偶数で、16進数字だけからなる文字列は§7.9.2.2のUTF-16BEのワイヤ形式として扱われ、IsHexadecimalを立てて格納されるので、リテラルの(FEFF...)ではなく<FEFF...>として直列化されます。上のCityの行が頼っているのはこの仕組みです。それ以外の文字列は、渡されたバイト列のままリテラル文字列として格納されるので、普通のラテン文字には普通のテキストを渡してください

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

ボタンフィールドでは、値だけでは何が描かれるかが決まらないからです。ISO 32000-1 §12.7.4.2.3は、チェックボックスのウィジェットが/AS外観状態を持ち、/AP /Nのどのストリームが現在表示されているかを名指すと定めています。そしてビューアは/Vではなく/ASから描画します。/VをYesに変えても/ASをOffのままにすると、ファイルは内部的に矛盾し、フラット化は古い未チェックの外観を喜んでページに焼き込み、その間にフォームデータはチェック済みと言います。その隙間を塞ぐためにReconcileLoadedButtonAppearanceStatesがあります。/FTがBtnであるフィールドについて、フィールド辞書そのものとその/Kids配列のすべてのエントリを訪れ、/AP /Nからオン状態の名前を読み、フィールドの値と一致すればその名前へ、一致しなければOffへ/ASを書き直します

HotPDFのチェックボックスが/Vだけを変えたときに古いチェックを残す理由。ビューアは/AP /Nの中の/AS外観状態から描画するので、ReconcileLoadedButtonAppearanceStatesがフィールドとすべての子を訪れ、Off以外の最初のキーをオン状態の名前として読み、一致すれば/ASを書き換え、そうでなければOffにします
ラジオグループは、InheritedButtonValueが/Parentチェーンをたどって取り戻した親の値と各子を比較するので、グループを1つのエクスポート値に設定すると、まさにそのウィジェットだけがオンになり兄弟はすべてオフになります

実物のフォームから得た2つの細部がv2.752.3の修正を形作りました。1つ目は、通常の外観辞書がオン状態しか持たないことが許されている点です。§12.7.4.2.3はオフの外観をOffと名付けていますが、オーサリングツールはそのストリームをしばしば省略し、ビューアに何も描かせません。以前のコードは、辞書のエントリが2つ未満だとそこで抜けてしまったので、そうした単一状態のチェックボックスは黙って古いチェックを保っていました。いまの検査は単に辞書が空でないことであり、オン状態の名前はOffでない最初のキーとして取られます。2つ目は、オン状態の名前が作者の選んだものであることです。実物のフォームは2、Yes、On、あるいは現地語の単語を使うので、比較は実際のキーに対して大文字小文字を区別せずに行われ、ハードコードされたYesと比較されることは決してありません。ラジオボタンにはもう1つしわ寄せがあり、§12.7.4.2.4に記述されています。選択は親フィールドの/Vにあり、個々の子がウィジェットを所有し、通常は自分自身の/Vを持ちません。そのため入れ子のInheritedButtonValueヘルパーは/Parentチェーンを最大64階層まで上り、空でない値を見つけるまで進みます。こうして各子は、自分が属するグループの値と比較されます。親をある子のエクスポート値に設定すると、まさにその子だけがオンになり、兄弟はすべてオフになります

// チェックボックス:エクスポート値は/AP /Nのオン状態キーと一致する必要がある
// (多くの場合は'Yes'だが、実物のフォームは'2'や'On'など何でも使う)
Pdf.SetFormFieldValue('Consent', 'Yes');

// ラジオグループ:/Vは親に書かれ、各子ウィジェットは
// 自分のエクスポート名かOffに/ASを設定される
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

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

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

コンボボックスやリストボックスでは、選択が記録される場所は/Vだけではありません。§12.7.4.4のTable 231は、/Iを、選択された項目を識別する/Optへの0ベースインデックスの配列と定義しています。/Vがオプション3を名指しているのに/Iがオプション0を指しているのを見つけたビューアは、間違った行をハイライトするかもしれません。v2.754.1以降、HPDFReconcileChoiceSelectionがすべてのSetFormFieldValue呼び出しの中で走り、継承された/FTがChであるとき、新しい値から/Iを組み直します。操作の順序は意図的です。まずローカルの/Iエントリを、その中身に触れずに削除します。古い配列が他のフィールドと共有された間接オブジェクトだった場合、その場で書き換えると相手のフィールドの選択を壊してしまうので、ルーチンは参照を落とし、代わりに新しい直接配列を作ります。次に、選択のオプションは継承されうるので、/Optを/Parentチェーンを通して解決し、エントリを走査します。裸の文字列のオプションは直接比較し、[export display]の対はそのエクスポート側の要素で比較し、要素が2つ未満の対は飛ばします。両側ともHPDFLoadedFormTextNameを通るので、16進UTF-16のオプションが16進UTF-16の値と、同じ綴りを書かなくても一致します。最初に一致したところで1要素の/Iが書かれ、走査は止まります。スカラーの値は、MultiSelectフラグに関係なく、以前の複数選択を必ず置き換えます

HotPDFが選択フィールドを一貫させる仕組み。HPDFReconcileChoiceSelectionはローカルの/I配列に触れる前に削除し、/Optを/Parentチェーンを通して解決し、各オプションのエクスポート側をHPDFLoadedFormTextNameで比較し、最初の一致で1要素の/Iを書き、編集可能なコンボの値にインデックスがなければ何も書きません
裸の文字列のオプションは直接比較し、export displayの対はそのエクスポート側で比較します。一方/Optの外の値はインデックスを残しません。間違った行を指す古い/Iは、無いより悪いからです

何も一致しなければ、/Iはまったく書かれません。編集可能なコンボボックスではこれが正しい結果です。§12.7.4.4は、ユーザーがオプションリストの外の値を打ち込むことを許しており、そのような値にはインデックスがなく、古いインデックスは無いより悪いからです。対になったオプションリストにエクスポート値ではなく表示ラベルを渡した場合も同じ結果になります。コンボボックスがこちらの選択を表示してくれないときは、対のどちら側を渡したかを確認してください

// /Optが[[US United States] [CA Canada] [MX Mexico]]の場合:
// エクスポート値で一致し、/Iは[1]になる
Pdf.SetFormFieldValue('Country', 'CA');

// /Optにない値を持つ編集可能なコンボ:/Vは書かれ、
// /Iは削除され、インデックスは捏造されない
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

値と外観は2つの別の操作

SetFormFieldValueは、テキストや選択フィールドの外観ストリームには一切触れません。呼び出しの後、/Vは新しいテキストを保持し、/AP /Nはまだ古いものを描きます。ビューアがどちらを表示するかは、AcroForm辞書が§12.7.3.3の/NeedAppearances trueを持つかどうか、そしてビューアがそれを尊重するかどうかによります。フラグを無視するフラットナやサムネイル生成器も含め、あらゆるリーダーで新しい値が描画される必要があるなら、フィールドインデックスを渡してEnsureLoadedFieldAppearanceStreamを呼んでください。これは、継承された/DA文字列、/Qのクワディング、/MaxLenのコームレイアウト、そして値からForm XObjectを組み立て、名前付きフォントをAcroFormの/DRリソースを通して解決するので、Type0フォントはHelveticaに格下げされずに自分のdescendant fontを保ち、少なくとも1つのウィジェットがストリームを受け取ったときにTrueを返します。SetFormFieldValueの名前指定オーバーロードはインデックスを返さないので、GetFormFieldを通して取得します。これは自分が所有し、解放しなければならないTHPDFLoadedFormFieldを返します。v2.752.1の変更に対するリグレッションスイートは、この分離について明示しています。値を設定し、EnsureLoadedFieldAppearanceStreamを呼び、それからページを描画して、ウィジェット矩形の内側の画素が変わり、外側の画素は変わっていないことを確かめます。/Vが変わったことを検証しても、ユーザーが何を見るかについては何も証明できません

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // 新しい値を/APへ描き、/NeedAppearancesを無視する
    // ビューアでも表示されるようにする
    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;

これを土台にする前に知っておくべき制限

ReconcileLoadedButtonAppearanceStatesは、指定した辞書のローカルの/FTを検査するので、ラジオの親、または自分自身の/FTを持つチェックボックスに対して働きます。単独で指定された子ウィジェットで、/FTが親にしかないものは、この経路では整合されません。HPDFReconcileChoiceSelectionが扱うのは単一のスカラー値で、書くインデックスは多くても1つです。複数の項目が選ばれた複数選択のリストボックスは、SetFormFieldValueがモデル化する範囲外です。どちらのルーチンも、渡された値を/Optやオン状態のキーと突き合わせて検証しないので、打ち間違いは例外ではなく、Offのチェックボックスやインデックスのないコンボを生みます。そしてGetFormFieldValueは、辞書にあるままの/Vテキストを返します。16進で符号化された値の場合、それはデコード後のテキストではなく16進の綴りです

値が入り外観も描かれたら、次に自然に進む2つの方向がこの操作の両側にあります。SetFormFieldValueを1回ずつ呼ぶのではなく、外部システムとフィールドデータをまとめてやり取りする話は、DelphiでのXFDFのインポートとエクスポートが扱っています。そして記入済みのフォームが最終で、もう編集できてはならない場合、DelphiでAcroFormとXFAのフィールドをフラット化するが、ここで説明した/AS状態と外観ストリームをそのまま静的なページ内容に焼き込みます。フラット化の前にそれらを一貫させておくことが必須なのは、そのためです

本記事で扱った読み込み済みフォームの編集API、つまりSetFormFieldValue、EnsureLoadedFieldAppearanceStream、そしてインクリメンタル再計算のグラフは、DelphiとC++Builder向けのHotPDF Delphi Componentの一部として出荷されています