Ga naar inhoud

Geavanceerde diagnostiek voor de PDF-parser

Het importpad van Artisan leest een door Chrome gegenereerd Portable Document Format-bestand (PDF) en importeert één pagina in een NextPDF-document. Als lastige invoer die import laat mislukken, kijk je onder PageImporter::import() naar de parserklassen die het bestand byte voor byte lezen.

Deze gids behandelt de low-level parserlaag in de namespace NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor, en de value objects PdfObject en RevisionXRefTable. Elk symbool dat hier wordt getoond, bestaat in nextpdf/artisan. De gids beschrijft de parser zoals die is gebouwd, niet als een geïdealiseerde interface.

Gebruik deze gids zowel als uitleg als als handleiding. Je ziet hoe de onderdelen samenhangen en loopt daarna door het inspecteren van een revisie bij een incrementele update. Voor de importgrens boven deze laag raadpleeg je de ontwikkelaarsgids voor Artisan.

Gebruik het parseroppervlak alleen wanneer het normale importpad al is mislukt en je de oorzaak moet vinden. Typische aanleidingen zijn onder meer:

  • PageImporter::import() gooit NextPDF\Artisan\Exception\PdfParseException, en je moet weten of de kruisverwijzingstabel, een streamfilter of de paginaboom de oorzaak is.
  • Een upgrade van Chrome verandert het uitvoerformaat, bijvoorbeeld wanneer een traditionele kruisverwijzingstabel een kruisverwijzingsstream wordt, of andersom, en je fixtures niet meer overeenkomen.
  • Je ontvangt een PDF van een derde partij die niet door Chrome is geproduceerd en wilt bevestigen of de parser deze überhaupt kan lezen.
  • Je analyseert een incrementeel bijgewerkt document en hebt byte-bereiken per revisie of inzicht in de zichtbaarheid van objecten nodig.

Als je een normale renderer-integratie schrijft, heb je dit oppervlak niet nodig. De parser is een intern diagnostisch hulpmiddel, geen algemene PDF-bibliotheek. Hij ondersteunt geen versleutelde PDF-bestanden, gelineariseerde hinttabellen of incrementele updates met conflicterende herdefinities van objecten.

De parser bestaat uit een kleine verzameling klassen met elk één verantwoordelijkheid. PdfReader is het ingangspunt. De andere klassen zijn samenwerkende objecten die PdfReader construeert of aanroept.

KlasseVerantwoordelijkheidBelangrijkste methoden
PdfReaderLeest de bestandsstructuur, lost objecten op en doorloopt de paginaboom.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerAnalyseert de lexicale syntaxis volgens ISO 32000-2:2020 §7.2: namen, strings, getallen, dictionaries, arrays en verwijzingen.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserParseert traditionele kruisverwijzingstabellen en kruisverwijzingsstreams.parseXRefTable(), parseXRefStream()
StreamDecoderDecodeert streambytes per /Filter.decode() (statisch)
ResourceCollectorDoorloopt een Resources-boom recursief en verzamelt elk bereikbaar indirect object.traverse(), getCollected()
RevisionExtractorSplitst een incrementeel bijgewerkt bestand op in byte-bereiken per revisie.extractRevision() (statisch), getRevisionBoundaries() (statisch)
PdfObjectOnveranderlijk geparseerd indirect object (dictionary plus optionele stream).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableOnveranderlijke kruisverwijzingsmomentopname per revisie.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Maak \NextPDF\Parser\PdfReader aan met de ruwe PDF-bytes en roep daarna parse() aan voordat je een andere methode aanroept. parse() controleert de %PDF--header, zoekt startxref aan het einde van het bestand en doorloopt de kruisverwijzingsketen door de /Prev-koppelingen te volgen.

Na parse() biedt de reader drie groepen methoden:

  • Objecttoegang. getObject(int $objNum) retourneert een PdfObject en lost Type 2-entries (objecten die in een object-stream zijn opgeslagen) automatisch op. getObjectNumbers() retourneert een gesorteerde list<int> met elk niet-vrij objectnummer. resolveRef(mixed $value) volgt één indirecte verwijzing. Een directe waarde blijft ongewijzigd.
  • Paginatoegang. getPage(int $pageIndex) lost de catalogus op, doorloopt /Pages en retourneert de pagina met de op nul gebaseerde index. getPageContentStream(), getPageResources() en getPageMediaBox() extraheren de onderdelen die PageImporter nodig heeft. collectPageResources() retourneert array<int, PdfObject> met elk object dat bereikbaar is vanuit de Resources en Contents van de pagina.
  • Revisietoegang. getRevisionCount() retourneert het aantal incrementele revisies. Een bestand met één revisie retourneert 1. getRevisionXRef(int $index) retourneert één RevisionXRefTable (index 0 is de meest recente). getRevisions() retourneert de volledige list<RevisionXRefTable>.

