基于SpringBoot,使用EasyExcel导出图片功能

1、前言

项目中有导出excel文件需要包含图片的需求,使用EasyExcel注解方式支持导出,但是不够灵活,而且当导出的图片过多,过大会导致导出时间过长,excel文件过大。这个时候可以考虑先压缩图片,然后再导出,如果必须要导出原图那就没办法了,只能异步导出了。下面介绍一下导出的方案。

2、导出方案

2.1 使用easyExcel注解,配合自定义handler,实现一个单元格,导出多张图片。

导出实体VO:

import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;

import java.util.List;

@Data
public class ExportVo {

    @ExcelProperty(value = "名称",index = 0)
    private String name;

    @ExcelProperty(value = "图片",converter = ImageConvert.class,index = 1)
    private List<String> signImages;

}

图片转换类:

package com.gotion.business.school.util;

import com.alibaba.excel.converters.Converter;
import com.alibaba.excel.enums.CellDataTypeEnum;
import com.alibaba.excel.metadata.GlobalConfiguration;
import com.alibaba.excel.metadata.data.ImageData;
import com.alibaba.excel.metadata.data.ReadCellData;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.metadata.property.ExcelContentProperty;
import com.alibaba.excel.util.IoUtils;
import com.alibaba.excel.util.ListUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;

import java.io.InputStream;
import java.net.URL;
import java.util.List;
 
@Slf4j
public class ImageConvert implements Converter<List<String>> {
 
    @Override
    public Class<?> supportJavaTypeKey() {
        return List.class;
    }
 
    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.EMPTY;
    }
 
    @Override
    public List<String> convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        return null;
    }
 
    @Override
    public WriteCellData<?> convertToExcelData(List<String> value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        // 这里进行对数据实体类URL集合处理
        List<ImageData> data = ListUtils.newArrayList();
        ImageData imageData;
        // for 循环一次读取
        for (String imageUrl : value) {
            URL url = new URL(imageUrl);
            try (InputStream inputStream = url.openStream()) {
                byte[] bytes = IoUtils.toByteArray(inputStream);
                imageData = new ImageData();
                imageData.setImage(bytes);
                data.add(imageData);
            } catch (Exception e) {
                log.error("导出临时记录图片异常:", e);
            }
        }
        WriteCellData<?> cellData = new WriteCellData<>();
        if (!CollectionUtils.isEmpty(data)) {
            // 图片返回图片列表
            cellData.setImageDataList(data);
            cellData.setType(CellDataTypeEnum.EMPTY);
        } else {
            // 没有图片使用汉字表示
            cellData.setStringValue("无图");
            cellData.setType(CellDataTypeEnum.STRING);
        }
        return cellData;
    }
}

自定义处理图片handler:

