מאמר טכני

פענוח XLUnicodeString ב-BIFF8 ב-Delphi: cch ו-fHigh

HotXLS מפענחת XLUnicodeString של BIFF8 על ידי קריאת cch ודגל fHigh תחילה, ואז בחירת הקורא שמתאים לקידוד: TXLSBlob.GetWideString עם מספר בתים של cch * 2 כאשר fHigh הוא 1, ו-TXLSBlob.GetString כאשר fHigh הוא 0. מחברים אותם בכיוון ההפוך והרשומה מחזירה מחרוזת ריקה או באורך חצי, לעולם לא exception

זה מה שהופך את סוג הבאג הזה ליקר. תרשים נפתח, הסדרות מצוירות נכון, הצירים נכונים וכיתוב trendline אחד פשוט ריק. שום דבר ב-log, שום דבר ב-exception handler, שום dialog של קובץ פגום. הקובץ היה תקין כל הזמן; הקורא ביקש מספר בתים שגוי וקיבל בדיוק את מה שביקש

מדוע מחרוזת BIFF8 חוזרת ריקה

מחרוזת BIFF8 חוזרת ריקה מפני ש-guard של אורך דחה את ה-payload לפני שהקריאה בכלל התרחשה, או מפני שהקורא עצר ב-NUL הראשון שמצא. שני המסלולים שקטים מטבעם. ב-HotXLS ה-guard הוא בדרך כלל בדיקת DataLength מפורשת ב-handler של הרשומה, והיא חייבת להיות מחושבת לפי הקידוד: payload של 16 ביט צריך 8 + cch * 2 בתים לגוף SXViewLink, אך payload של 8 ביט צריך רק 8 + cch. מחילים את החשבון של התווים הרחבים על רשומה של 8 ביט וכל שם קצר נכשל בשער. התנהגות ה-NUL היא המלכודת השנייה, מפני שגם TXLSBlob.GetString וגם TXLSBlob.GetWideString סורקות את התוצאה המפוענחת עבור terminator וקוטעות שם, ומחזירות מחרוזת ריקה כאשר ה-terminator נמצא במיקום הראשון. קוראים גוף של 16 ביט עם חצי ממספר הבתים ומשאירים את cch div 2 התווים הראשונים; קוראים גוף של 8 ביט דרך הקורא הרחב וזוגות הבתים יוצרים code points שרירותיים. רק קריאה ארוכה מדי רועשת: TXLSBlob.EnsureReadable מעלה Blob read exceeds data size כאשר הבקשה חורגת מה-blob. לקריאה קצרה אין אזעקה כזו

GetWideString סופרת בתים ולא תווים

TXLSBlob.GetWideString(Index, Count) מקבלת את Count בבתים. בפנים היא מבצעת SetString על PWideChar עם Count div SizeOf(WideChar), ולכן העברת מספר תווים מקצרת בשקט את המחרוזת לחצי. פריסות הרשומות של BIFF8, לעומת זאת, מבטאות אורך מחרוזת בתווים. לכן כל אתר קריאה של 16 ביט חייב לשאת בעצמו את ההמרה * 2, וכל אתר קריאה של 8 ביט חייב לא לשאת אותה. זהו אותו גבול קידוד שמופיע כשכותבים טקסט החוצה במקום לקרוא אותו, וכדאי לקרוא אותו לצד ייצוא גיליונות בטוח ל-Unicode ב-Delphi אם הצינור שלכם מזיז מחרוזות בשני הכיוונים

// XLUnicodeStringNoCch של 16 ביט: cch תווים, cch * 2 בתים
Name := Data.GetWideString(Start, cch * 2);        // נכון
Name := Data.GetWideString(Start, cch);            // חצי הטקסט, ללא שגיאה

// XLUnicodeStringNoCch של 8 ביט: cch תווים, cch בתים
Name := WideString(Data.GetString(Start, cch));    // נכון
Name := Data.GetWideStringWithZero(Start, cch);    // עדיין קורא רחב

