技術記事

DelphiのFPDF_FORMFILLINFO v2:DLLのABIに従う

テキストフィールドをいくつか持ちXFA内容を含まないAcroFormの請求書は、素のpdfium.dllでは問題なく開きます。ところが、同じファイルが、プロセスがXFA対応のpdfium.v8.dllを走らせていると、FPDFDOC_InitFormFillEnvironmentをNULLで返させることがあります。文書のバイト列は両実行で同一です。違うのは1つの整数です。XFAビルドのPDFiumはFPDF_FORMFILLINFO.versionに2を要求し、XFAなしの文書に1を書くバインディングは、PDFiumが1つのコールバックを見る前に門払いされます

FPDFDOC_InitFormFillEnvironmentはなぜ普通のAcroForm PDFを拒否するのか

拒否はバイナリから来て、文書には発言権がありません。フォームフィルのヘッダー、PDFiumのfpdf_formfill.hとそのPascal翻訳FPdfFormFill.pasは、XFAモジュール付きでコンパイルされたビルドはversion 2を要求すると述べ、同じコメントブロックの中でそれを2回言います。XFAなしのビルドは1も2も受け付けます。非XFAファイルにversion 1を送るバインディングの下では、バイナリと文書の4つの組み合わせのうち1つだけが失敗します。version 1のレコードがpdfium.v8.dllに、普通のAcroFormファイルとともに渡されるケースです。素のDLLはそのレコードを受け取ります。V8のDLLはどんな文書にもversion 2のレコードを受け取ります。文書に応じてバージョンを決めるバインディングがXFAファイルにversion 1を送ることは決してないので、失敗するセルは、誰もテストしようと思わないまさにそのセルです

手書きのバインディングは、そこでFPDFDOC_InitFormFillEnvironmentがNULLを返すのを見て、それ以上の手がかりを持ちません。PDFium ComponentのTPdfは、拒否された環境をActive := Trueの際のEPdfErrorとして報告しますが、常にversion 2を書くので、このセルはそこでは永久に顔を出しません。このセルに入り込むのは簡単です。ホストはAcroFormのJavaScriptが欲しくてグローバルのEnableV8EngineをTrueにする。その瞬間から、プロセス内のすべてのPDFがV8バイナリを通ります。あるいはホストはフラグに触れないが、最初に開いたファイルのAcroForm辞書に/XFAエントリや、カタログに/NeedsRendering trueがある。PDFiumライブラリがまだロードされていないとき、TPdfはこの2つの印を探してファイルを走査し、DLLをロードする前にEnableV8Engineをオンにします。どちらでも選択はプロセスの残りを通して効き続け、その後に開かれるすべての普通のAcroFormファイルがXFAビルドに出会います。そもそもどのバイナリを同梱するかは、PDFium DLLの配布とロード失敗の診断のガイドで扱います。以下はV8ビルドがすでにロードされている前提です

素のpdfium.dllとXFA対応pdfium.v8.dllの、AcroForm文書とXFA文書に対する4つの組み合わせを示すPDFium Componentの図:version 1のレコードが失敗するのは、V8ビルドに普通のフォームを渡したときのFPDFDOC_InitFormFillEnvironmentだけ。version 2のレコードなら4つすべてが開く
ABIバージョンを文書に紐付けると、プロセス全体でのV8バイナリの選択が、その後に開くすべての普通のPDFを失敗した環境初期化に変えます

FPDF_FORMFILLINFOのversionフィールドが約束するもの

FPDF_FORMFILLINFO.versionは、PDFiumがレコードのどれだけを読んでよいかを伝えます。version 1が安定部分を覆います。Release、FFI_InvalidateからFFI_DoGoToActionまでのコールバック、そしてm_pJsPlatformポインタです。version 2は実験的な末尾を追加します。XFAなしのビルドがversion 2を受け取ると、そこに見つかった実験的なコールバックも呼びます。XFA付きのビルドはそれ未満を受け付けません。ヘッダーは文書についてまったく言及しません。2を書くことは、m_pJsPlatformの後ろのメモリが存在し、その中のすべてのスロットが有効なcdecl関数かNULLのどちらかを保持している、という約束です

