مقال تقني

قراءة PDF عبر Memory Mapping في Delphi: نافذة منزلقة

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

لماذا تظل القراءات المتناثرة مكلفة بعد تحميل PDF؟

لا يعني تحميل PDF انتهاء قراءته، وفي ملف حجمه عدة جيجابايت تكون هذه الفجوة هي موضع استهلاك الوقت. فسجل الإسناد الترافقي أو تدفق الإسناد الترافقي (ISO 32000-1 §7.5.4 و§7.5.8) لا يسجل إلا موضع بدء كل كائن غير مباشر. وتصل البايتات لاحقًا عند تصيير صفحة، أو فك برنامج خط، أو استخراج تدفق ملف مضمّن (ISO 32000-1 §7.11.4). ويحوّل أرشيف حجمه 2 GB ويحتوي على عشرات الآلاف من الكائنات إلى عشرات الآلاف من القراءات الصغيرة غير المرتبة، ولا تكون أي منها معروفة وقت التحميل

كان المسار القديم لهذه القراءات يبدأ بـSeek مشترك يتبعه Read على تدفق ذي موضع واحد، وهو يفشل في اتجاهين معًا. إذ تدفع كل قطعة كلفة قراءة ملف حتى عندما تكون الصفحة موجودة أصلًا في ذاكرة التخزين المؤقت لنظام التشغيل، كما أن المؤشر حالة قابلة للتغيير ومشتركة، ولذلك لم يكن من الممكن لملف محلي ومصدر نطاق البايت خلف تحميل نطاق PDF التدريجي مع الجلب المسبق تشغيل كود المحلل نفسه من دون التنازع على الموضع. ويعالج PDFlibPas الأمرين بتحويل القراءة عند إزاحة مطلقة من تحسين إلى عقد

ما الذي يضمنه TPDFReadAtStream؟

يضمن TPDFReadAtStream قراءة عند إزاحة مطلقة لا تعتمد على مؤشر التدفق المنطقي ولا تغيّره. وهو سليل abstract من TStream يضم دالة virtual واحدة فقط، وينحدر منه المصدران المستقلان عن المؤشر في المكتبة: TReadOnlyMappedFileStream للملفات المحلية وTByteRangeStream للمصادر البعيدة المقدمة على شكل نطاقات. ويسأل قارئ شرائح الكائنات مرة واحدة إن كان مصدره من نوع TPDFReadAtStream، ويعود إلى التسلسل القديم Seek ثم Read إن لم يكن كذلك، ولذلك يستمر تدفق ملف عادي أو تدفق ذاكرة في العمل من دون تغيير

type
  // تدفقات للقراءة فقط تتجنب قراءاتها المطلقة Seek ثم Read المشتركين
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // وصول نافذي للقراءة فقط إلى ملف محلي واحد
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

أهمية هذا التمييز أكبر مما يوحي به التوقيع. تستخدم ReadAt الإزاحة التي تسلمها لها وتترك Position في مكانه تمامًا، وهذا ما يتيح لمستويات المحلل المتداخلة إصدار قراءات من دون حفظ الموضع واستعادته حول كل استدعاء. ولا يزال TReadOnlyMappedFileStream ينفذ Read وSeek وSize مثل أي TStream آخر، إذ يحصر Seek الموضع المنطقي داخل الملف، بينما يعيد Write دائمًا القيمة 0 لأن المصدر مفتوح للقراءة فقط

فتح PDF عبر عرض مربوط بالذاكرة في Delphi

تفتح نقطتا دخول صريحتان مصدرًا مربوطًا بالذاكرة، ولا تغيّر أي منهما سلوك نقاط الدخول التي تستخدمها أصلًا. تحمل LoadFromMappedFile مستندًا وتحدده، بينما تعيد DAOpenMappedFile مقبض Direct Access فوق الملف نفسه، وهو النمط المناسب عند دمج ملفات PDF بحجم جيجابايت وتقسيمها عبر Direct Access. وتحافظ LoadFromFile وDAOpenFile على دلالات مشاركة الملفات والأخطاء والتوافق الخاصة بهما من دون تغيير، لذلك لا يتبدل شيء لدى المستدعين الذين لا يختارون هذا المسار. وتأخذ كلتا نقطتي الدخول حجم WindowSize المطلوب بالبايت وقناع Options، وتقبلان القيمة 0 لأي منهما

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // تختار WindowSize 0 القيمة الافتراضية 64 MiB؛ والربط إلزامي هنا
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // يسير الاستخراج المؤجل الآن عبر النوافذ المربوطة بدل Seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

