مقاله فنی

خطاهای بررسی دامنه (Range Check) در کتابخانه‌های PDF دلفی: علل ریشه‌ای

خطاهای بررسی دامنه (Range check errors) در کتابخانه‌های PDF دلفی شهرت بدی در سخت بودن برای ردیابی دارند، زیرا از یک الگوی ورودی ثابت پیروی نمی‌کنند. یک سند خاص در یک ماشین این خطا را ایجاد می‌کند و در ماشین دیگر نه؛ همان مسیر کد در یک فایل 3 صفحه‌ای استثنا ایجاد می‌کند اما در یک فایل 12 صفحه‌ای بدون مشکل اجرا می‌شود. این عدم ثبات تقریباً همیشه به یک علت ریشه‌ای واحد برمی‌گردد: اشیای صفحه PDF (page objects) به ترتیب فایل ذخیره نمی‌شوند. اگر کتابخانه آرایه صفحات داخلی خود را با اسکن متوالی اشیاء به جای پیمایش درخت صفحه‌ای که توسط کاتالوگ اعلام شده بسازد، ایندکسی می‌سازد که دامنه معتبر آن با آنچه فراخوانی‌کننده‌ها (callers) انتظار دارند مطابقت ندارد، و بررسی دامنه این عدم تطابق را در بدترین زمان ممکن آشکار می‌کند

بررسی دامنه (Range checking) در Delphi چگونه کار می‌کند

با فعال بودن دستورالعمل کامپایلر {$R+} (که در پیکربندی Debug پیش‌فرض است)، RTL دلفی هر ایندکس آرایه، زیرنویس رشته و تخصیص شمارشی را در زمان اجرا اعتبارسنجی می‌کند. یک دسترسی خارج از محدوده (out-of-bounds) به جای خواندن بی‌صدا از حافظه مجاور، خطای ERangeError را صادر می‌کند. این رفتار ارزشمند است: باگ‌های پنهان را به جای اینکه اجازه دهد ساختار داده‌ای را مخدوش کنند که صد خط پایین‌تر دچار مشکل شود، در همان ابتدا نمایان می‌کند. بخش ناامیدکننده این است که استثنا در محل دسترسی (access site) اجرا می‌شود، نه در نقطه‌ای که ایندکس به درستی محاسبه نشده است. وقتی پشته فراخوانی (call stack) متدی را در عمق یک واحد PDF نشان می‌دهد، اشتباه واقعی معمولاً مربوط به چندین فریم قبل است

شرایط بولی مرکب (Compound boolean conditions) این وضعیت را بدتر می‌کنند. دلفی عبارات and را از چپ به راست با معنای مدار کوتاه (short-circuit) ارزیابی می‌کند، اما مدار کوتاه فقط زمانی ارزیابی را متوقف می‌کند که سمت چپ 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 آن‌ها لیست اشیاء صفحه را به ترتیب نمایش قرار می‌دهد. فایل این اشیاء را در هر آفستی (offset) که نویسنده فایل انتخاب کرده است ذخیره می‌کند؛ شیء شماره 20 می‌تواند از نظر فیزیکی پیش از شیء شماره 3 در جریان بایت (byte stream) قرار گیرد. کتابخانه‌ای که لیست صفحات خود را با تکرار روی جدول ارجاع متقاطع (cross-reference table) به ترتیب شماره اشیاء می‌سازد به جای دنبال کردن زنجیره Kids، توالی‌ای ایجاد می‌کند که از آنچه کاربر انتظار دارد متفاوت است. روی اسنادی که ایجادکننده بر حسب اتفاق صفحات را به ترتیب نوشته است، همه چیز به خوبی کار می‌کند. روی اسنادی که این کار را نکرده‌اند، اختلاف بین شماره‌گذاری صفحه کتابخانه و شماره‌گذاری صفحه فراخوان‌کننده، ایندکس‌هایی ایجاد می‌کند که خارج از PageArr قرار می‌گیرند

