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 包里消失 | 解码里 16 位 wchar_t 截断;form 序列化器过滤代理对 | 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 包,不用你在自己这边动包
哪个 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 包一文把这些探测讲得仔细
保存的 XFA 字段为什么多出换行?
保存的 XFA 字段多出换行,是因为两个原生 XFA 写出器——通用 XML 元素写出器和 form 包序列化器——都在起始标签之后用换行美化输出。多数 XML 里这点空白纯装饰。XFA 数据里不是:datasets 包重新解析时,<Comments> 与 </Comments> 之间的文本就是字段值,换行也算。于是空字段重开时揣着一个 LF,每多一轮保存重开就可能再添一个
想当然的修法——加载时修剪值——是错的。用户会往 XFA 字段里敲前导空格、尾随空格和刻意的多行文本,地址块或定宽代码必须逐字节存活。v3.125.2 的修复因此只移除序列化器自己在标签周围合成的空白。用户值、既有文本节点和 CDATA 段原样通过," indented" 保持缩进,刻意留空的字段保持为空
emoji 为什么回来成了另一个字符?
emoji 回来走样,是因为 Windows 的 wchar_t 只有 16 位宽,而两条解码路径把完整的 Unicode 标量值塞进单个 wchar_t。UTF-8 流解码器和解析 🙂 这类数字字符引用的解析器都这么干。U+1F642,那张微笑脸,16 位装不下,高位掉落,出现的成了 U+F642:私有使用区里的一个码点,大多数字体把它画成方框或什么都不画
form 序列化器的毛病正好相反。它逐个 wchar_t 过滤字符,看见两个各自非法的代理码元,把两个都丢了,emoji 于是从 form 包里整个消失。v3.125.2 里,解码器完整消费每个标量值、发出正经的代理对。只剩一个输出槽位时,它把低代理挂着不报流结束,等那个码元仍在缓冲。被读取块劈开的 UTF-8 序列会带到下一次读取,而不是被丢弃。form 导出器如今把合法代理对绑在一起,数字字符引用同样产出正确的对
Latin-1 测试数据永远撞不上这些,所以每个 XFA 往返测试至少要带一个增补平面字符
单流 XFA 与没人看见的保存失败
单流 XFA 文档丢编辑,是因为原生保存辅助拒绝那种存储布局、而调用方无视失败。ISO 32000-1 §12.7.8 允许交互式表单字典的 /XFA 条目是包名与流的数组,也可以是装着整个 XDP 文档的单流。包数组是常见情形,但单流完全合法,而 PDF 保存若无其事地完成了,表单数据停在旧值上
从 v3.125.2 起,V8 运行时处理受支持的单流子集。它先把两个活动包 datasets 和 form 导出到暂存区并校验,然后才替换原 XDP 里对应的包。其余包和根命名空间声明保留。暂存失败时,持久的 XFA 流分毫不动,文档保留修改标记
XML 注释和处理指令要额外小心,因为内部 XML DOM 会丢掉它们。v3.125.2 里它们的存在让保存干脆失败,而不是无声丢内容。v3.126.0 保留它们:解析之前,每条注释或处理指令被换成一个标记,标记由原文中任何地方都不出现的前缀构成。活动包替换完之后,每个标记必须恰好出现一次,才恢复原 token、写出流。被替换包之外的 token 因此保住文本与顺序,包括 prolog、template 和其他包里的 token
有些输入仍然被刻意拒绝,每次拒绝都是一次显式的保存失败:
- 活动
datasets或form包内部的注释或处理指令,因为它们原位置无法映射进新导出的内容 - DTD 声明和 XMLDSig 签名,因为重写 XDP 保不住 XML 签名的有效性
- 无效的 UTF-8 或 UTF-16 编码、未闭合标签、无效字符引用、未知实体和畸形处理指令——被拒收,而不是悄悄修好
单流输出是 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 包恢复有限状态,其余交给作者的脚本。保存的字段值和重复 subform 实例数照样回来,运行时设置的几何属性则不
暴露这件事的是一张三页表单,脚本把一个 subform 长到 h="450pt"。保存的 form 包里装着新高度、值和实例数。重开时布局却按模板高度重建,表单回流成两页。运行时没做错:模板从没要求自动恢复。在根 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 包,不是活动的 XFA 数据模型,所以保存之前调它看到的是旧值。重开之后,看到的就是写进去的东西。单流文档没有单独命名的包:PDFium 把整个 XDP 报成一个空名包,于是 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; // 包数组布局
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // 单流:一个未命名包
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 注释)跟踪的焦点。动态 XFA 页面通常一个都没有,所以那边文字通常经 TPdfView 的键盘输入到达,没有受跟踪字段持焦点时函数返回 False
function XmlText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [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,普通pdfium.dll用 v3.125.3 或更新,最终写入修复这才两边都在 LibraryName指向完整路径并把EnableV8Engine设为 True;路径缺失时失败,而不是加载另一份副本- 打开文档后确认
TPdf.XFA和TPdf.XfaRuntimeAvailable SaveAs之前先调ClearFormFieldFocus,焦点字段这才被提交- 永远别无视
SaveAs的布尔结果;False 时旧文件原地不动 - 验证方式:在新的
TPdf里重开并读GetXfaDatasets,单流 XFA 回退到GetXfaFormPackets - 用空值、前导空格、多行文本、
&和一个增补平面字符测,跨两代保存 - DTD、XMLDSig 和单流 XFA 活动包内的注释,预期得到显式的保存失败
- 动态表单重开丢了运行时几何,先查根 subform 有没有
restoreState="auto",再怀疑库
XFA 运行时指望宿主应用提供的回调结构,见Delphi 里的 FPDF_FORMFILLINFO version 2 与 XFA ABI。V8 运行时、Delphi 与 C++Builder 包装层和查看器控件都属PDFium Component for Delphi and C++Builder,含 Win32 与 Win64 两套 Windows 运行时