技术文章

Delphi 中 AcroForm 的继承字段值与重置

一份调查表在 Acrobat 里显示两个下拉列表,group.a 和 group.b。用"把任何自带 /FT 的节点当作成品字段"的代码去遍历 /AcroForm /Fields,数出来是 1:一个叫 group 的字段带两个 widget。按名字要 group.b,查找失败。打开子节点的字典,里面根本没有 /FT、没有 /Ff、也没有选项列表。什么都没坏。类型、标志和选项就放在上一层的父节点上,而 PDF 规范明确允许它们住在那里

这种布局既合法又经济。创作工具写一个带 /FT /Ch、/Ff 131078 和 /Opt 数组的组节点,然后在下面挂两个命名的孩子,每个都是合并的"字段加 widget"字典,只持有 /T、/Parent、/Rect 和自己的 /V。正确读取这样的表单需要三件事:为每个可继承键跟随 /Parent,判断字段树到底在哪结束,以及确保重置写入的是正确 PDF 类型的值。本文从在 Delphi 中设置已加载 PDF 的表单字段值停下的地方接着讲

字段能从父节点继承哪些 AcroForm 条目?

ISO 32000-1 §12.7.3.1(表 220)把 /FT、/Ff、/V 和 /DV 标为可继承,§12.7.4.3 的表 229 对文本字段的 /MaxLen 亦然。选项数组在这里也享受同等待遇,因为在孩子间共享选择列表的生成器会把 /Opt 存在组上。因此只查本地字典的读取器,会对一个完全合法的孩子报出错误的类型、为 0 的标志和空值

HotPDF Delphi Component 对所有这些读取给出同一套答案。它先在字段自己的字典里找键,键在就地解析间接引用并停止;否则跟随 /Parent 再试,最多 128 层。这个上限不是装饰品。损坏文件里的 /Parent 指针可以形成与 /Kids 毫无关系的环,无条件信任它们的读取器会在遇到第一个这种文件时挂死

这条遍历里有一条规则很容易搞反:第一个含有该键的字典获胜,哪怕存在那里的值是空字符串。group.b 上的本地 /V () 是作者在说"这个孩子是空白",遍历就此停止,而不是爬上去找父节点可能持有的值。把空当作"缺失",会让一个被清空的孩子显示出父节点的选择,与作者写的正好相反

HotPDF 可继承 AcroForm 属性图:组节点只带一份 /FT、/Ff 和 /Opt,而命名孩子 group.a 和 group.b 只持有 /T、/Parent、/Rect 和本地 /V;图中展示沿 /Parent 上行至多 128 层的继承属性查找——第一个持有键的字典获胜,本地空值屏蔽父节点
HotPDF 用同一个父节点遍历解析器解析 /FT、/Ff、/V、/DV 和 /Opt,命名孩子保持可寻址,而本地空值有意覆盖组上的一切

公开的 getter 全部落在同一套查找上:GetFormFieldType、GetFormFieldValue、GetLoadedFormFieldFlags、IsFormFieldRequired、IsFormFieldNoExport、GetLoadedFormFieldMaxLength、GetLoadedFormFieldDefaultValue,以及选项助手 GetLoadedFormFieldOptionCount 和 GetLoadedFormFieldOptions。所以读调查组的子字段不需要任何特殊代码:

var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' 带 /FT /Ch、/Ff 131078 和 /Opt;子字段
    // 'group.b' 只有 /T、/Parent、/Rect 和自己的 /V
    Field := Pdf.GetFormField('group.b');
    try
      if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
      begin
        // 131078 = Combo(位 18)+ NoExport(位 3)+ Required(位 2)
        Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
        Writeln(Pdf.IsFormFieldRequired(Field.Index));    // TRUE
        Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
        Writeln(Pdf.GetFormFieldValue(Field.Index));       // 本地的 /V
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

GetFormField 每次调用都返回新的 THPDFLoadedFormField 包装器,名字未知时抛出异常:把调用放在包装器的 try 块之外,并自己释放包装器

找到字段树的尽头

用本地 /FT 判断末端字段是错的,因为父节点可以自带类型、同时拥有命名的孩子字段。诱人的遍历在节点有自己的 /FT 或没有 /Kids 时就停止下探。在调查表里,group 既有 /FT /Ch 又有 /Kids,于是那种遍历把它登记成一个字段带两个 widget 批注。group.a 和 group.b 这两个名字凭空消失。字段计数是 1,按孩子名查找失败,值写入只能落在共享的父节点上

