Zum Inhalt springen
getnextpdf.com

Generierte PDFs in CI testen

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.

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

Prü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; // bool

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

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

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

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

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

  • Golden-Tests brauchen DeterministicSettings. Ohne einen gepinnten Zeitstempel und fileIdSeed ändern sich CreationDate, ModDate und der Trailer-Dateibezeichner bei jedem Lauf, und die Byte-Assertion geht nie durch.
  • fileIdSeed ist exakt 32 Hex-Zeichen. Jede andere Länge oder ein Nicht-Hex-Zeichen wirft InvalidConfigException bei 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 Sie pdftotext oder 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.

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.

  • 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 die 8.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.

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.