import com.alibaba.excel.enums.CellDataTypeEnum;
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.metadata.data.ImageData;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteTableHolder;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.usermodel.ClientAnchor.AnchorType;
import org.apache.poi.util.Units;
import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFPicture;
import org.apache.poi.xssf.usermodel.XSSFShape;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class CustomImageModifyHandler implements CellWriteHandler {

    private static final Logger log = LoggerFactory.getLogger(CustomImageModifyHandler.class);

    private final CopyOnWriteArrayList<String> processedCells = new CopyOnWriteArrayList<>();

    private static final int DEFAULT_IMAGE_WIDTH_PX = 60;
    private static final int DEFAULT_IMAGE_HEIGHT_PX = 60;
    private static final int IMAGE_SPACING_PX = 2;
    private static final double POINTS_PER_PIXEL = 72.0 / 96.0;
    private static final double PIXELS_PER_CHAR = 8.0;

    private final int imageWidthPx;
    private final int imageHeightPx;

    public CustomImageModifyHandler() {
        this(DEFAULT_IMAGE_WIDTH_PX, DEFAULT_IMAGE_HEIGHT_PX);
    }

    public CustomImageModifyHandler(int imageWidthPx, int imageHeightPx) {
        this.imageWidthPx = Math.max(1, imageWidthPx);
        this.imageHeightPx = Math.max(1, imageHeightPx);
    }

    @Override
    public void afterCellDataConverted(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder,
                                       WriteCellData<?> cellData, Cell cell, Head head,
                                       Integer relativeRowIndex, Boolean isHead) {
        if (isHead || CollectionUtils.isEmpty(cellData.getImageDataList())) {
            return;
        }
        cellData.setType(CellDataTypeEnum.EMPTY);
    }

    @Override
    public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder,
                                 List<WriteCellData<?>> cellDataList, Cell cell,
                                 Head head, Integer relativeRowIndex, Boolean isHead) {
        if (isHead || CollectionUtils.isEmpty(cellDataList)) {
            return;
        }

        WriteCellData<?> cellData = cellDataList.get(0);
        List<ImageData> imageDataList = cellData.getImageDataList();
        if (CollectionUtils.isEmpty(imageDataList)) {
            return;
        }

        String cellKey = cell.getRowIndex() + "_" + cell.getColumnIndex();
        if (processedCells.contains(cellKey)) {
            return;
        }
        processedCells.add(cellKey);

        Sheet sheet = cell.getSheet();
        Workbook workbook = sheet.getWorkbook();
        int colIndex = cell.getColumnIndex();
        int rowIndex = cell.getRowIndex();

        try {
            setOptimalRowHeight(sheet, rowIndex);
            setOptimalColumnWidth(sheet, colIndex, imageDataList.size());
            insertAllImages(workbook, sheet, cell, imageDataList);
            cell.setCellValue("");
            //removeEasyExcelAutoPictures(sheet);
        } catch (Exception e) {
            log.error("处理图片失败: row={}, col={}", rowIndex, colIndex, e);
        }
    }

    /**
     * 关键方法:删除EasyExcel自动插入的所有图片
     */
    private void removeEasyExcelAutoPictures(Sheet sheet) {
        Drawing<?> drawing = sheet.getDrawingPatriarch();
        if (drawing instanceof XSSFDrawing) {
            XSSFDrawing xssfDrawing = (XSSFDrawing) drawing;
            if (xssfDrawing != null) {
                // 获取所有图片形状
                List<XSSFShape> shapes = xssfDrawing.getShapes();

                // 倒序遍历并删除(避免并发修改异常)
                for (int i = shapes.size() - 1; i >= 0; i--) {
                    XSSFShape shape = shapes.get(i);
                    if (shape instanceof XSSFPicture) {
                        XSSFPicture picture = (XSSFPicture) shape;
                        picture.resize(0);

                    }
                }
            }
        }
    }

    private void setOptimalRowHeight(Sheet sheet, int rowIndex) {
        Row row = sheet.getRow(rowIndex);
        if (row == null) row = sheet.createRow(rowIndex);
        double heightInPoints = imageHeightPx * POINTS_PER_PIXEL;
        row.setHeightInPoints((float) heightInPoints);
    }

    private void setOptimalColumnWidth(Sheet sheet, int columnIndex, int imageCount) {
        double totalWidthPx = imageWidthPx * imageCount + IMAGE_SPACING_PX * (imageCount - 1);
        double widthInChars = totalWidthPx / PIXELS_PER_CHAR;
        int columnWidth = (int) (widthInChars * 256);
        columnWidth = Math.max(columnWidth, 6_000); // 至少 6000
        sheet.setColumnWidth(columnIndex, columnWidth);
    }

    // ==================== 删除原图 + 插入新图 ====================

    private void insertAllImages(Workbook workbook, Sheet sheet, Cell cell, List<ImageData> imageDataList) {
        Drawing<?> drawing = sheet.getDrawingPatriarch();
        if (drawing == null) {
            drawing = sheet.createDrawingPatriarch();
        }

        int colIndex = cell.getColumnIndex();
        int rowIndex = cell.getRowIndex();



        // ✅ 插入新图片
        for (int i = 0; i < imageDataList.size(); i++) {
            ImageData imageData = imageDataList.get(i);
            if (imageData == null || imageData.getImage() == null) continue;

            try {
                int pictureType = detectPictureType(imageData.getImage());
                int pictureIndex = workbook.addPicture(imageData.getImage(), pictureType);
                insertSingleImage(drawing, workbook, colIndex, rowIndex, pictureIndex, i, imageDataList.size());
            } catch (Exception e) {
                log.warn("插入图片失败: {}", e.getMessage());
            }
        }
    }



    /**
     * 插入单图
     */
    private void insertSingleImage(Drawing<?> drawing, Workbook workbook, int colIndex, int rowIndex,
                                   int pictureIndex, int imageIndex, int totalImages) {
        CreationHelper helper = workbook.getCreationHelper();
        ClientAnchor anchor = helper.createClientAnchor();

        int startPx = imageIndex * (imageWidthPx + IMAGE_SPACING_PX);
        int endPx = startPx + imageWidthPx;

        anchor.setDx1(Units.pixelToEMU(startPx));
        anchor.setDx2(Units.pixelToEMU(endPx));
        anchor.setDy1(Units.pixelToEMU(0));
        anchor.setDy2(Units.pixelToEMU(imageHeightPx));
        anchor.setCol1(colIndex);
        anchor.setCol2(colIndex);
        anchor.setRow1(rowIndex);
        anchor.setRow2(rowIndex);
        anchor.setAnchorType(AnchorType.MOVE_AND_RESIZE);

        drawing.createPicture(anchor, pictureIndex);
    }

    /**
     * 检测图片类型
     */
    private int detectPictureType(byte[] data) {
        if (data.length > 2) {
            if ((data[0] & 0xFF) == 0xFF && (data[1] & 0xFF) == 0xD8) {
                return Workbook.PICTURE_TYPE_JPEG;
            } else if (data[0] == 'W' && data[1] == 'M' && data[2] == 'F') {
                return Workbook.PICTURE_TYPE_WMF;
            } else if (data[0] == (byte) 0x89 && data[1] == 'P' && data[2] == 'N') {
                return Workbook.PICTURE_TYPE_PNG;
            }
        }
        return Workbook.PICTURE_TYPE_PNG;
    }

    // 其他方法留空
    @Override public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Head head, Integer columnIndex, Integer relativeRowIndex, Boolean isHead) {}
    @Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) {}
}

