从 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 被报告为回退到注释数组序,而不是冒充声明的策略,所以支持日志分得清“作者要求了这个顺序”和“页面什么都没要求”
行序列序为什么不直接排 /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 没有任何条款说哪个答案是错的
结构序如何决定顺序?
结构序跟随结构树里的对象引用(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
组件在内部完成这个转换,所以它的结果可以直接喂给 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 条目