Ga naar inhoud
getnextpdf.com

Gegenereerde PDF's testen in CI

Dit recept is voor applicatieontwikkelaars die PDF’s genereren met NextPDF en hun eigen uitvoer onder test willen houden. Het is de consumentkant van de eigen testdiscipline van de engine: je test NextPDF niet opnieuw, je asserteert dat je document nog steeds zegt wat het zou moeten zeggen en er nog steeds uitziet zoals het deed.

Twee assertiestijlen dekken vrijwel alles:

  • Semantische asserties op geëxtraheerde tekst — genereer, herstel de Unicode-tekst, en asserteer dat hij de strings bevat die je verwacht. Dit overleeft layout-aanpassingen en lettertypewijzigingen.
  • Golden- (snapshot-) asserties op bytes — pin DeterministicSettings zodat een herbouw byte-identiek is, en vergelijk de nieuwe bytes daarna met een gecommit referentiebestand. Dit vangt elke onbedoelde wijziging op.

Gebruik semantische asserties voor inhoudscorrectheid en golden-asserties als regressie-tripwire. Beide draaien ongewijzigd in CI zodra de runner dezelfde bytes produceert als je werkstation.

Terminal window
composer require --dev phpunit/phpunit
composer require nextpdf/core:^3

Asserteer op geëxtraheerde tekst, niet op een byte-diff

Sectie met titel “Asserteer op geëxtraheerde tekst, niet op een byte-diff”

Een ruwe byte-diff van twee PDF’s is fragiel: een nieuw tijdstempel, een opnieuw gesubsette lettertype, of een herordend object veranderen allemaal de bytes zonder te veranderen wat een lezer ziet. Asserteer in plaats daarvan op inhoud.

NextPDF Core is een producent, dus maak de tekst eerst extraheerbaar. Dit zijn twee verschillende mechanismen, niet één. Tekstextractie steunt op een correcte /ToUnicode-CMap (ISO 32000-2 §9.10.2) die glyphcodes terug toewijst naar Unicode — de engine stoot hem uit voor ingesloten lettertypen, zodat extractors echte tekens herstellen in plaats van ruwe glyph-indexen. Tagged PDF staat los: enableTaggedPdf() en setLanguage() voegen de structuurboom toe die de leesvolgorde en toegankelijkheid vastlegt, wat niet is wat de /ToUnicode-CMap aanmaakt. Schakel beide in voordat je inhoud schrijft: de CMap voor schoon tekstherstel, tagging voor leesvolgorde. Zie Extraheerbare tekstinhoud produceren voor de producentdetails. Herstel daarna de tekst en asserteer erop.

Voor pagina-aantal- en structurele feiten heeft de Quick-diepte van de Inspect-module een pure-PHP-fallback die in-process draait wanneer geen Spectrum-sidecar beschikbaar is — handig op een CI-runner, maar het is een gedegradeerde scan. Hij signaleert een INSPECT-FALLBACK-001-issue (“accuracy may be limited”) en leidt het pagina-aantal af uit een grove /Type /Page-regex over de ruwe bytes, geen volledige objectboomparse. Wanneer een Spectrum-sidecar wel is geconfigureerd, gebruikt zelfs de Quick-diepte hem — InspectDepth bepaalt hoeveel analyse de sidecar uitvoert, dus Quick is niet inherent sidecar-vrij.

<?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() retourneert een onveranderlijke InspectResult. Voor volledig tekstherstel draai je een downstream-extractor (pdftotext, of de Inspect Spectrum-sidecar op Standard-diepte) over de bytes en asserteer je op de uitvoer ervan — asserteer op de herstelde tekst, nooit op de exacte bytes van de producent.

Maak uitvoer byte-identiek voor golden snapshots

Sectie met titel “Maak uitvoer byte-identiek voor golden snapshots”

Een golden-test werkt alleen als een herbouw dezelfde bytes produceert. PDF heeft twee ingebouwde bronnen van non-determinisme: de datumvelden (CreationDate / ModDate) en de bestandsidentifier in de trailer (ISO 32000-2 §7.5.5). NextPDF verwijdert beide via DeterministicSettings, een eersteklas configuratiewaarde — geen testhack.

