技术文章

PDF 元数据、大纲书签与注解结构详解

把页面描述剥掉,剩下的是一层薄薄的结构:没有人会打印它,但每一个阅读器、每一个索引器、每一套归档系统都依赖它。一个页面对象并不知道自己属于哪一章、由谁撰写,也不知道那条指向别处的脚注。这些知识住在上一层,在挂到文档目录(catalog)上的三种结构里:元数据流、大纲树,以及每页的注解数组。它们有一个共同点,使它们特别容易出错。三者都不在页面上留下可见的痕迹,所以一份文件可以渲染得完美无缺,却同时丢了书签、与自己的作者字段自相矛盾,或者把一个链接指向一个已经不存在的页面对象

PDF 库把这一层暴露成文档属性、书签 API 以及链接或注解调用,而搜索爬虫读的也正是这一层,用来判断你的文档讲的是什么。它底下的对象模型在深入解析 PDF 文档结构中已经讲过。这里只专注于挂在目录上的那些东西

PDF 示意图:PDF 文档目录连向页面树、大纲根、名称字典和 XMP 元数据流四个可选子系统
文档目录把四个互相独立的子系统接在一起,每个都通过自己的条目抵达。其中任何一个缺席,页面依然照常渲染

三种结构都挂在目录上。一份把它们串起来的完整目录长这样:

1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
   /Outlines 3 0 R
   /Names << /EmbeddedFiles 4 0 R >>
   /Metadata 5 0 R
>>
endobj

四个条目,四个互相独立的子系统。/Pages 是可见的文档;/Outlines 是书签树;/Metadata 指向 XMP 流;/Names 通向全文档范围的名称字典,其中除别的以外还存着嵌入的文件附件。每一个都是可选的,一个哪一个都没找到的阅读器照样能显示页面。正是这种可选性,让导航层成为文件被只懂页面的工具编辑过之后最先腐烂的东西

两套互相矛盾的元数据存储

PDF 同时在两个地方携带文档元数据,麻烦就从它们说法不一开始。最早的机制是文档信息字典,由 trailer 中的 /Info 引用:一组扁平的键值对,包含 /Title、/Author、/Subject、/Keywords、/Creator、/Producer 以及两个日期。它很简单,而且每一个查看器都读它。PDF 2.0 把其中大部分标记为弃用,转而推荐第二种机制,也就是 XMP 元数据流

PDF 示意图:对比 PDF Info 字典与 XMP 元数据流两套存储,它们携带重叠的字段并必须保持同步,以 XMP 为准
Info 字典和 XMP 流并行回答同样的问题。让它们保持同步的办法是从同一组取值重新生成两者

XMP 是一份自足的 XML 文档,用 RDF 书写,存成一个流,目录通过 /Metadata 抵达它,并标记为 /Type /Metadata /Subtype /XML。与埋在 PDF 对象结构内部的 Info 字典不同,XMP 包被设计成可以由完全不懂 PDF 的工具单独提取和解析。下面是一个有代表性的包:

5 0 obj
<< /Type /Metadata /Subtype /XML /Length 1235 >>
stream
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
    <rdf:Description rdf:about=""
        xmlns:dc="http://purl.org/dc/elements/1.1/"
        xmlns:xmp="http://ns.adobe.com/xap/1.0/"
        xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
      <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report</rdf:li></rdf:Alt></dc:title>
      <dc:creator><rdf:Seq><rdf:li>A. Author</rdf:li></rdf:Seq></dc:creator>
      <xmp:CreateDate>2026-06-16T10:46:27+08:00</xmp:CreateDate>
      <xmp:CreatorTool>Reporting Service 4.2</xmp:CreatorTool>
      <pdf:Producer>losLab PDF Library</pdf:Producer>
    </rdf:Description>
  </rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
endstream
endobj

那段代码里有三个细节,决定了这份元数据能不能扛得住真实工具链的接触。xpacket 处理指令不是装饰:它们为包划出边界,好让提取器能在更大的字节流里找到它,而一个漏写收尾 <?xpacket end="w"?> 的写入方产出的文件打得开,却过不了严格校验器。属性的数据类型同样要紧。dc:title 是一个包在 rdf:Alt 里的语言候选项,而 dc:creator 是一个有序列表,要用 rdf:Seq;把两者中的任何一个写成裸文本节点,是最常见的 XMP 错误,大多数查看器都容忍它,直到遇上那个不容忍的为止。命名空间前缀只是约定俗成,但它们绑定到的 URI 是规范性的:解析器认的是 URI,不是前缀

