Teste PDFs gerados no CI
Visão geral
Seção intitulada “Visão geral”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
DeterministicSettingspara 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.
Instalação
Seção intitulada “Instalação”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Faç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; // boolInspector::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 chameaddFontDirectory()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 repoNã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.
Workflow do GitHub Actions
Seção intitulada “Workflow do GitHub Actions”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=pdfO 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.
Casos extremos e armadilhas
Seção intitulada “Casos extremos e armadilhas”- Testes golden precisam de
DeterministicSettings. Sem um timestamp efileIdSeedfixados,CreationDate,ModDatee o identificador de arquivo do trailer mudam a cada execução e a asserção de bytes nunca passa. fileIdSeedtem exatamente 32 caracteres hexadecimais. Qualquer outro comprimento ou um caractere não hexadecimal lançaInvalidConfigExceptionna 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: usepdftotextou o sidecar Inspect Spectrum. O trabalho do produtor é emitir um CMap/ToUnicodecorreto (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.
Desempenho
Seção intitulada “Desempenho”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.
Notas de segurança
Seção intitulada “Notas de segurança”- 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 minor8.4;poppler-utilsvia 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.
Conformidade
Seção intitulada “Conformidade”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.