Przejdź do głównej zawartości

Diagnostyka zaawansowanego parsera PDF

Ś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.

Korzystaj z powierzchni parsera tylko wtedy, gdy normalna ścieżka importu już zawiodła i musisz znaleźć przyczynę. Typowe sytuacje to:

  • PageImporter::import() zgłasza NextPDF\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.

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.

KlasaOdpowiedzialnośćKluczowe metody
PdfReaderOdczytuje strukturę pliku, rozwiązuje obiekty i przechodzi drzewo stron.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerAnalizuje 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()
CrossRefParserParsuje tradycyjne tablice odwołań krzyżowych oraz strumienie odwołań krzyżowych.parseXRefTable(), parseXRefStream()
StreamDecoderDekoduje bajty strumienia według /Filter.decode() (statyczna)
ResourceCollectorRekurencyjnie przechodzi drzewo Resources i zbiera każdy osiągalny obiekt pośredni.traverse(), getCollected()
RevisionExtractorDzieli plik aktualizowany przyrostowo na zakresy bajtów poszczególnych rewizji.extractRevision() (statyczna), getRevisionBoundaries() (statyczna)
PdfObjectNiezmienny sparsowany obiekt pośredni (słownik z opcjonalnym strumieniem).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableNiezmienna migawka odwołań krzyżowych dla pojedynczej rewizji.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

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) zwraca PdfObject, 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 /Pages i zwraca stronę o indeksie liczonym od zera. getPageContentStream(), getPageResources() i getPageMediaBox() wyodrębniają części potrzebne PageImporter. collectPageResources() zwraca array<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ą zwraca 1. getRevisionXRef(int $index) zwraca pojedynczą RevisionXRefTable (indeks 0 jest najnowszy). getRevisions() zwraca pełną list<RevisionXRefTable>.

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 PdfParseException i 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 do readName(), readLiteralString(), readHexString(), readArray(), readDictionary() albo czytnika liczb/odwołań. Odwołanie pośrednie N G R jest zwracane w postaci tablicy ['type' => 'ref', 'num' => N, 'gen' => G]. PdfObject::getRef() i PdfReader::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łownik trailer.
  • 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 /W oraz 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::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)
  • ASCIIHexDecode
  • ASCII85Decode

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. Rewizja 0 (najnowsza) zwraca cały plik; wyższe indeksy zwracają coraz starsze migawki.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) zwraca list<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.

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.

  1. Wczytaj bajty PDF do pamięci i odrzuć puste dane wejściowe przed utworzeniem czytnika.
  2. Utwórz \NextPDF\Parser\PdfReader i wywołaj parse().
  3. Odczytaj getRevisionCount(). Wartość 1 oznacza plik z jedną rewizją, bez aktualizacji przyrostowych.
  4. Dla każdej rewizji odczytaj jej RevisionXRefTable i sprawdź getActiveObjectCount(), hasRootUpdate() i getSize().
  5. Oblicz zakresy bajtów poszczególnych rewizji za pomocą RevisionExtractor::getRevisionBoundaries().
  6. Przechwyć PdfParseException, najbardziej szczegółowy wyjątek zgłaszany przez parser, i wyświetl komunikat diagnostyczny.
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;
}

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:

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);
}

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 komunikatuEtapCo to oznacza
missing %PDF- headerPdfReader::parse()Bajty nie są plikiem PDF lub dane wejściowe zostały obcięte na początku.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()Końcówka pliku jest uszkodzona lub wskaźnik odwołań krzyżowych jest poza zakresem.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Tradycyjna tablica odwołań krzyżowych jest błędnie sformułowana.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()W strumieniu odwołań krzyżowych brakuje wymaganych wpisów słownika.
exceeds limit of (liczba xref lub strumienia obiektów)CrossRefParser / PdfReaderSfałszowany licznik uruchomił zabezpieczenie przed odmową usługi.
Unsupported stream filterStreamDecoder::decode()Strumień używa filtra spoza obsługiwanego zestawu FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderSkompresowane dane są nieprawidłowe lub rozszerzają się ponad limit 16 MiB.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerSpreparowana lub patologiczna struktura uruchomiła limit tokenizera.
Page index ... not found / out of range in subtreePdfReader::getPage()Żądany indeks strony nie istnieje w drzewie stron.
Revision index ... out of rangePdfReader / RevisionExtractorIndeks 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ć.

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;
}
  • Zawsze najpierw wywołuj parse(). Każdy akcesor w PdfReader zakłada, że łańcuch odwołań krzyżowych jest wczytany. Wywołanie getObject() lub getPage() przed parse() 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 printToPDF w 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. PdfParseException z limitu to poprawny wynik dla spreparowanego pliku. Podniesienie limitu w celu zaakceptowania takiego pliku poszerza powierzchnię ataku.
  • Domyślnie używaj strony 0. getPage() i PageImporter::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.
  • Przewodnik dla programistów Artisana — granica importu nad parserem, w tym ChromeHtmlRenderer, PageImporter oraz 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.