Salta ai contenuti

Diagnostica avanzata del parser PDF

Il percorso di importazione di Artisan legge un file Portable Document Format (PDF) generato da Chrome e porta una pagina in un documento NextPDF. Quando l’importazione non riesce su un input ostico, occorre scendere al di sotto di PageImporter::import(), fino alle classi del parser che leggono il file byte per byte.

Questa guida documenta la superficie di basso livello del parser nel namespace NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor e gli oggetti valore PdfObject e RevisionXRefTable. Ogni simbolo mostrato qui esiste in nextpdf/artisan. La guida descrive il parser così com’è implementato, non una superficie idealizzata.

Leggere questa guida come una spiegazione affiancata da una procedura pratica. Illustra come si combinano i singoli elementi, quindi accompagna nell’ispezione di una revisione con aggiornamento incrementale. Per il confine di importazione al di sopra di questo livello, vedere la guida per sviluppatori di Artisan.

Usare la superficie del parser solo quando il normale percorso di importazione è già fallito ed è necessario individuare la causa. Casi tipici:

  • PageImporter::import() genera NextPDF\Artisan\Exception\PdfParseException e occorre stabilire se la responsabilità sia della tabella dei riferimenti incrociati, di un filtro di flusso o dell’albero delle pagine.
  • Un aggiornamento di Chrome modifica il formato di output (una tabella di riferimenti incrociati tradizionale diventa un flusso di riferimenti incrociati, o viceversa) e le fixture non corrispondono più.
  • Si riceve un PDF di terze parti non prodotto da Chrome e occorre verificare se il parser riesca effettivamente a leggerlo.
  • Si esegue un’analisi forense di un documento con aggiornamento incrementale e occorrono gli intervalli di byte per revisione o la visibilità degli oggetti.

Per una normale integrazione del renderer, questa superficie non è necessaria. Il parser è uno strumento diagnostico interno, non una libreria PDF generica: non supporta i PDF cifrati, le tabelle di hint linearizzate, né gli aggiornamenti incrementali con ridefinizioni di oggetti in conflitto.

Il parser è un insieme ristretto di classi a responsabilità singola. PdfReader è il punto di ingresso; le altre sono collaboratrici che costruisce o richiama.

ClasseResponsabilitàMetodi principali
PdfReaderLegge la struttura del file, risolve gli oggetti e attraversa l’albero delle pagine.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerEsegue l’analisi lessicale secondo ISO 32000-2:2020 §7.2 — nomi, stringhe, numeri, dizionari, array, riferimenti.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserAnalizza le tabelle tradizionali e i flussi di riferimenti incrociati.parseXRefTable(), parseXRefStream()
StreamDecoderDecodifica i byte del flusso in base a /Filter.decode() (statico)
ResourceCollectorAttraversa in profondità un albero Resources e raccoglie ogni oggetto indiretto raggiungibile.traverse(), getCollected()
RevisionExtractorSuddivide un file con aggiornamento incrementale in intervalli di byte per ciascuna revisione.extractRevision() (statico), getRevisionBoundaries() (statico)
PdfObjectOggetto indiretto analizzato e immutabile (dizionario più flusso opzionale).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableSnapshot immutabile dei riferimenti incrociati per revisione.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Costruire \NextPDF\Parser\PdfReader con i byte grezzi del PDF, quindi chiamare parse() prima di qualsiasi altro metodo. parse() verifica l’intestazione %PDF-, individua startxref nella coda del file e percorre la catena dei riferimenti incrociati seguendo i collegamenti /Prev.

Dopo parse(), il reader espone tre gruppi di metodi:

  • Accesso agli oggetti. getObject(int $objNum) restituisce un PdfObject, risolvendo in modo trasparente le voci di tipo 2 (oggetti memorizzati all’interno di un flusso di oggetti). getObjectNumbers() restituisce una list<int> ordinata di tutti i numeri di oggetto non liberi. resolveRef(mixed $value) segue un singolo riferimento indiretto; un valore diretto viene restituito invariato.
  • Accesso alle pagine. getPage(int $pageIndex) risolve il catalogo, percorre /Pages e restituisce la pagina all’indice in base zero. getPageContentStream(), getPageResources() e getPageMediaBox() estraggono le parti di cui PageImporter ha bisogno. collectPageResources() restituisce array<int, PdfObject> di ogni oggetto raggiungibile dalle Resources e dai Contents della pagina.
  • Accesso alle revisioni. getRevisionCount() restituisce il numero di revisioni incrementali (un file a revisione singola restituisce 1). getRevisionXRef(int $index) restituisce una RevisionXRefTable (l’indice 0 è la più recente). getRevisions() restituisce l’intera list<RevisionXRefTable>.

