技术文章

Delphi 中的 HotXLS 图表、图像与绘图对象

任何漂浮在工作表网格上方的物体(图表、徽标、图章、标注框)都是绘图对象,而绘图对象由两件事物定义:它是什么,以及它被锚定在何处;锚点是人们最容易弄错的部分;图表并不活在单元格中,它位于一个固定在特定行列范围内的矩形中,且它所绘制的数据是一组独立的 A1 引用,锚点对此一无所知;移动框架,绘图保持原样;在其下方插入行,框架就会随之向下平移;理清这两个坐标系统,是让绘图代码正常工作的关键所在

HotXLS 是原生的 Object Pascal 库,无需 Excel 自动化即可读写 XLS 和 XLSX,它携带两个独立的绘图模型,因为这两种文件格式存储绘图的方式不同;BIFF8 .xls 格式将图表保留在它们自己的专用图表工作表上,并将漂浮的形状保留在附加到工作表的 OfficeArt 流中;OOXML .xlsx 格式可以将图表直接嵌入在网格中(锚定到单元格矩形),同时还支持相同种类的悬浮图片和形状;对象模型镜像了这一拆分,而值得记录的失效案例全都是由于将一种格式的规则应用于另一种格式所致

哪种容器可以容纳什么

在编写任何图表代码前,必须先选择容器,因为这两者之间可用的对象类型有所不同:

  • XLS (BIFF8):图表位于通过 Sheets 集合上的 AddChartSheet 创建的专用图表工作表上;图片、文本框、矩形、椭圆和折线是 OfficeArt 形状,通过工作表的 Shapes 集合进行管理;没有在普通的工作表网格内部嵌入图表的 API
  • XLSX (OOXML):图表可以使用 TXLSXWorksheet.AddChart 直接嵌入到工作表中(锚定到单元格矩形),或者使用 TXLSXWorkbook.AddChartSheet 放置在专用的图表工作表上;图像通过 AddImageAddImageFromFile 放入,悬浮标签则使用 AddTextBox 放入

因此,“在数字旁边带有图表的仪表板工作表”这样的需求实际上是对 .xlsx 的需求;在 .xls 中,您只能通过将图表推送到它自己的工作表上来近似实现,这改变了用户浏览文件的方式,也改变了您的代码行为方式;XLS 侧 AddChartSheet 返回的工作表是图表子流,而不是网格:使用 Cells.Item 写入它会产生一个不一致的绘图流,它在生成时没有错误,但 Excel 在打开时会将其丢弃;图表会直接消失,构建日志中却没有任何原因提示;将返回的工作表视为仅图表工作表,那么整类“图表丢失”的报告就会彻底消失

在 XLSX 工作表中嵌入图表

XLSX 路径是具有回旋余地的路径,在这里,引言中提到的两个坐标系统变得具体起来;传递给 AddChart 的锚点矩形用工作表的行和列表示,并固定了图表框架所在的位置;系列数据表示为包含工作表名称的绝对 A1 引用;它们是独立的:您可以将框架移动到工作表的另一端,它绘制的依然是相同的单元格

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Chart: TXLSXChart;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Sales');
    Sheet.Cells[1, 1].Value := 'Region';
    Sheet.Cells[1, 2].Value := 'Revenue';
    Sheet.Cells[2, 1].Value := 'East';
    Sheet.Cells[2, 2].Value := 1184350;
    Sheet.Cells[3, 1].Value := 'Central';
    Sheet.Cells[3, 2].Value := 902210;
    Sheet.Cells[4, 1].Value := 'West';
    Sheet.Cells[4, 2].Value := 1010675;

    // Frame anchored to rows 6..22, columns 1..8
    Chart := Sheet.AddChart(xlsxChartColumn, 'Revenue by Region', 6, 1, 22, 8);
    Chart.AddSeries('Revenue', 'Sales!$A$2:$A$4', 'Sales!$B$2:$B$4');
    Chart.ValueAxisTitle := 'USD';

    Sheet.AddImageFromFile(1, 5, 'logo.png');
    Book.SaveAs('dashboard.xlsx');
  finally
    Book.Free;
  end;
