Diagnostyka zaawansowanego parsera PDF
W skrócie
Dział zatytułowany „W skrócie”Ścieżka importu Artisana odczytuje plik Portable Document Format (PDF) wygenerowany przez Chrome i przenosi jedną stronę do dokumentu NextPDF. Gdy problematyczne dane wejściowe przerwą ten import, zejdź poniżej PageImporter::import() do klas parsera, które odczytują plik bajt po bajcie.
Ten przewodnik opisuje niskopoziomową powierzchnię parsera w przestrzeni nazw NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor oraz obiekty wartości PdfObject i RevisionXRefTable. Każdy wymieniony tu symbol istnieje w nextpdf/artisan. Przewodnik przedstawia parser w jego rzeczywistej postaci, a nie jako wyidealizowany interfejs.
Korzystaj z tego przewodnika zarówno jako z wyjaśnienia, jak i z instrukcji. Pokazuje, jak elementy do siebie pasują, a następnie przeprowadza przez inspekcję rewizji aktualizacji przyrostowej. Informacje o granicy importu znajdującej się nad tą warstwą znajdziesz w przewodniku dla programistów Artisana.
Kiedy tego potrzebujesz
Dział zatytułowany „Kiedy tego potrzebujesz”Korzystaj z powierzchni parsera tylko wtedy, gdy normalna ścieżka importu już zawiodła i musisz znaleźć przyczynę. Typowe sytuacje to:
PageImporter::import()zgłaszaNextPDF\Artisan\Exception\PdfParseException, a Ty musisz ustalić, czy winę ponosi tablica odwołań krzyżowych, filtr strumienia, czy drzewo stron.- Aktualizacja Chrome zmienia format wyjściowy, na przykład gdy tradycyjna tablica odwołań krzyżowych staje się strumieniem odwołań krzyżowych lub odwrotnie, i Twoje fikstury przestają pasować.
- Otrzymujesz plik PDF od osoby trzeciej, który nie został utworzony przez Chrome, i chcesz potwierdzić, czy parser w ogóle potrafi go odczytać.
- Analizujesz dokument aktualizowany przyrostowo i potrzebujesz zakresów bajtów poszczególnych rewizji lub widoczności obiektów.
Jeśli tworzysz zwykłą integrację renderera, ta powierzchnia nie jest potrzebna. Parser jest wewnętrznym narzędziem diagnostycznym, a nie biblioteką PDF ogólnego przeznaczenia. Nie obsługuje zaszyfrowanych plików PDF, tablic podpowiedzi linearyzacji ani aktualizacji przyrostowych ze sprzecznymi redefinicjami obiektów.
Powierzchnia parsera
Dział zatytułowany „Powierzchnia parsera”Parser to niewielki zestaw klas o pojedynczej odpowiedzialności. PdfReader jest punktem wejścia. Pozostałe klasy to klasy współpracujące, które tworzy lub wywołuje.
| Klasa | Odpowiedzialność | Kluczowe metody |
|---|---|---|
PdfReader | Odczytuje strukturę pliku, rozwiązuje obiekty i przechodzi drzewo stron. | parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions() |
PdfTokenizer | Analizuje składnię leksykalną zgodnie z ISO 32000-2:2020 §7.2: nazwy, ciągi znaków, liczby, słowniki, tablice i odwołania. | readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset() |
CrossRefParser | Parsuje tradycyjne tablice odwołań krzyżowych oraz strumienie odwołań krzyżowych. | parseXRefTable(), parseXRefStream() |
StreamDecoder | Dekoduje bajty strumienia według /Filter. | decode() (statyczna) |
ResourceCollector | Rekurencyjnie przechodzi drzewo Resources i zbiera każdy osiągalny obiekt pośredni. | traverse(), getCollected() |
RevisionExtractor | Dzieli plik aktualizowany przyrostowo na zakresy bajtów poszczególnych rewizji. | extractRevision() (statyczna), getRevisionBoundaries() (statyczna) |
PdfObject | Niezmienny sparsowany obiekt pośredni (słownik z opcjonalnym strumieniem). | get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes() |
RevisionXRefTable | Niezmienna migawka odwołań krzyżowych dla pojedynczej rewizji. | getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize() |
PdfReader — punkt wejścia
Dział zatytułowany „PdfReader — punkt wejścia”Utwórz \NextPDF\Parser\PdfReader z surowymi bajtami PDF, a następnie wywołaj parse() przed wywołaniem jakiejkolwiek innej metody. parse() sprawdza nagłówek %PDF-, znajduje startxref w końcówce pliku i przechodzi łańcuch odwołań krzyżowych, podążając za wpisami /Prev.
Po wywołaniu parse() czytnik udostępnia trzy grupy metod:
- Dostęp do obiektów.
getObject(int $objNum)zwracaPdfObject, automatycznie rozwiązując wpisy typu 2 (obiekty przechowywane wewnątrz strumienia obiektów).getObjectNumbers()zwraca posortowanąlist<int>wszystkich niewolnych numerów obiektów.resolveRef(mixed $value)podąża za jednym odwołaniem pośrednim. Wartość bezpośrednia pozostaje bez zmian. - Dostęp do stron.
getPage(int $pageIndex)rozwiązuje katalog, przechodzi/Pagesi zwraca stronę o indeksie liczonym od zera.getPageContentStream(),getPageResources()igetPageMediaBox()wyodrębniają części potrzebnePageImporter.collectPageResources()zwracaarray<int, PdfObject>dla każdego obiektu osiągalnego z Resources i Contents strony. - Dostęp do rewizji.
getRevisionCount()zwraca liczbę aktualizacji przyrostowych. Dla pliku z jedną rewizją zwraca1.getRevisionXRef(int $index)zwraca pojedyncząRevisionXRefTable(indeks0jest najnowszy).getRevisions()zwraca pełnąlist<RevisionXRefTable>.
PdfTokenizer — analiza leksykalna
Dział zatytułowany „PdfTokenizer — analiza leksykalna”PdfTokenizer odczytuje strumień bajtów. Rzadko tworzysz go samodzielnie, ponieważ PdfReader i CrossRefParser mają własne instancje. Sprawdzaj tę warstwę, gdy parsowanie kończy się błędem na nieprawidłowo sformułowanym tokenie. W diagnostyce istotne są dwa zachowania:
- Limity bezpieczeństwa są stałymi, a nie konfiguracją. Tokenizer ogranicza zagnieżdżanie ciągów literałów, zagnieżdżanie słowników i tablic, długość słów kluczowych oraz liczbę elementów tablicy. Gdy dane wejściowe przekroczą limit, zgłasza
PdfParseExceptioni podaje nazwę limitu w komunikacie. Spreparowane dane wejściowe, które wyzwalają jeden z tych limitów, to działająca zgodnie z zamierzeniem ochrona, a nie błąd parsera. readValue()kieruje parsowaniem. Sprawdza następny bajt i deleguje doreadName(),readLiteralString(),readHexString(),readArray(),readDictionary()albo czytnika liczb/odwołań. Odwołanie pośrednieN G Rjest zwracane w postaci tablicy['type' => 'ref', 'num' => N, 'gen' => G].PdfObject::getRef()iPdfReader::resolveRef()rozpoznają tę postać.
CrossRefParser — rozwiązywanie odwołań krzyżowych
Dział zatytułowany „CrossRefParser — rozwiązywanie odwołań krzyżowych”CrossRefParser parsuje oba formaty, które może emitować Chrome:
parseXRefTable()odczytuje tradycyjną tablicęxref(w stylu PDF 1.x): nagłówki podsekcji, wpisy o stałej szerokości 20 bajtów, a następnie słowniktrailer.parseXRefStream()odczytuje strumień odwołań krzyżowych (PDF 2.0, ISO 32000-2:2020 §7.5.8): obiekt pośredni z/Type /XRef, tablicę szerokości pól/Woraz binarny strumień wpisów.
Oba zwracają tę samą postać: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() decyduje, który parser wywołać, podglądając cztery bajty na przesunięciu odwołań krzyżowych: xref wybiera parser tablicy, a wszystko inne jest traktowane jako obiekt strumienia. Oba parsery wymuszają górną granicę miliona wpisów na podsekcję, aby odrzucić sfałszowane liczniki, które w przeciwnym razie zmusiłyby parser do zbyt długiej pracy.
StreamDecoder — filtry strumieni
Dział zatytułowany „StreamDecoder — filtry strumieni”StreamDecoder::decode(string $data, string|array $filter) jest statyczna i stosuje jeden filtr albo połączoną listę filtrów. Obsługuje dokładnie te filtry, które emituje printToPDF w Chrome:
FlateDecode(zlib, z awaryjnym trybem raw-deflate)ASCIIHexDecodeASCII85Decode
Każda inna nazwa filtra powoduje zgłoszenie PdfParseException z komunikatem Unsupported stream filter. Dekoder ogranicza zdekompresowane dane wyjściowe do 16 MiB, aby zmniejszyć ryzyko bomby dekompresyjnej. Zbyt duże dane wyjściowe powodują zgłoszenie wyjątku zamiast alokacji bez ograniczeń. Gdy PdfReader odczytuje strumień, a dekodowanie zgłosi wyjątek, wraca do surowych bajtów strumienia, więc jeden wadliwy filtr nie przerywa całego parsowania.
ResourceCollector — głębokie przechodzenie zasobów
Dział zatytułowany „ResourceCollector — głębokie przechodzenie zasobów”ResourceCollector jest tworzony z PdfReader i wywoływany przez PdfReader::collectPageResources(). Jego metoda traverse() rekurencyjnie przetwarza wartość, podąża za każdym odwołaniem ['type' => 'ref'] przez getObject() i zapisuje każdy rozwiązany obiekt raz w array<int, PdfObject> z kluczem będącym numerem obiektu. Ogranicza głębokość rekurencji i po cichu pomija odwołania, których nie może rozwiązać, więc jedno wiszące odwołanie daje częściowy zbiór zamiast twardej awarii.
RevisionExtractor — aktualizacje przyrostowe i rewizje
Dział zatytułowany „RevisionExtractor — aktualizacje przyrostowe i rewizje”Plik PDF, który po utworzeniu został podpisany, opatrzony adnotacjami lub w inny sposób edytowany, zawiera aktualizacje przyrostowe. Każda edycja dołącza nową sekcję odwołań krzyżowych i trailer, zakończoną znacznikiem %%EOF. RevisionExtractor działa wyłącznie przez metody statyczne na sparsowanym PdfReader:
extractRevision(string $pdfData, PdfReader $reader, int $revision)zwraca plik obcięty na granicy%%EOFżądanej rewizji. Rewizja0(najnowsza) zwraca cały plik; wyższe indeksy zwracają coraz starsze migawki.getRevisionBoundaries(string $pdfData, PdfReader $reader)zwracalist<array{revision, startByte, endByte, sizeBytes}>opisującą zakres bajtów wniesiony przez każdą rewizję.
Ta izolacja jest celowa. Wyodrębnienie starszej rewizji udostępnia tylko obiekty widoczne do tego momentu, co blokuje hybrydowe ataki na odwołania krzyżowe, w których późniejsza rewizja przedefiniowuje wcześniejszy obiekt.
Przewodnik: inspekcja rewizji
Dział zatytułowany „Przewodnik: inspekcja rewizji”Ta procedura sprawdza historię rewizji pliku PDF, który mógł zostać edytowany po wygenerowaniu go przez Chrome. Przykład jest przygotowany pod kątem produkcji: deklaruje typy ścisłe, używa pełnych podpowiedzi typów, waliduje dane wejściowe i przechwytuje najbardziej szczegółowy wyjątek.
- Wczytaj bajty PDF do pamięci i odrzuć puste dane wejściowe przed utworzeniem czytnika.
- Utwórz
\NextPDF\Parser\PdfReaderi wywołajparse(). - Odczytaj
getRevisionCount(). Wartość1oznacza plik z jedną rewizją, bez aktualizacji przyrostowych. - Dla każdej rewizji odczytaj jej
RevisionXRefTablei sprawdźgetActiveObjectCount(),hasRootUpdate()igetSize(). - Oblicz zakresy bajtów poszczególnych rewizji za pomocą
RevisionExtractor::getRevisionBoundaries(). - Przechwyć
PdfParseException, najbardziej szczegółowy wyjątek zgłaszany przez parser, i wyświetl komunikat diagnostyczny.
<?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;}Czytnik porządkuje rewizje od najnowszej (index0) do najstarszej. Aby wyodrębnić starszą migawkę jako samodzielne bajty, na przykład w celu porównania zmian wprowadzonych przez edycję, wywołaj ekstraktor bezpośrednio:
<?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);}Obsługa awarii
Dział zatytułowany „Obsługa awarii”Każda awaria parsera ujawnia się jako NextPDF\Artisan\Exception\PdfParseException. Komunikat wskazuje przyczynę. Skorzystaj z poniższej tabeli, aby zmapować fragment komunikatu na etap, który go zgłosił.
| Fragment komunikatu | Etap | Co to oznacza |
|---|---|---|
missing %PDF- header | PdfReader::parse() | Bajty nie są plikiem PDF lub dane wejściowe zostały obcięte na początku. |
Cannot find startxref marker / Invalid startxref offset | PdfReader::parse() | Końcówka pliku jest uszkodzona lub wskaźnik odwołań krzyżowych jest poza zakresem. |
Expected 'xref' keyword / Invalid xref subsection header | CrossRefParser::parseXRefTable() | Tradycyjna tablica odwołań krzyżowych jest błędnie sformułowana. |
XRef stream ... /Type /XRef / invalid /W array | CrossRefParser::parseXRefStream() | W strumieniu odwołań krzyżowych brakuje wymaganych wpisów słownika. |
exceeds limit of (liczba xref lub strumienia obiektów) | CrossRefParser / PdfReader | Sfałszowany licznik uruchomił zabezpieczenie przed odmową usługi. |
Unsupported stream filter | StreamDecoder::decode() | Strumień używa filtra spoza obsługiwanego zestawu FlateDecode / ASCIIHexDecode / ASCII85Decode. |
FlateDecode decompression failed / output exceeds ... bytes limit | StreamDecoder | Skompresowane dane są nieprawidłowe lub rozszerzają się ponad limit 16 MiB. |
Maximum nesting depth ... exceeded / Keyword exceeds maximum length | PdfTokenizer | Spreparowana lub patologiczna struktura uruchomiła limit tokenizera. |
Page index ... not found / out of range in subtree | PdfReader::getPage() | Żądany indeks strony nie istnieje w drzewie stron. |
Revision index ... out of range | PdfReader / RevisionExtractor | Indeks rewizji jest poza zakresem od 0 do getRevisionCount() - 1. |
Gdy przechwytujesz wyjątek, zapisz w dzienniku komunikat i ścieżkę źródłową, a następnie ponownie go zgłoś albo zwróć zdefiniowany błąd. Nie odrzucaj go po cichu. Pusty blok catch ukrywa jedyną informację diagnostyczną, którą parser zdołał wytworzyć.
<?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;}Bezpieczne ustawienia domyślne
Dział zatytułowany „Bezpieczne ustawienia domyślne”- Zawsze najpierw wywołuj
parse(). Każdy akcesor wPdfReaderzakłada, że łańcuch odwołań krzyżowych jest wczytany. WywołaniegetObject()lubgetPage()przedparse()nie zwraca użytecznego wyniku. - Traktuj parser jako narzędzie tylko do odczytu, dostosowane do Chrome. Jest przeznaczony do podzbioru składni PDF, który emituje
printToPDFw Chrome. Zaszyfrowane pliki PDF, tablice podpowiedzi linearyzacji oraz sprzeczne aktualizacje przyrostowe są z założenia poza zakresem. Nie rozszerzaj go do roli ogólnego narzędzia do naprawy plików PDF. - Utrzymuj limity bezpieczeństwa. Limity zagnieżdżania, długości słów kluczowych, rozmiaru tablicy, liczby odwołań krzyżowych oraz dekompresji ograniczają zużycie zasobów przy wrogich danych wejściowych.
PdfParseExceptionz limitu to poprawny wynik dla spreparowanego pliku. Podniesienie limitu w celu zaakceptowania takiego pliku poszerza powierzchnię ataku. - Domyślnie używaj strony
0.getPage()iPageImporter::import()domyślnie używają pierwszej strony. Wybieraj inny indeks tylko wtedy, gdy proces celowo tego wymaga. - Waliduj dane wejściowe przed utworzeniem czytnika. Odrzucaj puste lub nieczytelne bajty wcześnie, tak jak robią to powyższe przykłady, aby zrozumiały błąd na poziomie aplikacji pojawił się przed jakimkolwiek wyjątkiem parsera.
- Przechwytuj
PdfParseException, nigdy gołego\Exception. To jedyny konkretny typ zgłaszany przez parser. Przechwytywanie go chroni niepowiązane awarie przed zamaskowaniem.
Zobacz też
Dział zatytułowany „Zobacz też”- Przewodnik dla programistów Artisana — granica importu nad parserem, w tym
ChromeHtmlRenderer,PageImporteroraz warstwy architektury. - Dokumentacja API Artisana — tabele metod opublikowanej publicznej powierzchni pakietu.
- Rozwiązywanie problemów z Artisanem — wskazówki oparte na objawach dotyczące awarii renderera i importu.
- Konfiguracja renderera Chrome — konfigurowanie renderera, który tworzy pliki PDF odczytywane przez ten parser.
- ISO 32000-2:2020 §7.5 (struktura pliku, odwołania krzyżowe, aktualizacje przyrostowe) oraz §7.2 (konwencje leksykalne) — specyfikacja, którą implementują tokenizer i parser odwołań krzyżowych. Skonsultuj się z opublikowanym standardem, aby poznać miarodajny format na poziomie bajtów.