تخطَّ إلى المحتوى

تشخيص محلّل PDF المتقدّم

يقرأ مسار الاستيراد في ⁨Artisan⁩ ملف ⁨Portable Document Format⁩ (⁨PDF⁩) الذي يُنشئه ⁨Chrome⁩ ويجلب صفحة واحدة إلى مستند ⁨NextPDF.⁩ عندما يمنع مُدخَل معقّد إكمال هذا الاستيراد، انظر أسفل PageImporter::import() إلى أصناف المحلّل التي تقرأ الملف بايتًا بايتًا.

يغطّي هذا الدليل سطح المحلّل منخفض المستوى في فضاء الأسماء NextPDF\Parser: PdfReader، وPdfTokenizer، وCrossRefParser، وStreamDecoder، وResourceCollector، وRevisionExtractor، وكائنا القيمة PdfObject وRevisionXRefTable. كل رمز معروض هنا موجود في nextpdf/artisan. يصف الدليل المحلّل كما هو منفّذ فعليًّا، لا بوصفه واجهة مثالية.

استعمل هذا الدليل بوصفه شرحًا ومرجعًا عمليًّا في الوقت نفسه. فهو يوضّح كيف تتكامل الأجزاء، ثم يرشدك خطوةً بخطوة إلى فحص مراجعة تحديث تزايدي. لمعرفة حدود الاستيراد فوق هذه الطبقة، راجع دليل مطوّري ⁨Artisan⁩.

استعمل سطح المحلّل فقط عندما يكون مسار الاستيراد الاعتيادي قد فشل بالفعل وتحتاج إلى تحديد السبب. تشمل الحالات النموذجية ما يلي:

  • يطرح PageImporter::import() استثناء NextPDF\Artisan\Exception\PdfParseException، وتحتاج إلى معرفة ما إذا كان الخلل في جدول المراجع المتقاطعة، أو في مرشّح دفق، أو في شجرة الصفحات.
  • تغيّر ترقية في ⁨Chrome⁩ صيغة المُخرَج، كأن يتحوّل جدول مراجع متقاطعة تقليدي إلى دفق مراجع متقاطعة أو العكس، فتتوقّف أدواتك الثابتة عن المطابقة.
  • تستلم ملف ⁨PDF⁩ من طرف ثالث لم يُنتِجه ⁨Chrome⁩، وتريد التأكّد مما إذا كان المحلّل يستطيع قراءته أصلًا.
  • تحلّل مستندًا محدَّثًا تزايديًّا وتحتاج إلى نطاقات بايت لكل مراجعة أو إلى رؤية الكائنات.

إن كنت تكتب تكامل عرض اعتياديًّا، فلن تحتاج إلى هذا السطح. المحلّل أداة تشخيص داخلية، وليس مكتبة ⁨PDF⁩ عامة الغرض. لا يدعم ملفات ⁨PDF⁩ المشفّرة، ولا جداول التلميح الخطّية، ولا التحديثات التزايدية التي تتضمّن إعادات تعريف متعارضة للكائنات.

المحلّل مجموعة صغيرة من الأصناف أحادية المسؤولية. PdfReader هو نقطة الدخول، أما الأصناف الأخرى فهي عناصر مساعدة يُنشئها أو يستدعيها.

الصنفالمسؤوليةالطرائق الرئيسة
PdfReaderقراءة بنية الملف، وحلّ الكائنات، واجتياز شجرة الصفحات.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerتحليل البنية المعجمية وفق ⁨ISO 32000-2⁩:2020 §7.2: الأسماء، والسلاسل، والأرقام، والقواميس، والمصفوفات، والمراجع.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserتحليل جداول المراجع المتقاطعة التقليدية ودفوق المراجع المتقاطعة.parseXRefTable(), parseXRefStream()
StreamDecoderفكّ ترميز بايتات الدفق بحسب /Filter.decode() (ساكنة)
ResourceCollectorاجتياز شجرة الموارد تعاوديًّا وجمع كل كائن غير مباشر يمكن الوصول إليه.traverse(), getCollected()
RevisionExtractorتقطيع ملف محدَّث تزايديًّا إلى نطاقات بايت لكل مراجعة.extractRevision() (ساكنة)، getRevisionBoundaries() (ساكنة)
PdfObjectكائن غير مباشر مُحلَّل وثابت (قاموس بالإضافة إلى دفق اختياري).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableلقطة مراجع متقاطعة ثابتة لكل مراجعة.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

