打开一个旧 xls,再存一次,原本调用已注册分析库的加载项公式如今指向工作簿内部一个空引用。HotXLS 把这场静默腐化追溯到一个坏假设:BIFF SupBook 记录要么是 self,要么是外部文件。[MS-XLS] 定义的是七种,不是两种
为什么保存后的工作簿丢了加载项链接?
因为分类测试是结构性的而不是类型化的。传统捷径读一条 SupBook 记录($01AE),检查它是否带 self 标记,不带就把后面的字符串当文档 URL。凡不是这两样的记录都跌进默认分支,而默认分支几乎总是“这是工作簿本身”。加载项支持链接、同表链接、未用槽位、截断记录,全被贴上同一张错误标签。这期间没有任何东西抛异常:记录解析了,公式重编译了,文件毫无警告地保存了,缺陷三周后浮出水面,有人发现一列零站在汇率换算原来的位置上。[MS-XLS] §2.4.271 描述的记录可以是自引用、同表引用、加载项函数容器、带虚拟路径和工作表名表的外部工作簿、DDE 或 OLE 数据链接、未用占位符,还有第七种规范没写但真实磁盘上存在的状态:解析不了的记录。修法不是更好的启发式,而是根本不要有启发式
SupBook 记录能携带的七种形态
HotXLS 在 lxExternSheet.pas 中把支持链接分类法声明为封闭枚举,下游每个决策都按它分派。九个枚举值覆盖七类,因为 DDE 和 OLE 情形需要一个待定状态才能解析:
type
TXLSSupportingLinkKind = (
slkUnknown, // 解析失败,或尾部有剩余字节
slkSelf, // 本工作簿
slkSameSheet, // U+0000 标记
slkAddIn, // 加载项函数容器
slkExternalWorkbook, // 虚拟路径 + 工作表名表
slkDde, // 由 ExternName 标志解析
slkOle, // 由 ExternName 标志解析
slkDdeOrOle, // 二者之一,暂不知哪个
slkUnused); // 单空格占位符
TXLSFormulaReferenceClass = (
frcInternal,
frcExternalWorkbook,
frcExternalOther,
frcUnknownOrMalformed);
TXLSXtiInfo = record
XtiIndex : Integer; // 从零起,与 ExternSheet.rgXTI 中存储一致
ExternID : Integer; // 从一起,内部约定
SupBookIndex: Integer;
Sheet1Index : Integer;
Sheet2Index : Integer;
LinkKind : TXLSSupportingLinkKind;
end;
分发由哨兵驱动,不是字符串驱动。字段值 $0401 标记 self 记录。工作表数为一配 $3A01 标记加载项容器。只有 1 到 $00FF 范围的值才意味着后面跟编码虚拟路径,也只有这时 HotXLS 才解码字符串。不属于这三种形状的一切保持 slkUnknown;工作表名表没有恰好消费完记录体的记录,即便头看着像样,也被降回 slkUnknown
为什么同表标记解码出来是空字符串?
因为通用 BIFF 字符串读取器毁掉了分类所依赖的那个字节。同表支持链接是单字符字符串,其唯一字符是 U+0000,而 TXLSBlob.GetBiffString 把它交回为空的 WideString,与真空路径无法区分,这正是自引用启发式会回答“self”的输入。所以 HotXLS 从记录体里读原始第一个码位,而不是信任解码值:
StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
StringOptions := Data.GetByte(StringOffset + 2);
if (StringOptions and $01) = 0 then
FirstChar := Data.GetByte(StringOffset + 3) // 压缩格式,一字节
else
FirstChar := Data.GetWord(StringOffset + 3); // 宽字符,两字节
end;
if FirstChar = 0 then
FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
FKind := slkDdeOrOle
else if FDocUrl <> '' then
FKind := slkExternalWorkbook;
注意压缩与宽字符的分支。选项字节坐在距字符串头的固定偏移处,第一个码位按位 0 是一字节或两字节,于是一律按字节读在多数文件上好使,在本地化构建写出的文件上失败,对一个缺陷来说最坏不过的分布。未用占位符用同样方式抓:按它字面单空格载荷;DDE 或 OLE 情形按编码路径里内嵌的 U+0003 分隔符抓
为什么 DDE 和 OLE 在 SupBook 时分不开?
因为 SupBook 记录不携带区分位。它告诉你链接是二者之一;决定是哪个的 fOle 和 fOleLink 标志住在流中稍后到达的 ExternName 记录($0023)里。HotXLS 解析时记 slkDdeOrOle,在 ParseExternalName 里收窄;如果 ExternName 永远不到达,这个种类就永远保持待定,这是对的,因为文件确实没说。下游每个消费者都把待定值当真实值而不是缺失值,于是没有调用方需要发明平局裁决。在这里猜“大概是 DDE”能买来一个更整齐的枚举和一类无人能回溯的错误答案:
if FKind = slkDdeOrOle then
begin
if Data.DataLength < 2 then
Exit;
Flags := Data.GetWord(0);
if (Flags and $0010) <> 0 then
FKind := slkOle
else if (Flags and $0008) <> 0 then
FKind := slkDde;
end;
XTI 索引盘上从零起,内部从一起
HotXLS 恰好做一次差一换算,位置在记号进入内部语法树的那一刻,别无他处。PtgNameX.ixti([MS-XLS] §2.5.198.85)是 ExternSheet 记录($0017,§2.4.106)rgXTI 数组的零基索引,而库内部 ExternID 约定是一基,零保留给“无外部表”。BIFF8 读路径解码 tNameX 记号时做 FExternID := wValue + 1,写路径发 StoreExternID - 1,原始记号视图和盘上语义原样不动。弄错这一点异常难抓:外部定义名称解析到相邻条目,而在只有单个 XTI 条目的文件里索引 0 变成索引 1,脱靶,名称静默降级。只操练重编译公式文本的回归测试永远看不见它,因为重编译根本不碰盘上索引,这正是跨工作表与工作簿的定义名称值得对真实字节流测试的同一个陷阱。解析两端都有界:TlxExternSheetSheet.TryResolveXti 对负索引或缺失条目返回 False,TXLSSupBook.TryGetKind 对数组外 SupBook 索引返回 False,ClassifyXti 随后把 slkSelf 和 slkSameSheet 映射到 frcInternal,slkExternalWorkbook 到 frcExternalWorkbook,slkAddIn、slkDde、slkOle 和 slkDdeOrOle 到 frcExternalOther。其余一切,包括每条越界路径,都落到 frcUnknownOrMalformed
冻结之前先分类公式
TXLSCompiledFormula.ClassifyReferences 直接扫描保留的 BIFF 记号流,而不是反编译公式再找方括号。在公式文本里找括号是穿着解析器外衣的文本启发式:它匹配字符串字面量,匹配结构化引用,还完全漏掉外部定义名称,因为那些反编译形态里根本没有括号。记号扫描只看 PtgNameX、PtgRef3d、PtgArea3d、PtgRefErr3d 和 PtgAreaErr3d,没有 BIFF 流存活时回退到语法树遍历。合并刻意悲观,固定优先级是 frcUnknownOrMalformed,然后 frcExternalWorkbook,然后 frcExternalOther,然后 frcInternal,于是一个读不了的记号污染整个公式。对外部定义名称还要校验名称索引:一基、在界内、有保留的 ExternName 记录背书
var
Wb : TXLSWorkbook;
Sheet: TXLSWorksheet;
i : Integer;
begin
Wb := TXLSWorkbook.Create;
try
Wb.Open('quarterly.xls');
for i := 1 to Wb.Sheets.Count do // Sheets 从一起
begin
Sheet := Wb.Sheets[i];
// 只冻结分类为 frcExternalWorkbook 的公式;
// 内部、加载项、DDE/OLE 和畸形引用保持为公式
Sheet.ConvertFormulasToValues(True);
end;
Wb.SaveAs('quarterly-detached.xls');
finally
Wb.Free;
end;
end;
OnlyExternal 参数是分类法回本的地方。冻结公式不可逆,所以操作必须证明一个引用是外部工作簿,而不仅仅是怀疑。加载项调用存活,DDE 和 OLE 链接存活,解析器没能完全理解的任何东西存活,因为面对不确定的安全结果是什么都不改。同样的纪律管着工作簿间复制公式的重绑定,那里一个分错类的引用会重绑到错误的簿上而不是响亮失败
解析不了的记录原样写回
HotXLS 保留原始 SupBook 载荷,在记录从未被编辑时逐字节重发。解析失败置 slkUnknown 并清空派生状态,但捕获的记录体留在 FRawData,存储路径优先用它而非任何重构,只要该项不脏且不是 self 记录。替代方案,即把没解析的记录归一化成自引用,好让写出端有个良构东西可发,是把一个你没看懂的记录变成一个铁定错误的记录。这个原则与加载保存循环中应用于 VBA 工程及其外部引用的是同一份契约,也是一个能往返真实世界文件的库和一个只能往返其测试套件恰好所含文件的库之间的差别。一个历经十五年 Excel 版本、一个报表生成器和两个迁移工具的工作簿会含有如今在世者没人设计过的记录。发现它们什么样,就什么样写回去
SupBook 和 XTI 记录的类型化分类随 HotXLS 2.361.2 到 2.361.4 交付,配套有界 XTI 解析和本文描述的更安全 ConvertFormulasToValues 路径。如果你维护的 Delphi 或 C++Builder 代码要读携带加载项调用、DDE 或 OLE 链接、外部定义名称的遗留 xls 文件,HotXLS Delphi 电子表格组件原生处理整个分类法,干活机器上无需 Excel 安装、无需 OLE 自动化