ما الذي يفرضه PDF_MAPPED_FILE_REQUIRE_MAPPING فعليًا؟

يحوّل PDF_MAPPED_FILE_REQUIRE_MAPPING الرجوع الصامت إلى فشل فوري قابل للتشخيص وقت الفتح. وعندما تُترك Options بالقيمة 0، تقبل نقطتا الدخول الرجوع إلى تدفق ملف للقراءة فقط: فإذا لم توفر المنصة كود الربط، أو فشل استدعاء الربط، يظل المستند يُفتح وتمر كل قراءة عبر تدفق ملف عادي. أما عند ضبط العلم، فلا يقبل PDFlibPas الإدخال إلا بعد إنشاء العرض الأول، ويبلغ عن الرفض عبر LastErrorCode بالقيمة 401 بدل تحميل مستند ينفذ بصمت بالطريقة القديمة نفسها تمامًا

على Windows يفتح التدفق المربوط مقبضًا ثانيًا للقراءة فقط باستخدام FILE_SHARE_READ وFILE_SHARE_WRITE وFILE_SHARE_DELETE مع FILE_FLAG_RANDOM_ACCESS، وينشئ فوقه ربطًا من نوع PAGE_READONLY، ثم يربط النافذة الأولى داخل الباني. والربط المبكر هو الفكرة كلها: إذ يظهر فشل «الربط مطلوب» عند LoadFromMappedFile، لا عند أول قراءة كائن مؤجلة في منتصف مهمة التصيير. لكن ينبغي توضيح حدود هذا الضمان. فكود الربط يُترجم لأهداف Windows فقط، ولا يحاول ملف حجمه صفر بايت إنشاء ربط أصلًا، ولذلك فإن PDF_MAPPED_FILE_REQUIRE_MAPPING طلب يمكن أن يفشل بصورة مشروعة وليس وعدًا محمولًا. كما يُرفض مباشرة حجم WindowSize السالب، أو أي بت في Options غير القيمة الوحيدة الموثقة، بالخطأ 401 نفسه

نافذة واحدة يعاد ربطها وفق دقة التخصيص

لا يُحتفظ أبدًا إلا بعرض واحد، وهذا ما يبقي استخدام مساحة العناوين مستقلًا عن حجم الملف. تختار قيمة WindowSize البالغة 0 حجم 64 MiB، وتُرفع القيمة الأصغر من دقة تخصيص النظام إليها، وتُحصر القيمة الأكبر من 1 GiB، ثم تُقرب النتيجة إلى عدد صحيح من وحدات الدقة، وهي 65536 بايت على Windows ما لم يبلغ GetSystemInfo عن dwAllocationGranularity مختلف. وعندما تقع قراءة خارج العرض الحالي، يفك PDFlibPas ربطه، ويحاذي الإزاحة المطلوبة إلى الأسفل عند حد دقة، ثم يربط نافذة جديدة هناك. وتُحصر النافذة الأخيرة في حجم الملف الفعلي، ولذلك لا يمتد العرض بعد نهاية الملف

