技術文章

PDFium Component XFA 存檔:換行與 restoreState

PDFium Component 跑的是 v3.125.2 以後隨附的 Windows V8 執行期 pdfium.v8.dll 時,編輯過的 XFA 表單值能原封不動地撐過存檔與重開。較舊的執行期會往欄位值裡塞換行、把 emoji 砍成一個不相干的 BMP 字元、無聲跳過單一串流的 XFA 存檔,還可能吞掉失敗的最後一次寫入。倒是有一個重開症狀根本不是函式庫缺陷:根 subform 沒有 restoreState="auto" 的動態表單,會照範本重建版面

這一類 bug 回報長得都很像。客戶在 Delphi 檢視器裡填一張 XFA 請款表單,存檔、重開,哪裡就是有點不對勁。原本空著的意見欄多了一個空行,再存一次變兩個。名字裡的 emoji 重開後變成一個私用區字形。誰都收不到錯誤——這正是這類 bug 昂貴的地方:走樣要到幾星期後,才在別人的匯出檔裡現形

XFA 表單存檔再重開,會出什麼岔子?

原生 XFA 存檔路徑上有四個各自獨立的缺陷造成值走樣,而且每一個都躲在一次看起來成功的存檔後面。兩個來自序列化,一個來自單一串流的儲存版面,一個來自 PDF 寫入器本身。下表把每個症狀對到原因,以及 PDFium Component 修掉它的版本

重開後的症狀原因修正版本
空欄位裡躺著一個換行;每存一次檔值就多一個換行兩個 XFA 寫入器都在起始標籤後面插入版面換行v3.125.2,pdfium.v8.dll
U+1F642 重開後變 U+F642,或 emoji 從 form packet 消失解碼時 16 位元 wchar_t 截斷;表單序列化器過濾代理對v3.125.2,pdfium.v8.dll
單一串流 XFA 文件裡的編輯直接蒸發原生存檔拒絕這種串流版面,回傳值卻沒人理v3.125.2;註解與處理指示自 v3.126.0 起保留
檔案被截斷,存檔卻回報成功寫入器回報成功之後,最後一段緩衝寫入才失敗v3.125.2 V8 執行期;v3.125.3 一般版 pdfium.dll
三頁的動態表單重開只剩兩頁根 subform 沒有要求 restoreState="auto"表單製作的問題,不是函式庫缺陷

早先的文章結論是 XFA 欄位編輯根本沒辦法用 PDFium 持久化——對當時的執行期來說,那個結論沒錯。較新的 V8 執行期能原生保存 XFA 值,在活動表單裡做的編輯會一路進到存檔的 datasets packet,不需要您自己動手術改 packet

哪個 PDFium 執行期存得住 XFA 值?

XFA 存檔保真度取決於原生 DLL,不是 Delphi 包裝層,所以第一步是確認行程實際載入的是哪個執行期。PDFium Component 每種架構出兩個 Windows 建置:不帶 V8 與 XFA 的一般版 pdfium.dll,以及帶著 JavaScript 引擎與 XFA 表單執行期的 pdfium.v8.dll。只有 pdfium.v8.dll 跑得動 XFA 表單,所以這裡講的每一項 XFA 修正都住在它裡面,從 v3.125.2 重新建置的 Win32 與 Win64 V8 函式庫開始

最後一次寫入的修正是一般 PDF 寫入器程式碼,所以對普通文件同樣要緊。v3.125.3 重新建置了一般版 pdfium.dll 函式庫,把同一個修補帶上。原始碼相同不代表行為相同:二進位檔沒重新建出來之前,舊 DLL 就繼續帶著舊 bug

第二個陷阱藏在載入器裡。v3.125.2 之前,把 EnableV8Engine 設成 True 會讓綁定層挑預設的 pdfium.v8.dll 檔名、無視 LibraryName 裡的完整路徑。明明指著剛部署好的執行期,應用程式卻可能一直從別的資料夾載入舊複本。v3.125.2 起,帶有目錄的 LibraryName 在兩種引擎模式下都精確選中那個檔案,路徑不存在就直接失敗,不再退回另一份隨附函式庫

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // LibraryName 帶目錄就釘死這個檔案(v3.125.2 起);
  // 檔案不在就改為擲出例外,不再退回別份函式庫
{$IFDEF WIN64}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
  PDFium.EnableV8Engine := True;
  PDFium.LoadLibrary;  // 讓失敗發生在啟動時,而不是第一次存檔
