技术文章

在 Delphi 中使用 HotPDF 构建 AcroForm 字段和动作

一个理赔团队发布了一份由 Delphi 生成的同意书,在 Acrobat 里看起来没有任何问题:每个复选框都能显示,每个标题都对齐,版面也与纸质原件一致。三周后,接收服务开始拒绝三分之一的提交。后端期望同意复选框提交值 Y;表单导出的却是 Yes,因为构建它的人假定可见标题和导出值是同一回事。渲染后的页面完全看不出这个差异。这就是 AcroForm 开发的核心特性:可见的 widget 和底层数据契约是两套结构,而有人打开文件目视检查时,通常只验证了其中一套。

HotPDF 是一个原生 VCL 组件,可直接从 Delphi 和 C++Builder 代码写入 AcroForm 字段字典,因此这两套结构都处在显式程序控制之下,也都可能各自出错。下面讨论字段创建、按钮动作和字段级 JavaScript,重点放在页面预览与表单数据会悄悄分叉的位置。

字段名是路由键,不是标题

每个 AcroForm 字段都有一个完全限定名,ISO 32000-1 §12.7.3 规定,字段值在 FDF、XFDF 和 HTTP 表单提交中使用的键就是这个名称,而不是可见标题。对来自 VCL 窗体设计的开发者来说,这会带来两个经常令人意外的后果,因为在那里控件名称通常只是代码里的一个标识符。

第一,两个完全限定名相同的字段并不是两个字段。PDF 模型会把它们视为同一个字段的两个 widget 注释,共享同一个值:在一个位置输入,另一个位置会立即更新。把客户姓名重复显示在合同的每一页,是这种行为的合理用法。如果生成循环意外在三页里重复使用 'Field1',那就是 bug,而且视觉检查抓不到,因为每一页仍然显示自己的 widget,只有用户开始输入时才会看到镜像。

第二,像 applicant.email 这样的点分名称会建立层级:父节点 applicant 会把子字段分组,供只针对表单一部分的重置和提交操作使用。一开始就用层级方式命名字段几乎没有成本,而接收系统第一次要求“只提交申请人块”时就会得到回报。

单选按钮还增加第三条规则:应作为一组互斥切换的按钮必须共享组名。在 HotPDF 中,使用相同组名调用 AddRadioButton 会把它们的 widget 连接到同一个父字段,每个按钮的导出值,例如 'basic''full',用于标识所选选项。如果给每个按钮都起唯一名称,得到的是一组彼此独立的开关,而不是互斥单选组。

按页面创建字段集

HotPDF 通过 THPDFPage 方法放置字段,因此每个字段都属于创建它的页面对象。这里最容易踩中的顺序陷阱是:AddPage 会立即把 CurrentPage 指向新页面,所以之后发出的任何字段调用都会落在新页面上,即使从逻辑上它属于上一页。应当在调用 AddPage 之前完整构建当前页,包括绘制内容和字段。

procedure BuildClaimForm(Pdf: THotPDF);
begin
  // Page 1: applicant block
  Pdf.CurrentPage.AddTextField('applicant.name', '', Rect(50, 700, 300, 722));
  Pdf.CurrentPage.AddTextField('applicant.email', '', Rect(50, 660, 300, 682));
  Pdf.CurrentPage.AddCheckBox('consent', 'Y', Rect(50, 620, 70, 640), False);
  Pdf.CurrentPage.AddRadioButton('coverage', 'basic', Rect(50, 580, 70, 600), True);
  Pdf.CurrentPage.AddRadioButton('coverage', 'full', Rect(90, 580, 110, 600), False);
  Pdf.CurrentPage.AddComboBox('plan', 'Standard',
    ['Basic', 'Standard', 'Premium'], Rect(50, 540, 200, 565));

  Pdf.AddPage;  // CurrentPage now points at page 2
  Pdf.CurrentPage.AddListBox('riders', 'None',
    ['None', 'Flood', 'Earthquake'], Rect(50, 500, 200, 600));
end;

坐标遵循 PDF 约定:原点在页面左下角,这也是 TextOut 用于绘制文本的约定。因此 Rect(50, 100, 200, 120) 位于 Letter 页面靠近底部的位置,而不是顶部。从 VCL 移植布局表的团队,因为 VCL 中 Y 从顶部向下增长,第一版常常会把所有字段垂直镜像。应在一个共享辅助函数中转换坐标,而不是在每个调用点各自处理,这样修复会一次性覆盖所有位置。

把按钮连接到 URI、JavaScript 和提交动作

推送按钮在绑定动作之前什么也不会做。HotPDF 通过 THPDFButtonAction 枚举公开 ISO 32000-1 §12.6.4 的动作类型,包括 baURIbaJavaScriptbaSubmitURLbaResetFormbaHidebaShowbaNamed,并提供两个方法在创建按钮的同时绑定动作。

// Open a help page in the system browser
Pdf.CurrentPage.AddPushButtonWithAction('btnHelp', 'Help',
  'https://www.example.com/claims-help', Rect(320, 700, 420, 730), baURI);

