技术文章

在 Delphi 中用 HotPDF 把 XFA 扁平化为 AcroForm

两份表单可以带着同样的字段,行为却毫无相似之处。AcroForm 把字段保存为普通的 PDF 对象,叠在真实的页面内容之上,因此任何合规阅读器都能把它画出来。动态 XFA 表单则几乎什么都不以 PDF 形式保存:字段、版式,乃至页面几何都住在一个 XML 包里,可见的页面要到打开时才由一个只有 Adobe 广泛发行过的排版引擎生成。把这样的文件喂给网页查看器、归档渲染器或文本提取器,你拿不到表单。你拿到的是一张灰色的页面,上面写着“Please wait... If this message is not eventually replaced by the proper contents of the document, your PDF viewer may not be able to display this type of document.”。凡是处理过政府或保险文书的人,一眼就认得这一页

这个占位页并不是文件损坏。当没有 XFA 处理器在场时,格式规定的行为恰恰就是这样,而到了 2026 年,这个描述几乎适用于桌面版 Acrobat 之外的每一个查看器。所以务实的做法,是在动态表单抵达任何下游环节之前,先把它转换成普通的 AcroForm。HotPDF 是 losLab 面向 Delphi 与 C++Builder 的 PDF 库,它用代码完成这项转换,把 XML 表单重建成真实页面上的原生字段

HotPDF:并排对比 AcroForm 与动态 XFA 表单,前者的页面、控件和值都存在 PDF 里,后者在缺少 XFA 引擎时只显示一张占位页
AcroForm 把页面、控件和值都留在 PDF 内部,因此任何阅读器都能画出表单,动态 XFA 却把它们藏在 Please-wait 占位页后面

两种模型为什么无法共存

AcroForm 定义在 ISO 32000-1 §12.7。每个字段都是一个带控件注释和外观流的 PDF 对象,页面是货真价实的 PDF 内容,数据叠在其上。XFA 把这一切倒了过来:表单是一份 XML 文档,也就是存放在 AcroForm 字典 /XFA 条目中的 XDP 包,而动态表单的 PDF 页面里只有那张“Please wait”占位页,别无他物,因为真正的内容从未被序列化成 PDF。阅读器只能按其中一种模型处理文件。忽略 /XFA 条目,你看到的是空壳;尊重它却没有 XFA 引擎,你看到的是警告页。ISO 32000-2 把 XFA 从 PDF 2.0 中剔除,终结了这场争论,而这正是“趁还能转赶紧转”从边缘情形变成常规入库政策的主要原因

动手转换之前先做分类,因为不是每份 XFA 文件都会显示占位页。静态 XFA 表单在 XML 旁边附带预渲染好的 PDF 页面,所以它们到哪儿都能显示,只有在填写时才会出问题。动态表单只带占位页,不转换就无法使用。该信的是文档本身,绝不是扩展名或发送方。一份在非 Adobe 查看器里能渲染出真实内容、却仍带着 /XFA 条目的文件,是静态或混合型;显示警告页的文件则是动态型。记下每份入库文件落进了哪个桶。这两类日后的坏法不同,而当入库日志里已经写着“动态 XFA,已转换,映射 47 个字段,2 条警告”时,一张关于归档表单空白的工单几秒钟就能关掉

Delphi 中 XFA 文档的入库分类流程:在 Acrobat 之外仍能渲染真实内容的文件是静态或混合型,显示 Please-wait 页的文件则是动态型,必须转换
混合型表单靠在非 Adobe 查看器中渲染真实内容自证身份,动态表单则单凭那张占位页暴露自己

把已加载的 XFA 文档转换成原生字段

转换是针对已经在内存中的文档进行的。FlattenLoadedXFA 解析 XFA 模板及其数据包,把表单排好版,再将它重建成真实 PDF 页面上的 AcroForm 字段:

var
  Pdf: THotPDF;
  MappedCount, I: Integer;
  Warnings: TStrings;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('dynamic_xfa.pdf');
    MappedCount := Pdf.FlattenLoadedXFA(True);   // True = 字段保持可编辑
    Warnings := Pdf.XFAFlattenWarnings;
    for I := 0 to Warnings.Count - 1 do
      Log('XFA flatten warning: ' + Warnings[I]); // 未映射的元素
    Pdf.SaveLoadedDocument('native_acroform.pdf');
    Log(Format('Mapped %d fields', [MappedCount]));
  finally
    Pdf.Free;
  end;
end;

返回值和警告列表都是输出,不是调试噪音,两者都要留住。转换按其本性就会丢失信息:XFA 脚本、计算字段和动态子表单行为在 AcroForm 里都没有对应物,而 XFAFlattenWarnings 会点名每一个没能映射的模板元素。把转换后的文件连同警告列表一起归档,否则总有一天你会盯着归档副本里一个空白的合计框,却查不到任何原因记录。Editable 标志决定新字段是否仍可填写。当人们之后还要继续使用这份表单时传 True,而目标是一份冻结记录时就把值锁死

