HotXLS 给每个 BIFF8 字体引用编号的方式完全遵循 [MS-XLS] §2.5.129 FontIndex 的定义:0 到 3 从零起算,4 以上从一起算,4 永不出现,所以第五条 FONT 记录是 ifnt 5,而最大合法 ifnt 恰等于 FONT 记录的总数。从 HotXLS 2.384.4 起,XF 写入器、XF 读取器、富文本字符串 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 的 FontIndex 是从一起算的记录位置,值 4 不得使用(MUST NOT)。同一个 FontIndex 类型被 XF 记录、SST 格式 run 和 TXO 格式 run 共用,所以一条读错的规则会同时弄坏三处。用 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 从一起算,4 非法
function FontIndexToRecordNo(Ifnt: Word): Integer; // 从一起算的 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 到 4 的位置做减一,所以位置 5 写出去就是 ifnt 5。TXLSReader.ParseXF 在加载时做逆变换:任何 5 以上的 ifnt 减一变成从零起算的字体列表槽位,低于 5 的原样保留。SST 富 run 重映射和 CountRichRunFontRefs 用的是同一套 ifnt >= 5 转换,这正是要点:一条约定,所有消费方
// TXLSFontList.GetSaveIndex(写入侧)
Result := inherited GetSaveIndex(Index); // 引用位置从一起算
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 run 重映射,以及 Sheets.AddCopy 里的跨工作簿 run 迁移。HotXLS 自己的往返看着一切正常,因为写入器和读取器互相一致。Excel 不一致。2.384.1 写出的文件把第一个自定义字体放在 ifnt 4——Excel 把它当作默认字体——而其后每个自定义字体都提前一条记录;打开 Excel 生成的文件则反过来,每个字体都绑晚一条记录
本该拦住这次改动的线索就躺在同一个代码库里。CountRichRunFontRefs、图表 FONTX 和 FBI 重映射、以及样式引擎的字体列表从未被动过,仍在用跳过 4 的编号,所以 2.384.1 一落地库就自相矛盾了;只是富文本字体通常恰好也被某个 XF 引用,这个矛盾才一直藏着。当一条约定出现在七处而你要改四处时,先怀疑自己的改动,再怀疑另外三处。v2.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 起,经典引擎的每条复制路径都保留批注格式 run,因为 Range.Copy、CopyRange、Sheets.AddCopy,以及 Range.Insert 和 Range.Delete 背后的单元格位移,全都经过 TXLSRange.CopyCell,而 CopyCell 过去只复制批注文本和作者。位移等于一次复制加一次清除,所以在一条两 run 的批注上方插入一行,会让它剩零个 run、一个孤儿字体。修复版逐个复制 run,并通过 TXLSWorkbook.MigrateRunFontIndex 迁移其字体:把跳 4 索引转成槽位,按值把字体迁入目标字体表,再转回文件编号;Sheets.AddCopy 里的 SST 富文本迁移现在也调用同一个函数,不再自带一份算术副本。顺带修掉了两个边界情况:源与目标是同一条批注的原地粘贴,必须在读取之前不清空自己的 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 电子表格组件,它读写 XLS 与 XLSX 都不依赖 Excel 或 OLE automation