Technical Article

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

没有要保持同步的目标,这正是关键所在。命名操作在插入和删除页面后仍能正常使用,因为它从一开始就没有指定具体的页面。相比之下,显式的跳转链接会存储目标页面索引,一旦文档增长,您就必须重新对其进行编号。

隐藏操作及其数组陷阱

隐藏操作(ISO 32000-1 第 12.6.4.10 节表 196)可切换一个或多个字段的可见性。这是在不使用脚本的情况下构建显示和隐藏行为的最干净的方法,也是您对于“显示详细信息”链接或两个互斥面板(显示一个会隐藏另一个)所需要的。该操作在其 /T 条目中携带目标,并携带一个决定方向的布尔值 /H:为 true 时隐藏,为 false 时显示。

其中的奥妙完全在于该目标的编码方式,这种细节往往会导致表单在您的机器上工作正常,但在客户的机器上却发生失效。当操作命名单个字段时,/T 被写入为一个文本字符串。当命名多个字段时,/T 被写入为文本字符串数组。旧版查看器处理单元素数组的方式与处理裸字符串不同,因此编码必须根据数量进行分支:如果希望最广泛的阅读器支持它,则单个名称必须输出为字符串,而不是长度为一的数组。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 预填数据

隐藏操作重新排列页面上已有的内容,而导入数据操作则从页面外部引入值。ISO 32000-1 第 12.6.4.8 节表 198 将其定义为从磁盘上的表单数据格式(Forms Data Format,FDF)文件填充 AcroForm 的操作。这是“重新加载示例数据”或“重置为默认值”控件背后的操作,其中 FDF 文件随 PDF 一起交付并保存规范的字段值。该调用与其他调用类似,接受热区矩形、FDF 的路径以及外观位掩码:Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1)。构建 PDF 时该文件无需存在,但在用户点击时它必须存在,并且路径中的任何反斜杠都会为您重写为 PDF 规范的斜杠形式。

有一个限制值得明确指出,因为它经常令人感到意外。导入数据操作指向外部文件,因此在 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 为文本,2 为下压按钮,3 为复选框,4 为单选按钮,5 为选择,6 为签名,7 为拥有子项但自身不绘制任何内容的父项。您传入的标题不能包含句点,因为句点是操作用于寻址子字段的完全限定名称中的分隔符。

单选按钮组和分层表单是通过为父字段提供子字段来构建的。NewChildFormField 在命名的父字段下添加一个子字段,对于单选和选择的情况,AddFormFieldSub 添加单个选项并传回一个临时索引,您可以使用该索引来定位每个选项。当交互阶段结束并且您想要冻结字段以使其当前外观成为永久页面内容时,FlattenFormField 将字段绘制到页面上并将其从表单中移除。扁平化后,后续字段的索引会向下移动一,这是在循环中扁平化多个字段时需要记住的一点。

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;

扁平化调用被特意注释掉了。不调用它,文档就会作为一个活动表单交付,其操作在阅读器中触发。启用它,字段就会被渲染为静态标记,这正是您在表单已填写完成且结果应作为固定记录传输时所需要的。同一个字段,同一段代码,取决于您是否冻结它,会产生两个截然不同的文档。

选择正确的动词

这四个操作根据它们所触及的内容进行了清晰的划分。命名操作移动视口且不需要字段。隐藏操作改变可见性并需要字段标题,字符串与数组的编码已为您处理好。导入数据操作访问磁盘上的文件,因此在 PDF/A 中是受限的。JavaScript 操作运行任意逻辑,最好分割为全局函数包和细小的单操作调用。选择能完成工作的最简单方法:隐藏操作比设置隐藏标志的脚本更具可移植性,而命名操作比存储的页面目标更耐用,因为没有需要维护的页码。

从这里开始,两个相邻的主题完善了这一图景。如果表单是可访问文档的一部分,屏幕阅读器读取的结构树将在我们关于标记 PDF 和可访问性结构的分析文章中介绍。当填写完成的表单必须被锁定并签名时,工作流程在合规性与签名工作台演练中进行了描述。这三者都基于相同的引擎构建,该引擎作为 PDF library for Delphi与本博客其他地方介绍的创建、表单和签名 API 一起交付。