Generierte PDFs in CI testen
Auf einen Blick
Abschnitt betitelt „Auf einen Blick“Dieses Rezept ist für Anwendungsentwickler, die PDFs mit NextPDF erzeugen und ihre eigene Ausgabe unter Test halten wollen. Es ist die Konsumentenseite der eigenen Testdisziplin der Engine: Sie testen NextPDF nicht erneut, Sie prüfen, dass Ihr Dokument noch das sagt, was es soll, und noch so aussieht, wie es aussah.
Zwei Assertion-Stile decken nahezu alles ab:
- Semantische Assertions auf extrahiertem Text — erzeugen, den Unicode-Text zurückgewinnen und prüfen, dass er die erwarteten Zeichenketten enthält. Das übersteht Layout-Anpassungen und Schriftänderungen.
- Golden- (Snapshot-) Assertions auf Bytes — pinnen Sie
DeterministicSettings, sodass ein Rebuild byteidentisch ist, und vergleichen Sie dann die neuen Bytes gegen eine eingecheckte Referenzdatei. Das fängt jede unbeabsichtigte Änderung.
Verwenden Sie semantische Assertions für die Inhaltskorrektheit und Golden-Assertions als Regressions-Stolperdraht. Beide laufen unverändert in CI, sobald der Runner dieselben Bytes erzeugt wie Ihre Workstation.
Installation
Abschnitt betitelt „Installation“composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Prüfen Sie extrahierten Text, nicht einen Byte-Diff
Abschnitt betitelt „Prüfen Sie extrahierten Text, nicht einen Byte-Diff“Ein roher Byte-Diff zweier PDFs ist brüchig: ein neuer Zeitstempel, eine neu subgesetzte Schrift oder ein umgeordnetes Objekt ändern alle die Bytes, ohne zu ändern, was ein Leser sieht. Prüfen Sie stattdessen den Inhalt.
NextPDF Core ist ein Producer, also machen Sie den Text zuerst extrahierbar. Das
sind zwei verschiedene Mechanismen, nicht einer. Die Textextraktion stützt sich auf
eine korrekte /ToUnicode-CMap (ISO 32000-2 §9.10.2), die Glyphencodes zurück auf
Unicode abbildet — die Engine emittiert sie für eingebettete Schriften, sodass
Extraktoren echte Zeichen zurückgewinnen statt roher Glyphenindizes. Tagged PDF ist
separat: enableTaggedPdf() und setLanguage() fügen den Strukturbaum hinzu, der
Lesereihenfolge und Barrierefreiheit aufzeichnet, was nicht das ist, was die
/ToUnicode-CMap erzeugt. Aktivieren Sie beides, bevor Sie Inhalt schreiben: die
CMap für saubere Textrückgewinnung, das Tagging für die Lesereihenfolge. Siehe
Extrahierbaren Textinhalt erzeugen
für die Producer-Details. Gewinnen Sie dann den Text zurück und prüfen Sie ihn.
Für Seitenanzahl- und Strukturfakten hat die Quick-Tiefe des Inspect-Moduls einen
reinen PHP-Fallback, der prozessintern läuft, wenn kein Spectrum-Sidecar verfügbar
ist — auf einem CI-Runner praktisch, aber es ist ein degradierter Scan. Er flaggt
ein INSPECT-FALLBACK-001-„accuracy may be limited“-Issue und leitet die
Seitenanzahl aus einem groben /Type /Page-Regex über die rohen Bytes ab, nicht aus
einem vollständigen Objektbaum-Parse. Wenn ein Spectrum-Sidecar konfiguriert ist,
verwendet selbst die Quick-Tiefe ihn — InspectDepth steuert, wie viel Analyse der
Sidecar durchführt, sodass Quick nicht von Natur aus sidecar-frei ist.
<?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() gibt ein unveränderliches InspectResult zurück. Für die
vollständige Textrückgewinnung führen Sie einen nachgelagerten Extraktor
(pdftotext oder den Inspect-Spectrum-Sidecar bei Standard-Tiefe) über die Bytes
aus und prüfen seine Ausgabe — prüfen Sie den zurückgewonnenen Text, niemals die
exakten Bytes des Producers.
Machen Sie die Ausgabe byteidentisch für Golden-Snapshots
Abschnitt betitelt „Machen Sie die Ausgabe byteidentisch für Golden-Snapshots“Ein Golden-Test funktioniert nur, wenn ein Rebuild dieselben Bytes erzeugt. PDF hat
zwei eingebaute Quellen von Nicht-Determinismus: die Datumsfelder (CreationDate /
ModDate) und den Dateibezeichner im Trailer (ISO 32000-2 §7.5.5). NextPDF entfernt
beide über DeterministicSettings, einen erstklassigen Konfigurationswert — kein
Test-Hack.
DeterministicSettings nimmt ein festes DateTimeImmutable und einen
32-Zeichen-Hex-fileIdSeed. Übergeben Sie es an das Config und bauen Sie dann Ihr
Dokument aus dieser Konfiguration. Mit dem gepinnten deterministischen Profil (fester
Zeitstempel und /ID) liefert dieselbe Eingabe byteidentische Ausgabe über Läufe
hinweg auf derselben gepinnten Toolchain — der PHP-Patch, die Versionen der
Erweiterung und der Komprimierungsbibliothek sowie die Schriftdateien alle konstant
gehalten. Über Maschinen hinweg, die sich in einem dieser Punkte unterscheiden,
können die Bytes dennoch divergieren; bevorzugen Sie dort die
Textextraktions-Assertions und reservieren Sie den Golden-Snapshot für eine feste,
gepinnte Umgebung.
<?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();}Der fileIdSeed muss exakt 32 hexadezimale Zeichen sein, sonst wirft der Konstruktor
InvalidConfigException. Wenn Sie bereits ein Config halten, können Sie eine
deterministische Kopie mit $config->withDeterministic($settings) ableiten, statt
es neu zu bauen.
Ein PHPUnit-Test für beide Assertion-Stile
Abschnitt betitelt „Ein PHPUnit-Test für beide Assertion-Stile“Diese Testklasse übt eine semantische Assertion und eine Golden-Assertion gegen denselben Builder aus. Die Golden-Datei wird einmal erzeugt, von einem Menschen geprüft und eingecheckt; danach scheitert der Test bei jeder Byte-Änderung.
<?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); } }}Die Byte-Assertion ist nur deshalb aussagekräftig, weil buildInvoice()
DeterministicSettings pinnt. Ohne sie würde allein CreationDate den Golden-Test
bei jedem Lauf scheitern lassen.
Pinnen Sie Schriften, damit CI dieselben Bytes erzeugt
Abschnitt betitelt „Pinnen Sie Schriften, damit CI dieselben Bytes erzeugt“Byteidentische Ausgabe hängt davon ab, dass dieselben Schrift-Bytes auf jeder
Maschine subgesetzt werden. Eine Schrift, die auf dem Runner anders aufgelöst wird
als auf Ihrer Workstation, ändert das eingebettete Subset und bricht den Golden-Test
— selbst mit gepinnten DeterministicSettings.
Zwei Regeln halten Schriften stabil:
- Verwenden Sie die Base-14-Standardschriften (zum Beispiel
helvetica) für Golden-Tests, bei denen Sie keine bestimmte Schriftart brauchen. Sie vermeiden das Einbetten eigener Schrift-Bytes — sie stützen sich auf stabile eingebaute Metriken, obwohl das exakt gerenderte Erscheinungsbild dennoch von der Schriftersetzung des Viewers abhängen kann. - Vendoren Sie jede eigene Schrift in das Repository und zeigen Sie NextPDF
explizit darauf, statt sich auf einen Systemschriftpfad zu verlassen, der zwischen
Maschinen differiert. Setzen Sie
Config(fontsDirectory: ...)oder rufen SieaddFontDirectory()mit dem eingecheckten Verzeichnis auf:
<?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 repoInstallieren Sie keine Schriften aus dem OS-Paketmanager für Golden-Tests: Distributions-Schriftpakete unterscheiden sich in Version und Hinting, sodass ein Runner-Upgrade Ihre Bytes stillschweigend ändert. Ein gevendortes Schriftverzeichnis beseitigt diese Variable.
GitHub-Actions-Workflow
Abschnitt betitelt „GitHub-Actions-Workflow“Dieser Workflow installiert PHP mit den Erweiterungen, die NextPDF braucht,
installiert einen Textextraktor für die semantischen Assertions und führt PHPUnit
aus. Die Zeile php-version: "8.4" pinnt die PHP-Minor-Version (8.4), nicht den
Patch — setup-php löst sie zur neuesten verfügbaren 8.4.x auf. Für
Byte-Reproduzierbarkeit pinnen Sie einen konkreten Patch, den Sie unterstützen (zum
Beispiel php-version: "8.4.8"), sodass ein Runner-Image-Upgrade den PHP-Build unter
Ihren Golden-Snapshots nicht verschieben kann.
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 stellt pdftotext für die Text-Assertions bereit. Die
Erweiterungsliste deckt sich mit dem, was NextPDF Core zwingend erfordert: curl,
gd, intl, mbstring, openssl und zlib decken Netzwerk, Rasterbildbehandlung,
internationalisierten Text und Kollation, Multibyte-Text, Kryptografie für
Verschlüsselung/Signieren sowie Stream-Komprimierung ab. Installieren Sie alle —
Cores composer.json erfordert jede einzelne, sodass eine fehlende Erweiterung
composer install scheitern lässt, nicht nur ein einzelnes Feature. Wenn ein
späterer Assertion-Schritt HTML- oder XML-Ausgabe parst, fügen Sie dom für diesen
Schritt hinzu; es ist keine Core-Anforderung. Weil die Schriften im Repository
gevendort sind, ist keine Installation eines Schriftpakets nötig — das ist es, was
die Bytes des Runners gleich Ihren hält.
Grenzfälle & Stolperfallen
Abschnitt betitelt „Grenzfälle & Stolperfallen“- Golden-Tests brauchen
DeterministicSettings. Ohne einen gepinnten Zeitstempel undfileIdSeedändern sichCreationDate,ModDateund der Trailer-Dateibezeichner bei jedem Lauf, und die Byte-Assertion geht nie durch. fileIdSeedist exakt 32 Hex-Zeichen. Jede andere Länge oder ein Nicht-Hex-Zeichen wirftInvalidConfigExceptionbei der Konstruktion.- Schriften sind Teil der Bytes. Eine andere Schriftversion auf dem Runner subgesetzt die Glyphen neu und lässt den Golden-Test scheitern. Vendoren Sie die Schrift oder verwenden Sie Base 14.
- Core liefert kein
extractText()aus. Textrückgewinnung für Assertions ist Konsumentenarbeit: Verwenden Siepdftotextoder den Inspect-Spectrum-Sidecar. Die Aufgabe des Producers ist, eine korrekte/ToUnicode-CMap zu emittieren (automatisch für eingebettete Schriften), sodass Extraktoren echtes Unicode zurückgewinnen;enableTaggedPdf()fügt den Strukturbaum obendrauf, aber es ist nicht das, was die CMap erzeugt. - Die Inspect-Quick-Tiefe hat einen prozessinternen PHP-Fallback, wenn kein
Sidecar vorhanden ist (eingeschränkte Genauigkeit — flaggt
INSPECT-FALLBACK-001); Standard und Full erfordern immer den Sidecar. Für CI ohne Sidecar gibt der Quick-Fallback Seitenanzahl, Version und das Verschlüsselungs-Flag — behandeln Sie seine Ergebnisse als näherungsweise und stützen Sie sich für die Inhaltskorrektheit auf extrahierten Text. - Regenerieren Sie Goldens bewusst. Wenn eine Änderung beabsichtigt ist, löschen Sie den Snapshot, führen Sie erneut aus, um einen frischen zu schreiben, und prüfen Sie den Diff vor dem Einchecken. Überschreiben Sie ein Golden niemals automatisch in CI.
Performance
Abschnitt betitelt „Performance“Beide Assertion-Stile sind günstig. Ein Golden-Vergleich ist ein Build plus ein
String-Vergleich. Der semantische Pfad fügt einen prozess-externen
pdftotext-Aufruf pro Dokument hinzu; halten Sie diese auf die Dokumente
beschränkt, deren Text Sie tatsächlich prüfen. Der Inspect-Quick-PHP-Fallback (kein
Sidecar) ist ein Single-Pass-Scan der Bytes, sodass er einem Test vernachlässigbare
Zeit hinzufügt; wenn ein Sidecar konfiguriert ist, macht die Quick-Tiefe stattdessen
einen Sidecar-Roundtrip.
Sicherheitshinweise
Abschnitt betitelt „Sicherheitshinweise“- Behandeln Sie extrahierten Text als maschinenlesbar: prüfen Sie niemals, dass ein Geheimnis in den Bytes fehlt, als Vertraulichkeitskontrolle. Getaggter Text ist von jedem mit der Datei lesbar. Für Vertraulichkeit verschlüsseln Sie.
- Bauen Sie den temporären Dateipfad für den Extraktor mit
tempnam()und räumen ihn auf; leiten Sie Test-Fixtures nicht über einen vorhersehbaren gemeinsamen Pfad. - Pinnen Sie Tool- und Action-Versionen (einen konkreten PHP-Patch wie
8.4.8, nicht nur die8.4-Minor-Version;poppler-utilsüber die Distribution; Action-SHAs oder -Tags), sodass ein Supply-Chain-Bump Ihre Golden-Bytes oder Ihre Toolchain nicht stillschweigend ändern kann.
Konformität
Abschnitt betitelt „Konformität“Diese Anleitung erhebt keinen normativen Standardanspruch. Der Determinismus, auf
den sie sich stützt, ist die Entfernung der beiden nicht-deterministischen Felder,
die in ISO 32000-2 benannt sind — der Trailer-Dateibezeichner (/ID, §7.5.5) und
die Datumsfelder der Dokumentinformation (CreationDate / ModDate, getragen im
Document-Information-Dictionary, einem von Trailer getrennten Ort) — über
DeterministicSettings. Text-Assertions stützen sich auf die /ToUnicode-CMap
(§9.10.2), die die Engine für eingebettete Schriften emittiert; enableTaggedPdf()
fügt den Strukturbaum separat hinzu und erzeugt diese CMap nicht. Jeder gezeigte
NextPDF-Aufruf ist verifizierte öffentliche API.