技术文章

HotXLS 在 Delphi 里的 BIFF8 Selection 记录与窗格滚动

HotXLS 通过一套窗格感知的 API 在 TXLSWorksheet 和 TXLSXWorksheet 上存储工作表选区与滚动位置:SelectAreas、GetSelectedAreas、ScrollWindow 和 TryGetWindowScroll。对经典 .xls 文件,HotXLS 写出的 BIFF8 Selection 记录(0x001D)每个最多装 1369 个区域,把逻辑窗格名转换成格式定义的窗格字节,并把每根滚动轴放在 Excel 期望的 Window2 或 Pane 记录上

这个问题通常在对账或审计工具里冒出来。工具打开一份账目导出,找出与源系统不一致的每个单元格,然后保存工作簿,让这些单元格在冻结的表头行下面保持选中,这样审阅者一打开就落在差异上,不用自己滚动去找。四十个差异时一切正常。月末文件有 3,000 个,而一条装着 3,000 个区域的 Selection 记录不可能存在:它的记录体需要 18,009 字节,是一条 BIFF8 记录能承载量的两倍还多。滚动位置有个类似的坑。在冻结窗格的表上,「用户当时在看哪」是四个窗格共享两个行位置和两个列位置,不是一个坐标

为什么大选区需要不止一条 Selection 记录?

大选区需要拆成多条记录,是因为 BIFF8 记录体的上限是 8224 字节,而每个选中区域固定花 6 个字节。[MS-XLS] §2.4.248 把 Selection 记录定为一个 9 字节的固定部分(窗格字节、活动单元格的 rwAct 和 colAct、活动区域的 irefAct、区域计数的 cref),后面跟 cref 个 RefU 结构,每个装两个 16 位行号和两个 8 位列号。装得下的最大计数是 (8224 − 9) / 6 向下取整,即 1369,产生一个 8223 字节的记录体,恰好比上限少一字节。TXLSWorksheet.StoreSelectionGroup 把这个常数用作 MaxAreasPerRecord,把更大的组写成同一窗格的连续 Selection 记录,一次 1369 个区域

真正咬人的细节是 irefAct。每个分块都重复同样的活动行、活动列和活动区域索引,而 irefAct 索引的是所有分块聚合后的序列,不是携带它的那条记录内部的区域。一个超限一个区域的选区能把这件事讲具体:1370 个区域、最后一个为活动区域,会变成两条记录,第一条 cref 为 1369、第二条 cref 为 1,而两条都带着 irefAct 1369。这个值比第二条记录自己的区域数还大。在每条记录里拿 irefAct 对 cref 做范围检查的读取器会拒绝一个合法文件,而每条记录都替换自身状态的读取器会把前 1369 个区域丢掉。HotXLS 的读取器把连续的同窗格记录追加进一个组,要求每个分块在活动单元格和索引上一致,并把范围检查推迟到工作表的 EOF 记录处、等完整序列已知之后才跑。因此窗格优先的 SelectAreas 重载没有 1369 区域的天花板。它在拿工作表写锁之前就校验每个 A1 引用和活动索引,任何格式不对就返回 False,先前的选区保持不变

HotXLS 为什么把大工作表选区写成多条 BIFF8 Selection 记录示意图:8,224 字节的记录体上限装得下 9 个固定字节加 1369 个六字节 RefU 区域,于是 3,000 个区域变成同窗格的三条记录 1369、1369 和 262,irefAct 索引聚合后的序列,所以 1370 个区域、最后一个为活动时两条记录都带 irefAct 1369
每个分块重复同样的活动单元格和索引,HotXLS 读取器把连续的同窗格记录追加进一个组,范围检查只在 EOF 记录处、完整序列已知之后才跑
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // 表头行和 A 列原地不动

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // 冻结会重置已存的选区,所以要先冻结再选。
    // 3000 个区域存成三条 Selection 记录:1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

Selection 记录用哪个窗格字节?

Selection 记录用格式定义的数字代码标识自己的窗格:0 是右下、1 是右上、2 是左下、3 是左上。公开枚举 TXLSPanePosition 按阅读顺序声明:xlspTopLeft、xlspTopRight、xlspBottomLeft、xlspBottomRight,所以 Ord(xlspTopLeft) 是 0,而这在文件里是右下窗格。把枚举直接强转成窗格字节,会把每个左上选区写到右下窗格上,而且一声不吭。HotXLS 的每个窗格感知入口都改用显式 case 语句转换枚举,调用方完全不用碰数字代码。窗格存在性也会检查:右上窗格只在垂直拆分时存在,左下只在水平拆分时存在,右下则要两者都有。对当前拆分或冻结几何里不存在的窗格,SelectAreas 返回 False,GetSelectedAreas 返回空数组且 ActiveAreaIndex 为 -1,不会在工作簿里创建窗格、选区对象或单元格

