技术文章

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

一份 14 列、45 行数据的电子表格存成 ODS,结尾往往比你预想的多一个元素:<table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>。这一行描述了从第 46 行直到网格底部的每一行。把它按字面展开——那行写上 1,048,530 遍——小小的 content.xml 就变成一百万条行记录、一百万条高度条目,而且如果列上还带默认单元格样式,就是近一千五百万个带样式的空白单元格

HotXLS 如何把一个 ODS 重复行压成紧凑状态:content.xml 里 table:number-rows-repeated 为 1048530、样式 ro1 的元素,映射成一条覆盖第 46 到 1048575 行、12.81 pt 的 TXLSXRowHeightRun 记录,加上每个同样式列区间一条 StyleOverlays 条目,而不是把同一元素展开成一百万条 SetRowHeight 记录和单元格对象
重复计数、ro1 的行高和列默认样式描述了第 45 行以下的每个空白行,导入器由此只需建一条 run 记录和几条列区间叠加,完全不碰一百万个坐标

为什么单个重复行的导入代价这么高

代价来自展开,而展开恰是写导入器最顺手的方式。样式 ro1 设了 style:row-height="0.452cm",即 12.81 磅。行收尾器若对每个重复行调一次 SetRowHeight(RowIndex + i, RowHeight),就往对象模型保存的每行高度表里写 1,048,530 条。如果那张表是键式字符串列表、每次写入都要先查已有键,导入的复杂度对重复计数就是平方级的,而一百万的重复计数正是让平方级不再是理论问题的那种输入

空白单元格让情况更糟。这类文件里的每个 <table:table-column> 通常带 table:default-cell-style-name,重复行里的每个空单元格都继承它。对多数对象模型来说,带样式的空白也是单元格。每列每个重复行都实例化一个,内存账单就把高度表甩出几条街

保存一侧有镜像对称的问题。一旦模型说第 1,048,575 行有高度,一个从第 1 行走到最后有效行的写入器就会吐出一百万个 <table:table-row> 元素。便宜的导入配昂贵的保存,只是把代价挪到另一个按钮上

HotXLS 如何存重复行高

HotXLS Delphi Excel Component 把重复的 ODS 行高存成区间(run):一条 TXLSXRowHeightRun 记录,持有一个 FirstRow、一个 LastRow 和一个 Height。TXLSXWorkbook.OpenODS 结束一个重复计数为 1 的行时,跟别的代码一样调 SetRowHeight。计数更大时把整段记成一条 run,于是示例里的尾部变成第 46 到 1,048,575 行、12.81 pt。越过网格末端的重复计数在第 1,048,576 行截断,而不是被拒绝

把结果与源文件做 diff 的人会被导入器里的一条规则惊到。写 ODS 的电子表格应用倾向于给每行样式都配 style:row-height,包括从没人调过的行,并标上 style:use-optimal-row-height="true"。对一个不存任何单元格的行,自动高度就是默认高度,所以 HotXLS 不为它记高度。存了东西的行即便标着 optimal 也保留高度,否则一个因换行文本撑高的行,工作簿重开成 XLSX 时会缩回去。这个取舍是对的:丢掉真实高度产生看得见的版式变化,留着冗余高度只花一条 run

读高度走普通的 RowHeight 属性。它先查每行列表,该行没有显式条目时才退到 run;HasRowHeight 顺序相同。run 之间互不重叠,所以回退停在第一个包含该行的 run

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Spans: TXLSXAxisSpans;
  I: Integer;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // 一个末行元素重复 1,048,530 次的工作表
    Workbook.OpenODS('tail-repeat.ods');
    Sheet := Workbook.Sheets[1];  // Sheets 从 1 开始
    // 存储的行元数据:先单行条目,再 run
    Spans := Sheet.GetStoredAxisSpans(True);
    for I := 0 to High(Spans) do
      Writeln(Spans[I].FirstIndex, '..', Spans[I].LastIndex);
    // 两次读取都来自同一条 run
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // 每行条目先于 run 被查找,所以它生效
    Sheet.RowHeight[500000] := 36;
    // 清除 run 内的一行,会在其中挖出一个单行洞
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001));           // FALSE
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt');   // 仍是 run 的高度
  finally
    Workbook.Free;
  end;