// Run viewer-side JavaScript
Pdf.CurrentPage.AddPushButtonWithAction('btnRecalc', 'Recalculate',
  'app.alert("Totals updated.");', Rect(320, 660, 420, 690), baJavaScript);

// Submit as XFDF and keep empty fields in the payload
Pdf.CurrentPage.AddPushButtonWithSubmitAction('btnSubmit', 'Submit claim',
  'https://api.example.com/claims', Rect(320, 620, 420, 650),
  [sffXFDF, sffIncludeNoValueFields]);

提交标志通常比实际得到的设计关注更重要。AddPushButtonWithSubmitAction 接收一个 THPDFSubmitFormFlags 集合,空集合会产生普通的 url-encoded POST,许多示例端点能接受这种格式,但许多生产端点不能。加入 sffXFDF 会把载荷切换为 XFDF;sffGetMethod 会改变 HTTP 动词;sffIncludeNoValueFields 会让空字段出现在载荷中,而不是被静默丢弃,这在消费者需要区分“缺失”和“空白”时很关键。这个标志集合实际上是你与接收端点之间接口契约的一部分,因此应与解析提交内容的团队一起选择,而不是等第一批被拒后再补救。

字段级 JavaScript:keystroke、format、validate

除按钮点击外,HotPDF 还可以把 JavaScript 绑定到支持脚本的查看器在数据录入期间触发的字段事件。三个触发点对应输入生命周期中的不同阶段:keystroke 动作在字符到达时运行,format 动作在变更提交后重写显示值,validate 动作接受或拒绝已提交的值。

// Reject committed values that are not plausible email addresses
Pdf.AttachFieldKeyStrokeAction('applicant.email',
  'if (event.willCommit && !/^[\w.-]+@[\w.-]+\.\w+$/.test(event.value)) event.rc = false;');

// Display US phone numbers as (NNN) NNN-NNNN
Pdf.AttachFieldFormatAction('applicant.phone',
  'event.value = event.value.replace(/(\d{3})(\d{3})(\d{4})/, "($1) $2-$3");');

// Refuse applicants under 18 at commit time
Pdf.AttachFieldValidateAction('applicant.age',
  'if (parseInt(event.value) < 18) event.rc = false;');

在 keystroke 或 validate 脚本中设置 event.rc = false 会让查看器拒绝该输入。需要牢记的限制是:这段代码只在带有 JavaScript 引擎的查看器中执行。Acrobat 和少数桌面产品会运行它;大多数移动阅读器、浏览器内嵌渲染器和打印流水线会完全忽略它。字段脚本可以提高有脚本环境用户的数据质量,但它不是安全边界,每个提交值到达服务器时仍然需要服务端验证。

能通过视觉审阅的缺陷

有些表单缺陷在“打开看一眼”的检查中结构上不可见。下面四类问题占了大多数进入支持流程的 AcroForm 升级案例,而且都可以在发布前用机械方式捕获:

  • 导出值漂移。AddCheckBox('consent', 'Yes', ...) 创建的复选框会提交 Yes;如果消费者按 Y 匹配,就会拒绝每一次提交,而页面看起来完全正常。应从 Acrobat 将填好的表单导出为 XFDF,并把值与消费者的 schema 做差异比较。
  • 意外值镜像。 重复的完全限定名会把字段合并成一个。症状出现在数据录入时,不出现在生成时,所以应通过在表单里输入来测试,而不是只渲染它。
  • 组合框值不在选项列表中。 如果传给 AddComboBox 的当前值不在选项里,不同查看器会在显示它、清空它或标记它之间产生分歧。默认值应保持在列表内。
  • 流程关闭后字段仍可编辑。 HotPDF 没有针对 AcroForm 字段的外观扁平化调用;冻结已完成表单的受支持方式,是用 ffReadOnly 标志创建字段,这样值仍通过字段自身的 appearance stream 渲染,同时阻止编辑。只读字段仍是活动表单对象,下游组装和签名工具会以可预测方式处理它。

还有一种查看器端行为值得写进回归说明,即使没有代码变更可以修复它:企业部署的 Acrobat 可以通过策略禁用 JavaScript 或限制提交目标,因此开发期间一直可用的动作,在客户桌面上可能完全不响应。应为提交提供可见的后备路径,至少在按钮无效时给出打印说明。

表单工作如何连接到文档其他部分

签名字段本身也是一种 AcroForm 字段类型,因此后续需要认证或会签的表单,应在生成阶段预留该字段,而不是事后补丁式加入;字节级机制详见关于使用 HotPDF 进行数字签名和 PAdES 签署的配套文章。如果输入来源是 XFA 包,而不是原生 AcroForm,把 XFA 扁平化为 AcroForm 字段是另一套工作流,有自己的损失模型,因为这两种表单技术在单个文件内互相排斥。

本文展示的字段、动作和触发器方法,都是面向 Delphi 和 C++Builder 的标准 HotPDF Component API 的一部分;产品页链接了完整参考,包括字段标志重载和完整的提交标志枚举。