技术文章

Delphi 中 PDF 表单字段的导航(PDFium 组件)

在你代码构建的一个 PDF 表单里按 Tab,光标落在离它该去的地方隔了两个字段的地方,或完全跳过第二列,或在第三个字段之后跳回顶部而不是去第四个。在你的查看器里填发票的人期望键盘像它走过他们用过的每个网页表单那样走过这个表单。当它不那样时,他们伸手去拿鼠标,寻觅下一个框,并悄悄断定你的工具没做完。可预测的字段遍历是一个人们忍受的数据录入查看器与一个他们信任的查看器之间的差别,而这几乎完全是使用正确的焦点 API 而非用模拟点击伪造键盘输入的事情

下面的例子使用 PDFium Component,一个基于 PDFium、面向 Delphi、C++Builder 和 Lazarus 的 VCL/LCL 组件。导航是一个表单查看器必须做对的三件事之一;另外两件——正确打开表单和保存填好的值让它们真正显示出来——才是大多数意外藏身的地方,所以下面三件都涵盖

打开一个表单:FormFill、FormType 与 XFA 之问

字段访问需要由 FormFill 属性控制的表单填充子系统,在文档打开之前启用。一旦激活,FormType 告诉你面对的是哪种表单,而答案改变你能承诺的功能集:

示意图:Delphi PDFium Component 查看器中 FormFill 设置与 FormType 检测分支,分出 ftNone、ftAcroForm 和 ftXfaFull 三种处理
启用 FormFill 后 FormType 开始分支;每个分支承诺不同的功能集
Pdf.FileName := FormPath;
Pdf.FormFill := True;   // 在 Active 前启用;访问任何字段都需要
Pdf.Active := True;

case Pdf.FormType of
  ftNone:
    DisableFormPanel('This document has no interactive form');
  ftAcroForm:
    BuildFieldList;     // 可进行完整的字段导航和编辑
  ftXfaFull:
    ShowXfaNotice;      // XFA 根据自身的 XML 模板渲染
                        // 将字段编辑视为受限功能
end;

两条实用笔记从那个 switch 得出。AcroForm 是标准的 ISO 32000 表单模型,也是这里每个 API 的目标。XFA 文档嵌入它们自己的 XML 表单架构,所以在一个快速 AcroForm 演示之后向客户承诺完整的 XFA 编辑是一项你会后悔的承诺。第二条笔记关于副作用:把 FormFill 设为 True 还初始化文档 JavaScript。在一个数据录入查看器里这恰好正确,因为计算脚本正是让一个运行中的总计在某个人键入时保持当前的东西。在一个为来源不明文件准备的预览窗口里这恰好错误。安全 PDF 预览文章涵盖那种权衡的 FormFill := False 一面

落在用户期望之处的 Tab 键遍历

回到开头的键盘问题。诱惑是通过在下一个部件的矩形上合成一次鼠标点击来伪造 Tab,这在某个字段滚出屏幕或两个部件重叠的那一刻坏掉。焦点 API 直接移动表单自身的焦点,无需几何猜测。五个调用覆盖它:按索引的 FocusFormField、用于步进的 FocusNextFormFieldFocusPreviousFormField、读取你在哪里的 FocusedFormFieldIndex、以及完全丢掉焦点的 ClearFormFieldFocus

示意图:Delphi PDFium Component 查看器中 Tab 键焦点遍历——FocusNextFormField 在单页 Tab 顺序内循环,五个焦点 API 覆盖键盘导航
遍历在单页的 Tab 顺序内循环;跨到下一页仍是查看器的职责
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
begin
  if ssShift in Shift then
    PdfView.FocusPreviousFormField
  else
    PdfView.FocusNextFormField;
  UpdateFieldStatus;  // e.g. "Field 4 of 17: InvoiceDate"
end;

绊倒人的那一点行为是回绕。遍历在当前页的 Tab 顺序上工作并在其内循环:越过最后一个字段你就回到第一个。两个步进函数都返回新的字段索引,或在该页根本不持字段时返回 -1。那种循环是按页的,而非按文档的,这意味着跨到下一页是你的活而非库的。把返回的索引与你出发的那个相比较,在它已回绕时察觉,并在表单本应作为一个连续序列阅读时自己推进 PageNumber。跳过这道检查,一个两页的表单就会悄悄把光标困在第一页上,这是破损 Tab 投诉的又一种风味

一旦其余 UI 对遍历做出反应,它就变得有用。OnFormFieldEnter 事件在焦点到达时触发,而在查看器上 OnFormFieldFocusChange 报告新的字段索引,所以一个侧边面板能与键盘刚刚选中的东西保持同步。当你需要反向映射——从一个屏幕位置到一个字段——FormFieldAt 索引属性为工具提示预览和点击即编辑面板做命中测试。这一切中有一个安静的可访问性回报:因为焦点跟随文档自身的字段顺序,你为 Tab 键接线的那条路径正是一个屏幕阅读器播报的同一条路径,无需额外工作

显示字段名而非原始索引号只需再多一个属性。FormFieldInfo[] 按索引返回一个 TPdfFormFieldInfo 记录,携带字段名、类型、字号、勾选状态、导出值和组成员身份,这正是导航列表该显示的("Field 4 of 17: InvoiceDate" 而非 "4")。单选组是值得一个专用测试文件的案例。多个部件可以共享单个字段名,所以一个从部件天真组装的列表把同一组显示好几次,把每个读它的人都搞糊涂

