技術文章

在 Delphi 中使用 PDFium 偵測與解開 XFA 表單

是的——PDFium 支援 XFA 表單,但有一個重要的區分。PDFium 元件可以使用標準 DLL 偵測 XFA 表單,並將其 templatedatasetsconfig 封包(packets)解開為原始 XML,且具有零執行期相依性。而執行一個「動態」的 XFA 表單——將其 XML 描述排版為動態、互動式的頁面——則是另一個問題,另外需要啟用 V8 的建置版本。社群一直在詢問 PDFium 是否「支援 XFA」,就好像這是一個單純「是或否」的功能一樣,而真實的答案是:讀取 XFA 資料與轉譯 XFA 表單是兩件截然不同的事情,且具有截然不同的相依性足跡

本文將使用 PDFium 元件(Delphi 和 C++Builder 的原生 PDFium VCL 包裝器)引導您瞭解這兩個部分。首先是偵測和靜態解開路徑——FormTypeXFA 屬性以及 GetXfaPacket* 讀取器,接著是具有 V8 限制的動態執行期路徑,最後是當部署的 DLL 無法執行引擎時,能讓您以優雅方式宣告失敗的功能探測(capability probes)

PDFium 支援 XFA 表單嗎?

請將其視為兩種功能,而非一種。靜態 XFA 解開功能是隨時可用的:支援 GetXfaPacketCountGetXfaPacketNameGetXfaPacketContent 的低階 FPDF_GetXFAPacket* 匯出函數已編譯至每個相當現代的 pdfium.dll 中,即使在關閉了 XFA 的建置版本中也是如此。這就是為什麼從表單中提取原始 templatedatasetsconfig XML 完全不帶有任何執行期相依性的原因——它是純粹的結構讀取,不涉及任何引擎。動態 XFA 則是另一種功能:實際執行表單、評估其計算,並讓 XFA 排版引擎對其進行分頁。該路徑僅存在於在 V8 JavaScript 引擎之上編譯了 XFA 支援的 PDFium 建置版本中,這也是大多數「PDFium 是否支援 XFA」討論串真正爭論的部分

標準清晰地界定了這種分離。XFA 在 Adobe XFA 3.3 (2012) 中的核心 PDF 語法之外進行了定義,且 PDF 1.7 §8.6.7 描述了 XFA 表單如何在 PDF 中承載:表單封包存在於 AcroForm 字典的 /XFA 項目下,且一個文件如果必須由 XFA 處理器進行排版才具備意義,會在目錄中設定 /NeedsRendering true。這兩個標記正是 PDFium 元件所尋找的,也是您在推導從未見過的檔案時可以依賴的依據

XFA 與 AcroForm 有何不同?

AcroForm 是經典的 PDF 表單:定位在普通 PDF 頁面固定座標上的控制項註解(widget annotations)——文字欄位、核取方塊、選項按鈕群組。您看到的頁面就是檔案中的頁面,且欄位位於其上方。XFA 則採取相反的方式。互動式表單完全以 XML 描述,且在完整的(動態)XFA 文件中,PDF 頁面實際上是一個預留位置——真實內容在開啟時由 XFA 引擎讀取 XML 範本並進行排版而產生,並隨著符合資料的大小而大而縮減。這正是 /NeedsRendering true 所宣告的:如果沒有 XFA 處理器,靜態頁面可能是空白的,或者僅包含「請在相容的檢視器中開啟」的提示

XFA 封包與 AcroForm 的關係

XFA 承載資料本身是一組具名的封包,其中三個封包攜帶了大多數整合所需的一切。template 封包是表單定義——欄位、版面配置、計算和指令碼。datasets 封包保存了實際的執行個體資料,即作為 XML 樹的已填寫欄位值。config 封包攜帶了 XFA 引擎的處理指示。雖然還存在其他封包(例如 localeSet、connectionSet、xdp 包裝器),但為了稽核表單或從中轉移資料,範本加上資料集通常就是全部的工作。因為這些資料只是存在於資料流中的 XML,讀取它絕不需要執行任何內容,這也是靜態提取無需相依性的根本原因

