对账工具找出与总账不一致的 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 个区域的选区都是错误答案
写入侧,窗格优先的 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,两个调用都不会在半路创建窗格、选区或单元格
工作表还保留较旧的双参数 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;
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 输出行为一致