测试导出:

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.google.common.base.Stopwatch;

import java.util.ArrayList;
import java.util.List;

public class TestExport {
    public static void main(String[] args) {
        Stopwatch stopwatch = Stopwatch.createStarted();
        System.out.println("开始时间:" + stopwatch.elapsed());
        ExportVo exportVo = new ExportVo();
        exportVo.setName("张三");
        exportVo.setSignImages(List.of("https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/08/chart_20250808141512A001.png", "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/08/1_20250808143309A006.JPEG",
                "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/touxiang_20250813203853A009.jpg","https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/14/7e2c5ca66699934eb4991db8fb9e5c32_origin(1)_20250814170814A004.jpg"));


        ExportVo exportVo2 = new ExportVo();
        exportVo2.setName("李四");
        exportVo2.setSignImages(List.of("https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/14/tmp_3783a8a1124b11c5d87fc525636a1249458fb04850517a40_20250814141318A005.jpg", "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/a3f5dd3c-dd99-468c-bc37-a785961a6bac_20250813191852A006.jpg",
                "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/4c164be45de6c822ba9ccc5d0bcacbc8_20250813202916A004.png"));

        List<ExportVo> list = new ArrayList<>();
        list.add(exportVo);
        list.add(exportVo2);
         //将Excel文件写入本地文件系统
        String fileName = "测试导出数据.xlsx";
        try (ExcelWriter excelWriter = EasyExcel.write(fileName, ExportVo.class)
                .autoCloseStream(false)
                .registerWriteHandler(new CustomImageModifyHandler(90,90))  // 添加处理器
                .build()) {
            WriteSheet writeSheet = EasyExcel.writerSheet("测试数据").build();
            excelWriter.write(list, writeSheet);
            System.out.println("Excel文件已成功保存到: " + fileName);
        } catch (Exception e) {
            System.err.println("导出Excel文件时发生错误: " + e.getMessage());
            e.printStackTrace();
        }
        System.out.println("耗时:"+stopwatch.elapsed().toMillis());

    }
}

效果:

image-20260223194559783

