مقال تقني

التحميل التدريجي لنطاقات PDF في Delphi عبر PDFlibPas

أرشيف ممسوح حجمه 2 غيغابايت يعيش في حاوية S3 والمستخدم يريد الصفحة 900۔ يستطيع PDFlibPas تقديم تلك الصفحة دون تنزيل الملف: LoadFromRangeSource تبني تدفقا للقراءة فقط قابلا للتموضع فوق نداء نطاقات البايت الخاص بك وتسلّمه إلى TPDFDocument، بحيث يسحب المحلل جداول المراجع المتقاطعة وفرعا واحدا من شجرة الصفحات وتدفق محتوى واحدا

جانب النقل في هذا قديم وممل۔ فخوادم HTTP تعلن عن نطاقات البايت منذ عقود، وهي الآن محددة في RFC 9110 §14، وكل مخزن كائنات يتكلم اللهجة نفسها۔ وجانب PDF مستقر بالمثل: تحدد ISO 32000-1 §7.5.8 الخطية (linearization) بدقة بحيث يستطيع القارئ عرض الصفحة الأولى من مقدمة الملف۔ وما كان ناقصا في Delphi هو القطعة في المنتصف، الجزء الذي يقرر أي نطاقات يطلبها، وكم منها يحتفظ به، وكيف يتجنب الطلب مرتين

ماذا تحتاج LoadFromRangeSource من طبقة النقل لديك؟

شيئان، وكلاهما ليس تدفقا۔ يطلب PDFlibPas SourceSize موثوقا ونداء قراءة متزامنا من النوع TPDFlibRangeReadEvent، معلنا كالتالي function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object۔ داخليا يصبح الزوج TCallbackByteRangeSource يكشف SourceSize وReadRange، ملفوفا في تدفق تنتقل ملكيته إلى المستند۔ هدف النداء لديك وخادمه الخلفي يبقيان لك: يحرر المستند الغلاف عند الإغلاق أو المسح أو إعادة التحميل، لكنه لا يلمس كائن النقل خلف مؤشر الأسلوب أبدا

العقد متسامح عمدا في اتجاه وصارم في الآخر۔ القراءة القصيرة مشروعة وتعني ببساطة أن المحلل يطلب مجددا۔ والنداء الذي يطلق استثناء يُحوَّل إلى قراءة قصيرة ويتقارب عبر مسار فشل التحميل العادي۔ والنداء الذي يدّعي كتابة أكثر من Count بايت يُقيَّد، لأن المزوّد المعطوب يجب ألا يستطيع تجاوز مخزن الذاكرة المؤقتة۔ ومحاولات كلمة المرور تعيد بناء تدفق نطاقات جديدا وحالة تحليل جديدة فوق مصدر النداء نفسه، بحيث لا تترك محاولة فاشلة خلفها حالة تموضع أو نافذة أو فك تشفير بالية

type
  TObjectStoreSource = class
  private
    FClient: TRangeHttpClient;
    FSize: Int64;
  public
    function ReadRange(Sender: TObject; Offset: Int64;
      Buffer: Pointer; Count: LongInt): LongInt;
    function IsResident(Sender: TObject; Offset: Int64;
      Count: LongInt): Integer;
    property Size: Int64 read FSize;
  end;

function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
  Buffer: Pointer; Count: LongInt): LongInt;
begin
  { طلب GET حاجز واحد مع Range: bytes=Offset-(Offset+Count-1) }
  Result := FClient.FetchInto(Offset, Count, Buffer);
end;

