מאמר טכני

סיווג קישורים חיצוניים של BIFF SupBook ו־XTI ב־Delphi

פתחו xls ישן, שמרו אותו שוב, ונוסחת ה־add-in שקראה לספריית ניתוח רשומה מצביעה עכשיו על הפניה ריקה בתוך החוברת עצמה. HotXLS עוקב אחר השחיתה השקטה הזאת עד להנחה אחת רעה: שרשומת BIFF SupBook היא או self או קובץ חיצוני. [MS-XLS] מגדיר שבעה סוגים, לא שניים

למה חוברת שנשמרה מאבדת את קישורי ה־add-in שלה?

כי בדיקת הסיווג הייתה מבנית ולא טיפוסית. הקיצור המסורתי קורא רשומת SupBook ($01AE), בודק אם היא נושאת את סימון ה־self, ואם לא, מתייחס לכל מחרוזת שבאה אחריו כאל URL של מסמך. כל רשומה שאינה אף אחת משתי אלה נופלת לענף ברירת מחדל, וענף ברירת המחדל הוא כמעט תמיד "זו החוברת עצמה". קישור תומך של add-in, קישור same-sheet, משבצת לא בשימוש ורשומה קטועה — כולם מסיימים עם אותה תווית שגויה. שום דבר לא זורק חריגה בזמן שזה קורה: הרשומה נותחה, הנוסחה הורכבה מחדש, הקובץ נשמר בלי אזהרה, והפגם עולה אל פני השטח שלושה שבועות אחר כך כשמישהו שם לב לעמודת אפסים במקום המרת מטבע. [MS-XLS] §2.4.271 מתאר רשומה שיכולה להיות הפניה עצמית, הפניית same-sheet, מכל פונקציות add-in, חוברת חיצונית עם נתיב וירטואלי וטבלת שמות גיליונות, קישור נתונים DDE או OLE, או מציין מקום לא בשימוש — ומצב שביעי שאינו במפרט אבל קיים על דיסקים אמיתיים, הרשומה שלא מנותחת. הפתרון אינו היוריסטיקה טובה יותר; הוא לוותר על היוריסטיקה כליל

שבעת הסוגים שרשומת SupBook יכולה לשאת

HotXLS מגדיר את הטקסונומיה של קישורים תומכים כמניין סגור ב־lxExternSheet.pas, וכל החלטה במורד הזרם מתגלגלת עליו. תשעה ערכי מניין מכסים את שבע הקטגוריות, כי המקרה של DDE ו־OLE זקוק למצב זמני לפני שאפשר לפתור אותו:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // ניתוח נכשל, או שנותרו בתים בסוף
    slkSelf,              // החוברת הזאת
    slkSameSheet,         // סימון U+0000
    slkAddIn,             // מכל פונקציות add-in
    slkExternalWorkbook,  // נתיב וירטואלי + טבלת שמות גיליונות
    slkDde,               // נפתר מדגלי ExternName
    slkOle,               // נפתר מדגלי ExternName
    slkDdeOrOle,          // אחד מהשניים, עדיין לא ידוע איזה
    slkUnused);           // מציין מקום של רווח בודד

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // אפס־מבוסס, כפי שנשמר ב־ExternSheet.rgXTI
    ExternID    : Integer;   // אחד־מבוסס, המוסכמה הפנימית
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

הניתוב מונחה סנטינלים, לא מונחה מחרוזות. ערך שדה $0401 מסמן את רשומת ה־self. מספר גיליונות של אחד בצימוד עם $3A01 מסמן מכל add-in. רק ערך בתחום 1 עד $00FF אומר שאחריו בא נתיב וירטואלי מקודד, ורק אז HotXLS מפענח מחרוזת בכלל. כל דבר מחוץ לשלוש הצורות האלה נשאר slkUnknown, ורשומה שטבלת שמות הגיליונות שלה אינה צורכת את גוף הרשומה בדיוק מורדת בדרגה חזרה ל־slkUnknown גם כשהראש נראה סביר

סולם הסנטינלים שבו HotXLS משתמש כדי לסווג רשומת BIFF SupBook לשבעה סוגים, מפענח מחרוזת רק עבור ערכים בתחום הנתיב המקודד ונופל חזרה לסוג לא ידוע במקום לענף ברירת מחדל
כל סוג מושג באמצעות סנטינל ולא באמצעות בדיקת מחרוזת, ורשומה שאינה מתאימה לאף צורה נשארת לא ידועה במקום ליפול לענף ברירת מחדל שמשמעותו החוברת הזאת

למה סימון ה־same-sheet מפוענח כמחרוזת ריקה?

