技术文章

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

在几乎所有 PDF 查看器的字段列表里,一份德语索赔表单的街道字段显示为 Straße。它 /T 条目背后的字节讲的却是另一个故事:<FEFF005300740072006100DF0065>——一个前面带字节顺序标记的十六进制 UTF-16BE 字符串。拿字段名去匹配这些原始字节的代码会报告字段不存在,尽管用户能直接点进去。同一份表单可能把复选框的开启态存成 2 而不是 Yes,把国家列表存成 [export display] 对——你写的值和查看器画的值,就此有了三种各不相同的翻车方式

三种花样来自同一个地方:一份多年前用客户手头随便哪个制作工具搭出来的表单,如今你的 Delphi 应用要从数据库填它,还得交回一个在每种阅读器里渲染一致的文件。表单你重做不了,所以填充代码只能遵守表单当初被写下的规则。ISO 32000-1 对三种情形各有一条规则:第 7.9.2.2 节管文本字符串,第 12.7.4.2.3 节管按钮外观状态,第 12.7.4.4 节的表 231 管选择字段的选中项。创建尚不存在的字段是另一件事,见在 Delphi 中给已加载的 PDF 添加 AcroForm 字段

找到字段:HotPDF 先解码 /T 再匹配名字

THotPDF.SetFormFieldValue 有两个重载,一个收从 0 起算的字段索引,一个收全限定名,两者都对着组件首次使用时从 AcroForm 树构建的名字表解析。构建这张表时,每个部分名都要过一遍解码器。十六进制 /T 字符串先还原成字节;如果这些字节以 FE FF 开头且长度为偶数,余下部分按 UTF-16BE 读出、再编码成 UTF-8。解码出的部分名随后按第 12.7.3.1 节的描述用句点接回父名,所以名为 Address 的父字段下名为 City 的子字段登记为 Address.City。解码的只是查找键。文件里的 /T 对象保持十六进制拼写,保存文档不会碰你只是填过的字段的身份

键进表前先转小写,所以 'address.city' 能找到 Address.City。这超出了标准——标准把字段名当大小写敏感——也意味着两个只差大小写的字段在查找里会撞车。对从别人手打的配置文件里读字段名的代码来说,漏一个大写字母远比同一份表单里两个只差大小写的字段常见

HotPDF 如何解析非 ASCII 的 AcroForm 名字:十六进制 /T 字符串还原成字节,FE FF 字节顺序标记按 UTF-16BE 解码并重编成 UTF-8,限定名接回父名,Applicant.FullName 和名为 Straße 的字段都进查找表
变的只是缓存键:字段字典保持十六进制编码,查找为方便起见规范化成小写(超出标准的便利),保存文档绝不改写你只是填过的字段的身份

解码出的名字是装在 AnsiString 里的 UTF-8 字节,Delphi 自己的字符串转换偏偏在这里使绊子。传给 AnsiString 参数的 'Applicant.Straße' 这样的字面量会经系统 ANSI 代码页转换,ß 到手成了单字节 DF 而不是 UTF-8 的 C3 9F,查找落空。ASCII 名字不受影响。其他情况,先枚举一遍字段,用 UTF8ToString 解码每个名字,走索引重载写入:

// FindFieldIndex 是你自己加的辅助函数:它拿解码后的
// UTF-8 名字与 Unicode 字符串比较,中间不隔
// 一层 ANSI 代码页
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldCount - 1 do
    if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
      Exit(I);
  Result := -1;
end;

var
  Pdf: THotPDF;
  Street: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ASCII 名字可以直接走按名重载
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');

    Street := FindFieldIndex(Pdf, 'Applicant.Straße');
    // 值也是 AnsiString,字面量 'ß' 得看系统代码页脸色;
    // FEFF 十六进制(Hauptstraße 12)绕开它
    if Street >= 0 then
      Pdf.SetFormFieldValue(Street,
        'FEFF00480061007500700074007300740072006100DF0065002000310032');

    // 同样带 FEFF 前缀的 UTF-16BE 十六进制对按名重载也管用
    // (这条是 München);它会写成十六进制字符串
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

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

SetFormFieldValue 一步步写了什么

