Przejdź do głównej zawartości
getnextpdf.com

Testowanie wygenerowanych PDF-ów w CI

Ten przepis jest dla deweloperów aplikacji, którzy generują PDF-y za pomocą NextPDF i chcą trzymać swoje własne wyjście pod testami. To strona konsumencka własnej dyscypliny testowej silnika: nie testujesz ponownie NextPDF, lecz robisz asercję, że twój dokument wciąż mówi to, co powinien, i wciąż wygląda tak, jak wyglądał.

Dwa style asercji pokrywają niemal wszystko:

  • Asercje semantyczne na wyodrębnionym tekście — wygeneruj, odzyskaj tekst Unicode i potwierdź, że zawiera łańcuchy, których oczekujesz. To przeżywa poprawki układu i zmiany czcionek.
  • Asercje golden (zrzutowe) na bajtach — przypnij DeterministicSettings, tak by przebudowa była identyczna bajtowo, a następnie porównaj nowe bajty z zatwierdzonym plikiem referencyjnym. To wyłapuje każdą niezamierzoną zmianę.

Używaj asercji semantycznych dla poprawności treści, a asercji golden jako mechanizmu wyzwalającego przy regresji. Obie działają niezmienione w CI, gdy runner wytwarza te same bajty co twoja stacja robocza.

Okno terminala
composer require --dev phpunit/phpunit
composer require nextpdf/core:^3

Asercja na wyodrębnionym tekście, a nie na różnicy bajtów

Dział zatytułowany „Asercja na wyodrębnionym tekście, a nie na różnicy bajtów”

Surowa różnica bajtów dwóch PDF-ów jest krucha: nowy znacznik czasu, ponownie-podzbiorowana czcionka czy przestawiony obiekt — każde z nich zmienia bajty bez zmiany tego, co widzi czytelnik. Rób asercję na treści zamiast tego.

NextPDF Core to producent, więc najpierw uczyń tekst wyodrębnialnym. To dwa odrębne mechanizmy, nie jeden. Wyodrębnianie tekstu opiera się na poprawnym CMap /ToUnicode (ISO 32000-2 §9.10.2), który mapuje kody glifów z powrotem na Unicode — silnik emituje go dla osadzonych czcionek, więc ekstraktory odzyskują prawdziwe znaki, a nie surowe indeksy glifów. Tagged PDF to coś odrębnego: enableTaggedPdf() i setLanguage() dodają drzewo struktury, które zapisuje kolejność czytania i dostępność, co nie jest tym, co tworzy CMap /ToUnicode. Włącz oba przed zapisaniem treści: CMap dla czystego odzysku tekstu, tagowanie dla kolejności czytania. Zobacz Wytwarzanie wyodrębnialnej treści tekstowej po szczegóły producenta. Następnie odzyskaj tekst i zrób na nim asercję.

Dla faktów o liczbie stron i strukturalnych głębokość Quick modułu Inspect ma czysto PHP-owy fallback, który działa w procesie, gdy żaden sidecar Spectrum nie jest dostępny — wygodny na runnerze CI, ale to skan zdegradowany. Flaguje problem INSPECT-FALLBACK-001 „accuracy may be limited” i wyprowadza liczbę stron z przybliżonego wyrażenia regularnego /Type /Page po surowych bajtach, a nie pełnego parsowania drzewa obiektów. Gdy sidecar Spectrum jest skonfigurowany, nawet głębokość Quick go używa — InspectDepth steruje tym, ile analizy wykonuje sidecar, więc Quick nie jest z natury wolny od sidecara.

<?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() zwraca niezmienny InspectResult. Dla pełnego odzysku tekstu uruchom downstreamowy ekstraktor (pdftotext lub sidecar Spectrum modułu Inspect na głębokości Standard) po bajtach i zrób asercję na jego wyjściu — rób asercję na odzyskanym tekście, nigdy na dokładnych bajtach producenta.

Uczyń wyjście identycznym bajtowo dla zrzutów golden

Dział zatytułowany „Uczyń wyjście identycznym bajtowo dla zrzutów golden”

Test golden działa tylko wtedy, gdy przebudowa wytwarza te same bajty. PDF ma dwa wbudowane źródła niedeterminizmu: pola dat (CreationDate / ModDate) oraz identyfikator pliku w zwiastunie (ISO 32000-2 §7.5.5). NextPDF usuwa oba przez DeterministicSettings, pierwszorzędną wartość konfiguracji — a nie hack testowy.

