Testare i PDF generati in CI
In sintesi
Sezione intitolata “In sintesi”Questa ricetta è per gli sviluppatori di applicazioni che generano PDF con NextPDF e vogliono mantenere il proprio output sotto test. È il lato consumatore della disciplina di test del motore stesso: non si ri-testa NextPDF, si asserisce che il proprio documento dice ancora ciò che dovrebbe e ha ancora l’aspetto che aveva.
Due stili di asserzione coprono quasi tutto:
- Asserzioni semantiche sul testo estratto — generare, recuperare il testo Unicode e asserire che contiene le stringhe attese. Questo sopravvive a ritocchi di layout e cambi di font.
- Asserzioni golden (snapshot) sui byte — fissare
DeterministicSettingscosì che una ricostruzione sia byte-identica, poi confrontare i nuovi byte con un file di riferimento committato. Questo coglie qualsiasi modifica involontaria.
Usare le asserzioni semantiche per la correttezza del contenuto e le asserzioni golden come tripwire di regressione. Entrambe girano invariate in CI una volta che il runner produce gli stessi byte della propria workstation.
Installazione
Sezione intitolata “Installazione”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Asserire sul testo estratto, non su un diff di byte
Sezione intitolata “Asserire sul testo estratto, non su un diff di byte”Un diff di byte grezzo di due PDF è fragile: un nuovo timestamp, un font ri-subset o un oggetto riordinato cambiano tutti i byte senza cambiare ciò che un lettore vede. Asserire sul contenuto invece.
NextPDF Core è un produttore, quindi rendere prima il testo estraibile. Questi sono
due meccanismi distinti, non uno. L’estrazione del testo si basa su una CMap
/ToUnicode corretta (ISO 32000-2 §9.10.2) che mappa i codici dei glifi di nuovo
su Unicode — il motore la emette per i font incorporati, così che gli estrattori
recuperino caratteri reali anziché indici di glifo grezzi. Il Tagged PDF è
separato: enableTaggedPdf() e setLanguage() aggiungono l’albero della struttura
che registra l’ordine di lettura e l’accessibilità, che non è ciò che crea la CMap
/ToUnicode. Abilitare entrambi prima di scrivere il contenuto: la CMap per un
recupero pulito del testo, il tagging per l’ordine di lettura. Vedere
Produrre contenuto testuale estraibile
per i dettagli del produttore. Poi recuperare il testo e asserire su di esso.
Per il conteggio delle pagine e i fatti strutturali, la profondità Quick del
modulo Inspect ha un fallback pure-PHP che gira in-process quando nessun sidecar
Spectrum è disponibile — comodo su un runner CI, ma è una scansione degradata.
Segnala un problema INSPECT-FALLBACK-001 «accuracy may be limited» e deriva il
conteggio delle pagine da una regex grezza /Type /Page sui byte grezzi, non da
un parsing completo dell’albero degli oggetti. Quando un sidecar Spectrum è
configurato, anche la profondità Quick lo usa — InspectDepth controlla quanta
analisi il sidecar esegue, quindi Quick non è intrinsecamente privo di sidecar.
<?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() restituisce un InspectResult immutabile. Per il recupero
completo del testo, eseguire un estrattore a valle (pdftotext, o il sidecar
Inspect Spectrum a profondità Standard) sui byte e asserire sul suo output —
asserire sul testo recuperato, mai sui byte esatti del produttore.
Rendere l’output byte-identico per gli snapshot golden
Sezione intitolata “Rendere l’output byte-identico per gli snapshot golden”Un test golden funziona solo se una ricostruzione produce gli stessi byte. Il PDF
ha due sorgenti integrate di non determinismo: i campi data (CreationDate /
ModDate) e l’identificatore di file nel trailer (ISO 32000-2 §7.5.5). NextPDF
rimuove entrambi tramite DeterministicSettings, un valore di configurazione di
prima classe — non un trucco da test.
DeterministicSettings accetta un DateTimeImmutable fisso e un fileIdSeed
esadecimale di 32 caratteri. Passarlo sul Config, poi costruire il proprio
documento da quella config. Con il profilo deterministico fissato (timestamp fisso
e /ID), lo stesso input produce un output byte-identico tra le esecuzioni sulla
stessa toolchain fissata — la patch PHP, le versioni dell’estensione e della
libreria di compressione e i file di font tutti tenuti costanti. Tra macchine che
differiscono in uno qualsiasi di questi, i byte possono comunque divergere;
preferire lì le asserzioni di estrazione del testo e riservare lo snapshot golden
a un ambiente fisso e bloccato.
<?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();}Il fileIdSeed deve essere esattamente 32 caratteri esadecimali, o il costruttore
solleva InvalidConfigException. Se si detiene già un Config, si può derivare
una copia deterministica con $config->withDeterministic($settings) anziché
ricostruirlo.
Un test PHPUnit per entrambi gli stili di asserzione
Sezione intitolata “Un test PHPUnit per entrambi gli stili di asserzione”Questa classe di test esercita un’asserzione semantica e un’asserzione golden contro lo stesso builder. Il file golden viene generato una volta, revisionato da un umano e committato; dopodiché il test fallisce su qualsiasi modifica di byte.
<?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); } }}L’asserzione di byte è significativa solo perché buildInvoice() fissa
DeterministicSettings. Senza di essa, CreationDate da solo farebbe fallire il
test golden a ogni esecuzione.
Fissare i font così che CI produca gli stessi byte
Sezione intitolata “Fissare i font così che CI produca gli stessi byte”L’output byte-identico dipende dal fatto che gli stessi byte di font vengano
subset su ogni macchina. Un font che si risolve diversamente sul runner rispetto
alla propria workstation cambia il subset incorporato e rompe il test golden —
anche con DeterministicSettings fissato.
Due regole mantengono stabili i font:
- Usare i font standard Base 14 (per esempio
helvetica) per i test golden dove non serve un carattere specifico. Evitano di incorporare byte di font personalizzati — si basano su metriche integrate stabili, anche se l’aspetto esatto del rendering può comunque dipendere dalla sostituzione font del visualizzatore. - Versionare qualsiasi font personalizzato nel repository e puntare NextPDF ad
esso esplicitamente, anziché basarsi su un percorso di font di sistema che
differisce tra le macchine. Impostare
Config(fontsDirectory: ...)o chiamareaddFontDirectory()con la directory committata:
<?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 repoNon installare font dal package manager del sistema operativo per i test golden: i pacchetti di font delle distribuzioni differiscono in versione e hinting, quindi un upgrade del runner cambia silenziosamente i propri byte. Una directory di font versionata rimuove quella variabile.
Workflow GitHub Actions
Sezione intitolata “Workflow GitHub Actions”Questo workflow installa PHP con le estensioni di cui NextPDF ha bisogno, installa
un estrattore di testo per le asserzioni semantiche ed esegue PHPUnit. La riga
php-version: "8.4" fissa la versione minor di PHP (8.4), non la patch —
setup-php la risolve all’ultima 8.4.x disponibile. Per la riproducibilità a
livello di byte, fissare una patch concreta che si supporta (per esempio
php-version: "8.4.8") così che un upgrade dell’immagine del runner non possa
spostare la build PHP sotto i propri snapshot 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 fornisce pdftotext per le asserzioni di testo. L’elenco delle
estensioni corrisponde a ciò che NextPDF Core richiede in modo rigido: curl,
gd, intl, mbstring, openssl e zlib coprono il networking, la gestione
delle immagini raster, il testo internazionalizzato e la collation, il testo
multibyte, la crittografia per cifratura/firma e la compressione degli stream.
Installarle tutte — il composer.json di Core le richiede ognuna, quindi
un’estensione mancante fa fallire composer install, non solo una singola
funzionalità. Se un passaggio di asserzione successivo analizza output HTML o XML,
aggiungere dom per quel passaggio; non è un requisito di Core. Poiché i font
sono versionati nel repository, non è necessaria alcuna installazione di pacchetti
di font — è questo che mantiene i byte del runner uguali ai propri.
Casi limite e insidie
Sezione intitolata “Casi limite e insidie”- I test golden necessitano di
DeterministicSettings. Senza un timestamp fissato e unfileIdSeed,CreationDate,ModDatee l’identificatore di file nel trailer cambiano a ogni esecuzione e l’asserzione di byte non passa mai. fileIdSeedè esattamente 32 caratteri esadecimali. Qualsiasi altra lunghezza o un carattere non esadecimale sollevaInvalidConfigExceptionalla costruzione.- I font fanno parte dei byte. Una versione di font diversa sul runner ri-fa il subset dei glifi e fa fallire il test golden. Versionare il font o usare Base 14.
- Core non include alcun
extractText(). Il recupero del testo per le asserzioni è lavoro del consumatore: usarepdftotexto il sidecar Inspect Spectrum. Il compito del produttore è emettere una CMap/ToUnicodecorretta (automatica per i font incorporati) così che gli estrattori recuperino Unicode reale;enableTaggedPdf()aggiunge l’albero della struttura in cima, ma non è ciò che produce la CMap. - La profondità Quick di Inspect ha un fallback pure-PHP in-process quando
nessun sidecar è presente (accuratezza limitata — segnala
INSPECT-FALLBACK-001); Standard e Full richiedono sempre il sidecar. Per CI senza un sidecar, il fallback Quick fornisce conteggio delle pagine, versione e flag di cifratura — trattare i suoi risultati come approssimati e basarsi sul testo estratto per la correttezza del contenuto. - Rigenerare i golden deliberatamente. Quando una modifica è intenzionale, eliminare lo snapshot, rieseguire per scriverne uno nuovo e revisionare il diff prima del commit. Non sovrascrivere mai automaticamente un golden in CI.
Prestazioni
Sezione intitolata “Prestazioni”Entrambi gli stili di asserzione sono economici. Un confronto golden è una build
più un confronto di stringhe. Il percorso semantico aggiunge una chiamata
out-of-process pdftotext per documento; tenerle ai documenti di cui si asserisce
effettivamente il testo. Il fallback PHP Quick di Inspect (nessun sidecar) è una
scansione a passaggio singolo dei byte, quindi aggiunge tempo trascurabile a un
test; quando un sidecar è configurato, la profondità Quick fa invece un round-trip
al sidecar.
Note di sicurezza
Sezione intitolata “Note di sicurezza”- Trattare il testo estratto come leggibile dalla macchina: non asserire mai che un segreto sia assente dai byte come controllo di riservatezza. Il testo taggato è leggibile da chiunque abbia il file. Per la riservatezza, cifrare.
- Costruire il percorso del file temporaneo per l’estrattore con
tempnam()e ripulirlo; non passare le fixture di test attraverso un percorso condiviso prevedibile. - Fissare le versioni di strumenti e action (una patch PHP concreta come
8.4.8, non solo la minor8.4;poppler-utilstramite la distribuzione; SHA o tag delle action) così che un bump della supply chain non possa cambiare silenziosamente i propri byte golden o la propria toolchain.
Conformità
Sezione intitolata “Conformità”Questa guida non avanza alcuna pretesa normativa rispetto agli standard. Il
determinismo su cui si basa è la rimozione dei due campi non deterministici
nominati in ISO 32000-2 — l’identificatore di file nel trailer (/ID, §7.5.5) e i
campi data dell’informazione del documento (CreationDate / ModDate, portati nel
dizionario informativo del documento, una posizione separata dal trailer) —
tramite DeterministicSettings. Le asserzioni di testo si basano sulla CMap
/ToUnicode (§9.10.2) che il motore emette per i font incorporati;
enableTaggedPdf() aggiunge l’albero della struttura separatamente e non crea
quella CMap. Ogni chiamata NextPDF mostrata è API pubblica verificata.