Technical Article

أخطاء فحص النطاق في مكتبات Delphi PDF: الأسباب الجذرية

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

كيف يعمل فحص النطاق في Delphi

مع تفعيل توجيه المترجم {$R+} (وهو الإعداد الافتراضي في تكوين Debug)، يتحقق Delphi RTL وقت التشغيل من كل فهرس مصفوفة، وفهرس سلسلة، وإسناد لقيمة تعداد. ويؤدي الوصول خارج الحدود إلى رفع ERangeError بدلًا من قراءة الذاكرة المجاورة بصمت. هذا السلوك مفيد: فهو يكشف الأخطاء الكامنة مبكرًا بدلًا من السماح لها بإفساد بنية بيانات لا تنهار إلا بعد مئة سطر. الجزء المحبط هو أن الاستثناء يحدث عند موضع الوصول، لا عند النقطة التي حُسب فيها الفهرس بشكل خاطئ. وعندما تُظهر مكدسة الاستدعاءات طريقة عميقة داخل وحدة PDF، يكون الخطأ الحقيقي غالبًا عدة إطارات إلى الخلف.

التعابير المنطقية المركبة تجعل هذا أسوأ. يقيّم Delphi تعبيرات and من اليسار إلى اليمين بآلية القصر المنطقي، لكن القصر لا يتجاوز التقييم إلا عندما يكون الجانب الأيسر False. يبدو تعبير مثل:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

يبدو آمنًا، لكنه لا يحمي من فهرس خارج النطاق إلا إذا كان FDocStarted يساوي True وكان DestIndex غير سالب. أما الفحص DestIndex < Length(PageArr) فلا يفعل شيئًا عندما يكون DestIndex سالبًا، لأن مقارنة عدد صحيح سالب بطول غير سالب تعطي True في الحسابات الموقعة، ويظل الوصول اللاحق إلى المصفوفة يرمي خطأ النطاق. ونقل فحص الحدود إلى الموضع الخارجي هو الإصلاح الصحيح:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

هذا هو الإصلاح الميكانيكي. إنه يوقف التعطل، لكنه لا يفسر لماذا حصل DestIndex على قيمة خارج النطاق الصالح من الأصل.

السبب الحقيقي: ترتيب الكائنات مقابل ترتيب الصفحات

تعرّف ISO 32000-1 §7.7.3 شجرة الصفحات بأنها شجرة من عقد Pages، وتحتوي مصفوفات Kids فيها على كائنات الصفحات بترتيب العرض. يخزن الملف تلك الكائنات عند أي إزاحات يختارها منشئ الملف؛ فقد يسبق الكائن رقم 20 الكائن رقم 3 فعليًا في تدفق البايتات. أي مكتبة تبني قائمة صفحاتها عبر مسح جدول المراجع المتقاطعة حسب ترتيب أرقام الكائنات بدلًا من اتباع سلسلة Kids ستنتج تسلسلًا يختلف عما يتوقعه المستخدم. في المستندات التي صادف أن أنشأ فيها المولد الصفحات بترتيبها، يعمل كل شيء. أما في المستندات التي لم يحدث فيها ذلك، فإن التباين بين ترقيم الصفحات في المكتبة وترقيم الصفحات عند المستدعي ينتج فهارس تقع خارج PageArr.

النهج الصحيح هو البدء من الكتالوج، وحل المرجع غير المباشر /Pages، ثم السير في مصفوفة Kids تعاوديًا. بالنسبة إلى مستند مسطح بلا عقد Pages وسيطة، يكون الاجتياز مباشرًا:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

بعد تنفيذ ذلك، يصبح PageArr[0] أول صفحة سيعرضها العارض، بغض النظر عن موضع ذلك الكائن في تدفق البايتات. أما الفهارس التي يمررها المستدعون الذين يفترضون ترتيب العرض فتصبح مطابقة، وتتوقف أخطاء النطاق.

التحايلات الثابتة تضاعف المشكلة

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

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

إذا كنت تصون مكتبة تُظهر هذا النمط، ففعّل فحص النطاق في Release build مؤقتًا وشغّلها على مجموعة متنوعة من ملفات PDF: مستندات أنتجها Word، وLaTeX، وبرامج firmware الخاصة بالماسحات الضوئية، وأدوات تقسيم PDF إلى PDF. الملفات التي تثير الاستثناءات هي تلك التي يختلف فيها ترتيب كائنات الصفحات عن ترتيب الاجتياز الذي يفترضه الكود. كل ملف منها نقطة بيانات، وليس علة منفصلة.

بالنسبة إلى الشيفرة الجديدة التي تستدعي مكتبة Delphi PDF، النصيحة العملية هي اعتبار عدد صفحات المكتبة هو المرجع الحاسم، وعدم تمرير أي فهرس مشتق من حسابات على بيانات خارجية قبل التأكد من أنه يقع ضمن 0..PageCount - 1. يوفّر مكوّن HotPDF عدد الصفحات المحسوب عبر THotPDF.PageCount بعد BeginDoc أو بعد تحميل مستند؛ وهذه القيمة تعكس دائمًا اجتياز شجرة الصفحات، وهي آمنة للاستخدام كحد أعلى لأي حساب على الفهارس.