その末尾はxfa_disabledから始まります。version 2未満では無視されるFPDF_BOOLで、その後にFFI_DisplayCaretからFFI_DoURIActionWithKeyboardModifierまで17個の関数ポインタが続きます。このうち15個、FFI_DisplayCaretからFFI_PutRequestURLまでは"XFAに必須、それ以外はNULL"と文書化されています。最後の2個、FFI_OnFocusChangeとFFI_DoURIActionWithKeyboardModifierはどのホストにも任意で、ヘッダーはさらに、FFI_DoURIActionWithKeyboardModifierを設定しないままのversion 2エンベッダーはURIクリックがFFI_DoURIActionへ転送されると述べています。まとめると、XFAを駆動しないホストにとってNULLこそが仕様上の値です。FillCharでゼロクリアしversion 2を刻印したレコードは、素のビルド上でversion 1のレコードとまったく同じく契約を満たし、しかもXFAビルドが受け付ける唯一のレコードでもあります

PDFium ComponentのDelphiにおけるFPDF_FORMFILLINFOレコードの図:version 1はFFI_InvalidateからFFI_DoGoToActionまでのコールバックとm_pJsPlatformを対象とし、version 2はxfa_disabledとFFI_DisplayCaret時代の17個のポインターを追加します。FillCharがすべてのバイトをクリアし、NULLのスロットはXFAを駆動していないホストのための文書化された状態です
Pascalのレコードは常に完全なversion 2のレイアウトなので、XFA対応ビルドはそれを受け入れ、素のビルドはNULLのままの実験的スロットを単に一度も呼びません

JavaScript側も同じパターンに従います。m_pJsPlatformはV8なしのビルドでは使われません。V8ビルドではNULLポインタは、文書がレンダリングされる間JavaScriptの実行を止めるだけです。それが指すIPDF_JsPlatformレコードには独自のversionフィールドがあり、ヘッダーはそれが現在2でなければならないと言っています

落とし穴:文書にABIバージョンを選ばせる

間違いは、それ自体ではもっともらしく読める1つの条件分岐です。手書きのバインディングはすでにXFAコールバックを配線する価値があるかを判断しなければならず、同じテストにバージョン番号も選ばせたくなります

// 手書きバインディングの誘惑的な形:ABIバージョンが文書に追随する
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
  FormInfo.version := 2
else
  FormInfo.version := 1;   // XFA以外の文書はすべて1になる

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// 普通のAcroFormファイルをpdfium.v8.dllで開くと、ここでFormHandleはnilになる

この分岐の意図は防御的です。versionを1に保ち、XFAビルドがホストが埋めていないスロットを読まないようにする。しかし1行上のFillCharがすでにスロットをNULLで埋めています。NULLは、XFAを実行していないホストに対する正しい値としてヘッダーが定義しているものです。分岐は宣言が取り除いた危険を防御しながら、ヘッダーが警告する危険へまっすぐ歩いていきます。しかもXFAファイルのテストでは捕捉できません。それらは毎回version 2のパスを通るからです

PDFium Componentはレコードを逆の順で組み立てます。TPdf.FormFillがTrueである限り、文書を開くときにレコード全体をクリアし、version 2とxfa_disabled := 1を書き、それから初めて文書が何を必要とするかを尋ねます。どんなバインディングにも当てはまるルールとして言えば、宣言したレコードからバージョンを設定し、それに触れさせるものは何もない、ということです。次は自分のコードのためのその形です。THostFormFillInfoラッパーは後で宣言しています

// FormInfoはビューアオブジェクトのTHostFormFillInfoフィールドなので、
// フォームハンドルより長生きする。HostInvalidate、HostGetPageなどは
// 自前のcdeclコールバック
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;

FormInfo.Info.version := 2;        // 宣言したレイアウトに常に一致
FormInfo.Info.xfa_disabled := 1;   // 使えると確認できるまでXFAはオフのまま

FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... version 1のその他のコールバック

IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
  FormInfo.Info.xfa_disabled := 0;
  FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
  FormInfo.Info.FFI_PageEvent := HostPageEvent;
  // ... version 2の残りのスロット
end;

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
  raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);

RuntimeReadyとV8FeaturesAvailableが属する場所

