技术文章

Delphi 中的交互式 PDF 表单:动作与 JavaScript

单独一个 PDF 表单字段,只不过是个保存值的盒子。真正让表单像一个小应用那样运转起来的,是附着在它上面的动作:一次点击可以隐藏某个区域、从文件里拉回已保存的数据、跳到最后一页,或者运行一个脚本,把某一列加总。所有这些都不住在字段本身里,而是住在动作字典里,ISO 32000-1 在第 12.6 节把整套动作家族组织起来。本文会依次走过 Delphi 程序最常用的那些动作,并展示 PDFlibPas 如何把它们接到字段或链接上

最值得记住的心智模型,是字段和动作是彼此分离的对象,只是通过一个引用连在一起。一个小部件批注或者链接批注,会在自己的 /A 项里带上一个动作。动作通过标题而不是索引来指向它要操作的字段,所以你给字段起的标题,就是后面所有动作拿来寻找它的句柄。只要把这层分离想清楚,API 就不再像一堆零碎调用,而会显得像同一种模式被应用到四类动词上

命名动作:不依赖页码的导航

最简单的动作完全不携带参数。ISO 32000-1 第 12.6.4.11 节表 194 定义了命名动作:查看器在运行时解释一个符号名称,而不是去跟随某个预存的目标位置。普遍支持的名字一共有四个,恰好也是读者从工具栏上最熟悉的那几个:NextPage、PrevPage、FirstPage 和 LastPage。因为目标相对于查看器当前正在显示的页面而定,所以用这种方式做出来的“下一页”按钮可以在每一页上工作,而不需要你自己计算目标页

在 PDFlibPas 里,命名动作会附着到当前页面上的一个热点矩形。第四和第五个整数参数分别决定要执行的动词和可视外观

// NamedActionType: 0 = NextPage, 1 = PrevPage, 2 = FirstPage, 3 = LastPage
// Options bit 0 (value 1) draws a border around the hotspot
Pdf.AddLinkToNamedAction(500, 560, 60, 18, 0, 1);   // Next
Pdf.AddLinkToNamedAction(40, 560, 60, 18, 1, 1);    // Previous
Pdf.AddLinkToNamedAction(110, 560, 60, 18, 3, 1);   // jump to last page

正因为它根本没有存储一个具体目标,所以才不用额外维护同步。页面插入或删除之后,命名动作仍然成立,因为它从一开始就没有把某一页写死。与之对照的是显式 go-to 链接,那种链接会保存一个目标页索引,一旦文档增长,你就得重新编号

Hide 动作与它的数组陷阱

Hide 动作,也就是 ISO 32000-1 第 12.6.4.10 节表 196,用来切换一个或多个字段的可见性。它是构建显示与隐藏行为时最干净的无脚本方案,特别适合 Show details 这样的链接,或者两个互斥面板那种“显示一个就隐藏另一个”的场景。动作在它的 /T 项里记录目标,再用布尔值 /H 决定方向:true 表示隐藏,false 表示显示

这里真正微妙的地方,完全在于这个目标的编码方式,而且这类细节非常容易造出“自己机器上能用,客户机器上不认”的表单。当动作只命名一个字段时,/T 会写成一个文本字符串;当它命名多个字段时,/T 会写成一个文本字符串数组。旧版查看器对“只有一个元素的数组”和“裸字符串”的处理并不一样,所以编码必须根据数量分支:如果只有一个名字,就必须写成字符串,而不是长度为 1 的数组,才能让尽可能多的阅读器都认它。PDFlibPas 会替你做这个判断。你只要传入用逗号、分号或换行分隔的字段名,写入器就会在一个名字时输出单字符串,在两个及以上时输出数组

// HideFlag non-zero hides the listed fields (/H true); zero shows them.
// One name -> /T is a text string. Two or more -> /T is an array of strings.
Pdf.AddLinkToHideField(40, 700, 90, 18, 'ShippingAddress', 1, 1);
Pdf.AddLinkToHideField(140, 700, 90, 18,
  'ShippingName,ShippingAddress,ShippingZip', 1, 1);

因为这个动作不引用任何外部资源,所以它与 PDF/A 保持兼容。你传进去的名称必须是字段的完全限定标题,这就是为什么一个分组里的子字段必须通过完整的点路径来寻址,而不能只写最后那一截叶子名

ImportData:从 FDF 预填数据

Hide 动作是在重新安排页面上已有的东西,而 import-data 动作则是把页面外部的数据拉进来。ISO 32000-1 第 12.6.4.8 节表 198 把它定义成一种从磁盘上 Forms Data Format 文件填充 AcroForm 的动作。这就是 Reload sample data 或 Reset to defaults 一类控件背后的机制:一个 FDF 文件和 PDF 一起分发,里面保存着规范字段值。调用形式与其他动作相似,同样接收热点矩形、FDF 路径以及一个外观位掩码:Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1)。这个文件在构建 PDF 时不必已经存在,但用户点击时它必须在场;而路径里的反斜杠会自动替你改写成 PDF 规范使用的斜杠形式

有一条限制值得明确写出来,因为它常常让人意外:import-data 动作指向外部文件,所以在 PDF/A 中不被允许。当文档处于 PDF/A 模式时,这个调用会返回零,而且不会添加任何内容,而不是硬生生写出一个验证失败的文件。如果你的流水线目标是归档输出,那么预填这件事必须在生成阶段直接写入字段值,而不能延后到一次点击上