end;

示例里的两处编辑特意行为不同。Sheet.RowHeight[500000] := 36 不动 run,只往每行列表加一条条目,由于它先被查询,会把 run 遮住。ClearRowHeight 不能这么干,因为被清的行必须报告没有高度,哪怕 run 仍跨着它。所以它先删掉每行条目,再重建 run 列表:清除行之外的 run 原样保留,包含它的 run 拆成前后两段,第 500,001 行本身什么都不存。GetStoredAxisSpans(True) 现在列出 46..500000 和 500002..1048575 两条独立 run,旁边是 500,000 的单行条目。ClearRowHeights 一次调用清空每行列表和全部 run

HotXLS 里的行高 run 手术:OpenODS 之后一条 run 覆盖第 46 到 1048575 行、12.81 pt,同时每行条目把第 500000 行设为 36 pt 并因 RowHeight 先查每行列表而赢得查找;对第 500001 行调 ClearRowHeight 把 run 挖成绕开洞的两段不相交区间
ClearRowHeight 保留 run 在清除行之外的部分,该行本身什么都不存;run 由此互不重叠,查找可以在首个命中处停下,第 500000 行的覆盖也不受影响

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

继承来的空白样式变成样式叠加:工作表上携带样式的矩形区域,背后没有单元格对象。读行时,导入器把重复单元格按解析到同样式的相邻列区间来走。样式看起来与 Default 完全一样的区间什么都不存。带真实格式的区间——比如居中或加粗的列默认——变成一条覆盖其全部列的行值

这个行值提交且该行重复时,HotXLS 在区间左上角实例化一个单元格,把继承样式套上去,并通过 Sheet.StyleOverlays.Add 为整个矩形——区间里的每一列、从第一个重复行到最后一个——登记六个样式索引(字体、填充、边框、数字格式、对齐、保护)。其余 1,048,529 行不再创建任何东西。Sheet.Cells.Count 保持在真正装了数据的单元格数量附近

矩形内的单元格在你代码一碰到它的那一刻就拿到叠加。打开一个尾部被加粗列默认格式化的表之后,Sheet.Cells[700000, 1].FontIndex 解析到加粗字体,尽管一行代码之前那个坐标上还不存在单元格。带格式化但空列的 XLSX 文件走同一套机制,行块单元格存储与区间样式叠加一文讲了多条叠加如何层叠与解析。ODS 导入器的区别只在叠加的来源:它自己从重复计数推导

SaveAsODS 如何把重复计数写回去

TXLSXWorkbook.SaveAsODS 把每段相同的行写成一个带 table:number-rows-repeated 的 <table:table-row>,只在行的某个属性变化处断开。表格向下延伸到最低的“有东西”的行:一个值、一个只有样式的空白单元格、一条单行高度、一条 run 的末端、一个叠加的下边缘、隐藏或分级条目,或一个有效性或条件格式区域

从第 1 行起,写入器向前看下一个边界。候选包括下一个实例化的单元格、run 的起止、任何单行条目(高度、隐藏标志、分级层级、折叠状态、手工分页符)、叠加边缘、合并区域、超链接、批注、图片,以及每个有效性和条件格式矩形的边缘。到边界为止的一切是一个元素。元素内部同样的逻辑沿列运行,所以空段变成一个带 table:number-columns-repeated 的 <table:table-cell>,样式名取叠加解析的结果

行样式是共享的。行高格式化成文本,示例里是 12.81pt,这段文本连同隐藏标志和分页标志构成自动行样式的键。三个值都相同的行指向同一个 ro<N> 样式,它只持有一条 style:row-height 属性。没有高度、可见且无分页的行,完全不配行样式

