Lewati ke konten
getnextpdf.com

Uji PDF yang dihasilkan di CI

Resep ini ditujukan bagi pengembang aplikasi yang menghasilkan PDF dengan NextPDF dan ingin menjaga keluaran mereka sendiri tetap diuji. Ini adalah sisi konsumen dari disiplin pengujian engine itu sendiri: Anda tidak menguji ulang NextPDF, Anda menegaskan bahwa dokumen Anda masih mengatakan apa yang seharusnya dan masih tampak seperti sebelumnya.

Dua gaya assertion mencakup hampir segalanya:

  • Assertion semantik pada teks yang diekstrak — hasilkan, pulihkan teks Unicode-nya, dan tegaskan bahwa ia berisi string yang Anda harapkan. Ini bertahan terhadap penyesuaian tata letak dan perubahan fon.
  • Assertion golden (snapshot) pada byte — pin DeterministicSettings sehingga rebuild identik-byte, lalu bandingkan byte baru terhadap sebuah file referensi yang ter-commit. Ini menangkap setiap perubahan yang tak disengaja.

Gunakan assertion semantik untuk kebenaran konten dan assertion golden sebagai kawat sandung regresi. Keduanya berjalan tanpa perubahan di CI begitu runner menghasilkan byte yang sama dengan workstation Anda.

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

Lakukan assertion pada teks yang diekstrak, bukan pada diff byte

Bagian berjudul “Lakukan assertion pada teks yang diekstrak, bukan pada diff byte”

Diff byte mentah dari dua PDF bersifat rapuh: timestamp baru, fon yang di-subset ulang, atau objek yang diurutkan ulang semuanya mengubah byte tanpa mengubah apa yang dilihat seorang pembaca. Lakukan assertion pada konten sebagai gantinya.

NextPDF Core adalah produser, jadi buat teksnya dapat diekstrak lebih dulu. Ini dua mekanisme yang berbeda, bukan satu. Ekstraksi teks bergantung pada CMap /ToUnicode yang benar (ISO 32000-2 §9.10.2) yang memetakan kode glyph kembali ke Unicode — engine memancarkannya untuk fon yang ditanamkan, sehingga ekstraktor memulihkan karakter sebenarnya alih-alih indeks glyph mentah. Tagged PDF terpisah: enableTaggedPdf() dan setLanguage() menambahkan structure tree yang mencatat urutan baca dan aksesibilitas, yang bukan hal yang menciptakan CMap /ToUnicode. Aktifkan keduanya sebelum Anda menulis konten: CMap untuk pemulihan teks yang bersih, tagging untuk urutan baca. Lihat Hasilkan konten teks yang dapat diekstrak untuk detail produser. Lalu pulihkan teksnya dan lakukan assertion padanya.

Untuk jumlah-halaman dan fakta struktural, kedalaman Quick milik modul Inspect memiliki fallback pure-PHP yang berjalan in-process saat tidak ada sidecar Spectrum yang tersedia — nyaman pada runner CI, tetapi itu adalah scan yang terdegradasi. Ia menandai isu INSPECT-FALLBACK-001 “accuracy may be limited” dan menurunkan jumlah halaman dari regex /Type /Page yang kasar atas byte mentah, bukan parse pohon-objek penuh. Ketika sebuah sidecar Spectrum memang dikonfigurasi, bahkan kedalaman Quick menggunakannya — InspectDepth mengontrol seberapa banyak analisis yang dilakukan sidecar, jadi Quick tidak inheren bebas-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() mengembalikan sebuah InspectResult yang immutable. Untuk pemulihan teks penuh, jalankan sebuah ekstraktor hilir (pdftotext, atau sidecar Spectrum Inspect pada kedalaman Standard) atas byte-nya dan lakukan assertion pada keluarannya — lakukan assertion pada teks yang dipulihkan, jangan pernah pada byte persis milik produser.

Buat keluaran identik-byte untuk snapshot golden

Bagian berjudul “Buat keluaran identik-byte untuk snapshot golden”