رویکرد صحیح این است که از کاتالوگ شروع کنید، ارجاع غیرمستقیم /Pages را تجزیه و تحلیل کنید و به صورت بازگشتی در آرایه Kids پیش بروید. برای یک سند مسطح (flat) بدون گره‌های واسطه 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
      // گره واسط: به طور بازگشتی وارد Kids آن شوید
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

پس از اجرای این، PageArr[0] همان صفحه اولی است که نمایشگر به کاربر نشان می‌دهد، فارغ از اینکه آن شیء کجای جریان بایت قرار داشته باشد. ایندکس‌های عبور داده شده توسط فراخوان‌کننده‌هایی که فرضشان ترتیب نمایش است اکنون به درستی تطابق می‌یابند و خطاهای دامنه متوقف می‌شوند

راه‌حل‌های درون‌کد (Hard-coded) مشکل را تشدید می‌کنند

در پایگاه‌کدهایی (codebases) که علت ریشه‌ای هرگز شناسایی نشده، یافتن پچ‌های شهودی رایج است: تعویض صفحه اول و آخر در صورتیکه تعداد کل برابر 3 باشد، چرخش ایندکس برای اسناد تولید شده از یک ایجادکننده خاص، اعمال یک آفست در صورتی که شماره اولین شیء از یک آستانه خاص فراتر رود. هر یک از آن پچ‌ها دقیقاً با مجموعه فایل‌های تستی که در زمان نوشتن آن پچ موجود بودند تطابق دارد. یک منبع PDF متفاوت اضافه کنید تا یکی از این پچ‌ها در زمان اشتباهی اجرا شده و ایندکسی را تولید کند که اینک دو برابر اشتباه است: اشتباه است زیرا از یک آرایه نامرتب محاسبه شده، و دوباره اشتباه است زیرا یک نگاشت (mapping) نامربوط روی آن اعمال شده است. بررسی‌کننده دامنه آن را در جایی پایین‌تر در جریان کشف می‌کند و ردیابی پشته (stack trace) به هیچ جای مفیدی اشاره نمی‌کند

تنها مسیر سازنده این است که هر نگاشت شهودی را حذف کرده و ساختار آرایه صفحه را با یک پیمایش درخت صحیح جایگزین کنید. هنگامی که ایندکس‌ها با ساختار خود صحیح باشند، نیازی به هیچ پچ‌ای نیست و بررسی‌کننده دامنه به یک دارایی ارزشمند به جای یک مانع تبدیل می‌شود

اگر شما در حال نگهداری از کتابخانه‌ای هستید که این الگو را از خود نشان می‌دهد، به طور موقت بررسی دامنه (range checking) را در ساخت Release فعال کنید و آن را روی مجموعه متنوعی از فایل‌های PDF اجرا کنید: اسناد تولید شده توسط Word، توسط LaTeX، توسط سیستم عامل دستگاه‌های اسکنر، یا ابزارهای جداکننده PDF به PDF. فایل‌هایی که باعث ایجاد استثنا می‌شوند همان‌هایی هستند که ترتیب شی صفحه آنها از ترتیب پیمایشی که کد شما فرض می‌کند متفاوت است. هر کدام یک نقطه داده (data point) هستند، نه یک باگ جداگانه

برای کدهای جدیدی که یک کتابخانه PDF دلفی را فراخوانی می‌کنند، توصیه عملی این است که تعداد صفحات کتابخانه را معتبر دانسته و هرگز یک ایندکس برگرفته از محاسبات داده‌های خارجی را تا قبل از اطمینان از قرار گرفتن آن در بازه 0..PageCount - 1 عبور ندهید. کامپوننت HotPDF تعداد صفحه تجزیه شده را از طریق THotPDF.PageCount پس از BeginDoc یا پس از بارگیری سند در اختیار می‌گذارد؛ این مقدار همیشه بازتاب‌دهنده پیمایش درخت صفحات است و برای استفاده به عنوان حد بالایی برای هرگونه محاسبه ایندکس ایمن است