Перейти к содержимому
getnextpdf.com

Тестирование сгенерированных PDF в CI

Этот рецепт для разработчиков приложений, которые генерируют PDF с помощью NextPDF и хотят держать свой собственный вывод под тестом. Это потребительская сторона собственной тестовой дисциплины движка: вы не перетестируете NextPDF, вы утверждаете, что ваш документ по-прежнему говорит то, что должен, и по-прежнему выглядит так, как выглядел.

Два стиля утверждений покрывают почти всё:

  • Семантические утверждения по извлечённому тексту — сгенерировать, восстановить текст Unicode и утверждать, что он содержит ожидаемые вами строки. Это переживает правки макета и смену шрифтов.
  • Эталонные (snapshot) утверждения по байтам — зафиксировать DeterministicSettings, чтобы пересборка была байт-идентичной, затем сравнить новые байты с закоммиченным эталонным файлом. Это ловит любое непреднамеренное изменение.

Используйте семантические утверждения для корректности содержимого и эталонные утверждения как растяжку-сигнализацию регрессий. Оба работают без изменений в CI, как только раннер производит те же байты, что и ваша рабочая станция.

Окно терминала
composer require --dev phpunit/phpunit
composer require nextpdf/core:^3

Утверждайте по извлечённому тексту, а не по диффу байтов

Заголовок раздела «Утверждайте по извлечённому тексту, а не по диффу байтов»

Сырой дифф байтов двух PDF хрупок: новая отметка времени, пере-субсеттированный шрифт или переупорядоченный объект — все они меняют байты, не меняя того, что видит читатель. Вместо этого утверждайте по содержимому.

NextPDF Core — это производитель, поэтому сначала сделайте текст извлекаемым. Это два различных механизма, а не один. Извлечение текста опирается на корректную CMap /ToUnicode (ISO 32000-2 §9.10.2), которая отображает коды глифов обратно в Unicode — движок выдаёт её для встроенных шрифтов, поэтому экстракторы восстанавливают реальные символы, а не сырые индексы глифов. Теговый PDF — это отдельное: enableTaggedPdf() и setLanguage() добавляют дерево структуры, которое записывает порядок чтения и доступность, что не есть то, что создаёт CMap /ToUnicode. Включите оба перед тем, как писать содержимое: CMap для чистого восстановления текста, теги для порядка чтения. См. Производство извлекаемого текстового содержимого для деталей производителя. Затем восстановите текст и утверждайте по нему.

Для фактов о числе страниц и структуре глубина Quick модуля Inspect имеет чисто-PHP запасной вариант, который работает внутри процесса, когда sidecar Spectrum недоступен — удобно на раннере CI, но это деградированное сканирование. Оно поднимает проблему INSPECT-FALLBACK-001 “accuracy may be limited” и выводит число страниц из грубого regex /Type /Page по сырым байтам, а не из полного разбора дерева объектов. Когда sidecar Spectrum настроен, даже глубина Quick использует его — InspectDepth управляет тем, сколько анализа выполняет sidecar, поэтому Quick не является изначально свободной от 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() возвращает неизменяемый InspectResult. Для полного восстановления текста запустите нижестоящий экстрактор (pdftotext или sidecar Inspect Spectrum на глубине Standard) по байтам и утверждайте по его выводу — утверждайте по восстановленному тексту, никогда по точным байтам производителя.

Сделайте вывод байт-идентичным для эталонных снимков

Заголовок раздела «Сделайте вывод байт-идентичным для эталонных снимков»

Эталонный тест работает только если пересборка производит те же байты. У PDF два встроенных источника недетерминизма: поля дат (CreationDate / ModDate) и идентификатор файла в трейлере (ISO 32000-2 §7.5.5). NextPDF убирает оба через DeterministicSettings, первоклассное значение конфигурации — не тестовый хак.

DeterministicSettings принимает фиксированный DateTimeImmutable и 32-символьный шестнадцатеричный fileIdSeed. Передайте его на Config, затем стройте документ из этой конфигурации. С закреплённым детерминированным профилем (фиксированная отметка времени и /ID), тот же вход даёт байт-идентичный вывод между запусками на той же закреплённой цепочке инструментов — патч PHP, версии расширения и библиотеки сжатия, а также файлы шрифтов держатся постоянными. На машинах, различающихся в любом из этих пунктов, байты всё ещё могут расходиться; там предпочитайте утверждения по извлечению текста и резервируйте эталонный снимок для фиксированного, закреплённого окружения.

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

fileIdSeed должен быть ровно 32 шестнадцатеричными символами, иначе конструктор бросает InvalidConfigException. Если у вас уже есть Config, вы можете вывести детерминированную копию через $config->withDeterministic($settings) вместо пересборки.

Этот тестовый класс задействует семантическое утверждение и эталонное утверждение против одного и того же сборщика. Эталонный файл генерируется один раз, проверяется человеком и коммитится; после этого тест падает при любом изменении байтов.

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

Утверждение по байтам осмысленно только потому, что buildInvoice() закрепляет DeterministicSettings. Без него один только CreationDate провалил бы эталонный тест при каждом запуске.

Закрепите шрифты, чтобы CI производил те же байты

Заголовок раздела «Закрепите шрифты, чтобы CI производил те же байты»

Байт-идентичный вывод зависит от того, что на каждой машине субсеттируются те же байты шрифта. Шрифт, который разрешается на раннере иначе, чем на вашей рабочей станции, меняет встроенное подмножество и ломает эталонный тест — даже с закреплёнными DeterministicSettings.

