复选框和单选按钮展平后变成未勾选状态,是因为外观状态/AS从未与字段值/V同步过。PDFium Component是面向Delphi、C++Builder和Lazarus、基于PDFium的VCL与LCL组件,现在改用FPDFAnnot_GetFormFieldValue来读取这个值,它解析的是父字段字典,而不是控件批注本身
引出这篇文章的那份bug报告,一开始是那种让人半信半疑的报告。客户展平一份已签署的同意书,打开结果,每一个复选框都是空的。用Acrobat打开源文件,那些框明显是打了勾的。用同一个组件把源文件读回来,字段值也是对的。只有展平后的输出丢掉了这些信息,而且只丢复选框和单选按钮:同一页上的文本字段完全正常
为什么复选框展平后会变成未勾选?
因为展平操作从来不看/V。FPDFPage_Flatten把控件的外观流烘焙进页面内容,它挑选的外观是由/AS指名的那一个。如果/AS依然写着/Off,而字段值却说这个框是勾选状态,展平操作会忠实地烘焙进那个"关闭"外观。字段值从来没有丢失,只是从来没有被查阅过
ISO 32000-1 §12.5.5定义了外观字典/AP,有三个可能的条目:/N、/R和/D。对复选框或单选按钮来说,/N条目不是一个流,而是一个子字典,键是外观状态名称,§12.5.2规定当/N是一个子字典时,/AS就成为必须的选择器。所以一个复选框携带的是两份预先构建好的外观,加上一个指针。指针指错了,渲染结果就是错的,而且无论/V有多正确都修不好。这也正是为什么这个失败模式和文本字段不一样——文本字段根本没有预先构建好的外观可供挑选:文本字段的/N是一个单独的流,字段值改变之后必须从头重新生成,所以GenerateFormAppearances通过两条完全独立的代码路径来处理这两种情况,而只有按钮这条路径是坏的
复选框的值到底存放在哪里?
存在字段字典上,不是存在控件上。ISO 32000-1 §12.7.5.2把复选框和单选按钮描述为按钮字段,其/V是一个命名当前外观状态的名称对象,§12.7.3.1把/V列为所有字段字典共有的条目之一。§12.5.6.19定义的控件批注贡献的是/AS和/AP。规范里没有任何地方要求一个控件必须携带/V
// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off
{ What the two objects look like when the field has several widgets:
12 0 obj % field dictionary (the parent)
<< /FT /Btn /T (Consent) /V /On
/Kids [ 13 0 R 14 0 R ] >>
endobj
13 0 obj % widget annotation (a kid)
<< /Type /Annot /Subtype /Widget /Parent 12 0 R
/AS /Off
/AP << /N << /On 20 0 R /Off 21 0 R >> >> >>
endobj }
FPDFAnnot_GetStringValue本身没有缺陷。它的契约正如它的名字所说的那样:从你传给它的那个批注字典中取出一个字符串条目。向它询问对象13上的/V什么都拿不到,是因为对象13确实没有/V。缺陷出在调用方身上,它假设了一个ISO 32000-1从未承诺过的扁平对象模型
什么时候字段和控件会共用同一个字典?
只要一个字段恰好只有一个控件。§12.5.6.19允许把字段字典和它唯一的控件批注合并成一个对象,大多数创作工具都会走这条捷径。在一个合并后的对象里,/FT、/T、/V、/AS和/AP会全部并排存在,所以一次针对控件层面的/V读取能够成功,整个bug也就一直隐藏不露
而一旦一个字段拥有两个或更多控件,合并就不可能了,§12.7.3.1要求这些控件成为一个独立字段字典的/Kids。每一个单选按钮组按构造都是这种形态。在页眉和页脚里重复出现的同意复选框也是如此,任何一个被创作工具复制到了第二页的字段同样如此。这就是为什么这个缺陷能在回归测试套件里潜伏下来的全部原因:测试语料库里满是单控件表单,而客户的文件不是这样。如果你自己遍历控件而不是依赖组件本身,同样的不对称性会出现在枚举顺序上,用PDFium Component做PDF表单字段导航一文介绍了页面层级的批注遍历与文档层级的字段树之间的关系
按照PDFium的本意读取字段值
FPDFAnnot_GetFormFieldValue才是正确的API,而且它其实早就已经绑定进了组件里,只是复选框这条路径一直没有用上它。它同时接受表单句柄和批注,这正是关键信号所在:只要表单填写环境可用,PDFium就会把这个批注解析到它所属的表单控件,从字段对象上读取值,所以不管是合并布局还是拆分布局,它返回的答案都是对的
FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
begin
// /AP is prebuilt per state; only /AS has to be synchronised with /V.
// FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
// which is where ISO 32000-1 12.7.5.2 keeps the value.
buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
if buflen >= 4 then
begin
SetLength(OrigVal, buflen div 2 - 1);
FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
end;
end;
这段代码里有两个细节很容易出错。返回的长度是UTF-16文本的字节数,包含结尾的终止符,所以字符数是buflen div 2 - 1,返回值为2就意味着一个空字符串。因此buflen >= 4这道防线的意思是"至少有一个真实字符",正是它防止了一个根本没有/V的字段被写入一个空名称到/AS上
/AS和/AP /N之间真正约定一致的是什么
它们约定一致的是一个名称,而这个名称是由生成这份文件的工具选定的。§12.7.5.2要求"关闭"状态必须叫/Off,而"打开"状态叫什么完全由生成者决定。/Yes只是一种惯例,不是一条规则。Acrobat写的是/Yes,但相当多的生成器写的是/On、/1、/Choice1,或者某种本地化的词,而一个单选按钮组通常会给每个子项赋予各不相同的"打开"状态名,这样这个组才能表达出到底是哪个按钮被选中了。这正是为什么把/V原样拷贝进/AS是正确的做法,而不是一个取巧的手段:对一个已勾选的控件,PDFium报告的是文件自己定义的那个"打开"状态名,对一个未勾选的控件,它报告的是Off,所以你写进/AS的值,保证是那个控件/AP /N子字典里确实存在的一个键。如果硬编码成/Yes,在Acrobat产出的文件上能用,在其他地方就会悄悄出错
操作顺序,以及仍然需要留意的地方
这个顺序是固定且不容出错的:启用表单填写、赋值、重新生成外观、展平、然后保存。跳过重新生成这一步,FPDFPage_Flatten会找到空的或者过时的外观流,毫无怨言地把它们烘焙进去,这是一次悄无声息的数据丢失,而不是一个错误返回
Pdf.FileName := FormPath;
Pdf.FormFill := True; // required: FormHandle must exist
Pdf.Active := True;
Pdf.FormField[0] := 'On'; // writes /V only
Pdf.GenerateFormAppearances; // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
Pdf.SaveAs('consent-flat.pdf');
还留有两处诚实的局限。第一,这次同步会把字段值写进该字段每一个控件的/AS上,这对复选框来说是对的,但对每个子项各自定义自己"打开"状态名的单选按钮组来说只是一种近似处理;一个子项如果它的/AP /N里没有与写入的/AS相匹配的条目,按§12.5.5规定它就没有外观可供选择,于是一个未被选中的按钮展平后可能变成什么都没有,而不是一个空心圆圈。在展平之前用FPDFAnnot_GetFormControlIndex审核一遍单选按钮组,多写这几行代码是值得的。第二,以上这些都不适用于XFA,那里的值存在一个XML数据包里,而不是AcroForm字典中,这种区分在未被持久化的XFA字段编辑一文中有介绍。这次修复之外还有一条通用的经验值得记住:只要一个API除了批注之外还接受表单句柄,它就是在告诉你它会替你解析字段层级关系;只要它只接受批注,它就只会精确读取你传给它的那个对象。这一区分同样支配着数据交换,因为导入导出XFDF表单数据用的始终是完全限定的字段名,而不是控件位置
表单展平是那种看起来只是一次API调用、实际上却是三个字典之间一份契约的功能之一。如果你更愿意用一个已经把这份契约内置进去的组件,面向Delphi和C++Builder的PDFium Component把这里描述的外观重新生成、展平和表单字段访问,都作为普通的属性和方法提供出来