技术文章

用 HotXLS 在 Delphi 中框定 BIFF PivotCache 记录

BIFF 的 PivotCache 子流把数据透视表缓存的数据集与展示它的视图分开存放,HotXLS 读写这层子流时靠检查记录体,而不是相信记录号。这个区别就是全部的故事:同一个记录号可能对应两种互不兼容的记录体布局,取决于写出文件的作者是谁,所以读取器从看到的第一条记录体决定框定方式

当一张数据透视表必须活着完成一次往返时,你就会撞上这一层。没有缓存的透视视图只是一个壳,Excel 打开文件时会从源区域重建缓存——在源区域还在的情况下这没问题;等到源区域没了、数据是从查询粘贴来的、或者这份工作簿是一份归档结账件、谁打开它都不许发生变化的时候,就没那么好了

两套结构,文件里的两个位置

缓存的数据和缓存的定义住在工作簿的不同部位,把它们混为一谈是要最先弄对的事。缓存记录自成一条子流,[MS-XLS] 第 2.1.7.12 节给出 PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF。注意里面缺了什么:这条产生式的开头没有 BOF

定义放在 workbook globals 里,形式是 PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE](第 2.1.7.20.3 节),位置在格式化记录之后、BoundSheet 和 Country 记录之前。于是一个缓存被相隔几百条记录的两个地方共同描述,连接两者的是一个流标识符,它必须在三处同时一致

BIFF8 中 HotXLS 的 PivotCache 框定:带 SXStreamID 的 PIVOTCACHEDEFINITION 位于 workbook globals 中格式化记录之后、BoundSheet 之前;缓存记录住在 _SX_DB_CUR 存储下的一条流里,流名是四位大写十六进制,装着 SXDB、SXDBEx、SXFORMULA、FDB 和 DBB 记录且没有 BOF;SXStreamID.idStm、SXDB 的 idstm 字段与流名三者必须一致
一个透视缓存被相隔几百条记录的两处共同描述,由一个流标识符缝合——它必须同时与 globals、SXDB 头部和子流名一致

每条缓存住在一个 _SX_DB_CUR 之下的流里,流名是其标识符的四位大写十六进制拼写。SXStreamID.idStmSXDB 头部重复出现的 idstm 字段、以及那个流名,三者必须全部匹配。分配新标识符时,先把文件里已经读到的所有号码都保留下来,否则新缓存可能认领一个属于老缓存的号码,而读取器还没走到那个老缓存

还有一个标识符会坑人。透视视图里的 iCache 值是对应 SXStreamID 在全局序列中的 0 基位置,不是由你随意选择的缓存标识符。写出时它必须从缓存对象映射到其实际输出位置,而且已有视图必须随之重新编号,否则升级一条缓存会悄悄把某个视图指向另一条

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET,见 MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // 信任缓存的记录
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // 先清零,再定记录网格尺寸
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

调用两次 SetRecordCount 不是迷信。RecordCount 是一次不分配内存的普通属性写入,内部增长路径只初始化新加的行,所以一个经由头部路径设置过计数的缓存,最终可能拿着一张零长度的索引网格。此后对 RecordIndices 的写入会被无声丢弃。把计数归零再设回,网格才重新建立起来,而且这必须发生在每个字段都添加完之后,因为行宽来自字段数

为什么记录号判断不了记录体布局?

因为记录号和记录体布局是在不同时间点各自演化的,两者之间的映射构不成一个函数。旧集合里的一个号码只出现在老写出器的文件里,这让它成为一个单向的可靠信号。另一个号码则真的有歧义:它既出现在正确的文件里,也出现在一批「用了新号码、配的却是旧记录体」的中间版本文件里

所以框定必须从记录体判断,而且每个缓存子流判断一次,不是逐条记录。HotXLS 用每个子流里第一条 SXDBB 记录的长度锁定方言。在规格框定下,一条 SXDBB 恰好装一条缓存记录,长度等于一行宽。在旧的紧凑框定下,第一条记录装下尽可能多的行,于是对任何超过一行的缓存,它的长度至少是两倍行宽。只要两个预测不同,这个比较就是决定性的

