技術文章

Delphi 的 FPDF_FORMFILLINFO 版本 2 與 DLL ABI

PDFium Component 現在為它初始化的每一個表單填寫環境,都把 FPDF_FORMFILLINFO.version 設為 2,因為原生 PDFium 組建接受的版本是那個組建的屬性,不是被開啟文件的屬性。啟用 XFA 的 pdfium.v8.dll 會直接拒收版本 1,所以一份透過它開啟的普通 AcroForm PDF,過去會在 FPDFDOC_InitFormFillEnvironment 裡失敗,而整份文件裡連 XFA 的影子都沒有。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 組建已經在行程裡

PDFium Component 中普通 pdfium.dll 與啟用 XFA 的 pdfium.v8.dll 對上 AcroForm 與 XFA 文件四種組合的示意:版本 1 的記錄只弄壞了 V8 組建配普通表單那一格,在 FPDFDOC_InitFormFillEnvironment 丟出 EPdfError;修正後的版本 2 記錄四種全都開得起來
一個條件判斷把 ABI 版本綁在文件上,於是全行程性的 V8 二進位檔選擇,就把之後每一份普通 PDF 都變成一次失敗的環境初始化

FPDF_FORMFILLINFO 裡的版本欄位到底承諾了什麼?

FPDF_FORMFILLINFO.version 告訴 PDFium 它可以讀這筆記錄的哪些欄位,而公開標頭 fpdf_formfill.h 把可接受的值綁在函式庫怎麼編譯上,不是綁在文件上。用白話轉述,這份合約有三個部分。版本 1 涵蓋從 FFI_Invalidate 到 FFI_DoGoToAction 的穩定回呼,加上 m_pJsPlatform 指標。不含 XFA 模組的組建接受 1 或 2,而給 2 時它也會呼叫那些額外的實驗性回呼。含 XFA 模組的組建要求 2,沒有例外,而標頭把這個要求講了兩次,好像預期大家會漏看。合約裡沒有任何地方提到文件。版本是對您配置的那筆記錄的陳述:給 2,您就是在承諾 m_pJsPlatform 之後的記憶體存在,而且裡面放的是有效的函式指標或 NULL

版本 2 那一區就是所有 XFA 機制住的地方。它從 xfa_disabled 開始——一個標頭描述為「版本 2 以下忽略、只有在編入了 XFA 模組時才有意義」的 FPDF_BOOL——接著是十七個函式指標,從 FFI_DisplayCaret 到 FFI_DoURIActionWithKeyboardModifier。它們每一個都被記載為 XFA 必需,否則就該設成 NULL。這句話就是整個修正的關鍵。NULL 對那些欄位不是錯誤狀態;它是「宿主沒有在驅動 XFA」的記載狀態。一筆用 FillChar 清乾淨、再標成版本 2 的記錄,在不含 XFA 的組建上滿足合約的程度與版本 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 的實驗性欄位

舊的選擇方式把 ABI 綁在文件上

缺陷就是一個單獨看很合理的條件判斷。TPdf.InitializeFormFill 從三件事算出一個 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;

// ... 而 runtime 缺失的那個分支又把它釘死一次
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

拿著標頭讀這段程式,失敗就一目了然。RuntimeReady 對每一份普通 AcroForm 文件都是 false,所以每一份普通文件都宣告版本 1。在 pdfium.dll 上這沒問題。在 pdfium.v8.dll——也就是啟用 XFA 的那個組建——上,PDFium 檢查這個欄位,發現它低於要求的 2,於是回傳一個空的 FPDF_FORMHANDLE,再由 CheckPdf 變成上面那個例外。舊程式碼的意圖是防禦性的:保持版本 1,好讓 XFA 組建永遠不會去讀沒被指派的版本 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;

  // 完整的版本 2 記錄已在上方配置並清空。PDFium 接受
  // 不含 XFA 的版本 2,並且在每一個啟用 XFA 的組建裡
  // 都要求它,包括本文沒有 XFA 表單時也一樣。
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady 只守 XFA 回呼與 xfa_disabled,永遠不守版本。
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

RuntimeReady 還是該管什麼:回呼與 xfa_disabled

RuntimeReady 保住它作為 XFA 行為守門人的工作;它只是不再碰記錄佈局。版本 1 的回呼——FFI_Invalidate、FFI_SetTimer、FFI_GetPage、FFI_DoURIAction、FFI_DoGoToAction 以及那一整塊其餘的部分——一律無條件接上,因為 AcroForm 與 XFA 都靠它們。十七個版本 2 指標只在 RuntimeReady 分支裡被指派,連同 xfa_disabled := 0。當文件是 XFA 而 runtime 不在時,記錄停在版本 2、xfa_disabled 為 1、版本 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
    // Runtime 不可用:保持版本 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;