أنشئ \NextPDF\Parser\PdfReader باستخدام بايتات ⁨PDF⁩ الخام، ثم استدعِ parse() قبل استدعاء أي طريقة أخرى. تتحقّق parse() من ترويسة %PDF-، وتعثر على startxref في ذيل الملف، وتجتاز سلسلة المراجع المتقاطعة باتّباع روابط /Prev.

بعد parse()، يوفّر القارئ ثلاث مجموعات من الطرائق:

  • الوصول إلى الكائنات. يُعيد getObject(int $objNum) كائن PdfObject، ويحلّ مدخلات النوع 2 (الكائنات المخزَّنة داخل دفق كائنات) تلقائيًّا. يُعيد getObjectNumbers() قيمة list<int> مرتَّبة لكل رقم كائن غير حرّ. يتّبع resolveRef(mixed $value) مرجعًا غير مباشر واحدًا. أما القيمة المباشرة فتمرّ من دون تغيير.
  • الوصول إلى الصفحات. يحلّ getPage(int $pageIndex) الفهرس، ويجتاز /Pages، ويُعيد الصفحة عند الفهرس المبدوء من صفر. تستخرج getPageContentStream()، وgetPageResources()، وgetPageMediaBox() الأجزاء التي يحتاجها PageImporter. يُعيد collectPageResources() قيمة array<int, PdfObject> لكل كائن يمكن الوصول إليه من موارد الصفحة ومحتوياتها.
  • الوصول إلى المراجعات. يُعيد getRevisionCount() عدد المراجعات التزايدية. الملف ذو المراجعة الواحدة يُعيد 1. يُعيد getRevisionXRef(int $index) كائن RevisionXRefTable واحدًا (الفهرس 0 هو الأحدث). ويُعيد getRevisions() قيمة list<RevisionXRefTable> الكاملة.

يقرأ PdfTokenizer دفق البايتات. نادرًا ما تنشئه بنفسك لأن PdfReader وCrossRefParser يستخدمان نسخهما الخاصة. افحص هذه الطبقة عندما يفشل التحليل عند رمز مُشوَّه. يهمّك سلوكان عند التشخيص:

  • حدود الأمان ثوابت، لا إعدادات. يحدّ المحلّل من تداخل السلاسل النصّية الحرفية، وتداخل القواميس والمصفوفات، وطول الكلمات المفتاحية، وعدد عناصر المصفوفة. عندما يتجاوز المُدخَل حدًّا، يطرح PdfParseException ويذكر ذلك الحدّ في الرسالة. المُدخَل المُصاغ خصّيصًا لتجاوز أحد هذه الحدود يدل على دفاع يعمل كما صُمِّم، لا على عيب في المحلِّل.
  • readValue() يوجّه التحليل. يفحص البايت التالي ويفوّض إلى readName()، وreadLiteralString()، وreadHexString()، وreadArray()، وreadDictionary()، أو قارئ ⁨number/reference.⁩ يُعاد المرجع غير المباشر N G R على هيئة المصفوفة ['type' => 'ref', 'num' => N, 'gen' => G]. يتعرّف PdfObject::getRef() وPdfReader::resolveRef() على هذه الهيئة.

CrossRefParser — حلّ المراجع المتقاطعة

قسم بعنوان «CrossRefParser — حلّ المراجع المتقاطعة»

يحلّل CrossRefParser الصيغتين اللتين قد يصدرهما ⁨Chrome⁩:

  • parseXRefTable() يقرأ جدول xref تقليديًّا (بأسلوب ⁨PDF 1.x⁩): ترويسات الأقسام الفرعية، ومدخلات ثابتة العرض بحجم 20 بايتًا، ثم قاموس trailer.
  • parseXRefStream() يقرأ دفق مراجع متقاطعة (⁨PDF 2.0⁩، ⁨ISO 32000-2⁩:2020 §7.5.8): كائن غير مباشر يحمل /Type /XRef، ومصفوفة عرض حقول /W، ودفقًا ثنائيًّا من المدخلات.

