HotPDF 让多选列表框的值在 FDF 和 XFDF 中完整往返,靠的是把字段值从头到尾当数组。从 2.755.0 版起,ExportLoadedFormToFDF、ExportLoadedInterchangeToFDF 和 ExportLoadedFormToXFDF 把每个选中项写成独立的 FDF 字符串或 XFDF <value> 元素,配套的导入方法先把每个值对照字段选项校验、重建 /I 选择索引,然后才动任何东西。全程没有任何东西被拼成一个字符串
这个修复针对的失败很好复现。拿一张带产品选项多选列表框的订单表,让用户勾选其中两项,把表单数据导出给后台系统,再把改过的文件导回 PDF。改动之前,列表框回来时是空的或者错的。原因是一个导出值里含换行符,而旧路径把选中项压平成了一个以行分隔的字符串。从那个字符串里可靠地取回多个选中项从来就不可能,而当导出值本身就含换行时,它根本不可能成立
为什么用换行符拼接多选值会毁掉往返?
把选中项拼成一个字符串丢掉了值与值之间的边界,而值本身可以包含分隔符,所以没有任何导入器能把字符串正确拆回去。ISO 32000-1 §12.7.4.4 允许 choice 字段的 /V 条目是单个文本字符串或文本字符串数组,带 MultiSelect 标志(/Ff 位 22)的列表框在选中超过一项时用数组形式。同一节把 /I 定义为按升序排列的 0 起始选项索引数组,阅读器用它区分两个恰好共享同一导出值的选项。HotPDF 里标量 getter GetFormFieldValue 只读字符串形式,所以数组过一道它就把导出降级成了空字符串,旧的 XFDF 导入则用 LF 拼接重复的 <value> 元素。想象一个导出值是 Deep、换行、Blue:拼接之后 Deep\nBlue\nRed 可能是两项选中也可能是三项,文件没给你任何判断依据。修法就是彻底不再在往返中途使用标量
导出的 FDF 和 XFDF 文件里有什么?
HotPDF 把多选值写成 FDF 里的类型化数组和 XFDF 里每个选中项一个 <value> 元素,边界因此留在磁盘上。FDF 里每个条目保持它在源 PDF 里的拼写:十六进制字符串按 hex 输出,字面字符串由一个统一的辅助函数转义,把 CR 和 LF 变成 \r 和 \n。XFDF 里根节点按 ISO 19444-1 的要求带 xml:space="preserve",意味着文本元素内的任何空白都算数据。HotPDF 因此把每个 <value> 的开始标签、转义文本和结束标签一口气写出,缩进留在元素外面,并把 CR、LF 和 TAB 编码成字符引用,让应用行尾规范化的 XML 解析器改不了原始字节
<!-- FDF:每字段一个类型化数组 -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>
<!-- XFDF:每个选中项一个 <value> -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
<fields>
<field name="options">
<value>Deep
Blue</value>
<value>Red</value>
</field>
</fields>
</xfdf>
写调用代码之前有两个导出边界情形值得知道。第一,ExportLoadedFormToFDF 在创建目标文件之前先在内存里构建完整的 FDF body(2.755.1 修复),所以一个导不出的值——比如数组里装了非字符串——会在不截断既有文件的前提下抛异常。第二,列表框同时提供空字符串导出值时,空选中在 XFDF 里是含糊的,因为 <value/> 可以表示什么都没选、也可以表示选中了空选项。ExportLoadedFormToXFDF 在这种情况下抛异常而不是猜,而且它在打开目标文件之前就抛。FDF 没有这种含糊,因为 /V [] 和 /V [()] 是不同的。两个 FDF 导出器也都会跳过没有 /T 名字的纯控件末端,与 XFDF 导出器一致,因为任何导入器都不可能把这些条目对回某个字段
var
Pdf: THotPDF;
Written: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
begin
// 多选列表框被写成 /V [(...) (...)]
Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
try
Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
except
on E: Exception do
// 空选中加空导出选项:XFDF 分不清两者,
// 既有的 .xfdf 文件保持原样
ShowMessage('XFDF export refused: ' + E.Message);
end;
end;
finally
Pdf.Free;
end;
end;
导入时 HotPDF 怎么校验多选值?
HotPDF 只在目标是一个设置了 MultiSelect 标志的 choice 字段、且数组里每个值都匹配字段 /Opt 数组中的某个导出值时,才接受导入的数组。每个选项槽只能用一次,所以一个有两个选项共享导出值 b 的列表接受 [<62> <62>] 作为两个不同选中、拒绝第三个 b。重建的 /I 跟随 /Opt 顺序而不是传入值的顺序,因为 §12.7.4.4 要求索引升序。HotPDF 把新的 /V 和 /I 构建成分离对象,只在所有值都通过校验之后才赋值,所以被拒的值绝不会留下半个数组或过期索引。拷贝写入的是正被导入的字段而不是共享的祖先数组,从 FDF 进来的 hex 拼写在保存时保持 hex,依赖该列表框计算的字段被标记待重算。如果你只需要设一个值,在已加载 PDF 中设置单个表单字段值走的是标量路径——那条路径按设计不处理多选
有些其他工具把纯 ASCII 的导出值写成不带字节顺序标记的 hex string,比如 <416272>,然后把这些十六进制数字当文本写出 XFDF。回来时严格按字面比较会失败,导入中止。2.755.1 加了一次重试:当某个值匹配不到任何选项时,HPDFHexSpellingText 把文本当 hex 负载解码、再比较一次结果。重试只作用于本来会抛异常的输入,所以绝不会改动已经匹配的值。同一版还让标量与数组路径使用同一个 Unicode 解码器——理解 PDFDocEncoding、带任一字节顺序标记的 UTF-16 和 UTF-8。在此之前,在混用编码的文档里,同一个逻辑值可能在这条路径上匹配、在那条路径上失败
为什么合法的 FDF 文件在解析时仍会丢字段?
不跟踪十六进制字符串的 FDF 扫描器会在 hex 值恰好紧挨字典结束符收尾时把字段字典拦腰截断。在 << /T (region) /V <416273>> 里,第一个 > 关闭的是 hex 字符串,但天真的扫描器把它和下一个 > 一起读成字典结束,字段就被无声丢掉了。文件级 FDF 导入器早已跟踪自己是否在 hex 字符串内,2.755.1 让 ImportLoadedInterchangeFromFDF 背后的数组和字典扫描器也一样。第二个问题关于间接引用。FDF 文件是一个有自己对象编号的小型 PDF 语法文档(ISO 32000-1 §12.7.7),所以 /V [11 0 R] 这样的值指的是 FDF 文件的对象 11,不是你正在填充的 PDF 的对象 11。HotPDF 的简化 FDF 解析器不解析文件内部的引用,所以它拒绝这样的数组,而不是去读目标文档里恰好是对象 11 的那个东西
文件、流与 XFDF 导入的报告错误方式不同
三条导入路径校验方式相同、报告失败的方式不同,值得刻意挑一条。ImportLoadedFormFromFDF 跳过任何校验失败的字段、返回实际应用的字段数,所以数量低于预期是唯一的问题迹象。ImportLoadedInterchangeFromFDF 和 ImportLoadedFormFromXFDF 在第一个被拒字段处抛异常。每个字段各自提交,所以异常之前处理过的字段保留新值。别把其中任何一条当作覆盖整个交换文件的事务:需要全有或全无时,在异常发生时丢弃已加载的文档,而不是保存它
var
Pdf: THotPDF;
Source: TMemoryStream;
Status: AnsiString;
Info: THPDFFDFInterchangeInfo;
begin
Pdf := THotPDF.Create(nil);
Source := TMemoryStream.Create;
try
Source.LoadFromFile('order-form-reviewed.fdf');
if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
try
// 仅字段;/Opt 之外的值或非多选目标会抛异常
if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
Pdf.SaveLoadedDocument('order-form-filled.pdf');
except
on E: Exception do
ShowMessage('Import rejected, nothing saved: ' + E.Message);
end;
finally
Source.Free;
Pdf.Free;
end;
end;
扩展 XFDF 回调而不破坏既有调用方
低层 XFDF 单元里的数组支持住在一个独立的记录 THPDFXFDFArrayAccess 和 HPDFXFDFExportFields、HPDFXFDFImportFields 的新重载里,而不是作为新字段加在既有 THPDFXFDFAccess 记录的尾部。原因是二进制兼容。把 THPDFXFDFAccess 当局部变量填的代码往往只设置自己知道的槽位、从不清空其余的,所以加进那个记录的新函数指针会带着栈垃圾,而库会把它当成真回调。用独立记录,老调用方保持老布局和老重载,那些重载在内部传一个全 nil 的数组记录。原始的标量导入重载为兼容起见仍用 LF 拼接重复值,只有感知数组的重载把它们分开。绑定自己的数据存储时,从 Default(THPDFXFDFArrayAccess) 出发。任何取列表值的字段都让 GetFormFieldValueArray 返回 True——包括什么都没选的——返回 False 则回落到标量回调
uses HPDFXFDF;
// 普通函数指针,不是 "of object":Context 携带你自己的存储
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
out Values: THPDFXFDFValueArray): Boolean;
begin
Result := TFormStore(Context).IsListField(FieldIndex);
if Result then
Values := TFormStore(Context).Selections(FieldIndex);
end;
procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
Access: THPDFXFDFAccess;
ArrayAccess: THPDFXFDFArrayAccess;
begin
Access := MakeStoreAccess(Store); // 你既有的标量绑定
ArrayAccess := Default(THPDFXFDFArrayAccess); // 每个未用槽位都是 nil
ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;
多选交换作用于已存在且 /Ff 里设了 MultiSelect 位的列表框。choice 字段及其标志位最初怎么创建,见向已加载 PDF 添加 ListBox 和其他 AcroForm 字段。走 XFDF <annots> 树的批注标记,见HotPDF 的 XFDF 批注导入与导出。完整 API 参考和试用下载在 HotPDF Delphi PDF component 页面