Aller au contenu
getnextpdf.com

Tester les PDF générés en CI

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 DeterministicSettings pour 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.

Fenêtre de terminal
composer require --dev phpunit/phpunit
composer require nextpdf/core:^3

Asserte 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; // bool

Inspector::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 appelle addFontDirectory() 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 repo

N’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.

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=pdf

poppler-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.

  • Les tests de référence ont besoin de DeterministicSettings. Sans un horodatage et un fileIdSeed épinglés, CreationDate, ModDate et l’identifiant de fichier du trailer changent à chaque exécution et l’assertion d’octets ne passe jamais.
  • fileIdSeed fait exactement 32 caractères hexadécimaux. Toute autre longueur ou un caractère non hexadécimal lève InvalidConfigException à 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 : utilise pdftotext ou l’annexe Spectrum d’Inspect. Le travail du producteur est d’émettre une CMap /ToUnicode correcte (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.

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.

  • 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 mineure 8.4 ; poppler-utils via 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.

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.