run 支撑的表在 SaveAsODS 时写出什么:第 1 到 45 行有值,逐行一个元素;直到 1048575 行的尾部以重复的 table-row 元素输出,在 run 边缘、每行覆盖和叠加边缘处断开;相同高度的行共享一个自动行样式;单元格样式经叠加解析
每个重复元素跨一段均匀的行,在下一个 run 边缘、高度条目、叠加边缘、规则区域或实例化单元格处停住,于是空尾保存成区区几个元素,而 XLSX 导出给每个覆盖到的行写一个行元素
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('tail-repeat.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // 尾部以少数几个重复行元素写出
    Workbook.SaveAsODS(Saved);
    Writeln('ODS stream: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.OpenODS(Saved);
    // 预期:覆盖、洞和两段 run 都挺过这次往返
    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);   // run 的高度
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

对上面的表,尾部拆成四段:第 46 到 499,999 行按 run 高度,第 500,000 行 36 pt,第 500,001 行没有行样式,第 500,002 到 1,048,575 行又按 run 高度。任何默认样式留下叠加的列,都可以在叠加结束处再添一个断点。尾上方的 45 行数据逐行一个元素,因为每行都有单元格

XLSX 是另一回事。SpreadsheetML 没有重复属性,一个 <row> 元素精确描述一行,所以把 run 支撑的表存成 .xlsx 会枚举 run 覆盖的行,给每一行写 ht 和 customHeight="1"。模型在内存里保持紧凑;文件多大由目标格式决定。如果工作簿以 ODS 进、也只需以 ODS 出,它永远不用付这笔按行的代价

会重新编号行的编辑

行高的第二种表示要想正确,每个挪动行的操作都必须同步挪它。TXLSXWorksheet.InsertRows 和 DeleteRows 让 run 随每行列表一起平移:编辑点之前的部分原地不动,删除窗口内的行被丢弃,其余按插入或删除的数量平移。于是横跨插入点的 run 变成两条、中间隔一段无高度的行,横跨删除点的 run 变短

procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
  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
  Writeln(Sheet.HasRowHeight(500000));   // FALSE: inserted rows have no height
  // 再删掉这两行:覆盖和洞回到原来的行号
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);  // 36.00
  Writeln(Sheet.HasRowHeight(500001));   // FALSE
  Writeln(Sheet.UsedRange.Row2);         // 包含平移后 run 的末端
end;

往返不会让尾部原样回到原地。插入把尾部 run 从 500,002..1,048,575 推到 500,004..1,048,577,而网格止于第 1,048,576 行,越过它的部分被截掉。删除再把剩下的上移两行,run 止于第 1,048,574 行:第 1,048,575 行不再有存储的高度。如果网格最后几行对你重要,插入加删除之后给它们重新设高度。插进 run 中间的行也不会继承它的高度:它们打开一段无高度的间隙,不继承 run 高度,这正是上面 HasRowHeight(500000) 打印 FALSE 的原因

其余挪行的操作遵循同一模式。TileRangeAxisMetadata 清掉整个平铺跨距上的 run,然后按偏移为每个副本各登记一次源 run。TXLSXWorksheet.CopyFrom 和 TXLSXSheets.AddCopy 给新表一份自己的 run 列表副本,所以对克隆调 ClearRowHeights 动不了原件的高度。读取一侧,UsedRange 延伸到每条 run 的首尾行,XLSX 写入器的行遍历也穿过 run,让只有高度的行照样写出

隐藏行与分级层级按行保存

行高和继承来的空白样式是仅有的两种坍缩成 run 和叠加的东西。隐藏标志、分级层级和折叠状态按行各记一条。被隐藏的重复行,或位于 <table:table-row-group> 内的重复行,这些属性每个重复行产生一条条目,保存时每条条目都是自己的边界,于是隐藏段逐行写 <table:table-row>

对由你掌控的模板,把表的隐藏和分组部分收紧到数据需要的程度就好。报表下面藏二十行是免费的。从第 46 行一路藏到网格底部,意味着加载时一百万条条目、保存时一百万个行元素,而唯一可见的差别是再也没人能滚过报表

格式在工作簿层面的内容——哪些 ODS 特性映射到对象模型的哪些部分——见打开与保存 ODS 文件的走读,大文件的其他杠杆收在大型工作簿性能之下。行元素本身连同其重复与样式属性,定义在 ODF 1.3 Part 3 的 table 章节

百万行重复是一个原生读取器必须好好建模的格式细节,因为背后没有办公套件替你消化它。HotXLS Delphi 电子表格组件页面列出它能读写的 XLS、XLSX 和 ODS 特性,以及支持的 Delphi 和 C++Builder 版本