缺点:图片多,导出时间长,文件大小比较大。如何只是导出少量图片,这种方法可以满足需求。

2.2 不用注解,使用自定义handler处理图片位置并写入,同时可以加入图片压缩的逻辑,这样比较灵活。

图片压缩依赖:

    <!-- Thumbnailator 图片处理库 -->
    <dependency>
        <groupId>net.coobird</groupId>
        <artifactId>thumbnailator</artifactId>
        <version>0.4.19</version> <!-- 推荐最新稳定版 -->
    </dependency>
</dependencies>

导出实体VO:

import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;

import java.util.List;

@RequiredArgsConstructor
@AllArgsConstructor
@Data
@ExcelIgnoreUnannotated
public class MyData {

    @ExcelProperty(value = "名称",index = 0)
    private String name;

    @ExcelProperty(value = "图片",index = 1)
    private String picture;

    private List<String> signImages;

    // ✅ 改为 List<byte[]> 存多张图片
    private List<byte[]> imageList;
    

    public List<byte[]> getImageList() {
        return ImageUrlConverter.convertUrlListToByteArrayAsync(signImages);
    }
}

下载图片,并支持压缩功能:

package com.gotion.business.school.util;

import lombok.extern.slf4j.Slf4j;
import net.coobird.thumbnailator.Thumbnails;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

/**
 * 将图片 URL 列表转换为 byte[] 列表
 * 支持 HTTP/HTTPS,带超时和异常处理
 */
@Slf4j
public class ImageUrlConverter {

    private static final int TIMEOUT_MS = 10000; // 10秒超时
    private static final String[] VALID_IMAGE_HEADERS = {"image/", "application/octet-stream"};

    private static final int COMPRESS_MAX_WIDTH = 1920; // 增加宽度 1024
    private static final int COMPRESS_MAX_HEIGHT = 1080;  // 增加高度 768
    private static final long TARGET_SIZE_BYTES = 204_800; // ≈200 KB
    private static final float MIN_QUALITY = 0.5f;       // 设置最低质量限制
    private static final float QUALITY_STEP = 0.05f;     // 每次减少0.05
    // 500kb
    private static final long MAX_IMAGE_SIZE = 307_200;
    
    // 默认线程池
    private static final ExecutorService DEFAULT_EXECUTOR = Executors.newFixedThreadPool(5);
    
    /**
     * 将图片 URL 列表转换为 byte[] 列表
     *
     * @param signImages 图片 URL 列表
     * @return List<byte[]>,失败的 URL 返回 null,保持索引一致
     */
    public static List<byte[]> convertUrlListToByteArray(List<String> signImages) {
        if (signImages == null || signImages.isEmpty()) {
            return new ArrayList<>();
        }

        List<byte[]> imageList = new ArrayList<>();
        for (String imageUrl : signImages) {
            //byte[] imageBytes = downloadImageAsBytes(imageUrl);
            byte[] imageBytes = downloadAndProcessImage(imageUrl);
            imageList.add(imageBytes); // 失败时 add(null)
        }
        return imageList;
    }
    /**
     * 下载并处理单个图片(判断大小后决定是否压缩)
     *
     * @param imageUrl 图片 URL
     * @return 图片字节数组,失败返回 null
     */
    private static byte[] downloadAndProcessImage(String imageUrl) {
        byte[] imageData = downloadImageAsBytes(imageUrl);
        if (imageData == null) {
            return null;
        }

        // 如果图片小于500KB,直接返回
        if (imageData.length <= MAX_IMAGE_SIZE) {
            System.out.printf("✅ 图片大小 %d KB,无需压缩%n", imageData.length / 1024);
            return imageData;
        } else {
            System.out.printf("⚠️ 图片大小 %d KB,需要压缩%n", imageData.length / 1024);
            return createThumbnail(imageData);
        }
    }