قد تعبر قراءة واحدة أي عدد من النوافذ: تنسخ الحلقة ما يستطيع العرض الحالي توفيره، ثم تعيد الربط وتتابع، ويعيد الطلب الذي يتجاوز النهاية عددًا قصيرًا بدل الفشل. وما يتعمد PDFlibPas عدم فعله هو تسليمك مؤشرًا داخل العرض، لأن القراءة التالية العابرة للنوافذ تبطله ولا يستطيع أي مستدعٍ الدفاع عن ذلك بصورة معقولة. وتُنسخ البايتات المربوطة مباشرة إلى مخازن الوجهة التي يملكها المحلل، فيُزال مخزن إدخال الملف الإضافي وتبديل المواضع، لكن المكتبة لا تدعي تخزين المحلل النهائي من دون نسخ. كما تتآلف النوافذ في جانب القراءة مع جانب الكتابة، لأن إزاحة مراجع البايت أثناء دمج PDF سريع تبث بايتات الكائنات إلى الخارج بينما يبثها المصدر المربوط إلى الداخل. والمفاضلة في حجم النافذة واضحة: فالنافذة الأصغر تشغل مساحة عناوين أقل وتُعاد عملية ربطها أكثر، وهو ما يكون غالبًا الخيار الصحيح داخل عملية ذات 32 بت

ما الذي يحميه القفل، وما الذي يعرضه GetMappedFileInfo؟

يغطي قسم حرج واحد العرض المربوط، ومؤشر ملف الرجوع، والموضع المنطقي، والإحصاءات، وينشأ فصل طريقتي القراءة مباشرة من ذلك. تأخذ ReadAt القفل وتستدعي القارئ الداخلي الخالي من القفل، وتأخذ Read القفل نفسه وتستدعي القارئ الداخلي عند الموضع المنطقي الحالي ثم تقدمه. وإعادة استخدام الدالة الداخلية بدل ReadAt العامة هي ما يتجنب القفل العودي، كما أن الاحتفاظ بالقفل طوال حلقة النسخ كاملة هو ما يبقي إعادة ربط النافذة صحيحة مع الاستدعاءات المتزامنة. وهناك تفصيل مهم في Free Pascal قبل النقل: تعلن وحدة Windows في FPC سجلًا خاصًا بها اسمه TCriticalSection، لذلك يجب كتابة الحقل وإنشائه بصيغة SyncObjs.TCriticalSection. أما Delphi فيترجم الصيغة غير المؤهلة بسلاسة، بينما يحلها FPC إلى سجل لا يملك Create أو Enter أو Leave

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped تساوي false متى كان الرجوع المحمول إلى تدفق الملف فعالًا، وهي الحقل الوحيد الذي يثبت عدم إنشاء ربط
  • windowSize هو حجم النافذة الفعلي بعد المحاذاة لا القيمة التي طلبتها، بينما تكون mappedBytes أصغر منه في النافذة الأخيرة
  • mappedOffset هي بداية العرض المحتفظ به بعد محاذاتها إلى التخصيص، أو -1 عندما لا يكون هناك عرض نشط حاليًا
  • يعد readCalls طلبات القراءة الناجحة داخل النطاق، ويعد bytesRead البايتات المنسوخة إلى المستدعين، وتشمل remapCount العرض الأول

تغطي اختبارات التراجع المستهدفة القراءات المطلقة العابرة للنوافذ، والحفاظ على المؤشر المنطقي، والقراءات القصيرة عند الذيل، والإزاحات غير الصالحة، والكتابات المرفوضة، وإعادة الربط بين نوافذ متباعدة، والاستخراج المؤجل لمرفق غير قابل للضغط حجمه 220 KB، وتحول الإحصاءات إلى حالة غير صالحة بعد DACloseFile؛ وقد اكتشفت مجموعتا الاختبار عديمتا الرأس Win32 وWin64 كل منهما 1467 اختبارًا ونجحتا فيها كلها من دون تجاهل أو فشل أو خطأ أو تسريب. وإذا كنت تعمل مع ملفات PDF بحجم جيجابايت في Delphi أو C++Builder وكان المحلل يشير باستمرار إلى قراءات الملفات بدل التحليل، فتستحق نقاط الدخول الخاصة بالملفات المربوطة قضاء فترة بعد الظهر في القياس، وسيخبرك GetMappedFileInfo ما إذا كنت حصلت فعلًا على ربط. وتوجد مرجعية API الكاملة وبنية تجريبية في صفحة مكتبة PDFlibPas PDF لـDelphi