مقال تقني

دفق ملفات PDF الضخمة عند الطلب باستخدام PDFium في Delphi

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

يكشف المكون عن هذا المسار من خلال محول دفق. أنت تسلمه أي TStream، ويسحب PDFium الكتل من هذا الدفق عند الطلب. يمكن أن يقع الملف على القرص، أو في حقل كائن ثنائي كبير (blob) في قاعدة البيانات، أو خلف أي سليل TStream آخر، ولا يتم نسخ أي جزء منه إلى الذاكرة مقدمًا

كيف يطلب PDFium البايتات

تقوم واجهة برمجة تطبيقات C الخاصة بـ PDFium بتحميل مستند من كائن مقدم من المتصل موصوف بواسطة هيكل FPDF_FILEACCESS. يحتوي الهيكل على ثلاثة أجزاء تهم هنا: حقل طول، ورد اتصال للقراءة، ومعلمة مستخدم مبهمة. نقطة الإدخال التي تستهلكه هي FPDF_LoadCustomDocument. بمجرد أن يحمل PDFium ذلك الهيكل، فإنه يحلل المقطع الختامي، ويحدد موقع جدول المراجع التبادلية، ومنذ ذلك الحين يقرأ فقط ما تتطلبه عملية معينة. لمس المستند يلمس ذيل الملف ومجموعة من كائنات الكتالوج. قراءة الصفحة 400 تقرأ تدفقات المحتوى وموارد تلك الصفحة ولا شيء غير ذلك

هذا هو الفرق بين التحميل المخزن مؤقتًا والتحميل المتدفق. يقرأ التحميل المخزن مؤقتًا الملف من البداية إلى النهاية قبل أن يرى PDFium البايت صفر. يقلب التحميل المتدفق العلاقة: يدفع PDFium القراءات، والبايتات التي لا يتم لمسها أبدًا لا تتم قراءتها أبدًا. بالنسبة لملف متعدد الجيجابايت يُعرض صفحة واحدة في كل مرة، فهذه هي الفجوة بين التحميل غير القابل للاستخدام والتحميل الفوري

مخطط معماري يقابل تحميلًا مخزنًا ينسخ ملف PDF متعدد الجيجابايتات إلى الذاكرة قبل التحليل بالتدفق حيث يطلب PDFium مدى بايتات من TStream في Delphi عبر FPDF_FILEACCESS
يكلّف الفتح الذيلَ والفهرس فقط؛ ويكفي عرض التصيير الصفحةَ 400 جلبَ بايتات الصفحة 400 لا شيء غيرها عبر رد النداء

محول الدفق

المحول الذي يربط TStream في Delphi بـ FPDF_FILEACCESS هو TPdfStreamAdapter. يأخذ مُنشئه الدفق وعلامة ملكية، ويلتقط طول الدفق مرة واحدة، ويملأ سجل FPDF_FILEACCESS، ويربط رد اتصال القراءة. عندما يتصل PDFium لاحقًا مرة أخرى بإزاحة وحجم، يبحث المحول عن الدفق في تلك الإزاحة وينسخ هذا النطاق بالضبط في المخزن المؤقت الذي قدمه PDFium

// نصًا كما في المكوّن: جسر الدفق إلى FPDF_FILEACCESS
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen عدد طويل غير موقّع 32 بت. ارفض أي دفق
  // كان سيُقتطع بصمت بعد 4 جيجابايت.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

تحدد علامة الملكية من يحرر الدفق. مرر False ويحتفظ المتصل بالدفق ويجب أن يبقيه حيًا طوال عمر المستند. مرر True ويتولى المحول المسؤولية، ويحرر الدفق عند إغلاق المستند. في كلتا الحالتين، يجب أن يعيش الدفق لفترة أطول من كل قراءة سيقوم بها PDFium، لأن PDFium يحمل مؤشر FPDF_FILEACCESS وسيتصل مرة أخرى في أي وقت أثناء فتح المستند، وليس فقط أثناء التحميل الأولي

لماذا رد الاتصال هو دالة ثابتة

