מאמר טכני

אינדקס גופנים ב-BIFF8 מדלג על 4: ריצות טקסט עשיר ב-HotXLS

‏HotXLS ממספרת כל הפניית גופן של BIFF8 לפי ההגדרה של FontIndex ב-[MS-XLS] §2.5.129: ערכים 0 עד 3 הם מבוססי-אפס, ערכים מעל 4 הם מבוססי-אחד, ו-4 לא מופיע לעולם, כך שרשומת ה-FONT החמישית היא ifnt 5 וה-ifnt התקף הגדול ביותר שווה למספר רשומות ה-FONT. החל מ-HotXLS 2.384.4 כותב ה-XF, קורא ה-XF, ריצות הטקסט-העשיר במחרוזות והעברת הריצות בין חוברות כולם הולכים לפי הכלל הזה, ו-2.384.5 ו-2.384.6 מרחיבים אותו גם אל ריצות ההערות ותיבות הטקסט, כולל דרך העתקות והוספות שורות

הכלל הזה נראה כמו שגיאת הקלדה עד שנתקלים בו. מישהו פותח חוברת עם שמונה רשומות FONT, מוצא XF שמצביע על גופן 8, ומסיק שהכותב הפיק אינדקס מחוץ לטווח. בדיוק ההיגיון הזה יצא לאוויר ב-HotXLS 2.384.1 בתור "תיקון", והוא הפך מימוש נכון למימוש שבו כל גופן מותאם אישית בקובץ שנפתח ב-Excel נוחת משבצת אחת מוקדם מדי. החלק המעניין הוא לא ה-off-by-one עצמו אלא כמה מקומות בספריית BIFF8 נושאים את אותה מוסכמה, ואיך קישור של גופן יכול לשרוד שמירה אחת ולהישבר בשנייה. אם כבר התמודדת עם ההתנהגויות המוזרות של האורך והקידוד שכוסו בפענוח cch ו-fHigh של XLUnicodeString ב-BIFF8, זו אותה משפחה של באגים: הקובץ תקין, החשבון לא

מה הכלל של FontIndex ב-[MS-XLS] באמת אומר?

‏[MS-XLS] §2.5.129 קובע ש-FontIndex מתחת ל-4 הוא מיקום רשומה מבוסס-אפס, FontIndex מעל 4 הוא מיקום רשומה מבוסס-אחד, והערך 4 אסור לשימוש. את אותו טיפוס FontIndex משתמשות רשומות XF, ריצות העיצוב של ה-SST וריצות העיצוב של ה-TXO, כך שכלל אחד שנקרא לא נכון משחית את שלושתם. את הראיות קל לשחזר עם קובצים שנוצרו ב-Excel: ב-SOLVSAMP.XLS שמגיע עם Office יש 19 רשומות FONT ותקרת ה-ifnt של ה-XF עומדת על 19, חוברת של 43 רשומות מגיעה לכל היותר עד 43, וקובץ שנשמר על ידי Excel 16 עם 30 רשומות FONT מפנה את התאים שלו ב-Courier New אל ifnt 22, הרשומה ה-22. אף אחד מהם לא מכיל 4 לעולם. אם אתה צריך לנתח את המיפוי בעצמך בכלי אבחון, ההמרה היא שתי פונקציות קצרות

// [MS-XLS] 2.5.129 FontIndex: 0..3 zero-based, > 4 one-based, 4 פסול
function FontIndexToRecordNo(Ifnt: Word): Integer;  // רשומת FONT במיספור החל מ-1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 לא אמור להופיע
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
מיפוי ה-FontIndex של HotXLS לפי MS-XLS 2.5.129, שבו ifnt 0 עד 3 הם מיקומי רשומות FONT מבוססי-אפס, ifnt 5 ומעלה מבוססי-אחד והערך 4 לא מופיע לעולם, לצד המרת FontIndexToRecordNo וראיות מחוברות עבודה שנוצרו ב-Excel כמו SOLVSAMP.XLS
רשומת ה-FONT החמישית היא ifnt 5 ולא 4 — חוברת עם 19 רשומות מגיעה לכל היותר עד ifnt 19, ואף קובץ שנוצר ב-Excel לא מאחסן לעולם את הערך האסור שבתווך

