HotXLS นับการอ้างอิงฟอนต์แบบ BIFF8 ทุกจุดตามนิยาม FontIndex ของ [MS-XLS] §2.5.129: ค่า 0 ถึง 3 เป็น zero-based, ค่าที่มากกว่า 4 เป็น one-based และค่า 4 ไม่มีวันโผล่ ดังนั้น FONT record ตัวที่ห้าคือ ifnt 5 และ ifnt ที่ใหญ่ที่สุดเท่ากับจำนวน FONT record พอดี ตั้งแต่ HotXLS 2.384.4 ทั้งตัวเขียน XF, ตัวอ่าน XF, rich text string run และการย้าย run ข้าม workbook เดินตามกฎนี้หมด และ 2.384.5 กับ 2.384.6 ขยายไปครอบ run ของ comment กับ text box ด้วย รวมถึงตอน copy และแทรกแถว
กฎนี้หน้าตาเหมือนพิมพ์ผิด จนกว่าคุณจะชนเข้าจริง ใครสักคนเปิด workbook ที่มี FONT record แปดตัว เห็น XF ชี้ไปที่ฟอนต์ 8 แล้วสรุปว่าตัวเขียนผลิต index เกินขอบเขตออกมา เหตุผลเป๊ะ ๆ แบบนี้เคยขึ้นรถไปกับ HotXLS 2.384.1 ในฐานะ "fix" และเปลี่ยน implementation ที่ถูกต้องให้กลายเป็นตัวที่ฟอนต์ custom ทุกตัวในไฟล์ที่ Excel เปิดไปลงสล็อตเร็วไปหนึ่งช่อง ส่วนที่น่าสนใจไม่ใช่ off-by-one ตัวมันเอง แต่คือจำนวนจุดใน BIFF8 library ที่แบก convention เดียวกันนี้ และว่าการผูกฟอนต์จะรอดการบันทึกหนึ่งรอบแล้วพังรอบที่สองได้อย่างไร ถ้าคุณเคยประจันกับเรื่องความยาวและ encoding แปลก ๆ ที่เล่าไว้ในการ decode cch กับ fHigh ของ BIFF8 XLUnicodeString มาก่อน อันนี้เป็น bug ตระกูลเดียวกัน: ไฟล์ปกติ คณิตต่างหากพัง
กฎ FontIndex ของ [MS-XLS] พูดจริง ๆ ว่าอะไร
[MS-XLS] §2.5.129 บอกว่า FontIndex ที่ต่ำกว่า 4 คือตำแหน่ง record แบบ zero-based, FontIndex ที่สูงกว่า 4 คือตำแหน่ง record แบบ one-based และค่า 4 ห้ามใช้เด็ดขาด FontIndex type เดียวกันนี้ถูกใช้ทั้งโดย record XF, SST formatting run และ TXO formatting run กฎที่อ่านผิดหนึ่งครั้งจึงทำลายทั้งสามพร้อมกัน หลักฐานทำซ้ำได้ง่าย ๆ จากไฟล์ที่ Excel เขียน: SOLVSAMP.XLS ที่แถมมากับ Office มี FONT record 19 ตัวและ XF ifnt สูงสุดเท่ากับ 19, workbook ที่มี 43 record จบที่ 43 และไฟล์ที่ Excel 16 บันทึกไว้โดยมี FONT record 30 ตัวชี้ cell Courier New ไปที่ ifnt 22 ซึ่งคือ record ตัวที่ 22 ไม่มีตัวไหนเลยที่เคยมีเลข 4 อยู่ข้างใน ถ้าต้องวิเคราะห์การ map index เองใน diagnostic tool การแปลงใช้ฟังก์ชันสั้น ๆ สองตัว
// [MS-XLS] 2.5.129 FontIndex: 0..3 เป็น zero-based, > 4 เป็น one-based, 4 ใช้ไม่ได้
function FontIndexToRecordNo(Ifnt: Word): Integer; // FONT record แบบ 1-based
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;
ภายใน HotXLS กฎเดียวกันนี้อยู่สองจุดที่สมมาตรกัน TXLSFontList.GetSaveIndex รับตำแหน่งแบบ 1-based ของฟอนต์ในรายการที่ถูกอ้างอิง แล้วลดค่าเฉพาะตำแหน่ง 1 ถึง 4 ตำแหน่ง 5 จึงถูกเขียนเป็น ifnt 5 TXLSReader.ParseXF ทำทางกลับตอนโหลด: ifnt ตั้งแต่ 5 ขึ้นไปจะถูกลดลงไปเป็นสล็อต font list แบบ zero-based ส่วนค่าที่ต่ำกว่านั้นอยู่เฉย ๆ การ remap rich-run ของ SST และ CountRichRunFontRefs ใช้การแปลง ifnt >= 5 เดียวกัน ซึ่งนั่นแหละคือประเด็น: convention เดียว ครอบทุก consumer
// TXLSFontList.GetSaveIndex (ฝั่ง writer)
Result := inherited GetSaveIndex(Index); // ตำแหน่งที่ถูกอ้างอิงแบบ 1-based
if (Result > 0) and (Result < 5) then
Dec(Result); // 1..4 กลายเป็น 0..3, 5+ คงเดิม
// TXLSReader.ParseXF (ฝั่ง reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
Dec(fnti); // ifnt 5 คือสล็อต 4 ของ font list
ทำไม "fix" แบบ zero-based ถึงเลื่อนฟอนต์ custom ทุกตัวไปหนึ่งช่อง
การเขียนใหม่แบบ zero-based ใน HotXLS 2.384.1 เลื่อนฟอนต์ custom ทุกตัว เพราะมันอ่าน index แบบ one-based มาเป็น zero-based แล้วแก้ call site สี่จุดให้ตรงกับการอ่านผิดนั้น: GetSaveIndex, ParseXF การ remap run ของ SST และการย้าย run ข้าม workbook ใน Sheets.AddCopy round trip ภายใน HotXLS ยังดูปกติ เพราะ writer กับ reader เห็นพ้องกันเอง Excel ไม่เห็นพ้อง ไฟล์ที่ 2.384.1 เขียนเอาฟอนต์ custom ตัวแรกไปวางที่ ifnt 4 ซึ่ง Excel ถือว่าเป็น default font และฟอนต์ custom ตัวถัด ๆ มาเร็วไปหนึ่ง record ส่วนการเปิดไฟล์ Excel กลับไปทางตรงข้าม ผูกฟอนต์แต่ละตัวช้าไปหนึ่ง record
ร่องรอยที่ควรหยุดการแก้นั้นเสียก่อนนั่งอยู่ใน codebase เดียวกัน CountRichRunFontRefs การ remap FONTX กับ FBI ของ chart และ font list ของ style engine ไม่เคยถูกแตะและยังใช้แบบ skip-4 library จึงขัดแย้งกับตัวเองทันทีที่ 2.384.1 ลงมา และมีแค่ความบังเอิญที่ฟอนต์ของ rich text มักถูก XF สักตัวอ้างอิงด้วยจึงซ่อนความขัดแย้งไว้ได้ เมื่อ convention เดียวปรากฏในเจ็ดจุดแล้วคุณกำลังแก้สี่จุด ให้สงสัยการแก้ของคุณก่อนจะสงสัยอีกสามจุดที่เหลือ รุ่น 2.384.4 กู้ลำดับเลขตามสเปกคืนทั้งสี่จุด และ regression test ตัวเก่าที่ assert ifnt < FontCount จึงฝังการอ่านผิดไว้ในตัว ก็ถูกแทนด้วย test ที่แปลงทุก ifnt ที่เขียนออกไปกลับเป็นชื่อ FONT record ผ่านสูตรในสเปก ข้อจำกัดตรงไปตรงมาที่ยังเหลืออยู่คือ: ไฟล์ที่บันทึกโดย 2.384.1 ถึง 2.384.3 ที่มีฟอนต์ห้าตัวขึ้นไปจะแบก index ที่เลื่อนไปแล้วซึ่ง reader แยกออกจากข้อมูลที่ถูกต้องไม่ได้ ทางเดียวที่รักษาคือ regenerate ใหม่
ทำไม font run ของ comment ถึงพังตอนบันทึกครั้งที่สองเท่านั้น
run ของ comment กับ text box พังตอนบันทึกครั้งที่สอง เพราะ HotXLS เก็บ FONT record N-1 ตัวแรกไว้โดยไม่มีเงื่อนไข และทิ้งแค่ตัวสุดท้ายเมื่อไม่มี XF อ้างอิง ขณะที่ TXO formatting run ([MS-XLS] §2.4.329) ถูกเขียนกลับแบบไบต์ต่อไบต์โดยไม่เลขใหม่ ไฟล์ .xls ที่ Excel เขียนมักจบด้วยฟอนต์ trailing ที่ไม่มีใครอ้างอิง (DengXian 9pt บนระบบ locale จีน) ตอนบันทึกแรกฟอนต์ที่มีแค่ comment run ใช้จึงไม่เคยเป็นตัวสุดท้าย และไม่มีอะไรขยับให้เห็น แต่การบันทึกแรกนั่นแหละที่ทิ้งฟอนต์ trailing แล้วเลื่อนฟอนต์ที่มีแค่ comment ใช้ขึ้นไปเป็นตัวสุดท้าย การบันทึกครั้งที่สองจึงทิ้งมันในฐานะตัวไม่ถูกอ้างอิง ifnt ของ run ชี้ทะลุปลายทาง และ Excel ตกกลับไปที่ default font ถ้าระหว่างนั้น workbook ได้ฟอนต์ใหม่เพิ่มมา run จะเงียบ ๆ ผูกกับตัวใหม่นั้นแทน ซึ่งในการทดสอบเปลี่ยน text box run ที่มีสไตล์ให้กลายเป็น Arial ไฟล์ที่หนักไปทาง comment อย่างที่เล่าในการสร้าง workflow ตรวจ comment กับ hyperlink คือจุดที่ปัญหานี้กัดพอดี เพราะไฟล์แบบนั้นถูกเปิด ใส่หมายเหตุ และบันทึกวนไปมา
HotXLS 2.384.5 ปฏิบัติ TXO run เหมือน SST run CountRichRunFontRefs ตอนนี้เดินไล่ทุก TMSOShapeTextBox บนทุก worksheet แปลง ifnt แบบ skip-4 ของ run แต่ละตัวเป็นสล็อต แล้วนับเป็นการอ้างอิงหนึ่ง ฟอนต์ที่มีแค่ run ใช้จึงรอดผ่าน filter ตอนบันทึก ตาราง slot-to-save-index ที่ได้จะเข้าไปอยู่ใน FontRunRemap ของ drawing แต่ละตัว และ TMSOShapeTextBox.Store เขียน index ของ run ใหม่บนสำเนาส่วนตัวของไบต์ run ดิบ โดยปล่อย TxOLastRun ท้าย ๆ ตามที่เป็น เพราะมันไม่พกฟอนต์ สำหรับโค้ดแอปพลิเคชันข้อสัญญาง่าย ๆ: TXLSComment.TextRuns.FontIndex กับ TXLSTextBox.TextRuns.FontIndex ใช้ลำดับเลขของไฟล์ ข้าม 4 เป๊ะตามที่อ่านได้ index ของ run เป็นแบบ 1-based และ CharIndex คือ offset ตัวอักษรที่ run เริ่มต้น หลังบันทึกเลขที่เก็บอาจไม่เท่ากับที่คุณตั้งไว้ แต่มันยังชี้ไปที่ฟอนต์ตัวเดิม
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;
การ copy, การแทรกแถว และการย้าย run ข้าม workbook
ตั้งแต่ HotXLS 2.384.6 เส้นทาง copy ของ classic engine ทุกเส้นเก็บ formatting run ของ comment ไว้ เพราะ Range.Copy, CopyRange, Sheets.AddCopy และการเลื่อน cell ที่ซ่อนหลัง Range.Insert กับ Range.Delete ล้วนผ่าน TXLSRange.CopyCell ซึ่ง CopyCell เคย copy แค่ข้อความกับผู้เขียนของ comment การเลื่อนคือ copy บวก clear แทรกแถวเดียวเหนือ note ที่มีสอง run จึงเหลือศูนย์ run กับฟอนต์ค้างเติ่งหนึ่งตัว การแก้ copy run แต่ละตัวแล้วย้ายฟอนต์ผ่าน TXLSWorkbook.MigrateRunFontIndex ซึ่งแปลง index แบบ skip-4 เป็นสล็อต ย้ายฟอนต์แบบ by value เข้าตารางฟอนต์ปลายทาง แล้วแปลงกลับเป็นลำดับเลขของไฟล์ ส่วนการย้าย rich text ของ SST ใน Sheets.AddCopy ตอนนี้เรียกฟังก์ชันเดียวกันแทนที่จะแบกสำเนาคณิตของตัวเอง มี edge case มาด้วยสองราย: paste ทับที่ต้นทางกับปลายทางเป็น comment เดียวกันต้องไม่เคลียร์ run ก่อนอ่าน และ Sheets.AddCopy ตอนนี้วนรอบที่สองเพื่อ comment ที่แนบกับ cell ที่ไม่มี cell record เก็บไว้ ซึ่งเดิมข้ามทิ้งทั้งหมด ฝั่งตารางฟอนต์ของการ copy ข้าม workbook เดินตรรกะ by value เดียวกับฝั่งสูตรที่เล่าไว้ในการ copy ข้าม workbook กับการผูกสูตรใหม่ บน engine XLSX เส้นทาง copy โคลน run แบบ by value มาก่อนแล้ว รูรั่วอยู่ที่ตัวส่วน comment เอง ตัวอ่านเมิน rFont, strike, u กับ vertAlign และตัวเขียนไม่เคย emit u กับ vertAlign เลย ตอนนี้ run จึงรอดทั้งการบันทึกและการเปิดใหม่อย่างสมมาตร
ควรทดสอบ index ฟอนต์ในไฟล์ BIFF8 อย่างไร
ทดสอบ index ฟอนต์ด้วยการบันทึกแล้วเปิดใหม่ อย่างดีคือข้ามมากกว่าหนึ่ง generation และด้วยการแปลง ifnt แต่ละตัวกลับเป็น FONT record แทนการ assert ช่วงตัวเลข ทุก bug ในเรื่องนี้ผ่าน test ในหน่วยความจำมาแล้วทั้งนั้น: regression ของ 2.384.1 อาศัยอยู่ในคู่ writer กับ reader ที่ตรงกันเอง การเลื่อนของ TXO ต้องการสองรอบบันทึกที่มีการเปลี่ยนตารางฟอนต์คั่นกลาง และ run ของ comment ที่หายบน XLSX โผล่ต่อเมื่อเปิดใหม่ harness ที่ใช้งานได้จริงเปิดตัวอย่างที่ Excel เขียน บันทึกผ่าน HotXLS สองรอบ แทรกหรือลบฟอนต์ระหว่างรอบ แล้วเช็กตำแหน่ง run บวกกับที่ระดับไบต์ ชื่อฟอนต์ที่ซ่อนหลัง 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 มีสอง run
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 และไม่อยากไล่ตามว่า consumer ฟอนต์ตัวไหนใน library ที่ยังเห็นพ้องกับ [MS-XLS] §2.5.129 อยู่ ลำดับเลขแบบ skip-4 การเลข run ใหม่ตอนบันทึก และการย้าย run แบบ by value ที่เล่าไว้นี้ build มาในตัวของHotXLS Delphi spreadsheet component ที่อ่านเขียน XLS กับ XLSX ได้โดยไม่ต้องมี Excel หรือ OLE automation