Aller au contenu

Diagnostics du parseur PDF avancé

Le chemin d’import Artisan lit un fichier PDF (Portable Document Format) généré par Chrome et réimporte une page dans un document NextPDF. Quand cet import échoue sur une entrée difficile, tu dois descendre sous PageImporter::import(), jusqu’aux classes du parseur qui lisent le fichier octet par octet.

Ce guide documente la surface de bas niveau du parseur dans l’espace de noms NextPDF\Parser : PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor, ainsi que les objets valeur PdfObject et RevisionXRefTable. Chaque symbole présenté ici existe dans nextpdf/artisan. Le guide décrit le parseur tel qu’il est construit, pas une surface idéalisée.

Lis ce guide comme à la fois une explication et un mode d’emploi. Il montre comment les pièces s’assemblent, puis te guide dans l’inspection d’une révision de mise à jour incrémentale. Pour la frontière d’import au-dessus de cette couche, consulte le guide du développeur Artisan.

N’utilise la surface du parseur que lorsque le chemin d’import normal a déjà échoué et que tu dois localiser la cause. Cas typiques :

  • PageImporter::import() lève NextPDF\Artisan\Exception\PdfParseException et tu dois savoir si le problème vient de la table de références croisées, d’un filtre de flux ou de l’arbre des pages.
  • Une mise à jour de Chrome change le format de sortie (une table de références croisées traditionnelle devient un flux de références croisées, ou inversement) et tes fixtures cessent de correspondre.
  • Tu reçois un PDF tiers qui n’a pas été produit par Chrome et tu veux vérifier que le parseur peut simplement le lire.
  • Tu fais l’analyse forensique d’un document mis à jour de façon incrémentale et tu as besoin des plages d’octets par révision ou de la visibilité des objets.

Si tu écris une intégration de moteur de rendu classique, tu n’as pas besoin de cette surface. Le parseur est un outil de diagnostic interne, pas une bibliothèque PDF générique : il ne prend pas en charge les PDF chiffrés, les tables d’indices linéarisées, ni les mises à jour incrémentales avec redéfinitions d’objets contradictoires.

Le parseur est un ensemble resserré de classes à responsabilité unique. PdfReader est le point d’entrée ; les autres sont des collaborateurs qu’il construit ou qu’il appelle.

ClasseResponsabilitéMéthodes clés
PdfReaderLit la structure du fichier, résout les objets et parcourt l’arbre des pages.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerAnalyse lexicale conforme à ISO 32000-2:2020 §7.2 — noms, chaînes, nombres, dictionnaires, tableaux, références.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserAnalyse les tables de références croisées traditionnelles et les flux de références croisées.parseXRefTable(), parseXRefStream()
StreamDecoderDécode les octets des flux selon /Filter.decode() (statique)
ResourceCollectorParcourt en profondeur un arbre de ressources et collecte chaque objet indirect atteignable.traverse(), getCollected()
RevisionExtractorDécoupe un fichier mis à jour de façon incrémentale en plages d’octets par révision.extractRevision() (statique), getRevisionBoundaries() (statique)
PdfObjectObjet indirect analysé immuable (dictionnaire plus flux optionnel).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableInstantané immuable des références croisées d’une révision.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Construis \NextPDF\Parser\PdfReader avec les octets bruts du PDF, puis appelle parse() avant toute autre méthode. parse() vérifie l’en-tête %PDF-, trouve startxref à la fin du fichier et parcourt la chaîne de références croisées en suivant les liens /Prev.

Après parse(), le reader expose trois groupes de méthodes :

  • Accès aux objets. getObject(int $objNum) renvoie un PdfObject, en résolvant de façon transparente les entrées de Type 2 (objets stockés à l’intérieur d’un flux d’objets). getObjectNumbers() renvoie une list<int> triée de tous les numéros d’objet non libres. resolveRef(mixed $value) suit une seule référence indirecte ; une valeur directe passe sans changement.
  • Accès aux pages. getPage(int $pageIndex) résout le catalogue, parcourt /Pages et renvoie la page à l’index commençant à zéro. getPageContentStream(), getPageResources() et getPageMediaBox() extraient les éléments dont PageImporter a besoin. collectPageResources() renvoie un array<int, PdfObject> de chaque objet atteignable depuis les Resources et les Contents de la page.
  • Accès aux révisions. getRevisionCount() renvoie le nombre de révisions incrémentales (un fichier à une seule révision renvoie 1). getRevisionXRef(int $index) renvoie une RevisionXRefTable (l’index 0 est le plus récent). getRevisions() renvoie la list<RevisionXRefTable> complète.

