Tester les PDF générés en CI
En un coup d’œil
Section intitulée « En un coup d’œil »Cette recette est pour les développeurs d’applications qui génèrent des PDF avec NextPDF et veulent garder leur propre sortie sous test. C’est le côté consommateur de la propre discipline de test du moteur : tu ne re-testes pas NextPDF, tu assertes que ton document dit toujours ce qu’il doit et a toujours l’aspect qu’il avait.
Deux styles d’assertion couvrent presque tout :
- Les assertions sémantiques sur le texte extrait — génère, récupère le texte Unicode, et asserte qu’il contient les chaînes que tu attends. Cela survit aux retouches de mise en page et aux changements de police.
- Les assertions de référence (instantané) sur les octets — épingle
DeterministicSettingspour qu’une reconstruction soit identique octet pour octet, puis compare les nouveaux octets à un fichier de référence committé. Cela attrape tout changement non intentionnel.
Utilise les assertions sémantiques pour la justesse du contenu et les assertions de référence comme fil-piège de régression. Les deux s’exécutent sans changement en CI dès que le runner produit les mêmes octets que ta station de travail.
Installation
Section intitulée « Installation »composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Asserte sur le texte extrait, pas sur un diff d’octets
Section intitulée « Asserte sur le texte extrait, pas sur un diff d’octets »Un diff d’octets brut de deux PDF est fragile : un nouvel horodatage, une police re-mise-en-sous-ensemble ou un objet réordonné changent tous les octets sans changer ce qu’un lecteur voit. Asserte sur le contenu à la place.
NextPDF Core est un producteur, donc rends d’abord le texte extractible. Ce sont deux
mécanismes distincts, pas un seul. L’extraction de texte repose sur une CMap
/ToUnicode correcte (ISO 32000-2 §9.10.2) qui mappe les codes de glyphes vers
Unicode — le moteur l’émet pour les polices embarquées, donc les extracteurs récupèrent
de vrais caractères plutôt que des indices de glyphes bruts. Le PDF balisé est distinct :
enableTaggedPdf() et setLanguage() ajoutent l’arbre de structure qui enregistre
l’ordre de lecture et l’accessibilité, ce qui n’est pas ce qui crée la CMap
/ToUnicode. Active les deux avant d’écrire du contenu : la CMap pour une récupération
de texte propre, le balisage pour l’ordre de lecture. Vois
Produire du contenu texte extractible pour
les détails côté producteur. Récupère ensuite le texte et asserte dessus.
Pour les faits de nombre de pages et de structure, la profondeur Quick du module Inspect
a un repli pur-PHP qui s’exécute en processus quand aucun annexe Spectrum n’est
disponible — pratique sur un runner CI, mais c’est un scan dégradé. Il signale un
problème INSPECT-FALLBACK-001 « accuracy may be limited » et dérive le nombre de pages
d’une regex /Type /Page grossière sur les octets bruts, pas d’un parse complet de
l’arbre d’objets. Quand un annexe Spectrum est configuré, même la profondeur Quick
l’utilise — InspectDepth contrôle combien d’analyse l’annexe effectue, donc Quick
n’est pas intrinsèquement sans annexe.
<?php
declare(strict_types=1);
use NextPDF\Inspect\Inspector;use NextPDF\Inspect\InspectConfig;
$result = (new Inspector())->inspect($pdfBytes, InspectConfig::quick());
// With no sidecar injected, Quick depth takes the in-process PHP fallback:// a degraded scan (page count from a regex) that flags INSPECT-FALLBACK-001.// If a Spectrum sidecar is available, Inspector uses it even at Quick depth.$pageCount = $result->pageCount; // int (regex-derived in the fallback)$version = $result->pdfVersion; // e.g. "2.0"$encrypted = $result->isEncrypted; // boolInspector::inspect() renvoie un InspectResult immuable. Pour la récupération de
texte complète, exécute un extracteur en aval (pdftotext, ou l’annexe Spectrum
d’Inspect à la profondeur Standard) sur les octets et asserte sur sa sortie — asserte sur
le texte récupéré, jamais sur les octets exacts du producteur.
Rends la sortie identique octet pour octet pour les instantanés de référence
Section intitulée « Rends la sortie identique octet pour octet pour les instantanés de référence »Un test de référence ne fonctionne que si une reconstruction produit les mêmes octets.
Le PDF a deux sources intégrées de non-déterminisme : les champs de date (CreationDate
/ ModDate) et l’identifiant de fichier dans le trailer (ISO 32000-2 §7.5.5). NextPDF
supprime les deux via DeterministicSettings, une valeur de config de première classe —
pas un bidouillage de test.
DeterministicSettings prend un DateTimeImmutable fixe et un fileIdSeed hexadécimal
de 32 caractères. Passe-le sur la Config, puis construis ton document depuis cette
config. Avec le profil déterministe épinglé (horodatage fixe et /ID), la même entrée
produit une sortie identique octet pour octet entre les exécutions sur la même chaîne
d’outils épinglée — le patch PHP, les versions de l’extension et de la bibliothèque de
compression, et les fichiers de polices tous tenus constants. Entre des machines qui
diffèrent sur l’un de ces points, les octets peuvent quand même diverger ; préfère là
les assertions d’extraction de texte et réserve l’instantané de référence à un
environnement fixe et épinglé.
<?php
declare(strict_types=1);
use DateTimeImmutable;use NextPDF\Core\Config;use NextPDF\Core\Document;use NextPDF\Core\DeterministicSettings;
function buildInvoice(int $invoiceId): string{ $config = new Config( deterministic: new DeterministicSettings( timestamp: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), fileIdSeed: '00000000000000000000000000000000', // exactly 32 hex chars ), );
$document = Document::createStandalone($config); $document->setLanguage('en'); $document->enableTaggedPdf('en'); // structure tree for reading order; /ToUnicode is emitted separately $document->addPage(); $document->setFont('helvetica', '', 12); $document->multiCell(0, 7, "Invoice #{$invoiceId}");
return $document->getPdfData();}Le fileIdSeed doit faire exactement 32 caractères hexadécimaux, sinon le constructeur
lève InvalidConfigException. Si tu détiens déjà une Config, tu peux dériver une copie
déterministe avec $config->withDeterministic($settings) au lieu de la reconstruire.
Un test PHPUnit pour les deux styles d’assertion
Section intitulée « Un test PHPUnit pour les deux styles d’assertion »Cette classe de test exerce une assertion sémantique et une assertion de référence contre le même constructeur. Le fichier de référence est généré une fois, revu par un humain, et committé ; après cela le test échoue à tout changement d’octet.
<?php
declare(strict_types=1);
namespace App\Tests\Pdf;
use PHPUnit\Framework\TestCase;
use function App\Pdf\buildInvoice; // the deterministic builder above
final class InvoicePdfTest extends TestCase{ private const GOLDEN = __DIR__ . '/__snapshots__/invoice-42.pdf';
public function testInvoiceTextIsPresent(): void { $pdf = buildInvoice(42);
// Recover text with an external extractor (installed in CI, see below). $text = self::extractText($pdf);
self::assertStringContainsString('Invoice #42', $text); }
public function testInvoiceBytesMatchGolden(): void { $pdf = buildInvoice(42);
// First run: write the golden, then review and commit it by hand. if (! \is_file(self::GOLDEN)) { \file_put_contents(self::GOLDEN, $pdf); self::markTestIncomplete('Golden file created — review and commit it.'); }
self::assertSame( \file_get_contents(self::GOLDEN), $pdf, 'Generated PDF bytes drifted from the committed golden snapshot.', ); }
private static function extractText(string $pdf): string { // tempnam() creates a zero-byte file; track it so the finally block // removes both it and the .pdf path, leaking neither. $tmp = \tempnam(\sys_get_temp_dir(), 'pdf'); $tmpPdf = $tmp . '.pdf'; try { \file_put_contents($tmpPdf, $pdf);
// Run pdftotext via proc_open so we can read the exit code AND // stderr. shell_exec() returns "" on a missing/failed binary, which // would silently turn a broken runner into a passing assertion — // the opposite of a reliable CI test. pdftotext writes UTF-8 to "-" // (stdout). Requires poppler-utils on the runner (see workflow). $descriptors = [ 1 => ['pipe', 'w'], // stdout 2 => ['pipe', 'w'], // stderr ]; $process = \proc_open( ['pdftotext', $tmpPdf, '-'], $descriptors, $pipes, );
if (! \is_resource($process)) { throw new \RuntimeException( 'Could not start pdftotext. Install poppler-utils on the runner.', ); }
$text = \stream_get_contents($pipes[1]); $stderr = \stream_get_contents($pipes[2]); \fclose($pipes[1]); \fclose($pipes[2]); $exitCode = \proc_close($process);
if ($exitCode !== 0) { throw new \RuntimeException(\sprintf( 'pdftotext failed (exit %d): %s. Is poppler-utils installed on the runner?', $exitCode, \trim((string) $stderr) !== '' ? \trim((string) $stderr) : '(no stderr)', )); }
return (string) $text; } finally { // Remove both the original tempnam() file and the .pdf we wrote. @\unlink($tmp); @\unlink($tmpPdf); } }}L’assertion d’octets n’a de sens que parce que buildInvoice() épingle
DeterministicSettings. Sans elle, CreationDate à elle seule ferait échouer le test
de référence à chaque exécution.
Épingle les polices pour que la CI produise les mêmes octets
Section intitulée « Épingle les polices pour que la CI produise les mêmes octets »La sortie identique octet pour octet dépend du fait que les mêmes octets de police
soient mis en sous-ensemble sur chaque machine. Une police qui se résout différemment
sur le runner et sur ta station de travail change le sous-ensemble embarqué et casse le
test de référence — même avec DeterministicSettings épinglé.
Deux règles gardent les polices stables :
- Utilise les polices standard Base 14 (par exemple
helvetica) pour les tests de référence où tu n’as pas besoin d’une fonte spécifique. Elles évitent d’embarquer des octets de police personnalisés — elles reposent sur des métriques intégrées stables, bien que l’apparence rendue exacte puisse encore dépendre de la substitution de police du visualiseur. - Mets toute police personnalisée en vendor dans le dépôt et pointe NextPDF vers
elle explicitement, plutôt que de reposer sur un chemin de police système qui diffère
d’une machine à l’autre. Définis
Config(fontsDirectory: ...)ou appelleaddFontDirectory()avec le répertoire committé :
<?php
declare(strict_types=1);
use NextPDF\Core\Config;use NextPDF\Core\Document;
$config = new Config(fontsDirectory: __DIR__ . '/fonts'); // committed to the repo$document = Document::createStandalone($config);$document->addFontDirectory(__DIR__ . '/fonts'); // or add it imperatively$document->addPage();$document->setFont('dejavusans', '', 12); // resolved from the repoN’installe pas de polices depuis le gestionnaire de paquets de l’OS pour les tests de référence : les paquets de polices de distribution diffèrent en version et en hinting, donc une mise à niveau du runner change silencieusement tes octets. Un répertoire de polices en vendor supprime cette variable.
Workflow GitHub Actions
Section intitulée « Workflow GitHub Actions »Ce workflow installe PHP avec les extensions dont NextPDF a besoin, installe un
extracteur de texte pour les assertions sémantiques, et exécute PHPUnit. La ligne
php-version: "8.4" épingle la version mineure de PHP (8.4), pas le patch — setup-php
la résout vers le dernier 8.4.x disponible. Pour la reproductibilité au niveau de
l’octet, épingle un patch concret que tu prends en charge (par exemple
php-version: "8.4.8") pour qu’une mise à niveau de l’image du runner ne puisse pas
déplacer le build PHP sous tes instantanés de référence.
name: PDF tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4
- name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: "8.4" extensions: curl, gd, intl, mbstring, openssl, zlib coverage: none
- name: Install text extractor for PDF assertions run: sudo apt-get update && sudo apt-get install -y poppler-utils
- name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist
- name: Run the test suite run: vendor/bin/phpunit --testsuite=pdfpoppler-utils fournit pdftotext pour les assertions de texte. La liste d’extensions
correspond à ce que NextPDF Core exige en dur : curl, gd, intl, mbstring,
openssl et zlib couvrent le réseau, le traitement d’images matricielles, le texte et
la collation internationalisés, le texte multi-octets, la cryptographie pour le
chiffrement/la signature, et la compression de flux. Installe-les toutes — le
composer.json de Core exige chacune, donc une extension manquante fait échouer
composer install, pas seulement une fonctionnalité. Si une étape d’assertion ultérieure
parse une sortie HTML ou XML, ajoute dom pour cette étape ; ce n’est pas une exigence
de Core. Parce que les polices sont en vendor dans le dépôt, aucune installation de
paquet de polices n’est nécessaire — c’est ce qui garde les octets du runner égaux aux
tiens.
Cas limites et pièges
Section intitulée « Cas limites et pièges »- Les tests de référence ont besoin de
DeterministicSettings. Sans un horodatage et unfileIdSeedépinglés,CreationDate,ModDateet l’identifiant de fichier du trailer changent à chaque exécution et l’assertion d’octets ne passe jamais. fileIdSeedfait exactement 32 caractères hexadécimaux. Toute autre longueur ou un caractère non hexadécimal lèveInvalidConfigExceptionà la construction.- Les polices font partie des octets. Une version de police différente sur le runner re-met les glyphes en sous-ensemble et fait échouer le test de référence. Mets la police en vendor ou utilise Base 14.
- Core ne livre aucun
extractText(). La récupération de texte pour les assertions est un travail de consommateur : utilisepdftotextou l’annexe Spectrum d’Inspect. Le travail du producteur est d’émettre une CMap/ToUnicodecorrecte (automatique pour les polices embarquées) pour que les extracteurs récupèrent du vrai Unicode ;enableTaggedPdf()ajoute l’arbre de structure par-dessus, mais ce n’est pas ce qui produit la CMap. - La profondeur Quick d’Inspect a un repli PHP en processus quand aucun annexe n’est
présent (précision limitée — signale
INSPECT-FALLBACK-001) ; Standard et Full exigent toujours l’annexe. Pour une CI sans annexe, le repli Quick donne le nombre de pages, la version et le drapeau de chiffrement — traite ses résultats comme approximatifs et appuie-toi sur le texte extrait pour la justesse du contenu. - Régénère les références délibérément. Quand un changement est voulu, supprime l’instantané, ré-exécute pour en écrire un frais, et revois le diff avant de committer. Ne réécris jamais automatiquement une référence en CI.
Performance
Section intitulée « Performance »Les deux styles d’assertion sont peu coûteux. Une comparaison de référence est un build
plus une comparaison de chaîne. Le chemin sémantique ajoute un appel pdftotext hors
processus par document ; garde-les aux documents dont tu assertes réellement le texte. Le
repli PHP Quick d’Inspect (sans annexe) est un scan en une passe des octets, donc il
ajoute un temps négligeable à un test ; quand un annexe est configuré, la profondeur
Quick fait un aller-retour vers l’annexe à la place.
Notes de sécurité
Section intitulée « Notes de sécurité »- Traite le texte extrait comme lisible par machine : n’asserte jamais qu’un secret est absent des octets comme contrôle de confidentialité. Le texte balisé est lisible par quiconque a le fichier. Pour la confidentialité, chiffre.
- Construis le chemin du fichier temporaire pour l’extracteur avec
tempnam()et nettoie-le ; ne fais pas passer les fixtures de test par un chemin partagé prévisible. - Épingle les versions d’outils et d’actions (un patch PHP concret tel que
8.4.8, pas seulement la mineure8.4;poppler-utilsvia la distribution ; SHAs ou tags d’actions) pour qu’un bump de chaîne d’approvisionnement ne puisse pas changer silencieusement tes octets de référence ou ta chaîne d’outils.
Conformité
Section intitulée « Conformité »Ce guide ne fait aucune revendication normative de standards. Le déterminisme sur lequel
il repose est la suppression des deux champs non déterministes nommés dans ISO 32000-2 —
l’identifiant de fichier du trailer (/ID, §7.5.5) et les champs de date du dictionnaire
d’informations du document (CreationDate / ModDate, portés dans le dictionnaire
d’informations du document, un emplacement distinct du trailer) — via
DeterministicSettings. Les assertions de texte reposent sur la CMap /ToUnicode
(§9.10.2) que le moteur émet pour les polices embarquées ; enableTaggedPdf() ajoute
l’arbre de structure séparément et ne crée pas cette CMap. Chaque appel NextPDF montré
est de l’API publique vérifiée.