בתוך HotXLS אותו כלל חי בשני מקומות תאומים. TXLSFontList.GetSaveIndex מקבל את המיקום בן ה-1 של גופן ברשימה המופנית ומחסיר 1 רק מהמיקומים 1 עד 4, כך שמיקום 5 נכתב בתור ifnt 5. TXLSReader.ParseXF עושה את ההפך בטעינה: כל ifnt של 5 ומעלה מוחסר למשבצת מבוססת-אפס ברשימת הגופנים, וכל מה שמתחת נשאר במקומו. מיפוי הריצות העשירות של ה-SST ו-CountRichRunFontRefs מיישמים את אותה המרה של ifnt >= 5, וזו בדיוק הנקודה: מוסכמה אחת, כל הצרכנים

// TXLSFontList.GetSaveIndex (צד הכותב)
Result := inherited GetSaveIndex(Index);   // מיקום מופנה במיספור החל מ-1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 הופכים ל-0..3, מ-5 ומעלה ללא שינוי

// TXLSReader.ParseXF (צד הקורא)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 הוא משבצת 4 ברשימת הגופנים

למה "התיקון" מבוסס-האפס הזיז כל גופן מותאם באחד?

הכתיבה מחדש מבוססת-האפס ב-HotXLS 2.384.1 הזיזה כל גופן מותאם אישית, כי היא קראה אינדקס מבוסס-אחד כאילו היה מבוסס-אפס ואז שינתה ארבע נקודות קריאה כדי להתאים לקריאה המוטעית הזאת: GetSaveIndex, ParseXF, מיפוי הריצות של ה-SST והעברת הריצות בין החוברות ב-Sheets.AddCopy. ה-round trips של HotXLS עדיין נראו תקינים, כי הכותב והקורא הסכימו זה עם זה. Excel לא הסכים. קובץ שנכתב על ידי 2.384.1 שם את הגופן המותאם הראשון ב-ifnt 4, ש-Excel מתייחס אליו כגופן ברירת המחדל, וכל גופן מותאם מאוחר יותר נחת רשומה אחת מוקדם; פתיחת קובץ של Excel הלכה לכיוון ההפוך, וקשרה כל גופן רשומה אחת מאוחר

הרגרסיה של HotXLS 2.384.1 שבה GetSaveIndex ו-ParseXF קראו ערכי FontIndex מבוססי-אחד כמבוססי-אפס, כתבו את הגופן המותאם הראשון כ-ifnt 4 האסור ש-Excel מפענח לגופן ברירת המחדל והנחיתו כל גופן מאוחר יותר רשומה אחת מוקדם, בזמן שה-round trips עדיין עברו
הכותב והקורא הסכימו על אותה קריאה מוטעית, ולכן בדיקה של שמירה ופתיחה מחדש נשארה ירוקה בזמן שכל גופן שנפתח ב-Excel נחת משבצת אחת בכיוון הלא נכון — כשמוסכמה אחת חיה בשבעה מקומות ומשנים ארבעה, חשוד קודם כל בשינוי שלך

הרמז שהיה צריך לעצור את השינוי ישב באותו בסיס קוד. CountRichRunFontRefs, מיפוי ה-FONTX וה-FBI של התרשימים ורשימת הגופנים של מנוע הסגנונות מעולם לא נגעו והמשיכו להשתמש בדילוג על 4, כך שהספרייה סתרה את עצמה מהרגע שבו 2.384.1 יצאה, ורק המקריות שריצות הטקסט-העשיר הופנו בדרך כלל גם על ידי איזה XF הסתירה את הסתירה. כשמוסכמה אחת מופיעה בשבעה מקומות ואתה משנה ארבעה, חשוד קודם בשינוי שלך ורק אחר כך בשלושת האחרים. גרסה 2.384.4 החזירה את המיספור של המפרט בכל ארבעת המקומות, ובדיקת הרגרסיה הישנה, שאימתה ifnt < FontCount ובכך קידדה את הקריאה המוטעית, הוחלפה בבדיקות שממפות כל ifnt שנכתב חזרה אל שם רשומת FONT דרך נוסחת המפרט. מגבלה כנה אחת נשארת: קובצים שנשמרו על ידי 2.384.1 עד 2.384.3 עם חמישה גופנים או יותר נושאים אינדקסים מוסטים שקורא אינו יכול להבדילם מנתונים תקינים, ולכן הריפוי היחיד הוא להפיק אותם מחדש

