跳转到内容
getnextpdf.com

在 CI 中测试生成的 PDF

这份示例面向那些用 NextPDF 生成 PDF、并希望让自己自身的输出处于测试之下的应用开发者。它是引擎自身测试纪律的消费者侧:你不重新测试 NextPDF,你断言你的文档仍然表达了它应表达的内容,并且看起来仍与从前一致。

两种断言风格几乎覆盖了一切:

  • 提取出的文本语义断言——生成、恢复出 Unicode 文本,并断言它包含你预期的字符串。这能在布局微调和字体变更后存活。
  • 字节黄金(快照)断言——固定 DeterministicSettings,使一次重建字节一致,然后把新字节与一个已提交的参考文件比较。这能捕获任何非预期的变更。

用语义断言保障内容正确性,用黄金断言作为回归绊线。一旦运行器产出与你工作站相同的字节,两者都能在 CI 中原样运行。

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

对提取出的文本断言,而非对字节差异断言

标题为“对提取出的文本断言,而非对字节差异断言”的章节

两份 PDF 的原始字节差异是脆弱的:一个新时间戳、一次重新子集化的字体,或一个被重排的对象,都会在读者所见不变的情况下改变字节。请改为对内容断言。

NextPDF Core 是一个生产者,因此请先让文本可提取。这是两种截然不同的机制,并非一种。文本提取依赖一个正确的 /ToUnicode CMap(ISO 32000-2 §9.10.2),它把字形码映射回 Unicode——引擎会为嵌入字体发出它,因此提取器恢复出的是真实字符,而不是原始字形索引。Tagged PDF 则是另一回事:enableTaggedPdf()setLanguage() 添加记录阅读顺序与无障碍性的结构树,而这并不是创建 /ToUnicode CMap 的东西。在写入内容之前把两者都启用:CMap 用于干净的文本恢复,标记用于阅读顺序。生产者侧的细节参见 产出可提取的文本内容。然后恢复出文本并对它断言。

对于页数与结构性事实,Inspect 模块的 Quick 深度有一个纯 PHP 回退,当没有可用的 Spectrum 旁车(sidecar)时它会在进程内运行——在 CI 运行器上很方便,但它是一次降级的扫描。它会标记一个 INSPECT-FALLBACK-001“accuracy may be limited”问题,并从一个对原始字节做的粗略 /Type /Page 正则中推导页数,而不是一次完整的对象树解析。当一个 Spectrum 旁车确实已配置时,即便是 Quick 深度也会使用它——InspectDepth 控制旁车执行多少分析,因此 Quick 并非内在地无旁车。

<?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,或在 Standard 深度下的 Inspect Spectrum 旁车)并对它的输出断言——对恢复出的文本断言,绝不对生产者的精确字节断言。

只有当一次重建产出相同的字节时,黄金测试才奏效。PDF 有两个内建的非确定性来源:日期字段(CreationDate / ModDate)以及 trailer 中的文件标识符(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) 派生出一个确定性副本,而不必重建它。

一个覆盖两种断言风格的 PHPUnit 测试

标题为“一个覆盖两种断言风格的 PHPUnit 测试”的章节

这个测试类针对同一个构建器演练一个语义断言和一个黄金断言。黄金文件生成一次、由人类审阅并提交;此后该测试会在任何字节变更时失败。

<?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 就会让黄金测试每次运行都失败。

字节一致的输出取决于每台机器上子集化的字体字节相同。一个在运行器上解析方式与你工作站不同的字体,会改变嵌入子集并破坏黄金测试——即便 DeterministicSettings 已固定。

两条规则让字体保持稳定:

  • 使用 Base 14 标准字体(例如 helvetica)来做那些你不需要特定字样的黄金测试。它们避免嵌入自定义字体字节——它们依赖稳定的内建度量,不过确切的渲染外观仍可能取决于查看器的字体替换。
  • 把任何自定义字体随仓库一同入库(vendor),并显式地把 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

