技术文章

FDF 与 XFDF 往返中的多选 PDF 字段(Delphi)

由 Deep、一个换行符和 Blue 组成的列表框导出值是完全合法的 PDF,名为 Red 的第二个选项也一样。两项都选,把选中项展平成一个以 LF 分隔的字符串写进交换文件,对端收到的就是 Deep\nBlue\nRed:两个选择还是三个,字节里没有任何东西说得清。多选值要从 FDF 或 XFDF 完整无缺地回来,唯一的办法是全程保持数组形态——从它离开 PDF 的那一刻,直到被写回去

为什么换行拼接永远装不下多选值?

拼接后的字符串丢掉了值与值之间的边界,而且因为值本身允许包含分隔符,任何导入器都无法把边界还原回去。ISO 32000-1 §12.7.4.4 允许 choice 字段的 /V 条目要么是单个文本字符串,要么是文本字符串数组;带 MultiSelect 标志(/Ff 的第 22 位)的列表框,只要选中项多于一个就用数组形态。同一节还定义了 /I——一个按升序排列的零基选项索引数组,阅读器靠它区分共享同一导出值的两个选项。这两种结构都塞不进字符串类型的 API。HotPDF 的 GetFormFieldValue 返回 AnsiString,读的是 /V 的字符串形态,所以构建在它之上的任何导出,每个字段永远只能写出一个标量

导入侧有镜像版的问题。XFDF 把每个选中项写成独立的 <value> 元素,而把它们交给标量设置器的导入器必须先把它们粘起来,通常用 LF。对上面的 Deep/Blue 选项,这层胶水与数据本身无法区分。因此 HotPDF 让标量彻底远离往返过程的中间地带:ExportLoadedFormToFDF、ExportLoadedInterchangeToFDF 和 ExportLoadedFormToXFDF 直接从字段读数组,配套的导入方法构建新数组,全程不经过拼接字符串

天真的多选往返:两个选中的列表框选项(其一内嵌换行符)经标量 GetFormFieldValue 路径被展平为单个字符串 Deep、换行、Blue、换行、Red,下游读者既可解析成两个选择也可解析成三个
把多选值拼成一个字符串摧毁了值的边界,而自身含换行符的导出值让展平形态变得有歧义

导出的 FDF 和 XFDF 文件里有什么

落到磁盘上,边界仍然可见。在 FDF 里,多选字段变成一个带类型的数组,每个条目保留它在源 PDF 里的写法:十六进制字符串按十六进制导出,字面量字符串按字面量导出,CR 和 LF 转义为 \r 和 \n。在 ExportLoadedFormToFDF 写出的文件里,字段上的富文本 /RV 或非零的本地 /Ff 会一并写进同一个字段字典。在 XFDF 里,根元素声明 xml:space="preserve",这是 ISO 19444-1 的要求,它把文本元素内的每个空白字符都变成数据。这对美化排版是个陷阱。HotPDF 把每个 <value> 的开始标签、转义文本和结束标签写在同一行,缩进留在元素之外,并把 CR、LF 和 TAB 编码为字符引用,这样应用行尾规范化的 XML 解析器就无法把 CR LF 对改写成单个 LF

HotPDF 为多选列表框写出的导出形态:FDF 每个字段带一个类型化数组,/V 为 [(Deep 换行 Blue) (Red)] 加十六进制区域值;XFDF 在 xml:space preserve 下每个选中项一个 value 元素,空白算数据
边界在磁盘上保持可见:FDF 把每个选中项留作独立的数组条目,XFDF 把每个选中项写进单独的 value 元素,导入器无须猜测
<!-- 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&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

