مقال تقني

التحقق من صحة ملفات PDF المضغوطة: تدفقات الكائنات وXRef

تكتب مدققاً صغيراً. يفتح ملف PDF، ويبحث عن النهاية، ويجد startxref، ويقرأ الإزاحة (offset)، ويتوقع أن يهبط على الكلمة الرئيسية xref مع وجود جدول إسناد ترافقي ذي عرض ثابت تحته. ومن هذا الجدول يجمع إزاحات الكائنات، ثم يمسح للخلف بحثاً عن الكلمة الرئيسية trailer لمعرفة /Root و/Size. ويعمل هذا بشكل مثالي على كل ملف قمت بتوليده لاختباره. ثم يصل ملف تم إنتاجه بواسطة إصدار حديث من Word، أو بواسطة مكتبة تستهدف PDF 1.5، ويعلن المدقق أنه تالف. حيث لا توجد كلمة رئيسية xref حيث تشير الإزاحة، ولا يوجد قاموس trailer في أي مكان، وجدول الكائنات الذي بناه المدقق فارغ تقريباً. الملف صالح. والمدقق يقرأه من خلال عدسة عمرها خمسة عشر عاماً

هذا هو السبب الفردي الأكثر شيوعاً لفشل فحص PDF على مستوى البايت المكتوب ضد التخطيط الكلاسيكي على المستندات الحديثة. فالبنية التي يعتمد عليها، وجدول الإسناد الترافقي النصي الصريح والكلمة الرئيسية trailer، أصبحت اختيارية في PDF 1.5 وغالباً ما تكون غائبة. وحلت ميزتان مكانها: تدفق الإسناد الترافقي (cross-reference stream) وتدفق الكائنات المضغوطة (compressed object stream). وكلاهما موصوف في معيار ISO 32000-1، والمدقق الذي لا يعرف عنهما يرى ملفاً سليماً ككومة من الكائنات المفقودة

ما الذي غيره PDF 1.5 بشأن نهاية الملف

يحدد معيار ISO 32000-1 الفقرة 7.5.8 تدفق الإسناد الترافقي، وتحدد الفقرة 7.5.7 تدفق الكائنات من النوع /ObjStm. ومعاً يتيحان للكاتب إسقاط البنيتين اللتين يعتمد عليهما المحلل الكلاسيكي. وقد ينتهي ملف PDF 1.5 بدون جدول xref على الإطلاق. وبدلاً منه، يكون الكائن الذي يشير إليه startxref عبارة عن كائن تدفق عادي يحمل قاموسه /Type /XRef، ويحتفظ هذا التدفق ببيانات الإسناد الترافقي في شكل ثنائي مدمج. ولا توجد كلمة رئيسية trailer أيضاً، لأن المقطورة هي الآن قاموس التدفق نفسه. والمفاتيح التي يبحث عنها المحلل الكلاسيكي، مثل /Root و/Size و/ID، تعيش داخل هذا القاموس

التغيير الثاني ينقل الكائنات نفسها. فبدلاً من كتابة كل كائن غير مباشر عند إزاحة البايت الخاصة به، يمكن للكاتب حزم العديد من الكائنات الصغيرة، وقواميس الصفحات، وقواميس التعليقات التوضيحية، وشجرة البنية، في تدفق كائنات واحد وضغط الحاوية بأكملها باستخدام Flate. ولم يعد للكائنات الفردية إزاحة بايت في الملف. بل لديها موضع داخل كتلة مضغوطة. والمدقق الذي يمسح البايتات الخام بحثاً عن 1 0 obj لا يجدها أبداً، لأن هذا النص لا يوجد إلا بعد النفخ (inflation). بالنسبة للمحلل الكلاسيكي، فإن نصف المستند قد اختفى ببساطة

مفاتيح المقطورة هي نص صريح، حتى في الملف المضغوط

الجزء المطمئن هو أن قراءة مقطورة تدفق الإسناد الترافقي لا تتطلب نفخ أي شيء. حيث يُكتب كائن التدفق كقاموس متبوعاً بالكلمة الرئيسية stream ثم البايتات المضغوطة. والقاموس عبارة عن نص صريح. لذلك عندما يشير startxref إلى تدفق إسناد ترافقي، فإن البايتات التي تلي رقم الكائن مباشرة تبدو كقاموس عادي، وتجلس /Root و/Size و/ID هناك بوضوح، قبل أن تبدأ الكلمة الرئيسية stream وبيانات Flate

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

