技术文章

Delphi 下 HotXLS 合并单元格与布局驱动的报表模板

迭代新打开的报表模板单元格,合并的标题表现得像泄露了一样;您读取 A1,得到的是“Quarterly Statement”(季度报表);读取明显位于同一横幅下的 B1F1,得到的却是什么也没有;将数值写入 C1 以修复表头,但它永远不会显示在屏幕上;网格并没有丢失您的数据,它所做的恰挂钩是合并的本意:在 XLS 和 XLSX 中,合并后的矩形渲染的是单个单元格(即左上角的锚点单元格)的内容,并把其余部分视为覆盖区域(即使保存了数值,但也绝不展示出来);Excel 用户是通过反复试验来理解这一点的;而报表生成器必须将其编码为规则,因为在生成的代码中,这种症状是无异常可追溯的空白区域;HotXLS 是原生的 Object Pascal 库,可让您从 Delphi 和 C++Builder 中读写这两种 Excel 格式,它显式地展现了合并表,因而您可以针对规则进行编程,而不是在售后工单中重新发现它

一个值,一个锚点

合并是分层在没有改变形状的网格之上的显示指令;文件中的每个被覆盖单元格都仍以其自己的槽位存在,合并记录仅仅是告诉消费者跨越矩形绘制锚点的内容;在编写任何布局代码之前,这种区别驱动了三种值得内化(Internalize)的行为:读取被覆盖单元格返回其自身存储的值,这对于您构建的横幅通常是空的,因此任何检查合并标题的代码都必须解析并读取锚点;向被覆盖的单元格写入在文件级别成功,但无处显现,这正是开头提到的无形表头陷阱;取消合并区域会暴露出一直坐落在它下方的任何东西,因此一旦有人解除了合并,写入被覆盖空间的杂乱值就会变成可见的缺陷

在 XLSX 侧,该表是一等公民; Sheet.MergedCells 携带了 Add('A1:C1')FindAt(Row, Col)DeleteAtItems,您最常触及的调用是 FindAt:递给它任何坐标,它会返回覆盖该单元格的合并区域,如果单元格是独立的则返回 nil;这种单次查找是正确合并处理的两个部分(安全读取和写入防卫)的基础,两者都将在稍后出现

两个接口,两种合并写法

递交给 Merge 的参数是人们经常出错的部分;在跨越两行的范围上,Merge(True) 产生两个独立的单行合并,这正是 Excel 的“跨列合并”,也是您对于应当保持行可分的堆叠表头带所想要的;Merge(False) 将整个矩形融合为单个块;范围还将 MergeCells 报告为状态标志,通过 MergeArea 返回包含它的区域,并用 Unmerge 自身解体;XLSX 接口在不同的名称下公开相同的操作:Sheet.MergeCells(Row1, Col1, Row2, Col2) 接受整数边界,TXLSXRange.Merge 接受等效的 Across 变体,而 MergedCells 集合持有结果

随数据增长的模板

Sheet.Range['A1:F1'].Merge;
Sheet.Cells[1, 1].Value := 'INVOICE #2026-0611';    // value goes to the anchor, A1
Sheet.RowHeight[1] := 28;
TitleFont := Book.Fonts.Add('Calibri', 16, True, False);
Sheet.Cells[1, 1].FontIndex := TitleFont + 1;        // pool index 0-based, cell side 1-based

// row 5 is the styled detail template line
for I := 0 to ItemCount - 1 do
  Sheet.CopyRange(5, 1, 5, 6, 6 + I, 1);             // styles and formulas travel with it

// open a gap above the totals block; content below shifts down
Sheet.InsertRows(6 + ItemCount, 1);
Sheet.Range['A1:F1'].SetBorders(xlsxEdgeOutline, xlsxBorderMedium);

有两行代码值得回味;字体赋值中包含一个悄然产生症状的差一错误(Off-by-one):Fonts.Add 递回基于 0 的池位置,而单元格存储基于 1 的字体引用(其中 0 表示默认字体),因此漏掉 + 1 不会引发任何异常,它只是在错误的字形中样式化您的标题;另一行代码是 CopyRange,它将格式化和公式与数值一起移动;这正是克隆手工构建的模板行而不是在代码中重建其外观的全部理由;设计师在模板中拥有一外观外观,生成器只会将数据灌入到它的副本中

当可重用的布局活在它自己的工作簿中(比如在报表之间共享的页眉和页脚段工作表)时,这种拆分能进一步扩展;CopyRangeTo 跨工作表边界执行相同的克隆,接受目标工作表和目标坐标,因此生成器可以保留一份原始的模板表,并将其区域印刻到任务需要的尽可能多的输出表中;另一种做法(在原处变异模板并试图在之后恢复它)属于能运行,直到有一天中途流产的那种代码

InsertRows 移动了什么,以及它没有移动什么

增长模板的模式之所以起作用,仅仅是因为 XLSX 的 InsertRows 是结构性的编辑,而不仅是单元格的洗牌;当它打开一个空隙时,它会重新定位位于插入点下方的合并区域、行高、超链接、批注、冻结窗格、自动筛选范围、条件格式、数据验证、表、已定义名称、图像锚点和图表锚点,而不仅是单元格的值;这正是让总计块到达其新行时仍保留了它的合并和数字格式,而不是剥落一空的原因

已记录的两个限制是需要在设计时避开的;公式调整的范围仅限于被编辑的工作表:重写该工作表内部的引用,其他工作表中指向平移区域的公式也会被重写,但这种调整仅仅是跟随针对被编辑表的引用,因此任何跨工作簿引用方案都值得自行进行审计,而不是盲目信任;第二个限制更加尖锐,它位于 XLS 侧;数据透视表作为原始保留记录在打开-保存周期中幸存下来,而不是作为 HotXLS 可以移动的建模对象,因此插入行并不能重新定位透视表的足迹;您针对 .xls 格式构建的任何模板都应该将其透视表区域停泊在远离任何增长的区域中

拒绝将数据写入布局空间

真正流入生产的合并单元格失效并不是外观上的,而是结构上的:明细行漂移到了合并的布局带中,其值落在被覆盖的单元格中并变成无形,而列总计静默地不再匹配阅读该工作表的任何人所能看到的值;因为 FindAt 回答了关于任何坐标的覆盖区域问题,所以生成器可以在写入即将发生的那一刻拒绝该写入,而不是寄送出一份静默少算的报表

// refuse to write detail data into a merged layout region
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 导出文章详细介绍了每种格式

为对照文件大小权衡这一点的人提供一个保证:在报表尺度上,合并几乎不费任何开销;合并表在单元格数据旁是微不足道的,并且读取被覆盖单元格仍通过 FindAt 进行,而不是通过扫描;大型工作簿上的性能压力来自其他地方,主要是样式池的增长和保存路径持有的内存,大型工作簿性能文章直接谈到了这一点;两个合并 API、结构性编辑操作以及模板演示均随 HotXLS Component 一起提供