技术文章

PDFium 组件:PDF 表单 Tab 顺序与 /Tabs 策略

从 3.223.0 版起,PDFium Component for Delphi 按每页声明的 /Tabs 策略排序表单字段导航:R 为行序、C 为列序、S 为结构序,外加 PDF 2.0 的 A 和 W。TPdf.FormFieldTabOrder 返回该顺序,其带状态的重载会报告应用的是声明的策略还是回退方案

催生这一切的用户抱怨永远相同:用户填一份两栏保险表,在名字字段后按下 Tab,光标跳到右栏底部。索引没有任何损坏。生产者只是先把右栏写进了 /Annots,又在页面上声明 /Tabs /R 来修正视觉顺序;而一个沿 /Annots 走、无视声明的查看器自然落错地方。widget 索引与注释索引一文讲了 widget 序数与 /Annots 数组的关系。在 PDFium Component 里,声明的策略只要能确立就赢;确立不了时,组件会告诉你为什么

/Tabs 条目实际声明了什么?

/Tabs 是页面对象里的可选名(ISO 32000-2 §7.7.3.3;ISO 32000-1 的 Table 30),告诉处理器按什么顺序访问该页的注释,其语义在 §12.5.1 的注释部分展开。PDF 1.5 引入三个值,PDF 2.0 又加了两个:

  • /R,行序:从上到下逐行,行内按阅读器的阅读方向
  • /C,列序:按阅读方向逐列,列内从上到下
  • /S,结构序:逻辑结构树引用注释的顺序
  • /A,注释数组序:/Annots 自身的顺序(PDF 2.0)
  • /W,widget 序:widget 按数组顺序在前,其余注释在后(PDF 2.0)

坑到手写实现的那个细节是继承。/Tabs 只属于页面字典。ISO 32000-2 §7.7.3.4 把可继承的页面属性限定为 Resources、MediaBox、CropBox 和 Rotate,所以挂在 /Pages 节点上的 /Tabs 键毫无意义,沿父链上翻找它的阅读器是在发明一个作者从未设过的策略。PDFium Component 只从页面本身读 /Tabs,别处不看

用 FormFieldTabOrder 读取声明的顺序

TPdf.FormFieldTabOrder 以 0 基 widget 序数返回当前页表单字段在声明 tab 顺序下的排列,带 out Status: TPdfFormTabOrderStatus 参数的重载还会解释这个顺序是怎么来的。同样的组合在 TPdfView 上也有,普通重载用完全相同的策略、只是丢弃状态

uses
  PDFium;

const
  ModeNames: array[TPdfFormTabOrderMode] of string =
    ('unspecified', 'R', 'C', 'S', 'A', 'W', 'unsupported');

procedure DumpTabOrder(const FileName: string; PageNo: Integer);
var
  Pdf: TPdf;
  Order: TPdfIntegerArray;
  Status: TPdfFormTabOrderStatus;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.FormFill := True;          // 本来就是默认值;处理字段需要它
    Pdf.Active := True;
    if not Pdf.Active or (Pdf.FormType <> ftAcroForm) then
      Exit;
    Pdf.PageNumber := PageNo;
    Order := Pdf.FormFieldTabOrder(Status);
    Writeln('requested ', ModeNames[Status.RequestedMode],
      ', applied ', ModeNames[Status.EffectiveMode]);
    if Status.UsedFallback then
      Writeln('fallback: ', Status.ErrorMessage);
    if Status.UnstructuredWidgetCount > 0 then
      Writeln(Status.UnstructuredWidgetCount, ' widgets not in the structure tree');
    for I := 0 to High(Order) do
      Writeln(I, ': field ', Order[I], ' ',
        string(Pdf.FormFieldInfo[Order[I]].Name));
  finally
    Pdf.Free;
  end;
end;

状态记录把一次无声的排序决定变成可以记日志的东西。RequestedMode 是页面声明的,EffectiveMode 是实际应用的,UsedFallback 为 true 表示声明的策略无法兑现、退回了注释数组序,ErrorMessage 里带简短原因。SnapshotAvailable 说明组件到底能不能读到文档元数据。缺失的 /Tabs 被报告为回退到注释数组序,而不是冒充声明的策略,所以支持日志分得清“作者要求了这个顺序”和“页面什么都没要求”

