Salta ai contenuti
getnextpdf.com

Testare i PDF generati in CI

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 DeterministicSettings così 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.

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

Asserire 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; // bool

Inspector::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 chiamare addFontDirectory() 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 repo

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

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

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

  • I test golden necessitano di DeterministicSettings. Senza un timestamp fissato e un fileIdSeed, CreationDate, ModDate e 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 solleva InvalidConfigException alla 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: usare pdftotext o il sidecar Inspect Spectrum. Il compito del produttore è emettere una CMap /ToUnicode corretta (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.

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.

  • 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 minor 8.4; poppler-utils tramite 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.

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.