مقال تقني

بناء طاولة عمل مراجعة مدخلات PDF في Delphi مع مكون PDFium

إن طاولة عمل (workbench) مراجعة استقبال PDF عبارة عن برنامج صغير بوظيفة واحدة: النظر في كل ملف قبل السماح لأي شيء أسفل المجرى بلمسه. للقيام بهذه المهمة، يجب تجميع مجموعة من الإمكانيات في تمريرة واحدة. إنه يفتح الملف (دون الوثوق به)، ويقرأ ما يدعيه الملف عن نفسه، ويبحث عن محتوى من شأنه أن يضلل أداة استخراج ساذجة أو يحمل هجومًا، ويقرر ما إذا كان هناك أي نص قابل للاستخراج على الإطلاق، ثم يوجه المستند إلى قائمة انتظار بناءً على ما وجده. تخط التفتيش وتكون حالات الفشل هادئة: إن ملف PDF المشفر بكلمة مرور المالك والذي يلف نموذج XFA يبحر عبر مستخرج النص كسلاسل فارغة، ويتم فهرسته كمستند فارغ، ولا يلاحظ أحد ذلك حتى يبحث شخص ما في المصب عن محتوى لم يُقرأ أبدًا. مكون PDFium عبارة عن عارض شفرة المصدر VCL/LCL ومكتبة فحص لـ Delphi و C++Builder و Lazarus، وهو يكشف عن مكالمات الاستبطان (introspection calls) التي تحتاجها طاولة العمل هذه. توضح الأقسام أدناه أي مكالمة تجيب على أي سؤال، والمكانين اللذين تمنحك فيهما المكالمة الواضحة إجابة خاطئة بثقة

خمسة أسئلة للإجابة عليها قبل توجيه الملف

انزع الشبكة وشريط الصور المصغرة، ويتقلص فرز المدخولات (intake triage) إلى خمسة أسئلة:

  • هل يمكن فتح الملف على الإطلاق، وتحت أي كلمة مرور؟
  • ماذا تدعي أن تكون: العنوان، المؤلف، تاريخ الإنشاء؟
  • هل تحمل محتوى نشطًا أو محفوفًا بالمخاطر مثل جافا سكريبت أو نموذج XFA أو ملفات مضمنة؟
  • هل يوجد نص قابل للاستخراج، أم أنه مسح متجه نحو OCR؟
  • بالنظر إلى كل ذلك، أي قائمة انتظار تحصل عليه: معالجة مستقيمة، أم مراجعة يدوية، أم حجر صحي (quarantine)؟

يتم تعيين كل سؤال في واحد أو اثنين من مكالمات مكون PDFium. يحتوي اثنان من هذه التعيينات على زوايا حادة تفسر معظم الملفات الموجهة بشكل خاطئ والتي اضطررت إلى تصحيحها في الإنتاج. تعيش البيانات الوصفية (metadata) للمستند في مكانين مختلفين يمكن أن يختلفا، والتشفير لا يمنع بالضرورة المستند من الفتح

مفتوح بثمن بخس: ملء النموذج مغلق، وتم تصيير صفر صفحة

يجب أن يكون الفرز (Triage) هو أرخص فتح ممكن. إن إعداد FormFill := False قبل Active := True يخبر المكون بتخطي بيئة ملء النموذج بالكامل. هذا يقصر وقت التحميل، و (بنفس الأهمية لملفات ذات أصل غير معروف) يمنع أي تهيئة لـ جافا سكريبت على مستوى المستند. لا تتطلب أي من خصائص التفتيش المستخدمة أدناه تصيير صفحة، لذلك لا يضطر تمرير الفرز أبداً إلى إنتاج صورة نقطية واحدة

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // no form environment, no JavaScript init
    Pdf.Active := True;        // failure is silent: Active simply stays False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // damaged file or user-password lock
      Exit;                    // the finally block still runs
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // never leak the instance on a malformed file
  end;
end;

الفحص بعد التعيين ليس اختيارياً، وهو فحص وليس معالج استثناء لسبب ما. عندما يتعذر على المحرك تحميل الملف، يبتلع المكون EPdfError الداخلي ويترك Active عند False بدلاً من نشره. الشفرة التي تنتظر الاستثناء ستقرأ بسعادة PageCount من مستند لم يُفتح قط. إذا كان سير عمل الرفض يحتاج إلى نص الخطأ الفعلي للمحرك، فاقرأ الملف في مصفوفة بايت (byte array) واستدع تحميل LoadDocument الزائد الذي يأخذ TBytes؛ هذا المسار يثير EPdfError مع الرسالة، بما في ذلك حالة كلمة المرور. لا يزال try..finally يكسب مكانه. تعمل خدمات السحب دون مراقبة لأسابيع، ولا يجوز لأي استثناء لاحق أن يسرب مثيل TPdf أو يمسك بقفل سيتعثر فيه تمرير إعادة المحاولة