{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
  if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
       65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
    Lib.SelectPage(900);
finally
  Lib.Free;  { يحرر تدفق الغلاف }
  Src.Free;  { النقل لك، ودورة حياته لك }
end;

كم يحمل ذاكرة النطاقات المؤقتة فعلا؟

افتراضيا 4 MiB، موزعة على نوافذ محاذية للكتل ومُخرَجة وفق LRU۔ كان التصميم السابق أحادي النافذة ينمو إلى أي طول يطلبه المستدعي، فيمكن لقراءة متتابعة كبيرة أن تتجاوز حجم الكتلة الاسمي بينما تقذف قفزة عشوائية النافذة السابقة فورا۔ الذاكرة المؤقتة الحالية تحاذي كل إزاحة مصدر إلى ChunkSize، وتجلب كتلة واحدة بالضبط لكل إخفاق، وتفرض ميزانية بايت صارمة عبر عدة نوافذ۔ أي ميزانية صريحة تمررها تُرفع إلى كتلة كاملة واحدة على الأقل، بحيث تتقدم القراءة الواحدة دائما كتلة كتلة ويبقى ذروة حمل الذاكرة المؤقتة متوقعة۔ وChunkSize الأقل من 4096 تتراجع إلى الافتراضي 64 KiB

كيف يخدم PDFlibPas قراءة محلل في Delphi دون تنزيل PDF: الإزاحة المطلقة تُحاذى نزولا إلى حجم الكتلة، وتُخدَم من إحدى نوافذ LRU عند الإصابة، أو تتحول إلى نداء واحد مقيَّد عند الإخفاق
كل إزاحة مصدر تُحاذى إلى حجم الكتلة، لذا يجلب إخفاق واحد كتلة واحدة بالضبط وتبقى ذروة حمل الذاكرة المؤقتة متوقعة

محاسبة القراءات المتكررة هي الجزء المستحق توصيله إلى قياساتك عن بُعد۔ يعرّف PDFlibPas التكرار ببداية الكتلة المحاذية ويحتفظ بفواصل متصلة مرتبة، وهو ما يفصل الجلب الأول الحقيقي عن إعادة الجلب بعد الإخراج مع منع المحاسبة من النمو خطيا مع حجم الملف۔ GetRangeSourceCacheInfo تُرجع الصورة كاملة كـ JSON، وSetRangeSourceCacheLimit تعيد تحجيم الميزانية وقت التشغيل، وClearRangeSourceCache تُسقط النوافذ وتعيد ضبط الإحصاءات معا۔ تصغير الميزانية وقت التشغيل يبقي التاريخ ويعدّ الإطلاقات المدفوعة بالميزانية كإخراجات، لذا فإن تصاعد repeatedReads مقابل ثبات hits هو إشارتك إلى أن مجموعة العمل لم تعد تتسع

var
  Info: WideString;
begin
  Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
  Lib.SelectPage(900);
  if Lib.GetRangeSourceCacheInfo(Info) = 1 then
    { "windowCount", "cacheLimitBytes", "cachedBytes", "hits", "misses",
      "evictions", "sourceReads", "sourceBytes", "repeatedReads",
      "coalescedRequests", "coalescedSourceReads" }
    LogRangeStats(Info);
end;

ماذا يحدث عندما تريد عدة خيوط الكتلة نفسها؟

تنتظر طلبا واحدا لا عدة طلبات۔ فـ TStream الكلاسيكي له مؤشر تموضع واحد، وخيطان يقفل كل منهما بشكل صحيح يمكن مع ذلك أن يُعاد كتابة ذلك التموضع بينهما بين Seek وRead، لذا تستخدم الكائنات الكسولة والقراءات المجزأة في PDFlibPas ReadAt المطلقة التي لا تحرك المؤشر أبدا۔ كل كتلة محاذية تحصل على طلب واحد قيد الطيران يتشاركه كل مستدعي لتلك الكتلة، والكتل المتجاورة المنتظِرة تُدمج قبل بدء قراءة المصدر، وقراءة مادية واحدة سقفها 16 MiB، بحيث لا يتضخم دفعة عمل صفحات متوازية إلى طلبات صغيرة مكررة ولا إلى طلب ضخم عبثي واحد۔ نافذة الدمج افتراضيتها 2 مللي ثانية وتنطبق فقط على أول كتلة مفقودة لكل ReadAt؛ وRead الموضعية لا تنتظرها أبدا، وتمرير صفر يزيل تأخير الجمع الأولي كاملا، وهو ما يهم لعمليات المسح المتتابعة الطويلة التي كانت ستتراكم فيها الانتظارات كتلة كتلة۔ التموضع وبيانات الذاكرة المؤقتة وقراءات المصدر تجلس خلف ثلاثة أقفال منفصلة، ونداء المصدر نفسه مُسلسَل، وهو ما يتيح استخدام محوِّل قاعدة بيانات أو مخزن كائنات بلا حماية خيوط داخلية دون تغيير۔ المنتظِرون يتلقون نسختهم الخاصة من البيانات، بحيث لا يمكن لإخراج LRU لاحق أن يبطل مخزنا سُلّم بالفعل

دمج الطلبات في تحميل النطاقات ضمن PDFlibPas لـ Delphi: خيطان يطلبان الكتلة نفسها يتشاركان طلبا واحدا قيد الطيران، والكتل المتجاورة المنتظِرة تُدمج داخل نافذة مليَي ثانية، وقراءة مصدر مُسلسَلة واحدة تخدمها كلها
تنهار دفعة عمل الصفحات المتوازية إلى طلب مشترك واحد لكل كتلة، وكل منتظِر يتلقى رغم ذلك نسخته الخاصة من البايتات

هل يمكنك السؤال إن كانت الصفحة 900 جاهزة دون جلبها؟

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

الاجتياز محدد النطاق لا شامل۔ استعلام الصفحة يسلك فقط فرع شجرة الصفحات الحاوي للصفحة الهدف ثم يضيف محتوى الصفحة والموارد والتعليقات التوضيحية وسمات الصفحة الموروثة، متخطيا الحواف الخلفية Parent وP بحيث لا تستطيع صفحة أو عنصر واجهة واحد أن يتمدد خلفيا إلى المستند كله۔ الرسم البياني للكائنات محدود بـ 100000 كائن مطلوب وعمق 256، وكائنات التدفق تُحلل قاموسا أولا، والتراجع إلى تحليل كامل مسموح فقط للكائنات المخزنة حتى 4 MiB۔ تقرير JSON يدمج الفواصل المتداخلة والمتجاورة قبل العد، بحيث تُحسب requiredBytes وmissingBytes من مصفوفتي requiredRanges وmissingRanges المدمجتين، التي يكون end فيهما نقطة نهاية شاملة۔ الاستعلام عن كائن متوفر بالفعل قد يملأ ذاكرة النطاقات المؤقتة؛ والاستعلام عن مفقود يترك إحصاءات القراءة دون مساس

var
  Report: WideString;
  Status: Integer;
begin
  Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
    Report);
  if Status = PDF_RANGE_DATA_AVAILABLE then
    RenderPageNow
  else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
    { التقرير يحمل "missingBytes" إضافة إلى "missingRanges" المدمجة }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { مثال: الملف بلا AcroForm إطلاقا }
