技术文章

Delphi 中的 Excel 表格 Slicer:HotXLS 往返

HotXLS 从 Delphi 和 C++Builder 用一次调用创建 Excel 表格 slicer——Worksheet.Slicers.Add(Table, ColumnName)——并写入一个 slicer 需要的全部八个包位置:slicer 和 slicerCache 部件、两个内容类型覆盖、带各自匹配 extLst 块的工作表和 workbook 关系,以及 drawing 锚点

slicer 看起来像你拖到工作表上的控件。在文件里它完全不是那么回事。它是一个分散的记录,横跨两个新部件、两张关系图、三个扩展块和一个 drawing 锚点,整个东西在 Excel 打开包时作为一个整体被校验。少一块,你得到的不是降级运行的 slicer;你得到修复对话框,然后是一个 slicer 被静默移除的工作簿。正是这种失败模式让手写 slicer 很烦人,也是为什么这里有趣的工程是记账而不是渲染

为什么一个 slicer 要碰包的八个位置?

因为一个 slicer 是三个独立的概念,OPC 把它们逼进三个独立的住所:过滤器状态(workbook 作用域)、视图(worksheet 作用域)和图形(drawing 作用域)。HotXLS 每个表格 slicer 正好写八个落点,而每一个都承重

  • xl/slicers/slicerN.xml —— 每个 worksheet 一个部件,在该工作表 http://schemas.microsoft.com/office/spreadsheetml/2009/9/main 命名空间里持有每一个 <slicer> 视图
  • xl/slicerCaches/slicerCacheN.xml —— workbook 级,每个被切片表列一个,携带实际的绑定
  • 在这两个部件各自的 [Content_Types].xml 里有一个 <Override>,使用 application/vnd.ms-excel.slicer+xml 和 application/vnd.ms-excel.slicerCache+xml(ECMA-376 Part 2 要求每个部件都有内容类型,没有它们 Excel 拒绝这个包)
  • 一个类型为 .../office/2007/relationships/slicer 的 worksheet 关系,加上 worksheet extLst 里指向那个 rid 的 <x14:slicerList> 扩展
  • 一个类型为 .../office/2007/relationships/slicerCache 的 workbook 关系,加上 workbook extLst 里列出相同 rid 的 <x15:slicerCaches> 扩展
  • 工作表 drawing 里的 mc:AlternateContent 锚点,给 slicer 位置和大小
  • 缓存部件里把它绑定到 table id 和 table column id 的 <x15:tableSlicerCache> 块

扩展块用标准 ECMA-376 Part 1 §18.2.10 的 extLst 机制:每个 <ext> 携带标识特性的 uri 属性,不识别那个 GUID 的读取器跳过该块。HotXLS 仅凭 GUID 区分表格 slicer 和透视 slicer——表格 slicerList 是 {3A4CF648-6AED-40f4-86FF-DC5316D8AED3},透视的是 {A8765BA9-456A-4dab-B4F3-ACF838C121DE},两个 workbook slicerCaches 扩展相应是 {46BE6895-7355-4a93-B00E-2C351335B9C9} 和 {BBE1A952-AA13-448e-AADC-164F8A28A991}。表格绑定本身住在 {2F2917AC-EB37-4324-AD4E-5DD8C200BD13} 下。这些常量在单元里暴露为 XlsxExtUriSlicerListX15、XlsxExtUriWbSlicerCachesX15 和 XlsxExtUriTableSlicerCache,所以写入路径里没有任何魔数字符串

HotXLS 为用 Worksheet.Slicers.Add 创建的一个表格 slicer 写入的八个包位置:slicer 和 slicerCache 部件、两个内容类型覆盖、worksheet 和 workbook 关系、x14:slicerList 和 x15:slicerCaches 的 extLst 块,以及 drawing 锚点,在 Excel 校验包时每一个都承重
一次 Worksheet.Slicers.Add 调用扇出到包里的八个落点:两个部件、两个内容类型覆盖、两个关系、两个 extLst 块和一个 drawing 锚点,Excel 打开文件时每个都作为一个单元被校验