PdfTokenizer lit le flux d’octets. Tu le construis rarement toi-même — PdfReader et CrossRefParser possèdent leurs propres instances — mais c’est la couche à inspecter quand une analyse échoue sur un token mal formé. Deux comportements comptent pour le diagnostic :

  • Les limites de sécurité sont des constantes, pas de la configuration. Le tokenizer plafonne l’imbrication des chaînes littérales, l’imbrication des dictionnaires et des tableaux, la longueur des mots-clés et le nombre d’éléments de tableau. Quand une limite est dépassée, il lève PdfParseException en nommant la limite dans le message. Une entrée fabriquée qui déclenche l’une de ces limites est une défense qui fonctionne comme prévu, pas un bug du parseur.
  • readValue() est le répartiteur. Il inspecte l’octet suivant et délègue à readName(), readLiteralString(), readHexString(), readArray(), readDictionary(), ou à un lecteur de nombre/référence. Une référence indirecte N G R est renvoyée sous forme de tableau ['type' => 'ref', 'num' => N, 'gen' => G]. C’est cette forme que PdfObject::getRef() et PdfReader::resolveRef() reconnaissent.

CrossRefParser — résolution des références croisées

Section intitulée « CrossRefParser — résolution des références croisées »

CrossRefParser analyse les deux formats que Chrome peut émettre :

  • parseXRefTable() lit une table xref traditionnelle (style PDF 1.x) : des en-têtes de sous-section suivis d’entrées de 20 octets à largeur fixe, puis un dictionnaire trailer.
  • parseXRefStream() lit un flux de références croisées (PDF 2.0, ISO 32000-2:2020 §7.5.8) : un objet indirect avec /Type /XRef, un tableau de largeurs de champ /W et un flux binaire d’entrées.

Les deux renvoient la même forme : array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() décide laquelle appeler en examinant les quatre octets situés au décalage des références croisées : xref sélectionne l’analyseur de table ; tout le reste est traité comme un objet flux. Les deux analyseurs imposent un plafond d’un million d’entrées par sous-section pour rejeter les comptes falsifiés qui feraient autrement tourner le parseur de façon excessive.

StreamDecoder::decode(string $data, string|array $filter) est statique et applique un filtre ou une liste de filtres enchaînés. Il prend en charge exactement les filtres que le printToPDF de Chrome émet :

  • FlateDecode (zlib, avec un repli en deflate brut)
  • ASCIIHexDecode
  • ASCII85Decode

Tout autre nom de filtre lève PdfParseException avec Unsupported stream filter. Le décodeur plafonne la sortie décompressée à 16 Mio pour borner le risque de bombe de décompression ; une sortie surdimensionnée lève une exception plutôt que d’allouer sans limite. Quand PdfReader lit un flux et que le décodage lève une exception, il se replie sur les octets bruts du flux pour qu’un seul filtre incorrect n’interrompe pas toute l’analyse.

ResourceCollector — parcours profond des ressources

Section intitulée « ResourceCollector — parcours profond des ressources »

ResourceCollector est construit avec le PdfReader et appelé via PdfReader::collectPageResources(). Sa méthode traverse() parcourt une valeur récursivement, suit chaque référence ['type' => 'ref'] via getObject() et enregistre chaque objet résolu une seule fois dans un array<int, PdfObject> indexé par numéro d’objet. Il plafonne la profondeur de récursion et ignore silencieusement les références qu’il ne peut pas résoudre, de sorte qu’une seule référence pendante produit une collection partielle plutôt qu’un échec brutal.

RevisionExtractor — mises à jour incrémentales et révisions

Section intitulée « RevisionExtractor — mises à jour incrémentales et révisions »

Un PDF qui a été signé, annoté ou modifié d’une autre manière après sa création contient des mises à jour incrémentales : chaque modification ajoute une nouvelle section de références croisées et un trailer, qui se termine par un marqueur %%EOF. RevisionExtractor fonctionne entièrement à partir de méthodes statiques sur un PdfReader analysé :

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) renvoie le fichier tronqué à la frontière %%EOF de la révision demandée. La révision 0 (la plus récente) renvoie tout le fichier ; les index plus élevés renvoient des instantanés de plus en plus anciens.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) renvoie une list<array{revision, startByte, endByte, sizeBytes}> décrivant la plage d’octets qu’a apportée chaque révision.

Cette isolation est délibérée : extraire une révision plus ancienne n’expose que les objets visibles jusqu’à ce point, ce qui bloque les attaques de références croisées hybrides où une révision ultérieure redéfinit un objet antérieur.

