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 的一部分