HotXLS 2.376.0 修复了经典 XLS 写入器中的 BIFF 记录长度漂移:PivotTable 视图的 SXEx 发射器在头部声明体长为 24 字节,却随后追加了 26 字节。BIFF 读取器信任声明长度,因此多出的两个字节会让后续所有内容失去同步;将 PivotTable 与图表工作表配对的工作簿重新打开后,图表就会消失
有意思的部分不只是多写了一个字。错误位置与症状之间的距离才是关键。错误发生时没有任何内容失败。Pivot 记录顺利序列化,文件写入没有报错,Excel 也能打开它,损坏却要到下游数百字节处、完全无关的子流中才显现。这种距离是所有长度前缀二进制格式的典型特征,在你为其中任何一种格式再写发射器前,都值得先理解
一个错误的记录长度为什么会破坏整个工作表流
BIFF8 工作簿流除了自身的算术关系,没有任何其他成帧机制。每条记录由 4 字节头部组成:记录 ID 2 字节加上体长 2 字节,后面紧跟正好那么多的载荷字节([MS-XLS] 2.1.4)。没有分隔符、魔数、校验和或重新同步点。读取器之所以能落在下一条记录上,只是因为上一条记录如实说明了自身大小。声明长度不是记录的元数据,而是指向下一条记录的指针。因此追踪多出的两个字节做了什么。读取器读完 SXEx 头部,跳过头部承诺的 24 字节,落在过大体的两个剩余零字节上。它把这些零读成记录 ID $0000,再把后面的工作表 EOF 记录 ID($000A)读成这个幽灵记录的长度,并尽职地向后跳过十个字节。从这以后,每个头部都在错误的偏移处读取。在失败的工作簿中,这产生了重新打开后 _Chart 为 nil 的图表工作表,还产生了显示 $18AF 被解释为记录 ID 的调试转储。这两个值都不在 Pivot 代码附近
发射器和写入器从不互相核对
之所以会发生漂移,结构原因在于 HotXLS 把 BIFF 记录构建为 TXLSBlob,头部和载荷是两个独立事实。EmitSXEx 写入记录 ID,然后用 Blob.AddWord(24) 写长度,再逐字段追加体内容。这个 24 是手工计算的常量,从未根据后续字节推导,也从未与后续字节核对。写入路径也没有弥补这一点:AddRec 将 blob 转交给 TXLSBlobList.Append,后者把 Data.DataLength 个字节原样复制到输出流。DataLength 才是真实字节数,因此写入器忠实地在声明 24 的头部后发出 26 字节体。两半都完全按照指令工作,却没有人负责注意到它们之间的矛盾。HotXLS 在重放保留载荷时已经避免了这一点:TXLSWorkbook.StoreDConnBlobs 根据实际体长计算头部长度字,而不是使用字面量,这正是 blob 重放从未漂移的原因
[MS-XLS] 2.4.282 对 SXEx 规定了什么
规范对大小的规定很明确,因此修复是机械性的。[MS-XLS] 2.4.282 将 SXEx 体定义为一个 4 字节的 grbit,后跟十个 2 字节字段:csxformat、cchErrorString、cchNullString、cchTag、csxselect、crwPage、ccolPage、cchPageFieldStyle、cchTableStyle 和 cchVacateStyle。4 加 20 等于 24。旧发射器按照规范定义的十个零字,却写入了十一个;匿名的 AddWord(0) 调用没有携带字段名,因此在审查时凭肉眼计数,可靠性正如你想象的那样。预分配是线索:TXLSBlob.Create(28) 只为 4 字节头部加 24 字节体申请空间,但 blob 每次调用都会超过这个提示,并且由于 AdjustBufferSize 会按需重新分配,超出过程是静默的。任何代码一开始就会越过的容量提示,都值得在序列化器中再看一眼
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // 4 字节头部 + 24 字节体
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// 十个零字完成 [MS-XLS] 2.4.282 规定的 24 字节体
// 声明长度必须与写入字节数一致,否则此后的每条记录
// 都会被错误解析
Blob.AddWord(0); // csxformat
Blob.AddWord(0); // cchErrorString
Blob.AddWord(0); // cchNullString
Blob.AddWord(0); // cchTag
Blob.AddWord(0); // csxselect
Blob.AddWord(0); // crwPage
Blob.AddWord(0); // ccolPage
Blob.AddWord(0); // cchPageFieldStyle
Blob.AddWord(0); // cchTableStyle
Blob.AddWord(0); // cchVacateStyle
AddRec(DataList, Blob);
Result := 1;
end;
为什么整个 PivotTable 测试套件都没有发现它
因为现有 Pivot 测试从未通过文件往返。它们构建工作簿,针对内存模型断言,然后停止;内存断言看不见只存在于序列化字节流中的长度不匹配。从 Delphi 写入 BIFF8 PivotTable 记录覆盖的记录集合按这个标准经过了充分测试,却仍然交付了会破坏流的发射器。这个缺陷还需要第二个功能才会可见:只有 Pivot 工作表、后面没有太多内容时,文件仍然能重新打开,因为损坏越过了一个没人检查的子流末尾。只有 PivotTable 与图表工作表组合,且图表工作表和绘图位于工作表之后的子流中时,静默错位才会变成明显丢失的对象
// PivotChartRoundTripThroughLinkRecords,压缩版
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath); // 错误解析在这里发生
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
修复前,Wb.Sheets[3]._Chart 在那一行是 nil,因为读取器早在到达图表 BOF 之前就已经丢失了子流边界。最终捕获 Pivot 序列化 bug 的断言,实际上是关于图表的断言
如何从第一个错误记录读回错位的 BIFF 流
遍历头部链并打印出来,因为错位的 BIFF 流会在数据看起来错误之前很久就从结构上暴露自己。从子流 BOF($0809)开始,读取 ID 和长度,以 4 加长度的步长前进并重复。流保持对齐时,你会落在合理的记录 ID 上,链也会准确在 EOF($000A)结束。一旦发生漂移,就会出现不存在的 ID、越过缓冲区的长度,或直接越过 EOF 原本应该出现的位置
// 遍历 BIFF 记录流,在第一个不可能成立的头部停止
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
Pos: LongWord;
Id, Len: Word;
begin
Pos := 0;
while Pos + 4 <= Size do
begin
Id := PWord(Buf + Pos)^;
Len := PWord(Buf + Pos + 2)^;
// 零 ID 从来不是合法记录,越过缓冲区的体说明链
// 在上游某处已经发生漂移
if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
begin
WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
Break;
end;
WriteLn(Format('%6d id=$%.4x len=%d', [Pos, Id, Len]));
if Id = $000A then
WriteLn('-- EOF, substream ends cleanly --');
Inc(Pos, 4 + LongWord(Len));
end;
end;
然后从输出末尾倒着读,并记住一条规则:第一个解析失败的记录几乎从来不是罪魁祸首,而是受害者。罪魁祸首是它前面的那条、最后一条没有报错地解析完成的记录,因为谎报自身长度的记录总能顺利完成解析。在这个案例中,遍历在幽灵 $0000 记录处停止,而它前面就是 SXEx。把该记录的声明长度逐字节与规范中的字段列表比较,算术关系要么成立,要么不成立。如果连第一个合理记录都到不了,问题就在更低一层,即承载 Workbook 流的 OLE2 复合文件中,继续打印记录级信息也不会有帮助
让发射器无法谎报自身长度
持久修复不是写对一个常量,而是移除写出错误常量的机会。先为长度字预留位置,写出体内容,再根据实际产生的字节数回填头部。HotXLS 暴露了所需接口:TXLSBlob.DataLength 提供当前偏移,SetWord 可以写回已经输出的位置
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // 记住长度字所在的位置
Blob.AddWord(0); // 占位符,由 EndRecord 回填
end;
procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
Body: LongWord;
begin
Body := Blob.DataLength - LenPos - SizeOf(Word);
if Body > 8224 then
raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
Blob.SetWord(Word(Body), LenPos);
end;
也要诚实说明保证的边界。断言输出字节等于 2 加 2 加声明长度,只适用于载荷不超过 BIFF8 上限 8224 字节的记录。更大的体会在头部合法地声明 8224,并通过 $003C Continue 记录继续,这正是 HotXLS Pivot 缓存和连接写入器处理大载荷的方式。因此不变量是有条件的:低于上限时,输出 blob 长度必须等于声明长度加四;超过上限时,则由拆分器负责算术。应当把这一区别编码在辅助函数中,而不是藏在注释里。同样的推理可以迁移到所有标签长度值格式,而不只是 BIFF。发射器在尚未知道大小之前就声明大小,写下的是代码无法检查、审阅者无法计数的主张;它会一直工作到下游第二个功能落地为止
这里讨论的 BIFF8 写入器、Pivot 记录发射器和图表子流,都属于面向 Delphi 和 C++Builder 的 HotXLS Delphi 电子表格组件,无需安装 Excel 即可读写 XLS、XLSX 和 ODS