رد اتصال القراءة الذي يخزنه PDFium في m_GetBlock هو مؤشر دالة C عادي مع اتفاقية استدعاء cdecl. لا يمكن استخدام طريقة Delphi بشكل مباشر، لأن الطريقة تحمل وسيطة Self مخفية لا يعرف متصل C شيئًا عنها ولن يوفرها أبدًا. لذلك يعلن المحول رد الاتصال كـ class function معلم كـ cdecl; static، والذي يترجم إلى دالة قائمة بذاتها بتخطيط إطار C الذي يتوقعه PDFium وبدون Self ضمني

هذا يحل اتفاقية الاستدعاء ولكنه يثير سؤالاً ثانيًا: بدون Self، كيف يصل رد الاتصال إلى الدفق المحدد الذي يفترض أن يقرأ منه؟ الجواب هو معلمة المستخدم المبهمة. عندما يبني المحول السجل فإنه يخزن مؤشر مثيله الخاص في m_Param. يعيد PDFium نفس المؤشر كالوسيطة الأولى لكل رد اتصال. تقوم الدالة الثابتة بتحويلها مرة أخرى إلى TPdfStreamAdapter وترسل القراءة مقابل دفق ذلك المثيل. هذا هو الترامبولين القياسي لتسليم سياق الكائن عبر حد C الذي ليس لديه فكرة عن الكائنات

مخطط للنطة cdecl التي تحمل طلبات كتل PDFium من حد C إلى نسخة TPdfStreamAdapter في Delphi وتطوي الاستثناءات إلى قيمة إرجاع صفرية
لا يخفي رد نداء cdecl ساكن أيَّ Self ضمني، فيسلّم m_Param نسخة المهايئ إلى كل استدعاء وينطوي أي استثناء Pascal على إرجاع صفر
// نصًا كما في المكوّن: الترامبولين cdecl العائد إلى المثيل
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // استرجع المثيل من m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // أبلغ عن الفشل بقيمة الإرجاع، لا بإطلاق استثناء أبدًا
  end;
end;

سقف 4 جيجابايت ولماذا يحتاج إلى حارس

من هنا يأتي الحد المذكور في البداية. حقل الطول m_FileLen في FPDF_FILEACCESS هو قيمة غير موقعة ذات 32 بت. أكبر طول يمكن تمثيله هو بايت واحد أقل من 4 جيجابايت. يُبلغ TStream عن حجمه كـ Int64، لذا يمكن للدفق وصف بايتات أكثر بكثير مما يمكن أن يستوعبه الحقل. في اللحظة التي يتجاوز فيها حجم الدفق ذلك السقف، لا توجد طريقة صادقة لإخبار PDFium عن طول الملف

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

المحول يرفض الإدخال بدلاً من ذلك. يقارن المُنشئ حجم الدفق مقابل High(FPDF_DWORD) ويثير EPdfError في اللحظة التي يكون فيها الدفق كبيرًا جدًا بحيث لا يمكن وصفه. خطأ صريح وفوري يسمي المشكلة الحقيقية في نقطة البناء. الاقتطاع الصامت يخفيه خلف عرض مضلل ستطارده في وقت لاحق. حد 4 جيجابايت هو قيد حقيقي لمسار التحميل هذا، والشيء الصادق هو إظهاره بصوت عالٍ بدلاً من التغطية عليه بعملية حسابية تصادف تجميعها. عندما يتجاوز الأرشيف حقًا الخط، فإن العلاجات الموعودة في الأعلى تعيش خارج واجهة برمجة التطبيقات هذه: قم بتقسيم المسح الضوئي إلى ملفات لكل وحدة تخزين تبقى كل منها تحت السقف، أو اترك المستند على القرص وقم بخدمته من خلال تصميم وصول مباشر مبني على إزاحات 64 بت بدلاً من FPDF_FILEACCESS

مخطط قرار يحرس حد 4 GiB في FPDF_FILEACCESS حيث يرفع TStream متجاوز للحجم في Delphi‏ EPdfError فورًا بدلاً من لف حقل الطول المصرَّح به بصمت
EPdfError فوري يتفوق على حساب يجتاز الترجمة فحسب: فالالتفاف m_FileLen يُرسل التنقيح أثرَ إسناد متقاطع خيالياً

يجب ألا تتجاوز الإخفاقات الحدود