DeterministicSettings neemt een vaste DateTimeImmutable en een 32-tekens hex fileIdSeed. Geef het door op de Config, en bouw daarna je document op basis van die config. Met het deterministische profiel gepind (vast tijdstempel en /ID) levert dezelfde invoer byte-identieke uitvoer over runs heen op dezelfde gepinde toolchain — de PHP-patch, de extensie- en compressiebibliotheekversies, en de lettertypebestanden allemaal constant gehouden. Over machines die in een van die zaken verschillen, kunnen de bytes nog steeds uiteenlopen; geef daar de voorkeur aan de tekstextractie-asserties en reserveer de golden snapshot voor een vaste, gepinde omgeving.

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

De fileIdSeed moet exact 32 hexadecimale tekens zijn, anders gooit de constructor InvalidConfigException. Als je al een Config vasthoudt, kun je een deterministische kopie afleiden met $config->withDeterministic($settings) in plaats van hem opnieuw te bouwen.

Deze testklasse oefent een semantische assertie en een golden-assertie tegen dezelfde builder. Het golden-bestand wordt één keer gegenereerd, door een mens beoordeeld, en gecommit; daarna faalt de test op elke byte-wijziging.

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

De byte-assertie is alleen betekenisvol omdat buildInvoice() DeterministicSettings pint. Zonder dat zou CreationDate alleen al de golden-test bij elke run laten falen.

Pin lettertypen zodat CI dezelfde bytes produceert

Sectie met titel “Pin lettertypen zodat CI dezelfde bytes produceert”

Byte-identieke uitvoer hangt ervan af dat dezelfde lettertype-bytes op elke machine worden gesubset. Een lettertype dat anders herleidt op de runner dan op je werkstation verandert de ingesloten subset en breekt de golden-test — zelfs met DeterministicSettings gepind.

Twee regels houden lettertypen stabiel:

  • Gebruik de Base 14-standaardlettertypen (bijvoorbeeld helvetica) voor golden-tests waar je geen specifiek lettertype nodig hebt. Ze vermijden het insluiten van custom lettertype-bytes — ze steunen op stabiele ingebouwde metrieken, hoewel het exacte gerenderde uiterlijk nog steeds kan afhangen van de lettertypevervanging van de viewer.
  • Vendor elk custom lettertype in de repository en wijs NextPDF er expliciet naar, in plaats van te steunen op een systeemlettertypepad dat per machine verschilt. Stel Config(fontsDirectory: ...) in of roep addFontDirectory() aan met de gecommitte map:
<?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

Installeer voor golden-tests geen lettertypen vanuit de OS-packagemanager: distributie-lettertypepackages verschillen in versie en hinting, dus een runner-upgrade verandert stilzwijgend je bytes. Een gevendorde lettertypemap verwijdert die variabele.