機能チェックが決めるのはどのスロットを埋めるかで、PDFium Componentではレイアウトに決して触れません。TPdfは3つの事実から準備完了を計算します。文書がTPdf.XFAを通してXFAフォームタイプを報告すること、グローバルのXfaFeaturesAvailableがロードされたDLLがエクスポートするXFA文字列ヘルパーを見つけること、そしてV8FeaturesAvailableがV8エクスポートを見つけることです。version 1のコールバックはすべての文書に対して配線されます。AcroFormとXFAの両方がそれに依存するからです。17個のversion 2スロットがすべて割り当てられxfa_disabledがクリアされるのは、3つの事実がすべて揃ったときだけです。環境が存在したあと、FPDF_LoadXFAはそのケースでのみ走り、戻り値が非ゼロのときだけTPdf.XfaRuntimeAvailableがTrueを報告します。FPDF_LoadXFAはXFAエンジンなしのビルドでもエクスポートされていて、そこでは単にfalseを返します。だからエクスポートの有無だけでは足りないのです

文書がXFAなのにランタイムがないとき、レコードはversion 2のまま、xfa_disabledは1、末尾はNULLのままです。環境は開き、XFA内容は静的なまま、TPdfはOnXfaRuntimeMissingを上げます。ホストがpdfium.v8.dllでの再起動を提案できるようにするためです。m_pJsPlatformは第3のルールに従います。V8FeaturesAvailableがTrueである限り、普通のAcroFormファイルに対しても接続されます。文書レベルのスクリプト、フィールド計算、app.alertがV8ビルドの下で走れるのはこれのおかげです。PageCountはXFAがFFI_PageEventを通して文書を再ページングするまで静的なページツリーを読み、その時点で新しい総数をFPDF_GetPageCountから取り、OnXfaPageCountChangedで報告します

ホスト側では、2つのプロパティが2つの異なる疑問に答えます。XFAはファイルが何を含むかを、バイナリに関係なくFPDF_GetFormTypeから伝えます。XfaRuntimeAvailableはこの文書に対してエンジンが実際に起動したかを伝えます。2つ目はActiveがTrueになった後に読みます(XFAフォームの検出とXFAパケットの抽出の記事がそうしています)。そしてversionフィールドは判断から完全に外します

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // 文書がXFAなのに読み込まれたDLLがエンジンを実行できないとき、オープン中に
  // 発火する。フォーム環境は存在し続け、XFAは無効のまま
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll for dynamic rendering';
end;

procedure TMainForm.OpenDocument(const FileName: string);
begin
  Pdf.Active := False;
  Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  Pdf.FormFill := True;
  Pdf.FileName := FileName;
  Pdf.Active := True;   // 普通のAcroFormファイルもpdfium.v8.dllの下で開ける
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaNotice;   // 自前のUIルーチン
end;

versionが読まれる前にレコードを壊すレイアウトミス

正しいバージョン番号も、その後ろのバイトがCコンパイラが置いた場所に座っていなければ役に立ちません。いくつかのバインディングエラーは、どのバージョンを運んでいようとPDFiumが誤読するレコードを作り、その1つはWin32では不可視のままです。FPDF_FORMFILLINFOをpacked recordとして宣言することです。Win32ではすべてのフィールドが4バイトなので、packedとアライン済みのレイアウトはたまたま一致します。Win64ではC構造体はversionの後に4バイトをパディングしてReleaseを8バイト境界に置き、xfa_disabledの後でもう一度パディングします。packedなPascal宣言は両方の隙間を落とし、すべてのコールバックポインタを4バイトずらします。FPdfFormFill.pasの宣言が素のrecordなのはこのためです

2つ目のエラーは切り詰められたレコードです。m_pJsPlatformで止まりながらversion 2を書く翻訳。PDFiumはその末尾を、レコードの後に続く何かのメモリから読みます。3つ目は寿命です。ヘッダーは、FPDFDOC_InitFormFillEnvironmentに渡されたレコードは、返されたフォームハンドルが閉じられるまで有効でなければならないと言います。だから文書を開くプロシージャのローカル変数が保持するレコードは、そのプロシージャが戻った瞬間にダングリングポインタです。TPdfはレコードをコンポーネントのフィールドとして保持します。外側のレコードがオフセット0にPDFium構造体を置き、直後に所有するTPdfへのバックポインタを置く形です。すべての静的コールバックが自分のオブジェクトを再発見できるのはこの構造のおかげです