HotPDF 改看孩子。一个 kid 在有自己的 /T、有自己的 /Kids、或不是 /Subtype /Widget 字典时,算作孩子字段。只有没有任何 kid 符合条件时,节点才是末端,它的 kids 随后被当作它的 widget。这条规则背后的两个边界情况都来自合并字典——§12.7.3.1 允许字段恰有一个 widget 时合并:

  • 命名的合并字典携带 /Subtype /Widget,仍然是孩子字段。仅凭 subtype 不能把它推进父节点的匿名 widget 列表;/T 说了算
  • 一些生成器把父节点的 /FT 重复写到每个匿名 widget 上。所以 widget 上的 /FT 也不能读成"新字段从这里开始"

同一套分类驱动字段索引、FormFieldExists 和 RemoveFormField。这些遍历同样记录已访问的字典,超过 128 层就放弃。设组是对象 5、且在自己的 kids 里列了自己两次:/Kids [5 0 R 5 0 R 6 0 R 7 0 R]。它仍然恰好报告两个字段:孩子 6 和 7。没有无限递归,自引用也不会造出幻影第三字段

RemoveFormField 怎么避免删掉兄弟字段?

RemoveFormField 只删你点名的那一个孩子,因为发现与删除共享同一个"末端字段"定义。按名字的重载先从字段列表解析出字段索引,再第二次遍历 /AcroForm /Fields、数着末端字段找到要删的字典。如果第二趟遍历用诱人的 /FT 规则而第一趟用基于孩子的规则,索引就会打架:删除遍历会把 group 看成字段 0,删 group.a 就会连父节点、兄弟和两个 widget 一起端走

所以删除遍历应用与发现相同的孩子字段测试和同一个已访问集合。它收集属于被删孩子的 widget 批注,只把这些从每页的 /Annots 里剥掉,并在父节点的 /Kids 数组清空时才移除父节点。group.b 还在列,父节点就留下,group.b 继承的类型、标志和选项也随之留下

HotPDF RemoveFormField 兄弟存活图:删除遍历与发现共用同一套孩子字段测试和已访问集合,只从 AcroForm /Fields 和页面 /Annots 里剥掉命名的孩子 group.a,并在共享父节点的 /Kids 数组仍持有幸存的 group.b 时保留父节点
发现与删除对"什么是末端字段"达成一致,所以删掉一个命名孩子后,完整重写或增量更新之后,兄弟的值和外观都完好
// 删除一个命名的孩子;它的兄弟和共享父节点存活
Pdf.RemoveFormField('group.a');

// 你自己会加的健全性检查,不是库的一部分
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// 类型、标志和选项仍经由父节点解析
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);

Pdf.SaveLoadedDocument('survey-trimmed.pdf');

在自己的表单上核查删除时,看三处,因为每一处抓住不同的错误:父节点的 /Kids、页面的 /Annots、幸存兄弟的值和外观。在 SaveLoadedDocument 之后做一次,在 SaveIncrementalUpdate 之后再做一次,因为增量保存只追加被改的对象,父节点的 /Kids 必须在其中

ResetLoadedFormField 从继承的 /DV 复制什么

ResetLoadedFormField 写入一个本地 /V,它是继承的 /DV 的新副本,保持相同的 PDF 对象类型,并且在碰字段之前先校验完整的默认值。复选框默认是 /Yes 这类名称。多选列表框默认是字符串数组。文本默认可以是 <FEFF...> 这样的十六进制 UTF-16 字符串。把其中任何一个经 GetLoadedFormFieldDefaultValue 和 SetFormFieldValue 走一趟,你得到的是名称该在之处的字符串、数组该在之处的空串,或者字面的十六进制数字

因此重置按继承的 /DV 到底是什么来分支:

  • 带字符串默认值的文本和 choice 字段,得到一个新的字符串对象,保留原有的十六进制标志
  • 带数组默认值的 choice 字段,得到一个由新字符串组成的新数组;数组里出现非字符串条目会抛异常
  • 复选框和单选钮得到新的名称对象
  • 其他类型的默认值抛异常,/V 和 /I 原样不动
HotPDF 类型化重置图:ResetLoadedFormField 按继承的 /DV 对象类型分支——复选框写入新的名称对象,多选 choice 写入新字符串组成的新数组,十六进制文本写入保留 IsHexadecimal 的字符串,无 /DV 时写空串或 /Off,类型不匹配时抛出且不碰 /V 与 /I
复制而非指向父节点的对象,让日后的值编辑无法悄悄改掉默认值;下压按钮和签名字段走普通字符串路径

