PDFium Componentは、初期化するすべてのフォーム入力環境に対してFPDF_FORMFILLINFO.versionを2に設定するようになりました。ネイティブのPDFiumビルドが受け入れるバージョンはそのビルドの性質であり、開いている文書の性質ではないからです。XFA対応のpdfium.v8.dllはversion 1をきっぱり拒否するため、それを経由して開いた普通のAcroForm PDFは、XFAがどこにも見当たらないのにFPDFDOC_InitFormFillEnvironmentで失敗していました。v3.116.0の修正は小さいものですが、その裏にある誤りは一般的なもので、名指しする価値があります。プロトコルのバージョンフィールドは相手側が期待するメモリレイアウトを記述するものであり、そのレイアウトが運ぶ機能をたまたま必要としているかどうかから導出しては決してなりません
pdfium.v8.dllで普通のPDFに対するFPDFDOC_InitFormFillEnvironmentが失敗する理由
失敗するのは、XFA対応のPDFiumビルドが何よりも先にversionフィールドを検証し、古いラッパーのロジックが現在の文書がXFAフォームでないときに1を渡していたからです。Delphiホストでの症状は、TPdf.InitializeFormFillから送出されるEPdfErrorで、メッセージはCannot initialize form fill environmentです。AcroFormのテキストフィールドしかない普通の請求書や税務フォームを開くときに投げられます。同じファイルは素のpdfium.dllでは問題なく開きます。同じDLLは本物のXFA文書も問題なく開きます。壊れるのはV8ビルドと非XFA文書の組み合わせだけで、それはまさに、AcroFormのJavaScriptを得るためにEnableV8Engineを有効にした後、あるいはLoadDocumentの自動選択が以前のXFAファイルのためにプロセスをすでにpdfium.v8.dllにコミットさせた後に、ホストが陥る組み合わせです。このコミットはプロセス全体に及びます。EnableV8Engineは最初のLoadLibraryより前に読まれ、XFAビルドが読み込まれた後は、以降のすべての普通のPDFが同じバイナリに対して同じ環境設定を通ります。ホストは何も悪いことをしていません。レコードを埋めるときにラッパーが間違った問いをしたのです。どのバイナリを配布するかをまだ決めているなら、PDFium DLLの配置と読み込み失敗の診断に関するメモで素の版とV8版の選択を扱っています。この記事はV8ビルドがすでにプロセス内にあることを前提とします
FPDF_FORMFILLINFOのversionフィールドは実際に何を約束するのか
FPDF_FORMFILLINFO.versionは、レコードのどのフィールドを読んでよいかをPDFiumに伝えます。公開ヘッダーfpdf_formfill.hは、許容される値を文書ではなくライブラリのコンパイル方法に結び付けています。要約すると、契約は3つの部分からなります。version 1はFFI_InvalidateからFFI_DoGoToActionまでの安定したコールバックと、m_pJsPlatformポインターを対象とします。XFAモジュールなしのビルドは1と2のどちらも受け入れ、2の場合は追加の実験的コールバックも呼び出します。XFAモジュールありのビルドは2を要求し、それで終わりです。ヘッダーはこの要求を、見落とされると予想しているかのように2回繰り返しています。契約のどこにも文書についての言及はありません。バージョンは、自分が確保したレコードについての宣言です。2を渡すなら、m_pJsPlatformの後ろのメモリが存在し、有効な関数ポインターかNULLを保持していると約束していることになります
version 2の領域にXFAの仕組みのすべてが住んでいます。始まりはxfa_disabledで、ヘッダーはこれを、version 2未満では無視され、XFAモジュールがコンパイルされているときだけ意味を持つFPDF_BOOLと説明しています。続いて17個の関数ポインター、FFI_DisplayCaretからFFI_DoURIActionWithKeyboardModifierまでが並びます。それぞれ、XFAには必須で、それ以外ではNULLに設定すべきものとして文書化されています。この言い回しが修正全体の鍵です。NULLはそれらのスロットにとってエラー状態ではなく、XFAを駆動していないホストのための文書化された状態なのです。FillCharでクリアしてからversion 2と印を付けたレコードは、非XFAビルドにおいてversion 1のレコードとまったく同じように契約を満たし、しかもXFAビルドが受け取る唯一のレコードです
古い選択がABIを文書に結び付けていた
不具合は、単独で見れば妥当に見える1つの条件分岐でした。TPdf.InitializeFormFillは、3つの事実からRuntimeReadyフラグを計算します。文書がTPdf.XFAを通してXFAフォーム型を報告すること、XFA文字列ヘルパーがXfaFeaturesAvailableを通して解決されること、V8エクスポートがV8FeaturesAvailableを通して解決されることです。v3.116.0より前は、同じフラグがバージョンも選んでいました
// v3.115.0以前:ABIバージョンが文書に追随していた
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
FFormFillInfo.Info.version := 2
else
FFormFillInfo.Info.version := 1;
// ... ランタイム欠如の分岐でも再び固定していた
else if XFA then
begin
FFormFillInfo.Info.version := 1;
FFormFillInfo.Info.xfa_disabled := 1;
if Assigned(FOnXfaRuntimeMissing) then
FOnXfaRuntimeMissing(Self);
end;
ヘッダーを手元に置いて読めば、失敗は明らかです。RuntimeReadyはすべての普通のAcroForm文書でfalseになるため、すべての普通の文書がversion 1を宣言していました。pdfium.dllではそれで問題ありません。pdfium.v8.dll、つまりXFA対応ビルドでは、PDFiumがフィールドを確認し、要求される2を下回っているのを見てnullのFPDF_FORMHANDLEを返し、CheckPdfがそれを上記の例外に変えます。古いコードの意図は防衛的でした。version 1のままにして、XFAビルドが未代入のversion 2スロットを読まないようにする、というものです。それはヘッダーがすでに排除している問題に対する防衛であり、ヘッダーが明示的に警告している問題を生み出していました。修正されたコードは、バージョンを最初に一度だけ、レコードが物理的に何であるかから決めます
procedure TPdf.InitializeFormFill;
var
RuntimeReady: Boolean;
begin
FXfaRuntimeUsable := False;
FXfaPageCountOverride := -1; // センチネル:静的なページツリーを使う
if not FormFill then
Exit;
FillChar(FFormFillInfo, SizeOf(FFormFillInfo), 0);
FFormFillInfo.Pdf := Self;
// 完全なversion 2のレコードは上で確保されクリアされている。PDFiumは
// XFAなしでもversion 2を受け入れ、XFA対応ビルドでは必ず要求する。
// この文書がXFAフォームを含まない場合も同様である。
FFormFillInfo.Info.version := 2;
FFormFillInfo.Info.xfa_disabled := 1;
// RuntimeReadyが制御するのはXFAコールバックとxfa_disabledで、versionではない。
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
...
RuntimeReadyがまだ担う役割:コールバックとxfa_disabled
RuntimeReadyはXFA挙動のゲートという役割を保ちますが、レコードのレイアウトにはもう触れません。version 1のコールバック——FFI_Invalidate、FFI_SetTimer、FFI_GetPage、FFI_DoURIAction、FFI_DoGoToAction、そしてそのブロックの残り——は、AcroFormもXFAもそれらに依存するため無条件に配線されます。17個のversion 2ポインターはRuntimeReady分岐の中でだけ、xfa_disabled := 0とともに代入されます。文書がXFAでランタイムが存在しない場合、レコードはversion 2のままxfa_disabledが1でversion 2のスロットはNULLのまま残り、ラッパーがOnXfaRuntimeMissingを発生させて、ホストがpdfium.v8.dllでの再起動を提案できるようにします。環境が存在した後、FPDF_LoadXFAはRuntimeReadyがtrueだったときにだけ呼ばれ、trueが返ったときだけFXfaRuntimeUsableが設定され、それをTPdf.XfaRuntimeAvailableが報告します
if RuntimeReady then
begin
FFormFillInfo.Info.xfa_disabled := 0; // 0 = XFA有効
FFormFillInfo.Info.FFI_DisplayCaret := FormFillDisplayCaret;
FFormFillInfo.Info.FFI_GetCurrentPageIndex := FormFillGetCurrentPageIndex;
FFormFillInfo.Info.FFI_SetCurrentPage := FormFillSetCurrentPage;
FFormFillInfo.Info.FFI_GotoURL := FormFillGotoURL;
FFormFillInfo.Info.FFI_GetPageViewRect := FormFillGetPageViewRect;
FFormFillInfo.Info.FFI_PageEvent := FormFillPageEvent;
FFormFillInfo.Info.FFI_PopupMenu := FormFillPopupMenu;
FFormFillInfo.Info.FFI_OpenFile := FormFillOpenFile;
FFormFillInfo.Info.FFI_EmailTo := FormFillEmailTo;
// ... FFI_UploadToからFFI_DoURIActionWithKeyboardModifierまで
end
else if XFA then
begin
// ランタイム利用不可:version 2を保ち、XFAを無効のままにし、ホストに伝える
if Assigned(FOnXfaRuntimeMissing) then
FOnXfaRuntimeMissing(Self);
end;
FFormHandle := FPDFDOC_InitFormFillEnvironment(FDocument, FFormFillInfo.Info);
CheckPdf(FFormHandle <> nil, 'Cannot initialize form fill environment');
if RuntimeReady then
FXfaRuntimeUsable := FPDF_LoadXFA(FDocument) <> 0;
このブロックの2つの詳細は、自分でバインディングを書くときに間違えやすいものです。FXfaPageCountOverrideは何よりも先にセンチネルとして-1に戻されるため、FFI_PageEventが再ページ割り付けを報告するまでPageCountは静的なページツリーにフォールバックします。ここが0だと、空の文書を黙って主張してしまいます。そしてversion 2の各コールバックは、レコードから所有するTPdfを復元し、PDFiumへ戻る前にPascalの例外をすべて飲み込む静的なcdeclルーチンです。DelphiでのPDFium ABIの堅牢化に関するメモがFFI_OpenFileについて述べているのと同じ規律です。バージョンの変更によってどちらのルールも緩みません
DLLにXFAモジュールがない場合でもversion 2は安全か
はい、安全です。その理由はライブラリからの約束ではなくレコードにあります。非XFAビルドでは、ヘッダーはversion 2だと実験的コールバックも呼ばれると述べているため、問いはPDFiumがそこで何を見つけるかです。TPdfFormFillInfoは、Infoメンバーがversion 2のフィールドをすべて含む完全なFPDF_FORMFILLINFOであるパックレコードで、InitializeFormFillは1バイトに触れる前にFillCharで全体をクリアします。したがって、素のpdfium.dllと普通の文書では、ライブラリが見るのはversion 2、xfa_disabledがセット済み、すべての実験的スロットがNULLで、これはXFAを実装していないホストに対してヘッダーが規定する状態そのものです。ライブラリが読み越すような切り詰められたレコードは存在しません。そもそもレコードがversion 2より短かったことがないからです。古いロジックは、Pascalの宣言がすでに排除していたレイアウトの不一致を防衛していたのです
正直に述べておくべき境界は、レコードが覆えないものです。普通の文書でversion 2を渡しても、JavaScript、XFAスクリプト、あるいはそれらのコールバックの背後にあるホストイベントが有効になるわけではありません。m_pJsPlatformはV8FeaturesAvailableがtrueのときにだけ取り付けられ、XFAはRuntimeReadyがtrueでない限り無効のままで、TPdf.XFAは環境が何を交渉したかに関係なくFPDF_GetFormTypeからのフォーム型を報告し続けます。動的なXFAが実際に描画されるかどうかを知りたいホストは、XFAフォームの検出とXFAパケットの抽出に関するメモが勧めるとおり、Activeがtrueになった後にXfaRuntimeAvailableを読み続けるべきで、バージョンフィールドから何かを推測すべきではありません
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// 文書がXFAなのに読み込まれたpdfium.dllがエンジンを実行できないとき、
// InitializeFormFillから発火する。どちらでもversion 2を渡しているため、
// フォーム環境は開く。無効なのはXFAランタイムだけである。
StatusBar.SimpleText :=
'XFA form detected; restart with pdfium.v8.dll to enable 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; // pdfium.v8.dllで普通のPDFでも例外を投げなくなった
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaWarning;
end;
プロトコルのバージョンと機能の可用性は別の軸
この修正から出てくる一般的なルールは、コールバック構造体のバージョンフィールドが「このレコードはどれだけ大きく、何を読んでよいか」という問いに答えるのに対し、機能検出は「そのスロットのどれが役に立つ仕事をするか」に答えるということです。前者はネイティブバイナリと、コンパイル時に参照したPascal宣言によって固定されます。後者は文書ごと、DLLのエクスポートテーブルごと、ホストの構成ごとに変わります。XFAのケースがたまたま両方を必要とするため、2つを1つの真偽値に潰すのは誘惑的ですが、あるビルドが最小バージョンを強制した瞬間、その潰し込みはその機能を必要としないすべての文書で壊れます。XFAフォーム——ISO 32000-1 §12.7.8で、AcroForm辞書と並んで存在するXMLペイロードとして記述されています——がここでの機能であり、レコードレイアウトがプロトコルであり、PDFiumはファイルを一度も見る前にレイアウトを要求する権利があります。同じ形は、Cライブラリが構造体にバージョンを付けるあらゆる場所に現れます。ビューア情報ブロック、レンダリングオプションのレコード、プラットフォームコールバックテーブルです。安全なパターンは、修正されたInitializeFormFillがたどっているものです。理解できる最新のレイアウトを宣言し、それを完全にクリアし、そのレイアウトに合わせてバージョンを無条件に設定し、どのスロットを埋めるかは機能チェックに決めさせるのです。将来のPDFiumヘッダーがversion 3を追加したら、変更は宣言とその1つの代入だけであり、誰もテストしなかった組み合わせで誤る文書依存の分岐ではありません
修正されたフォーム入力の初期化は、Delphi、Lazarus、C++Builder向けのPDFium Componentに含まれています。両ビルドが同じレコード宣言を共有するため、Win32とWin64の両方に適用されます。JavaScript駆動のAcroFormのためにすでにpdfium.v8.dllを選択しているアプリケーションなら、これがフォーム環境を特別扱いせずに、同じバイナリで残りのPDFアーカイブを開けるようにする変更です