Zum Inhalt springen

Diagnose des erweiterten PDF-Parsers

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.

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() wirft NextPDF\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.

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.

KlasseVerantwortungWichtige Methoden
PdfReaderLiest die Dateistruktur, löst Objekte auf, durchläuft den Seitenbaum.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerLexikalische 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()
CrossRefParserParst traditionelle Querverweis-Tabellen und Querverweis-Streams.parseXRefTable(), parseXRefStream()
StreamDecoderDekodiert Stream-Bytes nach /Filter.decode() (statisch)
ResourceCollectorDurchläuft einen Resources-Baum rekursiv und sammelt jedes erreichbare indirekte Objekt.traverse(), getCollected()
RevisionExtractorZerlegt eine inkrementell aktualisierte Datei in Bytebereiche pro Revision.extractRevision() (statisch), getRevisionBoundaries() (statisch)
PdfObjectUnveränderliches, geparstes indirektes Objekt (Dictionary plus optionaler Stream).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableUnveränderlicher Snapshot der Querverweise pro Revision.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

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 ein PdfObject und löst dabei Type-2-Einträge (Objekte, die in einem Object-Stream gespeichert sind) transparent auf. getObjectNumbers() liefert eine sortierte list<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 /Pages und liefert die Seite am nullbasierten Index. getPageContentStream(), getPageResources() und getPageMediaBox() extrahieren die Teile, die PageImporter benötigt. collectPageResources() liefert ein array<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 ergibt 1). getRevisionXRef(int $index) liefert eine RevisionXRefTable (Index 0 ist die aktuellste). getRevisions() liefert die vollständige list<RevisionXRefTable>.

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 PdfParseException mit 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 an readName(), readLiteralString(), readHexString(), readArray(), readDictionary() oder einen Reader für Zahlen oder Referenzen. Eine indirekte Referenz N G R wird als Array-Form ['type' => 'ref', 'num' => N, 'gen' => G] zurückgegeben. Genau diese Form erkennen PdfObject::getRef() und PdfReader::resolveRef().

CrossRefParser parst beide Formate, die Chrome ausgeben kann:

  • parseXRefTable() liest eine traditionelle xref-Tabelle (im PDF-1.x-Stil): Subsection-Header, gefolgt von 20 Byte breiten Einträgen mit fester Breite, dann ein trailer-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::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)
  • ASCIIHexDecode
  • ASCII85Decode

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. Revision 0 (die aktuellste) liefert die gesamte Datei; höhere Indizes liefern zunehmend ältere Snapshots.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) liefert eine list<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.

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.

  1. Lesen Sie die PDF-Bytes in den Speicher und weisen Sie leere Eingaben zurück, bevor Sie den Reader konstruieren.
  2. Konstruieren Sie \NextPDF\Parser\PdfReader und rufen Sie parse() auf.
  3. Lesen Sie getRevisionCount() aus. Ein Wert von 1 bedeutet eine Datei mit einer einzigen Revision ohne inkrementelle Updates.
  4. Lesen Sie für jede Revision ihre RevisionXRefTable und prüfen Sie getActiveObjectCount(), hasRootUpdate() und getSize().
  5. Berechnen Sie die Bytebereiche pro Revision mit RevisionExtractor::getRevisionBoundaries().
  6. Fangen Sie PdfParseException — die spezifischste Exception, die der Parser auslöst — und geben Sie eine Diagnosemeldung aus.
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;
}

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:

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

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.

NachrichtenfragmentStufeWas es bedeutet
missing %PDF- headerPdfReader::parse()Die Bytes sind kein PDF, oder die Eingabe wurde am Anfang abgeschnitten.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()Das Dateiende ist beschädigt oder der Querverweis-Zeiger liegt außerhalb der Grenzen.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Eine traditionelle Querverweis-Tabelle ist fehlerhaft.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()Einem Querverweis-Stream fehlen erforderliche Dictionary-Einträge.
exceeds limit of (xref- oder Object-Stream-Anzahl)CrossRefParser / PdfReaderEin gefälschter Zählwert hat einen Denial-of-Service-Schutz ausgelöst.
Unsupported stream filterStreamDecoder::decode()Der Stream nutzt einen Filter außerhalb des unterstützten Satzes FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderDie komprimierten Daten sind ungültig oder expandieren über die Obergrenze von 16 MiB hinaus.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerEine gezielt gestaltete oder pathologische Struktur hat eine Tokenizer-Grenze ausgelöst.
Page index ... not found / out of range in subtreePdfReader::getPage()Der angeforderte Seitenindex existiert nicht im Seitenbaum.
Revision index ... out of rangePdfReader / RevisionExtractorDer 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.

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;
}
  • Rufen Sie immer zuerst parse() auf. Jeder Accessor an PdfReader setzt voraus, dass die Querverweis-Kette geladen ist. Ein Aufruf von getObject() oder getPage() vor parse() 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 printToPDF ausgibt. 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 PdfParseException aufgrund 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() und PageImporter::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.
  • Artisan-Entwicklerleitfaden — die Importgrenze oberhalb des Parsers, einschließlich ChromeHtmlRenderer, PageImporter und 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.