كلاهما يُعيد الهيئة نفسها: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. يقرّر PdfReader::parse() أي محلِّل يستدعي عبر استراق النظر إلى البايتات الأربعة عند إزاحة المراجع المتقاطعة: xref يختار محلِّل الجدول، وأي شيء آخر يُعامَل بوصفه كائن دفق. يفرض كلا المحلِّلين سقفًا قدره مليون مدخل لكل قسم فرعي لرفض الأعداد المزوَّرة التي قد تجعل المحلِّل يعمل بإفراط.

StreamDecoder::decode(string $data, string|array $filter) طريقة ساكنة تطبّق مرشّحًا واحدًا أو قائمة مرشّحات متسلسلة. وهي لا تدعم إلا المرشّحات التي يصدرها printToPDF في ⁨Chrome⁩:

  • FlateDecode (⁨zlib⁩، مع احتياطي ⁨raw-deflate⁩)
  • ASCIIHexDecode
  • ASCII85Decode

أي اسم مرشّح آخر يطرح PdfParseException مع Unsupported stream filter. يحدّ المُفكِّك المُخرَج المفكوك ضغطه عند 16 ⁨MiB⁩ لتقليل خطر قنبلة فكّ الضغط. المُخرَج المفرط الحجم يطرح استثناءً بدلًا من التخصيص بلا حدّ. عندما يقرأ PdfReader دفقًا ويطرح فكّ الترميز خطأً، يلجأ إلى بايتات الدفق الخام، فلا يُفشِل مرشّح واحد تالف التحليل بأكمله.

ResourceCollector — الاجتياز العميق للموارد

قسم بعنوان «ResourceCollector — الاجتياز العميق للموارد»

يُنشأ ResourceCollector باستخدام PdfReader ويُستدعى عبر PdfReader::collectPageResources(). طريقته traverse() تجتاز قيمة تعاوديًّا، وتتّبع كل مرجع ['type' => 'ref'] عبر getObject()، وتسجّل كل كائن محلول مرة واحدة في array<int, PdfObject> مفهرسة برقم الكائن. تحدّ من عمق التعاود وتتخطّى بصمت المراجع التي لا تستطيع حلّها، فيُنتِج مرجع معلّق واحد مجموعة جزئية بدلًا من فشل قاطع.

RevisionExtractor — التحديثات التزايدية والمراجعات

قسم بعنوان «RevisionExtractor — التحديثات التزايدية والمراجعات»

ملف ⁨PDF⁩ الذي وُقِّع أو أُضيفت إليه تعليقات أو عُدّل بطريقة أخرى بعد الإنشاء يحتوي على تحديثات تزايدية. كل تعديل يُلحِق قسم مراجع متقاطعة جديدًا وذيلًا ينتهي بعلامة %%EOF. يعمل RevisionExtractor بالكامل من خلال طرائق ساكنة فوق PdfReader مُحلَّل:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) يُعيد الملف مقتطعًا عند حدّ %%EOF للمراجعة المطلوبة. المراجعة 0 (الأحدث) تُعيد الملف بأكمله؛ والفهارس الأعلى تُعيد لقطات أقدم تدريجيًّا.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) يُعيد list<array{revision, startByte, endByte, sizeBytes}> يصف نطاق البايت الذي أسهمت به كل مراجعة.

هذا العزل متعمَّد. استخراج مراجعة أقدم يكشف فقط الكائنات المرئية حتى تلك النقطة، ما يحجب هجمات المراجع المتقاطعة الهجينة التي تُعيد فيها مراجعة لاحقة تعريف كائن سابق.