PdfTokenizer leest de bytestream. Je maakt hem zelden zelf aan, omdat PdfReader en CrossRefParser hun eigen instanties beheren. Inspecteer deze laag wanneer een parse mislukt op een misvormd token. Twee eigenschappen zijn belangrijk voor diagnostiek:

  • Beveiligingslimieten zijn constanten, geen configuratie. De tokenizer begrenst de nesting van literal strings, de nesting van dictionaries en arrays, de lengte van keywords en het aantal array-elementen. Als invoer een limiet overschrijdt, gooit de tokenizer PdfParseException en noemt hij de limiet in de melding. Geprepareerde invoer die een van deze limieten activeert, is een verdediging die werkt zoals bedoeld, geen parserbug.
  • readValue() stuurt de parse aan. De methode inspecteert de volgende byte en delegeert naar readName(), readLiteralString(), readHexString(), readArray(), readDictionary(), of een number/reference-reader. Een indirecte verwijzing N G R wordt geretourneerd als de array-vorm ['type' => 'ref', 'num' => N, 'gen' => G]. PdfObject::getRef() en PdfReader::resolveRef() herkennen deze vorm.

CrossRefParser parseert beide formaten die Chrome kan genereren:

  • parseXRefTable() leest een traditionele xref-tabel (PDF 1.x-stijl): subsectie-headers, entries met vaste breedte van 20 bytes en vervolgens een trailer-dictionary.
  • parseXRefStream() leest een kruisverwijzingsstream (PDF 2.0, ISO 32000-2:2020 §7.5.8): een indirect object met /Type /XRef, een /W-array met veldbreedtes en een binaire stream met entries.

Beide retourneren dezelfde vorm: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() bepaalt welke parser wordt aangeroepen door te kijken naar de vier bytes vanaf de kruisverwijzingsoffset: xref selecteert de tabelparser en al het andere wordt als stream-object behandeld. Beide parsers handhaven een plafond van één miljoen entries per subsectie om vervalste tellingen af te wijzen die de parser anders buitensporig lang zouden laten lopen.

StreamDecoder::decode(string $data, string|array $filter) is statisch en past één filter of een geketende lijst filters toe. Hij ondersteunt precies de filters die Chrome via printToPDF produceert:

  • FlateDecode (zlib, met een raw-deflate-fallback)
  • ASCIIHexDecode
  • ASCII85Decode

Bij elke andere filternaam gooit de decoder PdfParseException met Unsupported stream filter. De decoder begrenst de gedecomprimeerde uitvoer op 16 MiB om het risico van een decompressiebom te beperken. Te grote uitvoer gooit een exception in plaats van onbeperkt geheugen te alloceren. Wanneer PdfReader een stream leest en het decoderen een exception gooit, valt hij terug op de ruwe streambytes, zodat één slecht filter niet de hele parse afbreekt.

ResourceCollector wordt geconstrueerd met de PdfReader en aangeroepen via PdfReader::collectPageResources(). De methode traverse() doorloopt een waarde recursief, volgt elke ['type' => 'ref']-verwijzing via getObject() en registreert elk opgelost object eenmaal in een array<int, PdfObject> met het objectnummer als sleutel. Hij begrenst de recursiediepte en slaat verwijzingen die hij niet kan oplossen stilzwijgend over, zodat één losse verwijzing een gedeeltelijke verzameling oplevert in plaats van een harde fout.

RevisionExtractor — incrementele updates en revisies

Sectie met titel “RevisionExtractor — incrementele updates en revisies”

Een PDF die na het aanmaken is ondertekend, geannoteerd of anderszins bewerkt, bevat incrementele updates. Elke bewerking voegt een nieuwe kruisverwijzingssectie en trailer toe en eindigt met een %%EOF-marker. RevisionExtractor werkt uitsluitend met statische methoden op basis van een geparseerde PdfReader:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) retourneert het bestand afgekapt op de %%EOF-grens van de gevraagde revisie. Revisie 0 (meest recent) retourneert het hele bestand; hogere indices retourneren steeds oudere momentopnamen.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) retourneert een list<array{revision, startByte, endByte, sizeBytes}> die het byte-bereik beschrijft dat elke revisie heeft bijgedragen.

Die isolatie is bewust gekozen. Het extraheren van een oudere revisie onthult alleen de objecten die tot dat punt zichtbaar zijn en blokkeert zo hybride kruisverwijzingsaanvallen waarbij een latere revisie een eerder object herdefinieert.

Met deze procedure inspecteer je de revisiegeschiedenis van een PDF die mogelijk is bewerkt nadat Chrome hem heeft geproduceerd. Het voorbeeld is opgezet voor productiegebruik: het declareert strict types, gebruikt volledige type hints, valideert zijn invoer en vangt de meest specifieke exception op.

  1. Lees de PDF-bytes in het geheugen en wijs lege invoer af voordat je de reader construeert.
  2. Construeer \NextPDF\Parser\PdfReader en roep parse() aan.
  3. Lees getRevisionCount(). Een waarde van 1 betekent een bestand met één revisie zonder incrementele updates.
  4. Lees voor elke revisie de RevisionXRefTable en inspecteer getActiveObjectCount(), hasRootUpdate() en getSize().
  5. Bereken byte-bereiken per revisie met RevisionExtractor::getRevisionBoundaries().
  6. Vang PdfParseException op, de meest specifieke exception die de parser gooit, en presenteer een diagnostische melding.
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;
}