end;

لماذا يجب على الجلب المسبق أن يكرر

لأن قراءة missingRanges الحالية مرة واحدة لا تجعل الصفحة متوفرة۔ فعقدة شجرة صفحات مفقودة أو تدفق كائنات مفقود لا يكشف عن الطبقة التالية من التبعيات إلا بعد وصوله، لذا تشغّل مهمة الجلب المسبق في PDFlibPas حلقة استعلام فجلب ثم إعادة استعلام حتى تصبح الصفحة أو النموذج أو الرسم البياني للكائنات متوفرة تماما أو يوقفها حد بايت أو عدد مرات۔ تستخدم المهمة قارئها الخاص وذاكرة مؤقتة ثانوية صغيرة يحوّل مصدر بياناتها القراءات المطلقة إلى تدفق النطاقات الأصلي، وهو ما يبقي حالة التحليل معزولة عن TSmartPDFReader الأمامي بينما البايتات التي تنزلها فعلا تحط في الذاكرة المؤقتة الرئيسية المشتركة۔ ثمة خيط عامل واحد لكل تدفق نطاقات، بما يطابق التسلسل الذي يتطلبه نداء المصدر أصلا، والطابور ينتقي بأربع أولويات ثم بترتيب الإرسال داخل الأولوية۔ MaxBytes تُحتسب ببايتات الكتل المادية، لذا فإن محللا يطلب بايتا واحدا داخل كتلة غير مخزنة يدفع ثمن الكتلة كاملة رغم ذلك، بينما الكتل الموجودة في الذاكرة المؤقتة المشتركة لا تكلف المهمة شيئا۔ إلغاء مهمة منتظِرة يبلغ حالة نهائية بصفر قراءات مصدر؛ والمهمة الجارية تُفحص قبل كل مرورة تبعيات وكل كتلة مصدر، وتحرير تدفق النطاقات ينتظر عودة نداء قيد الطيران بدلا من محاولة مقاطعته

