你接手了一批上游提供的 PDF 文件,任务听起来很简单:找出哪些书签会跳转到外部 URL,哪些书签会运行 JavaScript,以及文档内部的书签实际跳到哪里。打开 API 参考后却会发现,库可以创建所有这些动作,却没有提供读取现有动作的方法。这种读写能力不对称的情况在 PDF 工具中十分常见。写入一个打开 https://example.com 的书签只需一行代码;要询问现有书签“你会执行什么动作,目标是什么”,通常必须手动遍历原始对象树中的 /A、/S、/Dest,还要处理一系列很难一次写对的适配类型变体
PDFlibPas 是面向 Delphi 和 C++Builder 的原生 Object Pascal PDF 库,过去也存在同样的缺口:写入端有丰富的 setter,而 getter 只返回一个原始的 TPDFObject,其余解析工作都要由调用方完成。v3.77.0 通过一组精简的类型化内省调用弥补了部分缺口,这些调用会用普通记录返回动作类型、动作载荷和目标几何信息。本文介绍这些调用如何映射到 ISO 32000-1 的动作与目标模型,并分析三个会让手写解析代码悄无声息地产生错误的具体陷阱
为什么读取动作比写入动作更难
PDF 动作是一个字典,其中的 /S 键用于指定子类型,例如 GoTo、GoToR、URI、Launch、Named、JavaScript,以及一系列较少遇到的类型(ISO 32000-1 §12.6.4)。难点在于,每种子类型的载荷位于不同的键中,并不存在统一的“返回目标”字段。URI 动作把地址存放在 /URI 中;GoToR 或 Launch 动作把文件规范存放在 /F 中;JavaScript 动作把脚本存放在 /JS 中,而该值既可能是字符串,也可能是流。GoTo 动作本身没有载荷,其目标是挂在 /D 下的目标对象,需要另行解析
写入动作时,你事先知道动作类型,因此这些差异不会造成问题。读取动作时,则必须先根据 /S 分支,再访问正确的键,同时处理同一个逻辑概念,也就是“动作所指向的对象”,可能采用三种互不兼容的编码方式。类型化 getter 正是用来封装这些分支的。GetOutlineActionInfo 和 GetAnnotActionInfo 都会返回一个 TPDFlibActionInfo 记录:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // populated for akURI
JavaScript: WideString; // populated for akJavaScript
FileName: AnsiString; // populated for akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
记录通过 Kind 告诉你哪些字段有效。如果 Kind 返回 akURI,就读取 URI 并忽略其他字段。如果返回 akGoTo,所有载荷字段都不适用,应继续读取后文介绍的独立目标信息。书签或注释完全没有动作时,akNone 会给出明确结果,而不是返回一个含义不明的零值
遍历书签树并查找书签
内省书签之前,必须先取得它的句柄。PDFlibPas 使用整数 ID 标识书签树节点,FindOutlineByTitle 可以按可见文本查找节点,并让调用方明确控制搜索范围:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Depth 参数值得特别注意。osdSiblingsOnly 只扫描起始节点所在层级的同级节点,不会进入任何同级节点的子节点;它可以找到同级书签,但无法找到更深层的书签。osdChildrenOnly 只查看起始节点下一层的直接子节点。osdFullSubTree 则会递归搜索整个分支。选错搜索范围只会造成无提示的漏检,而不会报错:如果标题位于两层以下,使用仅限同级节点的搜索会直接返回零,让你误以为书签不存在。要从文档根节点开始搜索,可将 GetFirstOutline 作为起始 ID 传入
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// Search the whole tree from the root for a nested bookmark.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID is now a handle you can pass to the action and
// destination getters below.
;
end;
finally
Lib.Free;
end;
end;
匹配过程会将标题作为 WideString 进行精确比较,因此区分大小写,并严格保留文档中存储的 Unicode 文本。如果源 PDF 来自规范不一致的生成程序,应按照文档实际存储标题的方式规范化搜索文本,否则很容易反复排查并不存在的缺失问题
解析书签的动作与目标
取得句柄后,GetOutlineActionInfo 会返回类型化视图。使用方式很直接:调用该方法,根据 Kind 分支,然后读取该类型对应的字段
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetOutlineActionInfo(FoundID);
case Info.Kind of
akURI:
Writeln('Opens URL: ', Info.URI);
akGoToR, akLaunch:
Writeln('Opens file: ', Info.FileName,
' (new window: ', Info.OpenInNewWindow, ')');
akJavaScript:
Writeln('Runs script: ', string(Info.JavaScript));
akGoTo:
Writeln('Jumps within this document'); // see destination below
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
这里包含第一个真正的陷阱,也是实现过程中由测试反馈暴露的问题。库中还有一个较早的读取方法,名为 GetActionURL,直觉上很容易误用它来读取 URI 动作。GetActionURL 通过 /F 键解析文件规范。对目标确实是文件的 GoToR 和 Launch 来说,这是正确做法,但它使用的键与 URI 动作完全不同。URI 动作的地址是动作自身 /URI 键上的普通字符串,而不是文件规范。如果把 URI 动作交给文件规范解析路径,得到的结果会是空值或无意义内容。类型化读取方法会在内部正确区分:直接读取 /URI 来处理 akURI,只对 akGoToR 和 akLaunch 调用文件规范解析器。这正是手写实现最容易混淆的差别
目标适配类型及其几何信息
akGoTo 动作表示“在当前文档中导航”,但它并没有说明导航到哪里,也没有说明查看方式。这些信息由目标对象负责,而 PDF 目标比通常想象的更复杂。PDF 目标不仅包含页码,还包含一个“适配”规范,用于告诉阅读器应如何在窗口中呈现该页面(ISO 32000-1 §12.3.2.2)。GetOutlineDestinationInfo 会将这些信息作为记录返回:
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-based; 0 when unresolved
Left, Top, Right, Bottom, Zoom: Double;
end;
八种适配类型分别描述不同的页面呈现方式。dkXYZ 会按明确的缩放比例,把特定点放在左上角,因此使用 Left、Top 和 Zoom。dkFit 让整个页面适合窗口,并忽略坐标。dkFitH 和 dkFitV 分别按页面宽度或高度适配,只使用一个相关坐标,也就是顶部边缘或左侧边缘。dkFitR 较为特殊,它会适配指定矩形,因此四条边都有效。dkFitB* 系列提供类似行为,但参照的是可见内容的边界框,而不是完整页面。只有明确每种类型会使用哪些字段,才能正确读取目标,避免输出碰巧为零的无效坐标