找到字段之后,两个重载跑同一串步骤:写 /V,重建选择字段选中索引,把字段标记为已变,切换按钮外观状态,重新生成文本或选择外观,并把字段记进脏集合。最后一步喂给无参数的 RecalculateLoadedFormFieldsIncremental,它只重跑读取了变更字段的计算脚本,直接读或经“明细行、小计、合计”这样的链读都算

新 /V 的对象类型取决于字段。复选框和单选按钮,SetFormFieldValue 总是写名字对象,因为第 12.7.4.2.3 节把选中态存成名字,而 PDF 名字是纯字节、没有文本字符串编码。其他字段都收字符串对象,你传的值会先被检查。以 FEFF 开头、长度为偶、只含十六进制数字的字符串,视为第 7.9.2.2 节的 UTF-16BE 形式,序列化成 <FEFF...>。上面的 City 行靠的就是这个。其他任何值都按字面字符串存储,字节与所给完全一致,纯 Latin 文本进去还是纯文本

为什么值改了复选框还留着旧勾?

对按钮字段,值决定不了画什么。复选框 widget 带 /AS 条目,指明 /AP /N 里当前显示的是哪个流,查看器照 /AS 画、不照 /V。/V 改成 Yes 而 /AS 还说着 Off,文件就自相矛盾;拍扁器会把没勾的框烤进页面,表单数据却声称已勾。所以 SetFormFieldValue 会访问按钮字段和它 /Kids 数组里的每一项,从每个 widget 的 /AP /N 读开启态名字,匹配字段值就设 /AS 为该名字,不匹配就设 Off

为什么只改 /V 时 HotPDF 的复选框还留着旧勾:查看器按 /AS 外观状态从 /AP /N 里画,所以 SetFormFieldValue 访问字段和每个 kid,把除 Off 之外的第一个键当作开启态名字,匹配就重写 /AS,否则置 Off
单选组把每个 kid 与经 /Parent 链从父字段继承的值比较,所以把组设成一个导出值,恰好点亮那个 widget、熄掉所有兄弟

真实表单会在两处拧这条规则。关闭态外观标准里叫 Off,但制作工具经常干脆不放它的流、让查看器什么都不画,所以只含一条目的 normal 外观字典完全合法。开启态名字随作者挑:Yes、On、2、表单自己语言里的一个词。于是开启态取 /AP /N 里第一个不是 Off 的键,与你的值比较时忽略大小写。单选按钮加上第 12.7.4.2.4 节的安排:选中值放在父字段的 /V 里,每个 kid widget 通常没有自己的 /V。每个 kid 与经 /Parent 链继承来的值比较

// 复选框:值必须等于 /AP /N 里的开启态键
// (这里是 'Yes';表单设计者可能选了 '2' 或 'On')
Pdf.SetFormFieldValue('Consent', 'Yes');

// 单选组:/V 以名字形式写在父字段上;每个 kid widget
// 的 /AS 设成自己的开启态名字或 Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// 清空复选框:匹配不到任何开启态的值产出 /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

反面是打错字不抛异常。带尾随空格的 'yes ' 什么都匹配不上,框最后是 Off,文件依旧完全合法。值来自数据库列的话,调用前先 trim

选择字段:让 /I 与 /V 步调一致

组合框和列表框在两处记录选中。紧挨 /V,表 231 把 /I 定义为指向 /Opt 的从 0 起算索引数组,/I 指着选项 0 而 /V 说着选项 3 时,查看器可能高亮错行。SetFormFieldValue 每次对选择字段的调用都重建 /I。它先删字段自己的 /I 条目、别的什么都不干,也绝不原地改旧数组:间接数组可能与其他字段共享,一动就把那个字段的选中项也改了。然后经 /Parent 链解析 /Opt——选项可能继承而来——并遍历条目。裸字符串选项直接比较,[export display] 对比较其导出元素,元素少于两个的对跳过

HotPDF 如何保持选择字段一致:SetFormFieldValue 动手前先删除本地 /I 数组,经 /Parent 链解析 /Opt,把每个选项的导出半边按解码文本比较,首个匹配写单元素 /I,可编辑组合框的值没有索引时什么都不写
裸字符串选项直接比较,export display 对比较其导出元素,/Opt 之外的值正确地不留下索引 — 指错行的陈旧 /I 比没有更糟

