PDFlibPas 为 Delphi 和 C++Builder 开发者提供了三种离开当前页面进行导航的操作类型:GoToR(远程跳转)打开另一个 PDF 文件中的特定页面,GoToE(嵌入跳转)打开嵌入当前文档中的 PDF 文件,而 Launch 通过操作系统命令解释器运行外部程序或打开文件。这三者都属于 ISO 32000-1 §12.6.4,即也定义了日常 GoTo 操作的操作类型部分,而且每一种都带有一个容易踩坑的细节:页码会根据构建它的调用而含义不同,目标可能是名称而不是文件路径,还有一对看起来相同但服务于两个不同查看器的字符串参数
这并非假设场景。一个技术参考资料包——主手册、由分销商按自己的进度更新的规格 PDF,以及与两者一起安装的校准工具——正好依赖这种跨文档连接:必须跳转到规格文件第 5 页的交叉引用,适合放在手册内部而不是旁边的数据表,以及直接交给校准工具的链接。本文与从现有 PDF 中读取书签和注释操作的文章形成对应:那篇文章介绍读取其他生成器已经写入文件的 GoToR、Launch 或 GoToE 操作;本文介绍从零构建这三种操作类型,包括 PDFlibPas 在写入单个字节之前强制执行的字段级规则
PDF 操作离开当前页面的三种方式
PDFlibPas 在操作的 /S 键处将本地导航与其他导航分开,而 GoToR、GoToE 和 Launch 是三种目标位于当前页面之外的子类型:GoToR 位于 ISO 32000-1 §12.6.4.3,GoToE 位于 §12.6.4.4,Launch 位于 §12.6.4.5,它们都属于更广泛的 §12.6.4 Action Types 部分,该部分也定义了日常 GoTo 操作。普通 GoTo 操作的目标名称指向文档内部已经存在的页面对象,因此 PDFlibPas 可以立即验证它;GoToR 和 GoToE 无法以同样方式验证,因为外部文件可能根本不存在于当前机器上,而嵌入文件的页数也不是宿主文档所跟踪的信息,所以两者都携带未解析的引用,而不是硬链接——GoToR 使用文件规格和目标,GoToE 使用嵌入文件名称和目标页面——而 Launch 完全放弃目标概念,只为操作系统命名要运行或打开的对象。这种划分体现在写入端的两组调用中:AddLinkToFile、AddLinkToFileEx、AddLinkToEmbeddedPDF 和 AddLinkToLocalFile 等高级一次性构建器会同时创建页面热点链接注释及其操作,覆盖大多数实际布局——读者点击的一行文本或图标;而 SetActionRemoteDestinationEx、SetActionLaunchOptions 以及对应的 AddActionNext* 低级设置器,则会为你已经持有句柄的对象附加或替换操作:现有书签、表单字段触发器,或文档级、页面级生命周期事件。两组调用最终都会写入相同的字典形状;区别在于调用时所处的位置,以及下一节将介绍的页码含义
如何构建一个打开另一个 PDF 文件中页面的 GoToR 链接
GoToR 操作需要两项内容——文件规格和该文件中的目标——而 PDFlibPas 提供了两个不同的调用来提供第二部分,每个调用都有自己的页码约定。高级页面热点构建器 AddLinkToFile 和 AddLinkToFileEx 会验证其 Page 或 DestPage 参数必须大于零,这与 PDFlibPas 在其他位置(包括 SelectPage)使用的从 1 开始的编号相同。用于为已有句柄的对象附加或替换 GoToR 操作的低级设置器 SetActionRemoteDestinationEx,则验证 DestPage 必须大于或等于零,并将其直接写入操作的显式目标数组,不作任何调整:它需要目标文档的原始从 0 开始的页面索引,这正是 ISO 32000-1 为远程显式目标规定的编号方式。如果向低级设置器传入你会交给高级构建器的同一个数字,链接就会提前打开一页
var
Lib: TPDFlib;
ActionID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('manual.pdf', '') = 1 then
begin
Lib.SelectPage(12);
// Page is 1-based here, same as SelectPage above: this opens
// the fifth page of specs.pdf.
Lib.AddLinkToFile(72, 700, 200, 16, 'specs.pdf', 5, 0, 0, 0);
// A later maintenance pass repoints the same link at a
// reorganized file. SetActionRemoteDestinationEx edits the
// action directly, and DestPage here is the zero-based index
// PDF itself uses for a remote explicit destination -- "the
// fifth page" is now 4, not 5.
ActionID := Lib.GetAnnotActionID(1);
Lib.SetActionRemoteDestinationEx(ActionID, 'specs-2026.pdf',
4, Ord(dkFit), 0, 0, 0, 0, 0, 0, -1);
end;
finally
Lib.Free;
end;
end;
SetActionRemoteDestinationEx 的其余参数同样是字面值。ValueMask 是一个位集合——左侧为 1,顶部为 2,右侧为 4,底部为 8,缩放为 16——PDFlibPas 会在写入任何内容之前根据 DestType 检查它:dkFitR 目标必须恰好提供 15(四条边全部设置且不含缩放),dkFit 和 dkFitB 必须提供 0,而 dkFitH/dkFitV 只接受各自相关的坐标。在其他有效掩码中未设置的位不会从数组中省略;它们会写成显式的 PDF null,ISO 32000-1 将其解释为该坐标“保留查看器已有的值”——这是一种合法表达“跳转到此页面,但保持缩放不变”的方式,而不是疏漏。缩放本身会按你传入的值的一部分存储,因此请求 150% 的调用会向数组写入 1.5,合法输入范围为 0 到 6400
如何链接到嵌入自己文档中的 PDF
AddLinkToEmbeddedPDF 构建 GoToE 操作,其目标参数 EmbeddedFileName 是名称而不是路径:它必须匹配创建附件时传给 EmbedFile 的 Title 字符串,因为这个标题就是 PDFlibPas 存入文档 /EmbeddedFiles 名称树的字面键,而 GoToE 通过查找该名称解析目标,不会再次访问文件系统。该函数只检查 EmbeddedFileName 非空且 TargetPage 至少为 1——传入一个实际上从未嵌入的名称时,调用仍会返回成功,操作仍会被写入,而链接对于每一位点击它的读者都只会无法解析
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.NewDocument;
Lib.NewPage;
// The Title argument becomes the key PDFlibPas stores in the
// document's EmbeddedFiles name tree -- that string, not
// "datasheet.pdf", is the target GoToE resolves against.
if Lib.EmbedFile('Datasheet', 'datasheet.pdf', 'application/pdf') = 1 then
Lib.AddLinkToEmbeddedPDF(72, 700, 200, 16, 'Datasheet', 3, 0, 0);
Lib.SaveToFile('manual.pdf');
finally
Lib.Free;
end;
end;
这里叠加了两个版本下限,而不是一个。EmbedFile 需要 PDF 1.4 才能使用 /EmbeddedFiles 名称树,而 AddLinkToEmbeddedPDF 又会将下限单独提升到 PDF 1.6,因为 GoToE 操作类型本身需要 PDF 1.6,所以任何使用此功能的文档其实际最低版本是 1.6,而不是 1.4。还要注意,这里的 TargetPage 使用从 1 开始的编号,符合 PDFlibPas 的普通约定——这与上一节介绍的从 0 开始的 DestPage 形成有意的对比,也提醒我们适用哪种页码方案取决于操作类型和具体调用,而不是某条一概而论的规则。操作的目标字典还可以携带 /R 条目,其中 C 表示子级,P 表示父级,从而支持跳入嵌入文件或返回其容器的两跳链路,不过 AddLinkToEmbeddedPDF 只会构建子级方向,因为对于正在嵌入文件而不是被嵌入的文档来说,只有这个方向有意义
Launch 操作:一个 FileName,两个不可互换的字符串目标
SetActionLaunchOptions 从单个 FileName 参数写入 Launch 操作的两个不同键,而这两个键保存的是两种不同类型的字符串。顶层 /F 键获得一个文件规格字典,该字典通过 PDFlibPas 为 GoToR 使用的同一路径转换构建,这是 ISO 32000-1 §7.11.3 为文件规格字典定义的可移植形式。PDFlibPas 写入 /Win 子字典时,会将其自身的 /F 键设置为传入时的原始 FileName 值,不做任何转换,因为 ISO 32000-1 §12.6.4.5 中的 /Win /F 被定义为仅供 Windows 查看器读取的普通 Windows 路径字符串。传入一个已经转换好的可移植路径,并期待两个键最终相同,/Win 副本就会原封不动地保留你交给函数的内容
var
Lib: TPDFlib;
ActionID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('manual.pdf', '') = 1 then
begin
Lib.SelectPage(1);
Lib.AddLinkToLocalFile(72, 660, 220, 16, 'calibrate.exe', 0);
ActionID := Lib.GetAnnotActionID(1);
// Operation 0 leaves this as a normal open -- pass 1 to ask a
// Windows viewer to print instead. Parameters and
// DefaultDirectory only ever reach /Win /P and /Win /D, never
// the top-level /F.
Lib.SetActionLaunchOptions(ActionID, 'calibrate.exe',
'/silent /profile:default', 'C:\Tools\Calibration', 0, -1);
end;
finally
Lib.Free;
end;
end;
应将 Launch 视为三者中阻力最大的操作,因为它的全部目的就是在 PDF 沙箱之外运行程序或打开文件,而所有主流查看器都会相应处理它。除非目标位于明确受信任的位置,否则 Adobe Acrobat 的 Enhanced Security 默认会阻止 Launch 操作或发出提示,而且大多数企业 Acrobat 部署都会保持这一保护开启。因此,交给公众的文档中的 Launch 操作并不是可靠的触发方式:应预期无论哪个查看器打开文件,它都可能阻止、提示或静默忽略该操作,并将它留给你可以控制查看器信任设置的封闭环境——内部信息亭、受控的企业部署,或永远不会离开你管理的机器的文档
PDF/A 闸门:为什么 GoToR 和 Launch 调用可能返回零
SetActionRemoteDestinationEx 和 SetActionLaunchOptions 在目标文档处于任意 PDF/A 一致性模式时都会直接拒绝:两者都会将文档的 PDF/A 模式作为第一个条件进行检查,并在接触操作之前以结果 0 退出,且不会抛出异常。这是有意为之。PDF/A 对交互操作的限制明确排除了 Launch,因为让归档文件具备运行任意程序的能力,正是长期归档格式要防止的那类依赖环境的行为,而 PDFlibPas 在同一代码路径中也对远程跳转设置器应用了相同的保守闸门。开发过程中很容易忽略其实际影响:在普通 PDF 上有效的同一个调用,在加载了 PDF/A 一致性级别的文档上仍然可以编译、运行,却会悄无声息地什么也不做,所以应检查返回值,而不要假定成功——这里的 0 并不是输入格式错误,而是库拒绝了与文档自身一致性声明冲突的请求
GoToR、GoToE 和 Launch 在更大的 PDFlibPas 工作流中的位置
本文介绍的三种操作类型并不会到达相同的位置。关于文档和页面生命周期操作触发器的配套文章介绍了 SetDocumentAction 和 SetPageAction,它们可以通过共享的 PDF_ACTION_BUILDER_REMOTE_DESTINATION 和 PDF_ACTION_BUILDER_LAUNCH 常量,将 GoToR 或 Launch 操作附加到 WillClose 之类的触发器上——同一个构建器也支持普通 URI 或 JavaScript 触发器。GoToE 没有这样的常量,也完全没有进入该通用构建器的路径;AddLinkToEmbeddedPDF 是 PDFlibPas 构建它的唯一方式,因此它严格来说是页面热点操作,永远不是文档级或页面级触发器。GoToR 和 Launch 可以进入通用构建器,但代价是控制力:它构建的 GoToR 只能指向命名的远程目标,而 Launch 操作只能使用文件名和参数;本文介绍的显式页面与适合类型寻址,以及 Windows 特定的 Launch 选项,只能直接通过 SetActionRemoteDestinationEx 和 SetActionLaunchOptions 使用
在围绕这些设置器构建维护工具之前,有一个安全属性值得了解。SetActionRemoteDestinationEx 和 SetActionLaunchOptions 会先在临时字典中构建完整的替换操作,只有临时副本通过验证后,才会将 /F、/D 或 /Win 以及 /NewWindow 键删除并复制到实时操作上——因此,无论调用因超出范围的 ValueMask 还是空的 FileName 而验证失败,原始操作以及已经挂在其上的任何 /Next 链都会完全保持不变,而不会被半覆盖。这一点很重要,因为 GoToR 和 Launch 操作都可以位于由 AddActionNextRemoteDestinationEx、AddActionNextLaunchEx 或更通用的 AddActionNextEx 构建的 /Next 链中,让单个触发器依次触发 JavaScript 日志条目和远程跳转。本文介绍的 GoToR、GoToE 和 Launch 构建属于PDFlibPas,这是面向 Delphi 和 C++Builder 的原生 PDF 库