    /**
     * 使用 Thumbnailator 生成压缩缩略图字节数组
     */
    private static byte[] createThumbnail(byte[] originalImageBytes) {
        ByteArrayInputStream bis = null;
        ByteArrayOutputStream baos = null;

        try {
            bis = new ByteArrayInputStream(originalImageBytes);
            BufferedImage originalImage = ImageIO.read(bis);

            if (originalImage == null) {
                System.err.println("❌ 无法读取图片格式,跳过压缩");
                return originalImageBytes; // 不支持的格式(如 ICO、WebP 等)
            }

            // 获取原始尺寸
            int width = originalImage.getWidth();
            int height = originalImage.getHeight();

            // ✅ 保持宽高比,缩放到最大尺寸内
            double ratio = Math.min(
                    (double) COMPRESS_MAX_WIDTH / width,
                    (double) COMPRESS_MAX_HEIGHT / height
            );
            int scaledWidth = (int) (width * ratio);
            int scaledHeight = (int) (height * ratio);

            // 确保至少 50x50
            scaledWidth = Math.max(scaledWidth, 60);
            scaledHeight = Math.max(scaledHeight, 60);

            System.out.printf("原图 %dx%d → 缩放至 %dx%d%n", width, height, scaledWidth, scaledHeight);

            float quality = 0.9f; // 初始质量 80%
            byte[] result = null;

            while (quality >= MIN_QUALITY) {
                baos = new ByteArrayOutputStream();

                Thumbnails.of(originalImage)
                        .size(scaledWidth, scaledHeight)
                        .outputFormat("jpg")
                        .outputQuality(quality)
                        .toOutputStream(baos);

                result = baos.toByteArray();

                long sizeInKB = result.length / 1024;
                System.out.printf("尝试质量 %.2f → 输出大小: %d KB%n", quality, sizeInKB);

                // 如果压缩后 ≤ 300KB,接受结果
                if (result.length <= TARGET_SIZE_BYTES) {
                    System.out.printf("✅ 压缩成功: %d KB, 质量=%.2f%n", sizeInKB, quality);
                    return result;
                }

                quality -= QUALITY_STEP; // 减少质量
            }

            // 即使最低质量仍大于 300KB,返回最接近的结果
            long finalSizeKB = result.length / 1024;
            if (finalSizeKB > 500) {
                System.out.printf("⚠️ 无法压缩到 300KB 以内,最终大小: %d KB%n", finalSizeKB);
            } else {
                System.out.printf("🟡 接近目标: %d KB%n", finalSizeKB);
            }
            return result;

        } catch (IOException e) {
            System.err.println("❌ 图片压缩失败: " + e.getMessage());
            log.error("图片压缩失败", e);
            return originalImageBytes; // 失败则返回原图
        } finally {
            try {
                if (bis != null) bis.close();
            } catch (IOException ignored) {}
            try {
                if (baos != null) baos.close();
            } catch (IOException ignored) {}
        }
    }
    /**
     * 下载单个图片为 byte[]
     *
     * @param imageUrl 图片 URL
     * @return 图片字节数组,失败返回 null
     */
    private static byte[] downloadImageAsBytes(String imageUrl) {
        if (imageUrl == null || imageUrl.trim().isEmpty()) {
            System.err.println("❌ 空的图片 URL");
            return null;
        }

        imageUrl = imageUrl.trim();

        // 补全协议(如果缺失)
        if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
            imageUrl = "https://" + imageUrl;
        }

        InputStream inputStream = null;
        ByteArrayOutputStream buffer = null;
        HttpURLConnection connection = null;

