技术文章

Delphi 中 HotXLS 把 ODS 重复行存成行高区间

带 table:number-rows-repeated 和行高的 ODS 行,HotXLS Delphi Component 只用一条 TXLSXRowHeightRun 记录来存——首行、末行、一个高度——而不是每个重复行一条高度条目;这些行继承的空白单元格样式也被折叠成一份区间样式叠加。这就是 HotXLS 2.382.2 能在 0.02 秒内打开一个尾部重复 1,048,530 个空白行的电子表格、而 2.382.1 直接超时的全部原因;也是同一个文件保存回 ODS 时重复计数完好、而不是变成一百万条字面行的原因

说的这个文件本身很普通。LibreOffice Calc 写出一张十四列、45 行数据的工作表,然后用一个元素描述下面的一切:<table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>。样式 ro1 设了 style:row-height="0.452cm",而每个 <table:table-column> 都带一个 table:default-cell-style-name,这一段里每个空白单元格都继承它。整个 content.xml 只有 103 KB。文件本身没有任何地方写着「昂贵」;昂贵完全是我们自己造成的

HotXLS 如何把一个 ODS 重复行变成紧凑状态:content.xml 里带 table:number-rows-repeated 1048530 和样式 ro1 的元素,映射成一条覆盖第 46 到 1048575 行、12.81 pt 的 TXLSXRowHeightRun 记录,外加每列一条 StyleOverlays 条目;而 2.382.1 把同一个元素展开成一百万条 SetRowHeight 条目和单元格对象
重复计数、ro1 的行高和列默认样式已经描述了第 45 行以下的每一个空白行,所以导入器只建一条区间记录和每列一份叠加,根本不用碰一百万个坐标

为什么一个重复行会让 ODS 导入超时?

因为导入器以前会把它展开。在 2.382.1 里,行收尾逻辑对每个重复行都循环一次 SetRowHeight(RowIndex + i, RowHeight),把每个高度写进一个以行号为键的 Name=Value 字符串列表。每次插入都会对列表里已有的全部内容跑一次 IndexOfName 查找,于是一百万个高度就是一百万次线性扫描——HXLS-005 针对的正是这个二次复杂度的列表查找。与此同时,OdsCommitRow 会为每个继承了样式的列物化一个单元格对象,而且是在每一个重复行上,因为带样式的空白单元格也算一个单元格

保存侧有它自己的版本。那个 LibreOffice 文件在大段重复之后还跟着一行 ro1,于是带样式的最高行落在工作表最底部,而 OdsBuildTableXml 会一路走到那里,一行一行地输出 <table:table-row> 元素。即使某个工作簿导入得很便宜,写出也会很贵。只修导入不修导出,只是把超时挪了个地方,而不是消除它

HotXLS 里的行高区间是什么?

区间是能描述「第 46 行到第 1,048,575 行都是 12.81 点高」的最小东西,而且不必把这句话说 1,048,530 遍。TXLSXRowHeightRun 是包含 FirstRow、LastRow 和 Height 的记录;TXLSXRowHeightRuns 是它们的动态数组,每个 TXLSXWorksheet 在 FRowHeightRuns 里保留一份,与原有的逐行高度列表并排。在 ODS 导入时,行收尾逻辑现在按重复计数分支:计数为 1 仍然调用 SetRowHeight,更大的值则对整个跨度调用一次 XlsxAssignRowHeightRun。跨度会被夹到 XlsxMaxRow,也就是 1,048,576,所以超出工作表的重复计数是被截断而不是被拒绝

XlsxAssignRowHeightRun 是这个数组唯一的写入者,而且它从构造上保证各区间互不重叠。给定一个新区间,它会复制所有完全在它之外的既有区间、把与之重叠的区间拆成前面那段和后面那段,然后当 Present 为真时追加新区间——Present 为假时什么都不追加,ClearRowHeight 就是靠这个在区间上打出一个单行的洞。由此得到两点。数组里永远不会出现重叠区间,所以查找命中第一个就能停。而且数组从不原地修改;每次调用都新建一份拷贝,在这样的规模下这点开销可以忽略,却消掉了一整类别名 bug

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // 一张尾部行在一个行样式下重复 1,048,530 次的工作表
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // 两次读取都通过同一个区间解析;没有展开任何东西
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // 单行覆盖会遮住这个区间,但不会把它拆开
    Sheet.RowHeight[500000] := 36;
    // 清掉区间内的一行,会把区间切成两段
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // 仍然是区间高度
  finally
    Workbook.Free;
  end;
