Ir al contenido
getnextpdf.com

Prueba los PDF generados en CI

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 DeterministicSettings para 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.

Ventana de terminal
composer require --dev phpunit/phpunit
composer require nextpdf/core:^3

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

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

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

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

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

  • Las pruebas golden necesitan DeterministicSettings. Sin una marca de tiempo y un fileIdSeed fijados, CreationDate, ModDate y el identificador de archivo del tráiler cambian en cada ejecución y la aserción de bytes nunca pasa.
  • fileIdSeed tiene exactamente 32 caracteres hexadecimales. Cualquier otra longitud o un carácter no hexadecimal lanza InvalidConfigException en 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: usa pdftotext o el sidecar de Spectrum de Inspect. El trabajo del productor es emitir un CMap /ToUnicode correcto (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.

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.

  • 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 menor 8.4; poppler-utils mediante 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.

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.