页面 /Tabs 策略值 R、C、S、A 与 W,以及 Delphi 的 PDFium Component 在声明顺序被应用或回退时报告的状态字段
缺失 /Tabs 会作为回退到注释数组序被报告出来,而不是冒充成声明的策略

行序列序为什么不直接排 /Rect 值?

行序和列序必须在显示几何里计算,而不是原始注释坐标,因为页面的 /Rotate 和文档的阅读方向都会改变“行”的含义。把带 /Rotate 90 页面的 /Rect 数组按 y 坐标排序,得到的是查看器显示为列的东西。组件因此把每个 widget 矩形经页面显示变换(含固有旋转)映射到 1/64 点分辨率,再从那里分组

阅读方向来自 catalog 的 ViewerPreferences 字典(ISO 32000-2 §12.2)。/Direction /R2L 反转行内顺序和列顺序,阿拉伯语或希伯来语表单要的正是这个。/Direction 缺席时用 L2R。装的既不是 L2R 也不是 R2L 时,行序列序回退到注释数组序并在状态里给出原因——瞎猜方向比承认没有更糟

分组是各处理器合理分歧的地方,所以值得知道确切规则。行序下,最顶上未访问的 widget 成为锚点,垂直中心落在锚点上下界之内的每个 widget 都并入该行。列序是它的水平镜像。平局按 widget 序数确定性打破。在干净的网格上,这与任何讲道理的查看器一致。在字段大量重叠或错落的布局上,用不同容差的另一个处理器可能分出不同的组,而 ISO 32000 没有任何条款说哪个答案是错的

Delphi 中的 PDFium Component:注释数组 tab 顺序沿右栏一路跳下,声明的 /Tabs R 行序逐行访问两栏表单
行序在 /Rotate 与阅读方向之后的显示几何里计算,不是原始 /Annots 顺序

结构序如何决定顺序?

结构序跟随结构树里的对象引用(OBJR)字典,顺序是深度优先遍历 /K 数组实际抵达它们的顺序。它不跟随 /ParentTree 的键序,也不跟随枚举 StructTreeRoot 碰巧产出的顺序。单一生产者的文件里这些顺序常常重合,编辑器重排树之后就分道扬镳——而这恰恰是带标签表单的阅读顺序最要紧的时刻。如果你自己维护这棵树,保持 /ParentTree 与 /StructParents 同步指南讲了必须与之一致的反向映射

结构树从未引用的 widget,规范交由处理器处置。PDFium Component 按注释数组序把它们追加在结构化 widget 之后,并在 UnstructuredWidgetCount 里报告追加了多少。对根本没有结构树的文档声明 /S 的页面,按空遍历处理:每个 widget 都落进那个尾部,计数如实反映,结果不会标为完全回退。带环的结构树、被引用两次的 OBJR、或 /Pg 与注释所属页面相矛盾的 OBJR,都会让结构序回退。这些缺陷让 S 不可用,但不影响其他页的 R 或 C

为什么 tab 顺序值不是注释索引?

FormFieldTabOrder 返回的数组里的值只数 widget,而 OBJR 标识的是位于完整 /Annots 数组里的注释对象。把两套索引空间混用,是消费结构序的代码里最可能出现的 bug。取一个 /Annots 为 [Link, Widget A, Widget B] 的页面:Widget B 的 OBJR 指向注释索引 2,而 Widget B 是字段序数 1。把 2 喂给 FocusFormField,你要么聚焦了错误的字段,要么什么都没聚焦——取决于页面有多少 widget

结构 OBJR 指向注释索引 2,而 Delphi 的 PDFium Component 表单 API 数的是 widget,所以 Widget B 是字段序数 1
注释索引 2 不是字段序数 1——调用 FocusFormField 前先显式转换

