技術文章

在 Delphi 把靜態 XFA 表單轉成 AcroForm 欄位

PDFium Component 透過 FPdfDocumentXfaFlatten 單元裡的 FlattenPdfXfaToAcroForm,從 Delphi 把靜態 XFA 表單轉成普通 AcroForm 欄位。這個函式讀取 XFA 的 template 與 datasets 封包,把每個絕對定位的欄位映射成帶自己外觀流的 AcroForm 終端欄位,並把結果以增量修訂版追加,轉換後的 PDF 因此在從未支援 XFA 的檢視器裡以普通表單打開

動機通常是支援工單而不是路線圖條目。某份政府或保險表單當年用存成 XFA 的設計器製作,在某個桌面閱讀器裡正常,其他地方打開卻是空白頁,提示文件需要更新的檢視器。XFA 在 PDF 2.0(ISO 32000-2)中已被棄用,瀏覽器與行動檢視器從未實作它,PDFium 本身也只在啟用 V8 的建置下渲染 XFA。靜態 XFA 表單——版面在設計期固定的那種——在 template 裡帶著足夠的幾何,可以重建為 AcroForm 欄位,這正是這個轉換器處理的那一片

FlattenPdfXfaToAcroForm 實際產出什麼?

FlattenPdfXfaToAcroForm 產出的是追加在未改動來源副本上的位元組層級 AcroForm 修訂版,絕不改寫原始檔案。PDFium 沒有建立表單欄位的公開 API,所以元件在這件事上根本不經過 PDFium:它用自己的物件讀取器讀取作用中修訂版,以原始 PDF 物件的形式建出欄位字典、介面元件註記、外觀 XObjects 與 /Helv 字型資源,再按 ISO 32000-1 §7.5.6 的意義透過稀疏增量更新序列化

新的 /AcroForm 字典帶 /Fields、/DA 與 /DR,沒有 /XFA 條目。來源把 AcroForm 存成間接物件時,那個物件被原地改寫;內嵌字典則在 catalog 內被替換。無論哪種,作用中修訂版不再指向 XFA 封包,同時理解兩種模型的檢視器因此打開 AcroForm 欄位,而不是去排 XFA 表單的版。原始 XFA 位元組仍在更早的修訂版裡——增量更新本來就是這個意思——但目前的修訂版裡沒有任何東西參照它們

PDFium Component for Delphi 的靜態 XFA 轉 AcroForm 管線:FlattenPdfXfaToAcroForm 讀取 template 與 datasets 封包,把絕對定位欄位映射成欄位字典與介面元件,並把不帶 /XFA 條目的 AcroForm 以稀疏增量修訂版追加
FlattenPdfXfaToAcroForm 絕不碰原始位元組:轉換後的欄位、介面元件與外觀流以修訂版追加,其 /AcroForm 只是不再指向 XFA 封包
uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentXfaFlatten;

procedure ConvertXfaForm(const SourceFile, TargetFile: string);
var
  Source: TFileStream;
  Options: TPdfXfaFlattenOptions;
  OutputPdf: TBytes;
  Report: TPdfXfaFlattenReport;
  Fields: TPdfXfaFlattenFields;
begin
  Options := TPdfXfaFlattenOptions.Default;
  Options.MaxFields := 1024;   // 預設 512,允許範圍 1..4096

  Source := TFileStream.Create(SourceFile, fmOpenRead or fmShareDenyWrite);
  try
    if not FlattenPdfXfaToAcroForm(Source, Options, OutputPdf, Report, Fields) then
      raise Exception.Create('XFA conversion failed: ' + Report.ErrorText);
  finally
    Source.Free;
  end;

  TFile.WriteAllBytes(TargetFile, OutputPdf);
  Writeln(Format('%d template fields, %d bound to datasets, %d written on %d pages',
    [Report.TemplateFields, Report.DatasetBound, Report.FieldsWritten, Report.PagesWritten]));
end;

函式對任何被拒或畸形的輸入回傳 False 並填好 Report.ErrorText,離開時還原來源流位置。取消是唯一會向外傳播的例外路徑:Options.CancellationToken 觸發時,報告填好之後重新丟出 EPdfOperationCancelled。TPdfXfaFlattenReport 還帶 SourceSha256 與 OutputSha256,批次作業要證明哪份輸入產出哪份轉換檔時很方便。TPdfXfaFlattenOptions.Default 設 8 MiB 封包預算、512 個欄位與 512 MiB 輸出上限;來源不得加密

讀取 template 與 datasets 封包

PDFium Component 兩種 ISO 32000-1 §12.7.8 允許的 /XFA 條目布局都收:交替封包名稱與流參照的陣列形式,以及單一流裝整份 <xdp:xdp> 文件的單流 XDP 形式。封包流可以未壓縮,或是不帶預測器的 /FlateDecode,解壓受 MaxPacketBytes 約束