نادرًا ما تصبح الإنتاجية عنق الزجاجة. مع تعطيل ملء النموذج وعدم وجود تصيير، يهيمن الإدخال/الإخراج (I/O) على الفتح بالفرز، ويقوم عامل واحد بفحص عدة ملفات مريحة في الثانية من القرص المحلي. إذا زاد حجم الاستيعاب (intake volume) يومًا ما عن عامل واحد، فقم بتقسيم العمل حسب الملف بدلاً من الفحص. تشترك الأسئلة الخمسة في واحدة مفتوحة، وسيؤدي تقسيمها عبر العمليات إلى مضاعفة الخطوة الأكثر تكلفة بدلاً من إطفائها

تعيش البيانات الوصفية في مكانين، ويختلفان

يحدد ISO 32000-1 موطنين للبيانات الوصفية للمستند: قاموس معلومات المستند (البند 14.3.3) وحزمة XMP ملحقة بالكتالوج (البند 14.3.2). تقرأ خصائص Title و Author و Subject و CreationDate قاموس Info (معلومات)، مع MetaText[] لأي مفتاح آخر و DecodeDate لتحليل سلسلة التاريخ D:YYYYMMDD.... والمشكلة هي أن المنتجين الحديثين يكتبون XMP بشكل متزايد فقط، وهو اتجاه يجعله ISO 32000-2 رسميًا من خلال إهمال معظم مفاتيح قاموس Info في PDF 2.0. العرض في أداة المدخول هو الخرسانة. تظهر طاولة العمل الخاصة بك عنوانًا فارغًا بينما يعرض Adobe Acrobat واحدًا، لأن Acrobat تراجع إلى dc:title داخل حزمة XMP، والتي لا تلمسها خصائص قاموس Info أبدًا

procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // Info dictionary value
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // raw PDF date string ("D:2026...")

  // An empty Info title does not mean the document is untitled. The
  // component does not expose the XMP packet, so probe the raw file
  // bytes for the dc:title element before trusting the blank.
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

حتى مسبار السلسلة الفرعية (substring probe) الخام أعلاه يكسب ما يبقيه: "البيانات الوصفية موجودة، ولكن ليس حيث تبحث الأدوات القديمة" هي حقيقة ذات صلة بالتوجيه لأي مسار أرشيف (archive pipeline) يفهرس بناءً على العنوان أو المؤلف. إذا كان فهرس المصب الخاص بك يقرأ فقط قاموس Info، فستصبح الملفات المميزة بهذه الطريقة صامتة غير قابلة للبحث

الملفات المشفرة التي تفتح على أي حال

لا يفشل المستند المشفر بالضرورة في الفتح. يميز معالج الأمان القياسي (بند ISO 32000-1 7.6.3) بين كلمة مرور المستخدم، المطلوبة لفتح المستند، من كلمة مرور المالك التي تقيد الأذونات مثل الطباعة والنسخ فقط. حصة كبيرة من مستندات العمل "المحمية" مشفرة بكلمة مرور المالك وكلمة مرور مستخدم فارغة. فهي تفتح دون مطالبة، وتُفك التشفير بالكامل، وتعتمد على المتفرجين المتطوعين لتكريم علامات الإذن. هذه سياسة، وليست حماية، ويجب أن تعكس حالات استيعابك الاختلاف

اكتشاف التشفير بعد فتح ناجح يأخذ استدعاء محرك واحد بالإضافة إلى العودة (fallback). يُرجع FPDF_GetSecurityHandlerRevision(Pdf.Document) -1 للملفات غير المحمية ومراجعة المعالج بخلاف ذلك، و إرجاع Pdf.Permissions لأي شيء بخلاف القناع المحدد لكل البتات (all-bits-set mask) $FFFFFFFF هو إشارة مؤيدة. بالنسبة للملفات المقفلة بكلمة مرور المستخدم بشكل حقيقي، قم بتعيين Password قبل تعيين Active := True؛ إذا استمر الفتح بالفشل، فقم بتوجيه الملف إلى حالة حظر تطلب بيانات الاعتماد من المرسل عبر قناة آمنة بدلاً من إعادة المحاولة بشكل أعمى. وقاوم إغراء التعامل مع "مشفرة" على أنها حجر صحي تلقائي. في معظم الصناعات الثقيلة للمستندات، تكون الملفات المشفرة ولكن القابلة للفتح هي الحالة الطبيعية، وليست المشبوهة