HotXLS 如何把 TXLSPanePosition 映射到 BIFF8 Selection 窗格字节示意图:枚举按阅读顺序声明,Ord(xlspTopLeft) 是 0,而文件定义 0 为右下、1 为右上、2 为左下、3 为左上,所以每个窗格感知入口都通过显式 case 语句转换
把枚举直接强转成窗格字节会把每个左上选区写到右下窗格上,所以 HotXLS 在写入前还会对照当前拆分或冻结几何检查窗格是否存在

每个窗格的滚动位置存在哪?

每个窗格的滚动位置拆在两条记录里,因为四个窗格只共享两个行位置和两个列位置。在经典工作簿里,上排窗格的第一可见行和左列窗格的第一可见列是 Window2.rwTop 和 Window2.colLeft,而下排窗格的行和右列窗格的列是 Pane.rwTop 和 Pane.colLeft。所以 ScrollWindow(xlspTopRight, R, C) 写的是 Window2.rwTop 和 Pane.colLeft,设置右上窗格的列也会连带移动右下窗格——正如在 Excel 里这两个窗格共享同一根水平滚动条。公开方法用 1 起数的行号和列号。窗格不存在时返回 False 并把两个查询输出置零,越界坐标在任一轴改变之前就被拒绝。这一切都不依赖查看器怎么画网格。渲染控件维护自己的 TopRow 和 LeftCol,在自定义 VCL 网格里渲染工作簿那篇文章讲过,那些是运行时状态,不是存盘的东西

HotXLS 每根窗格滚动轴存在哪示意图:四个窗格共享两个行位置和两个列位置,上排行与左列是 Window2.rwTop 和 Window2.colLeft,下排行与右列是 Pane.rwTop 和 Pane.colLeft,ScrollWindow(xlspTopRight, 1, 6) 写一个 Window2 字段加一个 Pane 字段,右下随之跟随
XLSX 把同样的数据摊在 sheetView 和 pane 的 topLeftCell 属性上,把两层压成一层正是上部或左侧滚动位置在加载时无声消失的方式

XLSX 把同样的数据摊在两个元素上:sheetView/@topLeftCell(ECMA-376 Part 1, §18.3.1.87)对应整个窗口,子元素 pane/@topLeftCell(§18.3.1.66)对应拆分的右下侧。两个属性可以同时存在。HotXLS 先把外层属性读进窗口级字段,让 pane 子元素只覆盖窗格级字段,写回时两者分开写。把两层压成一层正是上部或左侧滚动位置在加载时无声消失的方式。两个引擎里工作表复制都带着这两层。较老的入口保持原行为:经典的 ScrollRow 和 ScrollColumn 属性,以及从 0 起数的 XLSX SetPaneScroll 和 GetPaneScroll。冻结与拆分几何本身用工作表级设置配置,见工作表保护、页面设置与打印

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // 右下:下排行轴(Pane.rwTop)和右列轴(Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // 右上共享右列轴,所以这也会把右下移到第 6 列
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // 右下从第 500 行、第 6 列开始
end;

Selection 记录损坏时会发生什么?

Selection 记录损坏时,HotXLS 把它当不透明字节保留,报告诊断码 1304(xlsDiagnosticSelectionRecordInvalid),并在保存时把原始记录体逐字节写回去。一条记录加入其窗格的组之前,读取器按顺序检查:窗格字节必须不大于 3;同一窗格的记录在流里必须连续;9 个固定字节必须在场;cref 必须在 1 到 1369 之间,记录体长度必须恰好是 9 + cref × 6 字节。组内每个分块必须在活动单元格和 irefAct 上一致,irefAct 不得置上符号位,活动列必须在网格上,任何区域不得边界倒置。单条物理记录内的问题按记录报告一次。只有聚合后才现形的矛盾——比如 irefAct 指过总区域数、或活动单元格落在被索引区域之外——在 EOF 处按组报告一次。无效的组对类型化 API 不可见:该窗格的 GetSelectedAreas 返回空数组、索引 -1,其他窗格照常工作

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

选区如何在行列插入后存活?

选区能在结构性编辑后存活,是因为插入或删除整行整列时,经典与 XLSX 两个引擎都通过同一个共享重映射器重映射每个已表示的窗格组。存活下来的区域保持顺序,活动区域保持身份。活动区域被删时,第一个存活的后续区域成为活动区域;后面没有东西时,则轮到最后一个存活的前置区域。所有区域都被删时,组塌缩成删除边界上的一个单元格,而不落在所选区域内的活动单元格会移到该区域的左上角,索引和坐标永远不互相矛盾。这些限制是故意的。无效的经典组被重映射器跳过而不是改写成一段编造的选区,所以它们的原始字节仍然能原样往返。编辑一个窗格只替换那个窗格的记录,其余字节保持不变。ODS 完全没有窗格选区状态,因为 ODF 没有等价的工作表视图结构来承载它

如果你的应用写出的 .xls 文件需要用户打开后导航——不管是查看标记的单元格、接着上次的进度继续,还是分享一个冻结的仪表盘——这套窗格感知的选区与滚动 API 是 HotXLS Delphi 电子表格组件的一部分,对 XLS 和 XLSX 行为一致