Diagnose des erweiterten PDF-Parsers
Auf einen Blick
Abschnitt betitelt „Auf einen Blick“Der Importpfad von Artisan liest eine von Chrome erzeugte PDF-Datei (Portable Document Format) und überführt eine Seite in ein NextPDF-Dokument. Wenn dieser Import an einer schwierigen Eingabe scheitert, müssen Sie unterhalb von PageImporter::import() bei den Parser-Klassen ansetzen, die die Datei Byte für Byte lesen.
Diese Anleitung dokumentiert die Low-Level-Parser-Oberfläche im Namespace NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor sowie die Wertobjekte PdfObject und RevisionXRefTable. Alle hier gezeigten Symbole existieren in nextpdf/artisan. Die Anleitung dokumentiert den Parser so, wie er gebaut ist, nicht als idealisierte Oberfläche.
Verstehen Sie diese Anleitung zugleich als Erklärung und als Schritt-für-Schritt-Anleitung. Sie erklärt, wie die Teile zusammenspielen, und führt Sie dann durch die Untersuchung einer Revision aus einem inkrementellen Update. Für die Importgrenze oberhalb dieser Schicht siehe den Artisan-Entwicklerleitfaden.
Wann Sie das brauchen
Abschnitt betitelt „Wann Sie das brauchen“Nutzen Sie die Parser-Oberfläche nur, wenn der normale Importpfad bereits gescheitert ist und Sie die Ursache eingrenzen müssen. Typische Auslöser:
PageImporter::import()wirftNextPDF\Artisan\Exception\PdfParseException, und Sie müssen wissen, ob die Querverweis-Tabelle, ein Stream-Filter oder der Seitenbaum die Ursache ist.- Ein Chrome-Upgrade ändert das Ausgabeformat (eine traditionelle Querverweis-Tabelle wird zu einem Querverweis-Stream oder umgekehrt), und Ihre Fixtures passen nicht mehr.
- Sie erhalten ein Drittanbieter-PDF, das nicht von Chrome erzeugt wurde, und möchten prüfen, ob der Parser es überhaupt lesen kann.
- Sie führen eine forensische Analyse eines inkrementell aktualisierten Dokuments durch und benötigen die Bytebereiche pro Revision oder die Sichtbarkeit von Objekten.
Wenn Sie eine normale Renderer-Integration schreiben, benötigen Sie diese Oberfläche nicht. Der Parser ist ein internes Diagnosewerkzeug, keine PDF-Allzweckbibliothek: Er unterstützt weder verschlüsselte PDFs noch linearisierte Hint-Tabellen noch inkrementelle Updates mit widersprüchlichen Objekt-Neudefinitionen.
Parser-Oberfläche
Abschnitt betitelt „Parser-Oberfläche“Der Parser besteht aus einem kleinen Satz von Klassen mit jeweils genau einer Verantwortung. PdfReader ist der Einstiegspunkt; die übrigen Klassen erzeugt oder ruft er nach Bedarf auf.
| Klasse | Verantwortung | Wichtige Methoden |
|---|---|---|
PdfReader | Liest die Dateistruktur, löst Objekte auf, durchläuft den Seitenbaum. | parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions() |
PdfTokenizer | Lexikalische Analyse gemäß ISO 32000-2:2020 §7.2 — Namen, Strings, Zahlen, Dictionarys, Arrays, Referenzen. | readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset() |
CrossRefParser | Parst traditionelle Querverweis-Tabellen und Querverweis-Streams. | parseXRefTable(), parseXRefStream() |
StreamDecoder | Dekodiert Stream-Bytes nach /Filter. | decode() (statisch) |
ResourceCollector | Durchläuft einen Resources-Baum rekursiv und sammelt jedes erreichbare indirekte Objekt. | traverse(), getCollected() |
RevisionExtractor | Zerlegt eine inkrementell aktualisierte Datei in Bytebereiche pro Revision. | extractRevision() (statisch), getRevisionBoundaries() (statisch) |
PdfObject | Unveränderliches, geparstes indirektes Objekt (Dictionary plus optionaler Stream). | get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes() |
RevisionXRefTable | Unveränderlicher Snapshot der Querverweise pro Revision. | getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize() |
PdfReader — der Einstiegspunkt
Abschnitt betitelt „PdfReader — der Einstiegspunkt“Konstruieren Sie \NextPDF\Parser\PdfReader mit den rohen PDF-Bytes und rufen Sie vor jeder anderen Methode parse() auf. parse() prüft den %PDF--Header, findet startxref am Dateiende und durchläuft die Querverweis-Kette entlang der /Prev-Verweise.
Nach parse() stehen am Reader drei Gruppen von Methoden bereit:
- Objektzugriff.
getObject(int $objNum)liefert einPdfObjectund löst dabei Type-2-Einträge (Objekte, die in einem Object-Stream gespeichert sind) transparent auf.getObjectNumbers()liefert eine sortiertelist<int>aller nicht freien Objektnummern.resolveRef(mixed $value)folgt einer einzelnen indirekten Referenz; ein direkter Wert wird unverändert durchgereicht. - Seitenzugriff.
getPage(int $pageIndex)löst den Katalog auf, durchläuft/Pagesund liefert die Seite am nullbasierten Index.getPageContentStream(),getPageResources()undgetPageMediaBox()extrahieren die Teile, diePageImporterbenötigt.collectPageResources()liefert einarray<int, PdfObject>mit jedem Objekt, das von den Resources und Contents der Seite aus erreichbar ist. - Revisionszugriff.
getRevisionCount()liefert die Anzahl der inkrementellen Revisionen (eine Datei mit einer einzigen Revision ergibt1).getRevisionXRef(int $index)liefert eineRevisionXRefTable(Index0ist die aktuellste).getRevisions()liefert die vollständigelist<RevisionXRefTable>.
PdfTokenizer — lexikalische Analyse
Abschnitt betitelt „PdfTokenizer — lexikalische Analyse“PdfTokenizer liest den Byte-Stream. Sie konstruieren ihn selten selbst — PdfReader und CrossRefParser besitzen ihre Instanzen —, aber es ist die Schicht, die Sie untersuchen, wenn ein Parse-Vorgang an einem fehlerhaften Token scheitert. Zwei Verhaltensweisen sind für die Diagnose wichtig:
- Sicherheitsgrenzen sind Konstanten, keine Konfiguration. Der Tokenizer begrenzt die Verschachtelung von Literal-Strings, die Verschachtelung von Dictionarys und Arrays, die Keyword-Länge und die Anzahl der Array-Elemente. Wird eine Grenze überschritten, wirft er
PdfParseExceptionmit der überschrittenen Grenze in der Fehlermeldung. Eine gezielt gestaltete Eingabe, die eine dieser Grenzen auslöst, ist eine planmäßig funktionierende Abwehr, kein Parser-Bug. readValue()ist der Dispatcher. Er prüft das nächste Byte und delegiert anreadName(),readLiteralString(),readHexString(),readArray(),readDictionary()oder einen Reader für Zahlen oder Referenzen. Eine indirekte ReferenzN G Rwird als Array-Form['type' => 'ref', 'num' => N, 'gen' => G]zurückgegeben. Genau diese Form erkennenPdfObject::getRef()undPdfReader::resolveRef().
CrossRefParser — Auflösung der Querverweise
Abschnitt betitelt „CrossRefParser — Auflösung der Querverweise“CrossRefParser parst beide Formate, die Chrome ausgeben kann:
parseXRefTable()liest eine traditionellexref-Tabelle (im PDF-1.x-Stil): Subsection-Header, gefolgt von 20 Byte breiten Einträgen mit fester Breite, dann eintrailer-Dictionary.parseXRefStream()liest einen Querverweis-Stream (PDF 2.0, ISO 32000-2:2020 §7.5.8): ein indirektes Objekt mit/Type /XRef, ein/W-Array mit Feldbreiten und ein binärer Stream mit Einträgen.
Beide liefern dieselbe Form zurück: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() entscheidet anhand der vier Bytes am Querverweis-Offset, welcher Parser aufzurufen ist: xref wählt den Tabellen-Parser; alles andere wird als Stream-Objekt behandelt. Beide Parser erzwingen eine Obergrenze von einer Million Einträgen pro Subsection, damit gefälschte Zählwerte den Parser nicht übermäßig lange laufen lassen.
StreamDecoder — Stream-Filter
Abschnitt betitelt „StreamDecoder — Stream-Filter“StreamDecoder::decode(string $data, string|array $filter) ist statisch und wendet einen einzelnen Filter oder eine verkettete Liste von Filtern an. Er unterstützt genau die Filter, die Chromes printToPDF ausgibt:
FlateDecode(zlib, mit einem Raw-Deflate-Fallback)ASCIIHexDecodeASCII85Decode
Jeder andere Filtername führt dazu, dass der Decoder PdfParseException mit Unsupported stream filter wirft. Der Decoder begrenzt die dekomprimierte Ausgabe auf 16 MiB, um das Risiko einer Dekompressions-Bombe zu beschränken; bei zu großer Ausgabe wirft er, statt unbegrenzt Speicher zu allozieren. Wenn PdfReader einen Stream liest und das Dekodieren fehlschlägt, fällt er auf die rohen Stream-Bytes zurück, damit ein einzelner fehlerhafter Filter nicht den gesamten Parse-Vorgang abbricht.
ResourceCollector — rekursive Ressourcen-Traversierung
Abschnitt betitelt „ResourceCollector — rekursive Ressourcen-Traversierung“ResourceCollector wird mit dem PdfReader konstruiert und über PdfReader::collectPageResources() aufgerufen. Seine Methode traverse() durchläuft einen Wert rekursiv, folgt jeder ['type' => 'ref']-Referenz über getObject() und erfasst jedes aufgelöste Objekt einmal in einem nach Objektnummer geschlüsselten array<int, PdfObject>. Die Methode begrenzt die Rekursionstiefe und überspringt nicht auflösbare Referenzen stillschweigend, sodass eine einzelne lose Referenz eine partielle Sammlung statt eines harten Fehlers ergibt.
RevisionExtractor — inkrementelle Updates und Revisionen
Abschnitt betitelt „RevisionExtractor — inkrementelle Updates und Revisionen“Ein PDF, das nach der Erstellung signiert, annotiert oder anderweitig bearbeitet wurde, trägt inkrementelle Updates: Jede Bearbeitung hängt einen neuen Querverweis-Abschnitt und Trailer an, der mit einem %%EOF-Marker endet. RevisionExtractor arbeitet ausschließlich mit statischen Methoden auf Basis eines geparsten PdfReader:
extractRevision(string $pdfData, PdfReader $reader, int $revision)liefert die Datei, abgeschnitten an der%%EOF-Grenze der angeforderten Revision. Revision0(die aktuellste) liefert die gesamte Datei; höhere Indizes liefern zunehmend ältere Snapshots.getRevisionBoundaries(string $pdfData, PdfReader $reader)liefert einelist<array{revision, startByte, endByte, sizeBytes}>, die den von jeder Revision beigetragenen Bytebereich beschreibt.
Diese Isolation ist beabsichtigt: Das Extrahieren einer älteren Revision legt nur die bis zu diesem Punkt sichtbaren Objekte offen. Dadurch werden hybride Querverweis-Angriffe blockiert, bei denen eine spätere Revision ein früheres Objekt neu definiert.
Schritt für Schritt: eine Revision untersuchen
Abschnitt betitelt „Schritt für Schritt: eine Revision untersuchen“Diese Prozedur untersucht die Revisionshistorie eines PDFs, das nach der Erzeugung durch Chrome möglicherweise bearbeitet wurde. Das Beispiel ist produktionsnah gestaltet: Es deklariert strikte Typen, nutzt vollständige Type-Hints, validiert seine Eingabe und fängt die spezifischste Exception.
- Lesen Sie die PDF-Bytes in den Speicher und weisen Sie leere Eingaben zurück, bevor Sie den Reader konstruieren.
- Konstruieren Sie
\NextPDF\Parser\PdfReaderund rufen Sieparse()auf. - Lesen Sie
getRevisionCount()aus. Ein Wert von1bedeutet eine Datei mit einer einzigen Revision ohne inkrementelle Updates. - Lesen Sie für jede Revision ihre
RevisionXRefTableund prüfen SiegetActiveObjectCount(),hasRootUpdate()undgetSize(). - Berechnen Sie die Bytebereiche pro Revision mit
RevisionExtractor::getRevisionBoundaries(). - Fangen Sie
PdfParseException— die spezifischste Exception, die der Parser auslöst — und geben Sie eine Diagnosemeldung aus.
<?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;}Der Reader ordnet Revisionen von der neuesten (index0) zur ältesten. Um einen älteren Snapshot als eigenständige Bytes zu extrahieren — zum Beispiel, um per Diff zu prüfen, was eine Bearbeitung geändert hat —, rufen Sie den Extractor direkt auf:
<?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);}Fehlerbehandlung
Abschnitt betitelt „Fehlerbehandlung“Jeder Parser-Fehler wird als NextPDF\Artisan\Exception\PdfParseException sichtbar. Die Nachricht grenzt die Ursache ein. Nutzen Sie die folgende Tabelle, um ein Nachrichtenfragment der Stufe zuzuordnen, die es ausgelöst hat.
| Nachrichtenfragment | Stufe | Was es bedeutet |
|---|---|---|
missing %PDF- header | PdfReader::parse() | Die Bytes sind kein PDF, oder die Eingabe wurde am Anfang abgeschnitten. |
Cannot find startxref marker / Invalid startxref offset | PdfReader::parse() | Das Dateiende ist beschädigt oder der Querverweis-Zeiger liegt außerhalb der Grenzen. |
Expected 'xref' keyword / Invalid xref subsection header | CrossRefParser::parseXRefTable() | Eine traditionelle Querverweis-Tabelle ist fehlerhaft. |
XRef stream ... /Type /XRef / invalid /W array | CrossRefParser::parseXRefStream() | Einem Querverweis-Stream fehlen erforderliche Dictionary-Einträge. |
exceeds limit of (xref- oder Object-Stream-Anzahl) | CrossRefParser / PdfReader | Ein gefälschter Zählwert hat einen Denial-of-Service-Schutz ausgelöst. |
Unsupported stream filter | StreamDecoder::decode() | Der Stream nutzt einen Filter außerhalb des unterstützten Satzes FlateDecode / ASCIIHexDecode / ASCII85Decode. |
FlateDecode decompression failed / output exceeds ... bytes limit | StreamDecoder | Die komprimierten Daten sind ungültig oder expandieren über die Obergrenze von 16 MiB hinaus. |
Maximum nesting depth ... exceeded / Keyword exceeds maximum length | PdfTokenizer | Eine gezielt gestaltete oder pathologische Struktur hat eine Tokenizer-Grenze ausgelöst. |
Page index ... not found / out of range in subtree | PdfReader::getPage() | Der angeforderte Seitenindex existiert nicht im Seitenbaum. |
Revision index ... out of range | PdfReader / RevisionExtractor | Der Revisionsindex liegt außerhalb von 0 bis getRevisionCount() - 1. |
Wenn Sie die Exception fangen, protokollieren Sie die Nachricht und den Quellpfad. Werfen Sie sie dann entweder erneut oder geben Sie einen definierten Fehler zurück. Verwerfen Sie sie nicht stillschweigend: Ein leerer catch-Block verbirgt genau die Information, die der Parser gezielt bereitstellt.
<?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;}Sichere Standardwerte
Abschnitt betitelt „Sichere Standardwerte“- Rufen Sie immer zuerst
parse()auf. Jeder Accessor anPdfReadersetzt voraus, dass die Querverweis-Kette geladen ist. Ein Aufruf vongetObject()odergetPage()vorparse()liefert keine brauchbaren Ergebnisse. - Behandeln Sie den Parser als schreibgeschützt und auf Chrome-Ausgaben zugeschnitten. Er zielt auf den Teil der PDF-Syntax, den Chromes
printToPDFausgibt. Verschlüsselte PDFs, linearisierte Hint-Tabellen und widersprüchliche inkrementelle Updates sind bewusst außerhalb des Geltungsbereichs. Erweitern Sie ihn nicht zu einem allgemeinen PDF-Reparaturwerkzeug. - Belassen Sie die Sicherheitsgrenzen an Ort und Stelle. Die Obergrenzen für Verschachtelung, Keyword-Länge, Array-Größe, Querverweis-Anzahl und Dekompression existieren, um den Ressourcenverbrauch bei feindlicher Eingabe zu beschränken. Eine
PdfParseExceptionaufgrund einer Grenze ist das richtige Ergebnis für eine gezielt gestaltete Datei; eine Grenze anzuheben, um eine solche Datei zu akzeptieren, vergrößert die Angriffsfläche. - Verwenden Sie standardmäßig Seite
0.getPage()undPageImporter::import()verwenden standardmäßig die erste Seite. Wählen Sie einen anderen Index nur, wenn der Workflow ihn bewusst benötigt. - Validieren Sie die Eingabe, bevor Sie den Reader konstruieren. Weisen Sie leere oder nicht lesbare Bytes früh zurück, so wie es die Beispiele oben tun, damit ein klarer Fehler auf Anwendungsebene jeder Parser-Exception vorausgeht.
- Fangen Sie
PdfParseException, niemals das bloße\Exception. Es ist der einzige spezifische Typ, den der Parser auslöst; ihn zu fangen verhindert, dass nicht zusammenhängende Fehler verdeckt werden.
Siehe auch
Abschnitt betitelt „Siehe auch“- Artisan-Entwicklerleitfaden — die Importgrenze oberhalb des Parsers, einschließlich
ChromeHtmlRenderer,PageImporterund der Schichtarchitektur. - Artisan-API-Referenz — die veröffentlichten Methodentabellen für die öffentliche Oberfläche des Pakets.
- Artisan-Fehlerbehebung — symptomorientierte Hilfe für Renderer- und Importfehler.
- Chrome-Renderer-Einrichtung — Konfiguration des Renderers, der die PDFs erzeugt, die dieser Parser liest.
- ISO 32000-2:2020 §7.5 (Dateistruktur, Querverweise, inkrementelle Updates) und §7.2 (lexikalische Konventionen) — die Spezifikation, die der Tokenizer und der Querverweis-Parser implementieren. Ziehen Sie den veröffentlichten Standard für das maßgebliche Byte-Level-Format zurate.