اختبر ملفات PDF المُولّدة في CI
لمحة سريعة
قسم بعنوان «لمحة سريعة»هذه الوصفة لمطوّري التطبيقات الذين يولّدون ملفات PDF بـNextPDF ويريدون إبقاء إخراجهم هم تحت الاختبار. وهي الجانب المستهلِك لانضباط اختبار المحرّك نفسه: فأنت لا تعيد اختبار NextPDF، بل تؤكّد أنّ مستندك ما زال يقول ما يجب وما زال يبدو كما كان.
نمطان من التأكيد يغطّيان كلّ شيءٍ تقريبًا:
- تأكيدات دلالية على النصّ المستخرَج — ولّد، واستعد نصّ Unicode، وأكّد أنّه يحتوي السلاسل التي تتوقّعها. وهذا ينجو من تعديلات التخطيط وتغييرات الخطوط.
- تأكيدات ذهبية (لقطة) على البايتات — ثبّت
DeterministicSettingsكي تكون إعادة البناء متطابقةً بايتًا، ثمّ قارن البايتات الجديدة بملفّ مرجعٍ مُودَع. وهذا يلتقط أيّ تغييرٍ غير مقصود.
استخدم التأكيدات الدلالية لصحّة المحتوى والتأكيدات الذهبية بوصفها سلكَ تعثّرٍ للانحدار. ويُشغَّل كلاهما دون تغييرٍ في CI بمجرّد أن يُنتِج المُشغِّل البايتات نفسها التي تُنتِجها محطّة عملك.
التثبيت
قسم بعنوان «التثبيت»composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3أكّد على النصّ المستخرَج، لا على فرق بايتات
قسم بعنوان «أكّد على النصّ المستخرَج، لا على فرق بايتات»فرق بايتاتٍ خامٌّ لملفَّي PDF هشّ: فطابع زمنٍ جديد، أو خطٌّ مُعاد تقسيمه إلى مجموعة فرعية، أو كائنٌ مُعاد ترتيبه كلٌّ منها يغيّر البايتات دون تغيير ما يراه القارئ. أكّد على المحتوى بدلًا من ذلك.
نواة NextPDF مُنتِجٌ، فاجعل النصّ قابلًا للاستخراج أوّلًا. هذان آليّتان متمايزتان،
لا واحدة. يعتمد استخراج النصّ على خريطة /ToUnicode CMap صحيحة (ISO 32000-2
§9.10.2) تَردّ رموز المحارف إلى Unicode — ويُصدِرها المحرّك للخطوط المضمَّنة، فيستعيد
المستخرِجون محارف حقيقيةً بدلًا من فهارس محارف خام. وTagged PDF منفصل:
enableTaggedPdf() وsetLanguage() يضيفان شجرة البنية التي تسجّل ترتيب القراءة
والوصولية، وهو ليس ما يُنشئ خريطة /ToUnicode CMap. مكّن كليهما قبل أن تكتب
المحتوى: الخريطة لاستعادةٍ نظيفة للنصّ، والتوسيم لترتيب القراءة. انظر
أنتِج محتوى نصٍّ قابلًا للاستخراج
لتفاصيل المُنتِج. ثمّ استعد النصّ وأكّد عليه.
لحقائق عدد الصفحات والحقائق البنيوية، لعمق Quick في وحدة Inspect احتياطيٌّ
بـPHP خالصة يعمل داخل العملية حين لا يتوفّر جانب Spectrum مساعِد —
ملائمٌ على مُشغِّل CI، لكنّه مسحٌ مُتدنٍّ. يرفع مشكلة INSPECT-FALLBACK-001
“قد تكون الدقّة محدودة” ويشتقّ عدد الصفحات من تعبيرٍ نمطيٍّ تقريبيٍّ /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، أو جانب Inspect Spectrum
المساعِد عند عمق Standard) على البايتات وأكّد على مخرجه — أكّد على النصّ
المستعاد، لا أبدًا على بايتات المُنتِج الدقيقة.
اجعل الإخراج متطابقًا بايتًا للقطات الذهبية
قسم بعنوان «اجعل الإخراج متطابقًا بايتًا للقطات الذهبية»لا ينجح اختبارٌ ذهبيٌّ إلّا إذا أنتجت إعادة البناء البايتات نفسها. ولـPDF مصدران
مدمجان لعدم الحتمية: حقول التاريخ (CreationDate / ModDate) ومعرّف الملفّ في
الذيل (ISO 32000-2 §7.5.5). وتزيل NextPDF كليهما عبر DeterministicSettings،
قيمة إعدادٍ من الدرجة الأولى — لا حيلةَ اختبار.
يأخذ DeterministicSettings كائن DateTimeImmutable ثابتًا وfileIdSeed ست
عشريًّا من 32 محرفًا. مرّره على 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 محرفًا بالضبط، وإلّا رمى المُنشِئ
InvalidConfigException. وإن كنت تحمل Config أصلًا، يمكنك اشتقاق نسخةٍ حتمية
بـ$config->withDeterministic($settings) بدلًا من إعادة بنائها.
اختبار PHPUnit لكلا نمطَي التأكيد
قسم بعنوان «اختبار PHPUnit لكلا نمطَي التأكيد»يمارس صنف الاختبار هذا تأكيدًا دلاليًّا وتأكيدًا ذهبيًّا على البانِي نفسه. ويُولَّد الملفّ الذهبي مرّةً، ويراجعه إنسان، ويُودَع؛ وبعدها يفشل الاختبار على أيّ تغيير بايتات.
<?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 وحده ليُفشِل الاختبار الذهبي في كلّ تشغيل.
ثبّت الخطوط كي يُنتِج CI البايتات نفسها
قسم بعنوان «ثبّت الخطوط كي يُنتِج CI البايتات نفسها»يعتمد الإخراج المتطابق بايتًا على تقسيم بايتات الخطوط نفسها إلى مجموعةٍ فرعية على
كلّ آلة. فخطٌّ يُحَلّ على المُشغِّل بطريقةٍ تختلف عمّا على محطّة عملك يغيّر المجموعة
الفرعية المضمَّنة ويكسر الاختبار الذهبي — حتى مع تثبيت DeterministicSettings.
قاعدتان تُبقيان الخطوط مستقرّة:
- استخدم خطوط 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لا تثبّت خطوطًا من مدير حزم نظام التشغيل للاختبارات الذهبية: فحزم خطوط التوزيعة تختلف في الإصدار والتلميح، فترقية المُشغِّل تغيّر بايتاتك بصمت. ودليل خطوطٍ مُدرَجٌ يزيل ذلك المتغيّر.
سير عمل GitHub Actions
قسم بعنوان «سير عمل GitHub Actions»يثبّت سير العمل هذا PHP بالامتدادات التي يحتاجها NextPDF، ويثبّت مستخرِج نصٍّ
للتأكيدات الدلالية، ويشغّل 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 تطلّبًا صارمًا: curl وgd وintl وmbstring
وopenssl وzlib تغطّي الشبكة، ومعالجة الصور النقطية، والنصّ المُدوَّل والترتيب،
والنصّ متعدّد البايتات، والتعمية للتشفير/التوقيع، وضغط المجرى. ثبّتها كلّها — إذ
يتطلّب composer.json الخاصّ بالنواة كلًّا منها، فامتدادٌ مفقودٌ يُفشِل
composer install، لا ميزةً واحدة فحسب. وإن حلّل تأكيدٌ لاحقٌ مخرج HTML أو XML،
فأضِف dom لتلك الخطوة؛ وهو ليس من متطلّبات النواة. ولأنّ الخطوط مُدرَجةٌ في
المستودع، لا يلزم تثبيت حزمة خطوط — وذلك ما يُبقي بايتات المُشغِّل مساويةً لبايتاتك.
الحالات الحدّية والمزالق
قسم بعنوان «الحالات الحدّية والمزالق»- تحتاج الاختبارات الذهبية إلى
DeterministicSettings. فبدون طابع زمنٍ مثبَّت وfileIdSeed، يتغيّرCreationDateوModDateومعرّف ملفّ الذيل في كلّ تشغيل ولا يمرّ تأكيد البايتات أبدًا. fileIdSeedستّ عشريٌّ من 32 محرفًا بالضبط. فأيّ طولٍ آخر أو محرفٍ غير ستّ عشري يرميInvalidConfigExceptionعند الإنشاء.- الخطوط جزءٌ من البايتات. فإصدار خطٍّ مختلفٌ على المُشغِّل يُعيد تقسيم المحارف إلى مجموعةٍ فرعية ويُفشِل الاختبار الذهبي. أدرِج الخطّ أو استخدم Base 14.
- النواة لا تشحن
extractText(). فاستعادة النصّ للتأكيدات عملٌ مستهلِك: استخدمpdftotextأو جانب Inspect Spectrum المساعِد. ومهمّة المُنتِج إصدار خريطة/ToUnicodeCMap صحيحة (تلقائيًّا للخطوط المضمَّنة) كي يستعيد المستخرِجون Unicode حقيقيًّا؛ ويضيفenableTaggedPdf()شجرة البنية فوق ذلك، لكنّه ليس ما يُنتِج الخريطة. - لعمق Inspect Quick احتياطيٌّ بـPHP خالصة داخل العملية حين لا يوجد جانبٌ
مساعِد (دقّة محدودة — يرفع
INSPECT-FALLBACK-001)؛ ويتطلّب Standard وFull الجانب المساعِد دائمًا. ولـCI دون جانبٍ مساعِد، يعطي احتياطي Quick عدد الصفحات والإصدار وراية التشفير — عامِل نتائجه تقريبيةً واتّكئ على النصّ المستخرَج لصحّة المحتوى. - أعد توليد الذهبيات عمدًا. حين يكون التغيير مقصودًا، احذف اللقطة، وأعد التشغيل لكتابة لقطةٍ جديدة، وراجع الفرق قبل الإيداع. لا تكتب فوق ذهبيٍّ تلقائيًّا في CI أبدًا.
الأداء
قسم بعنوان «الأداء»كلا نمطَي التأكيد رخيص. مقارنةٌ ذهبيةٌ بناءٌ واحد زائد مقارنة سلسلة. ويضيف المسار
الدلالي استدعاء pdftotext واحدًا خارج العملية لكلّ مستند؛ أبقِها على المستندات
التي تؤكّد على نصّها فعلًا. واحتياطي Inspect Quick بـPHP (بلا جانبٍ مساعِد) مسحٌ
بمرّةٍ واحدة على البايتات، فيضيف وقتًا مهملًا إلى اختبار؛ وحين يُضبَط جانبٌ مساعِد،
يؤدّي عمق Quick ذهابًا وإيابًا واحدًا إلى الجانب المساعِد بدلًا من ذلك.
ملاحظات أمنية
قسم بعنوان «ملاحظات أمنية»- عامِل النصّ المستخرَج بوصفه قابلًا للقراءة آليًّا: لا تؤكّد أبدًا أنّ سرًّا غائبٌ من البايتات بوصف ذلك ضابط سرّية. فالنصّ الموسوم قابلٌ للقراءة من أيّ من يملك الملفّ. وللسرّية، عمّ.
- ابنِ مسار الملفّ المؤقّت للمستخرِج بـ
tempnam()ونظّفه؛ لا تمرّر تجهيزات الاختبار عبر مسارٍ مشتركٍ متوقَّع. - ثبّت إصدارات الأدوات والإجراءات (رقعة PHP محدّدة مثل
8.4.8، لا الفرعي8.4فحسب؛ وpoppler-utilsعبر التوزيعة؛ وتجزئات أو وسوم الإجراءات) كي لا تغيّر قفزة سلسلة توريدٍ بصمت بايتاتك الذهبية أو سلسلة أدواتك.
المطابقة
قسم بعنوان «المطابقة»لا يدّعي هذا الدليل أيّ ادّعاءٍ معياريّ. والحتمية التي يعتمد عليها هي إزالة الحقلين
غير الحتميّين المسمَّيين في ISO 32000-2 — معرّف ملفّ الذيل (/ID، §7.5.5) وحقول
تاريخ معلومات المستند (CreationDate / ModDate، المحمولة في قاموس معلومات
المستند، موقعٌ منفصلٌ عن الذيل) — عبر DeterministicSettings. وتعتمد تأكيدات النصّ
على خريطة /ToUnicode CMap (§9.10.2) التي يُصدِرها المحرّك للخطوط المضمَّنة؛
ويضيف enableTaggedPdf() شجرة البنية منفصلةً ولا يُنشئ تلك الخريطة. وكلّ استدعاء
NextPDF مُبيَّنٌ واجهة API عامّة مُتحقَّقة.