哪些部件可以逐字重放,哪些必须重建?

HotXLS 用一条规则回答:只有它所引用的东西在保存期间不会被重新编号时,部件才能重放原始字节。表格 slicer 缓存过不了这个测试,所以 HotXLS 每次保存都从模型重建它们,无条件——即使用户从未碰过那个 slicer。原因是 tableId:<x15:tableSlicerCache> 元素用 <table> 的 id 属性识别它的表,而 HotXLS 在写入时对整个 workbook 重新编号表 id。早期实现把缓存部件在打开时存为原始字节、保存时原样推回去——这对大多数不透明部件是正确的策略。它产出的文件在常见情况下干净打开,却在有意思的情况下触发修复:在更早的工作表上加一张表、保存,缓存现在指向属于别的东西、或者什么都不属于的 table id。这个 bug 在普通打开-保存回归里不可见,因为 id 碰巧没有移动

透视形状的缓存走相反的路。它们的负载是 <pivotTables> 加一个描述缓存项、排序状态和条目索引的 <x14:data> 块——一个没有 HotXLS 侧重新编号、忠实建模又昂贵的结构。它们被保存为原始字节并精确重放,TXLSXSlicerCache.IsPivot 和 IsTableCache 把两者区分开。slicer 视图 走第三种策略,脏跟踪:未动过的 <slicer> 元素和它的 drawing 锚点逐字重放,一旦工作表上任何视图被修改,整个工作表部件就从模型重新生成。这就是判断的一般形状——引用稳定处逐字重放、写入器重新分配身份处从模型重建、两者都可接受处脏跟踪——而且它远远不限于 slicer,关于主题、extLst 块和 calcChain 的无损往返笔记 更深入地把这一点讲透了

HotXLS 中每个 slicer 部件的重放策略:表格 slicer 缓存总是从模型重建,因为 tableId 值在保存时对整个 workbook 重新编号;透视缓存重放原始字节,因为 pivotTables 和 x14:data 从不重新编号;slicer 视图脏跟踪,逐字重放直到工作表上任何视图变化
每个部件的重放策略:表格 slicer 缓存总是重建,因为表 id 在整个 workbook 范围重新编号;透视缓存重放原始字节,因为其中没有东西被重新编号;视图脏跟踪,让未动过的保存保持字节级干净

从 Delphi 创建表格 slicer

TXLSXSlicers.Add 就是整套创建 API。给一个 TXLSXTable 和列名,它物化表列 id、找到或创建底层的 TXLSXSlicerCache、预留匹配的 defined name,并把视图锚到表格右侧。两参数重载选放置位置;六参数那个接收显式的 1-based 含端盒

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Slicer: TXLSXSlicer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('sales.xlsx');
    Sheet := Book.Sheets[0];

    // 默认位置:与表格相隔两列,
    // 高 13 行,采用 19pt 默认行高
    Slicer := Sheet.Slicers.Add(Sheet.Tables[0], 'Region');
    Slicer.Caption   := 'Sales region';
    Slicer.StyleName := 'SlicerStyleLight1';
    Slicer.ColumnCount := 2;

    Book.SaveAs('sales-sliced.xlsx');
  finally
    Book.Free;
  end;
end;

两条命名规则值得知道,因为它们是 Excel 的约定而不是 HotXLS 的发明。缓存命名为 Slicer_<column>,非标识符字符折叠成下划线,冲突时追加数字后缀——Slicer_Region,然后 Slicer_Region1,依此类推。而且每个缓存名也被预留为公式是 #N/A 的 workbook defined name,这正是 TXLSXSlicers.ReserveCacheDefinedName 做的。Excel 自己会创建那个名字,而且它很重要:缓存名和 defined name 共享同一个命名空间,所以已经有一个叫 Slicer_Region 的名字的工作簿会在下次保存时冲突。如果你同时构建表格和校验,关于数据校验、AutoFilter 和表格的文章里的 defined name 交互覆盖了相邻情况。共享遵循 Excel 用的同一条规则。在创建新缓存前会先查询 TXLSXSlicerCaches.FindForTableColumn(ATableId, ATableColumnId),所以同一表列上的两个 slicer 视图——一个在仪表板工作表、一个在明细工作表——共享单个缓存部件并保持筛选同步。删除视图时跨工作簿每个工作表做反向检查,只有当引用它的最后一个视图消失时才丢弃缓存和它预留的 #N/A 名字

