Skip to content
getnextpdf.com

Test generated PDFs in CI

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

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

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

Inspector::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.

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.

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 call addFontDirectory() 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 repo

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

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

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

  • Golden tests need DeterministicSettings. Without a pinned timestamp and fileIdSeed, CreationDate, ModDate, and the trailer file identifier change every run and the byte assertion never passes.
  • fileIdSeed is exactly 32 hex characters. Any other length or a non-hex character throws InvalidConfigException at 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: use pdftotext or the Inspect Spectrum sidecar. The producer’s job is to emit a correct /ToUnicode CMap (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.

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.

  • 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 the 8.4 minor; poppler-utils via the distribution; action SHAs or tags) so a supply-chain bump cannot silently change your golden bytes or your toolchain.

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.