Sebuah tes golden hanya bekerja jika rebuild menghasilkan byte yang sama. PDF memiliki dua sumber non-determinisme bawaan: field tanggal (CreationDate / ModDate) dan pengidentifikasi file di trailer (ISO 32000-2 §7.5.5). NextPDF menghapus keduanya melalui DeterministicSettings, sebuah nilai config kelas-satu — bukan trik tes.

DeterministicSettings menerima sebuah DateTimeImmutable tetap dan sebuah fileIdSeed heks 32 karakter. Berikan ia pada Config, lalu bangun dokumen Anda dari config itu. Dengan profil deterministik yang dipin (timestamp tetap dan /ID), input yang sama menghasilkan keluaran identik-byte lintas eksekusi pada toolchain dipin yang sama — patch PHP, versi ekstensi dan pustaka kompresi, serta file fon semuanya dipertahankan konstan. Lintas mesin yang berbeda pada salah satu dari itu, byte-nya masih dapat menyimpang; utamakan assertion ekstraksi-teks di sana dan cadangkan snapshot golden untuk lingkungan yang tetap dan dipin.

<?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 harus tepat 32 karakter heksadesimal, atau constructor melempar InvalidConfigException. Jika Anda sudah memegang sebuah Config, Anda dapat menurunkan salinan deterministik dengan $config->withDeterministic($settings) alih-alih membangunnya ulang.

Sebuah tes PHPUnit untuk kedua gaya assertion

Bagian berjudul “Sebuah tes PHPUnit untuk kedua gaya assertion”

Kelas tes ini menjalankan sebuah assertion semantik dan sebuah assertion golden terhadap builder yang sama. File golden dihasilkan sekali, ditinjau oleh manusia, dan di-commit; setelah itu tes gagal pada setiap perubahan byte.

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

Assertion byte bermakna hanya karena buildInvoice() memin DeterministicSettings. Tanpa itu, CreationDate saja akan menggagalkan tes golden pada setiap eksekusi.

Keluaran identik-byte bergantung pada byte fon yang sama di-subset pada setiap mesin. Sebuah fon yang teresolusi berbeda pada runner dibanding workstation Anda mengubah subset yang ditanamkan dan merusak tes golden — bahkan dengan DeterministicSettings yang dipin.

Dua aturan menjaga fon tetap stabil:

  • Gunakan fon standar Base 14 (misalnya helvetica) untuk tes golden yang tidak memerlukan typeface tertentu. Mereka menghindari penanaman byte fon kustom — mereka mengandalkan metrik bawaan yang stabil, meski tampilan persis yang dirender masih dapat bergantung pada substitusi fon viewer.
  • Vendorkan setiap fon kustom ke dalam repositori dan arahkan NextPDF padanya secara eksplisit, alih-alih mengandalkan path fon sistem yang berbeda antar mesin. Atur Config(fontsDirectory: ...) atau panggil addFontDirectory() dengan direktori yang ter-commit:
<?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

Jangan memasang fon dari package manager OS untuk tes golden: paket fon distribusi berbeda dalam versi dan hinting, sehingga pemutakhiran runner secara senyap mengubah byte Anda. Direktori fon yang divendorkan menghilangkan variabel itu.

