跳到內容
getnextpdf.com

在 CI 中測試產生的 PDF

這份配方是給用 NextPDF 產生 PDF、並想把自己的輸出保持在測試之下的應用程式開發者。它是引擎自身測試紀律的消費端:你不是重新測試 NextPDF,而是斷言你的文件仍然說它該說的,且看起來仍然是它原本的樣子。

兩種斷言風格幾乎涵蓋一切:

  • 語意斷言,針對 擷取出的文字——產生、復原 Unicode 文字,並斷言它含有你預期的字串。這能撐過版面微調與字型變更。
  • Golden(快照)斷言,針對 位元組——釘選 DeterministicSettings,讓重建逐位元組相同,再把新的位元組與一份已提交的參考檔比較。這能抓出任何非預期的變更。

用語意斷言來確保內容正確性,用 golden 斷言當作一個回歸 tripwire。一旦 runner 產出與你工作站相同的位元組,兩者都能在 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 depth 有一個純 PHP 備援,在沒有 Spectrum sidecar 可用時於行程內執行—— 在 CI runner 上很方便,但它是一次降級掃描。它會標記一個 INSPECT-FALLBACK-001「accuracy may be limited」議題,並從一個對原始位元組做的粗略 /Type /Page regex 推導頁數,而非一次完整的物件樹剖析。 當 Spectrum sidecar 設定時,即使是 Quick depth 也會用它——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,或在 Standard depth 的 Inspect Spectrum sidecar),並對它的輸出做斷言——對 復原出的文字 做斷言,絕不對生產者的確切位元組做斷言。

讓輸出逐位元組相同,以供 golden 快照

標題為「讓輸出逐位元組相同,以供 golden 快照」的區段

一個 golden 測試只有在重建產出相同位元組時才成立。PDF 有兩個內建的非決定性來源:日期欄位(CreationDate / ModDate)與 trailer 中的檔案識別子(ISO 32000-2 §7.5.5)。NextPDF 透過 DeterministicSettings 移除兩者,這是一個一等的設定值——不是一個測試 hack。

DeterministicSettings 接受一個固定的 DateTimeImmutable 與一個 32 字元的十六進位 fileIdSeed。把它傳在 Config 上,再從該設定建構你的文件。 在決定性設定檔被釘選後(固定時間戳記與 /ID),相同的輸入會在多次執行間產出逐位元組相同的輸出 在相同的釘選工具鏈上——PHP patch、擴充功能與壓縮函式庫版本,以及字型檔全部維持不變。跨在任一項上有差異的機器,位元組仍可能分歧;在那裡請優先用文字擷取斷言,並把 golden 快照保留給一個固定、釘選的環境。

<?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 測試」的區段

這個測試類別對同一個 builder 做一個語意斷言與一個 golden 斷言。 golden 檔會被產生一次、由人類審查並提交;之後測試就會在任何位元組變更時失敗。

<?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 就會讓 golden 測試每次執行都失敗。

逐位元組相同的輸出依賴在每台機器上對相同的字型位元組做子集化。 一個在 runner 上解析方式與你工作站不同的字型,會改變內嵌的子集並弄壞 golden 測試——即使 DeterministicSettings 已釘選。

兩條規則讓字型保持穩定:

  • 使用 Base 14 標準字型 (例如 helvetica)來做你不需要特定字體的 golden 測試。它們避免內嵌自訂字型位元組——它們依賴穩定的內建度量,不過確切的繪製外觀仍可能取決於檢視器的字型替換。
  • 把任何自訂字型 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

不要為 golden 測試從 OS 套件管理器安裝字型:發行版的字型套件在版本與 hinting 上有差異,所以一次 runner 升級會默默改變你的位元組。一個 vendor 的字型目錄移除了那個變數。

這個 workflow 安裝帶有 NextPDF 所需擴充功能的 PHP、為語意斷言安裝一個文字擷取器,並執行 PHPUnit。php-version: "8.4" 這一行釘選 PHP minor 版本(8.4),而不是 patch——setup-php 會把它解析成最新可用的 8.4.x。為了位元組層級的可重現性,請釘選一個你支援的具體 patch(例如 php-version: "8.4.8"),讓一次 runner 映像檔升級無法在你的 golden 快照底下移動 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 的要求。因為字型已在儲存庫中 vendor,不需要安裝字型套件——那正是讓 runner 的位元組等於你的位元組的原因。

  • Golden 測試需要 DeterministicSettings 沒有釘選的時間戳記與 fileIdSeedCreationDateModDate 與 trailer 檔案識別子每次執行都會變,而位元組斷言永遠不會通過。
  • fileIdSeed 剛好是 32 個十六進位字元。 任何其他長度或一個非十六進位字元都會在建構時擲出 InvalidConfigException
  • 字型是位元組的一部分。 runner 上一個不同的字型版本會重新子集化字符並讓 golden 測試失敗。Vendor 字型,或使用 Base 14。
  • Core 不隨附 extractText() 為斷言而做的文字復原是消費端的工作:使用 pdftotext 或 Inspect Spectrum sidecar。生產者的工作是發射一個正確的 /ToUnicode CMap(內嵌字型時自動),讓擷取器復原真實 Unicode;enableTaggedPdf() 在其上加結構樹,但它不是產生該 CMap 的東西。
  • Inspect Quick depth 在沒有 sidecar 時有一個行程內的 PHP 備援 (準確度有限——標記 INSPECT-FALLBACK-001);Standard 與 Full 一律需要 sidecar。 對於沒有 sidecar 的 CI,Quick 備援給出頁數、版本與加密旗標——把它的結果當成近似值,並仰賴擷取出的文字來確保內容正確性。
  • 刻意地重新產生 golden。 當一個變更是有意的,刪掉快照、重新執行以寫出一份新的,並在提交前審查差異。 絕不在 CI 中自動覆寫一個 golden。

兩種斷言風格都很便宜。一次 golden 比較是一次建構加一次字串比較。語意路徑為每份文件加上一次行程外的 pdftotext 呼叫; 把那些限制在你實際斷言其文字的文件上。Inspect Quick PHP 備援(無 sidecar)是對位元組的一次單趟掃描,所以它對一個測試只加上可忽略的時間;當 sidecar 有設定時,Quick depth 改為做一次 sidecar 往返。

  • 把擷取出的文字當成機器可讀:絕不把斷言某個祕密 不存在 於位元組中當成一個機密性控制。被標記的文字任何擁有該檔案的人都讀得到。要機密性,請加密。
  • tempnam() 為擷取器建構暫存檔路徑,並清理它;不要把測試 fixture 透過一個可預測的共享路徑傳遞。
  • 釘選工具與 action 版本(一個具體的 PHP patch,例如 8.4.8,而非只是 8.4 minor;透過發行版的 poppler-utils;action SHA 或標籤),讓一次供應鏈升級無法默默改變你的 golden 位元組或你的工具鏈。

本指南未提出任何規範性標準主張。它所依賴的決定性,是移除 ISO 32000-2 中所指名的兩個非決定性欄位—— trailer 檔案識別子(/ID,§7.5.5)與文件資訊日期欄位 (CreationDate / ModDate,承載於文件資訊字典中,與 trailer 是另一個位置)——透過 DeterministicSettings。 文字斷言依賴引擎為內嵌字型發射的 /ToUnicode CMap(§9.10.2); enableTaggedPdf() 另外加上結構樹,並不會建立那個 CMap。 所示的每個 NextPDF 呼叫都是已驗證的公開 API。