تدفقات الكائنات: رأس، ثم كتلة Flate

تدفق الكائنات عبارة عن حاوية. ويحمل قاموسه /Type /ObjStm، وإدخال /N يعطي عدد الكائنات المحزومة بالداخل، وإدخال /First يعطي إزاحة البايت، داخل البيانات المنفوخة، حيث يبدأ جسم الكائن الأول. وتبدأ الحمولة المضغوطة، بمجرد نفخها، برأس صغير من أزواج الأعداد الصحيحة لـ /N. وكل زوج عبارة عن رقم كائن وإزاحة جسم هذا الكائن بالنسبة لـ /First. وبعد الرأس تأتي أجسام الكائنات نفسها متسلسلة

توسيع أحدها هو عملية ميكانيكية بمجرد نفخ البايتات. حيث تقرأ القاموس للحصول على /N و/First، وتنفخ التدفق باستخدام وحدة فك ترميز Flate، وتتصفح أزواج /N الرائدة لمعرفة أي رقم كائن يعيش عند أي إزاحة، ثم ترفع كل جسم للخارج كما لو كان كائناً غير مباشر عادياً. والاعتماد الحقيقي الوحيد هو وحدة فك ترميز Flate، ولديك بالفعل واحدة: تشحن Delphi ‏System.ZLib، وتشحن Free Pascal وحدة zstream، وكلاهما يغلف zlib وينفخ تدفق Flate الخام دون أي كود من جهة خارجية. والروتين الذي يلحق كل كائن مستخرج بجدول كائنات المدقق يجعل بقية المدقق، وهو الجزء الذي يتصفح /Root ويفحص شجرة الصفحات، يتصرف تماماً كما يفعل في ملف كلاسيكي

ما لا يتعين عليك تنفيذه

من السهل المبالغة في تقدير العمل. فقراءة مفاتيح المقطورة من ملف مضغوط لا تتطلب فك ترميز الإدخالات الثنائية لتدفق الإسناد الترافقي. ويستخدم تدفق الإسناد الترافقي للفقرة 7.5.8 ثلاثة أنواع من الإدخالات، والإدخال من النوع 2، وهو الذي يقول "يعيش هذا الكائن داخل تدفق الكائنات N عند الفهرس i"، هو ما ستفك ترميزه لبناء خريطة إزاحة كاملة. وتحتاج إلى تلك الخريطة لحل الكائنات العشوائية بالرقم. ولا تحتاجها لقراءة /Root و/Size و/ID، والتي تقع في القاموس النصي الصريح، ولا تحتاجها لتوسيع تدفقات الكائنات، لأن كل /ObjStm يعلن عن محتوياته الخاصة من خلال /N و/First

كذلك لا يتعين عليك التعامل مع وظائف التنبؤ لـ PNG وTIFF التي قد يطبقها تدفق الإسناد الترافقي من خلال /DecodeParms لمجرد الحصول على مفاتيح المقطورة. فالمتنبئات تقوم بتصفية صفوف الإسناد الترافقي الثنائية لجعلها تضغط بشكل أفضل؛ وليس لها علاقة بالقاموس الذي يسبق التدفق. وبالتالي فإن الترقية البسيطة التي تجعل المدقق الكلاسيكي مدركاً لـ PDF الحديث صغيرة: عندما تهبط startxref على تدفق بدلاً من الكلمة الرئيسية xref، قم بتحليل قاموس التدفق بحثاً عن مفاتيح المقطورة، وقم بتوسيع أي كائنات /ObjStm تصادفها حتى تدخل محتوياتها في جدول الكائنات. وفك ترميز إدخالات النوع 2 والمتنبئات هي مهمة منفصلة وأكبر يمكنك تأجيلها حتى تحتاج فعلياً إلى حل كائن عشوائي

لماذا يجب أن يوسع فحص الامتثال التدفقات أولاً