حلقة الجلب المسبق في PDFlibPas ضمن Delphi: مهمة تستعلم عن التوافر فتجلب النطاقات المفقودة ثم تستعلم من جديد، لأن كل عقدة شجرة صفحات أو تدفق كائنات يصل يكشف الطبقة التالية من التبعيات، حتى يكتمل الرسم البياني أو يوقفه حد
تكرر مهمة الجلب المسبق لأن العقدة المفقودة لا تسمي أبناءها إلا بعد وصولها، وتحتسب كل مرورة بكتل مادية كاملة
var
  Job: Integer;
  Info: WideString;
begin
  Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
    PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
  if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
       PDF_RANGE_PREFETCH_STATE_COMPLETED then
    PrepareNextPage
  else
    Lib.CancelRangeSourcePrefetch(Job);
  { "passes" و"plannedRanges" و"sourceReads" و"fetchedBytes" وآخر
    تقرير توافر كامل، بحيث يبقى LIMIT_REACHED مميزا
    عن FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

أين يتدهور هذا إلى تنزيل الملف كاملا

تحميل النطاقات رهان على تخطيط الملف، وبعض الملفات لا تفي به۔ الملف الخطي وفق ISO 32000-1 §7.5.8 هو الحالة الجيدة: قسم الصفحة الأولى يُحمَّم عند الفتح، محدودا بعتبة الأمان 4 MiB القائمة وبميزانية الذاكرة المؤقتة الحالية معا بحيث لا يمكن للإحماء أن يُخرج معظم نفسه فورا۔ والملف غير الخطي لا يزال يُحَل عبر التذييل وسلسلة المراجع المتقاطعة قرب النهاية، وهو ما يكلف رحلتين إضافيتين ذهابا وعودة لا كارثة۔ الهاوية الحقيقية هي ملف تالف يفرض مسار الإصلاح، لأن إعادة بناء جدول المراجع المتقاطعة يعني المسح بحثا عن ترويسات الكائنات عبر المستند كله، وهذا تنزيل كامل يصل كتلة كتلة۔ زمن الوصول هو الحد الصادق الآخر: عند 60 مللي ثانية لكل طلب، فإن تحليلا بالوصول العشوائي يحتاج أربعين كتلة غير مخزنة ينفق أكثر من ثانيتين في النقل مهما كانت الذاكرة المؤقتة جيدة، وهذا بالضبط ما وُجدت له حجة القراءة الاستباقية وطابور الأولويات لإخفائه۔ الانضباط نفسه يظهر في النهج بالوصول المباشر لدمج وتقسيم ملفات PDF الكبيرة، وهذه الذاكرة المؤقتة تجلس تحت عرض الصفحات المتوازي والذاكرة المؤقتة للصفحات على قرص العارض على حد سواء

واجهة مصدر النطاقات واستعلام التوافر وجدول الجلب المسبق كلها جزء من مكتبة PDFlibPas لـ PDF في Delphi القياسية لـ Delphi وC++Builder وFree Pascal؛ وصفحة المنتج تحمل مرجع المعاملات الكامل لـ LoadFromRangeSource إلى جانب ثوابت أولوية وحالة الجلب المسبق