مقال تقني

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

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

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

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

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

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

محول الدفق

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

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
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 is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  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 الذي ليس لديه فكرة عن الكائنات

// Verbatim from the component: the cdecl trampoline back to the instance
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);   // recover the instance from 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;  // report failure by return value, never by raising
  end;
end;

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

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

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

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

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

يمكن أن تفشل القراءة. قد يكون الدفق كائنًا مدعومًا بالشبكة تنتهي مهلته، أو مقبض كائن ثنائي كبير (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
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

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

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