end;

容易踩坑的参数是递交给 AddSeries 的范围字符串;它是一个在调用那一刻捕获的字面量,根本不知道您随后可能会追加 20 行数据;在数据写入后,利用您计算出的行数来构建它,而绝不要在写入前构建;散点图和气泡图用不同的含义重载了相同的两个参数:类别范围现在提供 X 值,数值范围提供 Y 值,而气泡半径来自通过返回的 TXLSXChartSeries 上的 BubbleSizeRange 设置的第三个引用;一旦离开柱形图和条形图家族,请将该调用解读为“X、Y、大小”,而不是“类别、数值”

TXLSXChartType 涵盖了柱形图、条形图、折线图、饼图、面积图、圆环图、散点图、气泡图和雷达图,覆盖了日常报表的所有类型;对于没有周围网格的全页图表, Book.AddChartSheet 返回一个 IsChartSheet 属性为 true 的工作表;它是传统图表工作表在 .xlsx 中的对应物,并带有相同的预期:不要向其写入单元格内容

图像作为字节放入,且尺寸以 EMU 衡量

插入图片有两个重载,将它们混淆是代码审查中最常出现的图像 bug:AddImage(ARow, ACol, AData, AFormat) 需要 AData 中已经编码过的图片字节:PNG、JPEG、GIF 或 BMP 的原始内容;向其传递文件路径,您存下的只是一个任何查看器都无法解码的 40 字节字符串,这正是您部署后最不想去调试的“损坏图片图标”报告;当数据源是磁盘上的文件时,调用 AddImageFromFile 代替,让库为您读取字节并对格式进行分类

接下来是尺寸调整;DrawingML 并不以像素衡量,它以英文公制单位(English Metric Units)衡量,其中 914,400 EMU 相当于一英寸,在 96 DPI 下,9,525 EMU 相当于一个像素;TXLSXImage 对象公开了 WidthEMUHeightEMU,因此一个旨在渲染为 180 x 60 像素的徽标需要 1,714,500 x 571,500 EMU;将该转换放入命名的常量中并对照其进行计算;在代码中散落着类似于 1,714,500 这样的魔法数字是不可读的,并且一旦有人更改了目标 DPI,它们就会悄无声息地出错;顺便说一下,锚定行和列是基于 1 的,这与其余单元格 API 相匹配,而不是基于 0 的 EMU 数学计算

传统 XLS 文件中的图表工作表和形状

在 BIFF8 侧,更丰富的 AddChartSheet 重载接受图表类型、轴标题以及 TXLSChartSeriesInfo 记录的开放数组,其中每个记录以字符串形式保存一个名称、一个类别范围和数值范围;悬浮形状则是另外一回事:它们位于数据工作表本身(通过其 Shapes 集合),而不是位于图表工作表上

var
  Book: IXLSWorkbook;
  Data, Trend: IXLSWorksheet;
  Series: array[0..0] of TXLSChartSeriesInfo;
begin
  Book := TXLSWorkbook.Create;   // interface-counted: do not Free
  Data := Book.Sheets.Add;
  Data.Name := 'Data';
  Data.Cells.Item[1, 1].Value := 'Month';
  Data.Cells.Item[1, 2].Value := 'Units';
  Data.Cells.Item[2, 1].Value := 'Apr';
  Data.Cells.Item[2, 2].Value := 1530;
  Data.Cells.Item[3, 1].Value := 'May';
  Data.Cells.Item[3, 2].Value := 1721;

  Series[0].Name := 'Units';
  Series[0].Categories := 'Data!$A$2:$A$3';
  Series[0].Values := 'Data!$B$2:$B$3';
  Trend := Book.Sheets.AddChartSheet('Trend', xlsChartTypeLine,
    'Units sold', 'Month', 'Units', Series);
  // Trend is a chart substream: never call cell methods on it

  Data.Shapes.AddTextBox('Source: ERP nightly export', 6, 1, 8, 4);
  Data.Shapes.AddPicture('approved-stamp.bmp');
  Book.SaveAs('trend.xls');