比较在解码文本上进行,所以按 PDFDocEncoding 存的选项能匹配写成 UTF-16BE 十六进制的值,反之亦然。标量值最多产出一个索引;字段之前持有的任何多选都被替换。什么都匹配不上时,/I 一个都不写。对可编辑组合框这是正确结果,因为第 12.7.4.4 节允许用户输入列表之外的值,这种值没有索引可指

组合框最常见的抱怨背后也是同一条规则:调用成功,/V 装着新文本,查看器却什么都不显示。通常是调用者把显示标签(Canada)传给了用 [CA Canada] 对搭起来的列表。组件不能把这个当错误,否则可编辑组合框就废了,所以检查得放在你的代码里。GetFormFieldOptionValue 返回每个选项的导出元素,守卫因此很短:

// SetChoiceByExport 是你自己的守卫,不是组件的一部分:
// 它拒绝匹配不到 /Opt 任何导出元素的值
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
  ExportValue: AnsiString);
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
    if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
    begin
      // /Opt 是 [[US United States] [CA Canada] [MX Mexico]]:
      // 'CA' 匹配导出元素,/I 变成 [1]
      Pdf.SetFormFieldValue(FieldName, ExportValue);
      Exit;
    end;
  raise Exception.CreateFmt('%s is not an export value of %s',
    [string(ExportValue), string(FieldName)]);
end;

// 带自由文本的可编辑组合框:直接写。/V 会设置,
// /I 会删除,不发明索引
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

没有 /NeedAppearances 新值也会显示吗?

会,文本和选择字段如此。查看器画存好的 /AP 流、不看 /V,所以新值配旧外观,屏幕上和打印里留下的都是旧文本,除非 AcroForm 字典带 /NeedAppearances true(第 12.7.3.3 节)且查看器选择买账。拍扁器和缩略图生成器经常无视这个标志。SetFormFieldValue 在调用内为文本和选择字段重新生成外观流,用的是与公开的 EnsureLoadedFieldAppearanceStream 相同的构建器。流由继承的 /DA 字符串、/Q 对齐、comb 字段的 /MaxLen 和值本身构建。/DA 里点名的字体经 AcroForm /DR 资源解析,所以 Type0 字体保住自己的字形,不会退回 Helvetica。按钮保留设计者画的各状态外观;对它们只有 /AS 动

SetFormFieldValue 是过程,不会告诉你画没画成。没有可用 /Rect 的 widget 拿不到流,而带孤儿 widget 或没矩形 widget 的坏表单确实存在。渲染结果要紧时,设完值自己调 EnsureLoadedFieldAppearanceStream。至少一个 widget 拿到流它就返回 True。GetFormField 给你一个 THPDFLoadedFormField,它的 Index 属性正是调用要的;对象归你所有,用完要 free

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // False 表示该字段的任何 widget 都没有可画的矩形;
    // 值存住了,但没有任何东西会显示它
    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;

检查 /V 变没变,几乎证明不了用户会看到什么。有意义的测试是前后各渲染一次页面,确认 widget 矩形内的像素变了、矩形外的没变。我们坚持这条检查,因为光看外观字节骗过过我们:一个流每个字节都对,而一个买 /NeedAppearances true 账的查看器从 /V 重建了显示,流的半个像素都没露

把值读回来,以及通常的下一步

GetFormFieldValue 返回字段继承的 /V。以 FEFF 前缀十六进制存的值回来时解码成 UTF-8,所以上面的 City 字段读回 München 的 UTF-8 字节,UTF8ToString 再把它变成 Delphi 字符串。字面字符串带着存储时的字节回来。往返校验时,两边都比解码后的文本,别比传进去的拼写

表单与外部系统之间批量搬字段数据——而非逐字段调用——是Delphi 中的 XFDF 导入与导出的主题。锁死做完的表单让谁也改不了,是Delphi 中拍扁 AcroForm 与 XFA 字段,它把这里描述的 /AS 状态和外观流原样变成静态页面内容。那一刻不一致的东西将永远不一致,所以先填、再查、后拍扁

SetFormFieldValue、EnsureLoadedFieldAppearanceStream 和增量重算 API 都是 HotPDF Delphi Component 的一部分,旁边还有改名、删除和拍扁字段——针对你从 Delphi 或 C++Builder 加载的文档——的那些调用