技术文章

没有 Pages 字典的 PDF:解析的影响

PDF Catalog 字典只有一个必需的导航键:/Pages。该键必须指向一个 /Pages 类型的间接对象,该对象又包含 /Kids 数组和页面的总 /Count。如果去掉该指针,则任何符合规范的阅读器都无法在文件中定位到任何一页。ISO 32000-1 §7.7.2 在这一点上非常明确:Catalog 必须有一个 /Pages 条目,并且引用的对象类型必须为 /Pages。违反此要求的文件不仅不符合规范;而且在大多数解析器处理得不好的方式上,它们在结构上也是损坏的。

规范实际怎么说

一个最小的符合规范的 PDF 至少有三个对象。对象 1 是 Catalog,对象 2 是 Pages 根,从对象 3 开始是单独的 Page 字典。Catalog 指向 Pages 根;Pages 根在 /Kids 中列出其子项;每个 Page 带有 /Parent 反向引用。整个链按设计是双向的,因此解析器可以从任何一端开始,对于平衡树,可以在 O(log n) 时间内遍历到任何页面。

% Minimal conforming structure (ISO 32000-1 §7.7.2)
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
endobj

3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << >> >>
endobj

4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

Pages 树可以嵌套。一个包含数千页的文档通常将页面分组为也带有 /Pages 类型的中间节点对象,每个对象都有自己的 /Kids 和反映其下子树的 /Count。根节点的 /Count 始终等于总页数。这个计数就是阅读器在尚未解析单个页面之前显示在页码字段中的内容,因为从对象 2 读取一个整数比遍历整棵树要节省得多。

没有 Pages 的文件是什么样子

丢失 Pages 字典的文件通常源于直接写入页面对象而不将它们组装成树的 PDF 生成器,或者源于移除根节点但保留叶子 Page 对象完好无损的损坏。这种文件中的 Catalog 要么完全缺少 /Pages 键,要么包含对交叉引用表中不再存在的对象的引用。

% Non-conforming: Catalog with no /Pages reference
1 0 obj
<< /Type /Catalog >>
endobj

% Page objects exist but are unreachable from the Catalog
5 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

15 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 16 0 R /Resources << >> >>
endobj

25 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 26 0 R /Resources << >> >>
endobj

遵循规范的解析器将读取 Catalog,尝试解析 /Pages,找不到任何内容(或无效引用),然后引发错误或报告零页。它决不能做的是就像文件有零页一样继续并默默地成功;这会产生一个在自动化工具看来是正确的,但对每个打开它的人来说都是错误的空白输出。

解析器为何崩溃

大多数 PDF 解析器在加载时根据 Pages 根的 /Count 值分配其内部页表。当缺少该根时,解析器要么读取零,不分配任何内容,然后在任何代码第一次请求第 1 页时解引用空指针,要么读取垃圾数据并分配非常不正确的缓冲区。这两种结果都不优雅。处理此类文件时崩溃日志中出现的 0x008E5D78 处的访问冲突正是因为这个:页面访问路径内的空指针解引用,由解析器假定始终存在的结构缺失所触发。

底层的设计假设是合理的。现存的绝大多数 PDF 都有一个 Pages 字典。跳过存在性检查以节省一些指令的解析器并不鲁莽;它们针对常见情况进行了优化。惩罚这种优化的文件足够罕见,生产代码可能永远不会遇到一个,直到遇到为止,如果在工程师没有阅读 §7.7.2 的情况下,此时的崩溃既是可重现的,又是令人困惑的。

在没有 Pages 树的情况下恢复

如果解析器必须处理这些文件而不是拒绝它们,则恢复遵循一条可预测的路径:扫描交叉引用表中的每个间接对象,收集那些带有 /Type /Page 的对象,并按对象编号对它们进行排序。在规范中并不保证对象编号顺序与阅读顺序匹配,但在实践中,省略 Pages 树的生成器倾向于按顺序发出页面,因此对象编号顺序通常是正确的。

检查本身开销很小。在遍历 Catalog 的 /Pages 指针之前,请确认该指针存在,它解析为真实的间接对象,并且解析的对象的 /Type 等于 /Pages。如果这三个条件中的任何一个失败,则进入线性扫描。对于大型文档,扫描比树遍历慢,因为它读取每个对象头而不是遵循平衡路径,但它有效,并且对于已经格式错误的文件,正确性高于速度。

线性扫描不会自动解决的一个边缘情况:页面排序。如果没有 /Kids 数组来定义顺序,则规范中未定义“正确”的顺序。对象编号顺序是实用的默认值;如果文件重要到需要仔细处理,那么检查 Page 对象是否带有明确的 /StructParents 或隐含阅读顺序的注释引用是值得额外工作的。

对 PDF 生成器的影响

对于编写 PDF 生成器而不是解析器的任何人来说,教训很小:在关闭文件之前始终输出 Pages 根。在该规范的任何修订版下,没有 /Pages 条目的 Catalog 都不是有效的 PDF。动态构建页面对象并在完成时组装树的生成器(大多数流式写入器使用的方法)是可以的,只要能够实际运行完成即可。常见的故障模式是异常或提前返回,从而在 trailer 完成之前中止写入,留下的文件在某些阅读器(具有恢复启发式方法)中能打开,而在其他阅读器(没有)中则打开失败。

PDF/A 和 PDF/UA 在基本规范要求之外对页面树施加了额外的限制,但都没有放宽 /Pages 要求。检查符合 ISO 19005 或 ISO 14289 的验证器甚至在到达特定于配置文件的规则之前就会捕获到丢失 Pages 字典的问题,将其视为对基本规范的违反。