end;

这里有两个生命周期细节很重要,并且它们的方向相反:TXLSWorkbook 通过 IXLSWorkbook 接口持有且被引用计数,因此自己对其调用 Free 会触发双重释放;前面章节中的 TXLSXWorkbook 是一个普通的对象,必须在 try..finally 中释放;如果代码审查员在 XLSX 侧标记了缺失 Free 的情况,他就必须在 XLS 侧标记出现了 Free 的情况,当您在同一个单元中处理这两种格式时,这确实是一个陷阱;形状助手本身是统一的:AddRectangleAddOvalAddLine,带有用于清除绘图区域的 DeleteInRange,都按行列对进行锚定,因此在其上方插入行的模板会将它们与网格一起平移

TXLSPicture.TransparentColor 从位图中掩蔽所选的背景颜色,这正是您在 BIFF 渲染从未学会 PNG alpha 通道的格式中,将非矩形图章(“Approved”印章、水印)置于网格之上的方式;设置图章创作时针对的颜色,周围的矩形就会消失

主题颜色在 BIFF8 往返中无法存活

OOXML 绘图填充可以指向主题颜色槽,这就是为什么通过更换主题来对整个 .xlsx 进行重新着色是非常廉价的原因;BIFF8 绘图记录没有这样的槽;当 HotXLS 将主题颜色应用于 XLS 绘图时,它将颜色解析为字面 RGB 值并予以存储,它所来源的主题索引在写入文件的瞬间就丢失了,重新打开也无法恢复;这在白标(white-label)报表工具中尤其明显 —— 这种工具会为许多客户重新品牌化同一个生成的文档;请在您自己的配置中保留主题到 RGB 的映射,并在每次生成时重新应用,而不是期望从保存的 .xls 中将其读回

在性能方面也出现了一个相关的决定:当您只需要大型旧文件的单元格数据时,可以通过将 _DisableGraphics 设置为 true,来让 XLS 接口完全跳过解析绘图层,这能为批量读取节省大量时间;该代价是永久性的:以这种方式打开的工作簿在内存中没有 OfficeArt流,因此保存它会将这些绘图写入虚无中;请将该标志保留给只读的分析作业;更广泛的性能图景在我们在 HotXLS 中关于大型工作簿性能的说明

在网格更改时保持锚点稳定

报表很少能保持生成时的大小,这就是引言中提到的锚点模型发挥作用的地方;XLSX 接口的结构操作(InsertRowsDeleteRows 以及等效的列操作)会将依赖层与单元格一起移动;合并区域、超链接、批注、冻结窗格、筛选范围、条件格式、验证、表、已定义名称,以及本主题中图像和图表的锚点,都会移动;当下方插入 10 行时,锚定在第 1 行的徽标依然保持在顶部;随着数据块的增长,锚定在数据块下方的图表框架会向下平移;唯一不会被重写的内容是您在插入发生之前作为字面量捕获的范围字符串,因为它只是文本,库没有理由去重新访问;这固定了模板填充的安全顺序:先写入并重塑数据,然后将创建图表和放置图像作为最后一步,每个范围字符串都派生自插入后拥有的行数,而不是在插入之前

两个较小的工具完善了布局工具包:XLS 侧的 TXLSTextBox.SetArea 将现有的文本框或自选图形重新锚定到新的单元格矩形上,这优于在页脚块平移时进行删除和重建;此外,AddPicture 的位图重载接受一个活的 TBitmap 以及一个可选的透明标志,因此您自己的 VCL 代码可以绘制的任何内容(仪表、迷你图条、原生列表中未提供的图表类型)都可以直接盖印到工作表中,而无需先写入临时文件

图表和图像几乎总是已经是结构化报表的最后装饰层,这就是为什么基础工作决定了它们是否能干净利落地着陆;图表将引用的数据填充在模板驱动的报表生成中进行了介绍,而在锚点下方保持网格稳定是合并单元格和布局控制的主题;完整的类和方法文档位于 HotXLS Component 产品页面中