DeterministicSettings przyjmuje ustalony DateTimeImmutable oraz 32-znakowy szesnastkowy fileIdSeed. Przekaż go na Config, a następnie zbuduj swój dokument z tej konfiguracji. Z przypiętym profilem deterministycznym (ustalony znacznik czasu i /ID) to samo wejście daje identyczne bajtowo wyjście między uruchomieniami na tym samym przypiętym łańcuchu narzędzi — z patchem PHP, wersjami rozszerzeń i bibliotek kompresji oraz plikami czcionek utrzymanymi jako stałe. Na maszynach różniących się którymkolwiek z nich bajty wciąż mogą się rozjechać; tam preferuj asercje na wyodrębnionym tekście i rezerwuj zrzut golden dla ustalonego, przypiętego środowiska.

<?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();
}

fileIdSeed musi mieć dokładnie 32 znaki szesnastkowe, w przeciwnym razie konstruktor rzuca InvalidConfigException. Jeśli już trzymasz Config, możesz wyprowadzić jego deterministyczną kopię za pomocą $config->withDeterministic($settings) zamiast budować go od nowa.

Ta klasa testowa ćwiczy asercję semantyczną i asercję golden względem tego samego budowniczego. Plik golden jest generowany raz, sprawdzany przez człowieka i zatwierdzany; potem test nie przechodzi przy każdej zmianie bajtów.

<?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);
}
}
}

Asercja bajtowa jest sensowna tylko dlatego, że buildInvoice() przypina DeterministicSettings. Bez niej sam CreationDate nie przeszedłby testu golden przy każdym uruchomieniu.

Identyczne bajtowo wyjście zależy od tych samych bajtów czcionki podzbiorowanych na każdej maszynie. Czcionka, która rozpoznaje się inaczej na runnerze niż na twojej stacji roboczej, zmienia osadzony podzbiór i psuje test golden — nawet z przypiętym DeterministicSettings.

Dwie reguły utrzymują czcionki stabilne:

  • Używaj 14 standardowych czcionek Base (na przykład helvetica) dla testów golden, gdzie nie potrzebujesz konkretnego kroju. Unikają osadzania bajtów niestandardowej czcionki — opierają się na stabilnych wbudowanych metrykach, choć dokładny renderowany wygląd wciąż może zależeć od podstawiania czcionek przez przeglądarkę.
  • Włącz każdą niestandardową czcionkę do repozytorium i wskaż NextPDF na nią jawnie, zamiast polegać na ścieżce czcionek systemowych, która różni się między maszynami. Ustaw Config(fontsDirectory: ...) lub wywołaj addFontDirectory() z zatwierdzonym katalogiem:
<?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

Nie instaluj czcionek z menedżera pakietów systemu operacyjnego dla testów golden: pakiety czcionek dystrybucji różnią się wersją i hintingiem, więc aktualizacja runnera po cichu zmienia twoje bajty. Włączony do repo katalog czcionek usuwa tę zmienną.