写调用代码时有两个导出边界情况要注意。ExportLoadedFormToFDF 先在内存里组装完整的 FDF 主体,然后才创建目标文件,所以无法导出的值(比如数组里装着非字符串)会在磁盘上既有的 .fdf 还完好无损时抛出。第二种情况是 XFDF 格式自身的一个真空:列表框同时提供空字符串导出值时的空选中。<value/> 既可以表示"什么都没选",也可以表示"选了空选项",格式没有第三种写法。ExportLoadedFormToXFDF 在这种情况下、在打开目标文件之前抛出。FDF 没有这个问题,因为 /V [] 和 /V [()] 是不同的对象。三个导出器都会跳过没有 /T 名称的纯 widget 末端,因为任何导入器都无法把这种条目匹配回字段

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;

导入的选中项如何对照 /Opt 校验?

只有当目标是设置了 MultiSelect 标志的 choice 字段、且每个值都匹配字段 /Opt 数组里的某个导出值(对双元素选项对取第一个元素)时,HotPDF 才接受传入的数组。每个选项槽只能用一次。因此一个有两个选项都导出 b 的列表,会接受 [<62> <62>] 作为两个不同的选择,并拒绝第三个 b,因为没有空槽剩给它。重建的 /I 按 /Opt 顺序排列,与值到达的顺序无关,这保证索引按 §12.7.4.4 的要求升序。新的 /V 和 /I 先建成分离对象,等每个值都通过之后才赋给字段,所以被拒的值不可能在字段上留下半个数组或过期索引

赋值完成后,字段会拿到新的外观流。手写导入器最容易漏掉的就是这一步,而症状很迷惑人:阅读器画的是存储的 /AP,不是 /V,所以数据变了,旧选中却还留在屏幕上和打印里。列表框还会加入已变更字段集合,这意味着一次无参数的 RecalculateLoadedFormFieldsIncremental 调用会恰好重跑读取它的那些计算。从 FDF 到达的十六进制写法在保存时仍是十六进制。如果你只需要设置一个标量值,在已加载 PDF 中设置单个表单字段值覆盖了那条路径——按设计它不处理多选

HotPDF 对多选值的导入校验:目标必须是 /Ff 里设置了 MultiSelect 的 choice 字段,每个传入值必须匹配某个 /Opt 导出值且每个槽只用一次,/I 按 /Opt 顺序升序重建,分离的 /V 与 /I 只在全部值通过后才赋值
任何东西写入之前,每个传入值都先对照字段选项检查,所以被拒的值绝不会留下半个数组或过期的选择索引

有一种输入模式值得专门处理。PDF 可以把纯 ASCII 导出值存成不带字节序标记的十六进制字符串,Abr 写作 <416272>,而逐字复制 PDF 字符串主体的导出器会产出元素文本为原始数字 416272 而非解码文本的 XFDF。携带十六进制编码导出值原始数字的 XFDF 无法按写法匹配 /Opt:严格比较找不到叫 416272 的选项,导入失败。HotPDF 的 XFDF 导入器收到这种文件时,会把未匹配的值按十六进制载荷解码后重试一次。这次重试刻意收得很窄:只对完全无精确匹配的值运行;每个精确匹配都先占好槽位,所以被挽救的写法绝不可能抢走后面某个精确写法需要的选项槽;存储的 /V 条目是选项自己的 PDF 字符串,所以 /V 和 /I 描述的是同一个选择。这是恰到好处的宽容:它只在导入本来要抛异常的地方触发,已经匹配的值绝不会被重新解释。匹配本身走单一的 Unicode 解码器,认得 PDFDocEncoding、带任一字节序标记的 UTF-16 和 UTF-8,所以同一个逻辑文本即使在混合编码的文档里也匹配同一个选项

FDF 解析的两个陷阱:十六进制终止符与外部对象号

第一个陷阱是词法上的。在 << /T (region) /V <416273>>> 里,第一个 > 关闭十六进制字符串,接下来两个才关闭字典。只找 >> 配对的扫描器会提前一个字符结束字典,丢掉整个字段。HotPDF 的文件级 FDF 导入器在切分 /Fields 数组时跟踪自己是否处于十六进制字符串或字面量内部,而 ImportLoadedInterchangeFromFDF 背后的流式读取器把整个文件当 PDF 语法做词法切分,所以两者都能看出三个闭合括号,而天真的扫描只看到两个