HotXLS 的 SXDBB 框定锁存:一个记录号携带两种互不兼容的记录体布局,读取器把第一条 SXDBB 记录长度与行宽比较——一行宽锁存规格方言,两倍或以上行宽锁存旧式紧凑框定,打平时取规格读法,方言按缓存子流锁存一次而非逐记录
记录号决定不了记录体布局,因为两者在不同时间演化;所以 HotXLS 按子流从第一条 SXDBB 长度锁存方言,打平时取规格读法

两个预测相同时,读取器取规格读法,原则是 Excel 写出的文件远多于某个中间构建写出的文件。这个盲区按构造就很窄,而且即便命中,文件本身仍按字节原样回放。受影响的只是暴露给调用方的类型化索引

索引宽度住在另一条记录里

SXDBB(第 2.4.276 节)为每个设置了不同值标志的缓存字段携带一个索引,按字段顺序排列,而每个索引的宽度在别处决定:对应的 SXFDB 字段记录(第 2.4.283 节)声明一个 short-items 标志,这个标志说明索引占两字节还是一字节。两条记录、一份隐含契约,规格里连接它们的只有一句话

这个耦合恰好就是自造编码出错的地方。HotXLS 早先的写入器把每个字段按最少位数打包、行间补齐到字节边界——孤立地看说得通,却直接与同一个写入器刚在 SXFDB 里声明的宽度相矛盾。一个只有三个不同值的字段,在一条记录里被描述为一字节宽,在另一条里只占两位。修复不是去改算术,而是把宽度决策抽成一个函数,让两个发射器都调用它,两条记录从此无法再漂移。这与 BIFF 记录长度声明漂移描述的是同一类缺陷:声明的大小与实际的记录体分道扬镳

完全不读这些记录的后果值得展开说,因为很容易低估。读取器跳过记录索引时,每份从文件加载的缓存,每行每个字段报告的都是索引零——意味着每一行都指向每个字段的第一个值。这不只是内省能力缩水:透视求值路径和缓存到单元格的填充路径消费的都是这张网格。而且往返测试测不出它,因为还在原始回放状态的缓存是从原始字节写回去的

// 来源标志告诉你手里拿着什么、哪些可以重写
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // 只有每条记录都有模型时,重新发射才是无损的
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

什么时候重写缓存是无损的?

只有三个条件同时成立时——而 CanUpgradeFraming 就是回答这个问题的那一个属性。缓存必须仍处于原始回放状态;子流必须处于本库以前写错过的那些框定之一;读取器必须已经为其中每条记录建立了完整的类型化模型。Excel 写出的缓存永远不合格,因为它的子流携带 HotXLS 没有模型的记录,从模型重新发射就会把它们丢掉

完整性检查比乍看起来更严格。一条读取器只当作不透明字节保留的记录,就会让模型不完整。发射器无法复现的公式记录声明数量也一样——重新发射会把「声明了若干条公式记录」改写成「声明了零条」,而文件里一个无法复现的值等价于一条无法复现的记录

刻意的保守也贯穿写入器。索引被钳制进合法范围,而不是编码成一个带外哨兵值,因为规格定义的是「不同值序列中的一个索引」,此外无他,而空单元格本身就是该序列中的一个值。超过 BIFF 记录上限的缓存记录体干脆不写——那需要几千个缓存字段,而且在 BIFF8 的列数上限内本来就够不着;退路是 Excel 从源区域刷新,这是有定义的行为,而不是一份损坏的文件

日期带着最后一个跨记录依赖。序列号到日期的转换取决于工作簿的日期系统,而记录发射器看不到工作簿,所以基准日期的选择以参数形式传入,默认 1900 系统,由工作簿级的保存路径提供。1900 系统下序列号就是值本身;1904 系统相差 1462 天。日期序列号的更完整处理见 日期序列号、1904 系统与数字格式

如果你工作在视图层而不是缓存层,描述可见透视的记录在 BIFF8 数据透视表记录集里有覆盖,计算侧行为见 计算字段、计算项与刷新。三层都随 HotXLS Delphi spreadsheet component 提供——正是这一点让你可以加载一份遗留工作簿,检查它的缓存里到底装了什么,并在动手之前判断重写它是否安全