المحتوى النشط: جافا سكريبت و XFA والملفات المضمنة

يجب أن تصل ثلاثة نتائج دائماً إلى قرار التوجيه. أولاً، جافا سكريبت: يقوم الحدث OnUnsupportedFeature بالإبلاغ عن الميزات الهيكلية مثل XFA أو المحتوى ثلاثي الأبعاد عندما يواجهها المحرك، ولكنه لا يكتشف جافا سكريبت. تحقق من JavaScriptActionCount بدلاً من ذلك وعامل النتيجة غير الصفرية كمحتوى نشط. ثانيًا، XFA: عندما يعود FormType بـ ftXfaFull، غالبًا ما تكون الصفحات المرئية أكثر قليلاً من تصيير قالب XFA، وسيُظهر استخراج النص التقليدي نصًا نمطيًا (boilerplate) بدلاً من القيم المعبأة. ثالثًا، المرفقات: يعد PDF تنسيقًا للحاوية، ويخبرك AttachmentCount ما إذا كان هذا التنسيق يحمل ركابًا

procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
  i, PageNo: Integer;
  Ext: string;
begin
  Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
    (FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
  Rec.HasForms := Pdf.FormType <> ftNone;
  Rec.IsXfa := Pdf.FormType = ftXfaFull;
  Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;

  // AnnotationCount is a per-page property; walk the pages to total
  // it. Loading a page object renders nothing, so this stays cheap.
  Rec.Annotations := 0;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    Inc(Rec.Annotations, Pdf.AnnotationCount);
  end;

  Rec.Attachments := Pdf.AttachmentCount;

  for i := 0 to Rec.Attachments - 1 do
  begin
    Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
    if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
      Include(Rec.Flags, ifDangerousAttachment);
  end;
end;

يستحق تفصيلان في تلك الحلقة الانتباه. يأتي اسم المرفق من داخل المستند، لذا لا تعيد استخدامه كمسار إخراج دون التعقيم أولاً؛ الاسم المضمن مثل ..\..\start.exe هو اجتياز مسار في انتظار استدعاء حفظ لا مبالي. وقائمة حظر الملحقات (extension blocklist) هي سلك تعثر (tripwire)، وليست ضمانًا. وظيفتها هي فرض قرار بشري، وليس التصديق على نظافة الملف

تحويل الإشارات إلى حالات التوجيه

يحتاج نموذج الحالة الصالح للعمل إلى حالات أقل مما يتوقعه معظم الفرق: جاهز (ready) (لا توجد حواجز، نص موجود)، مراجعة (review) (نجح الفتح ولكن شيئًا ما يحتاج إلى عيون، مثل نموذج XFA، أو جافا سكريبت، أو طبقة نص فارغة، أو عنوان فقط في XMP)، محظور (blocked) (مطلوب كلمة مرور المستخدم)، و تالف (damaged) (فشل الفتح). سجل الأدلة بجانب الدولة. تهم تجزئة الملف (file hash)، وعدد الصفحات، والعلامات الدقيقة، ورسالة خطأ المحرك للملفات التالفة جميعها، لأن الشخص الذي يشكك في قرار التوجيه سيفعل ذلك بعد أسابيع، ضد ملف ربما تم استبداله أو تعديله منذ ذلك الحين

عندما يحتاج المُشغل إلى النظر في ملف معزول (quarantined file)، لا تُسلمه إلى عارض قشرة (shell viewer) الافتراضي. صيرها داخل لوحة صلبة مع تعطيل البرمجة النصية والتعامل مع الارتباط، وهو النهج الموصوف في بناء سطح معاينة PDF آمن في Delphi. وإذا كان مدخولك يغذي أرشيفًا بمتطلبات التوافق، فإن ممر الفرز (triage pass) هو المكان الطبيعي لجدولة فحص أعمق؛ تلتقط التحقق من صحة ما قبل الرحلة للدُفعة (batch preflight validation) مقابل ملفات تعريف PDF/A و PDF/UA من النقطة التي يتوقف فيها هذا التفتيش

تغطي صفحة منتج المكون الترخيص، و API الفحص الكامل، والعروض التوضيحية المُجمعة، بما في ذلك فاحص مستند بأسلوب الاستيعاب: مكون PDFium