end;

文件開好之後,TPdf.XFA 告訴您檔案帶有 XFA,TPdf.XfaRuntimeAvailable 告訴您載入的 DLL 真的執行得了它。若還需要分辨靜態與動態表單,TPdf.FormType 會回傳 ftXfaFull 或 ftXfaForeground;在 Delphi 裡偵測 XFA 表單與擷取 XFA packet一文把這些探測講得很細

存檔的 XFA 欄位為什麼會多出換行?

存檔的 XFA 欄位會長出換行,是因為兩個原生 XFA 寫入器——一般的 XML 元素寫入器與 form packet 序列化器——都把輸出美化排版,在起始標籤後面補一個換行。在大多數 XML 裡,這種空白只是妝飾;在 XFA 資料裡不是:datasets packet 再次被解析時,<Comments> 與 </Comments> 之間的文字就是欄位值,換行也算在內。於是空欄位重開後帶著一個 LF,每多一輪存檔重開就可能再添一個

PDFium Component 的 XFA 存檔循環示意圖:寫入器在起始標籤後補換行,重開的解析器把 Comments 標籤之間的 LF 當成欄位值讀回,每多存一次又追加一個換行,直到 v3.125.2 只移除序列化器自行合成的空白
一輪存檔重開種下第一個換行,之後每一輪再添一個——走樣要到第二代才露出全貌,原因就在這裡

最直覺的修法——載入時把值 trim 掉——是錯的。使用者會在 XFA 欄位裡打行首空格、行尾空格與蓄意的多行文字,地址區塊或固定寬度的代碼必須逐位元組無損。v3.125.2 的修正因此只移除序列化器自己繞著標籤合成的空白。使用者輸入的值、既有的文字節點與 CDATA 區段原樣通過," indented" 的縮排保得住,刻意留空的欄位繼續空著

emoji 為什麼重開後變成另一個字元?

emoji 重開會走樣,是因為 Windows 的 wchar_t 只有 16 位元寬,而有兩條解碼路徑把完整的 Unicode 純量值塞進單一 wchar_t。UTF-8 串流解碼器與解析 &#x1F642; 這類數字字元參照的解析器都這麼幹。U+1F642(微微笑臉)塞不進 16 位元,高位元就這麼掉了,出場的換成 U+F642:私用區裡的一個 code point,大多數字型把它畫成方框或什麼都不畫

form serializer 的毛病剛好相反。它一次一個 wchar_t 地過濾字元,看到兩個各自不合法的代理 code unit,就把兩個都丟了,emoji 於是從 form packet 徹底消失。v3.125.2 起,解碼器把每個純量值完整吃掉、輸出成對的代理對。輸出槽只剩一個時,它讓低位代理先留著,緩衝裡還壓著那個 unit 就不回報串流結束。被讀取區塊切開的 UTF-8 序列會帶到下一輪讀取,不會被丟棄。表單匯出器現在把合法的代理對綁在一起,數字字元參照也能產出正確的成對輸出

PDFium Component 的代理對處理示意圖:U+1F642 以 UTF-16 對 D83D DE42 到達,兩條缺陷路徑把它弄壞——16 位元 wchar_t 解碼器把純量截斷成私用區的 U+F642,form serializer 則過濾落單的代理、把 emoji 整個丟掉
Windows 的 wchar_t 只有 16 位元寬,需要代理對的純量不是丟掉高半就是從 packet 消失,直到兩條路徑都學會成對保留

Latin-1 測試資料永遠測不出這些,所以每個 XFA round-trip 測試都得至少帶一個增補平面字元

單一串流 XFA,以及沒人看見的存檔失敗

單一串流的 XFA 文件會丟掉編輯,是因為原生存檔輔助函式拒絕這種儲存版面,呼叫端又無視了失敗。ISO 32000-1 §12.7.8 允許互動表單字典的 /XFA 項目是 packet 名稱與串流構成的陣列,也可以是裝著整份 XDP 文件的單一串流。packet 陣列是常見情況,但單一串流完全合法,而 PDF 存檔照樣完成、若無其事,表單資料卻停在舊值上

