מאמר טכני

שגיאות Range Check בספריות PDF ב-Delphi: סיבות שורש

שגיאות Range Check בספריות PDF ב-Delphi זוכות למוניטין של שגיאות שקשה לאתר כיוון שהן אינן עוקבות אחר דפוס קלט עקבי. אותו מסמך מייצר אותן במחשב אחד ולא באחר; אותו נתיב קוד מקפיץ את החריגה בקובץ בן 3 דפים אך פועל באופן נקי בקובץ בן 12 דפים. חוסר עקביות זה כמעט תמיד מוביל לסיבת שורש אחת: אובייקטי דפים של PDF אינם מאוחסנים לפי סדר בקובץ. אם הספרייה בונה את מערך הדפים הפנימי שלה על ידי סריקת אובייקטים ברצף במקום מעבר על עץ הדפים שהוצהר על ידי הקטלוג, היא בונה אינדקס שהטווח התקין שלו אינו תואם למה שהמתקשרים מצפים, ובדיקת הטווח (range checking) תופסת חוסר התאמה זה ברגע הגרוע ביותר האפשרי

כיצד עובדת בדיקת טווח ב-Delphi

עם הנחיית המהדר {$R+} פעילה (ברירת המחדל בתצורת Debug), ה-RTL של Delphi מאמת כל אינדקס מערך, אינדקס מחרוזת והשמה של ערך ממוספר (enumerated) בזמן ריצה. גישה מחוץ לגבולות מעלה ERangeError במקום לקרוא בשקט זיכרון סמוך. התנהגות זו היא בעלת ערך: היא מציפה באגים סמויים מוקדם במקום לתת להם להשחית מבנה נתונים שנכשל רק מאה שורות מאוחר יותר. החלק המתסכל הוא שהחריגה מוקפצת באתר הגישה, לא בנקודה שבה האינדקס חושב באופן שגוי. כאשר מחסנית הקריאות (call stack) מראה שיטה מקוננת עמוק ביחידת PDF, הטעות האמיתית היא לרוב כמה מסגרות אחורה

תנאים בוליאניים מורכבים מחמירים את זה. Delphi מעריכה ביטויי 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 שלהם מפרטים אובייקטי דפים לפי סדר התצוגה. הקובץ מאחסן אובייקטים אלה בהיסטים שבהם הכותב בחר; אובייקט מספר 20 יכול להקדים פיזית את אובייקט מספר 3 בזרם הבתים. ספרייה שבונה את רשימת הדפים שלה על ידי איטרציה בטבלת ההפניות המקורבות (cross-reference table) לפי סדר מספר האובייקט במקום לעקוב אחר שרשרת 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 באופן זמני והרץ אותה מול קורפוס מגוון של קובצי PDF: מסמכים המיוצרים על ידי Word, על ידי LaTeX, על ידי קושחת סורק, על ידי כלי פיצול מ-PDF ל-PDF. הקבצים המפעילים חריגות הם אלה שסדר אובייקטי הדפים שלהם סוטה מסדר המעבר שהקוד שלך מניח. כל אחד כזה הוא נקודת נתונים, לא באג נפרד

עבור קוד חדש שקורא לספריית PDF של Delphi, העצה המעשית היא להתייחס לספירת הדפים של הספרייה כסמכותית ולעולם לא להעביר אינדקס הנגזר מאריתמטיקה על נתונים חיצוניים מבלי לאשר תחילה שהוא נופל בטווח של 0..PageCount - 1. רכיב HotPDF חושף את ספירת הדפים הפתורה דרך THotPDF.PageCount לאחר BeginDoc או לאחר טעינת מסמך; ערך זה תמיד משקף את מעבר עץ הדפים ובטוח לשימוש כגבול העליון עבור כל אריתמטיקת אינדקסים