Pular para o conteúdo
getnextpdf.com

Teste PDFs gerados no CI

Esta receita é para desenvolvedores de aplicação que geram PDFs com o NextPDF e querem manter sua própria saída sob teste. É o lado consumidor da disciplina de teste do próprio mecanismo: você não retesta o NextPDF, você faz a asserção de que seu documento ainda diz o que deveria e ainda parece o que parecia.

Dois estilos de asserção cobrem quase tudo:

  • Asserções semânticas sobre o texto extraído — gere, recupere o texto Unicode e faça a asserção de que ele contém as strings que você espera. Isso sobrevive a ajustes de layout e mudanças de fonte.
  • Asserções golden (snapshot) sobre os bytes — fixe DeterministicSettings para que uma reconstrução seja byte a byte idêntica, e então compare os novos bytes contra um arquivo de referência commitado. Isso captura qualquer mudança não intencional.

Use asserções semânticas para correção de conteúdo e asserções golden como um fio de armadilha de regressão. Ambas rodam sem alterações no CI assim que o runner produzir os mesmos bytes que sua estação de trabalho produz.

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

Faça asserções sobre o texto extraído, não sobre um diff de bytes

Seção intitulada “Faça asserções sobre o texto extraído, não sobre um diff de bytes”

Um diff bruto de bytes de dois PDFs é frágil: um novo timestamp, uma fonte re-subdividida ou um objeto reordenado, todos mudam os bytes sem mudar o que um leitor vê. Faça a asserção sobre o conteúdo em vez disso.

O NextPDF Core é um produtor, então torne o texto extraível primeiro. Estes são dois mecanismos distintos, não um. A extração de texto depende de um CMap /ToUnicode correto (ISO 32000-2 §9.10.2) que mapeia códigos de glifo de volta para Unicode — o mecanismo o emite para fontes incorporadas, então os extratores recuperam caracteres reais em vez de índices de glifo brutos. O Tagged PDF é separado: enableTaggedPdf() e setLanguage() adicionam a árvore de estrutura que registra a ordem de leitura e a acessibilidade, o que não é o que cria o CMap /ToUnicode. Habilite ambos antes de escrever conteúdo: o CMap para a recuperação limpa de texto, a marcação (tagging) para a ordem de leitura. Consulte Produza conteúdo de texto extraível para os detalhes do produtor. Em seguida, recupere o texto e faça a asserção sobre ele.

Para fatos de contagem de páginas e estruturais, a profundidade Quick do módulo Inspect tem um fallback em PHP puro que roda in-process quando nenhum sidecar Spectrum está disponível — conveniente em um runner de CI, mas é um scan degradado. Ele sinaliza um problema INSPECT-FALLBACK-001 “accuracy may be limited” e deriva a contagem de páginas de um regex aproximado de /Type /Page sobre os bytes brutos, não de um parse completo da árvore de objetos. Quando um sidecar Spectrum está configurado, até a profundidade Quick o usa — InspectDepth controla quanta análise o sidecar realiza, então Quick não é inerentemente livre 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() retorna um InspectResult imutável. Para a recuperação completa de texto, execute um extrator downstream (pdftotext, ou o sidecar Inspect Spectrum na profundidade Standard) sobre os bytes e faça a asserção sobre sua saída — faça a asserção sobre o texto recuperado, nunca sobre os bytes exatos do produtor.

Torne a saída byte a byte idêntica para snapshots golden

Seção intitulada “Torne a saída byte a byte idêntica para snapshots golden”

Um teste golden só funciona se uma reconstrução produzir os mesmos bytes. O PDF tem duas fontes embutidas de não determinismo: os campos de data (CreationDate / ModDate) e o identificador de arquivo no trailer (ISO 32000-2 §7.5.5). O NextPDF remove ambos por meio de DeterministicSettings, um valor de configuração de primeira classe — não um truque de teste.

DeterministicSettings recebe um DateTimeImmutable fixo e um fileIdSeed hexadecimal de 32 caracteres. Passe-o no Config, e então construa seu documento a partir dessa configuração. Com o perfil determinístico fixado (timestamp fixo e /ID), a mesma entrada produz saída byte a byte idêntica entre execuções no mesmo toolchain fixado — o patch do PHP, as versões da extensão e da biblioteca de compressão, e os arquivos de fonte, todos mantidos constantes. Entre máquinas que diferem em qualquer um desses, os bytes ainda podem divergir; prefira as asserções de extração de texto ali e reserve o snapshot golden para um ambiente fixo e fixado.

<?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();
}

O fileIdSeed deve ser exatamente 32 caracteres hexadecimais, ou o construtor lança InvalidConfigException. Se você já tem um Config, pode derivar uma cópia determinística com $config->withDeterministic($settings) em vez de reconstruí-lo.

Um teste PHPUnit para os dois estilos de asserção

Seção intitulada “Um teste PHPUnit para os dois estilos de asserção”

Esta classe de teste exercita uma asserção semântica e uma asserção golden contra o mesmo builder. O arquivo golden é gerado uma vez, revisado por um humano e commitado; depois disso o teste falha em qualquer mudança 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);
}
}
}

A asserção de bytes só é significativa porque buildInvoice() fixa DeterministicSettings. Sem isso, só o CreationDate reprovaria o teste golden em toda execução.

Fixe as fontes para que o CI produza os mesmos bytes

Seção intitulada “Fixe as fontes para que o CI produza os mesmos bytes”

A saída byte a byte idêntica depende de os mesmos bytes de fonte serem subdivididos (subset) em toda máquina. Uma fonte que resolve de forma diferente no runner do que na sua estação de trabalho muda o subset incorporado e quebra o teste golden — mesmo com DeterministicSettings fixado.