复制而不是引用是刻意的,也是唯一安全的选择。指向父节点 /DV 数组的子 /V 会让"当前值"与"默认值"共享同一个对象;下一次对该孩子的 SetFormFieldValue 会悄悄改掉所有兄弟的默认值。每个重置字段多花几个字节,换来两者的隔离,很便宜。没有值的下压按钮(表 226,位 17)和签名字段走普通路径,写入自己 /DV 的字符串副本

链条上任何地方都没有 /DV 时,方法写入本地空字符串,复选框或单选字段则写 /Off。删掉孩子的 /V 看起来更整洁,答案是错的。如果父节点持有当前值,移除孩子的覆盖会让那个值直接回来,"重置"的字段显示的将是用户从没输入过的东西

var
  Field: THPDFLoadedFormField;
begin
  // 父节点 'palette' 是带 /Opt [(b) (g) (r)] 和 /DV [(b) (r)]
  // 的 MultiSelect 列表框;孩子只有 /T 和 /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // 孩子现在有自己的 /V [(b) (r)] 和 /I [0 2];
      // 父节点的 /DV 数组原封不动
    except
      on E: Exception do
        // 畸形默认值(类型不对、数组条目非字符串):
        // /V 和 /I 保持原样
        Writeln('Reset skipped: ', E.Message);
    end;
    // 标量 getter 无法表达数组默认值
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // 空
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

单字段重置也与 §12.7.5.3 的 ResetForm 动作是两回事——那个动作由阅读器在用户点击按钮时对一组字段执行,见用 HotPDF 构建 AcroForm 字段与动作。ResetLoadedFormField 编辑一个已加载字段,对无默认值的情况遵循自己的规则,并把字段标为已变更,让增量重算接得住

让 /V、/I 和 /AS 保持一致

只有选择索引和外观状态跟随值走,重置才算正确,ResetLoadedFormField 以与 SetFormFieldValue 相同的两步后续收尾。对 choice 字段,它移除孩子的本地 /I,把每个值与每条 /Opt 条目的导出半边匹配,写出新的已排序 /I。对选项 b、g、r 重置为 [(b) (r)] 得到 /I [0 2]。父节点继承的 /I(若有)不动

按钮从继承的类型出发。/FT /Btn 住在父节点上的孩子复选框,其 /AS 照样更新。写入一侧,即使孩子没有可供抄类型的本地条目,SetFormFieldValue 和 SetLoadedFormFieldDefaultValue 也会为继承的非下压按钮存一个名称对象。而 EnsureLoadedFieldAppearanceStream 重建按钮外观时,值不匹配 on 状态就写 /AS /Off,并给每个状态流 /Type /XObject、/Subtype /Form 和 /BBox。没有那个 /Off 检查,重置后重新生成外观会让复选框在文件还没保存时就又被打上勾

标量 getter 与其他需要提前规划的边界

字符串 getter 保持标量,比较其输出的代码应该知道它们怎么压平。GetFormFieldValue 和 GetLoadedFormFieldDefaultValue 对数组值返回空字符串,把数字和布尔变成 42 或 true,十六进制编码的字符串按十六进制写法报告。如果你想知道一个列表框默认值是两个选中还是零个选中,默认 getter 告诉不了你:两者都返回空字符串

/Parent 成环会让查找无异常终止,所以类型在环里丢失的字段报告 lfftUnknown 和标志 0。命名字段上的 lfftUnknown 当作值得记日志的文件损坏迹象处理。SetFormFieldValue 和 ResetLoadedFormField 永远写你寻址的那个孩子,绝不把值提升到共享父节点。这对调查表的两个独立下拉是对的。单选组不同:选择属于拥有组的那个字段,所以要寻址那个字段而不是它的某个孩子

每次调用也都是独立提交一个字段。对二十个字段的循环重置是二十次独立编辑;第五次因畸形默认值抛出时,前四次已经生效。批处理要先读每个目标的类型和默认值,决定要碰哪些字段,然后才开始写

继承属性查找、基于 kid 的字段树分类和类型化重置,都属于 HotPDF Delphi Component 的已加载表单 API;需要给既有文档添加新字段的 Delphi 和 C++Builder 项目,见在 Delphi 中给已加载 PDF 添加 AcroForm 字段