يتوقف هذا عن كونها أكاديمياً في اللحظة التي تقوم فيها بفحص ملف التعريف. حيث يقوم مدقق PDF/A أو PDF/X بفحص كائنات محددة: كتالوج المستند لمصفوفة /OutputIntents، وتدفق /Metadata لحزمة XMP بالمعرف الصحيح، وكل واصف خط لملف خط مضمن، والمقطورة لـ /ID. وفي الملف المضغوط، تقع معظم تلك الكائنات داخل تدفقات الكائنات. والمدقق الذي لم يقم بتوسيع تدفقات الكائنات لا يمكنه رؤية مفاتيح الكتالوج، ولا يمكنه العثور على البيانات الوصفية، ولا يمكنه تعداد الخطوط. وسيقوم بالإبلاغ عن مستند مطابق تماماً كونه يفتقر إلى نية الإخراج (output intent)، ويفتقر إلى XMP، ويفتقر إلى نصف بنيته، لأن الدليل الذي يحتاجه لا يزال يقبع في كتلة Flate لم يقم بنفخها أبداً

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

السماح لـ PDFium بالقيام بالتحليل من أجلك

يقوم مكون PDFium بتحليل تدفقات الإسناد الترافقي وتدفقات الكائنات كجزء من تحميل المستند، وهي الطريقة العملية لتجنب صنع خطوة النفخ والتوسيع يدوياً. وعندما تقوم بتحميل ملف باستخدام مكون TPdf، فإن الكائنات المحزومة في حاويات /ObjStm تكون قد تم حلها بالفعل، وترى نقاط دخول التحقق المستند موسعاً بالكامل. وتعيد ValidatePdfA سجل TPdfAValidationResult الذي يكون حقل Conformance الخاص به عبارة عن قيمة TPdfAConformance مثل pac1b أو pacNone، ويكون حقل Issues الخاص به عبارة عن مجموعة من المشاكل المحددة التي تم العثور عليها، ويكون أسلوب IsCompliant الخاص به صحيحاً فقط عند اكتشاف مستوى مطابقة وتكون مجموعة المشاكل فارغة. ولأن الكائنات تم توسيعها أثناء التحميل، يتم العثور على مصفوفة /OutputIntents أو خط مضمن كان يعيش داخل تدفق كائنات، ولا يتم الإبلاغ عن فقده

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

ينطبق الأمر نفسه على ValidatePdfX، والتي تعيد TPdfXValidationResult بنفس الشكل. والنقطة من التوجيه عبر PDFium هي أن فك الضغط الهيكلي الموصوف أعلاه يحدث مرة واحدة، وبشكل صحيح، داخل المحمل، لذا لا يرى كود التحقق الخاص بك أبداً الفرق بين ملف كلاسيكي وآخر مضغوط بالكامل. وكلاهما يصلان إلى المدقق كمجموعة كائنات محلولة

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

إذا كانت البايتات في الذاكرة بالفعل بدلاً من القرص، فإن نفس تسلسل التحميل ثم التحقق يعمل من خلال التحميل الزائد (overload) ‏LoadDocument(const Data: TBytes)، الذي يأخذ محتوى الملف الخام ويحلل إسناده الترافقي وتدفقات الكائنات بنفس الطريقة التي يفعلها مسار الملف. والعبرة لمدقق مكتوب يدوياً هي القاعدة الهيكلية، وليست واجهة برمجة التطبيقات: اقرأ مفاتيح المقطورة من قاموس التدفق في نص صريح، ووسع كل /ObjStm بفك ترميز Flate قبل أن تتصفح المستند، وعامل فك ترميز إدخالات الإسناد الترافقي الثنائية كمهمة أكبر واختيارية

بمجرد توسيع البنية، يمكن للمدقق تشغيل بقية تدفق العمل عليها. ولمشغل preflight لسطر الأوامر يبلغ عن المطابقة عبر مجلد من المدخلات، راجع دليلنا حول بناء واجهة تفاعلية لتقرير preflight الدفعي CLI. وعندما يكون التحقق بوابة قبل تقسيم مستند كبير، فإن التقنيات الواردة في دليلنا لتقسيم مستندات PDF إلى ملفات متعددة تقترن بشكل طبيعي مع نمط التحميل والفحص الموضح هنا. وكلاهما يبنيان على سطح التحميل والتحقق لـ مكون PDFium Component لدلفي وC++Builder