技术文章

Delphi 中使用 HotPDF 为现有 PDF 添加书签

HotPDF 通过 AddLoadedOutline 在 Delphi 中为现有 PDF 添加书签,该函数在已加载的文档上创建任何嵌套级别的提纲条目,并且它通过 ResolveLoadedNamedDestinationAddLoadedNamedDestination 读写命名目标(Named destinations);加载文件、构建树、调用 SaveLoadedDocument,曾经空白的阅读器侧边栏现在即可承载一个起作用的目录

促成这一切的场景是令人沮丧地普遍;您将十几个合同合并为一个审查包,或者将三本产品手册拼接成一个可分发文件,输出在结构上没有问题:每页都在,每种字体都完好;然后有人在 Acrobat 中打开它,书签面板却是空的;没有导航的 400 页文档在技术上是完整的,但在实践上是不可用的;直到 2.347.0 版本,HotPDF 可以读取、修改和删除已加载文档上的书签,但从未创建过它们;这个空白现在已经被填补,本文将介绍新的 API 表面以及 PDF 的一个奥秘 —— /Count 键,如果您把它当作简单的条目计数器,它就会咬您一口

提纲树到底是什么

PDF 提纲(Outline)是字典的双向链接树,而不是扁平列表,该结构解释了 API 中的每个参数;ISO 32000-1 §12.3.3 对其进行了定义:文档目录指向 /Outlines 根节点,根节点通过 /First/Last 指向其第一个 and 最后一个子节点,兄弟节点通过 /Next/Prev 链接,同时每个条目都通过 /Parent 向上指回;每个条目都携带一个 /Title 字符串,通常还有一个 /Dest 指示点击它时应当带读者去哪里

目标有两种形式,ISO 32000-1 §12.3.2 刻意将它们分开;显式目标是内联数组(例如 [page /XYZ x y zoom]):即对页面对象的直接引用加上视图规范;命名目标则存储名称字符串,而实际的目标位于文档目录中 /Names /Dests 命名树下,这是一级间接寻址,它允许多个链接共享同一个目标,且目标移动时无需触及链接;HotPDF 在创建书签时写入显式的 /XYZ 目标,并为您提供了用于读取和扩展命名树的单独调用

如何在 Delphi 中为现有 PDF 添加书签?

LoadFromFile 之后调用 AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom):对于顶级条目传递 ParentIndex = -1,或者传递现有顶级条目的基于零的索引以在其下进行嵌套;如果文档以前没有 /Outlines 根,该函数会创建它,构建条目字典,写入指向基于零的 DestPageIndex/Dest [page /XYZ x y zoom] 数组,并将新条目链接进兄弟链中;成功时返回 0,失败时返回 -1(例如当 DestPageIndex 超出范围时)

在顺序上,有一个链接细节很重要:新条目被插入到其父节点的子链的头部,作为新的 /First;因此,如果您将“Chapter 1”和“Chapter 2”作为顶级条目添加,Chapter 2 在侧边栏中出现在 Chapter 1 上方,而 Chapter 1 已平移为顶级索引 1;实际的模式要么是按相反的阅读顺序添加条目,要么是添加每个父节点并立即填充其子节点(当其仍位于索引 0 时)

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

XYZoom 参数直接映射到 /XYZ 目标,且全部默认为 0;PDF 将坐标原点置于左下角,因此 Y = 792 指向美式信纸(US Letter)页面的顶部,并且根据 ISO 32000-1,/XYZ 槽中的零值会告诉查看器保留其当前设置,这在几乎所有情况下都是您对缩放所想要的;一切都发生在内存中加载的对象图上,在运行 SaveLoadedDocument 之前没有任何内容触及磁盘,这与已加载文档 API 用于重写已加载 PDF 上的标题、作者和 XMP 元数据的“先编辑后保存”模型相同

重新指向书签:页面跳转与 URI 动作