底层实现利用了一项刻意保持的一致性,了解它有助于理解这种映射为什么可靠。内部的 GetDestType 按 XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV 的固定顺序,为八种适配类型返回 1 到 8 的整数。TPDFlibDestinationKind 的声明让枚举序数与之逐一对应:dkXYZ 的序数为 1,dkFitBV 的序数为 8,dkNone 位于零。因此,转换过程是在范围保护下直接进行序数类型转换,而不是使用可能随枚举扩展而失去同步的查找表。这只是一个小细节,但如果采用简单粗暴的实现方式,一旦有人调整枚举顺序,它就会变成偏差一位的错误
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // destination did not resolve
case Dest.Kind of
dkXYZ:
Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
dkFitR:
Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
dkFit, dkFitB:
Writeln(Format('Page %d, fit whole page', [Dest.Page]));
else
Writeln(Format('Page %d, fit kind %d',
[Dest.Page, Ord(Dest.Kind)]));
end;
end;
Page 为零表示目标未能解析,通常是因为动作不包含目标,或者无法找到命名目标。信任任何坐标之前,应先检查该值。还要注意,GetOutlineDestinationInfo 会同时检查目标可能出现的两个位置:书签自身的 /Dest,以及嵌入式 GoTo 动作中的 /D。调用方不必预先知道 PDF 生成程序采用了哪一种形式
注释动作与 SelectPage 陷阱
链接注释携带动作的方式与书签相同,GetAnnotActionInfo 也会返回同一个 TPDFlibActionInfo 记录,仍然采用先判断类型、再读取载荷的模式。不过,这里存在一个不适用于书签的有状态限制,也就是第三个陷阱
注释属于页面,PDFlibPas 通过只有在选择页面后才有效的状态公开当前页面的注释。调用 GetAnnotActionInfo 时如果没有先调用 SelectPage(N),注释句柄会是零;该调用会返回 akNone,让你误以为页面上没有带动作的注释。修复只需一行代码,但在循环处理页面时很容易忘记:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // mandatory before touching annotations
// GetAnnotActionID(1) <> 0 is the reliable "has an action"
// test. CheckPageAnnots returns a boolean-style flag, not a
// count, so it is the weaker signal here.
if Lib.GetAnnotActionID(1) <> 0 then
begin
Info := Lib.GetAnnotActionInfo(1);
if Info.Kind = akURI then
Writeln(Format('Page %d link -> %s', [P, Info.URI]));
end;
end;
end;
循环中有两处刻意安排。第一,每次迭代都在访问任何注释之前调用 SelectPage(P),因为每页的注释状态不会自动延续。第二,存在性测试使用 GetAnnotActionID(1) <> 0,而不是 CheckPageAnnots。后者以布尔式标志报告是否存在注释,而不是返回数量,因此非零动作 ID 能更准确地回答“是否存在第一个注释,并且它是否携带可读取的动作”。还有一个值得注意的细节:对于注释,JavaScript 动作的脚本会直接从 /JS 读取;脚本以流存储时进行解码,否则按字符串读取,因此可以处理两种常见编码
读取端内省的适用范围
这些读取方法的范围有意保持精简。它们是在库现有的整数句柄动作层和目标层之上构建的纯读取操作,不会接触写入路径,也不会给同时进行的文档编辑带来风险。它们只报告文件中的现有内容,不会根据策略验证内容,也不会重写任何对象。如果你的目标相反,需要创建带有这些动作的书签和链接注释,可以阅读配套文章 在 Delphi 中创建交互式表单动作和 JavaScript。如果需要从 PDF 中提取可见内容和结构内容,而不是分析导航图,请参阅 使用 PDFlibPas 提取文本、图像和字体
需要明确一个实际边界:内省只能看到 PDF 生成程序真正写入的内容。如果生成程序写入了格式错误的书签动作,或者目标指向从未定义的命名位置,API 会返回 akNone 或零页码,而不会抛出异常。对于用于审查不可信文件的读取 API,这是合理行为,但调用代码应把这些零值视为“不存在或无法解析”,而不能把它们当作输入格式正确的保证。本文介绍的类型化动作与目标内省功能属于面向 Delphi 和 C++Builder 的原生 PDF 库 PDFlibPas