כי קורא המחרוזות הכללי של BIFF הורס את הביט שעליו הסיווג תלוי. קישור תומך מסוג same-sheet הוא מחרוזת בת תו אחד שהתו היחיד שלה הוא U+0000, ו־TXLSBlob.GetBiffString מחזיר אותה כ־WideString ריקה, בלתי־נבדלת מנתיב ריק אמיתי — בדיוק הקלט שהיוריסטיקה של הפניה עצמית עונה עליו "self". לכן HotXLS קורא את נקודת הקוד הראשונה הגולמית מגוף הרשומה במקום לסמוך על הערך המפוענח:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // דחוס, בית אחד
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // רחב, שני בתים
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

שימו לב לענף דחוס־מול־רחב. בית האפשרויות יושב בהיסט קבוע מכותרת המחרוזת ונקודת הקוד הראשונה היא בית אחד או שניים לפי ביט 0, ולכן קריאתו כבית בלי תנאי עובדת על רוב הקבצים ונכשלת על אלה שנכתבו על ידי מבנים מקומיים — ההתפלגות הגרועה ביותר עבור באג. מציין המקום לא בשימוש נלכד באותה דרך, לפי מטען הרווח הבודד הליטרלי שלו, והמקרה של DDE או OLE לפי מפריד ה־U+0003 המוטמע בנתיב המקודד

מדוע HotXLS קורא את נקודת הקוד הראשונה הגולמית מגוף רשומת BIFF SupBook במקום את המחרוזת המפוענחת, כי קורא המחרוזות הכללי הופך את סימון ה־same-sheet של U+0000 לערך ריק
סימון ה־same-sheet הוא מחרוזת בת תו אחד שהתו שלה U+0000, ולכן קורא המחרוזות הכללי מקפל אותו לערך ריק, ורק נקודת הקוד הגולמית בהיסט בית האפשרויות משמרת אותו

למה אי אפשר להפריד DDE ו־OLE בשלב ה־SupBook?

כי רשומת ה־SupBook לא נושאת את הביטים המבדילים. היא אומרת לכם שהקישור הוא אחד מהשניים; הדגלים fOle ו־fOleLink שמחליטים איזה יושבים ברשומת ExternName ($0023) שמגיעה מאוחר יותר בזרם. HotXLS רושם slkDdeOrOle בזמן הניתוח ומצמצם אותו ב־ParseExternalName, ואם ExternName לא מגיע לעולם הסוג נשאר זמני לנצח — וזה נכון, כי הקובץ באמת לא אומר. כל צרכן במורד הזרם מתייחס לערך הזמני הזה כערך אמיתי ולא כחסר, ולכן אף קורא לא צריך להמציא שובר שוויון. לנחש כאן "כנראה DDE" יקנה מניין מסודר יותר ומחלקה שלמה של תשובות שגויות שאף אחד לא יוכל לעקוב אחריהן:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

אינדקסי XTI הם אפס־מבוססים על הדיסק ואחד־מבוססים בפנים

HotXLS מבצע את המרת הסטייה־באחד בדיוק פעם אחת, בנקודה שבה אסימון נכנס לעץ התחביר הפנימי, ולא בשום מקום אחר. PtgNameX.ixti ([MS-XLS] §2.5.198.85) הוא אינדקס אפס־מבוסס לתוך מערך ה־rgXTI של רשומת ExternSheet ($0017, §2.4.106), בזמן שהמוסכמה הפנימית של ExternID בספרייה היא אחד־מבוססת עם אפס שמור ל"אין גיליון חיצוני". נתיב הקריאה של BIFF8 עושה FExternID := wValue + 1 כשהוא מפענח אסימון tNameX ונתיב הכתיבה פולט StoreExternID - 1, ומשאיר את תצוגת האסימונים הגולמית ואת סמנטיקת הדיסק בלי גע יד. טעות כאן קשה במיוחד לגילוי: שמות מוגדרים חיצוניים נפתרים לרשומה השכנה, ובקובץ עם רשומת XTI אחת אינדקס 0 הופך לאינדקס 1, מפסיד, והשם מתדרדר בשקט. רגרסיה שבודקת רק טקסט נוסחה מורכב מחדש לעולם לא תראה אותה, כי הרכבה מחדש לא נוגעת באינדקס הדיסק בכלל — אותה מלכודת שהופכת את שמות מוגדרים שחוצים גיליונות וחוברות לשווה בדיקה מול זרמי בתים אמיתיים. הפתרון חסום בשני הקצוות: TlxExternSheetSheet.TryResolveXti מחזיר False לאינדקס שלילי או לרשומה חסרה, TXLSSupBook.TryGetKind מחזיר False לאינדקס SupBook מחוץ למערך, ו־ClassifyXti אז ממפה את slkSelf ו־slkSameSheet ל־frcInternal, את slkExternalWorkbook ל־frcExternalWorkbook, ואת slkAddIn, slkDde, slkOle ו־slkDdeOrOle ל־frcExternalOther. כל שאר הדברים, כל נתיב מחוץ לתחום כלול, נוחתים על frcUnknownOrMalformed