现有的书签可以重新连接,而无需重新创建它们,HotPDF 为这两种情况提供了两个截然不同的调用,因为其底层的 PDF 构造确实不同;SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) 将位于 Index 的顶级条目的 /Dest 替换为新鲜的 /XYZ 数组,该工具用于经典的合并后失效 —— 即每个书签仍指向原始的、合并前文件的页码;SetLoadedOutlineURI(Index, URI) 在结构上做了不同的事:它附加一个带有 /S /URI/A 动作字典,将书签转为网页链接,而不是文档内跳转

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

在设计树时,理清这两种机制;/Dest 是文档内的纯导航;URI 动作完全离开文档,ISO 32000-1 期望一个提纲条目通过其中一种机制进行导航,而不是同时使用这两种机制;因此,请将 SetLoadedOutlineURI 保留给尚未携带页面目标的条目,例如树末端的“产品主页”或“报告问题”叶子节点,而不是转换后的章节标题;这里写入的 URI 动作是用于页面内容链接批注的相同 /S /URI 构造,在使用 HotPDF 在 PDF 文档中创建超链接的指南中进行了更深入的介绍

命名目标在加载的 PDF 上如何工作?

命名目标是 ISO 32000-1 §12.3.2.3 的稳定锚点机制:文档不在每个链接中嵌入页面引用,而是在目录的 /Names /Dests 命名树中保留名称到目标的映射,链接通过名称引用条目;ResolveLoadedNamedDestination(Name) 在该树中查找名称,并返回它指向的基于零的页码索引,如果名称不存在则返回 -1;该解析器处理规范允许的映射值的两种存储形式:裸目标数组(如 [page /XYZ x y zoom])以及将数组包裹在 /D 键下的字典形式

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) 则相反:它在 /Names /Dests 树中注册一个新的锚点(当文档没有树时创建它),并在成功时返回 True;这对调用一起涵盖了合并文档必须尊重上游工具编写的锚点,并发布新锚点以供下游链接指向的工作流

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

/Count 陷阱:它计算的是后代,而不是条目

提纲根节点上的 /Count 键并不是大多数开发人员所认为的意思,错误的假设会产生看起来像数据损坏的测试失败;ISO 32000-1 §12.3.3 将 /Count 定义为所有级别上可见后代的总数,而不是顶级条目的数量;对于单个条目,正负号也带有含义:正值表示该条目已打开且侧边栏显示了该数量的后代,负值表示该条目已折叠,且 |N| 个后代被隐藏在它内部

这源自 HotPDF 自身的回归测试套件(在构建已加载文档的书签 API 时);测试文档持有三个顶级书签,其中一个带有一个子节点;删除一个顶级条目后,读取根 /CountGetLoadedOutlineCount 报告了 1,而不是期望的 2;没有任何内容损坏:初始的 3 从一开始就不是“三个顶级条目”,正确的重新计算必须遍历顶级链,累加每个节点及其正的 /Count,跳过已折叠节点的后代;这对于您自己的代码的教训是直接的:在结构性编辑后,千万不要对 /Count 的增量进行断言,因为该值随可见后代非线性地移动;通过内容验证结构代替

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

这在已加载文档工具集中的位置

书签和命名目标的创建,完善了已加载文档 API 填补了有一阵子的图景:相同的加载-编辑-保存周期已经可以重写文档元数据、添加链接批注,以及通过 XFDF 导入和导出往返运行批注;该模式是统一的:LoadFromFile、对内存中对象图的一系列 *Loaded* 调用,然后是一次 SaveLoadedDocument,这使得将提纲构建折叠到现有的合并或加盖印章的流水线中,并作为保存前的一步,变得非常直接

此处讨论的 API (AddLoadedOutlineSetLoadedOutlineDestinationSetLoadedOutlineURIResolveLoadedNamedDestinationAddLoadedNamedDestinationGetLoadedBookmarkPageIndex)随适用于 Delphi 和 C++Builder 的标准 HotPDF Component 一起提供,没有外部依赖,也不需要单独的查看器运行时