end;

查找顺序是值得记住的那部分。TXLSXWorksheet.GetRowHeight 先查逐行列表,只有当该行没有显式条目时才去查区间;HasRowHeight 同理。所以 Sheet.RowHeight[500000] := 36 根本不碰区间——它往逐行列表里加一条条目,而这条条目因为先被查到而胜出。ClearRowHeight 相反:它删掉任何逐行条目,然后以 Present = False 调用 XlsxAssignRowHeightRun,因为被清掉的行必须读作「没有高度」,即使有区间盖着它。ClearRowHeights 则一次性清空两个结构

HotXLS 里的行高区间手术:OpenODS 之后,一条区间以 12.81 pt 覆盖第 46 到 1048575 行;一条逐行条目把第 500000 行设成 36 pt,并因为 GetRowHeight 先查逐行列表而在查找中胜出;而对第 500001 行调用 ClearRowHeight 会把区间在洞的两侧拆成互不相交的两段
XlsxAssignRowHeightRun 复制被清区间之外的那些片段,对区间本身什么都不追加,所以区间从构造上保持互不相交,查找命中第一个即可停止,而第 500000 行上的覆盖丝毫未动

继承来的空白单元格样式去哪了?

进了每列一份的区间样式叠加,而不是单元格对象。OdsCommitRow 对每个列值判断它是不是紧凑空白:所在行重复次数大于一、单元格没有值、没有公式、也没有富文本。对紧凑空白,它只在区间的第一行创建真正的单元格,把继承的样式应用上去,然后把这同样的六个样式索引——字体、填充、边框、数字格式、对齐、保护——注册成一条 StyleOverlays.Add,覆盖该列从第二行到区间末尾。第一行之后的行在物化循环里被整个跳过

回归测试把这个形状落到了实处。打开一张第二行在一个加粗的列默认样式下重复 1,048,575 次的工作表之后,断言 Sheet.Cells.Count 小于 10,而 Sheet.Cells[700000, 1].FontIndex 仍然解析到那个加粗字体——坐标一被碰到,叠加就把样式供上。这正是 XLSX 那边让「有格式但为空的列」不至于花掉一百万个单元格的同一套机制;叠加如何分层与解析,行块单元格存储与区间样式叠加那篇笔记有讲。这里的新意在于:ODS 导入器自己根据重复计数把它们造出来,而不是等应用去格式化某个区域

SaveAsODS 怎么把重复计数写回去?

只在真正有变化的地方才切开工作表的空白尾部。OdsBuildTableXml 现在跟踪两个边界:contentMaxRow,最后一行真正带值、公式、超链接或手动分行;以及 maxRow,它还额外延伸到仅有样式的空白单元格、单行高度、每条区间的 LastRow 和每个叠加的下边缘。仅有样式的空白单元格不再算内容——把它排除掉的是 TXLSXCells.IsStyleOnlyBlank——于是那个 LibreOffice 文件末尾带样式的行不再把内容边界一路拖到工作表底部

contentMaxRow 以上的行完全照旧逐行写出。在它以下,写入器计算 nextRow,取这几者中的最小值:下一条区间的 FirstRow、当前区间的 LastRow + 1、下一条单行高度条目、下一个叠加边缘,以及下一个被物化的单元格。然后把从当前行到 nextRow - 1 的整段输出为一个 <table:table-row>,table:number-rows-repeated 设为差值,每列带一个 <table:table-cell/>,当叠加覆盖该列时用叠加解析出的样式名。行样式本身来自 TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec),它现在把高度文本——比如 12.81pt——和隐藏、分页标志一起折进去重键,于是区间里的每一行共用同一个 ro<N> 样式,属性里只有一条 style:row-height

SaveAsODS 给一张由区间支撑的工作表写出什么:contentMaxRow 停在值结束的第 45 行,而 maxRow 一直延伸穿过行高区间和它的覆盖;边界以上的行逐行写出,尾部则输出成若干重复的 table-row 元素,其行样式来自 RowStyleFor,单元格样式通过叠加解析
每个重复元素只覆盖一段均匀区间,并在下一个区间边缘、高度条目、叠加边缘或物化单元格处停下;所以没有数据验证的工作表只用几个元素就能保存,而带着数据验证或导出 XLSX 时仍按行付费
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('conditional-formatting.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // 空白尾部写成几个重复行,而不是一百万行
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // 覆盖、洞和区间都挺过了往返
    Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2);   // 36.00
    Writeln(Reopened.Sheets[1].HasRowHeight(500001));    // False
    Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2);   // 区间高度
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

