技術文章

Delphi 的 FPDF_FORMFILLINFO 版本 2 與 DLL ABI

一份只有幾個文字欄位、毫無 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 組建已經載入

PDFium Component 示意圖:普通 pdfium.dll 與啟用 XFA 的 pdfium.v8.dll 對上 AcroForm 與 XFA 文件的四種組合——版本 1 的記錄只在 V8 組建配普通表單時、在 FPDFDOC_InitFormFillEnvironment 失敗,版本 2 的記錄四種全開
把 ABI 版本綁在文件上,意味著全行程性的 V8 二進位檔選擇,會把之後每一份普通 PDF 都變成一次失敗的環境初始化

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 組建唯一肯收的記錄

PDFium Component 中 Delphi 的 FPDF_FORMFILLINFO 記錄示意:版本 1 涵蓋 FFI_Invalidate 到 FFI_DoGoToAction 的回呼加上 m_pJsPlatform,版本 2 加入 xfa_disabled 與十七個 FFI_DisplayCaret 世代的指標;FillChar 清掉每一個位元組,NULL 槽位就是宿主不驅動 XFA 時的記載狀態
Pascal 記錄永遠是完整的版本 2 佈局,所以啟用 XFA 的組建接受它,而普通組建就只是永遠不會呼叫那些保持 NULL 的實驗性槽位

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 有權在讀檔之前先檢查佈局

PDFium Component 示意圖,分開 FPDF_FORMFILLINFO 背後的兩條軸:由記錄佈局與原生二進位檔固定的協定版本,以及功能可用性——RuntimeReady 把關 xfa_disabled、十七個版本 2 槽位與 FPDF_LoadXFA,V8FeaturesAvailable 決定 m_pJsPlatform,逐文件、逐宿主而異
版本欄位描述對方可以讀的記憶體,能力檢查決定哪些槽位會做事,把兩者摺成一個布林值,在強制最低版本的組建上就會破裂

同樣的結構出現在任何 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 也好、不是也好——都能透過那一個二進位檔開啟,表單環境不需要特殊處理