技术文章

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

对账工具找出与总账不一致的 3,000 个单元格,希望保存出的 .xls 打开时它们全部处于选中状态、且在冻结的表头行之下。纸面上这是一条 Selection 记录;按字节算是 9 + 3,000 × 6 = 18,009,而 BIFF8 记录体在 8,224 处止步。同一份工作簿还带出第二个听起来琐碎、其实不然的问题:在冻结于 B2 的工作表上,"滚动位置"是四个窗格共享两个行偏移、两个列偏移,而这四个数字住在两条不同的记录里

两个问题都是格式算术:选区要切成记录、由读取器拼回去;窗格用一个不按阅读顺序走的代码命名;每条滚动轴落在 Excel 读取它的那条记录里

为什么 3,000 个选中区域需要三条 Selection 记录

[MS-XLS] §2.4.248 把 Selection 记录(0x001D)定为一个 9 字节固定部分加各区域。固定部分装着窗格字节、活动单元格的 rwAct 和 colAct、活动区域索引的 irefAct,以及区域计数的 cref。每个区域是带两个 16 位行、两个 8 位列的 RefU 结构。装得下的最大计数是 (8,224 − 9) / 6 向下取整,即 1,369 个区域、8,223 字节的记录体,离上限只差一个字节

HotXLS 用 1,369 作单条记录上限。TXLSWorksheet 保存超过这个数的选区组时,为同一窗格连续写出 Selection 记录,每次 1,369 个区域,每条记录重复同样的窗格字节、活动单元格和活动索引。3,000 单元格的对账选区出来是三条记录,装着 1,369、1,369 和 262 个区域。调用者永远看不见这次拆分;他们交进去的只是一个 A1 引用数组

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]);

    // FreezePanes 会清掉已存选区,所以先冻结再选择。
    // 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;

调用顺序在这里要紧。FreezePanes 重建窗格布局,并清掉工作表携带的每个窗格选区,所以更早设的选区到保存时已经没了。未冻结的工作表上右下窗格还不存在,同样的 SelectAreas 调用只会返回 False。先冻结,再选择

选区跨多条记录时,irefAct 指向什么?

拆分后的选区里,irefAct 索引的是横跨所有分块的聚合区域序列,每个分块携带同样的值。一个超限一个区域的选区能把这件事讲具体:1,370 个区域、最后一个活动,变成两条记录,第一条 cref 为 1,369、第二条 cref 为 1,两条都携带 irefAct 1,369。在第二条记录里,这个索引大于记录自己的区域数。一次只读一条记录的话它像是坏了,两类读取器正是在这里绊倒

  • 在每条记录内部拿 irefAct 对 cref 做检查的读取器,会在第二个分块处拒收一份完全合法的文件
  • 每读到一条 Selection 记录就替换自身选区状态的读取器,只留下最后一个分块,丢掉前 1,369 个区域

HotXLS 读取器把同一窗格的连续记录追加进一个组,要求每个分块在活动行、活动列和 irefAct 上一致。对 irefAct 的范围检查,以及活动单元格确实落在它指向的区域里的检查,只在工作表的 EOF 记录到达、完整序列已知之后运行。提前检查代码更简单,但对任何超过 1,369 个区域的选区都是错误答案

HotXLS 为什么把大的工作表选区写成多条 BIFF8 Selection 记录:8,224 字节记录体上限装得下 9 个固定字节加 1369 个六字节 RefU 区域,3000 个区域因此变成 1369、1369、262 的三条同窗格记录,irefAct 索引聚合序列,所以 1370 个区域最后一个活动时两条记录的 irefAct 都是 1369
每个分块重复同样的活动单元格和索引,HotXLS 读取器把连续的同窗格记录追加进一个组,范围检查只在完整序列已知的 EOF 记录处运行

写入侧,窗格优先的 SelectAreas 重载自身没有 1,369 区域上限。它在碰工作表之前解析每个引用:$ 标记被接受并剥离,C9:A2 这类反向区间被归一化成 A2:C9,空条目或解析不了的条目让整次调用失败。活动索引必须落在数组之内且不超过 32,767,因为 irefAct 是有符号 16 位字段。任何失败都返回 False,先前选区原样保留

窗格代码不按阅读顺序来

Selection 记录用格式定义的数字代码命名自己的窗格:0 是右下、1 是右上、2 是左下、3 是左上。公开枚举 TXLSPanePosition 按阅读顺序声明:xlspTopLeft、xlspTopRight、xlspBottomLeft、xlspBottomRight。于是 Ord(xlspTopLeft) 是 0,而文件里的 0 是右下。把枚举直接强转进窗格字节的写入器,会把每个左上选区写到右下窗格上,文件还保持结构合法——因为两个都是合法窗格码

HotXLS 里每个窗格感知入口都经显式 case 语句转换枚举,应用代码从不接触数字码。窗格是否存在还要对照当前的冻结或拆分几何检查。左上窗格永远存在;右上需要竖直拆分,左下需要水平拆分,右下两者都要。对工作表没有的窗格,SelectAreas 返回 False,GetSelectedAreas 返回空数组、ActiveAreaIndex 设为 -1,两个调用都不会在半路创建窗格、选区或单元格

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

