技术文章

用 Delphi 给已加载的 PDF 设置表单字段值

HotPDF Delphi Component 通过 THotPDF.SetFormFieldValue 填充已加载 PDF 上已有的 AcroForm 字段,寻址方式可以是 0 基的字段索引,也可以是全限定字段名。写下新的 /V 条目是容易的那部分;让这个调用在真实世界的表单单据上可靠的是,同一个方法还会保持三处状态一致,而这些状态不出问题之前是看不见的:字段解码后的身份,好让一个非 ASCII 名字至少能被找到;复选框和单选 widget 上的 /AS 外观状态;以及选择字段上的 /I 选中索引数组。可见的外观流是另一步,通过 EnsureLoadedFieldAppearanceStream 显式完成

场景很平常:客户发来他们自己的表单,一份税务申报、一份保险索赔、一张别人多年前在 Acrobat 里做的采购单,而你的 Delphi 应用必须从数据库填好它并交回一个到处都能正常打开的文件。你完全控制不了这份表单是怎么制作出来的。字段名可能是 UTF-16 编码的,复选框的导出值可能是 2 而不是 Yes,组合框可能用 [export display] 选项对。这些细节每一条在 ISO 32000-1 里都有规则,而每一条规则现在都由 SetFormFieldValue 替你处理。本文讲的是它做了什么、为什么,以及它到哪里为止。至于创建还不存在的字段这个姊妹问题,见在 Delphi 里给已加载的 PDF 添加 AcroForm 字段

为什么 SetFormFieldValue 找不到非 ASCII 名字的字段?

在 v2.752.1 之前,答案是编码:字段在文件里以十六进制 UTF-16BE 名字存在,而名字缓存存的是十六进制拼写而不是文本。ISO 32000-1 §12.7.3.1 把部分字段名 /T 定义为一个 text string,而 §7.9.2.2 说 text string 可以是带前导 FE FF 字节序标记的 UTF-16BE。制作工具按 §7.3.4.3 例行把这类名字序列化成十六进制字符串,所以一个叫 Straße 的字段到手时是 <FEFF005300740072006100DF0065>。在 HotPDF 内部,只要 IsHexadecimal 被置位,THPDFStringObject.Value 装的就是原始十六进制文本,这对原始字典的无损往返正是你要的,而作为查找键则正好是你不要的。HPDFLoadedFormTextName 把这两件事分开了。构建关系缓存时,每个 /T 值都过一遍它:如果字符串对象是十六进制的,HPDFHexToBytes 还原出字节序列;如果字节以 FE FF 开头且长度是偶数,载荷就按 UTF-16BE 解码并重新编码成 UTF-8;结果随后用句点与父名拼起来,形成 §12.7.3.1 描述的全限定名,于是一个叫 City 的子字段挂在叫 Address 的父字段下会被注册成 Address.City。缓存键被规范成小写,所以 SetFormFieldValue('address.city', ...) 也能成功;这是超出标准的一项便利,因为规范把名字当作大小写敏感。关键是,变的只有缓存键。字段字典里的 /T 对象保持它的十六进制编码,所以保存文档不会重写一个你只是填过内容的字段的身份

HotPDF 如何解析非 ASCII 的 AcroForm 名字示意图:HPDFHexToBytes 还原十六进制 /T 字符串背后的 UTF-16BE 载荷,FE FF 字节序标记被解码并重新编码成 UTF-8,限定名与父名拼接,于是 Applicant.FullName 和一个叫 Straße 的字段都能落进查找缓存
变的只有缓存键:字段字典保留它的十六进制编码,查找时规范成小写是超出标准的一项便利,而保存文档永远不会重写一个你只是填过内容的字段的身份
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // 限定名从 UTF-16BE 的 /T 字符串解码出来并用
    // 句点拼接,所以嵌套名字和非 ASCII 名字都能解析
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // 非 Latin-1 的值以 FEFF 前缀的 UTF-16BE 十六进制形式传递,
    // 并被写成 PDF 十六进制字符串
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValue 实际写了什么?