为何填好的值出来是空白的,以及修复它的那个调用

填满支持队列的另一项投诉比一个行为不当的 Tab 键更令人警觉:一个表单被程序填好,客户在 Acrobat 中打开它,而每个字段看起来都是空的。点进一个字段它的值就弹入视野。数据自始至终都在文件里。缺失的是数据的那幅画面,而原因值得理解一次,因为它解释了一整族 bug

一个 AcroForm 文本字段把它的值存在字段字典的 /V 条目中(ISO 32000-1 §12.7.3.3)。查看器实际绘制的是某种分开的东西:部件在 /AP 下的外观流(§12.5.5),一小段预渲染的内容片段。写 /V 而让 /AP 不动,两者就漂移分开。值在那里;它的渲染版本过时或缺席。Acrobat 恰好在一个字段获得焦点时重建其外观,这就是值仅在点击时出现的全部解释。旧的 NeedAppearances 标志曾要求查看器替你重新生成外观,它从未统一工作过并在 PDF 2.0 中被弃用,而打印服务器和缩略图生成器完全忽略它。它们只画 /AP 别无其他,所以如果 /AP 是空的它们就打印一个空框

通过 FormField[i] 赋值只写 /V。这就是为何填一个表单是一个三步序列,而团队丢掉的是中间那一步:

示意图:AcroForm 字段中 /V 值与 /AP 外观的漂移,以及围绕 GenerateFormAppearances 的三步 Delphi 填充序列
赋值只写 /V;中间那一步才是重绘打印服务器真正渲染内容的地方
procedure TFormViewer.FillAndSave(const Values: array of WString;
  const OutputPath: string);
var
  i: Integer;
begin
  for i := 0 to Pdf.FormFieldCount - 1 do
    Pdf.FormField[i] := Values[i];   // writes /V only

  // 重建 /AP 外观流;否则表单
  // 在 Acrobat 中会显示为空白,直到逐个点击字段
  Pdf.GenerateFormAppearances;

  Pdf.SaveAs(OutputPath);
end;

GenerateFormAppearances 就是整个修复。它从当前值、字体和 quadding 重建每个部件的外观流,这样一个从不运行焦点事件的查看器——一个打印服务器或缩略图器——也照样画出填好的状态。在那一批赋值之后调用它一次,而不是每个字段一次。外观生成做真正的排版工作,而逐字段的调用把那份工作在一个大表单上徒劳地乘开

重新生成外观也是字体和对齐自我主张的时刻,这是一项二阶意外的来源。新的流使用字段的字体、字号和 quadding 在部件矩形内部排布每个值。一个在你的测试表单中舒服坐着的值,在客户的副本中——那里同一个字段更窄——可能被裁剪或收缩。自动调整大小的字段(字号为零)把文本收缩以适配;固定大小的字段则裁剪它。两者都合法,而知道一个给定表单做哪一种的唯一诚实方式是看重新生成的输出而非你写入的字符串。当有人报告文本在一个框的边缘被切断时,这几乎总是原因

把校验当作完工的一部分而非事后的想法。在 Acrobat 中打开保存的文件,在你触碰任何字段之前确认值可见。然后从一个不同的查看器——一个完全忽略表单逻辑的——把它打印成 PDF 或图片,并确认值也挺过那条路径。两者之间,那两道检查捕获 /V 对 /AP 漂移的每一种变体

那些过了演示却在现场失败的字段配置

干净的演示表单藏着一组客户文件不会的边角案例。其中四个占了大多数"在我机器上能跑"的报告

  • 复选框导出值。"勾选"状态并不总是 Yes。一个表单可以自由定义自己的导出值,而写入错误的字符串会让框在视觉上保持未勾选,同时你的代码确信自己设了它。从 FormFieldInfo[] 读导出值,而不是假设某一个
  • 共享名单的单选组。一个字段,多个部件。你赋的值决定哪个部件读为选中,所以假设一个名字映射到一个矩形的 UI 代码最终把焦点环画在错误的按钮上
  • 计算字段。由文档 JavaScript 维护的总计响应字段事件更新。一次绕过那些事件的程序填充要么必须触发重新计算,要么直接覆盖计算字段。一份行项与总计不一致的表单比任一修复都更糟
  • 隐藏的必填字段。条件表单隐藏那些仍被标记为必填的字段。预先决定你的校验是尊重可见性还是原始的必填标志,然后把那个决定写在某个支持能找到的地方

有一处区分值得在它咬你之前敲定:生成外观不是扁平化。GenerateFormAppearances 让值在任何地方可见,同时保留字段可编辑。扁平化把外观烘焙进静态页面内容并永久剥除交互性,这对一份归档副本是对的,而对一份下一个人仍要填的表单是错的。如果 FormType 报告 ftXfaFull 而非 ftAcroForm,这里的编辑表面对它无论如何都不能干净地适用,因为文档从它自己的 XML 模板渲染;察觉那种情况并告诉用户,而不是让他们自己找到那条极限

此处展示的表单填充子系统、焦点遍历和外观生成属于面向 Delphi、C++Builder 和 Lazarus/FPC 的 PDFium Component。如果你的查看器还在表单数据之外处理评审者标记,注解评审文章涵盖那个相邻模型