技术文章

在 Delphi 中向已加载的 PDF 添加 AcroForm 字段

你手上有第三方发票模板,或是一份多年前由某个如今谁也找不到的软件生成的归档合同,现在要求把它变成交互式文档:在角落放一个签名框,补几个文本字段,把原本平面的检查清单改成真正的复选框。难点在于,你不是从零开始生成这个 PDF。它已经存在,已经带有页面、内容流和你无法控制的字体,而你需要在不重建对象图的前提下,把 AcroForm 小部件嫁接到这份对象图上。这与在新文档上创建表单完全不是一回事,最容易让人踩坑的部分在生成时根本看不见,直到你用查看器打开结果,才发现刚写进去的字段在页面上完全消失

HotPDF 是 Delphi 和 C++Builder 的原生 VCL PDF 组件,从 v2.247.0 开始,它专门公开了一组方法来处理这件事:直接在通过 LoadFromFile 加载的文档上构建全部六种标准字段类型。本文会说明这些方法实际做了什么、它们构造了哪些 ISO 32000-1 字典,以及少了哪个标志位时,整个流程会悄无声息地生成一个看起来空空如也的文件

为什么已加载文档的字段创建必须走独立代码路径

当你从零创建 PDF 时,HotPDF 掌控着完整的对象模型。每一页都是可写的 THPDFPage 包装器,而通过 AddTextField 添加文本字段时,会把新小部件连到该页的注释对象、页面对象以及表单字段集合上,然后利用文档的字体资源生成外观流。外观流就是小部件可见的表面,也就是边框、轮廓和默认文字,最终都会以 PDF 绘图操作符的形式绘制出来,由查看器逐字照着渲染

已加载文档不会给你这些脚手架。页面是以原始字典形式读入的;没有可写的 THPDFPage 包装器可供挂接小部件,更关键的是,也没有现成的字体资源管线来绘制外观流。因此,已加载路径必须改走另一条路线。它会把字段字典直接写入解析后的对象图,并通过从零开始的页面索引定位页面,而不是通过页面对象定位。字段类型和标志位与从零创建路径完全一致,所以无论哪条路径,Text 字段仍然是 Text 字段;变化的是底层布线方式,以及最关键的,小部件表面究竟如何被绘制出来

/NeedAppearances 在这里不是可选项

这是决定你成果能否显示出来的唯一关键点。因为已加载路径不会生成外观流,所以新添加的小部件交给查看器时没有 /AP 项:也就是一个没有描述其可见表面的字段。很多查看器在面对既没有外观、也没有指令要求它自行生成外观的小部件时,会什么都不画。字段确实在文件里,结构上也合法,表单填充工具也能找到它,但对人眼来说它完全不可见

补救通道写在 ISO 32000-1 §12.7.3 中:AcroForm 字典携带一个 /NeedAppearances 布尔值,当它为 true 时,符合规范的阅读器必须根据每个字段的 /DA(默认外观)字符串和值,自行构建缺失的外观流。HotPDF 会替你设置这一项。第一次向已加载文档添加任意字段时,EnsureLoadedAcroForm 会被执行:如果 catalog 没有 /AcroForm,它就创建一个;如果没有 /Fields 数组,它也会创建;并且强制把 /NeedAppearances true 设好。你不会直接调用它,但知道它存在,就能解释为什么会有这样的行为。这也顺带说明了一个很值得明说的部署注意点:少数极简或不完全符合规范的查看器会忽略 /NeedAppearances,于是仍然什么都不显示。对主流阅读器来说,这个标志位能发挥作用;但如果你的受众使用的是不常见的嵌入式渲染器,承诺之前先去那里做实测

添加六种字段类型

所有方法的形状都一样。你需要传入从零开始的页面索引、字段矩形的四个角在 PDF user-space 坐标中的位置、字段名,以及该字段类型所需的额外参数。这个矩形采用 X1, Y1, X2, Y2,PDF 的原点位于页面左下角,所以 Y 值越大,位置越靠上;这是文件格式的坐标约定,不是屏幕左上角原点的习惯,弄反它是仅次于忘记设置该标志位的第二大常见错误。每次调用都会返回新字段从零开始的索引;如果页面索引越界,或者页面对象无法解析,则返回 -1

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract.pdf') <= 0 then Exit;

    // Text field: name, initial value, max length (0 = unlimited)
    Idx := Pdf.AddLoadedTextField(0, 72, 680, 320, 700, 'FullName', '', 0);

    // CheckBox: export value, initial checked state
    Pdf.AddLoadedCheckBox(0, 72, 640, 90, 658, 'AgreeTerms', 'Yes', False);

    // Signature field: just a name and a rectangle
    Pdf.AddLoadedSignatureField(0, 360, 72, 540, 132, 'ApproverSig');

    if Idx >= 0 then
      Pdf.SaveLoadedDocument('contract-interactive.pdf');
  finally
    Pdf.Free;
  end;
end;

文本字段的第三和第四个字符串参数分别是字段名以及其初始 /V 值;整数参数是 /MaxLen,只有大于零时才会写入。HotPDF 为所有可编辑字段提供默认外观字符串 /Helv 12 Tf 0 0 0 rg,这正是会遵从 /NeedAppearances 的查看器用来决定渲染值时应使用哪种字体和颜色的依据。复选框会接收一个导出值,也就是勾选后表单提交时发送的字符串,以及一个表示初始状态的布尔值;在内部,它会写入对应的 /V/AS/DV 名称项,使开关状态在文件刚打开时就保持一致。若导出值为空,则默认使用 Yes,也就是约定俗成的复选框“选中”名称