两个重载跑的是同五步:定位字段字典,通过 HPDFSetDictFormValue 写 /V,核对选择索引,把字典标记为脏,核对按钮外观状态,最后通过 NoteLoadedFormFieldDirty 记下字段索引。最后一步在表单带计算脚本时很重要,因为脏集合正是无参重载 RecalculateLoadedFormFieldsIncremental 所消费的东西,用来只重跑那些会传递性地读取到已改字段的计算。HPDFSetDictFormValue 本身对它替换的对象类型很小心。如果已有的 /V 是一个 name 对象——复选框和单选字段用来表示导出值的正是这个——新值就写成 name,绝不写成字符串,因为 PDF name 按构造只能是 ASCII。否则它写一个字符串对象并检查你传进来的值:一个以 FEFF 开头、长度为偶数且全部由十六进制数字组成的字符串,会被当作 §7.9.2.2 所说的 UTF-16BE 线上形式处理,并以 IsHexadecimal 置位存起来,于是它序列化成 <FEFF...> 而不是字面量 (FEFF...)。上面 City 那一行依赖的就是这个机制;其他任何字符串都以字面字符串存储,装的是你给它的那些字节,所以对纯拉丁文本你就传纯文本

为什么改完值之后复选框还留着旧的勾?

因为对按钮字段来说,光有值决定不了画出来的是什么。ISO 32000-1 §12.7.4.2.3 规定,一个复选框 widget 带一个 /AS 外观状态,指明 /AP /N 里当前显示的是哪条流,而查看器是从 /AS 画的,不是从 /V 画的。如果你把 /V 改成 Yes 却让 /AS 停在 Off,文件内部就是自相矛盾的,而摊平会很乐意把那个过期未勾选的外观烤进页面,与此同时表单数据说着已勾选。ReconcileLoadedButtonAppearanceStates 存在的意义就是堵上这个缺口:对一个 /FT 为 Btn 的字段,它走访该字段字典本身以及它 /Kids 数组里的每个条目,从 /AP /N 读出开启态名字,然后它匹配字段值时把 /AS 重写成那个名字,不匹配时写成 Off

为什么只改 /V 时 HotPDF 的复选框还留着旧的勾示意图:查看器依据 /AS 外观状态从 /AP /N 里画,所以 ReconcileLoadedButtonAppearanceStates 走访字段和每个子项,把开启态名字读成第一个不是 Off 的键,匹配时重写 /AS、否则写成 Off
单选组把每个子项与 InheritedButtonValue 沿 /Parent 链找回的父值做比较,所以把组设成某个导出值时,正好打开那个 widget 并关掉所有兄弟

真实表单里的两个细节塑造了 v2.752.3 的修法。第一,一个 normal 外观字典允许只包含开启态;§12.7.4.2.3 把关闭态外观命名为 Off,但制作工具经常省略它的流,让查看器什么都不画。更早的代码在字典条目少于两条时就放弃,于是那些单状态复选框悄悄留着旧的勾。现在检查简化成字典非空,而开启态名字取第一个不是 Off 的键。第二,开启态名字是作者随便起的。真实表单会用 2、Yes、On 或者某个本地化词,所以比较是对着实际的键做、且不区分大小写,绝不是对着硬编码的 Yes。单选按钮还多一层麻烦,§12.7.4.2.4 有描述:选中状态住在父字段的 /V 上,而各个子项拥有 widget,通常自己没有 /V。所以那个嵌套的 InheritedButtonValue 助手会沿 /Parent 链向上走最多 64 层,直到找到一个非空值,于是每个子项都是跟它所属那一组的值做比较。把父字段设成某个子项的导出值,正好打开那个子项并关掉所有兄弟

// 复选框:导出值必须与 /AP /N 里的开启态键匹配
// (常见是 'Yes',但真实表单会用 '2'、'On' 或别的什么)
Pdf.SetFormFieldValue('Consent', 'Yes');

// 单选组:/V 写在父字段上;每个子 widget 的 /AS
// 被设成它自己的导出名或 Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// 清空复选框:任何不匹配任何开启态的值都会让 /AS 变成 Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

选择字段:让 /I 与 /V 保持同步

对一个组合框或列表框来说,/V 不是记录选中状态的唯一地方。§12.7.4.4 的 Table 231 把 /I 定义为一个 0 基索引数组,索引进 /Opt,用来标识被选中的项,而一个查看器如果发现 /I 指向选项 0 而 /V 说的是选项 3,可能会高亮错行。从 v2.754.1 起,HPDFReconcileChoiceSelection 在每一次 SetFormFieldValue 调用里都会运行,并在这个继承来的 /FT 为 Ch 时从新值重建 /I。操作的顺序是刻意的。先删掉本地的 /I 条目,不碰它的内容:如果旧数组是一个与别的字段共享的间接对象,原地改动它会破坏那个字段的选中状态,所以这个例程丢掉引用、改为新建一个直接数组。随后它沿 /Parent 链解析 /Opt,因为选择项可能被继承,然后扫描各个条目。裸字符串选项直接比较;[export display] 对按它的导出元素比较,而元素少于两个的对被跳过。两边都过 HPDFLoadedFormTextName,于是一个十六进制 UTF-16 选项能匹配一个十六进制 UTF-16 值,不需要你把它们写成一模一样。第一次匹配时写入一个单元素 /I 并停止扫描;标量值总是替换掉之前的多选,不管 MultiSelect 标志是什么

