Prueba los PDF generados en CI
De un vistazo
Sección titulada «De un vistazo»Esta receta es para desarrolladores de aplicaciones que generan PDF con NextPDF y quieren mantener su propia salida bajo pruebas. Es el lado del consumidor de la propia disciplina de pruebas del motor: no vuelves a probar NextPDF, sino que asercias que tu documento sigue diciendo lo que debe y sigue luciendo como lucía.
Dos estilos de aserción cubren casi todo:
- Aserciones semánticas sobre el texto extraído: genera, recupera el texto Unicode y asercia que contiene las cadenas que esperas. Esto sobrevive a los retoques de maquetación y a los cambios de fuente.
- Aserciones golden (snapshot) sobre los bytes: fija
DeterministicSettingspara que una reconstrucción sea idéntica byte a byte y luego compara los nuevos bytes contra un archivo de referencia confirmado. Esto detecta cualquier cambio no intencionado.
Usa las aserciones semánticas para la corrección del contenido y las aserciones golden como cable trampa de regresión. Ambas se ejecutan sin cambios en CI una vez que el runner produce los mismos bytes que tu estación de trabajo.
Instalación
Sección titulada «Instalación»composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Asercia sobre el texto extraído, no sobre una diferencia de bytes
Sección titulada «Asercia sobre el texto extraído, no sobre una diferencia de bytes»Una diferencia de bytes en bruto de dos PDF es frágil: una nueva marca de tiempo, una fuente vuelta a subdividir o un objeto reordenado cambian todos los bytes sin cambiar lo que ve un lector. Asercia sobre el contenido en su lugar.
NextPDF Core es un productor, así que primero haz que el texto sea extraíble. Estos
son dos mecanismos distintos, no uno. La extracción de texto se apoya en un CMap
/ToUnicode correcto (ISO 32000-2 §9.10.2) que mapea los códigos de glifo de vuelta
a Unicode: el motor lo emite para las fuentes incrustadas, de modo que los
extractores recuperan caracteres reales en lugar de índices de glifo en bruto. El
PDF etiquetado es algo aparte: enableTaggedPdf() y setLanguage() añaden el árbol
de estructura que registra el orden de lectura y la accesibilidad, que no es lo que
crea el CMap /ToUnicode. Habilita ambos antes de escribir contenido: el CMap para
una recuperación de texto limpia, el etiquetado para el orden de lectura. Consulta
Produce contenido de texto extraíble
para los detalles del productor. Después, recupera el texto y asercia sobre él.
Para el recuento de páginas y los hechos estructurales, la profundidad Quick del
módulo Inspect tiene un respaldo en PHP puro que se ejecuta en proceso cuando no
hay ningún sidecar de Spectrum disponible —cómodo en un runner de CI, pero es un
escaneo degradado. Marca un problema INSPECT-FALLBACK-001 «accuracy may be
limited» y deriva el recuento de páginas de una expresión regular tosca de
/Type /Page sobre los bytes en bruto, no de un análisis completo del árbol de
objetos. Cuando sí hay un sidecar de Spectrum configurado, incluso la profundidad
Quick lo usa: InspectDepth controla cuánto análisis realiza el sidecar, así que
Quick no es inherentemente libre de 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() devuelve un InspectResult inmutable. Para la recuperación
completa de texto, ejecuta un extractor posterior (pdftotext, o el sidecar de
Spectrum de Inspect a profundidad Standard) sobre los bytes y asercia sobre su
salida: asercia sobre el texto recuperado, nunca sobre los bytes exactos del
productor.
Haz que la salida sea idéntica byte a byte para los snapshots golden
Sección titulada «Haz que la salida sea idéntica byte a byte para los snapshots golden»Una prueba golden solo funciona si una reconstrucción produce los mismos bytes. El
PDF tiene dos fuentes integradas de no determinismo: los campos de fecha
(CreationDate / ModDate) y el identificador de archivo en el tráiler
(ISO 32000-2 §7.5.5). NextPDF elimina ambos mediante DeterministicSettings, un
valor de configuración de primera clase, no un truco de pruebas.
DeterministicSettings recibe un DateTimeImmutable fijo y un fileIdSeed
hexadecimal de 32 caracteres. Pásalo en el Config y luego construye tu documento
a partir de esa configuración. Con el perfil determinista fijado (marca de tiempo
fija y /ID), la misma entrada produce una salida idéntica byte a byte entre
ejecuciones en la misma cadena de herramientas fijada —el parche de PHP, las
versiones de la extensión y de la biblioteca de compresión, y los archivos de
fuente, todos mantenidos constantes. Entre máquinas que difieran en cualquiera de
ellos, los bytes aún pueden divergir; prefiere allí las aserciones de extracción de
texto y reserva el snapshot golden para un entorno fijo y fijado.
<?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();}El fileIdSeed debe tener exactamente 32 caracteres hexadecimales, o el
constructor lanza InvalidConfigException. Si ya tienes un Config, puedes derivar
una copia determinista con $config->withDeterministic($settings) en lugar de
reconstruirlo.
Una prueba de PHPUnit para ambos estilos de aserción
Sección titulada «Una prueba de PHPUnit para ambos estilos de aserción»Esta clase de prueba ejercita una aserción semántica y una aserción golden contra el mismo constructor. El archivo golden se genera una vez, lo revisa una persona y se confirma; después, la prueba falla ante cualquier cambio de bytes.
<?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); } }}La aserción de bytes solo tiene sentido porque buildInvoice() fija
DeterministicSettings. Sin ello, CreationDate por sí solo haría fallar la
prueba golden en cada ejecución.
Fija las fuentes para que CI produzca los mismos bytes
Sección titulada «Fija las fuentes para que CI produzca los mismos bytes»La salida idéntica byte a byte depende de que se subdividan los mismos bytes de
fuente en cada máquina. Una fuente que se resuelve de forma distinta en el runner y
en tu estación de trabajo cambia el subconjunto incrustado y rompe la prueba
golden, incluso con DeterministicSettings fijado.
Dos reglas mantienen estables las fuentes:
- Usa las fuentes estándar Base 14 (por ejemplo
helvetica) para las pruebas golden donde no necesites un tipo de letra específico. Evitan incrustar bytes de fuente personalizados —se apoyan en métricas integradas estables—, aunque la apariencia renderizada exacta aún puede depender de la sustitución de fuentes del visor. - Incorpora cualquier fuente personalizada al repositorio y apunta NextPDF a
ella explícitamente, en lugar de apoyarte en una ruta de fuentes del sistema que
difiera entre máquinas. Establece
Config(fontsDirectory: ...)o llama aaddFontDirectory()con el directorio confirmado:
<?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 repoNo instales fuentes desde el gestor de paquetes del SO para las pruebas golden: los paquetes de fuentes de la distribución difieren en versión y hinting, así que una actualización del runner cambia silenciosamente tus bytes. Un directorio de fuentes incorporado elimina esa variable.
Flujo de trabajo de GitHub Actions
Sección titulada «Flujo de trabajo de GitHub Actions»Este flujo de trabajo instala PHP con las extensiones que necesita NextPDF, instala
un extractor de texto para las aserciones semánticas y ejecuta PHPUnit. La línea
php-version: "8.4" fija la versión menor de PHP (8.4), no el parche: setup-php
lo resuelve a la última 8.4.x disponible. Para reproducibilidad a nivel de bytes,
fija un parche concreto que admitas (por ejemplo php-version: "8.4.8") para que
una actualización de la imagen del runner no pueda desplazar la compilación de PHP
bajo tus snapshots 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 proporciona pdftotext para las aserciones de texto. La lista de
extensiones coincide con lo que NextPDF Core requiere de forma estricta: curl,
gd, intl, mbstring, openssl y zlib cubren la red, el manejo de imágenes
ráster, el texto internacionalizado y la collation, el texto multibyte, la
criptografía para cifrado/firma y la compresión de flujos. Instálalas todas: el
composer.json de Core requiere cada una, así que una extensión faltante hace
fallar composer install, no solo una única función. Si un paso de aserción
posterior analiza salida HTML o XML, añade dom para ese paso; no es un requisito
de Core. Como las fuentes están incorporadas en el repositorio, no hace falta
instalar ningún paquete de fuentes: eso es lo que mantiene los bytes del runner
iguales a los tuyos.
Casos límite y trampas
Sección titulada «Casos límite y trampas»- Las pruebas golden necesitan
DeterministicSettings. Sin una marca de tiempo y unfileIdSeedfijados,CreationDate,ModDatey el identificador de archivo del tráiler cambian en cada ejecución y la aserción de bytes nunca pasa. fileIdSeedtiene exactamente 32 caracteres hexadecimales. Cualquier otra longitud o un carácter no hexadecimal lanzaInvalidConfigExceptionen la construcción.- Las fuentes son parte de los bytes. Una versión de fuente distinta en el runner vuelve a subdividir los glifos y hace fallar la prueba golden. Incorpora la fuente o usa Base 14.
- Core no incluye ningún
extractText(). La recuperación de texto para las aserciones es trabajo del consumidor: usapdftotexto el sidecar de Spectrum de Inspect. El trabajo del productor es emitir un CMap/ToUnicodecorrecto (automático para fuentes incrustadas) para que los extractores recuperen Unicode real;enableTaggedPdf()añade el árbol de estructura encima, pero no es lo que produce el CMap. - La profundidad Quick de Inspect tiene un respaldo en PHP puro en proceso cuando
no hay sidecar presente (precisión limitada: marca
INSPECT-FALLBACK-001); Standard y Full siempre requieren el sidecar. Para CI sin sidecar, el respaldo Quick da el recuento de páginas, la versión y la bandera de cifrado: trata sus resultados como aproximados y apóyate en el texto extraído para la corrección del contenido. - Regenera los golden deliberadamente. Cuando un cambio sea intencionado, borra el snapshot, vuelve a ejecutar para escribir uno nuevo y revisa la diferencia antes de confirmar. Nunca sobrescribas automáticamente un golden en CI.
Rendimiento
Sección titulada «Rendimiento»Ambos estilos de aserción son baratos. Una comparación golden es una construcción
más una comparación de cadenas. La vía semántica añade una llamada fuera de proceso
a pdftotext por documento; limítalas a los documentos cuyo texto realmente
asercias. El respaldo en PHP puro de Inspect Quick (sin sidecar) es un escaneo de
una sola pasada de los bytes, así que añade un tiempo despreciable a una prueba;
cuando hay un sidecar configurado, la profundidad Quick hace una ida y vuelta al
sidecar en su lugar.
Notas de seguridad
Sección titulada «Notas de seguridad»- Trata el texto extraído como legible por máquina: nunca asercies que un secreto está ausente de los bytes como control de confidencialidad. El texto etiquetado es legible por cualquiera que tenga el archivo. Para confidencialidad, cifra.
- Construye la ruta del archivo temporal para el extractor con
tempnam()y límpiala; no pases fixtures de prueba por una ruta compartida predecible. - Fija las versiones de herramientas y acciones (un parche de PHP concreto como
8.4.8, no solo la menor8.4;poppler-utilsmediante la distribución; SHA o tags de las acciones) para que un salto en la cadena de suministro no pueda cambiar silenciosamente tus bytes golden ni tu cadena de herramientas.
Conformidad
Sección titulada «Conformidad»Esta guía no hace ninguna afirmación normativa de estándares. El determinismo en el
que se apoya es la eliminación de los dos campos no deterministas nombrados en
ISO 32000-2 —el identificador de archivo del tráiler (/ID, §7.5.5) y los campos
de fecha de información del documento (CreationDate / ModDate, alojados en el
diccionario de información del documento, una ubicación distinta del tráiler)—
mediante DeterministicSettings. Las aserciones de texto se apoyan en el CMap
/ToUnicode (§9.10.2) que el motor emite para las fuentes incrustadas;
enableTaggedPdf() añade el árbol de estructura por separado y no crea ese CMap.
Cada llamada de NextPDF mostrada es API pública verificada.