HotPDF Delphi Component 把已加载 AcroForm 字段上的 /FT、/Ff、/V 和 /DV 当作可继承属性,沿 /Parent 链上溯解析。从 v2.754.3 和 v2.754.4 起,类型来自父节点的命名子字段保持可单独寻址,RemoveFormField 不再波及兄弟字段,ResetLoadedFormField 会以原始 PDF 对象类型拷贝继承来的默认值。在此之前,相当多普通表单都被读错了
暴露这一切的表单并不稀奇。创作工具构建一个组节点 group,一次性带上 /FT /Ch、字段标志和选项列表,然后在它下面挂两个命名子字段 a 和 b,每个都是字段加控件的合并字典,除了 /T、/Parent、/Rect 和自己的 /V 之外什么都没有。这是共享属性的完全合法方式,也正是 用 Delphi 设置已加载 PDF 的表单字段值一文 Limits 一节标明未处理的情形:按钮调和当时只看本地的 /FT。本文从那篇停下的地方接着讲:字段树如何分类、继承值如何读取、单字段重置允许写什么
字段能从父节点继承哪些 AcroForm 条目?
ISO 32000-1 §12.7.3.1 的 Table 220 把 /FT、/Ff、/V 和 /DV 标为可继承,§12.7.4.3 的 Table 229 对文本字段的 /MaxLen 也一样,所以只看本地字典的阅读器会对一个完全合法的子字段报出错误类型、错误标志和空值。HotPDF 把这些读取全部收进一个内部解析器 HPDFLoadedInheritedFieldObject:先在字典里查键,找到间接引用就解析,否则沿 /Parent 上溯最多 128 层——畸形文件能构造出与 /Kids 毫无关系的 /Parent 环。公开 getter 都架在它上面:GetFormFieldType、GetFormFieldValue、GetLoadedFormFieldFlags、IsFormFieldRequired、IsFormFieldNoExport、GetLoadedFormFieldMaxLength、GetLoadedFormFieldDefaultValue,以及选项辅助 GetLoadedFormFieldOptionCount 和 GetLoadedFormFieldOptions——它们也能捡到存在父节点上的 /Opt 数组。解析器里有一条规则很容易搞错:上溯在第一个包含该键的字典处停止,哪怕那里的值是空字符串。本地的 /V () 是刻意的覆盖、屏蔽了父节点,不是等着树上层来补的缺口
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;
为什么本地 /FT 是判断末端字段的错误标准?
因为父节点可以供应类型、同时仍拥有命名子字段,所以 /FT 的存在说明不了字段树在哪里终结。旧的遍历只要节点有自己的 /FT 或没有 /Kids 就宣布它是末端。在上面那个表单里,group 既有 /FT /Ch 又有 /Kids,于是它被注册成一个叫 group 的字段带两个控件,全限定名 group.a 和 group.b 就此消失。GetFormFieldCount 返回 1,按子名查找失败,SetFormFieldValue 只能写共享的父节点。替代测试 HPDFLoadedFieldHasChildFields 看的是孩子而不是父节点:一个 kid 如果有自己的 /T、有自己的 /Kids、或者根本不是 /Subtype /Widget 字典,它就是子字段。只有没有任何 kid 符合条件时节点才是末端,其 kids 作为它的控件注解处理
塑造这条规则的两个边界情形都来自合并字典——§12.7.3.1 允许字段在只有一个控件时采用合并形式。命名的合并字典带着 /Subtype /Widget、但仍然是子字段,所以单凭子类型不能把它划进父节点的匿名控件列表;/T 说了算。反过来的情形也有:一些生成器在每个匿名控件上都重复父节点的 /FT,所以 /FT 也不能当作某个控件开启了新字段的证据。这个分类由关系缓存、FormFieldExists 和 RemoveFormField 共用,这些遍历现在都会记录已访问的字典并在超过 128 层后停下。一个组把自己列了两次的回归文件——/Kids [5 0 R 5 0 R 6 0 R 7 0 R]——仍然恰好报出两个字段,既不会无限递归也不会把同一节点数两遍
RemoveFormField 怎么避免删掉兄弟字段?
RemoveFormField 现在只删你点名的那个子字段,因为发现与删除终于就「什么是末端字段」达成了一致。这个一致比看起来重要。按名重载经关系缓存解析出索引,然后在第二次遍历 /AcroForm /Fields 时数末端字段。缓存修好能看见 group.a 和 group.b 之后,一个没修的删除遍历仍会把 group 当成单个末端字段,索引 0 会把父节点连同每个兄弟和它们的所有控件一起删掉。删除遍历现在用同一个 HPDFLoadedFieldHasChildFields 测试和同一个已访问集合,只收集被删子字段的控件注解,把它们从每页的 /Annots 里剥掉,并且只有当父节点的 /Kids 数组最终变空时才删父节点。回归检查了错误会现形的三个地方:父节点的 /Kids、页面的 /Annots、幸存兄弟的值和外观——完整重写和增量更新之后各查一遍
// 删一个命名子字段;兄弟和共享父节点都活下来
Pdf.RemoveFormField('group.a');
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// 类型、标志和选项仍经由父节点解析
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');
默认值是继承来的时候,ResetLoadedFormField 写什么?
ResetLoadedFormField 写入一个本地 /V——继承来的 /DV 的全新拷贝、保持同样的 PDF 对象类型——而且在动字段之前先校验整个默认值。对象类型之所以要紧,是因为标量 getter 把一切都压平成文本。复选框的默认值是 /Yes 这样的名字,多选列表框的默认值是字符串数组,文本默认值可能是十六进制 UTF-16 字符串;把它们任何一个经 GetLoadedFormFieldDefaultValue 拷过来,名字会变成字符串,数组会变成空字符串,十六进制串会变成它的字面数字。所以重置按继承来的类型分支:文本与选择字段拿到一个保留 IsHexadecimal 标志的新字符串对象,带数组默认值的选择字段拿到一个由新字符串组成的新数组,非按钮的按钮拿到新名字对象。拷贝而不是指向父节点的对象是刻意的:共享父节点 /DV 数组或对象号的 /V 会在下次有人编辑值时改掉默认值本身。类型错误的默认值、或包含非字符串的选择数组,会抛异常并让 /V 和 /I 原封不动。没有值的 pushbutton(Table 226 位 17)和签名字段回落到旧的仅字符串路径
沿链上任何地方都没有 /DV 时,这个方法按自己的清除契约写一个本地空字符串,复选框或单选字段则写 /Off。删掉本地 /V 看着更整洁但是错的:父节点可能持有一个当前值,移除子字段的覆盖会让那个值无声回归。这也是为什么单字段重置不是 §12.7.5.3 的 ResetForm 动作——那是阅读器在用户点击按钮时对一组字段跑的,用 HotPDF 构建 AcroForm 字段与动作讲过。ResetLoadedFormField 是对单个已加载字段的编辑操作,对无默认值情形有自己的规则,并通过 NoteLoadedFormFieldDirty 记录字段,让增量重算看见这次变化
var
Field: THPDFLoadedFormField;
begin
Field := Pdf.GetFormField('group.a');
try
// 父节点在 MultiSelect 列表框上持有 /DV [(b) (r)]:group.a 得到
// 自己的 /V [(b) (r)] 和新的 /I [0 2];父节点不动
Pdf.ResetLoadedFormField(Field.Index);
// 标量 getter 表示不了数组默认值
Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // 空
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('survey-reset.pdf');
end;
让 /V、/I 和 /AS 保持一致
只有选择索引和外观状态跟着值走,重置才算正确,所以 ResetLoadedFormField 最后跑与 SetFormFieldValue 相同的两个调和器。HPDFReconcileChoiceSelection 现在接受数组值:它不改动地删掉本地 /I,把每个值与每条 /Opt 条目的导出半边匹配,写出一个新的已排序 /I,于是对着选项 b、g、r 重置为 [(b) (r)] 得到 /I [0 2]。ReconcileLoadedButtonAppearanceStates 现在索取继承类型,于是 /FT /Btn 挂在父节点上的子复选框终于能设置自己的 /AS。写入侧,SetFormFieldValue 和 SetLoadedFormFieldDefaultValue 对继承的非按钮按钮存名字对象,即使子字段没有可拷贝类型的本地条目。而当 EnsureLoadedFieldAppearanceStream 重建按钮外观时,它写 /AS /Off(除非值匹配 on 状态),并给每个状态流配齐 /Type /XObject、/Subtype /Form 和 /BBox;v2.754.4 之前,重置后再生成外观可能在文件保存前又把框勾上
在它之上构建前值得知道的边界
标量 getter 仍然是标量的。GetFormFieldValue 和 GetLoadedFormFieldDefaultValue 对数组值返回空字符串,把数字和布尔串成 42 或 true,十六进制编码的字符串按其十六进制拼写报告。/Parent 环会终止上溯而不抛异常,所以类型陷在环里的字段报告 lfftUnknown 和 0 标志而不是失败。SetFormFieldValue 和 ResetLoadedFormField 总是写你寻址的那个子字段、从不把值提升到共享父节点——这对独立子字段是对的,但意味着单选组应当经由拥有选择权的那一个字段来寻址。而且每次调用各自提交一个字段;这里没有任何东西让一批重置变成事务
这里描述的继承属性解析、统一的字段树分类和类型化重置,都是面向 Delphi 和 C++Builder 的 HotPDF Delphi Component 已加载表单 API 的一部分,与 在 Delphi 中向已加载 PDF 添加 AcroForm 字段覆盖的字段创建并列