进阶 PDF parser(解析器)诊断
Artisan 导入路径会读取 Chrome 生成的 Portable Document Format(PDF)文件,并把其中一页加载到 NextPDF 文档中。当导入因棘手输入而失败时,你需要深入查看 PageImporter::import() 底层那些按 byte 读取文件的 parser 类。
本指南记录 NextPDF\Parser namespace(命名空间)的底层 parser 接口:PdfReader、PdfTokenizer、CrossRefParser、StreamDecoder、ResourceCollector、RevisionExtractor,以及值对象 PdfObject 与 RevisionXRefTable。这里列出的每个符号都存在于 nextpdf/artisan 中。本指南记录的是实际构建出的 parser,而不是理想化的接口。
请把本指南当成说明文档加 how-to(操作指南)来读。它先说明各部分如何协作,再带你逐步检查一份 incremental-update 修订版本。关于此层之上的导入边界,请参阅 Artisan 开发者指南。
何时需要这个接口
标题为“何时需要这个接口”的章节只有在常规导入路径已经失败,而你需要定位原因时,才需要使用这个 parser 接口。常见的触发场景:
PageImporter::import()抛出NextPDF\Artisan\Exception\PdfParseException,而你需要判断是 cross-reference 表、某个 stream filter,还是 page tree(页面树)出了问题。- Chrome 升级改变了输出格式(传统的 cross-reference 表变成 cross-reference stream,或反过来),导致你的 fixtures(测试夹具)不再匹配。
- 你收到一份并非由 Chrome 生成的第三方 PDF,想确认 parser 到底能不能读取它。
- 你正在对一份增量更新的文档做取证分析,需要每个修订版本的 byte 范围或对象可见性。
如果你写的是普通的 renderer(渲染器)集成,并不需要这个接口。这个 parser 是内部诊断工具,不是通用的 PDF 库:它不支持加密的 PDF、linearized hint table(线性化提示表),也不支持存在对象重定义冲突的增量更新。
Parser 接口
标题为“Parser 接口”的章节这个 parser 由一小组单一职责的类组成。PdfReader 是入口点;其余都是它构建或调用的协作对象。
| 类 | 职责 | 主要方法 |
|---|---|---|
PdfReader | 读取文件结构、resolve(解析)对象、遍历 page tree。 | parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions() |
PdfTokenizer | 按 ISO 32000-2:2020 §7.2 执行词法分析(lexical analysis)——名称、字符串、数字、字典、数组、引用。 | readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset() |
CrossRefParser | 解析传统的 cross-reference 表与 cross-reference stream。 | parseXRefTable(), parseXRefStream() |
StreamDecoder | 按 /Filter 解码 stream 的 byte。 | decode()(静态) |
ResourceCollector | 深度遍历 Resources 树,收集每个可达的间接对象。 | traverse(), getCollected() |
RevisionExtractor | 把一份增量更新文件切分为每个修订版本的 byte 范围。 | extractRevision()(静态)、getRevisionBoundaries()(静态) |
PdfObject | 不可变的已解析间接对象(字典加上可选的 stream)。 | get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes() |
RevisionXRefTable | 不可变的逐修订版本 cross-reference 快照。 | getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize() |
PdfReader —— 入口点
标题为“PdfReader —— 入口点”的章节用原始 PDF byte 构建 \NextPDF\Parser\PdfReader,然后在调用任何其他方法之前,先调用 parse()。parse() 会检查 %PDF- 标头,在文件末尾找出 startxref,并沿着 /Prev 链遍历 cross-reference 链。
调用 parse() 之后,reader 会公开三组方法:
- 对象访问。
getObject(int $objNum)返回一个PdfObject,并透明解析 Type 2 项(保存在对象 stream 内的对象)。getObjectNumbers()返回一个已排序的list<int>,包含每个非 free 对象编号。resolveRef(mixed $value)会跟随单个间接引用;直接值则原样通过,不做更改。 - 页面访问。
getPage(int $pageIndex)会解析 catalog、遍历/Pages,并返回零起始 Index(索引)对应的页面。getPageContentStream()、getPageResources()与getPageMediaBox()会提取PageImporter所需的各部分。collectPageResources()返回array<int, PdfObject>,包含从该页面的 Resources 与 Contents 可达的每个对象。 - 修订版本访问。
getRevisionCount()返回增量修订版本数量(单一修订版本文件返回1)。getRevisionXRef(int $index)返回一个RevisionXRefTable(索引0是最新的)。getRevisions()返回完整的list<RevisionXRefTable>。
PdfTokenizer —— 词法分析
标题为“PdfTokenizer —— 词法分析”的章节PdfTokenizer 读取 byte stream。你很少需要自己构建它——PdfReader 与 CrossRefParser 都拥有各自的实例——但当解析因格式错误的 token 而失败时,它就是需要检查的那一层。有两种行为对诊断很重要:
- 安全限制是常量,不是配置。 tokenizer 会为字面字符串的嵌套层数、字典与数组的嵌套层数、关键字长度,以及数组元素数量设置上限。当超出某个限制时,它会抛出
PdfParseException,并在消息中指出该限制的名称。专门构造、用于触发某项限制的输入导致失败,说明防护按设计生效,并不是 parser 的 bug。 readValue()是分发器。 它检查下一个 byte,并分发给readName()、readLiteralString()、readHexString()、readArray()、readDictionary(),或一个 number/reference 读取器。一个间接引用N G R会以数组结构['type' => 'ref', 'num' => N, 'gen' => G]返回。这个结构就是PdfObject::getRef()与PdfReader::resolveRef()所识别的。
CrossRefParser —— cross-reference 解析
标题为“CrossRefParser —— cross-reference 解析”的章节CrossRefParser 解析 Chrome 能输出的两种格式:
parseXRefTable()读取传统的xref表(PDF 1.x 风格):subsection 标头后接固定宽度的 20-byte 项,最后是一个trailer字典。parseXRefStream()读取 cross-reference stream(PDF 2.0,ISO 32000-2:2020 §7.5.8):一个带有/Type /XRef、一个/W字段宽度数组,以及一段二进制条目 stream 的间接对象。
两者都返回相同的结构:array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}。PdfReader::parse() 通过预读 cross-reference 偏移处的那四个 byte 来决定要调用哪一个:xref 选用表解析器;其他则视为 stream 对象。两个解析器都会对每个 subsection 强制一个一百万个项目的上限,以拒绝会让 parser 运行过久的伪造计数。
StreamDecoder —— stream filter(流过滤器)
标题为“StreamDecoder —— stream filter(流过滤器)”的章节StreamDecoder::decode(string $data, string|array $filter) 是静态的,会应用单个 filter 或一串串联的 filter。它恰好支持 Chrome 的 printToPDF 所输出的那些 filter:
FlateDecode(zlib,并带 raw-deflate 回退)ASCIIHexDecodeASCII85Decode
任何其他 filter 名称都会抛出 PdfParseException,并带有 Unsupported stream filter。解码器将解压缩输出上限设为 16 MiB,以限制解压缩炸弹(decompression-bomb)风险;输出过大时会抛出异常,而不会无限制地分配内存。当 PdfReader 读取一个 stream 且解码抛出异常时,它会回退到原始 stream 的 byte,使单个损坏的 filter 不至于中止整个解析。
ResourceCollector —— 深度资源遍历
标题为“ResourceCollector —— 深度资源遍历”的章节ResourceCollector 通过 PdfReader 构建,并通过 PdfReader::collectPageResources() 调用。它的 traverse() 方法会递归遍历一个值,跟随每个 ['type' => 'ref'] 引用并通过 getObject() 解析,然后把每个解析后的对象以对象编号为键,在一个 array<int, PdfObject> 中各记录一次。它会对递归深度设置上限,并静默跳过无法解析的引用,因此单个悬空引用只会产生部分集合,而不是直接导致硬性失败。
RevisionExtractor —— 增量更新与修订版本
标题为“RevisionExtractor —— 增量更新与修订版本”的章节一份创建后又经过签署、注解或其他编辑的 PDF,会带有增量更新:每次编辑都会附加一个新的 cross-reference 区段与 trailer,并以一个 %%EOF 标记结束。RevisionExtractor 完全通过静态方法作用于一个已解析的 PdfReader 之上:
extractRevision(string $pdfData, PdfReader $reader, int $revision)返回在所请求修订版本的%%EOF边界处截断的文件。修订版本0(最新)返回整个文件;较高的索引则返回更早的快照。getRevisionBoundaries(string $pdfData, PdfReader $reader)返回一个list<array{revision, startByte, endByte, sizeBytes}>,描述每个修订版本贡献的 byte 范围。
这种隔离是刻意设计的:提取较旧的修订版本只会暴露截至该时点可见的对象,这能阻止混合式 cross-reference 攻击——也就是较晚的修订版本重新定义较早的对象。
步骤说明:检查一个修订版本
标题为“步骤说明:检查一个修订版本”的章节这个流程会检查一份可能在 Chrome 生成后又被编辑过的 PDF 的修订历史。这个范例贴近生产环境形态:它声明严格类型、使用完整的类型提示、验证输入,并捕获最明确的异常。
- 先把 PDF 的 byte 读入内存,并在构建 reader 之前拒绝空输入。
- 构建
\NextPDF\Parser\PdfReader并调用parse()。 - 读取
getRevisionCount()。值为1表示这是一份没有增量更新的单一修订版本文件。 - 针对每个修订版本,读取它的
RevisionXRefTable并检查getActiveObjectCount()、hasRootUpdate()与getSize()。 - 用
RevisionExtractor::getRevisionBoundaries()计算每个修订版本的 byte 范围。 - 捕捉
PdfParseException——parser 所抛出的最明确异常——并呈现一条诊断消息。
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;use NextPDF\Parser\PdfReader;use NextPDF\Parser\RevisionExtractor;use NextPDF\Parser\RevisionXRefTable;
/** * Inspect the incremental-update history of a PDF file. * * @return list<array{revision: int, activeObjects: int, rootUpdate: bool, size: int, startByte: int, endByte: int, sizeBytes: int}> * * @throws PdfParseException If the file is not a readable PDF. */function inspectRevisions(string $path): array{ $pdfData = \file_get_contents($path);
if ($pdfData === false || $pdfData === '') { throw new PdfParseException("Cannot read PDF bytes from path: {$path}"); }
$reader = new PdfReader($pdfData); $reader->parse();
$boundaries = RevisionExtractor::getRevisionBoundaries($pdfData, $reader); $report = [];
foreach ($reader->getRevisions() as $table) { \assert($table instanceof RevisionXRefTable);
$index = $table->index; $boundary = $boundaries[$index];
$report[] = [ 'revision' => $index, 'activeObjects' => $table->getActiveObjectCount(), 'rootUpdate' => $table->hasRootUpdate(), 'size' => $table->getSize(), 'startByte' => $boundary['startByte'], 'endByte' => $boundary['endByte'], 'sizeBytes' => $boundary['sizeBytes'], ]; }
return $report;}reader 会把修订版本由最新(index0)排到最旧。若要把一个较旧的快照提取成独立的 byte——例如用来比对某次编辑改了什么——可直接调用提取器:
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;use NextPDF\Parser\PdfReader;use NextPDF\Parser\RevisionExtractor;
/** * Extract one revision of a PDF as standalone bytes. * * @throws PdfParseException If the file is unreadable or the revision index is out of range. */function extractRevision(string $pdfData, int $revision): string{ if ($pdfData === '') { throw new PdfParseException('Empty PDF input'); }
$reader = new PdfReader($pdfData); $reader->parse();
// Throws PdfParseException with an "out of range" message for an invalid index. return RevisionExtractor::extractRevision($pdfData, $reader, $revision);}失败处理
标题为“失败处理”的章节每个 parser 失败都会以 NextPDF\Artisan\Exception\PdfParseException 呈现。消息会指出原因。请用下方的表格,把消息片段对应到触发它的阶段。
| 消息片段 | 阶段 | 代表的意义 |
|---|---|---|
missing %PDF- header | PdfReader::parse() | 这些 byte 并非 PDF,或者输入在开头就被截断了。 |
Cannot find startxref marker / Invalid startxref offset | PdfReader::parse() | 文件末尾损坏,或 cross-reference 指针超出范围。 |
Expected 'xref' keyword / Invalid xref subsection header | CrossRefParser::parseXRefTable() | 某个传统的 cross-reference 表格式错误。 |
XRef stream ... /Type /XRef / invalid /W array | CrossRefParser::parseXRefStream() | 某个 cross-reference stream 缺少必要的字典项。 |
exceeds limit of(xref 或 object-stream 计数) | CrossRefParser / PdfReader | 一个伪造的计数触发了拒绝服务防护。 |
Unsupported stream filter | StreamDecoder::decode() | 这个 stream 使用了不在 FlateDecode / ASCIIHexDecode / ASCII85Decode 支持集合内的 filter。 |
FlateDecode decompression failed / output exceeds ... bytes limit | StreamDecoder | 压缩数据无效,或膨胀后超过 16 MiB 的上限。 |
Maximum nesting depth ... exceeded / Keyword exceeds maximum length | PdfTokenizer | 一个专门构造或异常的结构触发了 tokenizer 的限制。 |
Page index ... not found / out of range in subtree | PdfReader::getPage() | 请求的页面索引在 page tree 中不存在。 |
Revision index ... out of range | PdfReader / RevisionExtractor | 修订版本索引落在 0 到 getRevisionCount() - 1 之外。 |
当你捕获这个异常时,记录消息与来源路径,然后选择重新抛出,或返回一个已定义的错误。不要静默丢弃它:空的 catch 块会掩盖 parser 费力产生的那唯一一条信息。
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;use NextPDF\Parser\PdfReader;use Psr\Log\LoggerInterface;
/** * Parse a PDF, logging the precise parser-stage message on failure. * * @throws PdfParseException Rethrown after logging so the caller can decide policy. */function parseWithDiagnostics(string $pdfData, LoggerInterface $logger): PdfReader{ if ($pdfData === '') { throw new PdfParseException('Empty PDF input'); }
$reader = new PdfReader($pdfData);
try { $reader->parse(); } catch (PdfParseException $exception) { $logger->error('PDF parse failed', [ 'reason' => $exception->getMessage(), 'bytes' => \strlen($pdfData), ]);
throw $exception; }
return $reader;}安全的默认做法
标题为“安全的默认做法”的章节- 永远先调用
parse()。PdfReader上的每个访问器都假设 cross-reference 链已加载。调用getObject()或getPage()而未先调用parse(),不会得到任何有用的结果。 - 把 parser 当成只读工具,并记住它是按 Chrome 输出形态设计的。 它针对的是 Chrome 的
printToPDF所输出的那一部分 PDF 语法。加密的 PDF、linearized hint table,以及彼此冲突的增量更新,按设计都不在范围内。不要把它扩展成一个通用的 PDF 修复工具。 - 让安全限制保持就位。 嵌套层数、关键字长度、数组大小、cross-reference 计数,以及解压缩等上限,是为了在恶意输入下限制资源使用。由限制而来的
PdfParseException对一份专门构造的文件而言正是正确的结果;为了接受这种文件而调高限制,反而会扩大攻击面。 - 默认用第
0页。getPage()与PageImporter::import()都默认使用第一页。只有在工作流程明确需要时,才选择其他索引。 - 在构建 reader 之前先验证输入。 如同上方范例所做,尽早拒绝空输入或无法读取的 byte,让清楚的应用层错误先于任何 parser 异常出现。
- 捕获
PdfParseException,绝不捕获裸的\Exception。 它是 parser 所抛出的唯一且明确的类型;捕获它能避免无关的失败被掩盖。
另请参阅
标题为“另请参阅”的章节- Artisan 开发者指南 —— parser 之上的导入边界,包含
ChromeHtmlRenderer、PageImporter,以及架构分层。 - Artisan API 参考 —— 这个包公共接口的已发布方法表。
- Artisan 疑难排解 —— 面向 renderer 与导入失败症状的指引。
- Chrome renderer 设置 —— 配置生成此 parser 所读取 PDF 的 renderer。
- ISO 32000-2:2020 §7.5(文件结构、cross-reference、增量更新)与 §7.2(词法惯例)—— tokenizer 与 cross-reference 解析器所实现的规范。关于权威的 byte 级格式,请查阅已发布的标准。