HotXLS ממיר את האינדקס האפס־מבוסס של XTI מאסימון BIFF PtgNameX ל־ExternID הפנימי האחד־מבוסס בנקודה אחת, עם פתרון חסום בשני הקצוות ומפת הסיווג הצורכת אותו
הסטייה־באחד בין האינדקס האפס־מבוסס על הדיסק ל־ExternID הפנימי האחד־מבוסס מיושמת פעם אחת, כשאסימון נכנס לעץ התחביר, וכל אינדקס בלתי־פתיר נוחת על המחלקה הפגומה

סיווג נוסחה לפני הקפאתה

TXLSCompiledFormula.ClassifyReferences סורק את זרם האסימונים של BIFF השמור ישירות במקום לפרק את הנוסחה ולחפש סוגריים מרובעים. ציד סוגריים בטקסט נוסחה הוא היוריסטיקת טקסט לבושה במעיל של מנתח: היא מתאימה מילוליות מחרוזת, היא מתאימה הפניות מובנות, והיא מפספסת שמות מוגדרים חיצוניים כליל, כי אלה אינם נושאים סוגריים בצורתם המפורקת. הסריקה מסתכלת רק על PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d ו־PtgAreaErr3d, ונופלת אחורה להליכה על עץ התחביר כשזרם BIFF לא שורד. המיזוג פסימי בכוונה — סדר העדיפויות הקבוע הוא frcUnknownOrMalformed, אחר כך frcExternalWorkbook, אחר כך frcExternalOther, ואז frcInternal — ולכן אסימון בלתי־קריא אחד מזהם את כל הנוסחה. עבור שם מוגדר חיצוני מתוקף גם אינדקס השם: אחד־מבוסס, בתחום, ומגובה ברשומת ExternName שנשמרה

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets הוא אחד־מבוסס
    begin
      Sheet := Wb.Sheets[i];
      // מקפיא רק נוסחאות שסווגו frcExternalWorkbook;
      // הפניות פנימיות, add-in, DDE/OLE ופגומות נשארות נוסחאות
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

פרמטר ה־OnlyExternal הוא המקום שבו הטקסונומיה משלמת את עצמה. הקפאת נוסחה היא בלתי־הפיכה, ולכן הפעולה חייבת להוכיח שהפניה היא חוברת חיצונית ולא רק לחשוד בכך. קריאות add-in שורדות, קישורי DDE ו־OLE שורדים, וכל דבר שהמנתח לא יכול היה להבין במלואו שורד, כי התוצאה הבטוחה של אי־ודאות היא לא לשנות דבר. אותה משמעת שולטת בקשירה מחדש של נוסחאות שהועתקו בין חוברות, שם הפניה שסווגה לרעה נקשרת מחדש לחוברת הלא נכונה במקום להיכשל בקול רם

רשומות שלא מנותחות נכתבות בחזרה ללא מגע

HotXLS שומר את מטען ה־SupBook המקורי ופולט אותו מחדש בית־אחר־בית כשהרשומה מעולם לא נערכה. כשל ניתוח קובע slkUnknown ומנקה את המצב הנגזר, אבל הגוף הנלכד נשאר ב־FRawData ונתיב השמירה מעדיף אותו על פני כל שחזור כל עוד הפריט אינו מלוכלך ואינו רשומת ה־self. האלטרנטיבה — נרמול רשומה שלא נותחה להפניה עצמית כדי שלכותב יהיה משהו תקני לפלוט — הופכת רשומה שלא הבנתם לרשומה שגויה בהחלט. העיקרון הזה הוא אותו חוזה שמיושם על פרויקטי VBA והפניותיהם החיצוניות לאורך מחזור טעינה־ושמירה, וזה ההבדל בין ספרייה שמבצעת סבב שלם על קבצים אמיתיים לבין כזאת שמבצעת סבב על הקבצים שחבילת הבדיקות שלה במקרה מכילה. חוברת שעברה חמש־עשרה שנות גרסאות Excel, מחולל דוחות ושני כלי הגירה תכיל רשומות שאף אחד שחי כיום לא תכנן. כתבו אותן בחזרה כפי שמצאתם אותן

סיווג טיפוסי של רשומות SupBook ו־XTI הגיע ב־HotXLS 2.361.2 עד 2.361.4, יחד עם פתרון XTI חסום ונתיב ה־ConvertFormulasToValues הבטוח יותר שתואר כאן. אם אתם מתחזקים קוד Delphi או C++Builder שקורא קבצי xls ישנים עם קריאות add-in, קישורי DDE או OLE, או שמות מוגדרים חיצוניים, HotXLS Delphi spreadsheet component מטפל בכל הטקסונומיה באופן מקורי, בלי התקנת Excel ובלי אוטומציית OLE על המכונה שעושה את העבודה