技術記事

PDFium DelphiフォームにおけるウィジェットインデックスとAnnotationインデックス

DelphiとC++Builder、Lazarus向けのPDFiumベースのVCL/LCLコンポーネントであるPDFium Componentでは、フォームフィールドインデックスは注釈インデックスではない。あるページはそのウィジェットの隣にLink、Text、Ink注釈を運んでいるため、フィールドの列挙はFPDFAnnot_GetSubtypeでフィルタし、0始まりの論理インデックスを公開し、それをネイティブ呼び出しの時点だけで実際の注釈位置へとマッピングし直さなければならない

これを露呈させるバグは、一度見れば見間違えようがない。テスターが記入済みの請求書フォームでTabを押すとカーソルが消える。フォーカスがフッター内のハイパーリンクに移ってしまったからだ。あるいはもっと悪いことに、何も起こらない:あなたのコードはフィールド3にフォーカスが当たったと記録し、UIパネルは更新されるが、FORM_SetFocusedAnnotはずっと静かにfalseを返し続けていた。この両方の症状は同じ設計上の誤りから来ており、そのうちの1つはさらにもう1つ根本原因が隠れている

PDFiumがあなたに渡す2つのインデックス空間

PDFiumは同じページに対して2つの番号付け方式を公開しており、それらはフォームウィジェット以外何も含んでいないたまたまなドキュメントでしか一致しない。1つ目は注釈インデックスだ:ページの/Annots配列内の位置であり、FPDFPage_GetAnnotCountが数え、FPDFPage_GetAnnotが受け取るものである(ISO 32000-1 §12.5.2)。2つ目は、アプリケーションレベルのAPIが提供すべき論理的なフィールドインデックスであり、ユーザーが実際に到達できる対話的なフィールドに対してゼロから走る。ISO 32000-1 §12.5.6.19はウィジェット注釈を対話的フォームフィールドの視覚的表現として定義しており、§12.7はフォームそのものを定義している。ページ上の他のすべては異なる意味論を持つ異なるサブタイプだ:Link注釈は行き先を持ち、Ink注釈はストロークのリストを持ち、Text注釈は付箋である。これらのどれもフィールド数には属さず、どれもフォームフォーカスを受け付けることができない。それでも/Annots配列の中では、生成元のアプリケーションが書いたどんな順序であれ、それらはウィジェットと入り混じって座っている。それは多くの場合、文書について他の何かが示唆する順序ではない

なぜTabは次のフィールドではなくハイパーリンクに着地するのか

フィールド数が実は注釈数だったからだ。元の実装はFormFieldCountからFPDFPage_GetAnnotCountを直接返しており、フィールド情報アクセサ、タブ順序ヘルパー、フォーカスヘルパーはすべて、その同じ整数をウィジェットの位置として扱っていた。6つのウィジェットとそれ以外何もない綺麗なAcroFormページでは、6は6に等しく、すべてのテストが通る。フッターにハイパーリンクを1つ、余白にレビューコメントを1つ加えると、その数は8個のフィールドを報告し、インデックス6と7はフォーム以外のオブジェクトへと解決され、Tabはまっすぐそれらの中へ入り込んでしまう

列挙側での修正は、注釈を数えるのではなくサブタイプを数えることだ。各注釈を開き、そのサブタイプを問い合わせ、ウィジェットだけを保持し、ハンドルはfinallyブロックの中で閉じる。FPDFPage_GetAnnotは所有権を持つハンドルを返し、それはFPDFPage_CloseAnnotを通じて返さなければならないからだ

function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
  Count, I: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := 0;
  if Page = nil then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);   // every annotation, not just fields
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
        Inc(Result);
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

これが意図的に行わないことに注目してほしい。フォーム入力環境には何も問い合わせず、フォームハンドルも必要としない。サブタイプは注釈辞書の中に住んでおり、ページだけから読み取れるからだ。これは順序にとって重要である:この文書がそもそもフォーム入力環境に値するかどうかをまだ決めていない段階でも、この数は利用可能だ。この点は、AcroForm JavaScriptとホストイベントに関する記事が利便性の問題ではなくセキュリティ上の判断として扱っている

ネイティブの境界で論理インデックスをマッピングし直す

この2つの空間が互いに漏れ出さないようにする規則は単純だ:あなたの公開APIを横切る数値は論理インデックスだけであり、それはネイティブ呼び出しの直前の関数で注釈インデックスへと変換される。フィールド情報、フォーカス、フラグのセッター、タブ順序が等しく使う1つのマッピングヘルパーこそが、この規則を強制可能にするものだ

function AnnotationIndexForField(Page: FPDF_PAGE;
  FieldIndex: Integer): Integer;
var
  Count, I, Current: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := -1;
  if (Page = nil) or (FieldIndex < 0) then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);
  Current := 0;
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
      begin
        if Current = FieldIndex then
          Exit(I);        // real /Annots position: native calls only
        Inc(Current);
      end;
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

このヘルパーの2つの性質ははっきり述べておく価値がある。これは線形走査であるため、すべてのフィールドに対する素朴なループは、数百のウィジェットを持つページでは2次的な数の注釈オープンのコストがかかる。ページ全体を列挙しているなら、フィールドごとにこのマッパーを呼ぶのではなく、注釈を一度だけ歩きながらウィジェットハンドルを集めていくこと。そしてこれは例外を発生させるのではなく-1を返す。これにより、呼び出し元は、古いインデックスが例外に値するプログラミング上の誤りなのか、無視してよいレースなのかを自分で決められる。たとえば編集によってキャッシュされたUIリストがまだ参照している注釈が削除された後などがそうだ

なぜFORM_SetFocusedAnnotはヘッドレスなページで失敗するのか