單流情況正是天真的 XML 處理會栽跟頭的地方。設計器常把 <xfa:datasets> 帶命名空間前綴寫出,而 template 沒有前綴,於是按字面標籤名切塊會找到一個封包、漏掉另一個。轉換器按本地名稱比對元素與屬性名,之前另有一項檢查確認開閉標籤按完整前綴名成對。只想查看封包而不轉換的話,用 PDFium VCL 偵測 XFA 表單並擷取其封包的指南涵蓋那條唯讀路徑

欄位值來自兩處。template 裡的 <value> 元素供應預設值,datasets 封包供應填好的資料,按欄位名稱綁定。真實表單裡欄位名稱會重複,所以綁定器比較具名 <subform> 祖先鏈與資料中的祖先路徑,取最長的無衝突匹配。兩個葉欄位在不同 subform 下共用名稱時,AcroForm 的 /T 變成 SubformPath.Name,兩個欄位轉換後仍然互不混淆

choiceList 為什麼需要顯式的 Combo 旗標?

XFA 與 PDF 對選擇欄位的預設相反,一個什麼旗標都不複製的轉換器會把每個下拉式選單變成清單方塊。帶預設 open="userControl" 或 open="onEntry" 的 XFA <choiceList> 是下拉式;不帶旗標的 PDF /FT /Ch 欄位是可捲動清單方塊,只有 Combo 旗標(ISO 32000-1 表 230 的位元位置 18,值 131072,即 PDFium 稱為 FPDF_FORMFLAG_CHOICE_COMBO 的那一位)能讓它成為下拉式方塊。所以轉換器為下拉情況設 Combo 旗標,只有 template 說 open="always" 或 open="multiSelect"——保持清單永久展開——時才留空旗標。3.181.1 之前的版本把 Combo 與 Pushbutton 的值弄反了,還在找非標準的 open="on",用它們扁平化過的表單值得重跑

PDFium Component 靜態 XFA 轉 AcroForm 轉換中的選擇欄位旗標決策:open 屬性為 userControl 或 onEntry 時設 Combo 旗標(位元 18、值 131072)產生下拉式方塊,open always 或 multiSelect 則留空旗標成為永久展開的清單方塊
沒有 Combo 旗標,XFA 下拉式選單會扁平化成可捲動清單方塊,所以轉換器讀取 open 屬性、只對下拉情況設位元 18

其餘 UI 映射遵循 ISO 32000-1 §12.7.4 的欄位旗標表:

  • textEdit、numericEdit 與 dateTimeEdit 變成 /FT /Tx;多行文字欄位帶 Multiline 旗標(/Ff 4096,表 228)與按行拆開的外觀流
  • passwordEdit 變成帶 Password 旗標(/Ff 8192)的 /FT /Tx,外觀為每個 Unicode 純量畫一個星號,而不是每個 UTF-16 代碼單元
  • checkButton 變成 /FT /Btn 核取方塊,綁定資料為 1 時值為 /Yes,否則 /Off
  • <exclGroup> 變成一個 /FT /Btn /Ff 32768 的單選父欄位(表 226),帶 /V、/DV、/Opt 與 /Kids,外加每個成員一個子介面元件,各自帶 /AS 與雙態 /AP
  • imageEdit 變成一個外觀繪出影像的按鈕;基線 JPEG 原樣嵌在 /DCTDecode 底下,PNG 則被解碼並重新編碼為 /FlateDecode 像素,PNG 帶 alpha 或 tRNS 透明時再加 /SMask

不是純可列印 ASCII 的值,包括換行,都以 <FEFF...> UTF-16BE 字串寫出,日文姓名或兩行地址因此安然過關。Fields 輸出鏡像寫下的內容,當審計軌跡很方便

procedure DumpConvertedFields(const Fields: TPdfXfaFlattenFields);
const
  KindNames: array[TPdfXfaFlattenFieldKind] of string = (
    'text', 'check', 'choice', 'image', 'numeric', 'dateTime', 'password', 'radio');
var
  I: Integer;
  F: TPdfXfaFlattenField;
begin
  for I := 0 to High(Fields) do
  begin
    F := Fields[I];
    Writeln(Format('%s.%s [%s] page %d at %.1f,%.1f size %.1f x %.1f',
      [F.SubformPath, F.Name, KindNames[F.Kind], F.PageIndex + 1, F.X, F.Y, F.W, F.H]));
    case F.Kind of
      fxfkChoice:
        Writeln(Format('  %d items, list box: %s', [Length(F.Choices), BoolToStr(F.ListBox, True)]));
      fxfkRadio:
        Writeln(Format('  %d radio widgets, value "%s"', [Length(F.RadioWidgets), F.Value]));
      fxfkImage:
        Writeln(Format('  %d image bytes', [Length(F.ImageData)]));
    else
      Writeln('  value "' + F.Value + '"');
    end;
  end;
end;

幾何、量測單位與頁面路由

XFA 幾何轉成 PDF 使用者空間的方式,是把每個欄位對它自己頁面的 MediaBox 高度翻轉,因為 XFA 從頁面頂部向下量,PDF 從底部向上量。巢狀 <subform> 元素的偏移沿 template 向下累加,每個欄位最終必須有正的寬與高,否則轉換被拒