JavaScript:全局包与逐动作脚本

如果逻辑已经超出了显示、隐藏和导入的范畴,动作家族就会延伸到文档级 JavaScript。脚本有两个完全不同的落脚点,而这个区别非常重要。文档级 JavaScript 包只为整个文件存一份,并且会在文档打开时运行,因此它最适合放函数定义和共享状态;逐动作脚本则附着到某个链接或字段上,只有该对象被激活时才会执行,因此它最适合放一句去调用全局包里已经定义好的函数

PDFlibPas 同时提供了这两种能力。AddGlobalJavaScript 在文档级存储一个具名包;如果重复使用同一个名字,就会替换原来挂在这个名字下面的内容。AddLinkToJavaScript 则把一段脚本附着到热点区域,让用户点击时执行它

// Document-level package: define a reusable function once.
Pdf.AddGlobalJavaScript('Totals',
  'function recalcTotal() {' +
  '  var net = this.getField("Net").value;' +
  '  var tax = this.getField("Tax").value;' +
  '  this.getField("Gross").value = Number(net) + Number(tax);' +
  '}');

// Per-action script on a link: just call the shared function.
Pdf.AddLinkToJavaScript(40, 620, 100, 18, 'recalcTotal();', 1);

把函数放在全局包里,再让链接只负责调用它,并不是风格偏好,而是有很实在的收益。这样可以避免把同一段函数体复制到每一个需要它的控件上,也意味着脚本被禁用的查看器遇到点击时,只会简单地什么也不做,而不会因为某个内联脚本块写坏了而出错。这还会让每个动作项本身保持很小,方便你之后自己去检查文件结构

字段、子字段,以及把结果冻结下来

动作总得有字段可作用,所以最好也顺手看看字段是怎样创建出来的。NewFormField 会在当前页面上新建一个字段,并返回它的索引;整数类型用来选择字段类别,其中 1 是 Text,2 是 Pushbutton,3 是 Checkbox,4 是 Radiobutton,5 是 Choice,6 是 Signature,7 则是只拥有子项而自己不绘制任何内容的 Parent。你传入的标题不能带句点,因为点号正是动作用来给子字段写完全限定名时的分隔符

单选组和层级表单,是通过给父字段挂子字段构建出来的。NewChildFormField 会在一个具名父字段下添加子字段;对单选或 choice 这种场景,AddFormFieldSub 则逐个加入选项,并返回一个临时索引,让你拿它去放置每一项。当交互阶段结束,你希望把当前外观永久烘焙成页面内容时,FlattenFormField 会把字段直接绘制到页面上,再把它从表单里移除。字段一旦被 flatten,后面字段的索引就会整体减一;如果你在循环里连续 flatten 多个字段,这就是唯一需要特别记住的地方

var
  Pdf: TPDFlib;
  FldShip: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.SetOrigin(1);          // top-left origin
    Pdf.SetPageSize('A4');
    Pdf.NewPage;

    // A text field the Hide action will target by its title.
    FldShip := Pdf.NewFormField('ShippingAddress', 1);
    Pdf.SetFormFieldBounds(FldShip, 40, 120, 240, 20);
    Pdf.SetFormFieldValue(FldShip, '');

    // Wire a Hide link and a navigation link to this page.
    Pdf.DrawText(40, 110, 'Toggle shipping block:');
    Pdf.AddLinkToHideField(220, 100, 70, 16, 'ShippingAddress', 1, 1);
    Pdf.AddLinkToNamedAction(500, 800, 60, 18, 3, 1);  // Last page

    // A document-level script available to every event in the file.
    Pdf.AddGlobalJavaScript('OnOpen',
      'app.alert("Form ready", 3);');

    // Freeze the field if the output should no longer be editable.
    // Pdf.FlattenFormField(FldShip);

    if Pdf.SaveToFile('form_actions.pdf') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

这里故意把 flatten 那一行注释掉了。不启用它,文档就是一个活表单,里面的动作会在阅读器中触发;启用它,字段就会被渲染成静态标记,这正是当表单已经填完,而且结果应该作为固定记录传播时你想要的行为。还是同一个字段,还是同一段代码,只因为你有没有把它冻结,最终会得到两份性质完全不同的文档

怎样选对动词

这四类动作可以按照它们触碰的对象被清晰分开。命名动作只移动视口,不需要字段;Hide 动作负责改变可见性,需要字段标题,而字符串与数组之间的编码细节已经由库替你处理;import-data 动作会触及磁盘文件,所以在 PDF/A 中受限;JavaScript 动作则可以运行任意逻辑,而最稳的组织方式通常是“全局函数包 + 小而薄的逐动作调用”。总之,能用最简单的动词完成任务时,就别上更重的那一个:Hide 动作通常比自己写脚本去设置 hidden 标志更便携,命名动作也通常比保存具体页号的目标位置更耐用,因为你根本没有数字需要维护

接下来有两个相邻话题可以把整幅图补全。如果表单属于一份可访问文档,那么屏幕阅读器会走的结构树,可以继续看我们关于 tagged PDF 与可访问性结构的文章;而当已经填完的表单需要被锁定并签名时,对应工作流则见合规与签名工作台演练。这三个主题都建立在同一个引擎之上,也就是随 PDF library for Delphi 一起提供的那套创建、表单和签名 API