一、背景
在企业微信消息推送场景中,业务方需要将存储在 OSS 上的 ZIP 压缩包中的 Excel(xlsx)文件转换为 PNG 图片,然后逐张发送到企业微信群。原始的 Excel 文件只有单行表头,无法满足多层级分类展示的需求(如"一级指标 → 二级指标 → 具体数值"),同时生成的 PNG 图片体积可能超过企业微信文件发送的限制(约 10MB)。
为此,实现了一套完整的 "ZIP 解压 → Excel 渲染 → 自定义多行表头替换 → PNG 输出 → 图片压缩 → 逐张发送" 流水线。
二、需求
-
自定义多行表头 :通过 JSON 配置多行表头结构,支持
colspan/rowspan单元格合并,替代原始 Excel 的单行表头。 -
标题行与数据日期行:在表头上方插入标题行(居中加粗)和数据日期行(居左),美化为报表标题。
-
PNG 渲染:将 Excel 每个 sheet 渲染为一张或多张 PNG 图片。
-
图片压缩:对超过 2MB 的 PNG 图片进行无损/有损压缩,确保单张 ≤ 2MB,兼顾清晰度。
-
ZIP 编解码:支持 GBK/UTF-8 编码自动回退解压;支持图片批量打包回 ZIP。
-
样式增强:表头蓝色+白字、数据区斑马条纹、黑色细边框、列宽/行高自适应。
三、设计思路
3.1 整体架构
OSS下载 → ZIP解压 → xlsx转PNG(多行表头+样式) → 图片压缩 → 企业微信发送 → 临时目录清理
3.2 核心类职责
| 类 | 职责 |
|---|---|
FileConvertUtil |
ZIP 解压/打包、xlsx→PNG 渲染、多行表头替换、样式应用 |
ImageCompressUtil |
PNG 图片压缩(色彩优化 + 等比缩放) |
QwMsgPush |
业务流程编排,调用上述工具类完成推送 |
3.3 多行表头 JSON 协议
表头配置采用两级 JSON 结构,rows 为二维数组,每个单元格包含:
{
"rows": [
[
{"text": "区域信息", "colspan": 2, "rowspan": 1},
{"text": "销售数据", "colspan": 3, "rowspan": 1}
],
[
{"text": "省份", "colspan": 1, "rowspan": 1},
{"text": "城市", "colspan": 1, "rowspan": 1},
{"text": "销售额", "colspan": 1, "rowspan": 1},
{"text": "订单量", "colspan": 1, "rowspan": 1},
{"text": "客单价", "colspan": 1, "rowspan": 1}
]
]
}
其中 colspan 和 rowspan 支持单元格跨列/跨行合并,\n 支持单元格内换行。
设计要点:以"先宽后窄"为默认原则,第一行为分类层、第二行为具体列名。JSON 协议完全由业务方自定义,前端可做成可视化表头编辑器。
3.4 转换流水线(xlsxToPng 方法内部)
1. loadAsposeLicense() — 加载 Aspose Cells 许可证
2. Workbook(sheet) — 打开 xlsx 文件
3. applyTableHeader() — 删除原始第0行 → 插入多行表头 → 合并单元格
4. insertTitleRow() — 在顶部插入标题行(合并整行,黑体14号居中)
5. insertDataDateRow() — 插入数据日期行("数据日期:2026-07-01")
6. applyZebraStyle() — 表头蓝色+白字 / 数据区斑马条纹 / 细边框
7. autoFitColumns() — 自动列宽
8. adjustHeaderColumnWidth() — 合并单元格二次列宽微调
9. 数据行行高自适应 — 根据文字长度 + 列宽计算换行高度
10. SheetRender.toImage() — 渲染为 PNG 输出
3.5 图片压缩策略(ImageCompressUtil)
采用三级渐进式压缩:
| 阶段 | 策略 | 说明 |
|---|---|---|
| 阶段0 | 大小预检 | 文件 ≤ 2MB 直接返回,零开销 |
| 阶段1 | 色彩优化 + 重编码 | 去除冗余 Alpha 通道,deflate level 9 最高压缩重编码 |
| 阶段2 | 等比缩放 | BICUBIC 插值 + 抗锯齿,每次缩至 90%,最小宽度 800px |
重编码后若体积反增,自动回退保留原文件。
四、核心依赖包
| 依赖 | 版本 | 用途 |
|---|---|---|
com.aspose:aspose-cells |
8.5.2 | Excel 解析、单元格操作、PNG 渲染 |
com.alibaba.fastjson2:fastjson2 |
2.0.47 | 多行表头 JSON 反序列化 |
cn.hutool:hutool-core |
(blade 框架内置) | 图片读取、文件操作、字符串工具 |
javax.imageio |
JDK 1.8 内置 | PNG 写入、压缩参数控制 |
java.util.zip |
JDK 1.8 内置 | ZIP 解压/打包 |
pom.xml 关键片段:
<properties>
<aspose.cells.version>8.5.2</aspose.cells.version>
<fastjson-version>2.0.47</fastjson-version>
</properties>
<dependency>
<groupId>com.aspose</groupId>
<artifactId>cells</artifactId>
<version>${aspose.cells.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson-version}</version>
</dependency>
Aspose Cells License 在应用启动时由 AsposeUtil 统一加载,存储在 CommonConstants.ASPOSE_XLS_LICENSE 中。
五、核心代码片段
5.1 多行表头 JSON 解析 + 替换
/**
* 表头配置顶层结构 — JSON 反序列化用。
*/
@lombok.Data
private static class HeaderConfig {
private List<List<HeaderCell>> rows;
}
/**
* 表头单元格 — JSON 反序列化用。
*/
@lombok.Data
private static class HeaderCell {
private String text; // 单元格文字,\n 换行
private Integer colspan; // 跨列数,默认 1
private Integer rowspan; // 跨行数,默认 1
}
private int applyTableHeader(Worksheet sheet, String tableHeaderJson) {
if (StrUtil.isBlank(tableHeaderJson)) {
return 0; // 无自定义表头,使用原始表头
}
HeaderConfig headerConfig = JSON.parseObject(tableHeaderJson, HeaderConfig.class);
List<List<HeaderCell>> rows = headerConfig.getRows();
// 校验:每行 colspan + rowspan 占位必须 == 数据总列数
// ...(略,详见源码 664-691 行)
// 删除原始第0行
cells.deleteRow(0);
int headerRowCount = rows.size();
cells.insertRows(0, headerRowCount);
// 逐行逐格设置文字 + 合并
boolean[][] occupied = new boolean[headerRowCount][totalCols];
for (int rowIdx = 0; rowIdx < headerRowCount; rowIdx++) {
int col = 0;
for (HeaderCell hc : rows.get(rowIdx)) {
while (col < totalCols && occupied[rowIdx][col]) col++;
int colspan = hc.getColspan() != null ? hc.getColspan() : 1;
int rowspan = hc.getRowspan() != null ? hc.getRowspan() : 1;
if (colspan > 1 || rowspan > 1) {
cells.merge(rowIdx, col, rowspan, colspan);
}
cells.get(rowIdx, col).putValue(hc.getText());
// 标记占用矩阵...
col += colspan;
}
}
adjustHeaderColumnWidth(sheet, headerRowCount, totalCols);
return headerRowCount;
}
5.2 xlsx 转 PNG 主流程
public List<File> xlsxToPng(File xlsxFile, File outputDir,
String title, String tableHeader, String dataDate) {
loadAsposeLicense();
Workbook workbook = new Workbook(xlsxFile.getAbsolutePath());
ImageOrPrintOptions options = new ImageOrPrintOptions();
options.setOnePagePerSheet(true);
for (int i = 0; i < sheets.getCount(); i++) {
Worksheet sheet = sheets.get(i);
sheet.setGridlinesVisible(true);
sheet.getPageSetup().setPrintGridlines(true);
int headerRows = applyTableHeader(sheet, tableHeader); // 多行表头
if (StrUtil.isNotBlank(title)) {
insertTitleRow(sheet, title); // 标题行
headerRows++;
}
if (StrUtil.isNotBlank(dataDate)) {
insertDataDateRow(sheet, dataDate, /*insertAt*/...);
headerRows++;
}
applyZebraStyle(sheet, headerRows, /*nonHeaderRows*/...);
sheet.autoFitColumns();
adjustHeaderColumnWidth(sheet, headerRows, ...);
SheetRender render = new SheetRender(sheet, options);
for (int page = 0; page < render.getPageCount(); page++) {
File pngFile = new File(outputDir, pngName);
render.toImage(page, pngFile.getAbsolutePath());
pngFiles.add(pngFile);
}
}
return pngFiles;
}
5.3 图片压缩核心逻辑
public static File compressImage(File imageFile) {
long originalSize = imageFile.length();
if (originalSize <= MAX_SIZE_BYTES) { // 2MB,直接返回
return imageFile;
}
// 策略1:色彩优化 + deflate level 9 重编码
BufferedImage original = ImgUtil.read(imageFile);
BufferedImage optimized = optimizeColorMode(original); // RGB → 丢弃Alpha
File compressed = writePngWithMaxCompression(optimized, imageFile);
if (compressed.length() <= MAX_SIZE_BYTES) return compressed;
// 策略2:等比缩放(BICUBIC + 抗锯齿,每次90%)
return scaleDownToFit(original, imageFile);
}
private static File writePngWithMaxCompression(BufferedImage image, File originalFile) {
ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionType("Deflate");
param.setCompressionQuality(0.0f); // 0.0 = 最高压缩率(deflate level 9)
writer.write(null, new IIOImage(image, null, null), param);
// 兜底:压缩后反而变大,删新留旧
if (output.length() > originalFile.length()) {
FileUtil.del(output);
return originalFile;
}
return output;
}
5.4 斑马条纹 + 样式应用
private void applyZebraStyle(Worksheet sheet, int titleRows, int nonHeaderRows) {
// 表头:蓝色背景 (RGB 68,114,196) + 白色加粗文字
Style headerStyle = sheet.getWorkbook().createStyle();
headerStyle.setForegroundColor(Color.fromArgb(68, 114, 196));
headerStyle.getFont().setColor(Color.getWhite());
headerStyle.getFont().setBold(true);
// 偶数数据行:浅蓝背景 (RGB 216,228,243)
Style evenRowStyle = sheet.getWorkbook().createStyle();
evenRowStyle.setForegroundColor(Color.fromArgb(216, 228, 243));
// 数据区统一黑色细边框
Style borderStyle = sheet.getWorkbook().createStyle();
borderStyle.setBorder(BorderType.TOP_BORDER, CellBorderType.THIN, Color.getBlack());
// ... 上下左右四边
}
5.5 业务调用链(QwMsgPush)
private String handleZipFile(File zipFile, String senders, String robotKey,
String fileName, String tableHeader, String dataDateStr) {
// 1. ZIP 解压
List<File> extracted = FileConvertUtil.unzip(zipFile, unzipDir);
// 2. xlsx → PNG(多行表头 + 标题 + 数据日期)
List<File> allPngs = new ArrayList<>();
for (File f : extracted) {
if (f.getName().toLowerCase().endsWith(".xlsx")) {
List<File> pngs = FileConvertUtil.xlsxToPng(
f, unzipDir, fileName, tableHeader, dataDateStr);
allPngs.addAll(pngs);
}
}
// 3. 图片压缩
allPngs = ImageCompressUtil.compressImages(allPngs);
// 4. 逐张发送企业微信
for (File png : allPngs) {
webhookSender.sendFileMessage(senders, png.getAbsolutePath(), fileName, "png");
}
// 5. 清理临时目录
FileUtil.del(unzipDir);
}
六、方案要点总结
| 要点 | 实现方式 |
|---|---|
| 多行表头 | JSON 配置 → 删除原始行 → insertRows + merge 精确控制 |
| 列校验 | rowspan 占位矩阵模拟,确保每行覆盖所有数据列 |
| 字体 | 表头/标题使用系统自带"黑体",无需额外安装字体文件 |
| 列宽自适应 | Aspose 原生字符宽度单位:中文=2,英文=1,含合并单元格 |
| 行高自适应 | 统计 \n 换行行数 × 16px,取整行最大值 |
| 图片压缩 | 三级渐进:跳过(≤2MB) → 重编码 → 等比缩放(BICUBIC) |
| ZIP 编码 | UTF-8 优先,MALFORMED 回退 GBK,ISO-8859-1 兜底 |
| 安全性 | Zip Slip 防穿越检查 + 临时目录 finally 清理 |
| 容错 | 表头 JSON 解析失败 → 保留原始表头;重编码变大 → 保留原文件 |