跳到內容

進階 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 不致中止整個剖析。

ResourceCollectorPdfReader 建構,並透過 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 層級格式,請查閱已發布的標準。