HotPDF 如何让选择字段保持一致示意图:HPDFReconcileChoiceSelection 先删掉本地的 /I 数组再动它,沿 /Parent 链解析 /Opt,通过 HPDFLoadedFormTextName 比较每个选项的导出半部,第一次匹配时写入一个单元素 /I,而当一个可编辑组合框的值没有对应索引时什么都不写
裸字符串选项直接比较,export display 对按它的导出元素比较,而一个不在 /Opt 里的值正确地不留下任何索引——一个指向错行的过期 /I 比没有更糟

什么都匹配不上时,根本不写 /I。对一个可编辑组合框来说这是正确结果,§12.7.4.4 允许用户输入一个选项列表之外的值;这样的值没有索引,而过期的索引比没有更糟。如果你对一个成对的选项列表传的是显示标签而不是导出值,得到的也是这个结果,所以当组合框拒绝显示你的选择时,检查一下你给的是对里的哪一半

// /Opt 是 [[US United States] [CA Canada] [MX Mexico]]:
// 按导出值匹配,/I 变成 [1]
Pdf.SetFormFieldValue('Country', 'CA');

// 可编辑组合框输入一个 /Opt 之外的值:/V 被写入,
// /I 被移除,不编造任何索引
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

值与外观是两个分开的操作

SetFormFieldValue 从不碰文本字段或选择字段的外观流。调用之后,/V 装着新文本,而 /AP /N 仍然画着旧的,查看器显示两者中的哪一个,取决于 AcroForm 字典是否按 §12.7.3.3 带 /NeedAppearances true,以及查看器是否认它。如果你需要文件在每个读取器里都渲染出新值,包括那些忽略这个标志的摊平器和缩略图生成器,那就带上字段索引调用 EnsureLoadedFieldAppearanceStream。它从继承来的 /DA 字符串、/Q 对齐方式、/MaxLen 梳状布局和值构建一个 Form XObject,通过 AcroForm 的 /DR 资源解析具名字体,好让一个 Type0 字体保住自己的后代字体而不是退化成 Helvetica,并在至少一个 widget 拿到流时返回 True。SetFormFieldValue 的按名重载不给你返回索引,所以要用 GetFormField 取一个,它返回一个归你所有、必须释放的 THPDFLoadedFormField。针对 v2.752.1 那次改动的回归套件把这个分工说得很明白:它设一个值,调用 EnsureLoadedFieldAppearanceStream,然后渲染页面并检查 widget 矩形内的像素变了而矩形外的没变。验证 /V 变了,证明不了用户会看到什么

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // 把新值画进 /AP,好让那些忽略
    // /NeedAppearances 的查看器也能显示它
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

在你据此动手之前值得知道的限制

ReconcileLoadedButtonAppearanceStates 检查的是你所寻址那个字典本地的 /FT,所以它作用在单选父字段上、或者作用在一个自带 /FT 的复选框上;一个被单独寻址、/FT 只在父字段上的子 widget 不走那条路径核对。HPDFReconcileChoiceSelection 处理单个标量值,最多写一个索引;选了多个条目的多选列表框超出了 SetFormFieldValue 所建模的范围。这两个例程都不校验你传的值是否符合 /Opt 或开启态键,所以一个笔误会产出一个 Off 复选框或一个没有索引的组合框,而不是抛异常。而 GetFormFieldValue 返回的是字典里存着的 /V 文本,对一个十六进制编码的值来说就是那个十六进制拼写,而不是解码后的文本

值填进去、外观画好之后,两个自然的下一步分别坐在这项操作的两侧。与外部系统批量交换字段数据、而不是一次一个 SetFormFieldValue 调用,是Delphi 里的 XFDF 导入导出覆盖的内容。而当填好的表单定稿、不该再可编辑时,在 Delphi 里摊平 AcroForm 与 XFA 字段会把这里描述的那些 /AS 状态和外观流正好烤进静态页面内容,这就是为什么在摊平之前让它们保持一致不是可选项

本文里的已加载表单编辑 API,包括 SetFormFieldValue、EnsureLoadedFieldAppearanceStream 以及增量重算图,都作为 HotPDF Delphi Component 的一部分发布,面向 Delphi 和 C++Builder