有两套存储时的硬性规则是:它们必须一致。如果 /Info 说作者是某个人,而 dc:creator 写的是另一个人,你交付的就是一份对同一个问题给出两种答案的文档,哪个答案胜出取决于消费工具读的是哪个字段。库通常会替你把两边都写上,但只要你手工改了其中一边,或者合并了来自不同生成器的文件,两者就会漂移开来。把 Info 字典当作遗留兼容,把 XMP 当作事实来源,并从同一组取值重新生成两者,而不是各打各的补丁。对 PDF/A 来说这变成一项合规要求:ISO 19005 强制要求 XMP,并禁止任何与其 XMP 对应项相矛盾的 Info 属性

书签面板背后的大纲树

查看器显示成书签面板的东西,在文件里是一棵由字典组成的双向链接树,叫做文档大纲。目录通过 /Outlines 指向一个根大纲字典;根指向它的第一个和最后一个顶层条目;每个条目都被串到它的邻居和父项上。文件里任何地方都没有一个书签数组。整个结构是靠跟着引用走重建出来的,这恰恰是为什么一处断链就能让一整个分支从面板里消失,而且不报任何错

PDF 示意图:PDF 大纲树中的 Parent、First、Last、Prev、Next 和 Count 链接如何重建查看器的书签面板
书签面板是从一棵由 Parent、First、Last、Prev、Next 和 Count 串起来的双向链接大纲树重建出来的。一处过期链接就会悄无声息地藏起整个分支
8 0 obj                                    % 大纲根
<< /Type /Outlines /Count 4 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % 顶层条目:一章
<< /Title (Chapter 1: Results)
   /Parent 8 0 R /Count 2
   /First 12 0 R /Last 15 0 R >>