自己寫繫結時,那段裡有兩個細節很容易搞錯。FXfaPageCountOverride 會在任何事發生之前就重設為哨兵值 -1,所以 PageCount 會退回靜態頁樹,直到 FFI_PageEvent 回報重新分頁為止;那裡放零會默不作聲地宣稱這是一份空文件。而版本 2 的每一個回呼都是靜態 cdecl 常式,會從記錄裡找回所屬的 TPdf,並在返回 PDFium 之前吞掉任何 Pascal 例外——這就是在 Delphi 中強化 PDFium ABI 的筆記針對 FFI_OpenFile 講清楚的紀律。版本改動完全沒有放寬這兩條規則中的任何一條

DLL 沒有 XFA 模組時,版本 2 安全嗎?

安全,而理由在記錄裡,不在函式庫的任何承諾裡。在不含 XFA 的組建上,標頭說版本 2 會讓那些實驗性回呼也被呼叫,所以問題是 PDFium 去看的時候找到什麼。TPdfFormFillInfo 是一個 packed 記錄,它的 Info 成員是完整的 FPDF_FORMFILLINFO、包含每一個版本 2 欄位,而 InitializeFormFill 在動到任何一個位元組之前先用 FillChar 把整筆清乾淨。所以在普通的 pdfium.dll 配普通文件時,函式庫看到的是版本 2、xfa_disabled 已設起、每一個實驗性欄位都是 NULL,而那正是標頭對「沒有實作 XFA 的宿主」所規定的狀態。沒有被截短的記錄讓函式庫讀過界,因為這筆記錄從頭就不比版本 2 短。舊的邏輯防的是一個 Pascal 宣告早就已經消除掉的佈局不符

值得誠實講明的界線,是這筆記錄涵蓋不到的那一條。普通文件上的版本 2 並不會打開 JavaScript、XFA 腳本,或那些回呼背後的任何宿主事件。m_pJsPlatform 只在 V8FeaturesAvailable 為 true 時才接上,XFA 除非 RuntimeReady 曾為 true 否則一直停用,而 TPdf.XFA 仍舊照 FPDF_GetFormType 回報表單型別,不管環境談成了什麼。想知道動態 XFA 到底會不會算繪出來的宿主,應該在 Active 變 true 之後繼續讀 XfaRuntimeAvailable,像偵測 XFA 表單與抽取 XFA 封包的筆記建議的那樣,而不是從版本欄位推論任何東西

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // 當文件是 XFA、但載入的 pdfium.dll 跑不了引擎時,
  // 由 InitializeFormFill 觸發。表單環境仍然會開起來,
  // 因為版本 2 兩種情況都傳了;只有 XFA runtime 是關的。
  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 這個情況剛好兩者都需要,但只要某個組建開始強制最低版本,這種壓縮就會對每一份不需要該功能的文件失效。XFA 表單——ISO 32000-1 §12.7.8 描述為與 AcroForm 字典並存的 XML 內容——在這裡是功能;記錄佈局才是協定,而 PDFium 有權在看檔案之前就堅持那個佈局。同樣的形狀出現在任何會為自己的結構編版本的 C 函式庫上:檢視器資訊區塊、算繪選項記錄、平台回呼表。安全的模式就是修正後的 InitializeFormFill 所遵循的那一套:宣告您所理解的最新佈局、把它徹底清空、無條件把版本設成與該佈局相符,然後讓能力檢查決定要填哪些欄位。如果將來的 PDFium 標頭加了版本 3,要改的是那個宣告與那一次指派,不是一個取決於文件的分支——那個分支遲早會在某個沒人測過的組合上出錯

PDFium Component 中把 FPDF_FORMFILLINFO 背後兩條軸分開的示意:協定版本由記錄佈局與原生二進位檔決定;功能可用性則由 RuntimeReady 守著 xfa_disabled、十七個版本 2 欄位、FPDF_LoadXFA 與 m_pJsPlatform,隨文件與宿主而異
版本欄位描述的是對方可以讀的記憶體,能力檢查決定哪些欄位會真的起作用,而把兩者壓成一個布林值,就會在強制最低版本的那個組建上壞掉

修正後的表單填寫初始化隨 PDFium Component(給 Delphi、Lazarus 與 C++Builder)出貨,而且在 Win32 與 Win64 上同樣適用,因為兩個組建共用同一份記錄宣告。如果您的應用程式已經為了 JavaScript 驅動的 AcroForm 而選擇 pdfium.v8.dll,這就是那個讓它能用同一個二進位檔開啟您其餘 PDF 館藏、不必再對表單環境做特例處理的改動