Duas regras mantêm as fontes estáveis:

  • Use as fontes padrão Base 14 (por exemplo helvetica) para testes golden onde você não precisa de um tipo específico. Elas evitam incorporar bytes de fonte personalizados — elas se apoiam em métricas embutidas estáveis, embora a aparência exata renderizada ainda possa depender da substituição de fontes do visualizador.
  • Inclua qualquer fonte personalizada no repositório (vendor) e aponte o NextPDF para ela explicitamente, em vez de se apoiar em um caminho de fonte do sistema que difere entre máquinas. Defina Config(fontsDirectory: ...) ou chame addFontDirectory() com o diretório commitado:
<?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

Não instale fontes a partir do gerenciador de pacotes do SO para testes golden: os pacotes de fontes da distribuição diferem em versão e hinting, então uma atualização do runner muda silenciosamente seus bytes. Um diretório de fontes incluído no repositório (vendored) remove essa variável.

Este workflow instala o PHP com as extensões que o NextPDF precisa, instala um extrator de texto para as asserções semânticas e executa o PHPUnit. A linha php-version: "8.4" fixa a versão minor do PHP (8.4), não o patch — o setup-php a resolve para o 8.4.x mais recente disponível. Para reprodutibilidade em nível de bytes, fixe um patch concreto que você suporta (por exemplo php-version: "8.4.8") para que uma atualização da imagem do runner não possa deslocar o build do PHP sob seus 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

O poppler-utils fornece o pdftotext para as asserções de texto. A lista de extensões corresponde ao que o NextPDF Core exige de forma rígida: curl, gd, intl, mbstring, openssl e zlib cobrem rede, manipulação de imagem raster, texto e collation internacionalizados, texto multibyte, criptografia para encriptação/assinatura e compressão de stream. Instale todas elas — o composer.json do Core exige cada uma, então uma extensão ausente reprova composer install, não apenas um único recurso. Se um passo de asserção posterior analisar saída HTML ou XML, adicione dom para esse passo; ele não é um requisito do Core. Como as fontes estão incluídas no repositório (vendored), nenhuma instalação de pacote de fontes é necessária — isso é o que mantém os bytes do runner iguais aos seus.

  • Testes golden precisam de DeterministicSettings. Sem um timestamp e fileIdSeed fixados, CreationDate, ModDate e o identificador de arquivo do trailer mudam a cada execução e a asserção de bytes nunca passa.
  • fileIdSeed tem exatamente 32 caracteres hexadecimais. Qualquer outro comprimento ou um caractere não hexadecimal lança InvalidConfigException na construção.
  • As fontes são parte dos bytes. Uma versão de fonte diferente no runner re-subdivide os glifos e reprova o teste golden. Inclua a fonte no repositório (vendor) ou use Base 14.
  • O Core não traz nenhum extractText(). A recuperação de texto para asserções é trabalho do consumidor: use pdftotext ou o sidecar Inspect Spectrum. O trabalho do produtor é emitir um CMap /ToUnicode correto (automático para fontes incorporadas) para que os extratores recuperem Unicode real; enableTaggedPdf() adiciona a árvore de estrutura por cima, mas não é o que produz o CMap.
  • A profundidade Quick do Inspect tem um fallback em PHP puro in-process quando nenhum sidecar está presente (precisão limitada — sinaliza INSPECT-FALLBACK-001); Standard e Full sempre exigem o sidecar. Para o CI sem um sidecar, o fallback Quick fornece contagem de páginas, versão e a flag de encriptação — trate seus resultados como aproximados e apoie-se no texto extraído para a correção de conteúdo.
  • Regenere os goldens deliberadamente. Quando uma mudança é intencional, apague o snapshot, execute novamente para escrever um novo e revise o diff antes de commitar. Nunca sobrescreva automaticamente um golden no CI.

Ambos os estilos de asserção são baratos. Uma comparação golden é um build mais uma comparação de string. O caminho semântico adiciona uma chamada pdftotext fora de processo por documento; mantenha-as nos documentos cujo texto você de fato afirma. O fallback em PHP do Inspect Quick (sem sidecar) é um scan de passagem única dos bytes, então adiciona tempo negligenciável a um teste; quando um sidecar está configurado, a profundidade Quick faz um round-trip de sidecar em vez disso.

  • Trate o texto extraído como legível por máquina: nunca afirme que um segredo está ausente dos bytes como um controle de confidencialidade. O texto com tags é legível por qualquer um que tenha o arquivo. Para confidencialidade, criptografe.
  • Construa o caminho do arquivo temporário para o extrator com tempnam() e faça a limpeza; não passe fixtures de teste por um caminho compartilhado previsível.
  • Fixe as versões de ferramentas e actions (um patch de PHP concreto como 8.4.8, não apenas a minor 8.4; poppler-utils via a distribuição; SHAs ou tags de action) para que um aumento de cadeia de suprimentos não possa mudar silenciosamente seus bytes golden ou seu toolchain.

Este guia não faz nenhuma declaração normativa de padrões. O determinismo em que ele se apoia é a remoção dos dois campos não determinísticos nomeados na ISO 32000-2 — o identificador de arquivo do trailer (/ID, §7.5.5) e os campos de data da informação do documento (CreationDate / ModDate, carregados no dicionário de informações do documento, um local separado do trailer) — por meio de DeterministicSettings. As asserções de texto se apoiam no CMap /ToUnicode (§9.10.2) que o mecanismo emite para fontes incorporadas; enableTaggedPdf() adiciona a árvore de estrutura separadamente e não cria esse CMap. Toda chamada do NextPDF mostrada é API pública verificada.