Uji PDF yang dihasilkan di CI
Sekilas pandang
Bagian berjudul “Sekilas pandang”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
DeterministicSettingssehingga 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.
Pemasangan
Bagian berjudul “Pemasangan”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Lakukan 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; // boolInspector::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.
Pin fon agar CI menghasilkan byte yang sama
Bagian berjudul “Pin fon agar CI menghasilkan byte yang sama”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 panggiladdFontDirectory()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 repoJangan 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 GitHub Actions
Bagian berjudul “Workflow GitHub Actions”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=pdfpoppler-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.
Kasus tepi & jebakan
Bagian berjudul “Kasus tepi & jebakan”- Tes golden membutuhkan
DeterministicSettings. Tanpa timestamp danfileIdSeedyang dipin,CreationDate,ModDate, dan pengidentifikasi file trailer berubah setiap eksekusi dan assertion byte tidak pernah lolos. fileIdSeedtepat 32 karakter heks. Panjang lain mana pun atau karakter non-heks melemparInvalidConfigExceptionsaat 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: gunakanpdftotextatau sidecar Spectrum Inspect. Tugas produser adalah memancarkan CMap/ToUnicodeyang 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.
Performa
Bagian berjudul “Performa”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.
Catatan keamanan
Bagian berjudul “Catatan keamanan”- 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 minor8.4;poppler-utilsmelalui distribusi; SHA atau tag action) sehingga lonjakan rantai-pasok tidak dapat secara senyap mengubah byte golden atau toolchain Anda.
Kesesuaian
Bagian berjudul “Kesesuaian”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.