type
  // Win64で間違い:packedはC構造体がversionの後に持つパディングを
  // 取り除く。だからReleaseはここではオフセット4、PDFiumではオフセット8
  TBadFormFillInfo = packed record
    version: Integer;
    Release: Pointer;
    // ... 残りのフィールド
  end;

  // 自前のラッパー:最初にPDFiumレコード、後に自前のバックポインタ
  THostFormFillInfo = record
    Info: FPDF_FORMFILLINFO;   // オフセット0。だからpThisはそのままキャストで戻せる
    Owner: TObject;
  end;
  PHostFormFillInfo = ^THostFormFillInfo;

procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
  left, top, right, bottom: Double); cdecl;
begin
  try
    // TMyViewerとInvalidatePdfRectは自前のビューアクラスの代役
    TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
      page, left, top, right, bottom);
  except
    // Pascalの例外はPDFiumのC++フレームを巻き戻してはならない
  end;
end;

さらに2つのスロットに注意が必要です。FFI_GetLocalTimeは16バイトのFPDF_SYSTEMTIMEを値で返しますが、DelphiとMSVCはWin32で隠し結果ポインタの置き場所について意見が割れるので、Pascal実装は呼び出し側のFPDF_FORMFILLINFOを壊します。現在のPDFiumはこのメンバに未使用の印を付け、Pascal宣言はnilのままとするコメントを運びます。PDFium Componentもそうしています。コンポーネントが実際にインストールするすべてのコールバックはPascal例外を捕捉し、代わりに文書化された失敗値を返します。C ABIには例外を境界の向こうへ運ぶ方法がないからです。DelphiでPDFium ABIをハードニングする記事が、FFI_OpenFileを実例にこの規律を歩きます

プロトコルバージョンと機能の利用可否は2つの軸

コールバック構造体のversionフィールドが答えるのは、レコードがどれだけ大きいか、何を読んでよいかです。機能検出が答えるのは、そのスロットのどれが実際に何かをするかです。前者はネイティブバイナリと、コンパイル対象の宣言で固定されます。後者は文書ごと、DLLのエクスポートテーブルごと、ホスト設定ごとに変わります。XFAのケースは両方の答えを同時に必要とし、1つのブーリアンに折り畳みたくなります。その折り畳みは、ビルドが最小バージョンを強制した瞬間に壊れます。ISO 32000-1 §12.7.8がAcroForm辞書に並んで運ばれるXMLペイロードと記述するXFAフォームが、この話の機能です。レコードレイアウトがプロトコルで、PDFiumはファイルを読む前にレイアウトを確認する権利を持っています

FPDF_FORMFILLINFOの背後の2軸を分けるPDFium Componentの図:レコードレイアウトとネイティブバイナリで固定されるプロトコルバージョンと、機能の利用可否。後者では文書とホストごとに、RuntimeReadyがxfa_disabledと17個のversion 2スロットとFPDF_LoadXFAを制御し、V8FeaturesAvailableがm_pJsPlatformを決める
versionフィールドは相手が読んでよいメモリを記述し、機能チェックはどのスロットが役に立つかを決めます。2つを1つのブーリアンに折り畳むと、最小バージョンを強制するビルドが壊れます

同じ構造は、Cライブラリが構造体にバージョンを付けるあらゆる場所に現れます。FPDF_LIBRARY_CONFIG、IPDF_JsPlatform、サイズやバージョンのヘッダーを持つ任意のコールバックテーブルです。理解している最新のレイアウトを宣言し、完全にクリアし、一致するバージョンを無条件に書き、スロットは文書とDLLが実際にできることに応じて埋めます。将来のfpdf_formfill.hがversion 3を導入しても、作業は新しい宣言と1つの代入変更です。文書内容をキーにした分岐は、ビルドとファイルのすべての組み合わせで見直すことになり、それが間違える組み合わせは、たいてい誰も開いたことのないものです

ここで扱ったレコード処理は、Delphi、Lazarus、C++Builder向けPDFium Componentの一部で、同じ宣言がWin32とWin64の両ビルドに使われます。JavaScript駆動のAcroFormのためにすでにpdfium.v8.dllを同梱するアプリケーションは、アーカイブの残りをXFAの有無にかかわらず、その1つのバイナリで、フォーム環境の特別扱いなしに開けます