ทดสอบ PDF ที่สร้างขึ้นใน CI
ภาพรวมโดยสังเขป
หัวข้อที่มีชื่อว่า “ภาพรวมโดยสังเขป”สูตรนี้สำหรับนักพัฒนาแอปพลิเคชันที่สร้าง PDF ด้วย NextPDF และต้องการให้ผลลัพธ์ ของตัวเอง อยู่ภายใต้การทดสอบ มันคือฝั่งผู้ใช้งานของวินัยการทดสอบของเอนจินเอง: คุณไม่ทดสอบ NextPDF ซ้ำ คุณ assert ว่าเอกสารของคุณยังบอกสิ่งที่ควรบอกและยังดูเหมือนเดิม
รูปแบบการ assert สองแบบครอบคลุมเกือบทุกอย่าง:
- การ assert เชิงความหมาย บน ข้อความที่แยกได้ — สร้าง กู้คืนข้อความ Unicode แล้ว assert ว่ามันมีสตริงที่คุณคาดหวัง วิธีนี้อยู่รอดการปรับเค้าโครงและการเปลี่ยนฟอนต์
- การ assert แบบ golden (snapshot) บน ไบต์ — pin
DeterministicSettingsเพื่อให้การ rebuild เหมือนกันทุกไบต์ แล้วเทียบไบต์ใหม่กับไฟล์อ้างอิงที่ commit ไว้ วิธีนี้จับการเปลี่ยนแปลงที่ไม่ตั้งใจใด ๆ
ใช้การ assert เชิงความหมายสำหรับความถูกต้องของเนื้อหา และการ assert แบบ golden เป็น tripwire สำหรับ regression ทั้งสองรันโดยไม่ต้องเปลี่ยนใน CI เมื่อ runner สร้างไบต์เดียวกับที่เวิร์กสเตชันของคุณสร้าง
การติดตั้ง
หัวข้อที่มีชื่อว่า “การติดตั้ง”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3assert บนข้อความที่แยกได้ ไม่ใช่บน byte diff
หัวข้อที่มีชื่อว่า “assert บนข้อความที่แยกได้ ไม่ใช่บน byte diff”byte diff ดิบของ PDF สองไฟล์เปราะบาง: timestamp ใหม่ ฟอนต์ที่ re-subset หรืออ็อบเจกต์ที่จัดลำดับใหม่ ล้วนเปลี่ยนไบต์โดยไม่เปลี่ยนสิ่งที่ผู้อ่านเห็น ให้ assert บน เนื้อหา แทน
NextPDF Core เป็นผู้ผลิต ดังนั้นจงทำให้ข้อความแยกได้ก่อน นี่คือสองกลไกที่แตกต่างกัน ไม่ใช่อันเดียว การแยกข้อความพึ่งพา /ToUnicode CMap ที่ถูกต้อง (ISO 32000-2 §9.10.2) ที่แมปรหัสกลีฟกลับไปเป็น Unicode — เอนจินปล่อยมันสำหรับฟอนต์ที่ฝัง ดังนั้นตัวแยกจึงกู้คืนอักขระจริงแทนดัชนีกลีฟดิบ Tagged PDF เป็นเรื่องแยกต่างหาก: enableTaggedPdf() และ setLanguage() เพิ่ม structure tree ที่บันทึกลำดับการอ่านและการเข้าถึง ซึ่งไม่ใช่สิ่งที่สร้าง /ToUnicode CMap เปิดใช้ทั้งสองก่อนเขียนเนื้อหา: CMap สำหรับการกู้คืนข้อความที่สะอาด และ tagging สำหรับลำดับการอ่าน ดูสร้างเนื้อหาข้อความที่แยกได้ สำหรับรายละเอียดฝั่งผู้ผลิต จากนั้นกู้คืนข้อความแล้ว assert บนมัน
สำหรับจำนวนหน้าและข้อเท็จจริงเชิงโครงสร้าง depth Quick ของโมดูล Inspect มี fallback แบบ pure-PHP ที่รันในกระบวนการเมื่อไม่มี Spectrum sidecar — สะดวกบน CI runner แต่มันคือสแกนแบบลดทอน มันยกธง INSPECT-FALLBACK-001 “accuracy may be limited” และ derive จำนวนหน้าจาก regex /Type /Page แบบหยาบบนไบต์ดิบ ไม่ใช่การ parse object-tree แบบเต็ม เมื่อ Spectrum sidecar ถูก กำหนดค่า แม้ depth Quick ก็ใช้มัน — 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; // boolInspector::inspect() คืน InspectResult ที่เปลี่ยนแปลงไม่ได้ สำหรับการกู้คืนข้อความฉบับเต็ม ให้รันตัวแยกปลายทาง (pdftotext หรือ Inspect Spectrum sidecar ที่ depth Standard) บนไบต์แล้ว assert บนผลลัพธ์ของมัน — assert บน ข้อความที่กู้คืน ไม่เคย assert บนไบต์ที่แน่นอนของผู้ผลิต
ทำให้ผลลัพธ์เหมือนกันทุกไบต์สำหรับ golden snapshot
หัวข้อที่มีชื่อว่า “ทำให้ผลลัพธ์เหมือนกันทุกไบต์สำหรับ golden snapshot”การทดสอบ golden ทำงานได้ก็ต่อเมื่อการ rebuild สร้างไบต์เดียวกัน PDF มีแหล่งความไม่แน่นอนในตัวสองแหล่ง: ฟิลด์วันที่ (CreationDate / ModDate) และตัวระบุไฟล์ใน trailer (ISO 32000-2 §7.5.5) NextPDF ลบทั้งสองผ่าน DeterministicSettings ซึ่งเป็นค่า config ชั้นหนึ่ง — ไม่ใช่ test hack
DeterministicSettings รับ DateTimeImmutable คงที่และ fileIdSeed แบบ hex 32 อักขระ ส่งมันบน Config แล้วสร้างเอกสารจาก config นั้น เมื่อ pin โปรไฟล์ deterministic แล้ว (timestamp คงที่และ /ID) อินพุตเดียวกันให้ผลลัพธ์ที่เหมือนกันทุกไบต์ข้ามการรัน บน toolchain ที่ pin เดียวกัน — PHP patch เวอร์ชันของส่วนขยายและไลบรารีบีบอัด และไฟล์ฟอนต์ ล้วนคงที่ ข้ามเครื่องที่ต่างกันในสิ่งใดสิ่งหนึ่งเหล่านั้น ไบต์ยังแตกต่างได้ ให้เลือกใช้การ assert แบบ text-extraction ที่นั่นและสงวน golden snapshot ไว้สำหรับสภาพแวดล้อมที่คงที่และ pin แล้ว
<?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 ต้องเป็นอักขระ hexadecimal 32 ตัวพอดี มิฉะนั้นคอนสตรักเตอร์จะ throw InvalidConfigException หากคุณถือ Config อยู่แล้ว คุณ derive สำเนา deterministic ด้วย $config->withDeterministic($settings) ได้แทนการสร้างใหม่
การทดสอบ PHPUnit สำหรับการ assert ทั้งสองแบบ
หัวข้อที่มีชื่อว่า “การทดสอบ PHPUnit สำหรับการ assert ทั้งสองแบบ”คลาสทดสอบนี้ฝึกการ assert เชิงความหมายและการ assert แบบ golden กับ builder เดียวกัน ไฟล์ golden ถูกสร้างครั้งเดียว ตรวจทานโดยมนุษย์ แล้ว commit หลังจากนั้น การทดสอบจะล้มเหลวเมื่อไบต์เปลี่ยนแปลงใด ๆ
<?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); } }}การ assert ไบต์มีความหมายก็เพราะ buildInvoice() pin DeterministicSettings หากไม่มีมัน เพียง CreationDate ก็จะทำให้การทดสอบ golden ล้มเหลวในทุกการรัน
pin ฟอนต์เพื่อให้ CI สร้างไบต์เดียวกัน
หัวข้อที่มีชื่อว่า “pin ฟอนต์เพื่อให้ CI สร้างไบต์เดียวกัน”ผลลัพธ์ที่เหมือนกันทุกไบต์ขึ้นอยู่กับการ subset ไบต์ฟอนต์เดียวกันบนทุกเครื่อง ฟอนต์ที่รีโซลฟ์ต่างกันบน runner กับบนเวิร์กสเตชันของคุณจะเปลี่ยน subset ที่ฝังและทำให้การทดสอบ golden ล้มเหลว — แม้ pin DeterministicSettings แล้ว
กฎสองข้อทำให้ฟอนต์เสถียร:
- ใช้ฟอนต์มาตรฐาน Base 14 (ตัวอย่างเช่น
helvetica) สำหรับการทดสอบ golden ที่คุณไม่ต้องการ typeface เฉพาะ มันเลี่ยงการฝังไบต์ฟอนต์ที่กำหนดเอง — มันพึ่งพา built-in metric ที่เสถียร แม้ว่าลักษณะที่เรนเดอร์จริงยังขึ้นกับการแทนที่ฟอนต์ของตัวแสดงเอกสารได้ - vendor ฟอนต์ที่กำหนดเองใด ๆ เข้าในที่เก็บ แล้วชี้ NextPDF ไปยังมันอย่างชัดเจน แทนการพึ่งพา path ฟอนต์ของระบบที่ต่างกันระหว่างเครื่อง ตั้ง
Config(fontsDirectory: ...)หรือเรียกaddFontDirectory()ด้วยไดเรกทอรีที่ 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อย่าติดตั้งฟอนต์จาก package manager ของ OS สำหรับการทดสอบ golden: แพ็กเกจฟอนต์ของ distribution ต่างกันในเวอร์ชันและ hinting ดังนั้นการอัปเกรด runner จะเปลี่ยนไบต์ของคุณอย่างเงียบ ๆ ไดเรกทอรีฟอนต์ที่ vendor แล้วขจัดตัวแปรนั้น
workflow ของ GitHub Actions
หัวข้อที่มีชื่อว่า “workflow ของ GitHub Actions”workflow นี้ติดตั้ง PHP พร้อมส่วนขยายที่ NextPDF ต้องการ ติดตั้งตัวแยกข้อความสำหรับการ assert เชิงความหมาย แล้วรัน PHPUnit บรรทัด php-version: "8.4" pin PHP minor version (8.4) ไม่ใช่ patch — setup-php รีโซลฟ์มันเป็น 8.4.x ที่ใหม่ที่สุดที่มี สำหรับความสามารถในการทำซ้ำระดับไบต์ ให้ pin patch ที่เป็นรูปธรรมที่คุณรองรับ (ตัวอย่างเช่น php-version: "8.4.8") เพื่อไม่ให้การอัปเกรดอิมเมจ runner ขยับ PHP build ใต้ golden snapshot ของคุณ
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 ให้ pdftotext สำหรับการ assert ข้อความ รายการส่วนขยายตรงกับสิ่งที่ NextPDF Core ต้องการอย่างเข้มงวด: curl, gd, intl, mbstring, openssl และ zlib ครอบคลุมเครือข่าย การจัดการรูปภาพ raster ข้อความและการเรียงลำดับแบบ internationalized ข้อความ multibyte การเข้ารหัสสำหรับการเข้ารหัส/ลงนาม และการบีบอัดสตรีม ติดตั้งทั้งหมด — composer.json ของ Core ต้องการทุกตัว ดังนั้นส่วนขยายที่หายไปจะทำให้ composer install ล้มเหลว ไม่ใช่แค่ฟีเจอร์เดียว หากขั้นตอน assert ภายหลัง parse ผลลัพธ์ HTML หรือ XML ให้เพิ่ม dom สำหรับขั้นนั้น มันไม่ใช่ข้อกำหนดของ Core เพราะฟอนต์ถูก vendor ในที่เก็บ จึงไม่ต้องติดตั้งแพ็กเกจฟอนต์ — นั่นคือสิ่งที่ทำให้ไบต์ของ runner เท่ากับของคุณ
กรณีขอบและจุดที่ควรระวัง
หัวข้อที่มีชื่อว่า “กรณีขอบและจุดที่ควรระวัง”- การทดสอบ golden ต้องการ
DeterministicSettingsหากไม่มี timestamp และfileIdSeedที่ pin แล้วCreationDate,ModDateและตัวระบุไฟล์ใน trailer จะเปลี่ยนทุกการรันและการ assert ไบต์ไม่เคยผ่าน fileIdSeedเป็นอักขระ hex 32 ตัวพอดี ความยาวอื่นใดหรืออักขระที่ไม่ใช่ hex จะ throwInvalidConfigExceptionตอนสร้าง- ฟอนต์เป็นส่วนหนึ่งของไบต์ เวอร์ชันฟอนต์ที่ต่างกันบน runner จะ re-subset กลีฟและทำให้การทดสอบ golden ล้มเหลว ให้ vendor ฟอนต์หรือใช้ Base 14
- Core ไม่มี
extractText()การกู้คืนข้อความสำหรับการ assert เป็นงานของผู้ใช้งาน: ใช้pdftotextหรือ Inspect Spectrum sidecar งานของผู้ผลิตคือปล่อย/ToUnicodeCMap ที่ถูกต้อง (อัตโนมัติสำหรับฟอนต์ที่ฝัง) เพื่อให้ตัวแยกกู้คืน Unicode จริงenableTaggedPdf()เพิ่ม structure tree ทับลงไป แต่ไม่ใช่สิ่งที่สร้าง CMap - depth Quick ของ Inspect มี fallback แบบ pure-PHP ในกระบวนการเมื่อไม่มี sidecar (ความแม่นยำจำกัด — ยกธง
INSPECT-FALLBACK-001) ส่วน Standard และ Full ต้องการ sidecar เสมอ สำหรับ CI ที่ไม่มี sidecar fallback ของ Quick ให้จำนวนหน้า เวอร์ชัน และธงการเข้ารหัส — ถือผลลัพธ์เป็นค่าประมาณและพึ่งพาข้อความที่แยกได้สำหรับความถูกต้องของเนื้อหา - สร้าง golden ใหม่อย่างจงใจ เมื่อการเปลี่ยนแปลงเป็นไปโดยตั้งใจ ให้ลบ snapshot รันใหม่เพื่อเขียนตัวใหม่ แล้วตรวจทาน diff ก่อน commit ไม่เคยเขียนทับ golden โดยอัตโนมัติใน CI
ประสิทธิภาพ
หัวข้อที่มีชื่อว่า “ประสิทธิภาพ”การ assert ทั้งสองแบบราคาถูก การเทียบ golden คือ build หนึ่งครั้งบวกการเทียบสตริง เส้นทางเชิงความหมายเพิ่มการเรียก pdftotext นอกกระบวนการหนึ่งครั้งต่อเอกสาร ให้จำกัดสิ่งเหล่านั้นไว้ที่เอกสารที่คุณ assert ข้อความจริง fallback แบบ PHP ของ Inspect Quick (ไม่มี sidecar) เป็นการสแกนไบต์แบบรอบเดียว จึงเพิ่มเวลาให้การทดสอบเล็กน้อย เมื่อกำหนดค่า sidecar แล้ว depth Quick ทำ sidecar round-trip หนึ่งครั้งแทน
หมายเหตุด้านความปลอดภัย
หัวข้อที่มีชื่อว่า “หมายเหตุด้านความปลอดภัย”- ถือว่าข้อความที่แยกได้เป็นแบบเครื่องอ่านได้: ไม่เคย assert ว่าความลับ ไม่มี ในไบต์เป็นการควบคุมการรักษาความลับ ข้อความที่ถูก tag อ่านได้โดยใครก็ตามที่มีไฟล์ สำหรับการรักษาความลับ ให้เข้ารหัส
- สร้าง path ไฟล์ชั่วคราวสำหรับตัวแยกด้วย
tempnam()แล้วเก็บกวาด อย่าส่ง test fixture ผ่าน path ที่ใช้ร่วมกันที่คาดเดาได้ - pin เวอร์ชันของเครื่องมือและ action (PHP patch ที่เป็นรูปธรรมเช่น
8.4.8ไม่ใช่แค่ minor8.4;poppler-utilsผ่าน distribution; SHA หรือ tag ของ action) เพื่อไม่ให้การ bump ของ supply-chain เปลี่ยน golden bytes หรือ toolchain ของคุณอย่างเงียบ ๆ
ความสอดคล้องตามมาตรฐาน
หัวข้อที่มีชื่อว่า “ความสอดคล้องตามมาตรฐาน”คู่มือนี้ไม่อ้างมาตรฐานเชิงบรรทัดฐานใด ความเป็น determinism ที่มันพึ่งพาคือการลบสองฟิลด์ที่ไม่ deterministic ที่ระบุชื่อใน ISO 32000-2 — ตัวระบุไฟล์ใน trailer (/ID, §7.5.5) และฟิลด์วันที่ของข้อมูลเอกสาร (CreationDate / ModDate ที่อยู่ใน document information dictionary ซึ่งเป็นตำแหน่งแยกจาก trailer) — ผ่าน DeterministicSettings การ assert ข้อความพึ่งพา /ToUnicode CMap (§9.10.2) ที่เอนจินปล่อยสำหรับฟอนต์ที่ฝัง enableTaggedPdf() เพิ่ม structure tree แยกต่างหากและไม่สร้าง CMap นั้น ทุกการเรียก NextPDF ที่แสดงเป็น API สาธารณะที่ตรวจยืนยันแล้ว