مقال تقني

بناء منصة فحص استقبال PDF في Delphi باستخدام PDFium

في مسار مطالبات تأمين عملت عليه، خسر الفريق نصف يوم بسبب ملف وارد واحد. "العقد الموقّع" الذي رفعه وسيط كان PDF مشفراً بكلمة مرور owner يلتف حول نموذج XFA: مستخرج النص downstream أعاد strings فارغة، وفهرسه النظام كمستند فارغ، ولم يلاحظ أحد حتى اتصل صاحب الوثيقة. الفشل لم يكن في المستخرج. كان في أن أي كود لم ينظر فعلاً إلى الملف قبل توجيهه. كل فريق يقبل PDFs من العالم الخارجي يبني الشيء نفسه في النهاية: منصة intake تفحص كل مستند وتقرر أين يسمح له بالذهاب. يوفّر PDFium Component، وهو مكتبة عرض وفحص مستندات VCL/LCL مع source code لـ Delphi وC++Builder وLazarus، استدعاءات introspection لبناء تلك المنصة؛ وبقية هذا المقال عن أي استدعاءات تجيب عن أي أسئلة، وأين يمكن أن تضللك.

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

إذا نزعت grid وشريط thumbnails، فإن triage الاستقبال ينخفض إلى خمسة أسئلة:

  • هل يمكن فتح الملف أصلاً، وبأي كلمة مرور؟
  • ما الذي يدّعيه عن نفسه: title وauthor وcreation date؟
  • هل يحمل محتوى نشطاً أو خطراً: JavaScript أو نموذج XFA أو ملفات مضمّنة؟
  • هل يوجد نص قابل للاستخراج، أم أنه مسح ضوئي متجه إلى OCR؟
  • بناءً على كل ذلك، إلى أي queue يذهب: معالجة مباشرة، مراجعة يدوية، أم quarantine؟

يرتبط كل سؤال باستدعاء أو استدعاءين في PDFium Component. اثنان من هذه الارتباطات لهما حواف حادة تفسر معظم الملفات التي رأيتها تُوجّه خطأ في الإنتاج: metadata المستند التي تعيش في مكانين مختلفين، والتشفير الذي لا يمنع المستند من الفتح.

افتح بأقل كلفة: form fill مغلق ولا صفحات مرسومة

ينبغي أن يكون triage أرخص فتح ممكن. ضبط FormFill := False قبل Active := True يخبر المكوّن بتخطي بيئة form-fill بالكامل، ما يقلل زمن التحميل، وبنفس الأهمية للملفات مجهولة الأصل، يمنع أي JavaScript على مستوى المستند من التهيئة. لا تحتاج أي من خصائص الفحص أدناه إلى تصيير صفحة، لذلك لا يلزم مرور triage أن ينتج bitmap واحدة.

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;

الفحص بعد الإسناد ليس اختيارياً، وهو فحص لا exception handler لسبب محدد: عندما لا يستطيع المحرك تحميل الملف، يبتلع المكوّن EPdfError الداخلي ويترك Active عند False بدلاً من تمريره. الكود الذي ينتظر exception سيقرأ PageCount بسعادة من مستند لم يُفتح قط. إذا احتاج مسار الرفض إلى نص خطأ المحرك الفعلي، فاقرأ الملف إلى byte array واستدعِ overload من LoadDocument يأخذ TBytes؛ هذا المسار يرفع EPdfError مع الرسالة، بما في ذلك حالة كلمة المرور. ولا يزال try..finally يستحق مكانه: خدمات intake تعمل بلا مراقبة لأسابيع، ولا ينبغي لأي exception لاحق أن يسرّب instance من TPdf أو يمسك lock يعثر به retry pass.

نادراً ما يصبح throughput عنق الزجاجة. مع تعطيل form fill ومن دون تصيير، يهيمن I/O على فتح triage، ويستطيع worker واحد فحص عدة ملفات في الثانية من قرص محلي. إذا تجاوز حجم intake يوماً worker واحداً، فقسّم العمل حسب الملف لا حسب الفحص؛ الأسئلة الخمسة تشترك في فتح واحد، وتقسيمها عبر عمليات سيضاعف أغلى خطوة بدلاً من amortizing لها.

Metadata تعيش في مكانين، ولا يتفقان

يعرّف ISO 32000-1 منزلين لـ document metadata: document information dictionary في البند 14.3.3، وحزمة XMP مرتبطة بالـ catalog في البند 14.3.2. تقرأ الخصائص Title وAuthor وSubject وCreationDate قاموس Info، مع MetaText[] لأي مفتاح آخر، وDecodeDate لتحليل string التاريخ D:YYYYMMDD.... الفخ أن producers الحديثين يكتبون XMP فقط على نحو متزايد؛ وهو اتجاه يجعله ISO 32000-2 رسمياً بإلغاء معظم مفاتيح Info dictionary في PDF 2.0. العَرَض في أداة intake ملموس: منصتك تعرض title فارغاً بينما يعرض Adobe Acrobat عنواناً، لأن Acrobat رجع إلى dc:title داخل حزمة XMP، وهي حزمة لا تلمسها خصائص Info dictionary.

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;

حتى probe النصي الخشن أعلاه يستحق مكانه: "metadata موجودة، لكن ليس حيث تبحث الأدوات legacy" حقيقة مهمة للتوجيه في أي مسار أرشفة يفهرس حسب title أو author. إذا كان index downstream يقرأ Info dictionary فقط، فإن الملفات الموسومة بهذه الطريقة ستصبح غير قابلة للبحث بصمت.

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

