在 Delphi 中生成精美 Excel 报表的方法,是从设计师构建的工作簿开始;财务人员在 Excel 中设计好发票布局:徽标、列标题、明细带的边框、加粗的总计行、货币格式;您的代码打开该文件,将实际数据填入设计师预留的单元格中,然后保存结果;外观设计属于他们,而数据属于您;HotXLS 是原生的 Delphi 和 C++Builder 库,无需驱动 Excel 即可读写 XLS 和 XLSX 工作簿,它为您提供了该方法所需的三个操作:通过文本搜索单元格、复制完整保留样式和公式的范围,以及插入行以便下方的一切随数据向下平移
把能在模板修改中生存下来的生成器,与在第一次修改时就崩溃的生成器区别开的唯一规则,就是绝对不要通过字面行号和列号来定位单元格;模板是其他人员编辑的文档;财务团队添加了一条税收行、提高了徽标行的高度、重排了地址块,文件格式对此完全帮不上忙:无论第 10 行是否仍是上季度的意思,BIFF 或 OOXML 的保存都会成功;将第一行明细写入硬编码的第 10 行的生成器,在有人在明细段上方插入块时,会将行项目盖印在错误的单元格上,并求和一个不再覆盖数据的总计范围;没有任何异常抛出,每次保存都返回成功,唯一的信号是客户注意到发票错误
将每个坐标锚定到占位符 Token
解决方法是让模板自己携带坐标;设计师将类似于 {{CUSTOMER}}、{{DATE}} 和 {{DETAIL_START}} 这样的 Token 写入生成器必须触及的单元格,生成器在运行时根据找到这些 Token 的位置计算出每个位置;布局修改不再起作用,因为 Token 随它所在的单元格一起移动;契约的另一半是失败规则:如果缺失了所需的 Token,则在任何客户数据到达文件之前,作业就会停止;发生漂移的模板应当产生一张失败的作业工单,而不是送达一份错误的文件
寻找 Token:FindText 和 ReplaceText
两个 HotXLS 类系列都公开了工作表级别的搜索;FindText 返回文本匹配的第一个单元格的行 and 列,并带有一个添加区分大小写的重载;ReplaceText 交换每一次出现并返回更改了多少次;这两者涵盖了您倾向于拥有的两种 Token:像客户名称这样只需定位一次并在其旁边写入的单个锚点;以及像报告日期这样应当正好出现一次,您需要进行替换并检查计数的 Token;在 XLSX 侧,以这种方式锚定自身的填充如下所示:
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
R, C: Integer;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('invoice-template.xlsx') <> 1 then
raise Exception.Create('Cannot open invoice template');
Sheet := Book.Sheets[0]; // TXLSXSheets.Items is 0-based
if not Sheet.FindText('{{CUSTOMER}}', R, C) then
raise Exception.Create('Template drift: {{CUSTOMER}} anchor missing');
Sheet.Cells[R, C].Value := 'ACME Corp';
if Sheet.ReplaceText('{{DATE}}',
FormatDateTime('yyyy-mm-dd', Date)) = 0 then
raise Exception.Create('Template drift: {{DATE}} token missing');
// detail expansion and save follow below
finally
Book.Free;
end;
end;
有两个细节很重要;首先,FindText 和 ReplaceText 匹配单元格的文本值,嵌入在公式字符串中的 Token 对它们是无形的,因此占位符 Token 属于普通单元格,绝不能放入公式内部;其次,替换计数是您的漂移检测器;应当包含正好一个 {{DATE}} Token 但报告零次替换的模板已被修改,在此时抛出异常正是将静默的布局漂移转化为可见失败的妥当做法
克隆明细行且不丢失样式或公式
明细部分的发票随数据增长;直接将数值写入示例行下方的空白行中,会丢弃设计师准备的一切:边框、数字格式、每行公式;保留所有这些的模式,是在模板中留出一个经过完整格式化的示例行并为每个项目克隆它;CopyRange 在单次调用中复制样式和公式,之后生成器只重写值单元格
const
DetailRow = 10; // the formatted sample row in the template
var
I: Integer;
begin
// Open space before the totals block first, so the SUM range
// below the detail band stretches together with the data.
if Length(Items) > 1 then
Sheet.InsertRows(DetailRow + 1, Length(Items) - 1);
for I := 0 to High(Items) do
begin
if I > 0 then // clone styles + formulas from the sample row
Sheet.CopyRange(DetailRow, 1, DetailRow, 5, DetailRow + I, 1);
Sheet.Cells[DetailRow + I, 1].Value := Items[I].Name;
Sheet.Cells[DetailRow + I, 2].Value := Items[I].Qty;
Sheet.Cells[DetailRow + I, 3].Value := Items[I].UnitPrice;
Sheet.Cells[DetailRow + I, 4].Formula :=
Format('B%d*C%d', [DetailRow + I, DetailRow + I]); // no '=' prefix
end;
end;
仔细观察公式赋值;XLSX 的 Formula 属性接受没有前导等号的表达式,而 XLS 接口期望通过 Value 赋值 '=B10*C10';在类家族之间,混淆这两种约定是最常见的移植错误,而且失败时毫无提示:单元格仅保存 Excel 显示为文本的字面字符串;如果模板用合并的标题行修饰明细带,请记住,只有合并区域的左上角单元格才承载值;布局驱动的报表模板中合并单元格的配套文章中的布局规则解释了为什么合并区域完全属于数据带之外
InsertRows 移动了什么,留下了什么
在总计块前插入行是保持 SUM 范围随明细部分增长而延伸的原因;在 XLSX 侧,InsertRows 会将大量依赖结构与单元格一起向下平移:合并范围、行高、超链接、批注、冻结窗格、自动筛选范围、条件格式、数据验证、表、已定义名称,以及图像和图表锚点;该列表中有一个边界值得铭记:公式重写仅触及同一工作表内的引用;汇总表上指向已平移区域的公式会保持其旧坐标,静默读取错误的单元格,这就是为什么跨工作表拉取的总计更安全地用工作簿级别的名称来表示;定义名称和跨工作表公式的配套文章介绍了这种模式
传统的 XLS 格式将界线划在更艰难的地方;HotXLS 将 BIFF 文件中的数据透视表、查询表和外部数据连接保留为原始字节块;它们在打开和保存后保持不变,但未进行建模,因此行插入绝不会触及它们;在展开的明细块下方放置了数据透视表的模板在保存时完全没有警告,而数据透视表源矩形偏离了数据;解决办法是结构性的,而不是防御性的:在生成器绝不插入的工作表上保留数据透视表和查询内容,陈旧就不会发生
在交付前重新计算,或者搞清楚为何跳过了它
HotXLS 在 SaveAs 期间不会评估公式;当人们打开文件时,Excel 会重新计算一切(如果需要引导此行为,XLS 接口会公开 CalculationMode 和 RecalcOnSave),因此发送至人工收件箱的报表无需您的额外操作;一旦工作簿供给另一个程序,情况就会发生变化;CSV 导出将公式写为其字面文本而从不计算,任何信任缓存值的下游解析器都会读取陈旧的数字或空白;对于这些路径,在服务器上使用 Calculate 进行计算,它会对已加载的工作簿评估任意表达式并返回结果:
var
Total: Variant;
LastDetail: Integer;
begin
LastDetail := DetailRow + Length(Items) - 1;
Total := Book.Calculate(Format('SUM(Invoice!D%d:D%d)',
[DetailRow, LastDetail]));
if (not VarIsNumeric(Total)) or
(Abs(Total - ExpectedTotal) > 0.005) then
raise Exception.Create('Invoice total does not match the order record');
if Book.SaveAs('invoice-2026-0611.xlsx') <> 1 then
raise Exception.Create('Save failed: check output path and permissions');
end;
在保存之前对照订单记录检查计算的总计是廉价且有回报的保险;它把错误的发票变成了失败的任务;操作员可以在几秒钟内重试失败的任务,而在客户邮箱中已经存在的错误发票会让客户经理付出道歉和修正的代价
两个类家族,一个算法
相同的逻辑可以在不同格式之间移植,但不是相同的代码;用于传统 .xls 的 TXLSWorkbook 是基于接口的且被引用计数(工作表索引基于 1),您绝不能手动释放它;用于 .xlsx 的 TXLSXWorkbook 是一个普通的对象(工作表索引基于 0),您必须在 try..finally 中进行释放,并且其公式约定如上所示; FindText、ReplaceText、CopyRange 和 InsertRows 在两边都存在,因此“定位-克隆-重新计算”的形态能干净地移植过来;实际的建议是每条管道仅使用一种格式,或者将两个对象生命周期隐藏在您自己的薄适配器层后面,而不是在生成器中散落着差异代码
对于这种模式产生的报表类型,大小很少会成为问题;在当前的硬件上,将带有样式的行克隆几千次是不费吹灰之力的;只有当明细带达到六位数的行数时,保存路径才会成为瓶颈,此时设置 StreamingWrite 将工作表 XML 直接发送到输出包中,而不是缓冲它;服务器批处理的流式写入文章介绍了何时值得进行这种权衡;图表的行为与布局的其余部分相同:在 XLSX 侧,当 InsertRows 在图表上方运行时,图表锚点及其系列引用都会移动,因此总计行下方的图表仍绑定到正确的数据;而在 XLS 侧,图表位于其自有的图表表上(如同数据透视表一样),且绝不移动;这是在展开的表之外保持呈现表的又一个论据
这种“定位-克隆-重新计算”的方法可以让设计师拥有工作簿的外观,而让您的代码控制其内容,这正是让生成的 Excel 输出值得维护的关键所在;此处所示的搜索、复制和插入调用,以及用于预交付总额检查的公式引擎,均随适用于 Delphi 和 C++Builder 的 HotXLS Component 一起提供