למה ריצות הגופנים של ההערות נשברות רק בשמירה השנייה?

‏ריצות ההערות ותיבות הטקסט נשברו בשמירה השנייה כי HotXLS החזיקה את N-1 רשומות ה-FONT הראשונות ללא תנאי והשמיטה רק את האחרונה כאשר אף XF לא הפנה אליה, בזמן שריצות העיצוב של ה-TXO ([MS-XLS] §2.4.329) נכתבו בחזרה בייט אחרי בייט בלי מיספור מחדש. קובצי ‎.xls‎ שנוצרו ב-Excel תמיד מסתיימים בגופן סיומת שאף הפניה לא מצביעה עליו (DengXian בגודל 9pt על מערכת עם locale סיני), ולכן בשמירה הראשונה הגופן ששימש רק ריצה של הערה לעולם לא היה אחרון, ושום דבר לא זז לעין. אבל אותה שמירה ראשונה כן השמיטה את גופן הסיומת, וקידמה את הגופן שמשמש הערות בלבד אל המקום האחרון. השמירה השנייה כבר זרקה אותו בתור לא-מופנה, ה-ifnt של הריצה הצביע מעבר לסוף, ו-Excel נפל בחזרה אל גופן ברירת המחדל; אם החוברת הרוויחה גופן חדש בין לבין, הריצה נקשרה בשקט אליו במקום, מה שבבדיקות הפך ריצה מעוצבת בתיבת טקסט ל-Arial. קובצים עמוסי הערות כמו אלה שמתוארים בבניית תהליך סקירה של הערות והיפר-קישורים הם בדיוק המקום שבו זה נושך, כי אותם פותחים, מסמנים ושומרים שוב ושוב

טבלת הגופנים של HotXLS לאורך שתי שמירות, שבה רשומת ה-FONT הסיומת שאף הפניה לא מצביעה עליה וש-Excel תמיד כותב נופלת ראשונה, הגופן שמשמש רק הערות הופך לאחרון ואז נזרק כי ריצות העיצוב של ה-TXO נכתבו בחזרה בלי ספירת הפניות, עד ש-CountRichRunFontRefs תיקנה את מסנן ההישרדות ב-2.384.5
השמירה הראשונה נראתה נקייה כי גופן הסיומת ספג את האובדן, וגופן ההערות נעלם רק בשמירה השנייה — מפה כל ifnt אל שם רשומת FONT לאורך השמירות במקום לסמוך על בדיקה בזיכרון

‏HotXLS 2.384.5 מתייחסת אל ריצות TXO כאל ריצות SST. CountRichRunFontRefs עוברת עכשיו על כל TMSOShapeTextBox בכל גיליון עבודה, ממירה את ה-ifnt מסוג skip-4 של כל ריצה למשבצת וסופרת אותו בתור הפניה, כך שגופן שמשמש רק ריצות שורד את מסנן השמירה. טבלת משבצת-אל-אינדקס-שמירה שמתקבלת נכנסת אל ה-FontRunRemap של כל ציור, ו-TMSOShapeTextBox.Store כותבת מחדש את האינדקסים של הריצות על עותק פרטי של בייטי הריצות הגולמיים, ומשאירה את TxOLastRun הסיומת במנוחה כי הוא לא נושא גופן. לקוד האפליקציה החוזה פשוט: TXLSComment.TextRuns.FontIndex ו-TXLSTextBox.TextRuns.FontIndex משתמשים במיספור הקובץ, עם דילוג על 4, בדיוק כפי שנקראו; אינדקסי הריצות הם מבוססי-1 וה-CharIndex הוא היסט התווים שבו הריצה מתחילה. אחרי שמירה המספר המאוחסן עשוי להיות שונה מזה שהצבת, אבל הוא עדיין מצביע על אותו גופן

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // מיספור הקובץ, 4 מדולג
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

