HotXLS 解码 BIFF8 XLUnicodeString 时,会先读取 cch 和 fHigh 标志,再选择与编码匹配的读取器:fHigh 为 1 时使用字节数为 cch * 2 的 TXLSBlob.GetWideString,fHigh 为 0 时使用 TXLSBlob.GetString。把它们配反后,记录只会产生空字符串或半长字符串,从来不会抛出异常
这正是这类 bug 代价高昂的原因。图表可以打开,序列正确绘制,坐标轴也没有问题,却有一条趋势线标题悄悄变成空白。日志里没有任何内容,异常处理器也没有任何内容,更不会弹出文件损坏对话框。文件从头到尾都是好的;读取器只是请求了错误的字节数,然后准确地得到了它请求的内容
BIFF8 字符串为什么会返回空值
BIFF8 字符串返回空值,可能是长度保护在读取前拒绝了载荷,也可能是读取器在遇到第一个 NUL 时停止。两条路径从设计上都是静默的。在 HotXLS 中,保护通常是记录处理器中的显式 DataLength 检查,而且必须按编码计算:16 位载荷的 SXViewLink 体需要 8 + cch * 2 个字节,8 位载荷只需要 8 + cch。把宽字符算术应用到 8 位记录上,会让每个短名称都无法通过门槛。NUL 行为是第二个陷阱,因为 TXLSBlob.GetString 和 TXLSBlob.GetWideString 都会在解码结果中扫描终止符并在那里截断;终止符位于第一个位置时,就会返回空字符串。用一半字节数读取 16 位体,会保留前 cch div 2 个字符;通过宽读取器读取 8 位体,则会把字节对组成任意代码点。只有过长读取会大声失败:请求超过 blob 时,TXLSBlob.EnsureReadable 会抛出 Blob read exceeds data size。少读没有这样的警报
GetWideString 计算的是字节,而不是字符
TXLSBlob.GetWideString(Index, Count) 的 Count 单位是字节。内部会对 PWideChar 调用 SetString,长度为 Count div SizeOf(WideChar),因此传入字符数会静默让字符串减半。另一方面,BIFF8 记录布局用字符表示字符串长度。因此每个 16 位调用点都必须自行携带 * 2 转换,每个 8 位调用点则不能带它。这是写回文本时也会遇到的同一编码边界;如果你的流水线需要双向移动字符串,值得和Delphi 中安全处理 Unicode 的电子表格导出一起阅读
// 16 位 XLUnicodeStringNoCch:cch 个字符,cch * 2 个字节
Name := Data.GetWideString(Start, cch * 2); // 正确
Name := Data.GetWideString(Start, cch); // 文本减半,不报错
// 8 位 XLUnicodeStringNoCch:cch 个字符,cch 个字节
Name := WideString(Data.GetString(Start, cch)); // 正确
Name := Data.GetWideStringWithZero(Start, cch); // 仍然是宽字符读取器
只要是手工遍历字节流,这个约定都成立。HotXLS 将长 String 记录($0207,[MS-XLS] 2.4.268)从 Continue 记录($003C)重新拼接时,宽字符分支根据段长度计算 segCh,再调用 GetWideString(3, segCh * 2),因为记录体从偏移 3 开始,而计数单位仍然是字节。富文本读取器在第一个 Continue 段从偏移 1 开始时也做同样处理。[MS-XLS] 2.5.293 保证当 fHighByte 为 1 时,断点会落在双字节字符边界上,因此不需要记录半个字符,但字节算术仍然要由你正确完成
GetWideStringWithZero 到底做什么
TXLSBlob.GetWideStringWithZero 是一个保留嵌入 NUL 的宽字符读取器。WithZero 后缀标记的是保留 NUL,而不是字符宽度:内部同样会对 PWideChar 调用 SetString,长度为 Count div SizeOf(WideChar),但不会像 GetWideString 那样扫描终止符。对应的一字节方法是 TXLSBlob.GetStringWithZero,它返回 AnsiString。名称本身没有说明二者分别是什么,这种歧义已经在代码库中造成过真实 bug。这个具体误读值得点名,因为它看起来非常合理:GetWideString 需要 cch * 2,所以 GetWideStringWithZero 一定直接接受 cch。它确实会不报错地接受 cch,也确实返回 WideString,编译器完全满意;但它也会返回一半的字符,而且是由错误字节对拼出来的字符。正确的 8 位路径是使用普通 cch 字节计数调用 TXLSBlob.GetString,在赋值时转换为 WideString。HotXLS 2.376.0 正是在两个图表解码器中修复了这种误用
SXViewLink 与按编码区分的长度门槛
SXViewLink($0858,[MS-XLS] 2.4.316)是最清晰的工作示例,因为它在一个 8 字节头部中同时包含了两种不对称性。布局是 rt(2)、unused(2)、reserved(2)、cch(1)、fHigh(1),后面跟着 XLUnicodeStringNoCch 体:fHigh = 1 表示 cch * 2 个 UTF-16 字节,fHigh = 0 表示 cch 个单字节字符,cch 的上限是 255,因为长度字段只有一个字节。当图表工作表链接到 PivotTable 视图时,HotXLS 会在 Units 之前、图表全局记录中写入该记录,紧邻 PivotChartBits($0859,[MS-XLS] 2.4.196);在 Delphi 中写入 BIFF8 PivotTable 记录的文章介绍了这套记录级机制
// SXViewLink([MS-XLS] 2.4.316):rt(2) unused(2) reserved(2) cch(1)
// 然后是 XLUnicodeStringNoCch:fHigh(1) 后跟字符
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;
两个分支不是装饰。早期版本让两种编码都通过宽字符表达式 8 + cch * 2 进行门控,因此 Excel 写出的 8 位视图名称无法通过保护,解码器返回空的 PivotSourceName,IsPivotChart 也保持为 false。Pivot 链接没有任何诊断就从模型中消失了。完全相同的错误还位于 Trendline 解码器($2050,[MS-XLS] 2.4.328)中;名称字段在 28 字节数值载荷之后,偏移 28 处有 2 字节 cch,偏移 30 处有 fHigh,字符从偏移 31 开始。Excel 用 8 位字符写入的趋势线标题同样会解码为空。两个问题在同一个版本中修复。而 8 位情况并不是只存在于Excel 2.0 到 4.0 文件中的遗留趣闻:只要所有字符都能用一个字节表示,当前 Excel 仍然会写出 8 位 BIFF8 载荷
如何在 Delphi 中安全解码新的 BIFF8 记录
字段确实是标准 XLUnicodeString 时,应使用 TXLSBlob.GetBiffString,而不是手写分支。它读取长度字段和选项字节,分派到匹配的读取器,并将游标推进到体内容之后。两个 Boolean 参数是需要仔细阅读的部分:is8bit 描述的是长度字段的宽度,不是字符宽度;iswide 表示长度字段后是否存在 fHigh 选项字节。低于 $0600 的 BIFF 版本两者都没有
var
Offset: LongWord;
begin
Offset := 6; // 在 SXViewLink 中 cch 字节从这里开始
// is8bit = 长度字段宽度为一个字节
// iswide = 长度字段后跟随 fHigh 选项字节
Name := Data.GetBiffString(Offset, True, True);
// Offset 现在指向字符串体之后的第一个字节
当处理器必须承受截断或恶意输入时,手写分支仍然有存在价值,因为 GetBiffString 依赖 EnsureReadable 抛出异常,而不是使用你能控制的边界检查。这就是 HotXLS 图表解码器检查 DataLength 并返回空结果而不是抛出异常的原因:格式错误的第三方工作簿应该只牺牲一个标题,而不是牺牲整个文档。这个取舍是有意的,也正是编码专属保护必须正确的原因,因为保护负责把错误读取转化为静默结果
同一版本还带来最后一个流程教训。应当对保存并重新打开的工作簿断言,而不是对刚构建的内存模型断言。2.376.0 批次还发现了一个 SXEx 发射器([MS-XLS] 2.4.282)声明体长 24 字节却只写了 22 字节,导致 PivotTable 视图之后的每条记录错位,包括工作表 EOF 和后面的图表工作表子流。现有 Pivot 测试始终没有发现它,因为全部断言都针对内存。字符串解码具有相同性质:只有通过文件往返,才能真正测试字节计数
如果你在 Delphi 或 C++Builder 中处理经典 XLS 内部结构,又不想自行维护 BIFF8 记录读取器,上述编码规则已经在HotXLS Delphi 电子表格组件中实现并经过回归测试,无需 Excel 或任何 OLE 自动化即可读写 XLS 和 XLSX