PDFium Component for Delphi 的 XFA 轉 PDF 幾何轉換:XFA template 裡自頂邊向下量得的欄位矩形,對其自身頁面的 MediaBox 高度翻轉,進入 y 座標自底向上增長的 PDF 使用者空間
向下的 XFA template 與向上的 PDF 使用者空間,在對 MediaBox 高度的一次減法裡交會,這正是把每個轉換後介面元件放到設計位置的那一步

量測接受真實設計器會輸出的單位:裸數字與 pt,加上 in(72 點)、mm(72 / 25.4)、cm(72 / 2.54)、pc(12)、px(0.75)與 mp(0.001,毫點)。解析與地區設定無關,德文 Windows 不會把 8.5in 變成亂碼。未知的單位後綴被拒收,而不是當成點數讀——那會悄悄把欄位放錯位置

頁面路由跟隨 <pageSet>:同層的 <pageArea> 元素按宣告順序從一編號,認領嵌在其下的欄位,沒有 <pageArea> 祖先的欄位留在第一頁。每頁有自己的 /Annots 更新。被路由到 PDF 最後一頁之後的欄位是錯誤,而不是把介面元件丟上第一頁

轉換器拒收哪些 XFA 表單?

凡是必須跑 XFA 版面引擎才知道版面的東西,轉換器一律拒收。也就是說:<subform> 或 <exclGroup> 上不是 position 的 layout 屬性(流式 tb、lr-tb 及其同類,動態表單正是用它們搭的)、任何 <script> 元素,以及上面七種之外任何 UI 類型。執行期長頁的動態表單屬於另一套工具鏈;PDFium Component 的動態 XFA 重分頁與頁數一文展示了那種即時排版涉及什麼

拒收是刻意的。流式 subform 沒有固定座標,任何「轉換」都得編造位置;帶腳本的表單常常在計算總計或切換可見性,靜態 AcroForm 重現不了。產出一份看起來完整、卻悄悄丟了計算總計的 PDF,比 Report.ErrorText 裡一條清楚的錯誤更糟。XFA 3.3 規範定義的遠多於這個子集,轉換器也不假裝不是如此

寫出並驗證 AcroForm 修訂版的陷阱

打造寫入器時冒出兩個問題,對任何手工產增量更新的人都適用。第一:catalog 必須永遠在更新集合裡,即使它的位元組沒變——因為 AcroForm 是在別處改寫的間接物件。稀疏更新寫入器從它攜帶的物件導出 trailer 的 /Root,缺根物件的更新產出的修訂版,檢視器錨定不了

第二個關於驗證。文件一帶 /AcroForm,透過 PDFium 包裝載入轉換後的檔案就會觸發表單環境初始化,而測試環境裡的宿主 pdfium.dll 缺 FORM_GetTextDirection 匯出,初始化在任何欄位被檢查之前就失敗了。所以讀回檢查改用元件的物件讀取器,它解析作用中修訂版而不碰表單層

uses
  System.SysUtils, System.Classes, FPdfCompress;

function HasActiveAcroForm(const Pdf: TBytes): Boolean;
var
  Stream: TBytesStream;
  Objects: TPdfActiveObjects;
  Revision: TPdfSparseRevisionInfo;
  ErrorText: string;
begin
  Stream := TBytesStream.Create(Pdf);
  try
    if not ReadPdfActiveObjects(Stream, TPdfActiveObjectReadOptions.Default,
      Objects, Revision, ErrorText) then
      raise Exception.Create(ErrorText);
    // 作用中 catalog 必須參照 AcroForm;轉換器寫出的
    // 內嵌 AcroForm 不帶 /XFA 條目
    Result := Pos('/AcroForm', string(Revision.CatalogDictionary)) > 0;
  finally
    Stream.Free;
  end;
end;

外觀流刻意簡單:文字與選擇值用 10 點的 Helvetica,核取方塊畫描邊方塊,單選選中態畫填色內方塊。它們讓轉換後的表單處處可讀,但不是原 XFA 渲染的像素複本,template 裡定義的字型、顏色與邊框不會帶過來。如果表單必須與設計器輸出長得一樣,先用 XFA 建置渲染一次、比對過,再把生產管線切過來

FAQ

FlattenPdfXfaToAcroForm 需要 V8 或 XFA 版的 PDFium 嗎?

不需要。FlattenPdfXfaToAcroForm 用 PDFium Component 的代碼解析封包、寫出修訂版,所以標準的 pdfium.dll 就能用,也不執行任何 XFA 腳本

轉換器處理得了加密的 XFA 文件嗎?

不能直接處理。加密來源會以錯誤被拒,請先解密文件,再轉換解密後的副本

轉換後的欄位還能編輯嗎?

可以。輸出含真正的 AcroForm 文字、核取方塊、單選、選擇與按鈕欄位,帶值與外觀流,使用者可以在任何支援 AcroForm 的檢視器裡繼續填表

靜態 XFA 轉換、XFA 封包擷取與啟用 V8 的 XFA 執行環境都是PDFium Component for Delphi and C++Builder 的一部分,它為 Delphi、C++Builder 與 Lazarus 專案包裝 PDFium