المستند المشفر لا يفشل بالضرورة في الفتح. يميز standard security handler في ISO 32000-1 clause 7.6.3 بين user password المطلوبة لفتح المستند، وowner password التي تقيّد أذونات مثل الطباعة والنسخ. حصة كبيرة من مستندات الأعمال "المحمية" مشفرة بكلمة owner وبـ user password فارغة؛ تفتح بلا prompt، وتفك تشفيرها بالكامل، وتعتمد على تطوع العوارض لاحترام permission flags. هذه سياسة لا حماية، ويجب أن تعكس حالات intake لديك الفرق.

كشف التشفير بعد فتح ناجح يحتاج إلى استدعاء محرك واحد مع fallback: يعيد FPDF_GetSecurityHandlerRevision(Pdf.Document) القيمة -1 للملفات غير المحمية وhandler revision خلاف ذلك، وتكون عودة Pdf.Permissions بأي قيمة غير القناع all-bits-set $FFFFFFFF إشارة داعمة. بالنسبة إلى الملفات المقفلة فعلاً بـ user password، أسند Password قبل ضبط Active := True؛ وإذا استمر الفتح بالفشل، فوجه الملف إلى حالة blocked تطلب credentials من المرسل عبر قناة آمنة بدلاً من retry أعمى. وقاوم إغراء اعتبار "encrypted" quarantine تلقائياً: في معظم الصناعات كثيفة المستندات، الملفات encrypted-but-openable هي الحالة الطبيعية لا الحالة المشبوهة.

المحتوى النشط: JavaScript وXFA والملفات المضمنة

ثلاث نتائج يجب أن تصل دائماً إلى قرار التوجيه. أولاً، JavaScript: يبلغ حدث OnUnsupportedFeature عن ميزات بنيوية مثل XFA أو محتوى 3D عندما يواجهها المحرك، لكنه لا يكشف JavaScript؛ افحص JavaScriptActionCount بدلاً من ذلك، وتعامل مع نتيجة غير صفرية كمحتوى نشط. ثانياً، XFA: عندما يعيد FormType القيمة ftXfaFull، تكون الصفحات المرئية غالباً مجرد rendering لقالب XFA، وسيرى استخراج النص التقليدي boilerplate لا القيم المعبأة. ثالثاً، attachments: 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;

تفصيلان في تلك الحلقة يستحقان الانتباه. يأتي اسم attachment من داخل المستند، لذلك لا تعِد استخدامه كمسار إخراج بلا sanitizing؛ اسم مضمّن مثل ..\..\start.exe هو path traversal ينتظر استدعاء save مهمل. وextension blocklist tripwire لا ضمان؛ وظيفتها إجبار قرار بشري، لا توثيق نظافة الملف.

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

يحتاج نموذج الحالة العملي إلى حالات أقل مما تتوقعه معظم الفرق: ready عندما لا توجد blockers والنص موجود، وreview عندما ينجح الفتح لكن يوجد شيء يحتاج إلى عين بشرية مثل XFA form أو JavaScript أو طبقة نص فارغة أو title في XMP فقط، وblocked عندما تتطلب user password، وdamaged عندما يفشل الفتح. سجّل الأدلة مع الحالة: file hash وpage count والأعلام الدقيقة ورسالة خطأ المحرك للملفات damaged، لأن من يشكك في قرار توجيه سيفعل ذلك بعد أسابيع، وربما يكون الملف قد استُبدل أو عُدّل.

عندما يحتاج operator إلى النظر في ملف quarantined، لا تسلمه إلى عارض shell الافتراضي. صَيّره داخل pane مقوى مع تعطيل scripting ومعالجة links؛ يصف بناء سطح secure PDF preview في Delphi هذا النهج. وإذا كان intake يغذي أرشيفاً بمتطلبات مطابقة، فإن triage هو المكان الطبيعي لجدولة فحص أعمق؛ batch preflight validation against PDF/A and PDF/UA profiles يبدأ بالضبط حيث يتوقف هذا الفحص.

Frequently asked questions

كيف أتحقق مما إذا كان PDF محمياً بكلمة مرور في Delphi؟

افتحه باستخدام PDFium Component واستعلم عن security handler: يعيد FPDF_GetSecurityHandlerRevision(Pdf.Document) القيمة -1 للملفات غير المحمية. إذا بقي Active عند False بلا كلمة مرور، فالملف غالباً يستخدم user password؛ أسند Password وحاول مرة أخرى. إذا فتح جيداً لكن security handler موجود، فالملف يحمل حماية owner-password فقط: إنه قابل للقراءة بالكامل، وpermission flags في Permissions إرشادية.

لماذا تعيد خاصية Title string فارغاً بينما يعرض Acrobat عنواناً؟

العنوان مخزن فقط في حزمة XMP metadata، لا في document information dictionary الذي تقرأه Title. لا يعرّض المكوّن حزمة XMP، لذلك افحص bytes الخام للملف بحثاً عن dc:title ووسم الملف للمسارات التي تفهرس على metadata قاموس Info.

هل يستطيع PDFium Component كشف JavaScript داخل PDF؟

نعم؛ افحص JavaScriptActionCount أو عدّد document-level actions عبر JavaScriptActions. لا تعتمد على حدث OnUnsupportedFeature لهذا؛ فهو يبلغ عن ميزات مثل XFA و3D لكن لا يبلغ عن scripting.

تغطي صفحة المنتج الترخيص وinspection API الكامل والـ demos المضمنة، بما فيها document inspector قريب من أسلوب intake: PDFium Component.