        try {
            URL url = new URL(imageUrl);
            connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("GET");
            connection.setConnectTimeout(TIMEOUT_MS);
            connection.setReadTimeout(TIMEOUT_MS);
            connection.setInstanceFollowRedirects(true); // 支持重定向

            // 设置 User-Agent(防止被屏蔽)
            connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");

            int responseCode = connection.getResponseCode();
            if (responseCode != 200) {
                System.err.println("❌ HTTP " + responseCode + " - 无法下载图片: " + imageUrl);
                return null;
            }

            // 验证 Content-Type 是否为图片
            String contentType = connection.getContentType();
            if (!isImageContentType(contentType)) {
                System.err.println("⚠️  非图片类型 (" + contentType + "): " + imageUrl);
                // 有些服务器不返回正确类型,所以不直接返回 null
            }

            inputStream = connection.getInputStream();
            buffer = new ByteArrayOutputStream();

            byte[] tempBuffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = inputStream.read(tempBuffer)) != -1) {
                buffer.write(tempBuffer, 0, bytesRead);
            }

            byte[] imageData = buffer.toByteArray();
            if (imageData.length == 0) {
                System.err.println("❌ 下载的图片为空: " + imageUrl);
                return null;
            }

            System.out.println("✅ 成功下载图片: " + imageUrl + " (" + imageData.length / 1024 + " KB)");
            return imageData;

        } catch (IOException e) {
            System.err.println("❌ 下载图片失败: " + imageUrl + " | " + e.getMessage());
            return null;
        } finally {
            closeQuietly(inputStream);
            closeQuietly(buffer);
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    /**
     * 判断 Content-Type 是否为图片类型
     */
    private static boolean isImageContentType(String contentType) {
        if (contentType == null) return false;
        for (String header : VALID_IMAGE_HEADERS) {
            if (contentType.toLowerCase().startsWith(header)) {
                return true;
            }
        }
        return false;
    }
/**
     * 安全关闭 Closeable 资源
     */
    private static void closeQuietly(Closeable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (IOException e) {
                // 忽略
            }
        }
    }

    public static List<byte[]> convertUrlListToByteArrayAsync(List<String> signImages) {
        if (signImages == null || signImages.isEmpty()) return new ArrayList<>();

        int size = signImages.size();
        List<byte[]> results = new ArrayList<>(Collections.nCopies(size, null));
        ExecutorService executor = Executors.newFixedThreadPool(
                Math.min(size, 10));
        try  {

            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < size; i++) {
                final int index = i;
                Future<?> future = executor.submit(() -> {
                    byte[] data = downloadAndProcessImage(signImages.get(index));
                    results.set(index, data);
                });
                futures.add(future);
            }

            // 等待完成
            for (Future<?> f : futures) {
                try {
                    f.get(10, TimeUnit.SECONDS); // ✅ 加超时,避免卡死
                } catch (Exception e) {
                    int idx = futures.indexOf(f);
                    results.set(idx, null); // 标记失败
                }
            }
        }finally {
            executor.shutdown();
        }

        return results;
    }
}

自定义handler:


import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteTableHolder;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.util.Units;

import java.util.*;
@Slf4j
public class ThumbnailWriteHandler implements CellWriteHandler {

    private final Map<String, List<byte[]>> imageDataMap = new HashMap<>();
    private int imageColumnIndex = 1; // 图片所在列索引

    private static final int DEFAULT_IMAGE_WIDTH_PX = 60;
    private static final int DEFAULT_IMAGE_HEIGHT_PX = 60;
    private static final int IMAGE_SPACING_PX = 2;
    private static final double POINTS_PER_PIXEL = 72.0 / 96.0;
    private static final double PIXELS_PER_CHAR = 8.0;
    private int maxImageCount = 0; // 记录所有行中最多的图片数量
    private boolean columnWidthSet = false; // 标记列宽是否已设置

    private final int imageWidthPx;
    private final int imageHeightPx;

    public ThumbnailWriteHandler() {
        this(DEFAULT_IMAGE_WIDTH_PX, DEFAULT_IMAGE_HEIGHT_PX);
    }
    public ThumbnailWriteHandler(int imageWidthPx, int imageHeightPx) {
        this.imageWidthPx = imageWidthPx;
        this.imageHeightPx = imageHeightPx;
    }
    public ThumbnailWriteHandler(int imageWidthPx, int imageHeightPx, int imageColumnIndex) {
        this.imageWidthPx = imageWidthPx;
        this.imageHeightPx = imageHeightPx;
        this.imageColumnIndex = imageColumnIndex;
    }


    // 外部传入数据:rowIndex -> 图片列表
    public void setImageData(Map<Integer, List<byte[]>> data) {
        this.imageDataMap.clear();
        maxImageCount = 0; // 重置

        for (Map.Entry<Integer, List<byte[]>> entry : data.entrySet()) {
            List<byte[]> images = entry.getValue();
            if (images != null && !images.isEmpty()) {
                maxImageCount = Math.max(maxImageCount, images.size());
            }
            this.imageDataMap.put("row_" + entry.getKey(), images);
        }
    }

