技術記事

PDFチェックボックスのフラット化バグ:Delphiのフィールド値対ウィジェット

チェックボックスとラジオボタンが未チェックとしてフラット化されてしまうのは、表示状態/ASがフィールド値/Vと一度も同期されていなかったからだ。DelphiとC++Builder、LazarusのためのPDFiumベースのVCL・LCLコンポーネントであるPDFium Componentは、現在ではウィジェット注釈ではなく親フィールド辞書を解決するFPDFAnnot_GetFormFieldValueでその値を読む

ここに至ったバグ報告は、最初は疑ってかかりたくなる類のものだ。ある顧客が署名済みの同意書をフラット化し、その結果を開くと、すべてのチェックボックスが空になっている。ソースファイルをAcrobatで開けば、そのボックスは目に見えてチェックされている。同じコンポーネントを通じてソースファイルを読み戻せば、フィールド値は正しい。フラット化された出力だけがそれを失っており、しかもチェックボックスとラジオボタンについてだけだ:同じページのテキストフィールドは問題なく出てくる

なぜフラット化の後でチェックボックスは未チェックになるのか

FPDFPage_Flattenが一切/Vを見ないからだ。FPDFPage_Flattenはウィジェットの表示ストリームをページのコンテンツへと焼き付けるが、それが選ぶ表示は/ASが名指ししているものである。フィールド値がそのボックスはオンだと言っているのに/ASがまだ/Offと言っているなら、フラット化は律儀にオフの表示を焼き付ける。値は失われたのではない。一度も参照されなかったのだ

ISO 32000-1 §12.5.5は表示辞書/APを、/N、/R、/Dという3つの取りうるエントリとともに定義している。チェックボックスやラジオボタンにとって/Nエントリはストリームではなく、キーが表示状態の名前であるサブ辞書であり、§12.5.2は/Nがサブ辞書であるとき/ASを必須のセレクタとしている。つまりチェックボックスは、あらかじめ組み立てられた2つの表示と1つのポインタを運んでいる。そのポインタを間違えれば、どれだけ/Vが正しくてもレンダリングは正しくならない。これはまた、この失敗のモードがテキストフィールドとは異なる理由でもある。テキストフィールドにはそもそも選択すべきあらかじめ組み立てられた表示が存在しない:テキストフィールドの/Nは単一のストリームであり、値が変わるたびに一から再生成されなければならない。そのためGenerateFormAppearancesはこの2つのケースをまったく別のコードパスで扱っており、壊れていたのはボタンの経路だけだった

チェックボックスの値は実際にどこに住んでいるのか

ウィジェットではなくフィールド辞書の上である。ISO 32000-1 §12.7.5.2はチェックボックスとラジオボタンを、その/Vが現在の表示状態を名指しする名前オブジェクトであるボタンフィールドとして記述しており、§12.7.3.1はすべてのフィールド辞書に共通のエントリの中に/Vを置いている。§12.5.6.19で定義されるウィジェット注釈が寄与するのは/ASと/APである。仕様の中には、ウィジェットが/Vを運ぶことを義務付けるものは何もない

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValueには欠陥はない。その契約はまさにその名前が言うとおりのものである:渡された注釈辞書からstringエントリを取得することだ。オブジェクト13に対して/Vを求めても何も返ってこないのは、オブジェクト13が本当に/Vを持っていないからである。欠陥は、ISO 32000-1が一度も約束していないフラットなオブジェクトモデルを前提にしていた呼び出し側にあった

フィールドとウィジェットが1つの辞書を共有するのはいつか

あるフィールドがちょうど1つのウィジェットしか持たないときである。§12.5.6.19は、フィールド辞書とその単一のウィジェット注釈を1つのオブジェクトへとマージすることを許しており、ほとんどの作成ツールはその近道を取る。マージされたオブジェクトの中では/FT、/T、/V、/AS、/APがすべて並んで存在するため、ウィジェットレベルでの/Vの読み取りは成功し、バグ全体が見えないままになる

あるフィールドが2つ以上のウィジェットを持つ瞬間、このマージは不可能になり、§12.7.3.1はウィジェットが別個のフィールド辞書の/Kidsになることを要求する。あらゆるラジオグループは構造上この形をしている。ヘッダーとフッターに繰り返された同意チェックボックスも、作成ツールが第2ページにコピーしたどんなフィールドもそうだ。これが、この欠陥が回帰テストスイートを生き延びた理由の全容である:テストコーパスは単一ウィジェットのフォームで溢れており、顧客のファイルはそうではなかった。コンポーネントに頼らず自分でウィジェットを歩いているなら、同じ非対称性は列挙順序にも現れる。PDFium ComponentによるPDFフォームフィールドナビゲーションに関するノートでは、ページレベルの注釈走査が文書レベルのフィールドツリーとどう関係するかを扱っている