يفحص هذا الإجراء سجلّ مراجعات ملف ⁨PDF⁩ قد يكون عُدّل بعد أن أنتجه ⁨Chrome.⁩ المثال مُهيّأ للإنتاج: يعلن الأنواع الصارمة، ويستعمل تلميحات نوع كاملة، ويتحقّق من مُدخَله، ويلتقط الاستثناء الأكثر تحديدًا.

  1. اقرأ بايتات ⁨PDF⁩ إلى الذاكرة، وارفض المُدخَل الفارغ قبل إنشاء القارئ.
  2. أنشئ \NextPDF\Parser\PdfReader واستدعِ parse().
  3. اقرأ getRevisionCount(). القيمة 1 تعني ملفًا بمراجعة واحدة دون تحديثات تزايدية.
  4. لكل مراجعة، اقرأ RevisionXRefTable الخاص بها وافحص getActiveObjectCount()، وhasRootUpdate()، وgetSize().
  5. احسب نطاقات البايت لكل مراجعة باستخدام RevisionExtractor::getRevisionBoundaries().
  6. التقط PdfParseException، وهو أكثر استثناء محدّد يطرحه المحلِّل، وأظهِر رسالة تشخيصية.
examples/inspect-revisions.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
use NextPDF\Parser\RevisionXRefTable;
/**
* Inspect the incremental-update history of a PDF file.
*
* @return list<array{revision: int, activeObjects: int, rootUpdate: bool, size: int, startByte: int, endByte: int, sizeBytes: int}>
*
* @throws PdfParseException If the file is not a readable PDF.
*/
function inspectRevisions(string $path): array
{
$pdfData = \file_get_contents($path);
if ($pdfData === false || $pdfData === '') {
throw new PdfParseException("Cannot read PDF bytes from path: {$path}");
}
$reader = new PdfReader($pdfData);
$reader->parse();
$boundaries = RevisionExtractor::getRevisionBoundaries($pdfData, $reader);
$report = [];
foreach ($reader->getRevisions() as $table) {
\assert($table instanceof RevisionXRefTable);
$index = $table->index;
$boundary = $boundaries[$index];
$report[] = [
'revision' => $index,
'activeObjects' => $table->getActiveObjectCount(),
'rootUpdate' => $table->hasRootUpdate(),
'size' => $table->getSize(),
'startByte' => $boundary['startByte'],
'endByte' => $boundary['endByte'],
'sizeBytes' => $boundary['sizeBytes'],
];
}
return $report;
}

يرتّب القارئ المراجعات من الأحدث (index0) إلى الأقدم. لاستخراج لقطة أقدم واحدة بوصفها بايتات مستقلة، مثلًا لمقارنة ما غيّره تعديل، استدعِ المُستخرِج مباشرة:

examples/extract-revision.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
/**
* Extract one revision of a PDF as standalone bytes.
*
* @throws PdfParseException If the file is unreadable or the revision index is out of range.
*/
function extractRevision(string $pdfData, int $revision): string
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
$reader->parse();
// Throws PdfParseException with an "out of range" message for an invalid index.
return RevisionExtractor::extractRevision($pdfData, $reader, $revision);
}

يظهر كل عطل في المحلِّل بوصفه NextPDF\Artisan\Exception\PdfParseException. تحدّد الرسالة السبب. استعمل الجدول أدناه لربط جزء من الرسالة بالمرحلة التي أطلقته.

جزء الرسالةالمرحلةماذا يعني
missing %PDF- headerPdfReader::parse()البايتات ليست ⁨PDF⁩، أو أن المُدخَل اقتُطع في بدايته.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()ذيل الملف معطوب، أو مؤشّر المراجع المتقاطعة خارج الحدود.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()جدول مراجع متقاطعة تقليدي مُشوَّه.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()دفق مراجع متقاطعة تنقصه مدخلات قاموس مطلوبة.
exceeds limit of (عدد ⁨xref⁩ أو دفق الكائنات)CrossRefParser / PdfReaderعدد مزوَّر تخطّى حارس الحجب عن الخدمة.
Unsupported stream filterStreamDecoder::decode()يستعمل الدفق مرشّحًا خارج المجموعة المدعومة FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderالبيانات المضغوطة غير صالحة أو تتمدّد بما يتجاوز سقف 16 ⁨MiB.⁩
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerبنية مُصاغة خصّيصًا أو مَرَضية تخطّت حدّ المُحوِّل.
Page index ... not found / out of range in subtreePdfReader::getPage()فهرس الصفحة المطلوب غير موجود في شجرة الصفحات.
Revision index ... out of rangePdfReader / RevisionExtractorفهرس المراجعة خارج النطاق من 0 إلى getRevisionCount() - 1.

