Testowanie wygenerowanych PDF-ów w CI
W skrócie
Dział zatytułowany „W skrócie”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.
Instalacja
Dział zatytułowany „Instalacja”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Asercja 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; // boolInspector::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.
Test PHPUnit dla obu stylów asercji
Dział zatytułowany „Test PHPUnit dla obu stylów asercji”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.
Przypnij czcionki, aby CI wytwarzało te same bajty
Dział zatytułowany „Przypnij czcionki, aby CI wytwarzało te same bajty”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łajaddFontDirectory()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 repoNie 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ą.
Workflow GitHub Actions
Dział zatytułowany „Workflow GitHub Actions”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=pdfpoppler-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.
Przypadki brzegowe i pułapki
Dział zatytułowany „Przypadki brzegowe i pułapki”- Testy golden potrzebują
DeterministicSettings. Bez przypiętego znacznika czasu ifileIdSeedCreationDate,ModDateoraz identyfikator pliku zwiastuna zmieniają się przy każdym uruchomieniu i asercja bajtowa nigdy nie przechodzi. fileIdSeedto dokładnie 32 znaki szesnastkowe. Każda inna długość lub znak nieszesnastkowy rzucaInvalidConfigExceptionprzy 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żyjpdftotextlub 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.
Wydajność
Dział zatytułowany „Wydajność”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.
Uwagi dotyczące bezpieczeństwa
Dział zatytułowany „Uwagi dotyczące bezpieczeństwa”- 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 minor8.4;poppler-utilsprzez 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.
Zgodność
Dział zatytułowany „Zgodność”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.