Два правила держат шрифты стабильными:

  • Используйте стандартные шрифты Base 14 (например, helvetica) для эталонных тестов, где вам не нужно конкретное начертание. Они избегают встраивания байтов кастомного шрифта — они опираются на стабильные встроенные метрики, хотя точный отрисованный вид всё ещё может зависеть от подстановки шрифтов в средстве просмотра.
  • Включите любой кастомный шрифт в репозиторий и явно укажите NextPDF на него, а не полагайтесь на путь системного шрифта, который различается между машинами. Задайте Config(fontsDirectory: ...) или вызовите addFontDirectory() с закоммиченным каталогом:
<?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

Не устанавливайте шрифты из менеджера пакетов ОС для эталонных тестов: дистрибутивные пакеты шрифтов различаются версией и хинтингом, поэтому обновление раннера молча меняет ваши байты. Включённый в репозиторий каталог шрифтов убирает эту переменную.

Этот рабочий процесс устанавливает PHP с расширениями, которые нужны NextPDF, устанавливает экстрактор текста для семантических утверждений и запускает PHPUnit. Строка php-version: "8.4" закрепляет минорную версию PHP (8.4), а не патч — setup-php разрешает её в последнюю доступную 8.4.x. Для воспроизводимости на уровне байтов закрепите конкретный патч, который вы поддерживаете (например, php-version: "8.4.8"), чтобы обновление образа раннера не могло сдвинуть сборку PHP под вашими эталонными снимками.

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 предоставляет pdftotext для текстовых утверждений. Список расширений соответствует тому, что NextPDF Core жёстко требует: curl, gd, intl, mbstring, openssl и zlib покрывают сеть, обработку растровых изображений, интернационализированный текст и сопоставление, многобайтовый текст, криптографию для шифрования/подписи и сжатие потоков. Установите все они — composer.json Core требует каждое из них, поэтому отсутствующее расширение проваливает composer install, а не только одну возможность. Если более поздний шаг утверждения разбирает вывод HTML или XML, добавьте dom для этого шага; это не требование Core. Поскольку шрифты включены в репозиторий, установка пакета шрифтов не нужна — это и есть то, что держит байты раннера равными вашим.

  • Эталонным тестам нужны DeterministicSettings. Без закреплённой отметки времени и fileIdSeed, CreationDate, ModDate и идентификатор файла трейлера меняются при каждом запуске, и утверждение по байтам никогда не проходит.
  • fileIdSeed — ровно 32 шестнадцатеричных символа. Любая другая длина или нешестнадцатеричный символ бросает InvalidConfigException при конструировании.
  • Шрифты — часть байтов. Другая версия шрифта на раннере пере-субсеттирует глифы и проваливает эталонный тест. Включите шрифт в репозиторий или используйте Base 14.
  • Core не поставляет extractText(). Восстановление текста для утверждений — это потребительская работа: используйте pdftotext или sidecar Inspect Spectrum. Работа производителя — выдать корректную CMap /ToUnicode (автоматически для встроенных шрифтов), чтобы экстракторы восстанавливали реальный Unicode; enableTaggedPdf() добавляет дерево структуры сверху, но это не то, что производит CMap.
  • Глубина Inspect Quick имеет чисто-PHP запасной вариант, когда sidecar отсутствует (ограниченная точность — поднимает INSPECT-FALLBACK-001); Standard и Full всегда требуют sidecar. Для CI без sidecar запасной вариант Quick даёт число страниц, версию и флаг шифрования — относитесь к его результатам как к приблизительным и опирайтесь на извлечённый текст для корректности содержимого.
  • Перегенерируйте эталоны намеренно. Когда изменение намеренно, удалите снимок, перезапустите, чтобы записать свежий, и проверьте дифф перед коммитом. Никогда не перезаписывайте эталон автоматически в CI.

Оба стиля утверждений дёшевы. Эталонное сравнение — это одна сборка плюс сравнение строк. Семантический путь добавляет один внепроцессный вызов pdftotext на документ; держите их на документах, текст которых вы действительно утверждаете. Чисто-PHP запасной вариант Inspect Quick (без sidecar) — это однопроходное сканирование байтов, поэтому он добавляет пренебрежимо малое время к тесту; когда sidecar настроен, глубина Quick делает один круговой обход к sidecar вместо этого.

  • Относитесь к извлечённому тексту как к машиночитаемому: никогда не утверждайте, что секрет отсутствует в байтах, как к контролю конфиденциальности. Текст с тегами читаем любым, у кого есть файл. Для конфиденциальности шифруйте.
  • Стройте путь временного файла для экстрактора через tempnam() и убирайте за собой; не передавайте тестовые фикстуры через предсказуемый общий путь.
  • Закрепляйте версии инструментов и действий (конкретный патч PHP, такой как 8.4.8, а не только минор 8.4; poppler-utils через дистрибутив; SHA или теги действий), чтобы обновление цепочки поставок не могло молча изменить ваши эталонные байты или вашу цепочку инструментов.

Это руководство не делает нормативного заявления о стандартах. Детерминизм, на который оно опирается, — это удаление двух недетерминированных полей, названных в ISO 32000-2: — идентификатора файла трейлера (/ID, §7.5.5) и полей дат информации о документе (CreationDate / ModDate, размещённых в словаре информации о документе, отдельном месте от трейлера) — через DeterministicSettings. Текстовые утверждения опираются на CMap /ToUnicode (§9.10.2), которую движок выдаёт для встроенных шрифтов; enableTaggedPdf() добавляет дерево структуры отдельно и не создаёт эту CMap. Каждый показанный вызов NextPDF — проверенный публичный API.