Cette procédure inspecte l’historique des révisions d’un PDF qui a peut-être été modifié après que Chrome l’a produit. L’exemple est structuré pour la production : il déclare des types stricts, utilise des annotations de type complètes, valide son entrée et intercepte l’exception la plus spécifique.

  1. Lis les octets du PDF en mémoire et rejette toute entrée vide avant de construire le reader.
  2. Construis \NextPDF\Parser\PdfReader et appelle parse().
  3. Appelle getRevisionCount(). Une valeur de 1 signifie un fichier à révision unique sans mise à jour incrémentale.
  4. Pour chaque révision, récupère sa RevisionXRefTable et inspecte getActiveObjectCount(), hasRootUpdate() et getSize().
  5. Calcule les plages d’octets par révision avec RevisionExtractor::getRevisionBoundaries().
  6. Intercepte PdfParseException — l’exception la plus spécifique que le parseur lève — et remonte un message de diagnostic.
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;
}

Le reader ordonne les révisions de la plus récente (index0) à la plus ancienne. Pour extraire un instantané plus ancien sous forme d’octets autonomes — par exemple, pour comparer ce qu’une modification a changé — appelle directement l’extracteur :

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

Chaque échec du parseur se manifeste sous la forme de NextPDF\Artisan\Exception\PdfParseException. Le message situe la cause. Utilise le tableau ci-dessous pour relier un fragment de message à l’étape qui l’a levé.

Fragment de messageÉtapeCe que ça signifie
missing %PDF- headerPdfReader::parse()Les octets ne sont pas un PDF, ou l’entrée a été tronquée en tête.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()La fin du fichier est corrompue ou le pointeur de références croisées est hors limites.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Une table de références croisées traditionnelle est mal formée.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()Un flux de références croisées ne contient pas les entrées de dictionnaire requises.
exceeds limit of (compte de xref ou de flux d’objets)CrossRefParser / PdfReaderUn compte falsifié a déclenché une protection contre le déni de service.
Unsupported stream filterStreamDecoder::decode()Le flux utilise un filtre en dehors de l’ensemble pris en charge FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderLes données compressées sont invalides ou s’étendent au-delà du plafond de 16 Mio.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerUne structure fabriquée ou pathologique a déclenché une limite du tokenizer.
Page index ... not found / out of range in subtreePdfReader::getPage()L’index de page demandé n’existe pas dans l’arbre des pages.
Revision index ... out of rangePdfReader / RevisionExtractorL’index de révision est en dehors de 0 à getRevisionCount() - 1.

Quand tu interceptes l’exception, journalise le message et le chemin source, puis relance-la ou renvoie une erreur définie. Ne l’ignore pas silencieusement : un bloc catch vide masque la seule information utile produite par le parseur.

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;
}
  • Appelle toujours parse() en premier. Chaque accesseur de PdfReader suppose que la chaîne de références croisées est chargée. Appeler getObject() ou getPage() avant parse() ne renvoie rien d’utile.
  • Traite le parseur comme étant en lecture seule et conçu pour Chrome. Il vise le sous-ensemble de syntaxe PDF que le printToPDF de Chrome émet. Les PDF chiffrés, les tables d’indices linéarisées et les mises à jour incrémentales contradictoires sont hors périmètre par conception. Ne l’étends pas pour en faire un outil de réparation de PDF générique.
  • Garde les limites de sécurité en place. Les plafonds d’imbrication, de longueur de mot-clé, de taille de tableau, de nombre de références croisées et de décompression existent pour borner l’utilisation des ressources sur des entrées hostiles. Une PdfParseException issue d’une limite est le bon résultat pour un fichier fabriqué ; relever une limite pour accepter un tel fichier élargit la surface d’attaque.
  • Par défaut, utilise la page 0. getPage() et PageImporter::import() utilisent par défaut la première page. Ne choisis un autre index que lorsque le workflow en a délibérément besoin.
  • Valide l’entrée avant de construire le reader. Rejette tôt les octets vides ou illisibles, comme le font les exemples ci-dessus, pour qu’une erreur claire au niveau applicatif précède toute exception du parseur.
  • Attrape PdfParseException, jamais \Exception nu. C’est le seul type spécifique que le parseur lève ; l’intercepter évite que des échecs sans rapport soient masqués.
  • Guide du développeur Artisan — la frontière d’import au-dessus du parseur, incluant ChromeHtmlRenderer, PageImporter et la stratification de l’architecture.
  • Référence de l’API Artisan — les tableaux de méthodes publiés pour la surface publique du package.
  • Dépannage Artisan — des conseils axés sur les symptômes pour les échecs du moteur de rendu et de l’import.
  • Configuration du moteur de rendu Chrome — configurer le moteur de rendu qui produit les PDF que ce parseur lit.
  • ISO 32000-2:2020 §7.5 (structure de fichier, références croisées, mises à jour incrémentales) et §7.2 (conventions lexicales) — la spécification que le tokenizer et l’analyseur de références croisées implémentent. Consulte la norme publiée pour la référence faisant autorité au niveau octet.