Workflow ini memasang PHP dengan ekstensi yang dibutuhkan NextPDF, memasang sebuah ekstraktor teks untuk assertion semantik, dan menjalankan PHPUnit. Baris php-version: "8.4" memin versi minor PHP (8.4), bukan patch — setup-php meresolusinya ke 8.4.x terbaru yang tersedia. Untuk reproduksibilitas tingkat-byte, pin sebuah patch konkret yang Anda dukung (misalnya php-version: "8.4.8") sehingga pemutakhiran image runner tidak dapat menggeser build PHP di bawah snapshot golden Anda.

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 menyediakan pdftotext untuk assertion teks. Daftar ekstensi cocok dengan apa yang diwajibkan keras NextPDF Core: curl, gd, intl, mbstring, openssl, dan zlib mencakup networking, penanganan gambar raster, teks dan kolasi terinternasionalkan, teks multibyte, kriptografi untuk enkripsi/penandatanganan, serta kompresi stream. Pasang semuanya — composer.json Core mewajibkan setiap satunya, jadi ekstensi yang hilang menggagalkan composer install, bukan sekadar satu fitur. Jika sebuah langkah assertion berikutnya mem-parse keluaran HTML atau XML, tambahkan dom untuk langkah itu; ia bukan kebutuhan Core. Karena fon divendorkan dalam repositori, tidak ada pemasangan paket fon yang diperlukan — itulah yang menjaga byte runner setara dengan byte Anda.

  • Tes golden membutuhkan DeterministicSettings. Tanpa timestamp dan fileIdSeed yang dipin, CreationDate, ModDate, dan pengidentifikasi file trailer berubah setiap eksekusi dan assertion byte tidak pernah lolos.
  • fileIdSeed tepat 32 karakter heks. Panjang lain mana pun atau karakter non-heks melempar InvalidConfigException saat konstruksi.
  • Fon adalah bagian dari byte. Versi fon yang berbeda pada runner men-subset ulang glyph dan menggagalkan tes golden. Vendorkan fon atau gunakan Base 14.
  • Core tidak mengirim extractText(). Pemulihan teks untuk assertion adalah pekerjaan konsumen: gunakan pdftotext atau sidecar Spectrum Inspect. Tugas produser adalah memancarkan CMap /ToUnicode yang benar (otomatis untuk fon yang ditanamkan) agar ekstraktor memulihkan Unicode sebenarnya; enableTaggedPdf() menambahkan structure tree di atasnya, tetapi itu bukan yang menghasilkan CMap.
  • Kedalaman Quick Inspect memiliki fallback PHP in-process saat tidak ada sidecar (akurasi terbatas — menandai INSPECT-FALLBACK-001); Standard dan Full selalu membutuhkan sidecar. Untuk CI tanpa sidecar, fallback Quick memberi jumlah halaman, versi, dan flag enkripsi — perlakukan hasilnya sebagai perkiraan dan andalkan teks yang diekstrak untuk kebenaran konten.
  • Regenerasi golden secara sengaja. Ketika sebuah perubahan diinginkan, hapus snapshot, jalankan ulang untuk menulis yang baru, dan tinjau diff-nya sebelum di-commit. Jangan pernah menimpa golden secara otomatis di CI.

Kedua gaya assertion murah. Sebuah perbandingan golden adalah satu build ditambah satu pembandingan string. Jalur semantik menambahkan satu pemanggilan pdftotext di luar proses per dokumen; jaga itu pada dokumen yang teksnya benar-benar Anda assert. Fallback PHP Quick Inspect (tanpa sidecar) adalah scan satu-lintasan atas byte-nya, sehingga ia menambahkan waktu yang dapat diabaikan pada sebuah tes; ketika sebuah sidecar dikonfigurasi, kedalaman Quick membuat satu round-trip sidecar sebagai gantinya.

  • Perlakukan teks yang diekstrak sebagai dapat dibaca mesin: jangan pernah menegaskan bahwa sebuah secret tidak ada dari byte-nya sebagai kontrol kerahasiaan. Teks bertag dapat dibaca oleh siapa pun yang memegang file-nya. Untuk kerahasiaan, enkripsi.
  • Bangun path file sementara untuk ekstraktor dengan tempnam() dan bersihkan; jangan melewatkan fixture tes melalui path bersama yang dapat ditebak.
  • Pin versi alat dan action (sebuah patch PHP konkret seperti 8.4.8, bukan sekadar minor 8.4; poppler-utils melalui distribusi; SHA atau tag action) sehingga lonjakan rantai-pasok tidak dapat secara senyap mengubah byte golden atau toolchain Anda.

Panduan ini tidak membuat klaim standar normatif. Determinisme yang diandalkannya adalah penghapusan dua field non-deterministik yang dinamai dalam ISO 32000-2 — pengidentifikasi file trailer (/ID, §7.5.5) dan field tanggal informasi-dokumen (CreationDate / ModDate, dibawa dalam document information dictionary, sebuah lokasi terpisah dari trailer) — melalui DeterministicSettings. Assertion teks mengandalkan CMap /ToUnicode (§9.10.2) yang dipancarkan engine untuk fon yang ditanamkan; enableTaggedPdf() menambahkan structure tree secara terpisah dan tidak menciptakan CMap itu. Setiap pemanggilan NextPDF yang ditunjukkan adalah API publik terverifikasi.