在 Delphi 中偵測 XFA 表單

偵測工作與每個 PDFium 元件工作一樣:設定 FileName、切換 Active := True,並檢查它是否確實開啟。Active := True 絕不會引發例外——缺少檔案、錯誤密碼或缺少 DLL 只會使 Active 保持為 False,因此該防護是強制性的。一旦文件開啟,FormType 就會告訴您它使用的是哪種表單模型。TPdfFormType 的兩個值為 XFA:ftXfaFull 是完整的動態 XFA 表單(需要轉譯的那種),而 ftXfaForeground 是 XFAF(前景子集,其中 XFA 內容繪製在其他正常的 AcroForm 頁面上,從而使靜態頁面仍具備意義)。XFA 布林值是「這是一個任一種類的 XFA 文件」的快速鍵。如果您也處理 XFAF 或 AcroForm 文件上的控制項級欄位,其機制已在PDFium 表單欄位導覽指南中介紹

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'application-form.pdf';
    Pdf.Active := True;
    if not Pdf.Active then
      Exit;                        // 損毀、加密或缺少 DLL
    case Pdf.FormType of
      ftXfaFull:
        Log('Full (dynamic) XFA form');
      ftXfaForeground:
        Log('XFAF: XFA layered over static AcroForm pages');
      ftAcroForm:
        Log('Classic AcroForm');
    else
      Log('No interactive form');
    end;
    if Pdf.XFA then
      Log('Document carries an XFA packet payload');
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

將 XFA 封包提取為原始 XML

解開是高價值、零相依性的部分。使用 XfaStaticReadable 進行防護——它確認了載入的 DLL 中解析了封包讀取器匯出函數——然後按索引走訪封包。GetXfaPacketCount 傳回表單攜帶的封包數量,GetXfaPacketName 給予每個封包名稱,而 GetXfaPacketContent 傳回原始位元組,對於知名的封包來說,這些是 UTF-8 編碼的 XML。當您已經知道需要哪個封包時,具名協助工具 GetXfaTemplateGetXfaDatasetsGetXfaConfig 會為您解析名稱並直接傳回 TBytes。對於資料遷移工作來說,真正重要的是 GetXfaDatasets:它將提交的欄位值作為 XML 文件提供給您,您可以對其進行解析並對應到您自己的綱要(schema)中,這與您在從 PDF 提取文字之後所做的後續工作相同

procedure ExtractXfaData(Pdf: TPdf; const Dir: string);
var
  I, Count: Integer;
  PacketName: string;
  Content, Datasets: TBytes;
begin
  if not Pdf.XfaStaticReadable then
    Exit;                          // 封包讀取器匯出函數不存在
  Count := Pdf.GetXfaPacketCount;
  for I := 0 to Count - 1 do
  begin
    PacketName := Pdf.GetXfaPacketName(I);
    Content := Pdf.GetXfaPacketContent(I);   // 原始 UTF-8 XML
    TFile.WriteAllBytes(
      TPath.Combine(Dir, PacketName + '.xml'), Content);
  end;

  // 或者透過名稱直接跳轉到填寫的欄位值:
  Datasets := Pdf.GetXfaDatasets;
  if Length(Datasets) > 0 then
    ParseAndMap(Datasets);
end;

為什麼動態 XFA 轉譯需要 V8 引擎?

結論先行:因為動態 XFA 表單是一個微型程式,而非靜態圖畫。範本封包中攜帶了 FormCalc 和 JavaScript 計算、驗證和事件指令碼,且 XFA 排版引擎必須執行它們才能決定頁面的外觀。PDFium 僅在 V8(Google 的 JavaScript 引擎)之上編譯了支援 XFA 的建置版本中實作了該引擎——這也是內部啟用 V8 的 DLL(pdfium32v8.dll 或 pdfium64v8.dll)比標準建置版本大得多的原因。沒有 V8,就沒有執行指令碼的環境,因此無法顯示轉譯後的頁面,而只能顯示您已經可以靜態讀取的原始 XML

