Test generated PDFs in CI
At a glance
Section titled “At a glance”This recipe is for application developers who generate PDFs with NextPDF and want to keep their own output under test. It is the consumer side of the engine’s own test discipline: you do not re-test NextPDF, you assert that your document still says what it should and still looks the way it did.
Two assertion styles cover almost everything:
- Semantic assertions on extracted text — generate, recover the Unicode text, and assert it contains the strings you expect. This survives layout tweaks and font changes.
- Golden (snapshot) assertions on bytes — pin
DeterministicSettingsso a rebuild is byte-identical, then compare the new bytes against a committed reference file. This catches any unintended change.
Use semantic assertions for content correctness and golden assertions as a regression tripwire. Both run unchanged in CI once the runner produces the same bytes your workstation does.
Install
Section titled “Install”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Assert on extracted text, not on a byte diff
Section titled “Assert on extracted text, not on a byte diff”A raw byte diff of two PDFs is brittle: a new timestamp, a re-subset font, or a reordered object all change the bytes without changing what a reader sees. Assert on content instead.
NextPDF Core is a producer, so make the text extractable first. These are two
distinct mechanisms, not one. Text extraction relies on a correct /ToUnicode
CMap (ISO 32000-2 §9.10.2) that maps glyph codes back to Unicode — the engine
emits it for embedded fonts, so extractors recover real characters rather than
raw glyph indices. Tagged PDF is separate: enableTaggedPdf() and
setLanguage() add the structure tree that records reading order and
accessibility, which is not what creates the /ToUnicode CMap. Enable both
before you write content: the CMap for clean text recovery, tagging for reading
order. See Produce extractable text content
for the producer details. Then recover the text and assert on it.
For page-count and structural facts, the Inspect module’s Quick depth has a
pure-PHP fallback that runs in-process when no Spectrum sidecar is available —
convenient on a CI runner, but it is a degraded scan. It flags an
INSPECT-FALLBACK-001 “accuracy may be limited” issue and derives the page count
from a rough /Type /Page regex over the raw bytes, not a full object-tree parse.
When a Spectrum sidecar is configured, even Quick depth uses it — InspectDepth
controls how much analysis the sidecar performs, so Quick is not inherently
sidecar-free.
<?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() returns an immutable InspectResult. For full text
recovery, run a downstream extractor (pdftotext, or the Inspect Spectrum
sidecar at Standard depth) over the bytes and assert on its output — assert on
the recovered text, never on the producer’s exact bytes.
Make output byte-identical for golden snapshots
Section titled “Make output byte-identical for golden snapshots”A golden test only works if a rebuild produces the same bytes. PDF has two
built-in sources of non-determinism: the date fields (CreationDate /
ModDate) and the file identifier in the trailer (ISO 32000-2 §7.5.5). NextPDF
removes both through DeterministicSettings, a first-class config value — not a
test hack.
DeterministicSettings takes a fixed DateTimeImmutable and a 32-character hex
fileIdSeed. Pass it on the Config, then build your document from that config.
With the deterministic profile pinned (fixed timestamp and /ID), the same input
yields byte-identical output across runs on the same pinned toolchain — the PHP
patch, the extension and compression-library versions, and the font files all
held constant. Across machines that differ in any of those, the bytes can still
diverge; prefer the text-extraction assertions there and reserve the golden
snapshot for a fixed, pinned environment.
<?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();}The fileIdSeed must be exactly 32 hexadecimal characters, or the constructor
throws InvalidConfigException. If you already hold a Config, you can derive a
deterministic copy with $config->withDeterministic($settings) instead of
rebuilding it.
A PHPUnit test for both assertion styles
Section titled “A PHPUnit test for both assertion styles”This test class exercises a semantic assertion and a golden assertion against the same builder. The golden file is generated once, reviewed by a human, and committed; after that the test fails on any byte change.
<?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); } }}The byte assertion is meaningful only because buildInvoice() pins
DeterministicSettings. Without it, CreationDate alone would fail the golden
test on every run.
Pin fonts so CI produces the same bytes
Section titled “Pin fonts so CI produces the same bytes”Byte-identical output depends on the same font bytes being subset on every
machine. A font that resolves differently on the runner than on your workstation
changes the embedded subset and breaks the golden test — even with
DeterministicSettings pinned.
Two rules keep fonts stable:
- Use the Base 14 standard fonts (for example
helvetica) for golden tests where you do not need a specific typeface. They avoid embedding custom font bytes — they rely on stable built-in metrics, though the exact rendered appearance can still depend on the viewer’s font substitution. - Vendor any custom font into the repository and point NextPDF at it
explicitly, rather than relying on a system font path that differs between
machines. Set
Config(fontsDirectory: ...)or calladdFontDirectory()with the committed directory:
<?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 repoDo not install fonts from the OS package manager for golden tests: distribution font packages differ in version and hinting, so a runner upgrade silently changes your bytes. A vendored font directory removes that variable.
GitHub Actions workflow
Section titled “GitHub Actions workflow”This workflow installs PHP with the extensions NextPDF needs, installs a text
extractor for the semantic assertions, and runs PHPUnit. The php-version: "8.4"
line pins the PHP minor version (8.4), not the patch — setup-php resolves it to
the latest available 8.4.x. For byte-level reproducibility, pin a concrete patch
you support (for example php-version: "8.4.8") so a runner image upgrade cannot
shift the PHP build under your golden snapshots.
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 provides pdftotext for the text assertions. The extension list
matches what NextPDF Core hard-requires: curl, gd, intl, mbstring,
openssl, and zlib cover networking, raster image handling, internationalized
text and collation, multibyte text, cryptography for encryption/signing, and
stream compression. Install all of them — Core’s composer.json requires every
one, so a missing extension fails composer install, not just a single feature.
If a later assertion step parses HTML or XML output, add dom for that step; it
is not a Core requirement. Because the fonts are vendored in the repository, no
font package install is needed — that is what keeps the runner’s bytes equal to
yours.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- Golden tests need
DeterministicSettings. Without a pinned timestamp andfileIdSeed,CreationDate,ModDate, and the trailer file identifier change every run and the byte assertion never passes. fileIdSeedis exactly 32 hex characters. Any other length or a non-hex character throwsInvalidConfigExceptionat construction.- Fonts are part of the bytes. A different font version on the runner re-subsets the glyphs and fails the golden test. Vendor the font or use Base 14.
- Core ships no
extractText(). Text recovery for assertions is consumer work: usepdftotextor the Inspect Spectrum sidecar. The producer’s job is to emit a correct/ToUnicodeCMap (automatic for embedded fonts) so extractors recover real Unicode;enableTaggedPdf()adds the structure tree on top, but it is not what produces the CMap. - Inspect Quick depth has an in-process PHP fallback when no sidecar is present
(limited accuracy — flags
INSPECT-FALLBACK-001); Standard and Full always require the sidecar. For CI without a sidecar, the Quick fallback gives page count, version, and the encryption flag — treat its results as approximate and lean on extracted text for content correctness. - Regenerate goldens deliberately. When a change is intended, delete the snapshot, re-run to write a fresh one, and review the diff before committing. Never auto-overwrite a golden in CI.
Performance
Section titled “Performance”Both assertion styles are cheap. A golden comparison is one build plus a string
compare. The semantic path adds one out-of-process pdftotext call per document;
keep those to the documents whose text you actually assert on. The Inspect Quick
PHP fallback (no sidecar) is a single-pass scan of the bytes, so it adds
negligible time to a test; when a sidecar is configured, Quick depth makes one
sidecar round-trip instead.
Security notes
Section titled “Security notes”- Treat extracted text as machine-readable: never assert that a secret is absent from the bytes as a confidentiality control. Tagged text is readable by anyone with the file. For confidentiality, encrypt.
- Build the temporary file path for the extractor with
tempnam()and clean it up; do not pass test fixtures through a predictable shared path. - Pin tool and action versions (a concrete PHP patch such as
8.4.8, not just the8.4minor;poppler-utilsvia the distribution; action SHAs or tags) so a supply-chain bump cannot silently change your golden bytes or your toolchain.
Conformance
Section titled “Conformance”This guide makes no normative standards claim. The determinism it relies on is
the removal of the two non-deterministic fields named in ISO 32000-2 — the
trailer file identifier (/ID, §7.5.5) and the document-information date fields
(CreationDate / ModDate, carried in the document information dictionary, a
separate location from the trailer) — through DeterministicSettings.
Text assertions rely on the /ToUnicode CMap (§9.10.2) that the engine emits
for embedded fonts; enableTaggedPdf() adds the structure tree separately and
does not create that CMap. Every NextPDF call shown is verified public API.