传统 Excel 二进制格式的几乎每个部分都是具有干净的 2 字节类型和 2 字节长度的单个记录。单元格是 LABELSST 或 NUMBER。合并区域是 MERGEDCELLS。你通过一次遍历一个记录并在类型字上进行分发来读取工作表的大部分内容。数据透视表打破了这种节奏。单个数据透视表不是一条记录,它是一个由数十个相互协作的记录组成的小程序,分布在同一个 OLE 复合文档流的两个不同地方,且它们之间的关系是位置相关的、位打包的,并且不容出错。这就是大多数 BIFF8 读取器要么完全跳过、要么作为不透明字节保留的结构,因为从头编写一个结构意味着要重现 Excel 自身维护的每个交叉引用。
数据透视表之所以困难,是因为它实际上是焊接在一起的两个产物。一个是数据透视缓存(pivot cache),一个具有其自身子流的源数据的自包含快照;另一个是表格视图,即指明哪些字段位于哪个轴上的布局。缓存和视图通过索引相互引用。错一个索引,文件打开时就会出现刷新错误或静默的空网格。
数据透视缓存是其自身的一个子流
缓存作为完整的 BIFF 子流存在于工作簿全局流中,由文档类型为 0x0006 的 BOF 记录进行框架界定(该值标记数据透视缓存,区别于工作簿的 0x0005 或工作表的 0x0010),并由相匹配的 EOF 关闭。在该框架内部,结构是固定的。SXDB 记录是缓存头部。它携带记录计数、缓存字段数以及表视图将引用以将自身绑定到该缓存的流标识符。然后每个源列贡献一个 SXFDB 字段定义记录,后跟对其进行分类的 SXFDBType,然后是该列采用的唯一值(按每个不同的值输出为一个有类型的项目记录)。
项目记录是缓存证明其存在价值的地方。文本值变为 SXSTRING,数值变为 SXNUM,逻辑值变为 SXBOOLEAN,公式错误变为 SXERR。缓存不存储源网格,它存储每个字段的不同值,以及一个索引表(该表说明对于记录 n,每个字段采用了哪个不同的项目)。这就是为什么以编程方式构建数据透视表不是复制单元格那么简单。你必须扫描源范围、从它持有的值中推断每个字段的类型、将它们去重到有类型的项目列表中,并将每一行记录为项目索引的元组。HotXLS 正是这样做:全数值列以 SXNUM 项目输出,混合文本列变成 SXSTRING 项目,日期作为序列值通过相同的数值路径传输。
SXDBB 和使其变得有趣的位打包
每个记录的索引表是整个结构中在技术上最奇特的部分,它存在于 SXDBB 记录中。天真的编码会将每个字段的项目索引存储为 16 位字。Excel 不会这样做。它将每个字段的索引精确地打包到寻址该字段项目所需的位数中,多一位都不行。宽度是 ceil(log2(itemCount + 1)) 位。这里的 + 1 很关键:多出来的值是表示“空白,此记录中的此字段没有值”的哨兵,因此具有三个不同项目的字段需要表示四种状态,因此需要两位,而不是三个项目本身所暗示的一位。根本没有项目的字段贡献零位,并且在打包过程中被完全跳过。
一个记录的位跨所有字段级联,然后下一个记录在新的字节边界开始。记录是字节对齐的,而不是首尾位打包的,这使得对表格的随机访问变得容易处理,代价是每行有几个填充位。字节内的打包是最低有效位优先。一旦你接受了这两个规则,编码器就是一个简单的位泵(bit pump),而解码器是它的镜像。
// Width of one field's index in the SXDBB stream.
// citmTotal distinct items need ceil(log2(citmTotal + 1)) bits,
// the +1 reserving a "blank" sentinel value.
function BitsForFieldItems(itemCount: Integer): Integer;
var
capacity: Integer;
begin
Result := 0;
if itemCount <= 0 then
Exit; // empty field contributes zero bits
Result := 1;
capacity := 2;
while capacity < itemCount + 1 do
begin
Inc(Result);
capacity := capacity * 2;
end;
end;
不能忽略此细节的原因是单个 BIFF 记录的 8224 字节上限。该格式中的每个记录(包括数据透视记录在内)都必须将其有效载荷限制在最多 8224 字节内,而具有数千个源行的数据透视缓存很早就会超出该限制。因此索引表被拆分了。HotXLS 将单个 SXDBB 主体限制在 8220 字节(这是 8224 记录限制减去类型和长度的 4 字节记录头部),将其除以一个打包记录的字节宽度以了解能容纳多少整行,然后根据行数需求输出相应数量的连续 SXDBB 记录。每个连续部分都在记录边界干净地重新开始,因此没有行会被切分到两个记录中。知道每个记录位宽度的读取器可以依次跨越每个 SXDBB,就好像它们是一个连续的位数组一样。
视图布局:SXLI 代表主体,SXPI 代表页面
缓存构建完成后,表视图是另一半。它的核心是轴线项目(axis line items),即枚举表格绘制的行字段和列字段值的每种组合的数据透视主体行。这些被携带在 SXLI 记录中(记录类型 0x00B5,在 [MS-XLS] §2.4.275 中描述)。一个 SXLI 保存许多行(同样直到 8224 字节限制强迫新记录产生),它使用一个小的压缩技巧:每一行仅存储它与上一行的不同之处,表示为公共前缀计数,因此深度嵌套的轴不会在每行上重复外部字段值。总计行和任何记录的第一行始终将该前缀计数重置为零,以便读取器永远不必跨越记录边界向后看以重建行。
页面轴(坐落在数据透视表上方的筛选下拉菜单)是一条单独的记录。SXPI(记录类型 0x00B6,[MS-XLS] §2.4.276)在每个页面字段携带一个 10 字节条目:数据透视字段索引 isxvd、选定的缓存项目 iCache、位置字 ipos 以及遗留对象 ID objId。iCache 值是需要关注的值。显示为“(全部)”、不进行任何筛选的页面字段存储哨兵值 0x7FFD,而不是真实的项目索引。以编程方式构建的数据透视表在打开时,每个页面字段都设置为“(全部)”,直到调用者预先选择一个项目,此时该项目的缓存索引将替换哨兵值,Excel 打开时就已应用了该筛选器。与这些并存的还有描述单个字段及其格式的辅助记录:代表字段视图定义的 SXVD 和 SXVDEx,代表排序每个轴的字段索引列表 of SXIVD,以及代表数字格式的 SXFormat,每一个都索引回主体行引用的相同缓存中。
二合一写入器:原始 blob 与类型化模型
HotXLS 保留两条完全独立的路径来写入数据透视表,这其中有结构上的原因,而且它直接源于对保真度的需求。当从磁盘读取工作簿时,其数据透视记录是由 Excel 或某些其他生成器写入的,它们可能会使用第三方写入器无法完全模拟的记录变体、排序特征或扩展记录。对这些字节唯一安全的操作是原封不动地返回它们。因此,从文件传入的数据透视表会被标记为 FromRawBlobs = True,并且在保存时,写入器会逐字回放保留的记录 blob。没有重新生成,没有重新解释,通过打开和保存的往返是字节稳定的。
由程序构建的数据透视表是相反的情况。没有要保留的原始字节,只有类型化的对象模型:具有其字段和项目列表的 TXLSPivotCache,以及具有其轴分配的 TXLSPivotTable。该表被标记为 FromRawBlobs = False,写入器以困难的方式对其进行序列化(输出一个全新的 BOF = 0x0006 缓存子流,根据类型化模型持有的项目索引来打包 SXDBB 索引表,并根据轴配置布置 SXLI 和 SXPI 记录)。该标志就是让这两种表共存于同一个工作簿中的原因。没有它,单一写入器将不得不要么放弃读入表的保真度,要么拒绝生成新的表。读入表携带的任何特定于生成器的扩展记录都保留为补充记录(可通过表的 SupplementalRecords 列表访问),因此通过类型化模型检查的表不会丢失模型不描述的部分。
在代码中构建数据透视表
上述所有机制都位于一次调用之后。AddPivotTable 接受 A1 标记法的源范围、表左上角锚定的目标单元格以及名称。它解析该范围,扫描它以推断字段类型并构建缓存(如果另一个表已经绑定到该相同范围,则重用现有的缓存),并返回一个类型化的 TXLSPivotTable,其中每个源列对应一个字段,每个字段最初都在轴外。然后,你将字段放置在轴上并选择一种聚合方式。签名正是如此,缓存、SXDBB 打包以及视图记录都会在保存时为你生成。
uses
lxHandle, lxPivot;
var
Book : TXLSWorkbook;
Sheet: IXLSWorkSheet;
Pivot: TXLSPivotTable;
begin
Book := TXLSWorkbook.Create;
try
Book.Open('Sales.xls');
Sheet := Book.Sheets[1];
// Source A1:E500 on 'Data'; anchor the pivot at row 3, col 1.
Pivot := Sheet.AddPivotTable('Data!$A$1:$E$500', 3, 1, 'SalesByRegion');
if Pivot <> nil then
begin
Pivot.AddRowField('Region');
Pivot.AddColumnField('Quarter');
Pivot.AddDataFieldByName('Revenue', xlpaSum);
end;
Book.SaveAs('Sales-Pivot.xls');
finally
Book.Free;
end;
end;
源范围的第一行被读取为命名缓存字段的头部,因此 AddRowField('Region') 通过其头部文本而不是通过位置来匹配一列。因为返回的表是 FromRawBlobs = False 的类型化模型,所以写入器采用从头开始的路径:它构建一个自包含的缓存,该缓存不依赖于在刷新时仍存在的源范围,这恰好是当你将透视表发送给可能移动或删除底层数据的接收者时所需的属性。
读取和协调非你生成的文件的数据透视和缓存记录(包括原始 blob 保留路径)已在工作簿审核和转换工作台指南中进行了介绍。当源范围达到数万行且 SXDBB 流跨越许多连续记录时,大工作簿性能笔记中的技术可以防止缓存构建主导你的运行时间。两者都与 Delphi 和 C++Builder 的 HotXLS 电子表格组件中提供的数据透视写入器配合使用,同时还包括本博客其他地方介绍的单元格、公式、图表和格式 API。