关系 id 如何在两端保持稳定?

它们保持稳定是因为 HotXLS 通过重放它发出其他每个关系的精确顺序来计算 slicer rid,并把新关系追加在尾部。文件里已有的东西都不会移动。这听起来像过度工程,直到你调试过另一种做法。worksheet .rels 里的关系 id 被 worksheet 部件内部引用——被超链接、drawing、批注、表格部件——而 ECMA-376 Part 2 在关系图内按位置解析它们,不靠任何稳定身份。如果写入器把 slicer 关系插在中间,它后面的每个引用都偏移一位,工作表就悄悄开始指向错误的部件。所以 XlsxWorksheetSlicerRelNumber 走完整条确定性链——外部超链接、然后批注 VML、批注、页眉页脚图片、threaded comments、drawing、工作表背景、打印设置、表格、透视表、查询表、table single cells——然后才为 slicer 认领下一个数字。XlsxWorkbookSlicerCacheFirstRid 在 workbook 侧做同样的事:sheets、sharedStrings、styles、VBA、external links、theme、connections、pivot caches、person、calcChain、sheet metadata、XML maps、custom XML,然后才是 slicerCache 序列。这个一般问题在关于 OPC 关系解析的文章里彻底讲透了。workbook 侧扩展随后由 XlsxBuildWorkbookSlicerCachesExtXml 从这些数字构建,如果源文件已有 slicerCaches 扩展块,它们会先从原始 extLst 剥离,让模型针对当前 rid 重新发出,而不是留下一份过期副本

HotXLS 如何保持 slicer 关系 id 稳定:XlsxWorksheetSlicerRelNumber 从外部超链接经 drawing 走到 table single cells 的确定性 worksheet 链,把 slicer 关系追加在尾部;XlsxWorkbookSlicerCacheFirstRid 在 workbook 侧做同样的事,所以插入永远不会移动已有的 rid 引用
关系 id 按位置解析,所以 slicer 关系在两侧都被追加到确定性发出顺序的尾部——插在中间会让后面每个引用偏移,让工作表指向错误的部件

AlternateContent 锚点及其旧版 fallback

drawing 锚点是 slicer 标记真正变奇怪的地方,而奇怪是有意的。HotXLS 按 ECMA-376 Part 3 在 mc:AlternateContent 元素里发出图形,带一个 Excel 2013 及以后版本会走的 Choice 分支和一个给更老一切的 Fallback 分支

// XlsxSlicerAnchorPayloadXml 生成的内容,结构上:
//
//   <mc:AlternateContent>
//     <mc:Choice xmlns:sle15=".../drawing/2012/slicer" Requires="sle15">
//       <xdr:graphicFrame>
//         <a:graphicData uri=".../drawing/2010/slicer">
//           <sle:slicer xmlns:sle=".../drawing/2010/slicer" name="Region"/>
//         </a:graphicData>
//       </xdr:graphicFrame>
//     </mc:Choice>
//     <mc:Fallback><xdr:sp>...notice shape...</xdr:sp></mc:Fallback>
//   </mc:AlternateContent>