endobj
12 0 obj                                   % 第一个子项
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [3 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % 第二个子项,也是最后一个兄弟
<< /Title (Methodology)
   /Parent 9 0 R /Prev 12 0 R
   /Dest [3 0 R /Fit] >>
endobj

把这些链接读一遍,不变量就显而易见了。每个条目都通过 /Parent 指回父项。兄弟项靠 /Prev 和 /Next 串成一条链,第一个省略 /Prev,最后一个省略 /Next。父项通过 /First 和 /Last 点名自己的头尾子项,而夹在中间的子项只能靠走兄弟链才能抵达。错一处,失败就是无声的:一个过期的 /Next 会把一章截断,一个 /Last 没能终结链条的父项会留下孤儿条目,而查看器只渲染它够得着的部分

/Count 字段携带的是一份让人意外的状态。在根上和任何展开的条目上,它保存的是当前可见的后代数量;在一个折叠的条目上,它是一个负数,绝对值表示展开后会出现多少个后代。所以 /Count 不是关于这棵树的固定结构事实,它是面板保存下来的展开或折叠状态,而一个把它硬编码成正数总计的生成器,会把作者本想留着关上的每一个分支都重新打开

每个条目靠指向某处来赚得自己的位置。/Title 是面板显示的内容;/Dest 是点击之后落脚的地方。目标位置可以像上面那样内联写在条目里,也可以是一个通过文档名称字典解析的名字;当许多书签和链接指向同样的位置时,后者是更好的选择,因为目标搬家时你只需要改一处。库一般会把这棵树藏在一个大纲根句柄和若干添加子条目的方法后面;在 HotPDF 里,文档暴露一个类型为 THPDFDocOutlineObject 的 OutlineRoot,并在你追加条目时替你串好 /Prev、/Next、/Parent 和 /Count 这些链接。这一点值得好好利用,因为跨越多次编辑手工维护那些不变量,正是大纲出问题的地方

目标位置:点击落到哪里的语法

书签和链接注解都指向目标位置,而目标位置不只是一个页码。它是一个数组,先点名一个页面对象,再通过第二个槽位里的动词指定查看器应当如何取景。最常见也最被滥用的是 /XYZ,形式为 [page /XYZ left top zoom]。它的三个操作数彼此独立,任何一个都可以是 null,意思是"这一项保持读者原来的样子"。所以 [page /XYZ null null null] 会跳到该页而不动滚动位置和缩放,这通常正是你想从一个"跳到某页"链接里得到的效果。数字用的是默认用户空间,从左下角量起、y 向上增长,与页面内容用的是同一套坐标系。从屏幕排版一路走来的作者会条件反射地从顶部量起,于是把读者送到页面的另一头

/Fit 家族用精确定位换取韧性。[page /Fit] 把整页缩放进窗口,[page /FitH top] 以给定的上边缘适配页宽,[page /FitR l b r t] 把一个矩形放大到填满视图。因为这些是从页面几何而不是固定坐标算出缩放的,所以一个 /Fit 目标位置在页面尺寸改变之后依然做着合理的事,而一个烘死了缩放值的 /XYZ 目标位置可能让读者盯着页边空白。对一份目录来说,用 /FitH 加上小节的上边缘坐标,比用 /XYZ 加一个猜出来的缩放值更经得起时间

注解:一切不属于页面内容的交互元素

注解是一个覆盖在页面之上、却不属于其内容流的对象。链接、便签、高亮、表单部件、文件附件图标、图章:全都是注解,列在它们所在页面的 /Annots 数组里。把一个注解从那个数组里移走,它就从页面上消失了,尽管底下的内容一点没动。这正是要点所在:注解是一层编辑图层,与它们覆盖的那些痕迹相互分离

每个注解都共享一根小小的脊梁。/Subtype 点名种类,/Rect 给出它在页面坐标中的包围盒,/Contents 保存的文本同时兼作无障碍描述。链接注解是值得研究的案例,因为它有两种形式:一个裸的目标位置,和一个动作

12 0 obj                                    % 指向目标位置的链接
<< /Type /Annot /Subtype /Link
   /Rect [100 200 300 250]
   /Border [0 0 0]
   /Dest [5 0 R /XYZ null null null] >>
endobj
13 0 obj                                    % 触发一个动作的链接
<< /Type /Annot /Subtype /Link
   /Rect [50 50 200 100]
   /Border [0 0 0]
   /A << /Type /Action /S /URI /URI (https://www.example.com) >> >>
endobj

/Rect 是一块热区;在它内部点击会把读者送到目标位置,复用的正是大纲用的那套语法。/Border [0 0 0] 在干实事:它压掉了查看器默认在链接周围画的那个难看矩形。第二种形式把裸的 /Dest 换成了一个 /A 动作,其 /S 子类型选定行为:/GoTo 在本文件内跳转,/GoToR 跳到另一个文件,/URI 指向一个网址,/Launch 运行一个外部程序。最后这个值得警惕。一个启动可执行文件的 /Launch 正是让 PDF 成为恶意软件载体的那种行为,所以合规的查看器要么直接封杀它,要么大张旗鼓地弹窗询问,于是这个链接对大多数读者来说都是失效的。请伸手去拿 /URI 和 /GoTo,别碰 /Launch

高亮和便签这类标记注解,以及 /Square 这类图形注解,多了一层褶皱:它们在屏幕上的样子并不由类型决定。除非你用外观流——也就是 /AP 条目,它引用一个装着绘图操作符的 form XObject——把外观钉死,否则查看器会自己渲染一个版本。省掉它,同一处高亮在两个阅读器里、或者在一次编辑器往返前后就可能长得不一样。凡是确切外观本身就是文档一部分的东西,都要提供 /AP。顺带一提,文件附件复用的也是同一套机制:一个嵌入文件流加一个文件规格字典,要么以 /FileAttachment 注解的形式浮出来,要么通过目录 /Names 下的 /EmbeddedFiles 名称树

这一层会在哪里坏掉,以及如何抓住它

贯穿这一切的常客是悬空引用。当目录里没有 /Outlines 条目,或者兄弟链在树的中间断掉时,书签就不再出现;当 XMP 流缺了 /Type /Metadata /Subtype /XML 这层标记,或者 xpacket 包装写坏了时,元数据就被忽略。每一种情况下页面内容都好好的,所以随手一开看着都对,缺陷只在那个没人检查的面板里才露头

两个便宜的习惯能抓住其中大部分。用一个真正的查看器打开成品文件,把书签面板和一部分链接点一遍,这会像读者那样把引用图跑一遍。然后用另一个工具把元数据读回来,确认 Info 字典和 XMP 说法一致——这是无论点多少下都暴露不出来的那一处矛盾。用一个负责链接记账的库来生成这一层,上面这些陷阱大多根本不会打开。面向 Delphi 和 C++Builder 的 HotPDF Delphi Component 通过文档级 API 暴露大纲、注解和元数据结构,于是你只描述书签层级和链接,把串引用的活交给它。至于这些结构所依附的对象模型,PDF 文件结构技术概览讲了它们赖以存在的目录和交叉引用表