技术文章

用 HotXLS 把 Excel 单元格区域导出为一张图片

有时交付物不是文档,而是一张表格的图片。状态邮件里的摘要块、仪表盘上渲染的 KPI 面板、搜索结果旁的缩略图:它们都只想要单元格,没有一个想要纸。HotXLS 的 TXLSCellImageExporter 接收一个经典格式或 XLSX 的单元格矩形,产出一张紧凑的 PNG 或 JPEG:没有页面尺寸、没有页边距、没有页眉页脚、没有打印标题、没有分页符。分辨率、缩放、格式与 JPEG 质量可配置,对象、网格线与单元格边框各有独立开关,背景可以是颜色或透明,文件写入走同文件夹原子替换,任何环节失败时既有目标文件纹丝不动

这需要独立的导出器而不是打印路径上的一个开关,原因是分页不是一个可以关掉的可选层——它正是页面管线存在的目的

为什么不通过打印管线渲染区域?

因为打印管线在你与单元格之间插进了一页。纸张尺寸决定装得下多少,页边距把内容向内挤,页眉页脚占据你没要的条带,打印标题重复你已有的行,分页符把区域切开。恰好跨过分页符的摘要块会变成两张图,关键的那一行被切成两半。你可以设置一个恰好匹配区域的自定义纸张尺寸来补偿这一切——人们确实这么做——但那意味着区域每次变化都要重算纸张几何,而且页眉条带与打印标题逻辑仍留在路径里

单元格导出器测量矩形,分配恰好该尺寸的位图,把单元格画进去,然后编码。没有页,所以没有什么可配置掉的。当你确实想要纸的时候,PDF 导出路径才是合适的工具,见工作表 PDF 导出一文

TXLSCellImageExporter 对每个单元格区域测量、绘制并编码成一张图片,打印管线则在分页符处切开区域
页面管线在你与单元格之间插入纸张几何;单元格导出器的路径里没有任何页

渲染之前先测量

Measure 返回当前设置将产出的像素尺寸,而不编码任何东西。这重要有两个原因。HTML 或邮件模板通常在图片存在之前就需要图片尺寸,以便预留框位、避免布局偏移。而渲染用户所选区域的服务,需要在为请求分配之前就有办法拒绝荒谬的请求

uses
  lxHandleX, lxPagination;

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Exporter: TXLSCellImageExporter;
  Summary: TXLSXRange;
  W, H, Bytes: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarter.xlsx');
    Sheet := Book.Sheets.ByPos[0];
    Summary := Sheet.Range['A1:F20'];

    Exporter := TXLSCellImageExporter.Create;
    try
      Exporter.ImageFormat := xpifPng;   // PNG 保持细线条清晰
      Exporter.DPI := 96;
      Exporter.Scale := 2.0;             // 视网膜密度输出
      Exporter.IncludeGridlines := False;
      Exporter.IncludeCellBorders := True;
      Exporter.TransparentBackground := True;
      Exporter.MaxPixels := 40 * 1000 * 1000;
      Exporter.MaxBytes := 8 * 1024 * 1024;

      if not Exporter.Measure(Summary, W, H) then
        raise Exception.Create('range exceeds the configured budget');
      // W 与 H 此时已知;编码前预留布局框
      Bytes := Exporter.Save(Summary, 'summary.png');
      if Bytes <= 0 then
        raise Exception.Create('image export failed, previous file kept');
    finally
      Exporter.Free;
    end;
  finally
    Book.Free;
  end;
end;

预算,因为缩放会相乘

MaxPixelsMaxBytes 不是防御性装饰。像素数随缩放系数的平方、也随分辨率比的平方增长:一个在 96 DPI 下合理的 1200 乘 800 区域,到 600 DPI 变成约 47 兆像素;用户若选的是整个已用区域而非摘要块,还要在此之上再加一个数量级。没有上限时,失败模式是进程无法满足的分配请求,它会把该进程正在做的其他事情一起拖垮

有上限时,请求失败,调用方可以选择:拒绝、调低缩放或收窄区域。这对报表服务器是好得多的处境,也与有界 EMF 与 WMF 解码器一文所述图元文件解码器中显式预算的推理一脉相承

HotXLS 中 TXLSCellImageExporter 的预算流程:Measure 先返回像素尺寸,再由 MaxPixels 与 MaxBytes 约束分配与输出大小
拒绝发生在分配之前,字节预算失败时旧图片对调用方原样保留

原子替换,以及为什么文件夹很重要

Save 到文件名不会直接写目标。它先在同文件夹写一个临时文件,把内容编码进去,然后才替换目标。如果编码失败、中途超预算,或者进程被杀,上一张图片仍在原地且仍然有效。按计划重建图块的仪表盘因此永远不会显示被截断的 PNG——那是"打开目标文件就开始流式写入"这种朴素写法的常见症状

同文件夹这个细节不是偶然。原子替换只在一个卷内是原子的,跨卷时操作系统必须先复制再删除,这重新引入了你试图关闭的窗口。任何把临时文件放进系统临时目录的此类实现,在输出位于另一个驱动器的机器上都不是原子的

TXLSCellImageExporter 的 Save 先编码进同文件夹的临时文件,再原子替换目标;失败时旧图片依然有效
临时文件必须与目标同处一地,因为原子替换只在一个卷内有效

绘制事件在真实画布上作画

区域导出器与页面导出器都暴露前置与收尾绘制事件,它们接收的是完整的只读上下文,而不只是一个画布句柄。TXLSPagePaintContext 携带活动画布、像素边界、以点为单位的页面尺寸、实际使用的分辨率与缩放、文档页码、表内页码、总页数、工作表名称,以及经典与 XLSX 两种形态的来源工作表。这足以画一个正确缩放的水印,或一个知道自己在整轮输出中位置的页面戳记

procedure TReportJob.StampDraft(Sender: TObject;
  const AContext: TXLSPagePaintContext);
begin
  // 感知缩放,戳记在 1x 与 3x 下看起来一致
  AContext.Canvas.Font.Height := Round(-48 * AContext.Scale);
  AContext.Canvas.Font.Color := clSilver;
  AContext.Canvas.Brush.Style := bsClear;
  AContext.Canvas.TextOut(AContext.Bounds.Left + Round(24 * AContext.Scale),
    AContext.Bounds.Top + Round(24 * AContext.Scale), 'DRAFT');
end;

Exporter.AfterPaint := Job.StampDraft;

三个行为值得依赖。事件对每个渲染帧恰好触发一次,包括多页 TIFF 的每一帧,所以在处理器里递增的计数器可信。它们在测量期间保持沉默,带副作用的处理器不会为一次输出跑两遍。而且如果前置事件抛异常,收尾事件不会触发,也不会写入任何不完整的图片字节,你自己绘图代码里的异常产不出半截戳记的文件

选择格式

文本密集的内容用 PNG。JPEG 的块变换会在细的高对比线条周围产生可见的振铃——单元格边框与小字正是这种线条——而且在照片看起来完美的质量档位上,伪影依然存在。当区域以嵌入照片为主、文件大小比边缘保真更重要时,JPEG 才有它的位置。透明背景必须 PNG,因为 JPEG 没有 alpha 通道,要放在彩色底面上的图块已经替你做了选择

如果你的区域包含合并单元格,请对照工作表检查输出:合并区域与列宽的相互作用常出乎意料,版式规则见合并单元格与报表模板一文。HotXLS 在 Delphi 与 C++Builder 中读写 XLS、XLSX、ODS 与 CSV,不依赖 Excel,导出器完整接口见 HotXLS Delphi spreadsheet component 产品页