Chẩn đoán trình phân tích PDF nâng cao
Tổng quan nhanh
Phần tiêu đề “Tổng quan nhanh”Luồng nhập của Artisan đọc một tệp Portable Document Format (PDF) do Chrome tạo và đưa một trang vào tài liệu NextPDF. Khi một đầu vào khó làm hỏng quá trình nhập đó, hãy nhìn xuống dưới PageImporter::import(), đến các lớp phân tích đọc tệp theo từng byte.
Hướng dẫn này bao quát bề mặt trình phân tích cấp thấp trong không gian tên NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor, cùng các đối tượng giá trị PdfObject và RevisionXRefTable. Mọi ký hiệu xuất hiện ở đây đều có trong nextpdf/artisan. Hướng dẫn này mô tả trình phân tích đúng như cách nó được xây dựng, không phải như một giao diện lý tưởng hóa.
Hãy dùng hướng dẫn này vừa như phần giải thích, vừa như hướng dẫn thực hành. Nội dung cho thấy các thành phần ăn khớp với nhau ra sao, rồi dẫn bạn qua cách kiểm tra một bản sửa đổi cập nhật tăng dần. Để biết ranh giới nhập ở lớp phía trên, hãy xem hướng dẫn dành cho nhà phát triển Artisan.
Khi nào bạn cần đến phần này
Phần tiêu đề “Khi nào bạn cần đến phần này”Chỉ dùng bề mặt trình phân tích khi luồng nhập thông thường đã thất bại và bạn cần tìm nguyên nhân. Các tình huống điển hình bao gồm:
PageImporter::import()némNextPDF\Artisan\Exception\PdfParseException, và bạn cần biết lỗi nằm ở bảng tham chiếu chéo, một bộ lọc luồng hay cây trang.- Một bản nâng cấp Chrome thay đổi định dạng đầu ra, chẳng hạn khi bảng tham chiếu chéo truyền thống trở thành luồng tham chiếu chéo, hoặc ngược lại, khiến các fixture của bạn không còn khớp.
- Bạn nhận được một tệp PDF bên thứ ba không do Chrome tạo và muốn xác nhận trình phân tích có đọc được tệp đó hay không.
- Bạn đang phân tích một tài liệu được cập nhật tăng dần và cần dải byte theo từng bản sửa đổi hoặc phạm vi nhìn thấy của đối tượng.
Nếu đang viết một tích hợp bộ kết xuất thông thường, bạn không cần đến bề mặt này. Trình phân tích là công cụ chẩn đoán nội bộ, không phải thư viện PDF đa năng. Nó không hỗ trợ các tệp PDF được mã hóa, bảng gợi ý đã tuyến tính hóa, hoặc các bản cập nhật tăng dần có định nghĩa lại đối tượng bị xung đột.
Bề mặt bộ phân tích
Phần tiêu đề “Bề mặt bộ phân tích”Trình phân tích là một tập hợp nhỏ gồm các lớp có trách nhiệm đơn nhất. PdfReader là điểm vào. Các lớp còn lại là những thành phần cộng tác mà nó khởi tạo hoặc gọi.
| Lớp | Trách nhiệm | Các phương thức chính |
|---|---|---|
PdfReader | Đọc cấu trúc tệp, phân giải các đối tượng và duyệt cây trang. | parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions() |
PdfTokenizer | Phân tích từ vựng theo ISO 32000-2:2020 §7.2: tên, chuỗi, số, từ điển, mảng và tham chiếu. | readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset() |
CrossRefParser | Phân tích các bảng tham chiếu chéo truyền thống và các luồng tham chiếu chéo. | parseXRefTable(), parseXRefStream() |
StreamDecoder | Giải mã các byte luồng theo /Filter. | decode() (tĩnh) |
ResourceCollector | Duyệt đệ quy một cây Resources và thu thập mọi đối tượng gián tiếp có thể tiếp cận. | traverse(), getCollected() |
RevisionExtractor | Cắt một tệp được cập nhật tăng dần thành các dải byte theo từng bản sửa đổi. | extractRevision() (tĩnh), getRevisionBoundaries() (tĩnh) |
PdfObject | Đối tượng gián tiếp đã được phân tích và bất biến (từ điển cùng một luồng tùy chọn). | get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes() |
RevisionXRefTable | Ảnh chụp tham chiếu chéo bất biến theo từng bản sửa đổi. | getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize() |
PdfReader — điểm vào
Phần tiêu đề “PdfReader — điểm vào”Khởi tạo \NextPDF\Parser\PdfReader bằng các byte PDF thô, rồi gọi parse() trước khi gọi bất kỳ phương thức nào khác. parse() kiểm tra phần đầu %PDF-, tìm startxref ở cuối tệp và đi qua chuỗi tham chiếu chéo bằng cách lần theo các liên kết /Prev.
Sau khi parse() chạy xong, bộ đọc cung cấp ba nhóm phương thức:
- Truy cập đối tượng.
getObject(int $objNum)trả về mộtPdfObject, tự động phân giải các mục Type 2 (các đối tượng được lưu bên trong một luồng đối tượng).getObjectNumbers()trả về mộtlist<int>đã sắp xếp gồm mọi số đối tượng không phải đối tượng trống.resolveRef(mixed $value)lần theo một tham chiếu gián tiếp. Giá trị trực tiếp sẽ được trả qua mà không thay đổi. - Truy cập trang.
getPage(int $pageIndex)phân giải catalog, đi qua/Pagesvà trả về trang tại chỉ mục tính từ không.getPageContentStream(),getPageResources()vàgetPageMediaBox()trích xuất các phần màPageImportercần.collectPageResources()trả vềarray<int, PdfObject>cho mọi đối tượng có thể tiếp cận từ Resources và Contents của trang. - Truy cập bản sửa đổi.
getRevisionCount()trả về số lượng bản sửa đổi tăng dần. Tệp chỉ có một bản sửa đổi sẽ trả về1.getRevisionXRef(int $index)trả về mộtRevisionXRefTable(chỉ mục0là bản gần nhất).getRevisions()trả về toàn bộlist<RevisionXRefTable>.
PdfTokenizer — phân tích từ vựng
Phần tiêu đề “PdfTokenizer — phân tích từ vựng”PdfTokenizer đọc luồng byte. Bạn hiếm khi tự khởi tạo nó vì PdfReader và CrossRefParser có các thể hiện riêng. Hãy kiểm tra lớp này khi quá trình phân tích thất bại ở một token sai định dạng. Có hai hành vi quan trọng đối với việc chẩn đoán:
- Các giới hạn bảo mật là hằng số, không phải cấu hình. Bộ tách token giới hạn độ sâu lồng nhau của chuỗi nguyên văn, từ điển và mảng, độ dài từ khóa, cũng như số phần tử trong mảng. Khi đầu vào vượt quá một giới hạn, nó ném
PdfParseExceptionvà nêu tên giới hạn đó trong thông báo. Đầu vào được dàn dựng để chạm một trong các giới hạn này là cơ chế phòng vệ hoạt động đúng như thiết kế, không phải lỗi của trình phân tích. readValue()định tuyến quá trình phân tích. Nó kiểm tra byte tiếp theo rồi ủy quyền choreadName(),readLiteralString(),readHexString(),readArray(),readDictionary(), hoặc bộ đọc number/reference. Tham chiếu gián tiếpN G Rđược trả về dưới dạng mảng có hình dạng['type' => 'ref', 'num' => N, 'gen' => G].PdfObject::getRef()vàPdfReader::resolveRef()nhận diện hình dạng này.
CrossRefParser — phân giải tham chiếu chéo
Phần tiêu đề “CrossRefParser — phân giải tham chiếu chéo”CrossRefParser phân tích cả hai định dạng mà Chrome có thể sinh ra:
parseXRefTable()đọc một bảngxreftruyền thống (kiểu PDF 1.x): phần đầu tiểu mục, các mục có chiều rộng cố định 20 byte, rồi đến một từ điểntrailer.parseXRefStream()đọc một luồng tham chiếu chéo (PDF 2.0, ISO 32000-2:2020 §7.5.8): một đối tượng gián tiếp với/Type /XRef, một mảng chiều rộng trường/Wvà một luồng nhị phân gồm các mục.
Cả hai đều trả về cùng một hình dạng: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() quyết định gọi bộ phân tích nào bằng cách nhìn trước bốn byte tại offset tham chiếu chéo: xref chọn bộ phân tích bảng, còn bất kỳ nội dung nào khác đều được xem là đối tượng luồng. Cả hai bộ phân tích đều áp đặt mức trần một triệu mục cho mỗi tiểu mục để từ chối các giá trị đếm bị giả mạo, vốn nếu không sẽ khiến trình phân tích chạy quá mức.
StreamDecoder — bộ lọc luồng
Phần tiêu đề “StreamDecoder — bộ lọc luồng”StreamDecoder::decode(string $data, string|array $filter) là phương thức tĩnh, áp dụng một bộ lọc hoặc một danh sách bộ lọc nối chuỗi. Nó chỉ hỗ trợ đúng các bộ lọc mà printToPDF của Chrome phát ra:
FlateDecode(zlib, với một phương án dự phòng raw-deflate)ASCIIHexDecodeASCII85Decode
Bất kỳ tên bộ lọc nào khác đều ném PdfParseException với Unsupported stream filter. Bộ giải mã giới hạn đầu ra đã giải nén ở mức 16 MiB để khống chế nguy cơ bom giải nén. Đầu ra vượt kích thước sẽ ném ngoại lệ thay vì cấp phát không giới hạn. Khi PdfReader đọc một luồng và quá trình giải mã ném ngoại lệ, nó quay về dùng các byte luồng thô, nên một bộ lọc hỏng sẽ không hủy toàn bộ quá trình phân tích.
ResourceCollector — duyệt tài nguyên theo chiều sâu
Phần tiêu đề “ResourceCollector — duyệt tài nguyên theo chiều sâu”ResourceCollector được khởi tạo với PdfReader và được gọi thông qua PdfReader::collectPageResources(). Phương thức traverse() duyệt đệ quy một giá trị, lần theo mọi tham chiếu ['type' => 'ref'] thông qua getObject(), và ghi lại mỗi đối tượng đã phân giải một lần trong một array<int, PdfObject> được lập khóa theo số đối tượng. Nó giới hạn độ sâu đệ quy và lặng lẽ bỏ qua những tham chiếu không thể phân giải, nên một tham chiếu bị treo sẽ tạo ra tập hợp một phần thay vì lỗi nghiêm trọng.
RevisionExtractor — cập nhật tăng dần và các bản sửa đổi
Phần tiêu đề “RevisionExtractor — cập nhật tăng dần và các bản sửa đổi”Một tệp PDF được ký, chú thích hoặc chỉnh sửa theo cách khác sau khi tạo sẽ mang theo các bản cập nhật tăng dần. Mỗi lần chỉnh sửa sẽ nối thêm một phần tham chiếu chéo và một trailer mới, kết thúc bằng dấu hiệu %%EOF. RevisionExtractor hoạt động hoàn toàn qua các phương thức tĩnh trên một PdfReader đã phân tích:
extractRevision(string $pdfData, PdfReader $reader, int $revision)trả về tệp được cắt cụt tại ranh giới%%EOFcủa bản sửa đổi được yêu cầu. Bản sửa đổi0(gần nhất) trả về toàn bộ tệp; các chỉ mục cao hơn trả về những ảnh chụp cũ hơn theo thứ tự.getRevisionBoundaries(string $pdfData, PdfReader $reader)trả về mộtlist<array{revision, startByte, endByte, sizeBytes}>mô tả dải byte mà từng bản sửa đổi đã đóng góp.
Sự cô lập này là có chủ đích. Việc trích xuất một bản sửa đổi cũ hơn chỉ phơi bày những đối tượng nhìn thấy được tính đến thời điểm đó, qua đó chặn các cuộc tấn công tham chiếu chéo lai, nơi một bản sửa đổi sau định nghĩa lại một đối tượng trước đó.
Hướng dẫn từng bước: kiểm tra một bản sửa đổi
Phần tiêu đề “Hướng dẫn từng bước: kiểm tra một bản sửa đổi”Quy trình này kiểm tra lịch sử sửa đổi của một tệp PDF có thể đã bị chỉnh sửa sau khi Chrome tạo ra. Ví dụ được viết theo hướng sản xuất: khai báo kiểu chặt chẽ, dùng đầy đủ gợi ý kiểu, kiểm tra đầu vào và bắt ngoại lệ cụ thể nhất.
- Đọc các byte PDF vào bộ nhớ và từ chối đầu vào rỗng trước khi khởi tạo bộ đọc.
- Khởi tạo
\NextPDF\Parser\PdfReadervà gọiparse(). - Đọc
getRevisionCount(). Giá trị bằng1nghĩa là tệp chỉ có một bản sửa đổi, không có cập nhật tăng dần. - Với mỗi bản sửa đổi, hãy đọc
RevisionXRefTablecủa nó và kiểm tragetActiveObjectCount(),hasRootUpdate(), vàgetSize(). - Tính các dải byte theo từng bản sửa đổi bằng
RevisionExtractor::getRevisionBoundaries(). - Bắt
PdfParseException, ngoại lệ cụ thể nhất mà trình phân tích ném ra, và hiển thị một thông báo chẩn đoán.
<?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;}Bộ đọc sắp xếp các bản sửa đổi từ mới nhất (index0) đến cũ nhất. Để trích xuất một ảnh chụp cũ hơn dưới dạng byte độc lập, chẳng hạn để so sánh một lần chỉnh sửa đã thay đổi những gì, hãy gọi trực tiếp bộ trích xuất:
<?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);}Xử lý thất bại
Phần tiêu đề “Xử lý thất bại”Mọi thất bại của trình phân tích đều xuất hiện dưới dạng NextPDF\Artisan\Exception\PdfParseException. Thông báo sẽ nhận diện nguyên nhân. Hãy dùng bảng dưới đây để ánh xạ một đoạn thông báo với giai đoạn phát sinh nó.
| Đoạn thông báo | Giai đoạn | Ý nghĩa |
|---|---|---|
missing %PDF- header | PdfReader::parse() | Các byte không phải là một tệp PDF, hoặc đầu vào đã bị cắt cụt ở phần đầu. |
Cannot find startxref marker / Invalid startxref offset | PdfReader::parse() | Phần đuôi tệp bị hỏng, hoặc con trỏ tham chiếu chéo nằm ngoài giới hạn. |
Expected 'xref' keyword / Invalid xref subsection header | CrossRefParser::parseXRefTable() | Bảng tham chiếu chéo truyền thống bị sai định dạng. |
XRef stream ... /Type /XRef / invalid /W array | CrossRefParser::parseXRefStream() | Một luồng tham chiếu chéo thiếu các mục từ điển bắt buộc. |
exceeds limit of (số đếm xref hoặc luồng đối tượng) | CrossRefParser / PdfReader | Một giá trị đếm bị giả mạo đã chạm cơ chế bảo vệ chống từ chối dịch vụ. |
Unsupported stream filter | StreamDecoder::decode() | Luồng dùng một bộ lọc nằm ngoài tập hợp được hỗ trợ gồm FlateDecode / ASCIIHexDecode / ASCII85Decode. |
FlateDecode decompression failed / output exceeds ... bytes limit | StreamDecoder | Dữ liệu nén không hợp lệ hoặc giãn nở vượt quá mức trần 16 MiB. |
Maximum nesting depth ... exceeded / Keyword exceeds maximum length | PdfTokenizer | Một cấu trúc được dàn dựng hoặc bất thường đã chạm một giới hạn của bộ tách token. |
Page index ... not found / out of range in subtree | PdfReader::getPage() | Chỉ mục trang được yêu cầu không tồn tại trong cây trang. |
Revision index ... out of range | PdfReader / RevisionExtractor | Chỉ mục bản sửa đổi nằm ngoài khoảng từ 0 đến getRevisionCount() - 1. |
Khi bắt ngoại lệ, hãy ghi lại thông báo và đường dẫn nguồn, rồi ném lại hoặc trả về một lỗi đã định nghĩa. Đừng âm thầm bỏ qua nó. Một khối catch rỗng che giấu chính mẩu thông tin mà trình phân tích đã nỗ lực tạo ra.
<?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;}Mặc định an toàn
Phần tiêu đề “Mặc định an toàn”- Luôn gọi
parse()trước. Mọi phương thức truy cập trênPdfReaderđều giả định chuỗi tham chiếu chéo đã được nạp. GọigetObject()hoặcgetPage()trướcparse()sẽ không trả về gì hữu ích. - Xem trình phân tích là chỉ-đọc và được định hình theo Chrome. Nó nhắm tới tập hợp con cú pháp PDF mà
printToPDFcủa Chrome phát ra. Các tệp PDF được mã hóa, bảng gợi ý đã tuyến tính hóa và các bản cập nhật tăng dần xung đột đều nằm ngoài phạm vi theo thiết kế. Đừng mở rộng nó thành công cụ sửa chữa PDF đa năng. - Giữ nguyên các giới hạn bảo mật. Các mức trần về độ sâu lồng nhau, độ dài từ khóa, kích thước mảng, số đếm tham chiếu chéo và giải nén giúp khống chế mức sử dụng tài nguyên trên đầu vào thù địch. Một
PdfParseExceptionphát sinh từ giới hạn là kết quả đúng đắn cho một tệp được dàn dựng. Việc nâng giới hạn để chấp nhận một tệp như vậy sẽ mở rộng bề mặt tấn công. - Mặc định là trang
0.getPage()vàPageImporter::import()mặc định lấy trang đầu tiên. Chỉ chọn một chỉ mục khác khi quy trình làm việc thực sự cần đến nó. - Kiểm tra đầu vào trước khi khởi tạo bộ đọc. Hãy từ chối sớm các byte rỗng hoặc không đọc được, như các ví dụ ở trên, để lỗi rõ ràng ở cấp ứng dụng xuất hiện trước bất kỳ ngoại lệ nào của trình phân tích.
- Bắt
PdfParseException, không bao giờ bắt\Exceptiontrần. Đây là kiểu duy nhất và cụ thể mà trình phân tích ném ra. Bắt nó giúp ngăn các thất bại không liên quan bị che lấp.
Xem thêm
Phần tiêu đề “Xem thêm”- Hướng dẫn dành cho nhà phát triển Artisan — ranh giới nhập ở phía trên trình phân tích, bao gồm
ChromeHtmlRenderer,PageImporter, và các lớp kiến trúc. - Tài liệu tham khảo API Artisan — các bảng phương thức đã công bố cho bề mặt công khai của gói.
- Khắc phục sự cố Artisan — hướng dẫn ưu tiên theo triệu chứng cho các lỗi của bộ kết xuất và quá trình nhập.
- Thiết lập bộ kết xuất Chrome — cấu hình bộ kết xuất tạo ra các tệp PDF mà trình phân tích này đọc.
- ISO 32000-2:2020 §7.5 (cấu trúc tệp, tham chiếu chéo, cập nhật tăng dần) và §7.2 (quy ước từ vựng) — đặc tả mà bộ tách token và bộ phân tích tham chiếu chéo triển khai. Hãy tham khảo tiêu chuẩn đã công bố để biết định dạng cấp byte có thẩm quyền.