一份只有幾個文字欄位、毫無 XFA 內容的 AcroForm 發票,用普通的 pdfium.dll 開得好好的;同一份檔案,一旦行程裡跑的是啟用 XFA 的 pdfium.v8.dll,卻可能讓 FPDFDOC_InitFormFillEnvironment 回傳 NULL。兩次執行裡文件的位元組完全相同,不同的是一個整數:XFA 組建的 PDFium 要求 FPDF_FORMFILLINFO.version 必須是 2,而按「文件沒有 XFA 就寫 1」行事的綁定,在 PDFium 看第一個回呼之前就被拒於門外
FPDFDOC_InitFormFillEnvironment 為什麼拒收普通的 AcroForm PDF?
拒收來自二進位檔,文件沒有發言權。表單填寫標頭——PDFium 的 fpdf_formfill.h 與它在 FPdfFormFill.pas 裡的 Pascal 翻譯——寫明編入了 XFA 模組的組建要求版本 2,而且同一個註解區塊裡講了兩次。不含 XFA 的組建接受 1 或 2。在「非 XFA 檔案就送版本 1」的綁定下,二進位檔與文件的四種組合裡只有一種會失敗:版本 1 的記錄連同普通 AcroForm 檔案交給 pdfium.v8.dll。普通 DLL 接受那筆記錄;V8 DLL 對任何文件都接受版本 2 的記錄;V8 DLL 上的真 XFA 表單,按文件決定版本的綁定永遠不會送版本 1——所以失敗的那一格,恰恰是沒有人想到要測的那一格
手寫綁定在那一刻只看到 FPDFDOC_InitFormFillEnvironment 回傳 NULL,沒有更多線索。PDFium Component 的 TPdf 會把被拒的環境當成 Active := True 期间的 EPdfError 回報,但它一律寫版本 2,那一格在那裡永遠不會出現。掉進那一格很容易:宿主為了 AcroForm JavaScript 把全域 EnableV8Engine 設成 True,從此行程裡每份 PDF 都走 V8 二進位檔;或者宿主從沒碰過那個旗標,但開的第一份檔案在 AcroForm 字典裡有 /XFA 項目、或目錄裡有 /NeedsRendering true——PDFium 函式庫還沒載入時,TPdf 會掃描檔案找這兩個標記,並在載入 DLL 前打開 EnableV8Engine。無論哪條路,選擇對行程其後的生命週期都成立,之後開的每一份普通 AcroForm 檔案都會撞上 XFA 組建。一開始該出貨哪個二進位檔,部署 PDFium DLL 與診斷載入失敗指南有講;以下全部假設 V8 組建已經載入
FPDF_FORMFILLINFO 的版本欄位承諾了什麼
FPDF_FORMFILLINFO.version 告訴 PDFium 它可以讀這筆記錄的多少。版本 1 涵蓋穩定部分:Release、從 FFI_Invalidate 到 FFI_DoGoToAction 的回呼,以及 m_pJsPlatform 指標。版本 2 加上一段實驗性的尾巴。不含 XFA 的組建收到版本 2 時,也會呼叫它在裡面找到的實驗性回呼;含 XFA 的組建則非 2 不收。標頭從頭到尾沒提文件半個字。寫 2,你承諾的是 m_pJsPlatform 之後的記憶體存在,而且每個槽位要嘛持有有效的 cdecl 函式、要嘛是 NULL
那段尾巴從 xfa_disabled 開始——一個版本 2 以下會被忽略的 FPDF_BOOL——接著是十七個函式指標,從 FFI_DisplayCaret 到 FFI_DoURIActionWithKeyboardModifier。其中十五個,FFI_DisplayCaret 到 FFI_PutRequestURL,文件記載為 "Required for XFA, otherwise set to NULL"。最後兩個,FFI_OnFocusChange 與 FFI_DoURIActionWithKeyboardModifier,對所有宿主都是可選的,標頭還補了一句:留著 FFI_DoURIActionWithKeyboardModifier 不設的版本 2 嵌入者,其 URI 點擊會轉送給 FFI_DoURIAction。合起來看,對不驅動 XFA 的宿主而言,NULL 就是被規定的值。用 FillChar 清零、標上版本 2 的記錄,在普通組建上滿足合約的程度與版本 1 記錄完全相同,而它也是 XFA 組建唯一肯收的記錄
JavaScript 那一側遵循同樣的模式。m_pJsPlatform 在不含 V8 的組建上沒有用處;在 V8 組建上,NULL 指標讓文件照樣渲染、JavaScript 不跑。它指向的 IPDF_JsPlatform 記錄有自己的 version 欄位,標頭說那一個目前必須是 2
陷阱:讓文件來選 ABI 版本
錯誤是一個單獨讀起來很合理的條件判斷。手寫綁定本來就得決定 XFA 回呼值不值得接,讓同一個測試順手挑版本號,誘惑就在這裡
// 手寫綁定裡誘人的寫法:版本跟著文件走
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);
// 在 pdfium.v8.dll 配普通 AcroForm 檔案時,這裡的 FormHandle 是 nil
那個分支的意圖是防禦性的:把版本維持在 1,XFA 組建就不會去讀宿主沒填的槽位。但上面那行的 FillChar 已經把那些槽位填成 NULL——標頭定義的、不跑 XFA 的宿主該有的值。分支防的是一個宣告已經排除的危險,卻一頭撞進標頭警告的那個。而且測 XFA 檔案永遠抓不到它,因為那些檔案每次都走版本 2 的路
PDFium Component 反過來建這筆記錄。只要 TPdf.FormFill 為 True,開啟文件就先清空整筆記錄、寫入版本 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;
// ... 其餘版本 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;
// ... 其餘版本 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 從三個事實計算就緒狀態:文件透過 TPdf.XFA 回報 XFA 表單類型、全域 XfaFeaturesAvailable 在載入的 DLL 裡找到 XFA 字串輔助函式的匯出、V8FeaturesAvailable 找到 V8 匯出。版本 1 回呼對每份文件都接上,因為 AcroForm 與 XFA 都依賴它們。十七個版本 2 槽位全部指派、xfa_disabled 清零,只在三個事實同時成立時發生。環境建好之後,FPDF_LoadXFA 只在那一種情況下執行,且只有非零回傳才讓 TPdf.XfaRuntimeAvailable 回報 True。FPDF_LoadXFA 連不含 XFA 引擎的組建都有匯出、在那裡只是回傳 false——這正是只查匯出不夠的原因
文件是 XFA 而 runtime 缺席時,記錄維持版本 2、xfa_disabled 為 1、尾巴保持 NULL。環境照樣開啟,XFA 內容維持靜態,TPdf 觸發 OnXfaRuntimeMissing,讓宿主建議換 pdfium.v8.dll 重啟。m_pJsPlatform 走第三條規則:只要 V8FeaturesAvailable 為真就掛上,普通 AcroForm 檔案也一樣——文件層級腳本、欄位計算與 app.alert 能在 V8 組建下執行,靠的就是這個。PageCount 在 XFA 透過 FFI_PageEvent 重新分頁文件之前讀的是靜態頁樹;那一刻起,新的總數取自 FPDF_GetPageCount,並透過 OnXfaPageCountChanged 回報
宿主這一側,兩個屬性回答兩個不同的問題。XFA 告訴你檔案裡有什麼,取自 FPDF_GetFormType,與二進位檔無關。XfaRuntimeAvailable 告訴你引擎這次是否真的為這份文件跑起來了。第二個要在 Active 變 True 之後讀,偵測 XFA 表單與擷取 XFA 封包一文正是這麼做;版本欄位則完全不必進入你的推理
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;
版本被讀到之前就弄壞記錄的佈局錯誤
版本號正確,還得背後的位元組坐在 C 編譯器放它們的位置上才有用。幾種綁定錯誤會產出 PDFium 無論帶哪個版本都會誤讀的記錄,其中一種在 Win32 上還看不出來。那就是把 FPDF_FORMFILLINFO 宣告成 packed record。Win32 上每個欄位都是四位元組,packed 與對齊佈局恰好重合;Win64 上 C 結構在 version 之後墊了四位元組讓 Release 落在 8 位元組邊界、xfa_disabled 之後又墊一次,packed 的 Pascal 宣告把兩處間隙都拿掉,每個回呼指標因此偏移四位元組。FPdfFormFill.pas 裡的宣告正是為此用普通 record
第二個錯誤是截短的記錄:翻譯停在 m_pJsPlatform,版本卻寫 2。PDFium 於是從記錄之後的任何記憶體裡讀尾巴。第三個是生命週期。標頭說交給 FPDFDOC_InitFormFillEnvironment 的記錄必須在回傳的表單控制代碼關閉前一直有效,所以開文件程序裡的區域變數記錄,程序一返回就是懸空指標。TPdf 把記錄存成元件的欄位,外面包一層記錄,讓 PDFium 結構在偏移 0、緊接著一個指回擁有者 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;
還有兩個槽位值得小心。FFI_GetLocalTime 以傳值方式回傳 16 位元組的 FPDF_SYSTEMTIME,Win32 上 Delphi 與 MSVC 對隱藏結果指標放哪裡意見不合,Pascal 實作會弄壞呼叫端的 FPDF_FORMFILLINFO。目前的 PDFium 已把該成員標記為不使用,Pascal 宣告帶著註解叫你留 nil,PDFium Component 照辦。元件安裝的每一個回呼都捕捉 Pascal 例外、改回傳文件記載的失敗值,因為 C ABI 沒有任何辦法把例外帶回邊界另一側。Delphi 裡強化 PDFium ABI 的文章用 FFI_OpenFile 當例子走過一遍這套紀律
協定版本與功能可用性是兩條軸
回呼結構裡的版本欄位回答的是記錄有多大、哪些可以讀。功能偵測回答的是那些槽位裡哪些真的會做事。前者由原生二進位檔與你編譯所對的宣告固定;後者隨文件、隨 DLL 匯出表、隨宿主設定而變。XFA 這個案例需要兩個答案同時到位,這正是把它們摺成一個布林值那麼誘人的原因,而一旦有組建強制最低版本,那個摺疊就當場破裂。XFA 表單——ISO 32000-1 §12.7.8 描述為伴隨 AcroForm 字典攜帶的 XML 承載——就是這個故事裡的功能。記錄佈局是協定,PDFium 有權在讀檔之前先檢查佈局
同樣的結構出現在任何 C 函式庫給結構定版的地方:FPDF_LIBRARY_CONFIG、IPDF_JsPlatform、任何帶大小或版本標頭的回呼表。宣告你看得懂的最新佈局、完整清空、無條件寫入配對的版本,槽位則按文件與 DLL 實際能做什麼來填。將來的 fpdf_formfill.h 若出現版本 3,工作量是一個新宣告加一處改動的指派。按文件內容分支的寫法,卻要對組建與檔案的每種組合重新審視,而它出錯的那一種,通常正是沒有人開過的那一種
這裡描述的記錄處理是 PDFium Component 的一部分,支援 Delphi、Lazarus 與 C++Builder,同一份宣告同時服務 Win32 與 Win64 組建。已經為 JavaScript 驅動的 AcroForm 出貨 pdfium.v8.dll 的應用程式,檔案庫裡其餘的部分——XFA 也好、不是也好——都能透過那一個二進位檔開啟,表單環境不需要特殊處理