Excel 转图片自定义多行表头的一种实现方案

一、背景

在企业微信消息推送场景中,业务方需要将存储在 OSS 上的 ZIP 压缩包中的 Excel(xlsx)文件转换为 PNG 图片,然后逐张发送到企业微信群。原始的 Excel 文件只有单行表头,无法满足多层级分类展示的需求(如"一级指标 → 二级指标 → 具体数值"),同时生成的 PNG 图片体积可能超过企业微信文件发送的限制(约 10MB)。

为此,实现了一套完整的 "ZIP 解压 → Excel 渲染 → 自定义多行表头替换 → PNG 输出 → 图片压缩 → 逐张发送" 流水线。


二、需求

  1. 自定义多行表头 :通过 JSON 配置多行表头结构,支持 colspan / rowspan 单元格合并,替代原始 Excel 的单行表头。

  2. 标题行与数据日期行:在表头上方插入标题行(居中加粗)和数据日期行(居左),美化为报表标题。

  3. PNG 渲染:将 Excel 每个 sheet 渲染为一张或多张 PNG 图片。

  4. 图片压缩:对超过 2MB 的 PNG 图片进行无损/有损压缩,确保单张 ≤ 2MB,兼顾清晰度。

  5. ZIP 编解码:支持 GBK/UTF-8 编码自动回退解压;支持图片批量打包回 ZIP。

  6. 样式增强:表头蓝色+白字、数据区斑马条纹、黑色细边框、列宽/行高自适应。


三、设计思路

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}
    ]
  ]
}

其中 colspanrowspan 支持单元格跨列/跨行合并,\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 解析失败 → 保留原始表头;重编码变大 → 保留原文件