看这两个命名空间。Requires="sle15" token 解析到 2012 slicer 命名空间,它是版本门——不理解 2012 slicer 的读取器必须跳过整个 Choice 分支。但 graphicData/@uri 和里面的 sle:slicer 元素用的是 2010 命名空间,因为那是图形框架一直使用的绘图侧标识符。把它们搞反会产出一个 Excel 毫无怨言打开、然后渲染成空框的文件,这是一个令人难忘的没用症状。Fallback 分支持有一个带文本体的普通矩形,说明这个版本不支持表格 slicer——那是 2007 时代的读取器代替控件画的东西,也是老版 Excel 能无损往返工作簿而不破坏任何东西的原因。TXLSXSlicer 通过 SetBounds(ARow1, ACol1, ARow2, ACol2) 和单独的 FromRow / ToCol 属性把几何暴露为 1-based 含端盒,加上通常的随单元格移动和大小的 AnchorType。有一个具体情况下 HasAnchor 是 False:嵌套在组形状里的 slicer。它们随组锚点逐字重放,HotXLS 刻意不为它们合成第二个锚点,因为那样会把图形发两次

编辑既有 slicer

var
  Slicer: TXLSXSlicer;
  Cache: TXLSXSlicerCache;
begin
  Slicer := Sheet.Slicers.FindByName('Region');
  if Slicer <> nil then
  begin
    Slicer.SetBounds(2, 8, 16, 11);      // 1-based, inclusive
    Slicer.ShowCaption := 1;             // -1 = attribute omitted
    Slicer.LockedPosition := 1;
    Slicer.RowHeightEmu := 19 * XlsxEmuPerPoint;   // 241300 EMU

    Cache := Slicer.Cache;
    if Cache <> nil then
    begin
      Cache.SortOrder   := 'descending';
      Cache.CrossFilter := 'showItemsWithNoData';
    end;
  end;

  // 移除某列上的最后一个视图会连带丢弃其缓存
  // 连同保留的 #N/A 定义名称一起
  Sheet.Slicers.Delete(Sheet.Slicers.IndexByName('Region'));

三态整数属性值得记一笔,因为它们看起来像建模事故,其实不是。ShowCaption、LockedPosition、ColumnCount、StartItem 和 CustomListSort 都用 -1 表示属性在源文件中不存在、写入时也应保持不存在,0 和 1 是显式值。Excel 在几个地方区分"不存在"和"显式默认值",保住这种区分正是让未动过保存的字节 diff 保持干净的原因。RowHeight 和 ColumnWidth 因相关原因保留为原始字符串:Excel 把小数写进这些 EMU 属性,经整数重新解析会在往返中丢精度。RowHeightEmu 是同一存储上的整数便捷访问器,XlsxSlicerDefaultRowHeight 为 241300 EMU——按 XlsxEmuPerPoint 12700 的比率是 19 点

这套能力的边界

两条诚实的限制。第一,HotXLS 完整建模表格 slicer,但不建模透视 slicer 的创建——你可以打开含透视 slicer 的工作簿、读它们的身份、跨保存保持原样、删除它们,但缓存侧以字节重放,也没有从零构建它的 API。第二,slicer 自己不筛任何东西。它是表列上的 UI,它驱动的可见行状态在 Excel 侧;HotXLS 写控件和绑定,不写计算出的筛选结果。如果你需要保存文件里行真的被筛过,还得同时设置表格 AutoFilter。HotXLS 在 <slicer> 或 <x15:tableSlicerCache> 上未建模的任何属性都原样带过,所以来自更新 Excel 构建的 slicer 即使被你移动过,仍会带着它不熟悉的属性完好地重新序列化。正是这个性质让脏重建路径安全可走:只有模型是它解析内容的超集时,从模型重建才可接受。ECMA-376 Part 1 §18.2.10 的 GUID 标记扩展机制,和 [MS-XLSX] 里的 slicer 部件定义,才是让这个保证从一开始就可表达的东西

表格 slicer 随 HotXLS Delphi Component 一起交付,它从 Delphi 5 到当前版本原生读写 XLSX、XLSM 和旧版 BIFF 工作簿,外加 C++Builder 和 Free Pascal,无需安装 Excel、无 OLE。完整 API 参考和试用版在 HotXLS Delphi Spreadsheet Component 产品页上