HotXLS decode BIFF8 XLUnicodeString ด้วยการอ่าน cch และ flag fHigh ก่อน แล้วเลือก reader ให้ตรงกับ encoding คือใช้ TXLSBlob.GetWideString พร้อม byte count cch * 2 เมื่อ fHigh เป็น 1 และใช้ TXLSBlob.GetString เมื่อ fHigh เป็น 0 หากจับคู่สองอย่างกลับด้าน record จะคืน string ว่างหรือยาวเพียงครึ่งเดียว และจะไม่เกิด exception
นี่คือเหตุผลที่ bug กลุ่มนี้มีต้นทุนสูง chart เปิดได้ series วาดถูก axes ถูกต้อง แต่ caption ของ trendline ตัวหนึ่งกลับว่าง ไม่มีอะไรใน log ไม่มีอะไรใน exception handler และไม่มี corrupt-file dialog ไฟล์ถูกต้องมาตลอด reader แค่ขอจำนวน byte ผิดและได้สิ่งที่ขอไปตรง ๆ
ทำไม BIFF8 string จึงกลับมาเป็นค่าว่าง
BIFF8 string กลับมาเป็นค่าว่างเพราะ length guard ปฏิเสธ payload ก่อน read จะเกิดขึ้น หรือเพราะ reader หยุดที่ NUL ตัวแรกที่พบ ทั้งสอง path เงียบโดยโครงสร้าง ใน HotXLS guard มักเป็น explicit DataLength check ใน record handler และต้องคำนวณแยกตาม encoding payload 16-bit ต้องใช้ 8 + cch * 2 byte สำหรับ SXViewLink body แต่ payload 8-bit ต้องใช้ 8 + cch หากใช้ arithmetic แบบ wide character กับ record 8-bit ทุกชื่อสั้นจะไม่ผ่าน gate trap ที่สองคือพฤติกรรม NUL เพราะ TXLSBlob.GetString และ TXLSBlob.GetWideString ต่าง scan decoded result หา terminator แล้ว truncate ตรงนั้นและคืน string ว่างเมื่อ terminator อยู่ตำแหน่งแรก อ่าน body 16-bit ด้วย byte count ครึ่งหนึ่งจะเก็บ character แรกเพียง cch div 2 ตัว ส่วนการอ่าน body 8-bit ผ่าน wide reader จะจับ byte เป็นคู่แล้วสร้าง code point แบบสุ่ม การอ่านเกินเท่านั้นที่ดัง TXLSBlob.EnsureReadable จะ raise Blob read exceeds data size เมื่อ request เลยขนาด blob การอ่านน้อยไม่มีสัญญาณเตือนแบบนั้น
GetWideString นับ byte ไม่ใช่ character
TXLSBlob.GetWideString(Index, Count) รับ Count เป็น byte ภายในมันทำ SetString บน PWideChar ด้วย Count div SizeOf(WideChar) ดังนั้นการส่ง character count เข้าไปจะลด string ลงครึ่งหนึ่งอย่างเงียบ ๆ ขณะเดียวกัน BIFF8 record layout แสดง string length เป็น character ทุก call แบบ 16-bit จึงต้องแบกการแปลง * 2 เอง และทุก call แบบ 8-bit ต้องไม่แบกมัน นี่คือ encoding boundary เดียวกับที่เจอเวลาเขียน text ออกไป ไม่ใช่แค่อ่าน หาก pipeline ของคุณส่ง string สองทิศทางควรอ่านคู่กับ Unicode-safe spreadsheet export ใน Delphi
// 16-bit XLUnicodeStringNoCch: cch character, cch * 2 byte
Name := Data.GetWideString(Start, cch * 2); // ถูกต้อง
Name := Data.GetWideString(Start, cch); // text หายครึ่งหนึ่ง ไม่มี error
// 8-bit XLUnicodeStringNoCch: cch character, cch byte
Name := WideString(Data.GetString(Start, cch)); // ถูกต้อง
Name := Data.GetWideStringWithZero(Start, cch); // ยังคงเป็น wide reader
convention นี้ใช้ทุกจุดที่เดิน byte stream ด้วยมือ เมื่อ HotXLS ต่อ String record ขนาดยาว ($0207, [MS-XLS] 2.4.268) กลับจาก Continue record ($003C) สาขา wide จะคำนวณ segCh จาก segment length แล้วเรียก GetWideString(3, segCh * 2) เพราะ body ของ record เริ่มที่ offset 3 และ count ยังคงเป็น byte ส่วน rich-text reader ก็ทำแบบเดียวกันจาก offset 1 บน Continue segment แรก [MS-XLS] 2.5.293 รับประกันว่า break อยู่บนขอบเขตของ double-byte character เมื่อ fHighByte เป็น 1 จึงไม่ต้องทำ partial-character bookkeeping แต่ byte arithmetic ยังเป็นสิ่งที่คุณต้องทำให้ถูกอยู่ดี
GetWideStringWithZero ทำอะไรจริง
TXLSBlob.GetWideStringWithZero เป็น wide-character reader ที่รักษา embedded NUL ไว้ suffix WithZero บอกการเก็บ NUL ไม่ได้บอกความกว้างของ character ภายในมันใช้ SetString บน PWideChar ด้วย Count div SizeOf(WideChar) เหมือน GetWideString แต่ไม่ทำ terminator scan ส่วน counterpart แบบ one-byte คือ TXLSBlob.GetStringWithZero ซึ่งคืน AnsiString ชื่อ method ไม่ได้บอกว่าอันไหนเป็นอะไร และความกำกวมนี้เคยสร้าง bug จริงใน codebase จุดที่อ่านผิดควรเรียกชื่อให้ชัด เพราะมันดูสมเหตุสมผลมาก: GetWideString ต้องการ cch * 2 ดังนั้น GetWideStringWithZero ก็น่าจะรับ cch ตรง ๆ มันรับ cch โดยไม่บ่น คืน WideString และ compiler ก็พอใจ แต่ผลที่ได้มี character เพียงครึ่งเดียวและประกอบจาก byte pair ที่ผิด เส้นทาง 8-bit ที่ถูกต้องคือ TXLSBlob.GetString ด้วย plain cch byte count แล้ว cast เป็น WideString ตอน assign HotXLS 2.376.0 แก้การใช้ผิดแบบนี้ใน chart decoder สองตัวพอดี
SXViewLink และ length gate ที่แยกตาม encoding
SXViewLink ($0858, [MS-XLS] 2.4.316) เป็นตัวอย่างที่ชัดที่สุด เพราะรวม asymmetry สองด้านไว้ใน header ขนาดแปด byte เดียว layout คือ rt(2), unused(2), reserved(2), cch(1), fHigh(1) แล้วตามด้วย XLUnicodeStringNoCch body โดย fHigh = 1 หมายถึง UTF-16 ขนาด cch * 2 byte และ fHigh = 0 หมายถึง character แบบ one-byte ขนาด cch byte ส่วน cch จำกัดที่ 255 เพราะ length field มีเพียงหนึ่ง byte HotXLS เขียน record นี้ใน chart globals ก่อน Units ถ้า chart sheet link ไปยัง PivotTable view และอยู่ข้าง PivotChartBits ($0859, [MS-XLS] 2.4.196) ส่วนมุมมองระดับ record ของ machinery นี้อธิบายใน การเขียน BIFF8 PivotTable record ใน Delphi
// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// ตามด้วย XLUnicodeStringNoCch - fHigh(1) แล้วจึงเป็น character
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;
สอง branch นี้ไม่ใช่แค่ความสวยงาม version ก่อนหน้าใช้ expression แบบ wide-character 8 + cch * 2 เป็น gate ให้ทั้งสอง encoding ดังนั้น view name แบบ 8-bit ที่ Excel เขียนจะไม่ผ่าน guard และ decoder จะคืน PivotSourceName ว่างพร้อม IsPivotChart เป็น false pivot link จึงหายจาก model โดยไม่มี diagnostic แม้แต่ตัวเดียว ความผิดเดียวกันอยู่ใน Trendline decoder ($2050, [MS-XLS] 2.4.328) ซึ่ง name field ตามหลัง numeric payload 28 byte โดยมี cch สอง byte ที่ offset 28, fHigh ที่ offset 30 และ character ที่ offset 31 ทำให้ trendline caption ที่ Excel เขียนด้วย 8-bit character decode เป็น string ว่าง ทั้งสองจุดถูกแก้ใน release เดียวกัน และกรณี 8-bit ไม่ใช่เรื่องเก่าที่จำกัดอยู่ใน ไฟล์ Excel 2.0 ถึง 4.0 Excel รุ่นปัจจุบันยังเขียน payload BIFF8 แบบ 8-bit เมื่อ character ทุกตัวใส่ใน byte เดียวได้
ถอดรหัส BIFF8 record ใหม่อย่างปลอดภัยใน Delphi อย่างไร
หาก field เป็น standard XLUnicodeString จริง ให้ใช้ TXLSBlob.GetBiffString แทนการเขียน branch เอง มันจะอ่าน length field, option byte, dispatch ไปยัง reader ที่ตรงกัน และเลื่อน cursor เลย body สอง Boolean parameter คือส่วนที่ต้องอ่านอย่างระวัง is8bit บอกความกว้างของ length field ไม่ใช่ความกว้างของ character และ iswide บอกว่ามี option byte fHigh หรือไม่ BIFF version ที่ต่ำกว่า $0600 ไม่มีทั้งสองอย่าง
var
Offset: LongWord;
begin
Offset := 6; // ใน SXViewLink cch byte เริ่มที่นี่
// is8bit = length field กว้างหนึ่ง byte
// iswide = มี fHigh option byte ตามหลัง length field
Name := Data.GetBiffString(Offset, True, True);
// ตอนนี้ Offset ชี้ไปยัง byte แรกหลัง string body
branch ที่เขียนเองยังมีที่ยืนเมื่อ handler ต้องอยู่รอดจาก input ที่ถูกตัดหรือ hostile เพราะ GetBiffString พึ่ง EnsureReadable ให้ raise แทน bounds check ที่คุณควบคุมได้ นี่เป็นเหตุผลที่ HotXLS chart decoder ตรวจ DataLength แล้วคืน nothing แทนการ throw workbook จาก third party ที่ malformed ควรเสีย caption เดียว ไม่ใช่ document ทั้งชุด tradeoff นี้ตั้งใจ และเป็นเหตุผลตรง ๆ ว่า encoding-specific guard ต้องถูกต้อง เพราะ guard คือสิ่งที่เปลี่ยน bad read ให้กลายเป็นความเงียบ
ขั้นตอนสุดท้ายอีกอย่างที่เรียนรู้จาก release เดียวกันคือ assert กับ workbook ที่ save และเปิดใหม่ ไม่ใช่ in-memory model ที่คุณเพิ่งสร้าง batch 2.376.0 ยังพบ SXEx emitter ([MS-XLS] 2.4.282) ที่ประกาศ body 24 byte แต่เขียนเพียง 22 ทำให้ record ทุกตัวหลัง PivotTable view misalign รวมถึง worksheet EOF และ chart sheet substream ที่ตามมา pivot test เดิมไม่เคยจับได้เพราะ assert กับ memory ทั้งหมด การ decode string ก็มีคุณสมบัติเดียวกัน round trip ผ่านไฟล์เท่านั้นที่ทดสอบ byte count จริง
หากคุณทำงานกับ classic XLS internals ใน Delphi หรือ C++Builder และไม่อยากดูแล BIFF8 record reader ของตัวเอง encoding rule ข้างต้นถูก implement และ regression-test แล้วใน HotXLS Delphi spreadsheet component ซึ่งอ่านและเขียน XLS กับ XLSX ได้โดยไม่ต้องใช้ Excel หรือ OLE automation