Gegenereerde PDF's testen in CI
In een oogopslag
Sectie met titel “In een oogopslag”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
DeterministicSettingszodat 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.
Installeren
Sectie met titel “Installeren”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Asserteer 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; // boolInspector::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.
Een PHPUnit-test voor beide assertiestijlen
Sectie met titel “Een PHPUnit-test voor beide assertiestijlen”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 roepaddFontDirectory()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 repoInstalleer 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.
GitHub Actions-workflow
Sectie met titel “GitHub Actions-workflow”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=pdfpoppler-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.
Randgevallen en valkuilen
Sectie met titel “Randgevallen en valkuilen”- Golden-tests hebben
DeterministicSettingsnodig. Zonder een gepind tijdstempel enfileIdSeedveranderenCreationDate,ModDateen de trailer-bestandsidentifier elke run en slaagt de byte-assertie nooit. fileIdSeedis exact 32 hex-tekens. Elke andere lengte of een niet-hex-teken gooitInvalidConfigExceptionbij 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: gebruikpdftotextof 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.
Prestaties
Sectie met titel “Prestaties”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.
Beveiligingsnotities
Sectie met titel “Beveiligingsnotities”- 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 de8.4-minor;poppler-utilsvia de distributie; action-SHA’s of -tags) zodat een supply-chain-bump je golden-bytes of je toolchain niet stilzwijgend kan veranderen.
Conformiteit
Sectie met titel “Conformiteit”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.