PDFiumは、そのページビューが一度も有効だとマークされたことのないウィジェットにフォーカスすることを拒むからだ。FORM_SetFocusedAnnotは、フォーム入力環境の内側にあるページビューに対してその注釈を解決する。そのページビューが存在しないなら、それは何の診断もなくfalseを返す。したがって、インデックスのマッピングだけを修正しても、Tabがハイパーリンクに着地する問題は直るが、2つ目の症状には手が触れられないままだ:あなたの論理フォーカスの記録はフィールド3だと言うが、ネイティブのフォーカスされたウィジェットは依然として何もなく、ネイティブのフォーカス、フォーカスされたテキスト、フォーカスされた値、選択肢の選択状態の上に組み立てられたあらゆるアクセサは空を返し続ける。ページビューはFORM_OnAfterLoadPageによって作られ、FORM_OnBeforeClosePageによって破棄される。ビジュアルコントロールを中心に組み立てられたビューアでは、これらの呼び出しはページを表示する処理の一部として起こる。だからこそこの失敗は非常にしばしばヘッドレス専用のバグのように見える:GUIデモでは動く同じコードがバッチツールでは失敗するのだ。このライフサイクルはビューアではなく文書オブジェクトに属するべきものであるため、PDFium Componentは今では、フォームハンドルが存在する状態でページがロードあるいはアンロードされるたびに、この両方の呼び出しを発行する。このCシグネチャはページを最初に、フォームハンドルを2番目に取る。これは手でバインディングを書くときに逆にしてしまいやすい

procedure ReportFirstField(const FileName: string);
var
  Pdf: TPdf;
  Idx: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FormFill := True;      // form-fill environment, before Active
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Pdf.PageNumber := 1;       // page load also runs FORM_OnAfterLoadPage

    Idx := Pdf.FocusNextFormField;   // logical index, 0-based over widgets
    if Idx < 0 then
      Exit;                    // page holds no widget annotations

    Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
      string(Pdf.FocusedFormFieldValue));   // reads the native focused widget
  finally
    Pdf.Free;                  // page unload runs FORM_OnBeforeClosePage
  end;
end;

この修正を証明するチェックは、両方の側を比較するものだ。論理インデックスでFocusFormFieldを呼び、それから、自分自身の記録を通じてではなく、FocusedFormFieldValueFocusedFormOptionSelectedのようなネイティブのフォーカスされたウィジェットを通じて値を読むアクセサを通じて値を読んでみること。論理インデックスは行って戻ってくるのに、ネイティブのアクセサが空を返すなら、欠けているのはマッピングではなくページビューである

論理フィールドインデックスが約束していないもの

0始まりのフィールドインデックスは利便性であって意味的な同一性ではなく、そこから4つの限界が導かれる。それはページごとであって文書ごとではないため、ページ2のインデックス0はページ1のインデックス0とは別のウィジェットであり、それらを比較することは無意味だ。それは位置に基づくものであるため、注釈を挿入または削除すれば、その変更より上のキャッシュされたすべてのインデックスが無効になる。保存したインデックスは、そのページが読み込まれたまま編集されていない間だけ有効だと扱うこと

3つ目の限界は、フィールドリストをレビューする人を驚かせるものだ。このインデックスはフィールドではなくウィジェットを列挙している。ラジオグループは複数のウィジェットのキッドを持つ1つのフィールドであるため、3つのボタンのグループは連続する3つのインデックスを提供し、それらはすべて同じNameを報告する。TPdfFormFieldInfoレコードはまさにこのケースのためにGroupCountGroupIndexを運んでおり、それらを無視するリストUIは同じフィールドを3回表示してしまう。4つ目の限界はトラバース順序に関するものだ:ここで公開されているタブ順序はウィジェットの列挙順序であり、これは/Annots配列に従うのであって、ページの/Tabsエントリ(ISO 32000-1 §7.7.3.3)にも、AcroFormのフィールドツリーにも従わない。ほとんどのプロデューサーではこの両者は一致する。右の列を先に出力するジェネレータによって2列にレイアウトされたフォームでは一致せず、フォームフィールドナビゲーションの記事で説明したキーボード経路は、すべてのインデックスが正しいにもかかわらず間違っているように感じられるだろう。顧客のファイルが奇妙に振る舞うときは、理論立てる前に両方のインデックス空間を並べてダンプすること:同じページの注釈ビューとフィールドビューを一緒に出力すれば、たいてい原因は一目で明らかになる

procedure DumpIndexSpaces(Pdf: TPdf);
var
  I: Integer;
  Info: TPdfFormFieldInfo;
begin
  for I := 0 to Pdf.AnnotationCount - 1 do
    Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));

  for I := 0 to Pdf.FormFieldCount - 1 do
  begin
    Info := Pdf.FormFieldInfo[I];
    Writeln('field ', I, ': ', string(Info.Name),
      ' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
  end;
end;

注釈数がフィールド数をはるかに上回っているということは、そのページが複数のサブタイプを混在させているということであり、これはレビュー済みの文書では正常なことであって、まさにこのマッピングが存在する理由そのものの状況である。注釈レビューワークフローの記事は同じページをマークアップ側から見ている。一方、どのテストファイルでも数が一致してしまうということは、あなたのフィクスチャがこの種のバグをまったく検出できないということであり、誠実な対応は、リンクと付箋を運ぶフォームフィクスチャを追加することだ

ここで説明したフィールド列挙、フォーカス、注釈のAPIは、DelphiとC++Builder、Lazarus向けのPDFium Componentに同梱されている。その製品ページには、フィールド情報レコードとフォーカスアクセサを含む、フォームフィールドの完全なリファレンスが掲載されている