对象号 1 并不等于第 1 页。这个事实比格式中的许多内容更容易导致解析实现出错,理解它要求你看过查看器显示的顺序,去看它实际读取的对象图
PDF 文件是一组编号的间接对象。每个对象有对象号和世代号,其它对象通过类似 N G R 的引用指向它:3 0 R 表示版本 3 的当前对象。页面也是这些对象之一,但显示顺序与它们在文件中的位置或编号无关。显示顺序完全由文档目录下的 /Pages 树决定,一个根节点在 catalog 里的树状结构。若只按对象号扫描并收集带 /Type /Page 的对象,就会在不少真实文件里得到错误页序
页面树:到底是谁在定序
每个 PDF 都以文档目录开始(ISO 32000-2 §7.7.2)。目录含有 /Pages 条目,指向页面树根节点。该根节点是一个字典,包含 /Type /Pages、间接引用数组 /Kids 以及该节点下叶节点页数量 /Count,显示顺序就是这棵树深度优先、从左到右遍历的结果
用一个三页最小样例可以看得更清楚:
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [20 0 R 4 0 R 9 0 R] /Count 3 >>
endobj
% Object 4 is stored third in the file but is page 2 in display order
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
% Object 9 is stored fourth but is page 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
% Object 20 is stored last but is page 1; Kids[0] decides, not object number
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
因为 /Kids 数组为 [20 0 R 4 0 R 9 0 R],所以对象 20 是第 1 页、对象 4 是第 2 页、对象 9 是第 3 页。对象编号在这里不具备意义。任何按数值顺序遍历对象并取出 /Type /Page 的代码在这个文件上都会输出错误页序
生成器为何会产生非顺序布局:有几个常见原因。库若先预分配全部页面号再写内容,会按创建顺序编号,但写入字节时顺序可能完全不同。合并工具组合多个文档时会重编号每个来源的对象以避免冲突,重编号后的页面对象会散落在对象表里,但新的根 /Kids 数组会维持正确显示顺序。增量更新会在文件尾追加新对象并分配更高编号,所以修订中新加的一页虽然显示上应在前面,也可能在字节流里靠后
扁平树与嵌套子树
规范允许两种页面树形态。简单生成器可能只产出扁平结构:一个根 /Pages 节点,其 /Kids 仅有 /Page 叶对象,这种结构最容易遍历,只有一层一趟扫完
大文件通常使用平衡树。根 /Pages 的 /Kids 数组里包含中间 /Pages 节点,每个节点也有自己的 /Kids。中间节点上的 /Count 记录其子树下的叶页总数,从而让查看器跳页时不必逐个解析,可以跳过整棵子树。一个 1,000 页且每个叶节点有 10 页的平衡树,查找第 750 页只需三到四次字典查找的二分过程
对处理代码的意义是:不能假定第一层 /Kids 里全是 /Page 对象。每个子节点都要检查。若其 /Type 为 /Pages,必须递归;若为 /Page,才是叶子。不递归的实现会在嵌套树上漏掉整片子树
页面属性继承
页面树同时也是资源共享机制。某些属性是可继承的,例如 /MediaBox、/CropBox、/Resources 和 /Rotate(见 ISO 32000-2 §7.7.3.4)如果 /Page 字典缺失某属性,查看器会沿 /Parent 向上回溯,直到找到该属性或到达根节点。统一字体时把共享字体字典放在根 /Pages 节点上,而不是每页复制,可显著缩小文件
这对读取页面属性的代码有两个注意点:直接从 /Page 读取 /MediaBox 并把缺失当作错误会出错,因为键可能只是继承而来。正确做法是沿父链回溯;同时还要防环,损坏文件里的 /Parent 可能回指已访问节点,缺少访问集会导致无限循环
交叉引用表与交叉引用流
间接对象查找依赖交叉引用表或其替代物 PDF 1.5 的交叉引用流。xref 将每个对象号映射到文件内字节偏移,符合规范的读取器因此按 xref 直接跳转到对象,不会顺序扫描文件;也正是这个随机访问设计,使页面跳转高效:读取 catalog 后通过 xref 找到根 /Pages,再逐层解析 /Kids,只访问必需对象
增量更新会在文件尾添加新 xref 区块,并通过 trailer 回指前一分区。修订中的对象会在新增 xref 区出现新条目,原始字节不变但被新版本覆盖。这样数字签名可继续生效:签名保护的字节范围不会被修改,而新增内容只在追加区内。页树也会随修订更新,一个带有新增或删除的修订会生成新的 /Pages 根和 /Kids,旧根仍留在原位置。如果是线性化文件,第一页附近对象会被物理重排以便边下载边渲染,但顺序仍由页面树中的偏移关系定义
不遍历树会出什么问题
对象号扫描的失败很安静。输出看起来像合法文档:页数可能正确,每页内容也能辨认,但顺序错位,而且这种错位与生成器、修订次数以及是否来源于外部合并流程有关。单一工具生成的一组文件可能全过,换到其他工具或合并后就可能失败,这也是为何用启发式修复不稳的原因。真实案例中可见到这些差异带来的排障代价
经过增量更新的文件更易出问题,因为后续修订添加或重排的页面通常拥有更高对象号,而显示顺序由更新后的 /Kids 数组控制,按数字顺序扫描会把这些页统一挤到后面
修复并不复杂:从 catalog 开始解析 /Pages 引用,递归遍历 /Kids,按遍历顺序输出叶节点。这就是定义中的显示顺序,不管对象编号、偏移量或文件结构。成熟的 PDF 库通常都会提供页数和支持索引访问的 page accessor,风险在于绕开库页模型、直接操作对象层的人为实现
另一个常见异常是中间节点的 /Count 可能错误。若只把 /Count 当作边界且不做完整遍历,且该值偏小时就会无声漏页。对关键文档更稳妥的做法是把 /Count 仅当容量预估或二分搜索提示,但真实页数仍应通过遍历得出