v3.125.2 起,V8 執行期處理受支援的單一串流子集。它先把兩個活動 packet——datasets 與 form——匯出到暫存區並驗證,然後才替換原始 XDP 裡對應的 packet。其他 packet 與根命名空間宣告原樣保留。暫存階段一旦失敗,持久化的 XFA 串流分毫不動,文件也保住修改標記

XML 註解與處理指示要格外小心,因為內部 XML DOM 會把它們丟掉。v3.125.2 遇到它們時選擇讓存檔整個失敗,而不是無聲丟內容。v3.126.0 起改為保留:解析之前,每個註解或處理指示先換成一個標記,標記的前綴在原文任何地方都不出現。活動 packet 替換完之後,每個標記必須恰好出現一次,然後才還原原始 token、寫出串流。替換範圍外的 token 因此保住文字與順序,包括 prolog、template 與其他 packet 裡的 token

有些輸入至今仍被刻意拒收,而每次拒收都是一次明確的存檔失敗:

  • 活動的 datasets 或 form packet 裡的註解或處理指示——它們的原始位置沒辦法映射進剛匯出的內容
  • DTD 宣告與 XMLDSig 簽章——改寫 XDP 不可能讓 XML 簽章繼續有效
  • 無效的 UTF-8 或 UTF-16 編碼、不完整的標籤、無效的字元參照、未知實體與畸形的處理指示——一律拒收,不做無聲修補
PDFium Component 的單一串流 XFA 存檔管線:活動的 datasets 與 form packet 先匯出到暫存區驗證,再於原始 XDP 內替換,註解靠標記保留;暫存失敗與 DTD、XMLDSig 之類的輸入則明確拒絕存檔
暫存匯出先驗證、後替換,存檔失敗時持久化的 XFA 串流分毫不動,文件保住修改標記

單一串流的輸出是 UTF-8,保留的是 XML 內容模型,不是原始的位元組版面或編碼宣告

最後一個缺陷在 XFA 底下。原生檔案寫入器以 32 KB 區塊緩衝輸出,最後一段未滿的區塊拖到解構子裡才 flush——那時文件寫入器早已回報成功。最後一個區塊碰上磁碟滿了或 I/O 錯誤,呼叫端完全看不見。V8 執行期自 v3.125.2、一般執行期自 v3.125.3 起,最後一次 flush 列入存檔結果,XFA 修改標記也只有在真正成功之後才清除。Delphi 端的 TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean 會先寫到目標旁邊的暫存檔,存檔回傳 True 才把檔案搬到位,失敗的存檔因此不動到原本的檔案

動態 XFA 表單為什麼重開後頁數變少?

動態 XFA 表單重開後頁數變少,是因為根 subform 沒有宣告 restoreState="auto"——這是表單製作時的決定,不是 PDFium Component 的缺陷。XFA 3.3 裡,根 subform 的 restoreState 預設是 manual。在 manual 模式下,XFA 處理器只從存檔的 form packet 還原有限狀態,其餘交給作者的腳本。存下的欄位值與重複 subform 的實例數照樣回來,但執行期設定的幾何屬性不會

把這件事逼出來的案例是一張三頁表單,腳本把某個 subform 撐到 h="450pt"。存檔的 form packet 記著新高度、值與實例數;重開時,版面卻按範本高度重建,表單重新排版成兩頁。執行期沒有錯:範本從沒要求過自動還原。在根 subform 上宣告它,重開就正常了:

<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
  <subform name="form1" layout="tb" restoreState="auto">
    <pageSet>
      <pageArea name="Page1">
        <contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
        <medium stock="letter"/>
      </pageArea>
    </pageSet>
    <subform name="Details" layout="tb" w="7.5in">
      <!-- 欄位;腳本可能在執行期改 h 或新增實例 -->
    </subform>
  </subform>
</template>

範本若不歸您管,也別在檢視器裡繞著它打補丁:依賴 manual 模式的表單,指望的是自己的腳本來重建狀態。使用者打字時的即時重新分頁是另一個主題,PDFium Component 如何追蹤動態 XFA 頁數與搬家的欄位一文專門講它

在 Delphi 裡怎麼驗證 XFA 存檔?

