技术文章

在 Delphi 中使用 PDFium 检测和提取 XFA 表单

是的——PDFium 支持 XFA 表单,但有一个重要的划分。PDFium 组件可以检测 XFA 表单并使用标准 DLL 将其 templatedatasetsconfig 数据包提取为原始 XML,且具有零运行时依赖。运行“动态”XFA 表单(将其 XML 描述布局到活动的、交互式的页面中)是一个独立的问题,此外还需要启用 V8 的构建。社区一直在问 PDFium 是否“支持 XFA”,就好像它是一个非黑即白的功能一样,坦诚的答案是,读取 XFA 数据与渲染 XFA 表单是两件截然不同的事情,且具有非常不同的依赖足迹

本文将通过 PDFium 组件(Delphi 和 C++Builder 下围绕 PDFium 的原生 VCL 包装器)详细介绍这两个部分。首先是检测和静态提取路径——FormTypeXFA 属性和 GetXfaPacket* 读取器,然后是带有 V8 约束的动态运行时路径,最后是当部署的 DLL 无法运行该引擎时让您优雅失败的能力探测

PDFium 支持 XFA 表单吗?

将它视为两个功能,而不是一个。静态 XFA 提取始终可用:支持 GetXfaPacketCountGetXfaPacketNameGetXfaPacketContent 的底层 FPDF_GetXFAPacket* 导出已编译到每个相当现代的 pdfium.dll 中,甚至是关闭了 XFA 的构建。这就是为什么从表单中提取原始 templatedatasetsconfig XML XML 完全不携带任何运行时依赖的原因——它是纯结构化的读取,不涉及引擎。动态 XFA 是另一个功能:实际运行表单、评估其计算并让 XFA 布局引擎对其进行分页。该路径仅存在于在 V8 JavaScript 引擎之上编译了 XFA 支持的 PDFium 构建中,而这正是大多数“PDFium 是否支持 XFA”讨论真正争论的部分

标准清晰地界定了这种划分。XFA 在 Adobe XFA 3.3 (2012) 中定义在核心 PDF 语法之外,并且 PDF 1.7 §8.6.7 描述了 XFA 表单是如何在 PDF 内部承载的:表单包存在于 AcroForm 字典的 /XFA 条目下,而必须在有意义之前由 XFA 处理器进行布局的文档在目录中设置 /NeedsRendering true。这两个标记正是 PDFium 组件寻找的,也正是您在推断从未见过的文件时可以依赖的

XFA 与 AcroForm 有什么不同?

AcroForm 是经典的 PDF 表单:小部件注释(文本字段、复选框、单选按钮组)锚定在普通 PDF 页面上的固定坐标处。您看到的页面就是文件中的页面,而字段坐落在其上方。XFA 采用了相反的方法。交互式表单完全在 XML 中描述,在完整的(动态)XFA 文档中,PDF 页面实际上是一个占位符——真实内容是在打开时由 XFA 引擎通过读取 XML 模板并对其进行布局而生成的,并根据数据进行增长或收缩。这就是 /NeedsRendering true 所宣告的:没有 XFA 处理器,静态页面可能为空,或者仅带有“请在兼容的查看器中打开”的通知

XFA 有效载荷本身是一组命名的包,其中三个包承载了大多数集成所需的一切。template 包是表单定义——字段、布局、计算和脚本。datasets 包保存实际 dwellings 实例数据,即作为 XML 树填写的字段值。config 包携带 XFA 引擎的处理指令。还存在其他包(localeSet、connectionSet、xdp 包装器),但对于审计表单或将其中的数据迁移出来,模板加数据集通常就是全部工作。因为该数据只是位于流中的 XML,读取 it 永远不需要执行任何操作,这是静态提取无依赖的根本原因

在 Delphi 中检测 XFA 表单

检测以每个 PDFium 组件任务相同的方式开始:设置 FileName,翻转 Active := True,并检查它是否实际打开。Active := True 永远不会引发异常——缺失文件、错误密码或缺失 DLL 只是让 Active 保持为 False,因此该防范是必须的。一旦文档打开,FormType 会告诉您它使用哪个表单模型。两个 TPdfFormType 值是 XFA:ftXfaFull 是完整的、动态的 XFA 表单(需要渲染的那种),而 ftXfaForeground 是 XFAF(前台子集,其中 XFA 内容绘制在其他方面正常的 AcroForm 页面上方,以便静态页面仍有意义)。XFA 布尔值是“这是任一风味的 XFA 文档”的快捷方式。如果您还处理 XFAF 或 AcroForm 文档上的小部件级字段,其机制在 PDFium 表单字段导航指南 中有所涵盖

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'application-form.pdf';
    Pdf.Active := True;
    if not Pdf.Active then
      Exit;                        // 损坏、加密或 DLL 缺失
    case Pdf.FormType of
      ftXfaFull:
        Log('Full (dynamic) XFA form');
      ftXfaForeground:
        Log('XFAF: XFA layered over static AcroForm pages');
      ftAcroForm:
        Log('Classic AcroForm');
    else
      Log('No interactive form');
    end;
    if Pdf.XFA then
      Log('Document carries an XFA packet payload');
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

将 XFA 数据包提取为原始 XML

提取是高价值、零依赖的那一半。用 XfaStaticReadable 保护——它确认加载的 DLL 中解析了数据包读取器导出——然后按索引遍历包。GetXfaPacketCount 返回表单携带的包数,GetXfaPacketName 给出每个包的名称,而 GetXfaPacketContent 返回原始字节,对于众所周知的包来说它们是 UTF-8 XML。当您已经知道想要哪个包时,命名辅助方法 GetXfaTemplateGetXfaDatasetsGetXfaConfig 会为您解析名称并直接交回 TBytes。对于数据迁移任务,最重要的是 GetXfaDatasets:它向您提供提交的字段值作为一个 XML 文档,您可以解析它并映射到您自己的模式中,这与 从 PDF 中提取文本 之后要做的下流工作相同