عندما تلتقط الاستثناء، سجّل الرسالة ومسار المصدر، ثم أعِد طرحه أو أعِد خطأً معرَّفًا. لا تتجاهله بصمت. كتلة catch فارغة تُخفي المعلومة الوحيدة التي اجتهد المحلِّل لإنتاجها.

examples/parse-with-diagnostics.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use Psr\Log\LoggerInterface;
/**
* Parse a PDF, logging the precise parser-stage message on failure.
*
* @throws PdfParseException Rethrown after logging so the caller can decide policy.
*/
function parseWithDiagnostics(string $pdfData, LoggerInterface $logger): PdfReader
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
try {
$reader->parse();
} catch (PdfParseException $exception) {
$logger->error('PDF parse failed', [
'reason' => $exception->getMessage(),
'bytes' => \strlen($pdfData),
]);
throw $exception;
}
return $reader;
}

الإعدادات الافتراضية الآمنة

قسم بعنوان «الإعدادات الافتراضية الآمنة»
  • استدعِ parse() أولًا دائمًا. كل طريقة وصول على PdfReader تفترض أن سلسلة المراجع المتقاطعة محمَّلة. استدعاء getObject() أو getPage() قبل parse() لا يُعيد شيئًا مفيدًا.
  • عامِل المحلِّل بوصفه للقراءة فقط ومصمَّمًا لهيئة ⁨Chrome.⁩ يستهدف المجموعة الجزئية من بنية ⁨PDF⁩ التي يصدرها printToPDF في ⁨Chrome.⁩ ملفات ⁨PDF⁩ المشفّرة، وجداول التلميح الخطّية، والتحديثات التزايدية المتعارضة خارج النطاق بحكم التصميم. لا توسّعه ليصبح أداة إصلاح ⁨PDF⁩ عامة.
  • أبقِ حدود الأمان في مكانها. تحدّ سقوف التداخل، وطول الكلمات المفتاحية، وحجم المصفوفة، وعدد المراجع المتقاطعة، وفكّ الضغط من استهلاك الموارد على المُدخَل المعادي. PdfParseException الناتج عن تجاوز حدّ هو النتيجة الصحيحة لملف مُصاغ خصّيصًا. رفع حدٍّ لقبول مثل هذا الملف يوسّع سطح الهجوم.
  • اجعل الصفحة 0 الافتراضية. getPage() وPageImporter::import() يُعيّنان أول صفحة افتراضيًّا. اختر فهرسًا آخر فقط حين يحتاج سير العمل إليه عمدًا.
  • تحقّق من المُدخَل قبل إنشاء القارئ. ارفض البايتات الفارغة أو غير القابلة للقراءة مبكّرًا، كما تفعل الأمثلة أعلاه، كي يظهر خطأ واضح على مستوى التطبيق قبل أي استثناء من المحلِّل.
  • التقط PdfParseException، لا \Exception المجرّد أبدًا. إنه النوع الوحيد المحدَّد الذي يطرحه المحلِّل. التقاطه يمنع إخفاء الأعطال غير ذات الصلة.
  • دليل مطوّري ⁨Artisan⁩ — حدود الاستيراد فوق المحلِّل، بما في ذلك ChromeHtmlRenderer، وPageImporter، وطبقات البنية.
  • مرجع واجهة ⁨Artisan⁩ البرمجية — جداول الطرائق المنشورة لسطح الحزمة العام.
  • استكشاف أعطال ⁨Artisan⁩ — إرشاد قائم على الأعراض لأعطال العرض والاستيراد.
  • إعداد عارض ⁨Chrome⁩ — ضبط العارض الذي يُنتِج ملفات ⁨PDF⁩ التي يقرأها هذا المحلِّل.
  • ⁨ISO 32000-2⁩:2020 §7.5 (بنية الملف، والمراجع المتقاطعة، والتحديثات التزايدية) و§7.2 (الاصطلاحات المعجمية) — المواصفة التي ينفّذها المحلّل المعجمي ومحلِّل المراجع المتقاطعة. راجع المعيار المنشور للاطّلاع على الصيغة الموثوقة على مستوى البايت.