PdfTokenizer legge il flusso di byte. Raramente lo si costruisce direttamente — PdfReader e CrossRefParser possiedono le proprie istanze — ma è il livello da ispezionare quando l’analisi fallisce su un token non valido. Due comportamenti sono importanti per la diagnostica:

  • I limiti di sicurezza sono costanti, non impostazioni configurabili. Il tokenizer limita l’annidamento delle stringhe letterali, l’annidamento di dizionari e array, la lunghezza delle parole chiave e il numero di elementi degli array. Quando un limite viene superato, genera PdfParseException con il limite indicato nel messaggio. Un input artefatto che fa scattare uno di questi limiti indica una difesa che opera come previsto, non un bug del parser.
  • readValue() è il dispatcher. Ispeziona il byte successivo e delega a readName(), readLiteralString(), readHexString(), readArray(), readDictionary(), o a un lettore per number/reference. Un riferimento indiretto N G R viene restituito nella forma di array ['type' => 'ref', 'num' => N, 'gen' => G]. È questa la forma riconosciuta da PdfObject::getRef() e PdfReader::resolveRef().

CrossRefParser — risoluzione dei riferimenti incrociati

Sezione intitolata “CrossRefParser — risoluzione dei riferimenti incrociati”

CrossRefParser analizza entrambi i formati che Chrome può emettere:

  • parseXRefTable() legge una tabella xref tradizionale (stile PDF 1.x): intestazioni di sottosezione seguite da voci a larghezza fissa di 20 byte, quindi un dizionario trailer.
  • parseXRefStream() legge un flusso di riferimenti incrociati (PDF 2.0, ISO 32000-2:2020 §7.5.8): un oggetto indiretto con /Type /XRef, un array di larghezze di campo /W e un flusso binario di voci.

Entrambi restituiscono lo stesso formato: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() decide quale chiamare osservando i quattro byte all’offset dei riferimenti incrociati: xref seleziona il parser della tabella; qualsiasi altro valore viene trattato come oggetto di flusso. Entrambi i parser applicano un limite massimo di un milione di voci per sottosezione, per rifiutare conteggi falsificati che altrimenti costringerebbero il parser a un lavoro eccessivo.

StreamDecoder::decode(string $data, string|array $filter) è statico e applica uno o un elenco concatenato di filtri. Supporta esattamente i filtri che printToPDF di Chrome emette:

  • FlateDecode (zlib, con fallback a raw-deflate)
  • ASCIIHexDecode
  • ASCII85Decode

Qualsiasi altro nome di filtro genera PdfParseException con Unsupported stream filter. Il decoder limita l’output decompresso a 16 MiB per contenere il rischio di decompression bomb; un output sovradimensionato genera un’eccezione anziché allocare memoria senza limiti. Quando PdfReader legge un flusso e la decodifica genera un’eccezione, ripiega sui byte grezzi del flusso, così che un singolo filtro difettoso non interrompa l’intera analisi.

ResourceCollector — attraversamento profondo delle risorse

Sezione intitolata “ResourceCollector — attraversamento profondo delle risorse”

ResourceCollector viene costruito con il PdfReader e richiamato tramite PdfReader::collectPageResources(). Il suo metodo traverse() percorre ricorsivamente un valore, segue ogni riferimento ['type' => 'ref'] tramite getObject() e registra una sola volta ciascun oggetto risolto in un array<int, PdfObject> indicizzato per numero di oggetto. Limita la profondità di ricorsione e ignora silenziosamente i riferimenti che non riesce a risolvere, così che un singolo riferimento pendente produca una raccolta parziale anziché un guasto irreversibile.

RevisionExtractor — aggiornamenti incrementali e revisioni

Sezione intitolata “RevisionExtractor — aggiornamenti incrementali e revisioni”

Un PDF firmato, annotato o comunque modificato dopo la creazione porta con sé aggiornamenti incrementali: ogni modifica accoda una nuova sezione di riferimenti incrociati e un trailer, terminando con un marcatore %%EOF. RevisionExtractor opera interamente tramite metodi statici su un PdfReader già analizzato:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) restituisce il file troncato al confine %%EOF della revisione richiesta. La revisione 0 (la più recente) restituisce l’intero file; gli indici più alti restituiscono snapshot via via più vecchi.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) restituisce una list<array{revision, startByte, endByte, sizeBytes}> che descrive l’intervallo di byte apportato da ciascuna revisione.

Questo isolamento è deliberato: estraendo una revisione più vecchia si espongono solo gli oggetti visibili fino a quel punto, bloccando gli attacchi ibridi sui riferimenti incrociati in cui una revisione successiva ridefinisce un oggetto precedente.

