進階 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 層級格式,請查閱已發布的標準。