המוסכמה מתקיימת בכל מקום שבו זרם הבתים נדרס ביד. כאשר HotXLS מחברת מחדש רשומת String ארוכה ($0207, [MS-XLS] 2.4.268) מתוך רשומות Continue שלה ($003C), הענף הרחב מחשב segCh מתוך אורך הקטע ואז קורא ל-GetWideString(3, segCh * 2), מפני שגוף הרשומה מתחיל ב-offset 3 וה-count עדיין בבתים. קורא הטקסט העשיר עושה אותו דבר מ-offset 1 במקטע Continue הראשון. [MS-XLS] 2.5.293 מבטיח שהחיתוך יושב על גבול של תו כפול כאשר fHighByte הוא 1, לכן אין צורך בניהול תו חלקי, אך החשבון הבתי עדיין באחריותכם לדייק בו

מה GetWideStringWithZero באמת עושה

TXLSBlob.GetWideStringWithZero הוא קורא תווים רחבים ששומר NUL-ים פנימיים. הסיומת WithZero מסמנת שמירת NUL ולא רוחב תו: בפנים הוא מריץ את אותו SetString על PWideChar עם Count div SizeOf(WideChar) כמו GetWideString, רק בלי סריקת terminator. המקביל בן בית אחד הוא TXLSBlob.GetStringWithZero, שמחזיר AnsiString. שום דבר בשם אינו אומר מי הוא מי, והעמימות הזו עלתה לבסיס הקוד באגים אמיתיים. כדאי לתת שם לקריאה השגויה הספציפית, מפני שהיא נראית סבירה כל כך: GetWideString צריך cch * 2, לכן GetWideStringWithZero ודאי הוא זה שמקבל cch ישירות. הוא אכן מקבל cch בלי complaint, מחזיר WideString והמהדר מרוצה. הוא גם מחזיר חצי מהתווים, מורכבים מזוגות הבתים הלא נכונים. הנתיב הנכון של 8 ביט הוא TXLSBlob.GetString עם count רגיל של cch בתים, עם cast ל-WideString בזמן ההשמה. HotXLS 2.376.0 תיקנה בדיוק את השימוש השגוי הזה בשני מפענחי תרשימים

SXViewLink ושער האורך לפי קידוד

SXViewLink ($0858, [MS-XLS] 2.4.316) היא הדוגמה הנקייה ביותר, מפני שהיא אורזת את שתי האסימטריות ב-header אחד של שמונה בתים. ה-layout הוא rt(2), unused(2), reserved(2), cch(1), fHigh(1), ואחריו גוף XLUnicodeStringNoCch: fHigh = 1 פירושו cch * 2 בתי UTF-16, fHigh = 0 פירושו cch בתים של תווים בני בית אחד ו-cch מוגבל ל-255 מפני ששדה האורך הוא byte יחיד. HotXLS כותבת את הרשומה ל-chart globals לפני Units, לצד PivotChartBits ($0859, [MS-XLS] 2.4.196), כאשר גיליון תרשים מקושר ל-PivotTable view; המבט ברמת הרשומות של המנגנון הזה מכוסה בכתיבת רשומות BIFF8 של PivotTable ב-Delphi

// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// אחר כך XLUnicodeStringNoCch - fHigh(1) ואחריו התווים
PivCch := Item.FData.GetByte(6);
if (PivCch > 0) and (Item.FData.GetByte(7) <> 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch * 2)) then
begin
  Result.PivotSourceName := Item.FData.GetWideString(8, PivCch * 2);
  Result.IsPivotChart := True;
end
else if (PivCch > 0) and (Item.FData.GetByte(7) = 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch)) then
begin
  Result.PivotSourceName := WideString(Item.FData.GetString(8, PivCch));
  Result.IsPivotChart := True;
end;