העתקות, הוספת שורות והעברת ריצות בין חוברות

החל מ-HotXLS 2.384.6, כל נתיב העתקה במנוע הקלאסי שומר על ריצות העיצוב של ההערות, כי Range.Copy, CopyRange, Sheets.AddCopy והזזות התאים שמאחורי Range.Insert ו-Range.Delete כולם עוברים דרך TXLSRange.CopyCell, ו-CopyCell נהג להעתיק רק את טקסט ההערה ואת המחבר. הזזה היא העתקה בתוספת ניקוי, ולכן הוספת שורה אחת מעל הערה עם שתי ריצות השאירה אותה עם אפס ריצות וגופן אחד. התיקון מעתיק כל ריצה ומעביר את הגופן שלה דרך TXLSWorkbook.MigrateRunFontIndex, שממירה את האינדקס מסוג skip-4 למשבצת, מהגרת את הגופן לפי ערך אל טבלת הגופנים של היעד וממירה בחזרה למיספור הקובץ; העברת הטקסט-העשיר של ה-SST ב-Sheets.AddCopy קוראת עכשיו לאותה פונקציה במקום לשאת עותק משלה של החישוב. שני מקרי קצה הגיעו יחד עם התיקון: הדבקה במקום שבה המקור והיעד הם אותה הערה בדיוק חייבת שלא לנקות את הריצות שלה לפני שקוראים אותן, ו-Sheets.AddCopy עושה עכשיו מעבר שני על הערות שמחוברות לתאים בלי רשומת תא מאוחסנת, שאותן דילגה בעבר לגמרי. הצד של טבלת הגופנים בהעתקה בין חוברות הולך אחרי אותו היגיון של העברה לפי ערך כמו הצד של הנוסחאות, שכוסה בהעתקה בין חוברות וקישור מחדש של נוסחאות. במנוע ה-XLSX נתיבי ההעתקה כבר שיבטו ריצות לפי ערך; הפער היה בחלק ההערות עצמו, שבו הקורא התעלם מ-rFont, strike, u ו-vertAlign והכותב מעולם לא הפיק u או vertAlign, ולכן הריצות כעת שורדות שמירה ופתיחה מחדש באופן סימטרי

איך בודקים אינדקסי גופנים בקובצי BIFF8?

בדוק אינדקסי גופנים באמצעות שמירה ופתיחה מחדש, במיטב המקרים על פני יותר מדור אחד, ובאמצעות מיפוי כל ifnt בחזרה אל רשומת FONT במקום assert על טווח מספרי. כל באג בסיפור הזה עבר בדיקה בזיכרון: הרגרסיה של 2.384.1 חיה בתוך זוג כותב-קורא תואמים, סטיית ה-TXO הזדקקה לשתי שמירות עם שינוי בטבלת הגופנים בין לבין, והריצות שאבדו ב-XLSX הופיעו רק אחרי פתיחה מחדש. תוכנית בדיקה שימושית פותחת דגימה שנוצרה ב-Excel, שומרת אותה פעמיים דרך HotXLS, מוסיפה או מסירה גופן בין השמירות, ואז בודקת את מיקומי הריצות ובנוסף, ברמת הבייט, את שמות הגופנים שמאחורי כל ifnt. אל תשווה ערכי FontIndex לפני ואחרי שמירה, כי מיספור מחדש הוא לגיטימי

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // נוצר ב-Excel, ל-C2 יש שתי ריצות
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 זזה אל C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // פתיחה מחדש, אף פעם לא סומכים על הזיכרון
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

אם אתה קורא וכותב XLS קלאסי מתוך Delphi או C++Builder ומעדיף שלא לעקוב בעצמך אחרי איזה מבין צרכני הגופנים הרבים של הספרייה עדיין תואם ל-[MS-XLS] §2.5.129, מיספור הדילוג-על-4, מיספור הריצות מחדש בשמירה והעברת הריצות לפי ערך שמתוארים כאן בנויים בתוך רכיב הגיליונות של HotXLS לדלפי, שקורא וכותב XLS ו-XLSX בלי Excel ובלי OLE automation