Deze workflow installeert PHP met de extensies die NextPDF nodig heeft, installeert een tekstextractor voor de semantische asserties, en draait PHPUnit. De regel php-version: "8.4" pint de PHP-minorversie (8.4), niet de patch — setup-php herleidt hem naar de nieuwste beschikbare 8.4.x. Voor byte-niveau-reproduceerbaarheid pin je een concrete patch die je ondersteunt (bijvoorbeeld php-version: "8.4.8") zodat een runner-image-upgrade de PHP-build niet onder je golden snapshots kan verschuiven.

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 levert pdftotext voor de tekst-asserties. De extensielijst komt overeen met wat NextPDF Core hard vereist: curl, gd, intl, mbstring, openssl en zlib dekken networking, rasterbeeldverwerking, geïnternationaliseerde tekst en collatie, multibyte-tekst, cryptografie voor encryptie/ondertekening, en streamcompressie. Installeer ze allemaal — de composer.json van Core vereist iedere, dus een ontbrekende extensie laat composer install falen, niet slechts één feature. Als een latere assertiestap HTML- of XML-uitvoer parseert, voeg dan dom toe voor die stap; het is geen Core-vereiste. Omdat de lettertypen in de repository zijn gevendord, is geen lettertype-package-installatie nodig — dat is wat de bytes van de runner gelijk houdt aan die van jou.

  • Golden-tests hebben DeterministicSettings nodig. Zonder een gepind tijdstempel en fileIdSeed veranderen CreationDate, ModDate en de trailer-bestandsidentifier elke run en slaagt de byte-assertie nooit.
  • fileIdSeed is exact 32 hex-tekens. Elke andere lengte of een niet-hex-teken gooit InvalidConfigException bij constructie.
  • Lettertypen zijn deel van de bytes. Een andere lettertypeversie op de runner subset de glyphs opnieuw en laat de golden-test falen. Vendor het lettertype of gebruik Base 14.
  • Core levert geen extractText() mee. Tekstherstel voor asserties is consumentwerk: gebruik pdftotext of de Inspect Spectrum-sidecar. De taak van de producent is het uitstoten van een correcte /ToUnicode-CMap (automatisch voor ingesloten lettertypen) zodat extractors echte Unicode herstellen; enableTaggedPdf() voegt daarbovenop de structuurboom toe, maar dat is niet wat de CMap produceert.
  • De Inspect Quick-diepte heeft een in-process-PHP-fallback wanneer geen sidecar aanwezig is (beperkte nauwkeurigheid — signaleert INSPECT-FALLBACK-001); Standard en Full vereisen altijd de sidecar. Voor CI zonder een sidecar geeft de Quick-fallback pagina-aantal, versie en de encryptie-flag — behandel de resultaten ervan als bij benadering en leun voor inhoudscorrectheid op geëxtraheerde tekst.
  • Regenereer goldens bewust. Wanneer een wijziging bedoeld is, verwijder de snapshot, draai opnieuw om een verse te schrijven, en beoordeel de diff voordat je commit. Overschrijf een golden nooit automatisch in CI.

Beide assertiestijlen zijn goedkoop. Een golden-vergelijking is één build plus een string-vergelijking. Het semantische pad voegt één out-of-process-pdftotext-aanroep per document toe; houd die beperkt tot de documenten waarvan je de tekst daadwerkelijk asserteert. De Inspect Quick-PHP-fallback (geen sidecar) is een enkele-doorloopscan van de bytes, dus hij voegt verwaarloosbare tijd toe aan een test; wanneer een sidecar is geconfigureerd, maakt de Quick-diepte in plaats daarvan één sidecar-round-trip.

  • Behandel geëxtraheerde tekst als machineleesbaar: asserteer nooit dat een geheim afwezig is uit de bytes als vertrouwelijkheidscontrole. Getagde tekst is leesbaar voor iedereen met het bestand. Voor vertrouwelijkheid, versleutel.
  • Bouw het tijdelijke bestandspad voor de extractor met tempnam() en ruim het op; geef testfixtures niet door via een voorspelbaar gedeeld pad.
  • Pin tool- en action-versies (een concrete PHP-patch zoals 8.4.8, niet alleen de 8.4-minor; poppler-utils via de distributie; action-SHA’s of -tags) zodat een supply-chain-bump je golden-bytes of je toolchain niet stilzwijgend kan veranderen.

Deze handleiding doet geen normatieve standaardenclaim. Het determinisme waar hij op steunt is het verwijderen van de twee niet-deterministische velden die in ISO 32000-2 worden genoemd — de trailer-bestandsidentifier (/ID, §7.5.5) en de documentinformatie-datumvelden (CreationDate / ModDate, opgenomen in het documentinformatiewoordenboek, een aparte locatie ten opzichte van de trailer) — via DeterministicSettings. Tekst-asserties steunen op de /ToUnicode-CMap (§9.10.2) die de engine uitstoot voor ingesloten lettertypen; enableTaggedPdf() voegt de structuurboom apart toe en maakt die CMap niet aan. Elke getoonde NextPDF-aanroep is geverifieerde openbare API.