技术文章

HotXLS 在 Delphi 中的合并单元格与报表模板布局

遍历一份刚打开的报表模板的单元格,一个合并过的标题会表现得像在漏水。你读 A1,得到“Quarterly Statement”;你读 B1F1,它们明明就压在同一条横幅底下,却什么都读不到。往 C1 写个值去修表头,屏幕上永远不会出现。网格并没有弄丢你的数据。它做的恰恰是合并的本意:在 XLS 和 XLSX 中,一个合并矩形只呈现其中一个单元格 —— 左上角锚点 —— 的内容,其余部分被当作被覆盖的空间,能存值却永不显示。Excel 用户靠反复试错吸收这条规则。而报表生成器必须把它编码成一条规则,因为在生成的代码里,症状是一片空白区域,且没有任何异常可供追溯。HotXLS 是一个原生 Object Pascal 库,从 Delphi 与 C++Builder 读写两种 Excel 格式,它把合并表暴露得足够显式,让你可以照着规则编程,而不是在一张支持工单里重新发现它

一个值,一个锚点

合并是叠加在一张形状不变的网格之上的显示指令。每个被覆盖的单元格在文件里仍然作为自己的槽位存在;合并记录只是告诉消费方把锚点的内容铺满整个矩形。这一区别驱动出三种行为,值得在写任何布局代码之前先内化。读一个被覆盖的单元格返回的是它自己存的值,而对你搭出来的横幅来说通常是空的,所以任何检视合并标题的代码都必须先解析出锚点再读。写入被覆盖的单元格在文件层面是成功的,却哪儿都不显示,这就是开头那个隐形表头陷阱。而取消一个区域的合并会把一直躺在下面的东西暴露出来,因此写进覆盖空间里的一个游离值,会在某天有人解散合并时变成一个可见的缺陷

HotXLS 合并横幅示意图:被覆盖的单元格保留各自的槽位,而在 Delphi 电子表格中读取时会解析到锚点 A1
HotXLS 把每个被覆盖的单元格都保留为真实槽位,只重绘锚点,因此读取经由 A1 解析,而写进覆盖空间的内容在取消合并前始终不可见

在 XLSX 一侧,那张表是一等对象。Sheet.MergedCells 带有 Add('A1:C1')FindAt(Row, Col)DeleteAtItems,而你最常伸手去拿的一个调用是 FindAt:给它任意一个坐标,它返回覆盖该单元格的合并区域,若该单元格独立存在则返回 nil。正确处理合并的两半 —— 安全读取和写入守卫 —— 都建立在这一次查找上,两者稍后都会出场

两套门面,两种合并习惯用法

HotXLS 把经典的 BIFF8 .xls 引擎与 OOXML .xlsx 引擎保持为彼此独立的对象模型,而它们表达合并的方式不同,因为它们出自不同的传统。XLS 门面沿用 Excel COM 的习惯用法:你从一个双参数的索引属性取出一个区域,再用一个 OleVariant 调用 Merge,该变体的取值决定你最终得到的几何形状

var
  Book: IXLSWorkbook;   // 接口引用计数:不要手动 Free
  Sh: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  Sh := Book.Sheets[1];                 // XLS 工作表集合从 1 开始
  Sh.Range['A1', 'F1'].Merge(False);    // False = 合并成一个整块
  Sh.Cells.Item[1, 1].Value := 'Quarterly Statement';
  Sh.Range['A3', 'F4'].Merge(True);     // True = 跨列合并:每行各一个合并
  Book.SaveAs('layout.xls');
end;

Merge 的那个参数是人们最常搞错的地方。在一个两行的区域上,Merge(True) 产生两个彼此独立的单行合并,也就是 Excel 的“跨列合并”,正是你为一条应当保持各行可分离的堆叠表头带所想要的。Merge(False) 则把整个矩形熔成单个块。该区域还以状态标志形式报告 MergeCells,通过 MergeArea 返回所属区域,并用 Unmerge 解散自己。XLSX 门面以不同的名字暴露同样的操作:Sheet.MergeCells(Row1, Col1, Row2, Col2) 接受整数边界,TXLSXRange.Merge 接受等价的 Across 变体,而结果保存在 MergedCells 集合中

一份随数据生长的模板

真实的报表模板不是固定网格。表头和合计是固定的,但夹在中间的明细区要伸展到查询返回多少就多长。经得起考验的模式是:在模板里保留一行样式完整的明细行,按记录数各克隆一次,然后在合计块之前撑开一段空隙,让锚定在下方的一切整体下移而不丢格式

HotXLS 报表模板在 Delphi 中生长:带样式的明细行按记录克隆,InsertRows 撑开空隙让合计块连同完好的合并一起下移
克隆带样式的明细行会把样式和公式带进每一份副本,随后 InsertRows 让合计带连同合并与格式完好地下移
Sheet.Range['A1:F1'].Merge;
Sheet.Cells[1, 1].Value := 'INVOICE #2026-0611';    // 值写入锚点 A1
Sheet.RowHeight[1] := 28;
TitleFont := Book.Fonts.Add('Calibri', 16, True, False);
Sheet.Cells[1, 1].FontIndex := TitleFont + 1;        // 池索引从 0 起,单元格一侧从 1 起

// 第 5 行是带样式的明细模板行
for I := 0 to ItemCount - 1 do
  Sheet.CopyRange(5, 1, 5, 6, 6 + I, 1);             // 样式和公式会随之带过去