選擇該建置版本具有一個您無法繞過的硬性限制:這是一個針對每個程序的單次決定。單一程序無法同時載入標準 pdfium.dll 和啟用 V8 的建置版本——它們匯出相同的符號,且 V8 保持著全域 Isolate 狀態——因此 PDFium 元件必須在函式庫首次載入之前選擇 V8 建置版本。為了使其自動化,LoadDocument 會預先掃描檔案中的 /XFA/NeedsRendering true 標記(相同的檢查以獨立的 PdfFileLooksXfa 函數公開),並在發現它們時在載入前設定 EnableV8Engine := True。一旦標準的 pdfium.dll 已經常駐記憶體,該切換就無法再生效,而這種情況正是執行期缺失路徑所回報的

begin
  // 在首次載入 PDFium 之前預覽 XFA,以便
  // 仍能選取啟用 V8 的建置版本。PdfFileLooksXfa 會掃描
  // /XFA 和 /NeedsRendering true,而無需解析整個檔案。
  if PdfFileLooksXfa('dynamic-form.pdf') then
    EnableV8Engine := True;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'dynamic-form.pdf';
    Pdf.Active := True;
    if Pdf.Active and Pdf.XFA then
    begin
      if Pdf.XfaRuntimeAvailable then
        RenderXfaPages(Pdf)          // 引擎已啟動:動態排版
      else
        ExtractXfaData(Pdf, 'C:\out'); // 遞補回靜態封包
    end;
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

優雅處理缺少的 XFA 執行階段

三個探測函數會確切告訴您擁有多少 XFA 功能,負責任的程式碼會對其進行檢查而非直接假設。XfaFeaturesAvailable 是對載入的 DLL 中是否存在 XFA/V8 協助工具匯出函數的全域檢查。XfaStaticReadable 針對每份文件確認封包讀取器是否已解析——這是解開的防護。XfaRuntimeAvailable 是強大的一個:僅當該特定文件確實啟動了 XFA 引擎時它才為 True,這意味著它是 XFA 文件、已載入 V8 建置版本且 FPDF_LoadXFA 成功。化個文件可以回報 XFA = TrueXfaRuntimeAvailable = False,只要部署的 DLL 缺少 XFA 或標準建置版本已被載入

當 XFA 文件開啟但引擎無法執行時,PDFium 元件不會冒險。它會將表單固定在安全的靜態模式下,而不是要求它無法支援的動態執行階段,並且它會引發一次 OnXfaRuntimeMissing,以便主機能夠做出反應——通常是告訴使用者使用啟用 V8 的建置版本重新啟動,同時在此期間仍提供靜態可讀的資料。連線該處理常式(handler)是乾淨的遞補與表單默默顯示空白之間的差別

procedure TForm1.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // 當 XFA 文件開啟但引擎無法執行時觸發一次:
  // 標準的 pdfium.dll 已載入,或該建置版本缺少 XFA/V8。
  ShowMessage('This is a dynamic XFA form. Restart with the ' +
    'V8-enabled build to render it; its packet data is still readable.');
end;

在您自己的產品中也要對該邊界限制保持誠實。如果您從不隨附啟用 V8 的 DLL,動態 XFA 表單將永遠無法為您的使用者轉譯——但您仍能偵測它們、解開每個封包並取出提交的資料,這對於很大一類工作(即從傳統政府和銀行表單中獲取資料,而非互動式地呈現它們)來說已經足夠了。請將較重的 V8 建置版本保留給確實需要動態、可填寫表單的情況,並讓功能探測將每份文件導向至其能實際執行的路徑。如果您的解開管線也處理嵌入的檔案,相同的唯讀理念也適用於 Delphi 中的 PDF 附件

此處介紹的 FormTypeXFAGetXfaPacketCountGetXfaPacketNameGetXfaPacketContentGetXfaTemplateGetXfaDatasetsGetXfaConfig 和功能探測 API 隨附於適用於 Delphi 和 C++Builder 的 PDFium 元件