PDFiumが意図するやり方で値を読む

FPDFAnnot_GetFormFieldValueが正しいAPIであり、これはチェックボックスの経路がそれを使うより前から、すでにコンポーネントにバインドされていた。これはannotationだけでなくformハンドルも受け取る。これが重要な合図だ:フォーム入力環境が利用可能であることで、PDFiumは注釈をそのフォームコントロールへと解決し、フィールドオブジェクトから値を読む。したがって、マージされたレイアウトでも分割されたレイアウトでも等しく正しい答えを返す

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

このスニペットの中の2つの詳細は間違えやすい。返される長さは終端記号を含むUTF-16テキストのバイト数であるため、文字数はbuflen div 2 - 1であり、値が2であれば空文字列を意味する。したがってbuflen >= 4というガードは少なくとも1文字は本物の文字があることを意味し、これが/Vをまったく持たないフィールドの/ASが空の名前で上書きされてしまうことを防いでいる

/ASと/AP /Nが本当に一致しているもの

両者が一致しているのは名前であり、その名前はそのファイルを作った誰かが選んだものである。§12.7.5.2はオフ状態を/Offと呼ぶことを要求しているが、オン状態はまるごとプロデューサーに委ねている。/Yesは規則ではなく慣習にすぎない。Acrobatは/Yesを書くが、数多くのジェネレータは/On、/1、/Choice1、あるいはローカライズされた単語を書き、ラジオグループは通常、どのボタンが選択されているかをそのグループが表現できるように、各キッドに別々のオン状態の名前を与える。だからこそ/Vを文字どおり/ASにコピーすることは、単なる小細工ではなく正しい操作なのだ:チェックされているコントロールに対して、PDFiumはそのファイル自身が定義しているオン状態の名前を報告し、チェックされていないものに対してはOffを報告する。したがってあなたが/ASに書き込む値は、そのウィジェットの/AP /Nサブ辞書に存在するキーであることが保証される。/YesをハードコードすればAcrobatの出力に対しては動くが、それ以外のすべての場所で静かに壊れることになる

操作の順序と、まだ注意が必要な場所

この順序は固定されており容赦がない:フォーム入力を有効にし、値を割り当て、表示を再生成し、フラット化し、それから保存する。再生成のステップを飛ばすと、FPDFPage_Flattenは空の、あるいは古い表示ストリームを見つけ、文句も言わずにそれを焼き付けてしまう。これはエラーを返すのではなく静かなデータ損失である

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

2つの正直な限界が残っている。第一に、この同期はそのフィールドのすべてのウィジェットの/ASにフィールド値を書き込む。これはチェックボックスにとっては正しいが、キッドそれぞれが自分自身のオン状態の名前を定義するラジオグループに対しては近似にすぎない:あるキッドの/AP /Nが書き込まれた/ASに一致するエントリを持っていない場合、§12.5.5のもとではそのキッドが選ぶべき表示は存在しなくなり、選択されていないボタンは空の円ではなく何もないものとしてフラット化されてしまうことがある。フラット化する前にFPDFAnnot_GetFormControlIndexでラジオグループを監査しておく価値はこの数行に見合う。第二に、これらはすべてXFAには当てはまらない。XFAでは値はAcroForm辞書ではなくXMLデータパケットの中に住んでいる。この区別は永続化されないXFAフィールド編集に関するノートで扱っている。この一つの修正を超えて記憶しておく価値のある一般的な教訓がある:あるAPIが注釈に加えてフォームハンドルを受け取るときはいつでも、それはフィールド階層をあなたの代わりに解決すると告げているのであり、注釈だけしか受け取らないときはいつでも、渡されたそのオブジェクトそのものだけを読むということだ。この区別はデータ交換も支配している。XFDFフォームデータのエクスポートとインポートは完全修飾されたフィールド名で動作し、ウィジェットの位置では決して動作しないからだ

フォームのフラット化は、単一のAPI呼び出しのように見えて、実は3つの辞書の間の契約であることが判明する機能の1つである。すでにその契約を組み込み済みのコンポーネントを使いたいなら、DelphiとC++Builder向けのPDFium Componentは、ここで説明した表示の再生成、フラット化、フォームフィールドアクセスを普通のプロパティとメソッドとして提供している