procedure ExtractXfaData(Pdf: TPdf; const Dir: string);
var
  I, Count: Integer;
  PacketName: string;
  Content, Datasets: TBytes;
begin
  if not Pdf.XfaStaticReadable then
    Exit;                          // 数据包读取器导出不存在
  Count := Pdf.GetXfaPacketCount;
  for I := 0 to Count - 1 do
  begin
    PacketName := Pdf.GetXfaPacketName(I);
    Content := Pdf.GetXfaPacketContent(I);   // 原始 UTF-8 XML
    TFile.WriteAllBytes(
      TPath.Combine(Dir, PacketName + '.xml'), Content);
  end;

  // 或通过名称直接跳转到填充的字段值
  Datasets := Pdf.GetXfaDatasets;
  if Length(Datasets) > 0 then
    ParseAndMap(Datasets);
end;

为什么动态 XFA 渲染需要 V8 引擎?

首先说结论:因为动态 XFA 表单是一个小小的程序,而不是静态图画。模板包携带了 FormCalc 和 JavaScript 计算、验证以及事件脚本,而 XFA 布局引擎必须执行它们来决定页面长什么样。PDFium 仅在 V8(Google 的 JavaScript 引擎)之上编译了具有 XFA 支持的构建版中实现该引擎——这也是启用 V8 的 DLL(pdfium32v8.dll 或 pdfium64v8.dll)明显大于标准构建版的原因。没有 V8 就没有人来运行脚本,因此就没有渲染好的页面可展示,只有您已经可以静态读取的原始 XML

选择该构建版有一个您无法规避的硬性约束:这是每个进程一次性的决定。单个进程不能同时加载标准 pdfium.dll 和启用 V8 的构建版——它们导出相同的符号,且 V8 保持着全局隔离区状态——因此 PDFium 组件必须在库被首次加载前选择 V8 构建版。为了使该过程自动化,LoadDocument 会预先扫描文件中的 /XFA/NeedsRendering true 标记(与暴露为独立的 PdfFileLooksXfa 函数相同的检查),并在发现它们时在加载前将 EnableV8Engine := True。一旦普通的 pdfium.dll 已经驻留,该切换就无法再生效,而这种情况正是运行时缺失路径所报告的

begin
  // 在首次加载 PDFium 前窥视 XFA,以便
  // 仍能选择启用 V8 的构建。PdfFileLooksXfa 扫描
  // /XFA 和 /NeedsRendering true 而不解析整个文件
  if PdfFileLooksXfa('dynamic-form.pdf') then
    EnableV8Engine := True;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'dynamic-form.pdf';
    Pdf.Active := True;
    if Pdf.Active and Pdf.XFA then
    begin
      if Pdf.XfaRuntimeAvailable then
        RenderXfaPages(Pdf)          // 引擎处于活动状态:动态布局
      else
        ExtractXfaData(Pdf, 'C:\out'); // 回退到静态数据包
    end;
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

优雅地处理缺失的 XFA 运行时

三个探测会确切地告诉您拥有多少 XFA 功能,诚实的代码会检查它们而不是假设。XfaFeaturesAvailable 是对加载的 DLL 中是否存在 XFA/V8 辅助导出进行的全局检查。XfaStaticReadable 针对每个文档确认数据包读取器已解析——提取的防范。XfaRuntimeAvailable 是强有力的一项:仅当该特定文档实际激活了 XFA 引擎时它才为 true,这意味着它是 XFA、加载了 V8 构建版,并且 FPDF_LoadXFA 成功了。每当部署的 DLL 缺乏 XFA 或已经加载了标准构建版时,文档都可以报告 XFA = TrueXfaRuntimeAvailable = False

当 XFA 文档打开但引擎无法运行时,PDFium 组件不会冒险。它将表单固定在安全的静态模式,而不是请求它无法兑现的动态运行时,并且它会引发一次 OnXfaRuntimeMissing,以便宿主做出反应——通常是通过告诉用户重启并使用启用 V8 的构建版,同时在此期间仍提供静态可读的数据。连接该处理程序是干净的回退与静默显示为空白的表单之间的区别

procedure TForm1.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // 在 XFA 文档打开但引擎无法运行时触发一次:
  // 普通的 pdfium.dll 已经加载,或构建版缺乏 XFA/V8
  ShowMessage('This is a dynamic XFA form. Restart with the ' +
    'V8-enabled build to render it; its packet data is still readable.');
end;

在您自己的产品中也要坦诚面对这一边界。如果您从未发运启用 V8 的 DLL,动态 XFA 表单将永远无法为您的用户渲染——但您仍然可以检测它们、提取每个数据包并拉出提交的数据,这对于主要旨在从遗留的政府和银行表单中获取数据(而不是进行交互式展示)的大量任务来说已经足够了。为真正需要活动的、可填充表单的场景保留更重的 V8 构建版,并让功能探测将每个文档路由到它实际能走的路径。如果您的提取管道还处理嵌入文件,相同的只读哲学也适用于 Delphi 中的 PDF 附件

这里展示的 FormTypeXFAGetXfaPacketCountGetXfaPacketNameGetXfaPacketContentGetXfaTemplateGetXfaDatasetsGetXfaConfig 和功能探测 API 都是适用于 Delphi 和 C++Builder 的 PDFium Component 的一部分