唯一可靠的 XFA 存檔檢查,是用全新的 TPdf 實例重開存好的檔案、把存下的資料讀回來。TPdf.GetXfaDatasets 回傳的是文件裡實際儲存的 datasets packet,不是活動的 XFA 資料模型,所以存檔前呼叫它看到的是舊值;重開之後呼叫,看到的就是真正寫進去的東西。單一串流文件沒有分開命名的 packet:PDFium 把整份 XDP 回報成一個空名稱的 packet,GetXfaPacketByName('datasets') 與 GetXfaDatasets 於是都拿不到東西,退路是透過 GetXfaFormPackets 讀整條串流

uses
  System.SysUtils, PDFium, FPdfXfa;

function ReadSavedXfaData(const FileName: string): string;
var
  Pdf: TPdf;
  Packets: TXfaPacketList;
  Bytes: TBytes;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Bytes := Pdf.GetXfaDatasets;          // packet 陣列版面
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // 單一串流:一個未命名的 packet
      if Length(Packets) = 1 then
      begin
        SetLength(Bytes, Length(Packets[0].Content));
        if Length(Bytes) > 0 then
          Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
      end;
    end;
    Result := TEncoding.UTF8.GetString(Bytes);  // 存檔的 XDP 輸出是 UTF-8
  finally
    Pdf.Free;
  end;
end;

存檔例程接著提交擱置的編輯、檢查 SaveAs 的回傳值,然後比對重開讀到的值。TPdf.ClearFormFieldFocus 清掉表單焦點——PDFium 提交聚焦欄位編輯緩衝的時機正在這裡。TPdf.SetFocusedFormFieldText(const Value: WString): Boolean 能以程式填入聚焦欄位,但它依賴包裝層追蹤的焦點,而焦點是 FocusFormField 走訪 widget annotation 記下來的。動態 XFA 頁面通常一個都沒有,那裡的文字多半是透過 TPdfView 的鍵盤輸入進來的,沒有任何受追蹤欄位持有焦點時,函式回傳 False

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // 選用的腳本填值;False 表示沒有任何受追蹤欄位持有焦點
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // 提交編輯緩衝區
  if not Pdf.SaveAs(FileName) then      // 含最後一次 flush(v3.125.2 起)
    raise EPdfError.CreateFmt('Saving %s failed', [FileName]);

  Saved := ReadSavedXfaData(FileName);
  if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
    Saved) = 0 then
    raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;

這個子字串測試只當煙霧測試看待。空元素可能被序列化成 <Tag/>,資料元素上可能帶屬性,& 與 < 之外的跳脫是序列化器的自由心證。要上生產環境的檢查,請用真正的 XML 解析器載入重開的 XML,比對綁定資料元素的文字節點。這個檢查也請連跑兩次,因為換行缺陷要到第二代才露出全貌

速查:XFA 存檔保真度檢查清單

  • 跑 XFA 表單請部署 v3.125.2 以上的 pdfium.v8.dll,一般文件請部署 v3.125.3 以上的 pdfium.dll,讓最後一次寫入的修正兩邊都到位
  • LibraryName 指向完整路徑,並把 EnableV8Engine 設成 True;路徑不存在就失敗,不會改載別份複本
  • 開文件之後確認 TPdf.XFA 與 TPdf.XfaRuntimeAvailable
  • SaveAs 之前先呼叫 ClearFormFieldFocus,讓聚焦欄位完成提交
  • 永遠不要無視 SaveAs 的布林回傳值;回傳 False 時舊檔案原封不動
  • 驗證方式是用新的 TPdf 重開並讀 GetXfaDatasets,單一串流 XFA 則退回 GetXfaFormPackets
  • 用空值、行首空格、多行文字、& 與增補平面字元來測,並且連測兩代存檔
  • DTD、XMLDSig,以及單一串流 XFA 活動 packet 裡的註解,都會換來明確的存檔失敗
  • 動態表單重開後丟了執行期幾何,先檢查根 subform 有沒有 restoreState="auto",再懷疑函式庫

XFA 執行期指望宿主應用程式提供的回呼結構,見Delphi 裡的 FPDF_FORMFILLINFO 第 2 版與 XFA ABI。V8 執行期、Delphi 與 C++Builder 包裝層和檢視器控制項,都是 PDFium Component for Delphi and C++Builder 的一部分,Win32 與 Win64 兩種 Windows 執行期都包含在內