// 在合计块上方撑开空隙;下方内容整体下移
Sheet.InsertRows(6 + ItemCount, 1);
Sheet.Range['A1:F1'].SetBorders(xlsxEdgeOutline, xlsxBorderMedium);

有两行值得再看一眼。字体赋值里藏着一处会悄悄咬人的差一错误:Fonts.Add 返回的是从 0 起的池位置,而单元格存的是从 1 起的字体引用,其中 0 表示默认字体,所以漏掉那个 + 1 不会引发任何报错,只会把你的标题排成错的字体。另一行是 CopyRange,它把格式和公式连同值一起搬走。这正是要克隆一行手工搭好的模板行、而不是在代码里重建其外观的全部理由。设计师在模板里一次性拥有外观;生成器只负责把数据倒进它的副本

当可复用的版式住在它自己的工作簿里时 —— 比如一张在多份报表间共享的页眉页脚带的表 —— 这种分工还能进一步扩展。CopyRangeTo 跨工作表边界执行同样的克隆,接受一个目标工作表加目标坐标,因此生成器可以保留一张纯净的模板表,把它的各个区域按任务所需盖印到任意多张输出表上。另一种做法 —— 就地修改模板再试图事后恢复 —— 属于那种一直好用、直到某次运行中途中止的那天为止的东西

InsertRows 会搬动什么,又不会搬动什么

模板生长这一模式之所以成立,是因为 XLSX 的 InsertRows 是一次结构性编辑,而不是单元格挪位。当它撑开空隙时,它会把插入点下方的合并区域、行高、超链接、批注、冻结窗格、自动筛选范围、条件格式、数据验证、表、定义名称、图片锚点和图表锚点统统重新定位,而不只是搬动单元格的值。正因如此,合计块到达新行时,其合并与数字格式完好无损,而不是被剥得精光

它有两条已载明的限制,是你该围绕它们做设计的地方。公式调整的作用域限于正在编辑的那张表:该表内部的引用会被重写,另一张表上指向位移区域的公式也会被重写,但这种调整只跟随那些以被编辑表为目标的引用,因此任何跨工作簿的引用方案都值得单独审计,而不是盲目信任。第二条限制更锋利,且在 XLS 一侧。数据透视表是以原样保留的记录挺过开-存周期的,而不是 HotXLS 能搬动的建模对象,所以插入行不会重新定位一个透视表的占位区域。凡是你为 .xls 格式搭的模板,都应把透视区域摆得远离任何会生长的带

拒绝把数据写进布局空间

真正会捅到生产环境的合并单元格故障不是那个外观上的。它是结构性的:一行明细漂进了一条合并的布局带,它的值落进被覆盖的单元格并变得不可见,于是列合计悄悄不再等于任何读表人所能看到的数字。因为 FindAt 能对任意坐标回答“谁覆盖了它”,生成器就能在这次写入即将发生的那一刻拒绝它,而不是发出一份悄悄少算的报表

// 拒绝把明细数据写进合并的布局区域
if Sheet.MergedCells.FindAt(Row, 1) <> nil then
  raise Exception.CreateFmt('row %d overlaps a merged layout region', [Row]);
Sheet.Cells[Row, 1].Value := Detail.Description;

凡是用户之后会对输出做排序或筛选的地方,都该有同一道边界检查。内部含合并的区域无法干净地排序,因为排序会独立搬动各行,而跨行的合并没有唯一一行可以随行而动;Excel 的回应要么是报错,要么是把版式搅乱。让报表保持正确的纪律是地理性的。把合并限制在标题带、章节分隔和签名块里,让表的表格化中段保持扁平。模板报表生成一文把这种布局与数据的分离展开成一套完整的占位符驱动工作流,而条件格式与富文本一文讲的是如何给那条扁平数据带做样式

合并在导出途中如何降级

合并是一个工作簿层面的概念,而每种面向文本的导出格式对它的尊重程度各不相同。事先知道这三种行为能省下一个 QA 周期。HTML 导出忠实再现合并,在单张表格上输出 colspanrowspan,因此面向浏览器的报表保得住它的分带外观。RTF 导出则完全不跨列:锚点文字落在它自己的单元格里,合并剩下的宽度输出成空单元格,于是一个宽标题在字处理软件里看上去被硬挤到了左边。CSV 没有合并这个概念,所以锚点值占一个字段,每个被覆盖的单元格都输出成空字段。对同时还要喂给分隔符导出的工作簿,结论是把任何承重的内容都挪出合并几何之外;CSV、TSV 与 HTML 导出一文逐个格式做了详解

HotXLS 的合并标题从 Delphi 导出:到 HTML 时带 colspan 与 rowspan,到 RTF 时没有跨列,到 CSV 时被拍平成字段
同一个合并标题在 HTML 导出中靠 colspan 与 rowspan 存活,在 RTF 中降级成孤零零锁在左边的一个单元格,在 CSV 中被拍平成一个值加若干空字段

给拿这件事跟文件体积做权衡的人一句宽心话:在报表这个量级上,合并几乎不花什么成本。合并表相对单元格数据小得可以忽略,而读取被覆盖的单元格走的仍然是 FindAt 而不是扫描。大型工作簿的性能压力来自别处,主要是样式池的膨胀和保存路径占住的内存,这些由大型工作簿性能一文直接接手。两套合并 API、结构性编辑操作以及模板演示,都随 HotXLS Delphi Component 一同发布