Choice 字段与 /Ff 标志位

ComboBox 和 ListBox 都属于 choice 字段,在 ISO 32000-1 §12.7.4 中字段类型为 /Ch。下拉框和可滚动列表之间的区别只体现在字段标志整数 /Ff 的一个 bit 上:第 18 位,也就是 Combo 标志,值为 $40000。HotPDF 会为 AddLoadedComboBox 设置这个 bit,而在 AddLoadedListBox 中保持它未设置;除此之外,两者完全相同,并且都把可选项作为字符串开放数组写入 /Opt

// Dropdown (Combo flag set internally) with an initial selection
Pdf.AddLoadedComboBox(0, 72, 600, 300, 620, 'Country', 'Canada',
  ['United States', 'Canada', 'Mexico']);

// Scrolling list, no initial value
Pdf.AddLoadedListBox(0, 72, 520, 300, 590, 'Priority', '',
  ['Low', 'Normal', 'High']);

// Push button with a caption drawn through /MK
Pdf.AddLoadedPushButton(0, 360, 600, 480, 626, 'SubmitBtn', 'Submit');

关于选项列表,还有两点要说明。HotPDF 会把每个 /Opt 项写成普通字符串,这意味着导出值和显示标签是同一段文本。ISO 32000-1 §12.7.4.4 也允许使用双元素 [export display] 形式,在需要“提交值”和“显示文本”不一致时很有用;而已加载创建方法采用的是更简单的单字符串形式,所以如果你需要分离导出值和显示值,就得在生成后的字典上自己动手设置。并且,你作为字段当前选择传入的值,最好就是你提供的选项之一,因为查看器会拿它去和列表做匹配

PushButton 是另一个由标志位驱动的情况:字段类型为 /Btn,再加上第 17 位 PushButton 标志,值为 $10000。正是这个 bit 把可点击按钮和复选框区分开来,后者同样也是 /Btn 字段,但没有这个标志。你传入的标题会被写入外观特征字典 /MK 中,作为普通标题 /CA。这里也要坦白一下范围边界:这个按钮会连同标签和矩形一起被创建出来,但已加载创建方法不会为它附加动作,所以单独看,它只是一个外观正确但点击后什么都不做的按钮。要连接 submit、reset 或 JavaScript 动作,是另一层工作;对于从零生成那一侧,“字段加动作”的工作流可以参见 在 Delphi 中构建 AcroForm 字段和动作,那篇文章正好能拿来对比已加载路径刻意留空了哪些部分

所有字段共享的字典

在全部六个方法的底层,有一个共享构建器负责构造 widget annotation,并把它登记到两个位置。它会写入 /Type /Annot/Subtype /Widget,写入来自四个坐标的 /Rect 数组,写入注释标志 /F 4,其中设置了 Print 位,这样字段既会显示在屏幕上,也会出现在纸面输出中;还会写入字段名 /T、字段类型 /FT、标志位 /Ff,以及指向页面对象的 /P 回指。随后,它会把新字段追加到 AcroForm 的 /Fields 数组,以及该页的 /Annots 数组中,并在过程中解析间接引用,确保扩展的是真正的数组,而不是把小部件孤零零地挂出去

这种双重登记很重要,因为只存在于两个列表之一的小部件,会以一种很隐蔽的方式损坏。一个出现在 /Fields 中、却缺少页面 /Annots 项的字段,会被表单逻辑知道却永远不会被绘制;反过来则会被绘制出来,但表单逻辑并不认识它。HotPDF 在每次添加时都会同时保持这两边同步,而这种记账工作如果你要自己对着规范手工完成,就必须一丝不差

几个需要坦诚说明的限制

在你基于这套能力设计工作流之前,先把预期说清楚。扁平化并重新生成的显示行为,取决于查看器是否遵守 /NeedAppearances,这覆盖了 Acrobat、现代浏览器的 PDF 引擎以及常见桌面阅读器,但并不能对所有渲染器做绝对保证。如果你必须产出一个在所有地方都以完全相同方式显示字段的文件,包括那些忽略该标志位的查看器,那么你就已经进入外观流的范畴,而从零生成、并会替你绘制 /AP 的那条路径会更合适。签名字段也是同理:这里创建出来的是一个待签名的空签名小部件;放置字段,并不等于已经应用了加密签名

如果你不是要继续往文档里加东西,而是要修改已经存在的内容,那么相关操作就是表单扁平化,也就是把交互字段重新烘焙回静态页面内容,使字段值变成永久且不可编辑的页面内容;这一往一返的过程,包括如何处理带 XFA 的表单,在 在 Delphi 中扁平化 XFA 和 AcroForm 字段 中有讨论。添加字段和扁平化字段,是同一生命周期的两个端点:本文讲的是如何把交互性加到原本没有交互性的文档上,而扁平化讲的是在表单完成使命之后,如何再把这种交互性拿掉

本文展示的已加载文档表单 API,作为标准 HotPDF Component 的一部分提供给 Delphi 和 C++Builder,同时也附带字段标志位、外观处理方式以及其余 AcroForm 模型的完整参考资料