不要为黄金测试从 OS 包管理器安装字体:发行版字体包在版本与 hinting 上各不相同,因此一次运行器升级会悄无声息地改变你的字节。一个入库的字体目录消除了那个变量。

这个工作流安装带有 NextPDF 所需扩展的 PHP、为语义断言安装一个文本提取器,并运行 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 硬性要求的相符:curlgdintlmbstringopensslzlib 涵盖网络、栅格图像处理、国际化文本与排序、多字节文本、用于加密/签名的密码学,以及流压缩。请把它们全部安装——Core 的 composer.json 要求每一个,因此缺一个扩展会让 composer install 失败,而不只是某个单一功能失败。如果后续某个断言步骤解析 HTML 或 XML 输出,请为那个步骤添加 dom;它不是一个 Core 要求。由于字体已随仓库入库,不需要安装任何字体包——这正是让运行器的字节与你相等的原因。

  • 黄金测试需要 DeterministicSettings 没有固定的时间戳与 fileIdSeedCreationDateModDate 与 trailer 文件标识符就会每次运行都变化,字节断言永远不会通过。
  • fileIdSeed 恰好是 32 个十六进制字符。 任何其他长度或一个非十六进制字符都会在构造时抛出 InvalidConfigException
  • 字体是字节的一部分。 运行器上一个不同的字体版本会重新子集化字形并使黄金测试失败。请入库字体或使用 Base 14。
  • Core 不出货 extractText() 用于断言的文本恢复是消费者的工作:使用 pdftotext 或 Inspect Spectrum 旁车。生产者的任务是发出一个正确的 /ToUnicode CMap(对嵌入字体自动完成),使提取器恢复出真实 Unicode;enableTaggedPdf() 在其之上添加结构树,但它并不是产出该 CMap 的东西。
  • 当没有旁车时,Inspect Quick 深度有一个进程内 PHP 回退(准确性有限——会标记 INSPECT-FALLBACK-001);Standard 与 Full 始终需要旁车。 对于没有旁车的 CI,Quick 回退给出页数、版本与加密标志——请把它的结果视为近似值,并依靠提取出的文本来保障内容正确性。
  • 刻意地重新生成黄金文件。 当一次变更是预期的,请删除快照、重新运行以写出一个新的,并在提交前审阅差异。绝不要在 CI 中自动覆盖一个黄金文件。

两种断言风格都很廉价。一次黄金比较是一次构建加一次字符串比较。语义路径为每份文档增加一次进程外的 pdftotext 调用;请把这些调用限于那些你确实对其文本断言的文档。Inspect Quick 的 PHP 回退(无旁车)是对字节的单遍扫描,因此它给测试增加的时间可以忽略;当配置了旁车时,Quick 深度改为做一次旁车往返。

  • 把提取出的文本视为机器可读:绝不要把“某个秘密在字节中缺失”当作一项机密性控制来断言。被标记的文本对任何持有该文件的人都可读。要保密,请加密。
  • tempnam() 为提取器构建临时文件路径并清理它;不要让测试夹具经由一个可预测的共享路径传递。
  • 固定工具与 action 版本(一个具体的 PHP 补丁版本如 8.4.8,而不只是 8.4 次版本;通过发行版安装 poppler-utils;action 的 SHA 或 tag),使一次供应链版本变动无法悄无声息地改变你的黄金字节或你的工具链。

本指南不提出任何规范性标准主张。它所依赖的确定性,是通过 DeterministicSettings 移除 ISO 32000-2 中点名的那两个非确定性字段——trailer 文件标识符(/ID,§7.5.5)以及文档信息日期字段(CreationDate / ModDate,承载于文档信息字典中,一个与 trailer 相分离的位置)。文本断言依赖引擎为嵌入字体发出的 /ToUnicode CMap(§9.10.2);enableTaggedPdf() 单独添加结构树,并不创建该 CMap。所示的每个 NextPDF 调用都是已验证的公共 API。