De reader ordent revisies van de nieuwste (index0) naar de oudste. Om één oudere momentopname als losstaande bytes te extraheren, bijvoorbeeld om te diffen wat een bewerking heeft gewijzigd, roep je de extractor rechtstreeks aan:

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

Elke parserfout wordt gemeld als NextPDF\Artisan\Exception\PdfParseException. De melding identificeert de oorzaak. Gebruik de onderstaande tabel om een meldingsfragment toe te wijzen aan de fase die het heeft veroorzaakt.

MeldingsfragmentFaseWat het betekent
missing %PDF- headerPdfReader::parse()De bytes zijn geen PDF, of de invoer is aan het begin afgekapt.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()Het einde van het bestand is corrupt, of de kruisverwijzingspointer ligt buiten de grenzen.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Een traditionele kruisverwijzingstabel is misvormd.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()Een kruisverwijzingsstream mist vereiste dictionary-entries.
exceeds limit of (xref- of object-stream-telling)CrossRefParser / PdfReaderEen vervalste telling activeerde een denial-of-service-beveiliging.
Unsupported stream filterStreamDecoder::decode()De stream gebruikt een filter buiten de ondersteunde set FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderDe gecomprimeerde data is ongeldig of de uitvoer wordt groter dan de limiet van 16 MiB.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerEen geprepareerde of pathologische structuur activeerde een tokenizerlimiet.
Page index ... not found / out of range in subtreePdfReader::getPage()De gevraagde pagina-index bestaat niet in de paginaboom.
Revision index ... out of rangePdfReader / RevisionExtractorDe revisie-index ligt buiten het bereik 0 tot getRevisionCount() - 1.

Wanneer je de exception opvangt, log dan de melding en het bronpad, en gooi die vervolgens opnieuw of retourneer een gedefinieerde fout. Negeer die niet stilzwijgend. Een leeg catch-blok verbergt precies de informatie die de parser bewust heeft geproduceerd.

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;
}
  • Roep altijd eerst parse() aan. Elke accessor op PdfReader gaat ervan uit dat de kruisverwijzingsketen is geladen. Het aanroepen van getObject() of getPage() vóór parse() levert geen bruikbaar resultaat op.
  • Behandel de parser als read-only en op Chrome afgestemd. Hij richt zich op de subset van PDF-syntaxis die Chrome via printToPDF produceert. Versleutelde PDF-bestanden, gelineariseerde hinttabellen en conflicterende incrementele updates vallen door het ontwerp buiten de scope. Breid de parser niet uit tot een algemeen PDF-reparatiehulpmiddel.
  • Houd de beveiligingslimieten op hun plaats. De limieten voor nesting, keyword-lengte, array-grootte, kruisverwijzingstelling en decompressie beperken het resourcegebruik bij vijandige invoer. Een PdfParseException door zo’n limiet is de juiste uitkomst voor een geprepareerd bestand. Het verhogen van een limiet om zo’n bestand te accepteren, vergroot het aanvalsoppervlak.
  • Gebruik standaard pagina 0. getPage() en PageImporter::import() gaan standaard uit van de eerste pagina. Kies alleen een andere index wanneer de workflow dat expliciet vereist.
  • Valideer invoer voordat je de reader construeert. Wijs lege of onleesbare bytes vroeg af, zoals de bovenstaande voorbeelden doen, zodat een duidelijke fout op applicatieniveau verschijnt voordat er een parser-exception ontstaat.
  • Vang PdfParseException op, nooit een kale \Exception. Het is het enige, specifieke type dat de parser gooit. Door dit specifieke type op te vangen voorkom je dat ongerelateerde fouten worden gemaskeerd.
  • Ontwikkelaarsgids voor Artisan — de importgrens boven de parser, inclusief ChromeHtmlRenderer, PageImporter en de architectuurlagen.
  • API-referentie voor Artisan — de gepubliceerde methodetabellen voor de publieke API van het pakket.
  • Probleemoplossing voor Artisan — symptoomgerichte hulp bij renderer- en importfouten.
  • Chrome-rendererinstallatie — het configureren van de renderer die de PDF-bestanden produceert die deze parser leest.
  • ISO 32000-2:2020 §7.5 (bestandsstructuur, kruisverwijzing, incrementele updates) en §7.2 (lexicale conventies) — de specificatie die de tokenizer en de kruisverwijzingsparser implementeren. Raadpleeg de gepubliceerde standaard voor het gezaghebbende formaat op byteniveau.