PDFium Component 中的 TPdf.SetFocusedFormFieldText 會寫入目前取得焦點的表單欄位即時編輯緩衝區;對於 XFA 表單,這個緩衝區永遠不會進入會被序列化到磁碟的 datasets 資料封包,因此使用者輸入的值即使已經被你的代碼确認接受,檔案下次開啟時也會悄無声息地消失。AcroForm 欄位沒有這個問題:同一個呼叫會在焦點移開時立即提交到欄位的 /V 項目。使用者填寫 XFA 收集表單、儲存後重新開啟,却發現金額欄位再次為空白時,遇到的不是轉譯故障,而是 PDFium 引擎自身在表單數據寫入方面的能力邊界
這比最初檢测 XFA 表單,或讓表單 JavaScript 執行更加具體:問題不是“PDFium 是否支援 XFA”,也不是“如何執行 AcroForm 指令碼”,而是 SetFocusedFormFieldText 回報成功後,欄位值究竟發生了什麼。簡而言之,就 PDFium 的寫入路徑而言,AcroForm 與 XFA 不是同一種表單模型的兩種方言,而是兩個完全不同的表單模型;使用者輸入的內容與儲存實際捕获的內容之間存在截然不同的關係。混淆二者,就會讓一次單行 API 呼叫在客戶试用部署上線三周後變成支援工單。AcroForm JavaScript 文章展示了這一行呼叫,并在代碼注释中說明 AcroForm 與 XFA 的結果;本文则繼續围绕同一個 API,逐步說明內部寫入路徑、證明 XFA 寫入從未落地的資料封包證據、問題為何位於 PDFium 本身而非 Delphi 繫結,以及如何為必須在儲存後保留編輯結果的檔案自行修补 XML
SetFocusedFormFieldText 如何寫入欄位值?
TPdf.SetFocusedFormFieldText 透過模拟逐個按鍵的編輯來工作,而非直接向檔案模型寫入值。它在內部呼叫 FORM_SelectAllText 選取目前取得焦點欄位的內容,再呼叫 FORM_ReplaceSelection 用新字串涵蓋選取區,這與鍵盤執行全选并輸入時觸發的兩個操作相同。由於寫入经过 PDFium 的交互式文字編輯路徑,而非绕过這條路徑,繫結到欄位的每個按鍵、格式化或計算指令碼都會像使用者亲自輸入一样觸發,這也正是該 API 对維持 JavaScript 活跃的檢視器进行程式化填表時有用的原因。讀取端对应的是 FocusedFormFieldText,它由 FORM_GetFocusedText 提供支援,反映的正是 SetFocusedFormFieldText 刚刚寫入的同一個即時緩衝區
if Pdf.FocusedFormFieldIndex >= 0 then
begin
if Pdf.SetFocusedFormFieldText('1284.50') then
Log('Buffer now reads: ' + Pdf.FocusedFormFieldText)
else
Log('No field is focused, or it does not accept text');
end
else
Log('Focus a field first - FocusFormField or a real click');
為什麼 AcroForm 能保留值,而 XFA 會遺失?
AcroForm 文字欄位與組合框欄位能够持續儲存,是因為 PDFium 自己的表單填充环境會為你提交編輯緩衝區:欄位失去焦點的瞬間,緩衝區就會寫入欄位的 /V 項目,而每個符合正向的 PDF 阅讀器都會透過這個键讀取欄位的存儲值。TPdf.ClearFormFieldFocus 在底層呼叫 FORM_ForceToKillFocus,可以按需强制完成提交,因此程式化设置值的代碼不必等待使用者真的在界面其他位置點選。随後立即儲存,新文字已經成為檔案对象圖的一部分,TPdf.SaveAs 執行前就已存在,因為 /V 是真实欄位字典中的真实項目,而非後來附加上去的內容
Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
Pdf.ClearFormFieldFocus; // forces the /V commit now
Pdf.SaveAs('invoice-acroform.pdf');
// Reopen and confirm - this is an AcroForm document, so it holds
Pdf.Active := False;
Pdf.FileName := 'invoice-acroform.pdf';
Pdf.Active := True;
Pdf.FocusFormField(FieldIndex);
Assert(Pdf.FocusedFormFieldValue = '1284.50'); // passes
XFA 欄位編輯實際上存在哪里?
XFA 欄位沒有這样的连接。使用者輸入的文字會進入屬於 PDFium XFA 轉譯與交互層的 CPWL_Edit 緩衝區,而該層沒有任何代碼路徑會把緩衝區複製回 PDF 中存儲的 datasets 資料封包。TPdf.GetXfaDatasets 讓這個断點清晰可見:在 XFA 欄位編輯前後分別呼叫它,返回的字节完全相同,因為該方法讀取的是檔案開啟時的原始資料封包,而非你刚刚編輯的控制項即時状态。這不是缓存 bug,也不是刷新時机問題,磁碟上的 datasets 資料封包與記憶體中的編輯緩衝區本來就是兩份不同的状态,而 PDFium 的公開 API 從未将它们连接起來
var
Before, After: TBytes;
begin
Before := Pdf.GetXfaDatasets;
Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
After := Pdf.GetXfaDatasets;
// Before and After are byte-for-byte identical on an XFA document -
// the edit never touched the packet GetXfaDatasets reads from
end;
這是 PDFium Component 的 bug,還是 PDFium 的限制?
缺失的部分位於 PDFium 本身,而不在其上層的 Delphi 繫結中。PDFium 公開 API 沒有 FPDF_SetXFAPacket 用於注入更新後的資料封包,也沒有 FPDF_SaveAsXFA 用於要求 XFA 引擎在儲存前将目前 DOM 序列化回 datasets XML。作為 TPdf.SaveAs 底層導出的 FPDF_SaveAsCopy 只會写出 PDFium 已經持有的檔案对象圖;它沒有钩子要求 XFA 引擎先刷新即時状态,因為上游根本不存在這样的钩子。PDFium Component 無法补上 PDFium 自身從未實現的状态协调,而自行编写一個猜测 PDFium 內部 XFA 状态的 DOM 到 XML 序列化器,會比坦诚面对這個缺口更糟:它可能看起來有效,直到下一個 PDFium 版本改變專案外部無人看得見的內部细节
這個邊界是在建置 SetFocusedFormFieldText 的同一轮 v2.13.2 稽核中暴露出來的。FORM_ReplaceSelection 早已繫結在 DLL 匯入表中,但过去的版本從未在 Pascal 代碼中呼叫它;真正补上使用該函式的寫入路徑後,持續儲存缺口才具體到足以紀錄,而不再只是理论問題。同一轮稽核還發現了一個無关但理念相近的缺口:AcroForm JavaScript 从 v2.13.0 起一直被悄悄停用,因為 JS 平台只在 XFA 初始化分支中接入,導致带有 app.alert 或計算欄位的一般 AcroForm 檔案根本沒有指令碼引擎。那個問題可以修正,方法是讓所有檔案都接入 JS 平台,不论是否使用 XFA,并且已在同一版本中發布;本文讨论的持續儲存缺口则由於前述原因無法修正。JavaScript 修正以及围绕它的宿主否决事件,详見 使用 PDFium Component 執行 AcroForm JavaScript
在 Delphi 中應該如何處理?
對於 AcroForm 檔案,修正方法只是養成正確習慣:每当程式化设置了值,在 SaveAs 之前呼叫 ClearFormFieldFocus,或以其他方式移開焦點,不要假定之後的界面交互一定會替你觸發提交。對於可能是 AcroForm 也可能是 XFA 的檔案——通用檢視器中最常見的情况——在向呼叫方承诺儲存結果會保留之前,先檢查 FormType 或 XFA 布林值,并阅讀檢测 XFA 表單并提取 XFA 資料封包,了解完整的偵測方法,其中包括 XFAF 情况:XFA 內容疊加在一般 AcroForm 控制項之上,而這些控制項确实會遵守 /V
對於真正的動態 XFA 表單,如果編輯後的值必須在儲存後繼續存在,交互式編輯緩衝區根本不是適合的工具。持續儲存路徑应当把 GetXfaDatasets 当作基準,而非結果:檔案開啟時讀取一次,逐欄位維护自己的使用者修改紀錄——這正是你的界面已經拥有的值,因為 PDFium 事後不會把它们交還给你——再自行把這些值修补进基準 XML,并產生自己的輸出。透過你自己的代碼控制的 XML 进行寫入,可以在 CPWL_Edit 緩衝區無法做到的情况下安全地跨越儲存程序
function ExportEditedXfaValue(Pdf: TPdf; const FieldPath,
NewValue: string): TBytes;
var
DatasetsXml: string;
begin
// GetXfaDatasets ships with PDFium Component; PatchXmlNode below is
// your own helper over your own XML library, nothing PDFium provides
DatasetsXml := TEncoding.UTF8.GetString(Pdf.GetXfaDatasets);
DatasetsXml := PatchXmlNode(DatasetsXml, FieldPath, NewValue);
Result := TEncoding.UTF8.GetBytes(DatasetsXml);
end;
如何在客戶發現之前捕捉這個缺口
TPdf.SaveAs 無论 XFA 欄位值是否成功保留,都會返回 True,因為从 PDFium 的角度看儲存确实成功了:它写出了被要求写出的每一個字节。這正是此類缺陷會绕过冒烟測試并到達客戶手中的原因:不擲出例外,不紀錄錯誤,檔案也能正常開啟,只有特定的值是錯的。任何允许使用者編輯 XFA 內容的檢視器,都應該把真正重新開啟儲存檔案并比較欄位值的往返測試纳入回归套件,也可以像前面的範例那样比較編輯前後的 GetXfaDatasets,而不能只測試默認能够正常工作的 AcroForm 路徑
與其說這是應該提交至 PDFium Component 的缺陷,不如說這是需要在设计中绕開的邊界:SetFocusedFormFieldText 对兩種表單模型都准确完成了其名称所描述的工作,最終結果的差異可以清楚地追溯到 AcroForm 與 XFA 在 PDFium 端分別将這個緩衝區连接到了什麼位置。這里引用的 API、焦點與儲存原語以及資料封包讀取器,都是面向 Delphi 與 C++Builder 的 PDFium Component 的一部分