HotPDF 流水线:在 Delphi 中把已加载的动态 XFA 文档扁平化为可编辑的 AcroForm 字段,并通过 XFAFlattenWarnings 暴露未映射的脚本与计算字段
FlattenLoadedXFA 解析 XDP 包并把它们重放成可编辑的 AcroForm 字段,XFAFlattenWarnings 则记下每一个无法映射的元素

检查一次转换,一半靠肉眼,一半靠结构,两半都不能少。结构那一半很容易:确认字段数量与 MappedCount 相符。肉眼那一半才是真正能抓到实质损坏的。在桌面版 Acrobat 里打开源表单 —— 它至今仍是唯一运行 XFA 引擎的查看器 —— 再在普通阅读器里打开转换后的文件并排对照,每个模板至少比对一份填写过的样本的值和版式。XFA 引擎显示为 2026-06-11 的日期,在 AcroForm 副本里可能落成一个未经格式化的原始值,而只有你的眼睛能发现这一点

当输入本身就是一个 XDP 包时

并非每项任务都从一份已填充的 PDF 开始。有时你收到的就是单独的 XDP 包,由表单设计工具导出,或者由合作方系统移交。ApplyXFAAsAcroForm 省去了加载这一步,把该包直接应用到当前文档上:

XDPBytes := TFile.ReadAllBytes('benefit-claim.xdp');
MappedCount := Pdf.ApplyXFAAsAcroForm(XDPBytes, True);

同一组调用也能反向运行,用于那种更少见的情形:你不得不产出 XFA 而不是消费它。AddXFAPacket 附加单个具名包,比如 'xdp''config'SetXFADocument 一次调用就装入一整份单流载荷。ClearXFAPackets 清空注册以便重来,而 AddXFASignaturePacket 为那些直接对 XML 表单数据签名的工作流嵌入 XAdES 材料。在 2026 年产出 XFA 是个小众需求,几乎总是被某个拒收其他一切格式的遗留消费方逼出来的,但当合同点名要它时,这些调用能把它压缩成一个配置选择,而不是另起一套工具

“扁平化”的另一层含义

“扁平化”这个词让不少对话跑偏,因为它还指代另一项完全不同的操作:把 AcroForm 字段的外观烧进页面内容流,直到不再剩下任何交互对象。HotPDF 今天没有对应的 API,而这一点你最好现在就知道,而不是项目做到一半才发现。库给你的替代方案,是在创建字段时就在字段层面加锁,并以文档权限作后盾:

// 在创建字段时锁定值:只读文本字段
Pdf.CurrentPage.AddTextField('CaseNumber', 'BC-2026-0117',
  Rect(50, 700, 220, 720), 0, [ffReadOnly]);

// 双保险:在整份文档范围内限制表单填写
Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aes256;
Pdf.OwnerPassword := 'records-owner';
Pdf.ProtectOptions := [prPrint, prInformationCopy, prExtractContent];
// 不授予填写权限:集合中没有 prFillAnnotations

要弄清楚这换来了什么,又没换来什么。一个只读字段仍然是表单对象。它会出现在查看器的字段面板里,它的值能通过表单 API 读出来,而一个能重写文件的工具可以再次清掉只读标志。权限标志抬高了门槛,但取决于查看器是否愿意尊重它们,这一局限 ISO 32000-1 说得很直白。当监管方坚持要求归档记录中不得含有任何表单对象时,用今天的 HotPDF,诚实的答案是重建文档:把值读出来,再作为普通 TextOut 内容画到一张新页上,而不是把只读标志包装成扁平化。走权限这条路要记住的一点是,CryptKeyLength 必须在 BeginDoc 之前设置;其余内容见我们关于 AES-256 加密与权限的文章

XFA 对归档合规意味着什么

PDF/A 和 PDF/X 都直接拒绝 XFA。因此,向 ISO 19005 归档库供料的流水线必须先转换,而且顺序没有商量余地:加载、FlattenLoadedXFA、保存,然后对 AcroForm 结果运行归档生成或验证。别把转换当成合规的证明。它只修正表单模型,字体、颜色和元数据原样不动,所以在信任输出之前先用 veraPDF 验证它。表单一旦站到 AcroForm 这一侧,它的行为就有了自己一套控制手段。JavaScript 触发器、提交动作和验证脚本,见 HotPDF AcroForm 字段与动作一文

本文展示的 XFA 注册、转换和表单 API 随面向 Delphi 与 C++Builder 的 HotPDF Delphi Component 一同发布,其文档会跟进近几个版本中不断扩充的 XFA 功能集