يمكن أن تفشل القراءة. قد يكون الدفق كائنًا مدعومًا بالشبكة تنتهي مهلته، أو مقبض كائن ثنائي كبير (blob) تم إغلاقه من تحتك، أو ملفًا تم اقتطاعه بعد فتح المستند. عقد PDFium لرد اتصال القراءة هو قيمة إرجاع: غير صفر للنجاح، صفر للفشل. إنه إطار C، وليس لديه آلية لالتقاط أو نشر استثناء Pascal

هذا هو السبب في أن الترامبولين يغلف البحث والقراءة في try/except الذي يبتلع الاستثناء ويعيد صفرًا. إذا سُمح لاستثناء Delphi بالانتشار خارج رد الاتصال، فسيتم فكه عبر إطارات مكدس cdecl الخاصة بـ PDFium، والتي لم يتم بناؤها أبدًا ليتم فكها بواسطة آلية استثناء Pascal. النتيجة هي سلوك غير محدد في أحسن الأحوال وتعطل شديد في أسوأ الأحوال، في أعماق محلل PDF بدون مكدس قابل للاستخدام. يؤدي إرجاع الصفر إلى إبقاء الفشل داخل العقد. يرى PDFium قراءة كتلة فاشلة، ويجهض العملية بوضوح، ويبلغ FPDF_LoadCustomDocument أنه لا يمكن تحميل المستند، وهو ما يظهره المكون كـ EPdfError على جانب Pascal حيث ينتمي

فتح مستند بهذه الطريقة

طريقة المكون التي تقود مسار الدفق هي LoadCustomDocument، المُعلن عنها كطريقة مميزة بدلاً من التحميل الزائد الآخر لـ LoadDocument بحيث لا يهبط تمرير TMemoryStream عن طريق الخطأ أبدًا على المسار المخزن مؤقتًا. يبني المحول، ويستدعي FPDF_LoadCustomDocument، ويبقي المحول على قيد الحياة طوال عمر المستند المحمل

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // سلّم ملكية الدفق إلى Pdf: إنه يحرر FileStream عند إغلاق المستند.
    Pdf.LoadCustomDocument(FileStream, True);
    // قرأ PDFium حتى الآن المقطع الختامي والكتالوج فقط.
    // عرض صفحة يسحب بايتات تلك الصفحة فقط عبر رد الاتصال.
    // ... اعرض الصفحات أو افحصها هنا ...
  finally
    Pdf.Free;  // يغلق المستند، وهذا يحرر المحول والدفق
  end;
end;

يعمل نفس الاستدعاء مع TMemoryStream، أو دفق كائن ثنائي كبير (blob) من مجموعة بيانات قاعدة بيانات، أو سليل TStream مخصص. يكتسب التحميل عند الطلب قيمته عندما يكون الملف كبيرًا ولن تتم قراءة سوى جزء منه: عارض أرشيف، أو منشئ صور مصغرة يأخذ عينات من بضع صفحات، أو فهرس بحث يسحب صفحة واحدة في كل مرة. عندما يكون الملف صغيرًا أو ستقرأه بالكامل على أي حال، فإن التحميل المخزن مؤقتًا يكون أبسط ولن تشتري لك آلات الدفق أي شيء. العامل الحاسم هو نسبة البايتات التي ستلمسها فعليًا إلى البايتات التي يحتوي عليها الملف

بمجرد تدفق الصفحات عند الطلب، يكون الشاغل التالي هو الحفاظ على استجابة الصفحات المعروضة أثناء قيام المستخدم بالتكبير والتمرير، وهو ما تمت تغطيته في ملاحظتنا حول ذاكرة التخزين المؤقت للعرض وأداء التكبير. عندما يكون المستند المتدفق مستندًا يجب أن يعرضه المشاهد ولكن لا يسمح للمستخدم بتصديره أو تغييره، فإن التقنيات الموجودة في دليل معاينة PDF الآمن تقترن بشكل طبيعي بمسار التحميل هذا. كلاهما يبني على التحميل المتدفق الموصوف هنا، والذي يتم شحنه كجزء من PDFium Component لـ Delphi و C++Builder إلى جانب واجهات برمجة تطبيقات العرض واستخراج النص والتعليقات التوضيحية التي تمت تغطيتها في مكان آخر في هذه المدونة