Ten workflow instaluje PHP z rozszerzeniami, których potrzebuje NextPDF, instaluje ekstraktor tekstu dla asercji semantycznych i uruchamia PHPUnit. Wiersz php-version: "8.4" przypina wersję minor PHP (8.4), a nie patch — setup-php rozwiązuje ją do najnowszej dostępnej 8.4.x. Dla reprodukowalności na poziomie bajtów przypnij konkretny patch, który wspierasz (na przykład php-version: "8.4.8"), aby aktualizacja obrazu runnera nie mogła przesunąć buildu PHP pod twoimi zrzutami golden.

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 dostarcza pdftotext dla asercji tekstowych. Lista rozszerzeń odpowiada temu, czego NextPDF Core twardo wymaga: curl, gd, intl, mbstring, openssl oraz zlib pokrywają sieć, obsługę obrazów rastrowych, zinternacjonalizowany tekst i zestawianie, tekst wielobajtowy, kryptografię do szyfrowania/podpisywania oraz kompresję strumieni. Zainstaluj je wszystkie — composer.json Core wymaga każdego z nich, więc brakujące rozszerzenie psuje composer install, a nie tylko pojedynczą funkcję. Jeśli późniejszy krok asercji parsuje wyjście HTML lub XML, dodaj dla tego kroku dom; nie jest to wymóg Core. Ponieważ czcionki są włączone do repozytorium, nie jest potrzebna instalacja pakietu czcionek — to właśnie utrzymuje bajty runnera równe twoim.

  • Testy golden potrzebują DeterministicSettings. Bez przypiętego znacznika czasu i fileIdSeed CreationDate, ModDate oraz identyfikator pliku zwiastuna zmieniają się przy każdym uruchomieniu i asercja bajtowa nigdy nie przechodzi.
  • fileIdSeed to dokładnie 32 znaki szesnastkowe. Każda inna długość lub znak nieszesnastkowy rzuca InvalidConfigException przy konstrukcji.
  • Czcionki są częścią bajtów. Inna wersja czcionki na runnerze ponownie podzbioruje glify i psuje test golden. Włącz czcionkę do repo lub użyj Base 14.
  • Core nie dostarcza extractText(). Odzysk tekstu dla asercji to praca konsumenta: użyj pdftotext lub sidecara Spectrum modułu Inspect. Zadaniem producenta jest wyemitowanie poprawnego CMap /ToUnicode (automatyczne dla osadzonych czcionek), tak by ekstraktory odzyskiwały prawdziwy Unicode; enableTaggedPdf() dodaje na wierzchu drzewo struktury, ale to nie ono wytwarza CMap.
  • Głębokość Quick modułu Inspect ma czysto PHP-owy fallback w procesie, gdy nie ma sidecara (ograniczona dokładność — flaguje INSPECT-FALLBACK-001); Standard i Full zawsze wymagają sidecara. Dla CI bez sidecara fallback Quick daje liczbę stron, wersję i flagę szyfrowania — traktuj jego wyniki jako przybliżone i opieraj się na wyodrębnionym tekście dla poprawności treści.
  • Regeneruj golden-y świadomie. Gdy zmiana jest zamierzona, usuń zrzut, uruchom ponownie, aby zapisać świeży, i przejrzyj różnicę przed zatwierdzeniem. Nigdy nie nadpisuj golden automatycznie w CI.

Oba style asercji są tanie. Porównanie golden to jedna budowa plus jedno porównanie łańcuchów znaków. Ścieżka semantyczna dodaje jedno wywołanie pdftotext poza procesem na dokument; ogranicz je do dokumentów, których tekst faktycznie sprawdzasz. Fallback PHP Quick modułu Inspect (bez sidecara) to skan bajtów w jednym przejściu, więc dodaje pomijalny czas do testu; gdy sidecar jest skonfigurowany, głębokość Quick wykonuje jeden obieg do sidecara zamiast tego.

  • Traktuj wyodrębniony tekst jako odczytywalny maszynowo: nigdy nie rób asercji, że sekret jest nieobecny w bajtach jako kontroli poufności. Tagowany tekst jest czytelny dla każdego, kto ma plik. Dla poufności szyfruj.
  • Buduj ścieżkę pliku tymczasowego dla ekstraktora za pomocą tempnam() i sprzątaj ją; nie przepuszczaj fikstur testowych przez przewidywalną współdzieloną ścieżkę.
  • Przypnij wersje narzędzi i akcji (konkretny patch PHP, taki jak 8.4.8, a nie tylko minor 8.4; poppler-utils przez dystrybucję; SHA lub tagi akcji), tak by podbicie łańcucha dostaw nie mogło po cichu zmienić twoich bajtów golden ani twojego łańcucha narzędzi.

Ten przewodnik nie formułuje normatywnego twierdzenia o standardach. Determinizm, na którym się opiera, to usunięcie dwóch niedeterministycznych pól wymienionych w ISO 32000-2 — identyfikatora pliku zwiastuna (/ID, §7.5.5) oraz pól dat informacji o dokumencie (CreationDate / ModDate, niesionych w słowniku informacji o dokumencie, odrębnym miejscu od zwiastuna) — przez DeterministicSettings. Asercje tekstowe opierają się na CMap /ToUnicode (§9.10.2), który silnik emituje dla osadzonych czcionek; enableTaggedPdf() dodaje drzewo struktury odrębnie i nie tworzy tego CMap. Każde pokazane wywołanie NextPDF to zweryfikowane publiczne API.