组件在内部完成这个转换,所以它的结果可以直接喂给 FocusFormField 和 FormFieldInfo。如果你自己的代码读 OBJR 目标或其他注释索引,请在把它们当字段用之前显式转换:

// 把页面注释索引转换为以下 API 使用的 widget 序数:
// FocusFormField、FormFieldInfo 和 FormFieldTabOrder;不是 widget 则返回 -1
function AnnotationToFieldOrdinal(Pdf: TPdf; AnnotIndex: Integer): Integer;
var
  I: Integer;
begin
  Result := -1;
  if (AnnotIndex < 0) or (AnnotIndex >= Pdf.AnnotationCount) then
    Exit;
  if Pdf.Annotation[AnnotIndex].Subtype <> anWidget then
    Exit;                          // 链接、便签和弹出注释不是字段
  Result := 0;
  for I := 0 to AnnotIndex - 1 do
    if Pdf.Annotation[I].Subtype = anWidget then
      Inc(Result);
end;

只在页面保持加载且未被编辑时,才把结果当有效。在某个 widget 之前增删任何注释都会同时移动两套索引空间,编辑前缓存的映射正是那种只在客户文件上复现的差一错误

用 FocusNextFormField 跨页导航

FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) 沿当前页的 tab 顺序走,然后继续深入后面的页面,从 3.223.0 起,它访问的每一页都应用自己的声明策略。跨页重载在 3.220.0 版到来,所以 表单字段导航一文里“自己探测回绕并改 PageNumber”的建议如今只适用于无参重载——它仍只在单页内循环

procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
  Field: Integer;
begin
  if ssShift in Shift then
    Field := PdfView.FocusPreviousFormField(True)  // 跨页,回绕
  else
    Field := PdfView.FocusNextFormField(True);
  if Field < 0 then
    Beep   // 扫描范围内没有合格目标;页面和焦点不变
  else
    UpdateFieldStatus(PdfView.PageNumber, Field);
end;

返回值是现在持有焦点的那个页面上的页内字段序数,所以要连着 PageNumber 一起读。跨页遍历会跳过用户没有理由 tab 进去的 widget:隐藏、不可见、NoView、只读、签名、未知类型和空矩形的 widget。传 WrapDocument = False 则在文档边界停下并返回 -1。MaxPagesToScan 必须在 1 到 4096 之间,每个检查过的页都计入,含当前页。扫描一无所获或原生引擎拒绝聚焦目标时,调用返回 -1,前一页和焦点保持原样

声明顺序在哪里失效?

只有组件能可靠读到页面元数据时,声明策略才被兑现;做不到的每种情形都带明确原因回退到注释数组 widget 序。边界具体到可以列出:

  • 元数据来自对当前原生文档的有界私有序列化,而不是原文件字节,因为内存中的页面移动和编辑会让磁盘上的文件过期。快照按内容修订缓存,收到通知的编辑使其失效,失败的快照也缓存
  • 加密文档,以及 page-object 编辑进行中请求的快照,没有快照可用,总是回退
  • 快照封顶 64 MiB、65,536 个活动对象、4096 页、每页 4096 条注释、总计 65,536 条注释和 64 层结构深度;更大的文档回退而不是让导航卡死
  • 只覆盖 AcroForm 文档。XFA 表单不在此 profile 内
  • 行序列序要求 widget 矩形有限、非空,页面尺寸合理;退化几何触发回退
  • 这些 API 从你的代码驱动导航。PDFium 自己对转发给引擎的 Tab 键的处理不会被替换,想要声明顺序就把按键路由给 FocusNextFormField

查询保留当前页、原生焦点和焦点字段里挂起的编辑文本,也不执行任何文档或页面生命周期动作。因此每次按键都调 FormFieldTabOrder 是安全的,而且在给定内容修订上首次调用之后就便宜

声明的 tab 顺序、状态诊断和跨页焦点导航都是 PDFium Component for Delphi and C++Builder 的一部分;产品页链接了完整 API 参考,含 TPdfFormTabOrderMode 和 TPdfFormTabOrderStatus 条目