第二个陷阱与引用有关。FDF 文件是一个带自身对象编号的小型 PDF 语法文档(ISO 32000-1 §12.7.7),所以 /V [11 0 R] 指的是 FDF 文件的对象 11,绝不是被填充 PDF 的对象 11。拿目标文档去解析它会悄无声息地拖进那个文档里恰好占用该编号的任何对象。在 Delphi 2009 及以后的构建里,交换读取器把 FDF 解码成完整的对象图,在图内解析引用,像校验任何内联数组一样校验得到的字符串,FDF 里没有对象 11 时抛出异常。文件级的 ImportLoadedFormFromFDF 不借助 FDF 的对象表、独立读取每个字段字典;引用数组过不了它仅限字符串的检查,该字段被跳过

选择导入路线:跳过、抛出还是回滚

三条导入路线用同一套方式校验多选值,差别在一个值没通过时怎么办。ImportLoadedFormFromFDF 跳过任何未通过的字段,返回实际应用了的字段数,所以计数低于你送入的条目数就是唯一的出事信号。ImportLoadedFormFromXFDF 和 ImportLoadedInterchangeFromFDF 在第一个被拒字段处抛出,而且每次调用在已加载文档上作为一个事务运行:当异常离开方法时,调用已经改过的每个字段(交换路线还包括每个批注)都会被回滚。返回 False 的交换导入同样回滚,Status 和 info 记录一并清空

经过人工审阅再送回的数据,用会抛出的路线之一。半套用的订单表单比被拒的更糟,因为保存下来的 PDF 里没有任何东西能说明哪些列表框来自审阅过的文件、哪些还留着旧值。ImportLoadedFormFromFDF 的跳过加计数行为适合另一类工作:拿一份可能按表单略有不同的修订版生成的 FDF,尽力做预填充

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
      begin
        // 只重跑读取了导入字段的那些计算
        Pdf.RecalculateLoadedFormFieldsIncremental;
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
      end;
    except
      on E: Exception do
        ShowMessage('Import rejected, document unchanged: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

把你自己的数据存储绑到 XFDF 数组回调上

HPDFXFDF 单元负责在 XFDF 和任何能用回调描述的字段存储之间转换,它把数组支持放在一个独立的记录 THPDFXFDFArrayAccess 里,并为 HPDFXFDFExportFields 和 HPDFXFDFImportFields 增加了重载。往既有的 THPDFXFDFAccess 记录末尾追加新槽位更省事,但是错的。调用者常把这个记录声明为局部变量、只填自己认识的槽位,末尾新添的函数指针就会装着栈垃圾,而库会把它误认成回调。分成独立记录后,既有代码保持原有的布局和重载,那些重载在内部传递一个全 nil 的数组记录

面对重复的 <value> 元素,两个导入重载行为不同。原有的标量重载仍然用 LF 拼接并调用 SetFormFieldValue,因为既有调用者依赖这个约定。数组感知的重载从不拼接:它把列表交给 SetFormFieldValueArray,仅当你的回调拒绝单个值时才回落到标量设置器,回调拒绝两个及以上的列表时抛出。导出时,从 Default(THPDFXFDFArrayAccess) 开始,让你不赋值的槽位(包括可选的 SetFormFieldRV)保持 nil。任何列表值字段都从 GetFormFieldValueArray 返回 True,包括什么都没选中的,返回 False 则让标量回调接管该字段。空数组会导出为单个空的 <value/>,所以提供空字符串选项的存储应当在回调内部拒绝这种情况,与 ExportLoadedFormToXFDF 的做法一致

uses HPDFXFDF;

// 普通函数指针,不是 "of object":Context 携带你自己的存储。
// TFormStore 及其方法是你自己的代码,不是 HotPDF 的一部分
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 批注导入与导出。本文每个调用的方法参考,外加一个能对你自己的表单运行的试用版,都在HotPDF Delphi PDF 组件页