שני הענפים אינם קישוט. גרסה קודמת הפעילה את שני הקידודים תחת הביטוי הרחב 8 + cch * 2, ולכן view name בן 8 ביט שנכתב על ידי Excel נכשל ב-guard וה-decoder החזיר PivotSourceName ריק עם IsPivotChart שנשאר false. קישור ה-pivot נעלם מהמודל בלי אבחון יחיד. אותה טעות ישבה במפענח Trendline ($2050, [MS-XLS] 2.4.328), שבו שדה השם מגיע אחרי 28 בתים של payload מספרי עם cch בן שני בתים ב-offset 28, fHigh ב-offset 30 והתווים ב-offset 31; כיתובי trendline ש-Excel כתבה בתווי 8 ביט פוענחו כמחרוזות ריקות. שניהם תוקנו באותה גרסה. וגם המקרה של 8 ביט אינו סקרנות legacy שמוגבלת לקובצי Excel 2.0 עד 4.0: Excel נוכחית עדיין כותבת payloads של BIFF8 ב-8 ביט בכל פעם שכל התווים נכנסים לבית אחד

איך מפענחים רשומת BIFF8 חדשה בבטחה ב-Delphi

כאשר השדה הוא באמת XLUnicodeString סטנדרטית, השתמשו ב-TXLSBlob.GetBiffString במקום לגלגל את הענף ביד. הוא קורא את שדה האורך, קורא את byte האפשרות, מנתב לקורא המתאים ומקדם את הסמן מעבר לגוף. שני הפרמטרים הבוליאניים הם החלק שצריך לקרוא בזהירות: is8bit מתאר את הרוחב של שדה האורך ולא את רוחב התווים, ו-iswide אומר אם byte option של fHigh קיים בכלל. לגרסאות BIFF שמתחת ל-$0600 אין אף אחד מהם

var
  Offset: LongWord;
begin
  Offset := 6;  // ב-SXViewLink byte ה-cch מתחיל כאן
  // is8bit = שדה האורך ברוחב byte אחד
  // iswide = byte option של fHigh מגיע אחרי שדה האורך
  Name := Data.GetBiffString(Offset, True, True);
  // Offset מצביע כעת על ה-byte הראשון שאחרי גוף המחרוזת

לענפים שנכתבו ביד עדיין יש מקום כאשר ה-handler חייב לשרוד קלט קטוע או עוין, מפני ש-GetBiffString מסתמכת על העלאת חריגה של EnsureReadable ולא על בדיקת bounds שבשליטתכם. זו הסיבה ש-decoder התרשימים של HotXLS בודק DataLength ומחזיר כלום במקום לזרוק: חוברת עבודה פגומה מצד שלישי צריכה לעלות לכם כיתוב אחד ולא את המסמך כולו. הפשרה מכוונת, והיא בדיוק הסיבה לכך שה-guard הספציפי לקידוד חייב להיות נכון, מפני שה-guard הוא הדבר שהופך קריאה רעה לשתיקה

עוד חלק אחד של התהליך, שנלמד בדרך הקשה באותה גרסה. בצעו assert על חוברת עבודה שנשמרה ונפתחה מחדש ולא על המודל בזיכרון שבניתם זה עתה. אצוות 2.376.0 חשפה גם emitter של SXEx ([MS-XLS] 2.4.282) שהצהיר על גוף בן 24 בתים וכתב רק 22, וכך הוציא מסנכרון כל רשומה אחרי תצוגת ה-PivotTable, כולל EOF של ה-worksheet וכל substream של גיליון תרשים שהגיע אחריו. בדיקות ה-pivot הקיימות מעולם לא תפסו את זה מפני שכולן טענו מול הזיכרון. לפענוח מחרוזות יש אותה תכונה: round trip דרך הקובץ הוא הבדיקה היחידה שבאמת מפעילה את חשבונות הבתים

אם אתם עובדים עם internals של XLS קלאסי ב-Delphi או C++Builder ומעדיפים לא לתחזק קורא BIFF8 משלכם, כללי הקידוד שלמעלה כבר ממומשים ונבדקו ברגרסיה ברכיב ה-spreadsheet של HotXLS ל-Delphi, שקורא וכותב XLS ו-XLSX בלי Excel ובלי OLE automation