    @Override
    public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder,
                                 List<WriteCellData<?>> cellDataList, Cell cell,
                                 Head head, Integer relativeRowIndex, Boolean isHead) {
        if (isHead || cell == null) return;

        Sheet sheet = cell.getSheet();
        Workbook workbook = sheet.getWorkbook();


        int colIndex = cell.getColumnIndex();
        int rowIndex = cell.getRowIndex();

        // 假设第 2 列(index=1)是你要插入缩略图的位置
        if (colIndex == imageColumnIndex) {
            List<byte[]> images = imageDataMap.get("row_" + rowIndex);
            if (images != null && !images.isEmpty()) {
                setOptimalRowHeight(sheet, rowIndex);
                // ✅ 只设置一次列宽
                if (!columnWidthSet) {
                    setOptimalColumnWidth(sheet, colIndex);
                    columnWidthSet = true;
                }
                insertThumbnails(workbook, sheet, cell, images);
            }
        }
    }

    private void setOptimalRowHeight(Sheet sheet, int rowIndex) {
        Row row = sheet.getRow(rowIndex);
        if (row == null) row = sheet.createRow(rowIndex);
        double heightInPoints = imageHeightPx * POINTS_PER_PIXEL;
        row.setHeightInPoints((float) heightInPoints);
    }

    private void setOptimalColumnWidth(Sheet sheet, int columnIndex) {
        //double totalWidthPx = imageWidthPx * imageCount + IMAGE_SPACING_PX * (imageCount - 1);
        double totalWidthPx = imageWidthPx * maxImageCount + IMAGE_SPACING_PX * (maxImageCount - 1);
        double widthInChars = totalWidthPx / PIXELS_PER_CHAR;
        int columnWidth = (int) (widthInChars * 256);
        columnWidth = Math.max(columnWidth, 6_000); // 至少 6000
        sheet.setColumnWidth(columnIndex, columnWidth);
    }


    private void insertThumbnails(Workbook workbook, Sheet sheet, Cell cell,
                                  List<byte[]> imageBytesList) {

        Drawing<?> drawing = sheet.getDrawingPatriarch();
        if (drawing == null) {
            drawing = sheet.createDrawingPatriarch();
        }

        int colIndex = cell.getColumnIndex();
        int rowIndex = cell.getRowIndex();

        int thumbWidth = imageWidthPx;   // 使用构造函数传入的宽度
        int margin = IMAGE_SPACING_PX;   // 使用类常量

        int xOffset = margin; // 所有图片从左侧 margin 开始

        //Set<String> added = new HashSet<>();

        for (byte[] imageBytes : imageBytesList) {
            if (imageBytes == null || imageBytes.length == 0) continue;

            // ✅ 可选:关闭去重(如果你确认图片不同但仍被跳过)
            // String hash = DigestUtils.md5Hex(imageBytes);
            // if (added.contains(hash)) continue;

            try {
//                byte[] imageToInsert;
//
//                // ✅ 判断:只有大于 1MB 才压缩
//                if (imageBytes.length > MAX_IMAGE_SIZE) { // 1MB = 1,000,000 字节
//                    //System.out.println("压缩图片: " + imageBytes.length + " bytes -> " + imageWidthPx + "x" + imageHeightPx);
//                   // imageToInsert = createThumbnail2(imageBytes, imageWidthPx, imageHeightPx);
//                    imageToInsert = createThumbnailByThumbnailator(imageBytes);
//                } else {
//                    System.out.println("小图不压缩,直接插入: " + imageBytes.length + " bytes");
//                    imageToInsert = imageBytes; // 直接使用原图
//                }
//
//                int pictureType = determinePictureType(imageToInsert);
//                int pictureIdx = workbook.addPicture(imageToInsert, pictureType);
                int pictureType = determinePictureType(imageBytes);
                int pictureIdx = workbook.addPicture(imageBytes, pictureType);

                ClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, colIndex, rowIndex, colIndex, rowIndex);
                anchor.setDx1(Units.pixelToEMU(xOffset));
                anchor.setDy1(Units.pixelToEMU(margin));
                anchor.setDx2(Units.pixelToEMU(xOffset + thumbWidth));
                anchor.setDy2(Units.pixelToEMU(margin + imageHeightPx));
                anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);

                drawing.createPicture(anchor, pictureIdx);
                // added.add(hash); // ✅ 如果关闭去重,注释这行

                // 只更新 x 偏移,y 不变(同一行)
                xOffset += thumbWidth + margin;

            } catch (Exception e) {
                System.err.println("插入缩略图失败: " + e.getMessage());
            }
        }
    }


    private int determinePictureType(byte[] data) {
        if (data == null || data.length < 3) {
            return Workbook.PICTURE_TYPE_DIB;
        }

        // JPEG
        if ((data[0] & 0xFF) == 0xFF && (data[1] & 0xFF) == 0xD8) {
            return Workbook.PICTURE_TYPE_JPEG;
        }
        // WMF
        if (data[0] == 'W' && data[1] == 'M' && data[2] == 'F') {
            return Workbook.PICTURE_TYPE_WMF;
        }
        // PNG
        if (data.length >= 8 &&
                data[0] == (byte) 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' &&
                data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A) {
            return Workbook.PICTURE_TYPE_PNG;
        }
        // EMF (更准确的检测)
        if (data.length >= 4) {
            // EMF文件以EMF元文件头开始
            if (data[0] == 0x01 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x00) {
                return Workbook.PICTURE_TYPE_EMF;
            }
            // 或者检查" EMF"签名(在某些EMF文件中存在)
            if (data.length >= 8 &&
                    data[0] == 0x20 && data[1] == 0x45 && data[2] == 0x4D && data[3] == 0x46) {
                return Workbook.PICTURE_TYPE_EMF;
            }
        }
        // BMP/DIB
        if (data[0] == 'B' && data[1] == 'M') {
            return Workbook.PICTURE_TYPE_DIB;
        }

        // 默认返回DIB
        return Workbook.PICTURE_TYPE_DIB;
    }

}

