跳转到内容

进阶 PDF parser(解析器)诊断

Artisan 导入路径会读取 Chrome 生成的 Portable Document Format(PDF)文件,并把其中一页加载到 NextPDF 文档中。当导入因棘手输入而失败时,你需要深入查看 PageImporter::import() 底层那些按 byte 读取文件的 parser 类。

本指南记录 NextPDF\Parser namespace(命名空间)的底层 parser 接口:PdfReaderPdfTokenizerCrossRefParserStreamDecoderResourceCollectorRevisionExtractor,以及值对象 PdfObjectRevisionXRefTable。这里列出的每个符号都存在于 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 由一小组单一职责的类组成。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()

用原始 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 读取 byte stream。你很少需要自己构建它——PdfReaderCrossRefParser 都拥有各自的实例——但当解析因格式错误的 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 解析 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 回退)
  • ASCIIHexDecode
  • ASCII85Decode

任何其他 filter 名称都会抛出 PdfParseException,并带有 Unsupported stream filter。解码器将解压缩输出上限设为 16 MiB,以限制解压缩炸弹(decompression-bomb)风险;输出过大时会抛出异常,而不会无限制地分配内存。当 PdfReader 读取一个 stream 且解码抛出异常时,它会回退到原始 stream 的 byte,使单个损坏的 filter 不至于中止整个解析。

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 的修订历史。这个范例贴近生产环境形态:它声明严格类型、使用完整的类型提示、验证输入,并捕获最明确的异常。

  1. 先把 PDF 的 byte 读入内存,并在构建 reader 之前拒绝空输入。
  2. 构建 \NextPDF\Parser\PdfReader 并调用 parse()
  3. 读取 getRevisionCount()。值为 1 表示这是一份没有增量更新的单一修订版本文件。
  4. 针对每个修订版本,读取它的 RevisionXRefTable 并检查 getActiveObjectCount()hasRootUpdate()getSize()
  5. RevisionExtractor::getRevisionBoundaries() 计算每个修订版本的 byte 范围。
  6. 捕捉 PdfParseException——parser 所抛出的最明确异常——并呈现一条诊断消息。
examples/inspect-revisions.php
<?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——例如用来比对某次编辑改了什么——可直接调用提取器:

examples/extract-revision.php
<?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- headerPdfReader::parse()这些 byte 并非 PDF,或者输入在开头就被截断了。
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()文件末尾损坏,或 cross-reference 指针超出范围。
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()某个传统的 cross-reference 表格式错误。
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()某个 cross-reference stream 缺少必要的字典项。
exceeds limit of(xref 或 object-stream 计数)CrossRefParser / PdfReader一个伪造的计数触发了拒绝服务防护。
Unsupported stream filterStreamDecoder::decode()这个 stream 使用了不在 FlateDecode / ASCIIHexDecode / ASCII85Decode 支持集合内的 filter。
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoder压缩数据无效,或膨胀后超过 16 MiB 的上限。
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizer一个专门构造或异常的结构触发了 tokenizer 的限制。
Page index ... not found / out of range in subtreePdfReader::getPage()请求的页面索引在 page tree 中不存在。
Revision index ... out of rangePdfReader / RevisionExtractor修订版本索引落在 0getRevisionCount() - 1 之外。

当你捕获这个异常时,记录消息与来源路径,然后选择重新抛出,或返回一个已定义的错误。不要静默丢弃它:空的 catch 块会掩盖 parser 费力产生的那唯一一条信息。

examples/parse-with-diagnostics.php
<?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 之上的导入边界,包含 ChromeHtmlRendererPageImporter,以及架构分层。
  • Artisan API 参考 —— 这个包公共接口的已发布方法表。
  • Artisan 疑难排解 —— 面向 renderer 与导入失败症状的指引。
  • Chrome renderer 设置 —— 配置生成此 parser 所读取 PDF 的 renderer。
  • ISO 32000-2:2020 §7.5(文件结构、cross-reference、增量更新)与 §7.2(词法惯例)—— tokenizer 与 cross-reference 解析器所实现的规范。关于权威的 byte 级格式,请查阅已发布的标准。