コンテンツにスキップ
getnextpdf.com

生成した PDF を CI でテストする

このレシピは、NextPDF で PDF を生成し、自分 自身 の出力をテスト下に保ちたいアプリケーション開発者向けです。これはエンジン自身のテスト規律の消費者側です。NextPDF を再テストするのではなく、自分のドキュメントが言うべきことを依然として言い、見えるべき見た目を依然として保っていることを表明します。

2 つの表明スタイルで、ほぼすべてをカバーできます。

  • 抽出テキスト に対する セマンティック表明 ——生成し、Unicode テキストを回復し、期待する文字列を含むことを表明します。これはレイアウトの微調整やフォントの変更を生き延びます。
  • バイト に対する ゴールデン(スナップショット)表明 ——DeterministicSettings を固定して再構築がバイト単位で同一になるようにし、新しいバイトをコミット済みの参照ファイルと比較します。これは意図しない変更をすべて捕捉します。

コンテンツの正しさにはセマンティック表明を、リグレッションのトリップワイヤーにはゴールデン表明を使ってください。ランナーがあなたのワークステーションと同じバイトを生成すれば、どちらも CI で変更なく実行されます。

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

2 つの PDF の生のバイト差分は壊れやすいものです。新しいタイムスタンプ、再サブセット化されたフォント、並べ替えられたオブジェクトはいずれも、読者が見るものを変えずにバイトを変えます。代わりに コンテンツ を表明してください。

NextPDF Core はプロデューサーなので、まずテキストを抽出可能にします。これらは 2 つの異なる機構であり、1 つではありません。テキスト抽出は、グリフコードを Unicode に対応付け直す正しい /ToUnicode CMap(ISO 32000-2 §9.10.2)に依存します——エンジンは埋め込みフォントに対してそれを出力するため、抽出器は生のグリフインデックスではなく実際の文字を回復します。Tagged PDF は別物です。enableTaggedPdf()setLanguage() は、読み上げ順序とアクセシビリティを記録する構造ツリーを追加しますが、それは /ToUnicode CMap を作るものではありません。コンテンツを書く前に両方を有効化してください。クリーンなテキスト回復のための CMap と、読み上げ順序のためのタグ付けです。プロデューサーの詳細は 抽出可能なテキストコンテンツを生成する を参照してください。その後、テキストを回復してそれを表明します。

ページ数や構造的な事実については、Inspect モジュールの Quick 深度に、Spectrum サイドカーが利用できないときにインプロセスで走るピュア PHP の フォールバック があります——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 には非決定性の組み込みソースが 2 つあります。日付フィールド(CreationDate / ModDate)と、トレーラー内のファイル識別子(ISO 32000-2 §7.5.5)です。NextPDF は、テストのハックではなく第一級の設定値である DeterministicSettings を通じて、その両方を取り除きます。

DeterministicSettings は、固定の DateTimeImmutable と 32 文字の 16 進 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 個の 16 進文字でなければならず、さもないとコンストラクターが InvalidConfigException を投げます。すでに Config を保持している場合は、再構築する代わりに $config->withDeterministic($settings) で決定論的なコピーを導出できます。

このテストクラスは、同じビルダーに対してセマンティック表明とゴールデン表明を行使します。ゴールデンファイルは 1 回生成され、人間によってレビューされ、コミットされます。その後、テストはあらゆるバイトの変更で失敗します。

<?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 を固定していても、ゴールデンテストを壊します。

2 つのルールがフォントを安定に保ちます。

  • 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

ゴールデンテストのために OS のパッケージマネージャーからフォントをインストールしないでください。ディストリビューションのフォントパッケージはバージョンとヒンティングが異なるため、ランナーのアップグレードがあなたのバイトをひそかに変えます。ベンダリングされたフォントディレクトリがその変数を取り除きます。

このワークフローは、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 が必要。 固定されたタイムスタンプと fileIdSeed がなければ、CreationDateModDate、トレーラーのファイル識別子が毎回変わり、バイト表明は決して通りません。
  • fileIdSeed は正確に 32 個の 16 進文字。 それ以外の長さや非 16 進文字は、構築時に InvalidConfigException を投げます。
  • フォントはバイトの一部。 ランナー上の異なるフォントバージョンはグリフを再サブセット化し、ゴールデンテストを失敗させます。フォントをベンダリングするか、Base 14 を使ってください。
  • Core は extractText() を出荷しない。 表明のためのテキスト回復は消費者の作業です。pdftotext または Inspect Spectrum サイドカーを使ってください。プロデューサーの仕事は、抽出器が実際の Unicode を回復できるように、正しい /ToUnicode CMap(埋め込みフォントには自動)を出力することです。enableTaggedPdf() はその上に構造ツリーを追加しますが、それは CMap を生成するものではありません。
  • Inspect の Quick 深度は、サイドカーがないときインプロセスの PHP フォールバックを持つ(精度は限定的——INSPECT-FALLBACK-001 をフラグ付け)。Standard と Full は常にサイドカーを必要とする。 サイドカーのない CI では、Quick フォールバックがページ数、バージョン、暗号化フラグを与えます——その結果は近似として扱い、コンテンツの正しさは抽出テキストに依拠してください。
  • ゴールデンを意図的に再生成する。 変更が意図的なときは、スナップショットを削除し、再実行して新しいものを書き、コミットする前に差分をレビューしてください。CI でゴールデンを決して自動上書きしないでください。

どちらの表明スタイルも安価です。ゴールデン比較は 1 つのビルドに 1 つの文字列比較を加えたものです。セマンティックパスは、ドキュメントごとに 1 つのプロセス外 pdftotext 呼び出しを加えます——それらは実際にテキストを表明するドキュメントに留めてください。Inspect の Quick PHP フォールバック(サイドカーなし)はバイトの単一パススキャンなので、テストに加える時間はごくわずかです。サイドカーが設定されている場合、Quick 深度は代わりに 1 つのサイドカーラウンドトリップを行います。

  • 抽出テキストを機械可読として扱ってください。シークレットがバイトから 欠如 していることを機密性の制御として決して表明しないでください。タグ付きテキストはファイルを持つ誰にでも読めます。機密性のためには暗号化してください。
  • 抽出器のための一時ファイルパスは tempnam() で構築し、後始末をしてください。テストフィクスチャを予測可能な共有パスに通さないでください。
  • ツールとアクションのバージョンを固定してください(8.4 マイナーだけでなく 8.4.8 のような具体的な PHP パッチ、ディストリビューション経由の poppler-utils、アクションの SHA またはタグ)——サプライチェーンのバンプがゴールデンバイトやツールチェーンをひそかに変えられないように。

このガイドは規範的な標準の主張を行いません。それが依拠する決定性は、ISO 32000-2 で名指された 2 つの非決定的なフィールド——トレーラーのファイル識別子(/ID、§7.5.5)と、ドキュメント情報の日付フィールド(CreationDate / ModDate、トレーラーとは別の場所であるドキュメント情報辞書に格納される)——を DeterministicSettings を通じて取り除くことです。テキスト表明は、エンジンが埋め込みフォントに対して出力する /ToUnicode CMap(§9.10.2)に依拠します。enableTaggedPdf() は構造ツリーを別途追加し、その CMap を作りません。示されたすべての NextPDF 呼び出しは検証済みの公開 API です。