钉住这一点的测试断言:对一张高度区间横跨 1,048,575 行、中间还打了一个覆盖和一个洞的工作表,保存出来的流小于 64 KB。这个数字旁边该放两条诚实的边界。第一,只要工作表带任何数据验证,contentMaxRow 就会被设成 maxRow,于是数据验证会让那张表的尾部压缩失效,又变成逐行写出。第二,XLSX 没有 repeat 属性——SpreadsheetML 的一个 <row> 只描述一行——所以把由区间支撑的工作表导出成 .xlsx 时,会枚举区间覆盖的那些行,并在每一行上写一个 ht 属性。模型在内存里保持紧凑;文件长什么样由文件格式说了算

每一次会重编行号的操作现在欠区间什么?

欠维护。行元数据的新表示只有在下面这一点成立时才是对的:每一个改变行号的操作都要带着它一起移动,就像它旁边的逐行列表那样;这次提交碰了每一个这类操作。InsertRows 和 DeleteRows 走 XlsxShiftRowHeightRuns,它重建数组的方式是:保留每条区间落在编辑点之前的部分、丢掉落在删除窗口里的部分、把剩下的按增量平移后重新加回去——于是跨过插入点的区间变成中间有空隙的两条,跨过删除点的区间变短。TileRangeAxisMetadata 会先清掉整个平铺跨度上的区间,然后按偏移量为每份拷贝重新注册每条源区间。TXLSXWorksheet.CopyFrom 和 TXLSXSheets.AddCopy 取的是数组的 Copy() 而不是直接赋值,这也是为什么测试能在克隆体上清掉所有高度之后,仍发现原工作表在第 1,048,576 行上完好无损

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // 在 500000 处插入两行:覆盖移到 500002,洞移到 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // 再把它们删掉:一切移回原处
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // 把第 2..4 行沿工作表向下平铺两次;区间高度跟着每份拷贝走
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // 区间高度
end;

读取侧的边界有同样的义务。GetUsedRange 会把下边缘顶到每条区间的 FirstRow 和 LastRow;BuildRowMajorCellOrder 则把含元数据的最大行延伸到每条区间上,让 XLSX 写入器仍然会访问只有高度的行。如果你哪天在 HotXLS 对象模型之上加了自己的按行索引结构,清单就是这些:插入、删除、平铺、复制、已用区域,以及每一个序列化器。漏掉一个,失败都是静默的——高度会按插入次数漂移,而且什么都不抛

哪些仍然按行展开,现在的数字是什么样

隐藏标志、分级显示级别和折叠状态仍然会展开。行收尾逻辑对每个重复行都循环调用 SetRowHidden 和 SetRowOutlineLevel,所以一张把一百万行尾部隐藏起来、或者把它嵌进 table:table-row-group 的工作表,会为这些属性各自付出逐行条目。2.382.2 的改动范围限定在 HXLS-005 真正量过的那两样东西——行高和继承的空白样式;如果哪天有文件提要求,同样的区间手法也能用到其他属性上。ODS reader 也不理会 style:use-optimal-row-height;一个既写着「optimal」、又给出高度的行样式,就按给的那个高度导入

对照语料库,conditional-formatting.ods 现在完成「打开、断言、保存、重新打开、再断言」这一整轮,在 Win32 上用 0.178 秒、Win64 上 0.158 秒,其中打开阶段本身 0.020 秒——而以前它会把 60 秒预算耗光。ODS 文件的打开与保存那篇走查描述了这套流程经过的工作簿级接口,大工作簿性能那篇介绍了应对大文件的更多手段;ODF 的行元素本身,连同它的 repeat 和 style 属性,定义在 ODF 1.3 Part 3 §9.1.4

HotXLS 用原生 Delphi 和 C++Builder 代码读写 XLS、XLSX 和 ODS,不需要安装 Excel 或 LibreOffice——正因如此,上百万行的重复是库必须好好建模的东西,而不是能甩给外部进程的事。HotXLS Delphi 电子表格组件页面列出了支持的格式与 RAD Studio 版本