工作表还保留较旧的双参数 SelectAreas(Areas, ActiveAreaIndex),它写到当前活动的窗格并返回 Integer 状态。在冻结窗格的工作表上,这往往是对一个不是你设的状态的猜测。凡在冻结或拆分工作表上跑的东西,优先用接受 TXLSPanePosition 的重载:窗格是调用的一部分,False 结果告诉你几何与请求不一致。读回结果遵循同样的形状:

var
  Areas: TXLSSelectedAreas;
  Active: Integer;
begin
  Areas := Sheet.GetSelectedAreas(xlspBottomRight, Active);
  if Active < 0 then
    Memo1.Lines.Add('No usable selection on the bottom-right pane')
  else
    Memo1.Lines.Add(Format('%d areas, active area %s',
      [Length(Areas), Areas[Active]]));
end;

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

每个窗格的滚动位置拆在两条记录里,因为四个窗格只共享两个行位置和两个列位置。经典工作簿里,上排窗格的第一可见行和左列窗格的第一可见列是 Window2.rwTop 和 Window2.colLeft;下排窗格的行和右列窗格的列是 Pane.rwTop 和 Pane.colLeft。因此右上从 Window2 拿行、从 Pane 拿列,左下恰好相反

ScrollWindow 和 TryGetWindowScroll 严格遵循这个布局,用从 1 起算的行列号。ScrollWindow(xlspTopRight, R, C) 写 Window2.rwTop 和 Pane.colLeft——设置右上列等于同时移动右下窗格。这与 Excel 一致:右侧两个窗格共享一条水平滚动条。窗格缺失时 ScrollWindow 返回 False,TryGetWindowScroll 把两个输出都置零。超界的行或列在任何一条轴变动之前就被拒收,坏列不会留下半程应用的行

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;
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)对应拆分的右下侧。两个属性可以同时在场。只保留其中一个的加载器,会丢掉它碰到的每张冻结工作表的上部行或左侧行列。TXLSXWorksheet 先把外层属性读进窗口级字段,再让 pane 子元素只覆盖窗格级字段,写出时两层分别写回。ScrollWindow 和 TryGetWindowScroll 在 TXLSXWorksheet 上签名与含义相同,复制工作表在两种格式下都带着两层走

只需要一对轴的代码仍有更窄的属性可用:经典工作表上的 ScrollRow 和 ScrollColumn,XLSX 上针对右下窗格的零基 SetPaneScroll 和 GetPaneScroll。渲染控件维护自己的 TopRow 和 LeftCol,如 在自定义 VCL 网格中渲染工作簿一文所述,那份运行时状态与保存的内容互不相干。冻结与拆分几何本身用 工作表保护、页面设置与打印覆盖的表级设置来配

损坏的 Selection 记录逐字节保留

Selection 记录畸形时,HotXLS 把它的记录体当不透明字节保留,以警告形式报告诊断码 1304(xlsDiagnosticSelectionRecordInvalid),保存时把原始记录体原样写回。来路不明的导出器或手搓的 BIFF 写入器都可能产出这种记录。记录加入其窗格的组之前,读取器检查:窗格字节不超过 3、每窗格记录连续、cref 在 1 到 1,369 之间且记录体恰好 9 + cref × 6 字节、各分块活动单元格与无符号位的 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 是你自己的 TStrings;HotXLS 只填 Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

另一种选择是修复记录——钳住 irefAct 或丢掉反向区域。保留它是更稳的默认:修好的选区是一个没人选过的选区,而原始字节将不复存在。诊断消息点名失败的检查,比如"the area count does not match the record length"或"records for the same pane are not contiguous",这个码的 RecordId 恒为 $001D

行列插入会重映射每个窗格的选区

插入或删除整行整列时,经典与 XLSX 两个引擎都经同一个共享例程重映射每个窗格的选区。幸存的区域保持顺序、随它们覆盖的单元格平移,活动区域保持身份。活动区域被删时,其后第一个幸存区域成为活动区域;后面没有了,则取其前最后一个幸存区域。所有区域都被删时,组坍缩成删除边界上的单个单元格。不再落在所选区域内的活动单元格挪到该区域左上角,保存的文件里索引和坐标因此永远不会互相矛盾

加载时被标记无效的组,重映射器不碰,原始字节在编辑后照样往返。在一个窗格上改选区只重写该窗格的记录,其他窗格的记录逐字节不变。ODS 文件完全不携带窗格选区状态,因为 OpenDocument 没有等价的每窗格工作表视图结构;选区和窗格滚动位置适用于 XLS 和 XLSX

如果你的应用写出的工作簿要被人打开来查看标记的单元格、接着上次的进度继续、或读一块冻结的仪表盘,窗格感知的选区与滚动调用随 HotXLS Delphi 电子表格组件交付,对 .xls 和 .xlsx 输出行为一致