数据测试:


import cn.hutool.core.date.StopWatch;
import com.alibaba.excel.EasyExcel;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class TestMyData {
    public static void main(String[] args) {
        StopWatch stopWatch = new StopWatch("多缩略图测试");
        stopWatch.start();
        MyData myData = new MyData();
        myData.setName("张三");
        myData.setSignImages(List.of("https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/banner_20250813202603A007.png", "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/08/1_20250808143309A006.JPEG",
                "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/touxiang_20250813203853A009.jpg","https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/14/7e2c5ca66699934eb4991db8fb9e5c32_origin(1)_20250814170814A004.jpg"));


        MyData myData2 = new MyData();
        myData2.setName("李四");
        myData2.setSignImages(List.of("https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/14/tmp_3783a8a1124b11c5d87fc525636a1249458fb04850517a40_20250814141318A005.jpg", "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/a3f5dd3c-dd99-468c-bc37-a785961a6bac_20250813191852A006.jpg",
                "https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/13/4c164be45de6c822ba9ccc5d0bcacbc8_20250813202916A004.png"));

        List<MyData> data = new ArrayList<>();
        data.add(myData);
        data.add(myData2);
        String fileName = "多缩略图测试.xlsx";

        // 构建图片数据映射
        ThumbnailWriteHandler handler = new ThumbnailWriteHandler();
        Map<Integer, List<byte[]>> imageMap = new HashMap<>();
        for (int i = 0; i < data.size(); i++) {
            imageMap.put(i+1, data.get(i).getImageList()); // 提前准备好图片
        }
        handler.setImageData(imageMap);

        EasyExcel.write(fileName, MyData.class)
                .registerWriteHandler(handler)
                .sheet("学生信息")
                .doWrite(data);
        stopWatch.stop();
        System.out.println("耗时:"+stopWatch.getTotalTimeMillis());

    }

}

效果如下:

image-20260223195552606

缺点:导出的图片经过压缩,不是原图,如果必须要原图,那么可以去掉图片压缩逻辑。

3、小结

easyExcel支持导出图片,可以直接使用,详细参考官方文档;如果导出图片有位置要求,或者需要压缩处理等其他需求,那么最好自定义handler,实现相关功能。