HotXLS 替每個 BIFF8 字型參照編號的方式,完全照 [MS-XLS] §2.5.129 FontIndex 的定義:0 到 3 從零起算,4 以上從 1 起算,而 4 永遠不會出現,所以第五條 FONT 記錄是 ifnt 5,最大的合法 ifnt 等於 FONT 記錄的總數。從 HotXLS 2.384.4 起,XF 寫入器、XF 讀取器、rich text 字串 run 與跨活頁簿 run 遷移都遵循這條規則,2.384.5 與 2.384.6 再把它延伸到註解與文字方塊 run,連複製和插入列的路徑也涵蓋在內
這條規則乍看像筆誤,直到您親自撞上它。有人打開一個有八條 FONT 記錄的活頁簿,發現某個 XF 指向字型 8,就斷定寫入器產生了超範圍的索引。一模一樣的推理在 HotXLS 2.384.1 裡以「修正」之名出貨,把一個正確的實作改成了 Excel 開啟的檔案裡每個自訂字型都提前一格。有意思的不是這個 off-by-one 本身,而是同一套慣例在 BIFF8 函式庫裡到底散落多少處,以及一個字型繫結怎麼能撐過第一次存檔、卻在第二次斷掉。如果您已經跟解碼 BIFF8 XLUnicodeString 的 cch 與 fHigh 裡那些長度與編碼怪癖交過手,這是同一族的 bug:檔案沒問題,出問題的是算術
[MS-XLS] 的 FontIndex 規則到底說了什麼?
[MS-XLS] §2.5.129 說:低於 4 的 FontIndex 是從零起算的記錄位置,高於 4 的是從 1 起算的記錄位置,而值 4 依規範不得使用。XF 記錄、SST 格式化 run 與 TXO 格式化 run 用的是同一個 FontIndex 型別,所以誤讀一條規則,三處一起壞。用 Excel 產生的檔案很容易重現證據:Office 附的 SOLVSAMP.XLS 有 19 條 FONT 記錄,XF 的最大 ifnt 正好是 19;一個 43 條記錄的活頁簿最大值就是 43;Excel 16 存的、帶 30 條 FONT 記錄的檔案,把 Courier New 儲存格指向 ifnt 22,也就是第 22 條記錄。沒有任何一個檔案出現過 4。若您要在診斷工具裡自行分析索引對應,轉換就是兩個短函式
// [MS-XLS] 2.5.129 FontIndex:0..3 從零起算,> 4 從 1 起算,4 不合法
function FontIndexToRecordNo(Ifnt: Word): Integer; // 1 起算的 FONT 記錄
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 起算的位置,只對 1 到 4 的位置減一,所以位置 5 寫出來就是 ifnt 5。TXLSReader.ParseXF 在載入時做反向操作:任何 5 以上的 ifnt 減一,換成從零起算的字型清單槽位,以下的則原地不動。SST rich run 重對應與 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 那次從零起算的改寫之所以移動了每個自訂字型,是因為它把從 1 起算的索引讀成從零起算,然後改了四個呼叫點來配合這個誤讀:GetSaveIndex、ParseXF、SST run 重對應,以及 Sheets.AddCopy 裡的跨活頁簿 run 遷移。HotXLS 自己來回存取看起來沒事,因為寫入端和讀取端彼此同意。Excel 不同意。2.384.1 寫出的檔案把第一個自訂字型放在 ifnt 4,Excel 把它當成預設字型,之後每個自訂字型都提前一條記錄;開啟 Excel 檔案則反過來,每個字型都晚一條記錄才綁上
本該攔下這次改動的線索,就躺在同一份程式碼庫裡。CountRichRunFontRefs、圖表的 FONTX 與 FBI 重對應、樣式引擎的字型清單,這些從頭到尾沒被碰過、照舊用跳 4 規則,所以 2.384.1 落地的瞬間函式庫就自相矛盾了,而矛盾之所以沒被發現,只是因為 rich text 字型通常也會被某個 XF 參照到。一套慣例出現在七個地方而您正在改其中四個,先懷疑自己的改動,再去懷疑另外三個。2.384.4 版在全部四處恢復了規格編號,而舊的迴歸測試——它斷言 ifnt < FontCount,等於把誤讀寫進了測試——換成了透過規格公式把每個寫出的 ifnt 對應回 FONT 記錄名稱的測試。一個誠實的限制仍然在:2.384.1 到 2.384.3 存的、帶五個以上字型的檔案,索引是錯位的,讀取器無法把它與合法資料區分開,唯一的解法是重新產生這些檔案
為什麼註解字型 run 只在第二次存檔時壞掉?
註解與文字方塊 run 在第二次存檔時壞掉,是因為 HotXLS 無條件保留前 N-1 條 FONT 記錄,只在最後一條沒有任何 XF 參照時丟棄它,而 TXO 格式化 run([MS-XLS] §2.4.329)是逐位元組原樣寫回、不重新編號。Excel 產生的 .xls 檔案結尾總有一條沒人參照的尾隨字型(中文語系系統上是 9pt 的 DengXian),所以第一次存檔時,只被註解 run 用到的字型永遠不會是最後一條,表面上什麼都沒動。但第一次存檔丟掉了那條尾隨字型,把只有註解在用的字型推到最後一位。第二次存檔就把它當成無人參照而丟棄,run 的 ifnt 指到了終點之外,Excel 退回預設字型;如果活頁簿在中間多了一個新字型,run 會悄悄綁到那個字型上——測試裡就有一個帶樣式的文字方塊 run 因此變成了 Arial。像建立註解與超連結審閱流程描述的那種註解密集的檔案,正是被咬到的地方,因為它們會被反覆開啟、加註、存檔
HotXLS 2.384.5 把 TXO run 當成 SST run 對待。CountRichRunFontRefs 現在會走訪每個工作表上的每個 TMSOShapeTextBox,把每個 run 的跳 4 ifnt 轉成槽位、記為一筆參照,所以只被 run 用到的字型能在存檔過濾中存活。得到的槽位對存檔索引表放進每個繪圖物件的 FontRunRemap,TMSOShapeTextBox.Store 在原始 run 位元組的私有副本上改寫 run 索引,不碰尾隨的 TxOLastRun,因為它不帶字型。對應用程式碼來說,合約很簡單:TXLSComment.TextRuns.FontIndex 與 TXLSTextBox.TextRuns.FontIndex 用檔案編號、跳過 4,讀到什麼就是什麼;run 索引從 1 起算,CharIndex 是 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;
複製、插入列與跨活頁簿 run 遷移
從 HotXLS 2.384.6 起,classic 引擎的每條複製路徑都保留註解格式化 run,因為 Range.Copy、CopyRange、Sheets.AddCopy,以及 Range.Insert 與 Range.Delete 背後的儲存格平移,全都經過 TXLSRange.CopyCell,而 CopyCell 過去只複製註解文字和作者。平移等於複製加清空,所以在有兩個 run 的註解上方插入一列,它就剩零個 run、一個字型。修正後會逐一複製 run,並透過 TXLSWorkbook.MigrateRunFontIndex 搬移其字型:把跳 4 索引轉成槽位、按值把字型遷入目標字型表、再轉回檔案編號;Sheets.AddCopy 裡的 SST rich text 遷移現在也呼叫同一個函式,不再自帶一份算術。順手修了兩個邊界情況:來源與目的地是同一個註解的原地貼上,不得在讀取前先清掉自己的 run;Sheets.AddCopy 現在也會為掛在沒有儲存格記錄的儲存格上的註解跑第二輪,過去這類註解整個被跳過。跨活頁簿複製的字型表一側,遵循與公式側相同的按值邏輯,即跨活頁簿複製與公式重新繫結所涵蓋的內容。XLSX 引擎的複製路徑本來就按值複製 run;缺口在註解部分本身——讀取器無視 rFont、strike、u 與 vertAlign,寫入器從不輸出 u 與 vertAlign,現在 run 能對稱地撐過存檔與重開
該怎麼測試 BIFF8 檔案裡的字型索引?
測字型索引要靠存檔再重開,最好跨不止一個世代,並把每個 ifnt 對應回 FONT 記錄,而不是斷言數值範圍。這個故事裡的每個 bug 都通過了記憶體內測試:2.384.1 的迴歸活在一對互相匹配的寫入端與讀取端裡,TXO 漂移需要兩次存檔、中間夾一次字型表變動,XLSX 上丟失的註解 run 則要重開之後才現形。好用的測試環境是:開一個 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;
如果您用 Delphi 或 C++Builder 讀寫傳統 XLS,又不想追蹤函式庫裡眾多字型消費者誰還認得 [MS-XLS] §2.5.129,這裡描述的跳 4 編號、存檔時的 run 重新編號與按值 run 遷移,都已內建在HotXLS Delphi 試算表元件裡,它不需 Excel 或 OLE automation 就能讀寫 XLS 與 XLSX