Questa procedura ispeziona la cronologia delle revisioni di un PDF che potrebbe essere stato modificato dopo la produzione da parte di Chrome. L’esempio è pronto per un contesto di produzione: dichiara tipi stretti, usa hint di tipo completi, convalida il proprio input e cattura l’eccezione più specifica.

  1. Leggere i byte del PDF in memoria e rifiutare l’input vuoto prima di costruire il reader.
  2. Costruire \NextPDF\Parser\PdfReader e chiamare parse().
  3. Leggere getRevisionCount(). Un valore di 1 indica un file a revisione singola senza aggiornamenti incrementali.
  4. Per ciascuna revisione, leggere la relativa RevisionXRefTable e ispezionare getActiveObjectCount(), hasRootUpdate() e getSize().
  5. Calcolare gli intervalli di byte per revisione con RevisionExtractor::getRevisionBoundaries().
  6. Catturare PdfParseException — l’eccezione più specifica sollevata dal parser — e fornire un messaggio diagnostico.
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;
}

Il reader ordina le revisioni dalla più recente (index0) alla più vecchia. Per estrarre uno snapshot più vecchio come byte autonomi — ad esempio, per confrontare ciò che una modifica ha cambiato — chiamare direttamente l’estrattore:

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

Ogni guasto del parser emerge come NextPDF\Artisan\Exception\PdfParseException. Il messaggio localizza la causa. Usare la tabella seguente per mappare un frammento di messaggio alla fase che lo ha generato.

Frammento di messaggioFaseSignificato
missing %PDF- headerPdfReader::parse()I byte non sono un PDF oppure l’input è stato troncato in testa.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()La coda del file è corrotta oppure il puntatore ai riferimenti incrociati è fuori dai limiti.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Una tabella di riferimenti incrociati tradizionale è malformata.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()A un flusso di riferimenti incrociati mancano voci di dizionario obbligatorie.
exceeds limit of (conteggio di xref o di flusso di oggetti)CrossRefParser / PdfReaderUn conteggio falsificato ha fatto scattare una protezione contro il denial-of-service.
Unsupported stream filterStreamDecoder::decode()Il flusso usa un filtro al di fuori del set supportato FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderI dati compressi non sono validi oppure si espandono oltre il tetto di 16 MiB.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerUna struttura artefatta o patologica ha fatto scattare un limite del tokenizer.
Page index ... not found / out of range in subtreePdfReader::getPage()L’indice di pagina richiesto non esiste nell’albero delle pagine.
Revision index ... out of rangePdfReader / RevisionExtractorL’indice di revisione è al di fuori dell’intervallo da 0 a getRevisionCount() - 1.

Quando si cattura l’eccezione, registrare il messaggio e il percorso di origine, quindi rilanciarla o restituire un errore definito. Non scartarla silenziosamente: un blocco catch vuoto nasconde l’unica informazione che il parser ha prodotto intenzionalmente.

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;
}
  • Chiamare sempre parse() per primo. Ogni accessor di PdfReader presuppone che la catena dei riferimenti incrociati sia caricata. Chiamare getObject() o getPage() prima di parse() non restituisce nulla di utile.
  • Trattare il parser come di sola lettura e modellato su Chrome. Punta al sottoinsieme della sintassi PDF emesso da printToPDF di Chrome. I PDF cifrati, le tabelle di hint linearizzate e gli aggiornamenti incrementali in conflitto sono fuori ambito per progettazione. Non estenderlo a strumento generico di riparazione PDF.
  • Mantenere i limiti di sicurezza al loro posto. I limiti su annidamento, lunghezza delle parole chiave, dimensione degli array, conteggio dei riferimenti incrociati e decompressione esistono per contenere l’uso di risorse su input ostile. Una PdfParseException generata da un limite è l’esito corretto per un file artefatto; alzare un limite per accettare un file del genere amplia la superficie di attacco.
  • Per impostazione predefinita, usare la pagina 0. getPage() e PageImporter::import() usano per impostazione predefinita la prima pagina. Scegliere un altro indice solo quando il flusso di lavoro lo richiede deliberatamente.
  • Convalidare l’input prima di costruire il reader. Rifiutare subito i byte vuoti o illeggibili, come fanno gli esempi precedenti, così che un chiaro errore a livello di applicazione preceda qualsiasi eccezione del parser.
  • Catturare PdfParseException, mai un \Exception nudo. È l’unico tipo specifico sollevato dal parser; catturarlo evita che guasti non correlati vengano mascherati.
  • Guida per sviluppatori di Artisan — il confine di importazione al di sopra del parser, inclusi ChromeHtmlRenderer, PageImporter e la stratificazione architetturale.
  • Riferimento API di Artisan — le tabelle dei metodi pubblicate per la superficie pubblica del pacchetto.
  • Risoluzione dei problemi di Artisan — guida orientata ai sintomi per i guasti del renderer e dell’importazione.
  • Configurazione del renderer Chrome — configurazione del renderer che produce i PDF che questo parser legge.
  • ISO 32000-2:2020 §7.5 (struttura del file, riferimenti incrociati, aggiornamenti incrementali) e §7.2 (convenzioni lessicali) — la specifica implementata dal tokenizer e dal parser dei riferimenti incrociati. Consultare lo standard pubblicato per il formato autorevole a livello di byte.