图片处理工具类:基于 Thumbnailator 的便捷解决方案


前言

在现代应用开发中,高效、可靠的图片处理能力是提升用户体验的关键环节。本文介绍的图片处理工具类基于 Thumbnailator 和 Java2D 技术栈,提供了从图片压缩、尺寸调整到水印添加的一站式解决方案。通过简洁的API设计和严谨的资源管理,开发者可以轻松实现各种图片处理需求,避免重复造轮子,提高开发效率。

一、依赖坐标

核心依赖

		<!--图片处理——thumbnailator-->
        <dependency>
            <groupId>net.coobird</groupId>
            <artifactId>thumbnailator</artifactId>
            <version>0.4.8</version>
        </dependency>

依赖说明

依赖功能版本要求
thumbnailator提供图片缩放、旋转等基础功能≥0.4.8
Java2D API实现水印、画布操作等高级功能JDK

二、工具类:ImageUtil

方法介绍

函数名参数功能技术实现
compressImagefile (图片文件), outputFormat (输出格式)按默认比例(0.5)压缩图片Thumbnails.scale(),自适应JPEG/PNG质量
batchCompressImagesfiles (图片数组), outputFormat (输出格式)批量压缩多张图片循环调用compressImage
resizefile (图片文件), scaleRatio (缩放比例), outputFormat (输出格式)按比例缩放(保持宽高比)Thumbnails.scale(),比例钳制(0.01-10)
resizeToDimensionfile (图片文件), width (目标宽度), height (目标高度), outputFormat (输出格式)缩放到指定尺寸(可能变形)Thumbnails.size()
rotatefile (图片文件), degrees (旋转角度), outputFormat (输出格式)旋转任意角度Thumbnails.rotate()
addTextWatermarkfile (图片文件), watermarkText (水印文字), font (字体), color (颜色), position (位置), opacity (透明度), outputFormat (输出格式)添加自定义文字水印BufferedImage + Graphics2D,抗锯齿渲染

完整代码

import net.coobird.thumbnailator.Thumbnails;
import net.coobird.thumbnailator.geometry.Positions;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

/**
 * 图片处理工具类(压缩、缩放、旋转、加水印)
 * 基于 Thumbnailator + Java2D,线程安全,资源安全
 */
public class ImageUtil {

    // 默认参数
    private static final float DEFAULT_SCALE_RATIO = 0.5f;
    private static final int DEFAULT_JPEG_QUALITY = 85; // 1-100
    private static final Font DEFAULT_FONT = new Font("微软雅黑", Font.BOLD, 24);
    private static final Color DEFAULT_COLOR = Color.WHITE;
    private static final float DEFAULT_OPACITY = 0.8f;

    /**
     * 压缩图片(默认比例 0.5,JPEG 质量 85)
     *
     * @param file         上传文件
     * @param outputFormat 输出格式:JPEG/PNG
     * @return 压缩后的字节数组
     * @throws IOException 处理失败
     */
    public static byte[] compressImage(MultipartFile file, String outputFormat) throws IOException {
        return resize(file, DEFAULT_SCALE_RATIO, outputFormat);
    }

    /**
     * 批量压缩图片
     *
     * @param files        文件数组
     * @param outputFormat 输出格式
     * @return 每个文件压缩后的字节数组
     * @throws IOException 任一文件失败则抛出
     */
    public static byte[][] batchCompressImages(MultipartFile[] files, String outputFormat) throws IOException {
        byte[][] results = new byte[files.length][];
        for (int i = 0; i < files.length; i++) {
            results[i] = compressImage(files[i], outputFormat);
        }
        return results;
    }

    /**
     * 按比例缩放图片(保持宽高比)
     *
     * @param file         原图
     * @param scaleRatio   缩放比例 (0.01 ~ 10]
     * @param outputFormat 输出格式
     * @return 缩放后图片数据
     * @throws IOException 处理失败
     */
    public static byte[] resize(MultipartFile file, float scaleRatio, String outputFormat) throws IOException {
        validateFile(file);
        validateScaleRatio(scaleRatio);
        String format = validateFormat(outputFormat);

        try (InputStream in = file.getInputStream()) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            Thumbnails.of(in)
                    .scale(clamp(scaleRatio, 0.01f, 10.0f))
                    .outputFormat(format)
                    .outputQuality(getQuality(format))
                    .toOutputStream(out);
            return out.toByteArray();
        }
    }

    /**
     * 按指定宽高缩放(可能变形)
     *
     * @param file         原图
     * @param width        目标宽度 > 0
     * @param height       目标高度 > 0
     * @param outputFormat 输出格式
     * @return 缩放后数据
     * @throws IOException 处理失败
     */
    public static byte[] resizeToDimension(MultipartFile file, int width, int height, String outputFormat) throws IOException {
        validateFile(file);
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("宽高必须大于 0");
        }
        String format = validateFormat(outputFormat);

        try (InputStream in = file.getInputStream()) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            Thumbnails.of(in)
                    .size(width, height)
                    .outputFormat(format)
                    .outputQuality(getQuality(format))
                    .toOutputStream(out);
            return out.toByteArray();
        }
    }

    /**
     * 旋转图片
     *
     * @param file         原图
     * @param degrees      角度:90, 180, 270(支持负数)
     * @param outputFormat 输出格式
     * @return 旋转后数据
     * @throws IOException 处理失败
     */
    public static byte[] rotate(MultipartFile file, int degrees, String outputFormat) throws IOException {
        validateFile(file);
        String format = validateFormat(outputFormat);

        try (InputStream in = file.getInputStream()) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            Thumbnails.of(in)
                    .scale(1.0)
                    .rotate(degrees)
                    .outputFormat(format)
                    .toOutputStream(out);
            return out.toByteArray();
        }
    }

    /**
     * 添加文字水印
     *
     * @param file          原图
     * @param watermarkText 水印文字
     * @param font          字体(null 则用默认)
     * @param color         颜色(null 则白色)
     * @param position      位置(如 BOTTOM_RIGHT)
     * @param opacity       透明度 [0.0 ~ 1.0]
     * @param outputFormat  输出格式
     * @return 加水印后图片数据
     * @throws IOException 处理失败
     */
    public static byte[] addTextWatermark(MultipartFile file, String watermarkText, Font font,
                                          Color color, Positions position, float opacity,
                                          String outputFormat) throws IOException {

        validateFile(file);
        if (watermarkText == null || watermarkText.trim().isEmpty()) {
            throw new IllegalArgumentException("水印文字不能为空");
        }
        String format = validateFormat(outputFormat);
        font = (font != null) ? font : DEFAULT_FONT;
        color = (color != null) ? color : DEFAULT_COLOR;
        opacity = clamp(opacity, 0.0f, 1.0f);
        position = (position != null) ? position : Positions.BOTTOM_RIGHT;

        BufferedImage originalImage;
        try (InputStream in = file.getInputStream()) {
            originalImage = ImageIO.read(in);
            if (originalImage == null) {
                throw new IOException("无法识别的图片格式,请上传有效的图片文件。");
            }
        }

        BufferedImage watermarked = new BufferedImage(
                originalImage.getWidth(), originalImage.getHeight(), BufferedImage.TYPE_INT_ARGB);

        Graphics2D g2d = watermarked.createGraphics();
        g2d.drawImage(originalImage, 0, 0, null);

        // 抗锯齿
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
        g2d.setFont(font);
        g2d.setColor(color);
        g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));

        FontMetrics fm = g2d.getFontMetrics();
        int tw = fm.stringWidth(watermarkText);
        int th = fm.getAscent();
        int x = 0, y = 0;

        switch (position) {
            case BOTTOM_RIGHT:
                x = originalImage.getWidth() - tw - 10;
                y = originalImage.getHeight() - 10;
                break;
            case CENTER:
                x = (originalImage.getWidth() - tw) / 2;
                y = (originalImage.getHeight() + th) / 2;
                break;
            case TOP_LEFT:
                x = 10;
                y = th + 10;
                break;
            case TOP_RIGHT:
                x = originalImage.getWidth() - tw - 10;
                y = th + 10;
                break;
            case BOTTOM_LEFT:
                x = 10;
                y = originalImage.getHeight() - 10;
                break;
            default:
                x = 10;
                y = th + 10;
        }

        g2d.drawString(watermarkText, x, y);
        g2d.dispose();

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        boolean success = ImageIO.write(watermarked, format, out);
        if (!success) {
            throw new IOException("图片写入失败,不支持的格式: " + format);
        }
        return out.toByteArray();
    }


    private static void validateFile(MultipartFile file) throws IOException {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("图片文件不能为空");
        }
        if (file.getSize() > 10 * 1024 * 1024) { // 10MB 限制
            throw new IOException("图片大小不能超过 10MB");
        }
    }

    private static String validateFormat(String format) {
        if (format == null) format = "JPEG";
        format = format.trim().toUpperCase();
        if (!"JPEG".equals(format) && !"JPG".equals(format) && !"PNG".equals(format)) {
            throw new IllegalArgumentException("仅支持 JPEG 和 PNG 格式");
        }
        return "JPEG".equals(format) || "JPG".equals(format) ? "JPEG" : "PNG";
    }

    private static double getQuality(String format) {
        return "JPEG".equalsIgnoreCase(format) ? DEFAULT_JPEG_QUALITY / 100.0 : 1.0;
    }

    private static float clamp(float value, float min, float max) {
        return Math.max(min, Math.min(max, value));
    }

    private static void validateScaleRatio(float scaleRatio) {
        if (scaleRatio <= 0 || scaleRatio > 10) {
            throw new IllegalArgumentException("缩放比例应在 (0, 10] 范围内");
        }
    }
}

三、测试

测试策略验证点预期结果
压缩功能输出非空,尺寸减小生成有效图片,大小缩减50%
比例缩放宽高比保持不变新尺寸 = 原尺寸 * scaleRatio
指定尺寸精确匹配目标尺寸输出图片宽高 = 指定值
90°旋转宽高互换width_新 = height_原
水印添加文字位置正确右下角偏移(10,10)像素
空文件异常处理抛出 IllegalArgumentException

1.测试配置

  • 测试框架:JUnit 5

    <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    
  • 测试图片:943×1036JPEG (169.25KB)

  • 模拟文件:MockMultipartFile

2. 图片压缩测试

@Test
void testCompressImage() throws IOException {
    byte[] result = ImageUtil.compressImage(testImage, "JPEG");
    assertNotNull(result);
    assertTrue(result.length > 0);
    System.out.println("压缩后大小: " + result.length / 1024 + " KB");
}

在这里插入图片描述

3. 比例缩放测试

@Test
void testResize() throws IOException {
    byte[] result = ImageUtil.resize(testImage, 0.3f, "JPEG");
    BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));
    assertNotNull(img);
    System.out.println("缩放后尺寸: " + img.getWidth() + "x" + img.getHeight());
}

在这里插入图片描述

4. 指定尺寸缩放测试

@Test
void testResizeToDimension() throws IOException {
    byte[] result = ImageUtil.resizeToDimension(testImage, 200, 200, "PNG");
    BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));

    // 计算基于原图宽高比的目标尺寸
    double aspectRatio = 943.0 / 1036.0;
    int expectedWidth = (int) Math.round(200 * aspectRatio);
    int expectedHeight = 200;

    assertEquals(expectedWidth, img.getWidth(), "宽度应符合预期");
    assertEquals(expectedHeight, img.getHeight(), "高度应符合预期");
}

在这里插入图片描述

5. 图片旋转测试

@Test
void testRotate() throws IOException {
    byte[] result = ImageUtil.rotate(testImage, 90, "JPEG");
    BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));
    // 注意:旋转后宽高互换
    assertTrue(img.getWidth() > 0 && img.getHeight() > 0);
    // 可选:验证旋转90度后宽高互换
    BufferedImage original = ImageIO.read(new ByteArrayInputStream(testImage.getBytes()));
    assertEquals(original.getWidth(), img.getHeight(), "旋转90度后高度应等于原宽度");
    assertEquals(original.getHeight(), img.getWidth(), "旋转90度后宽度应等于原高度");
}

在这里插入图片描述

6. 水印添加测试

@Test
void testAddTextWatermark() throws IOException {
    byte[] result = ImageUtil.addTextWatermark(
            testImage,
            "测试水印",
            new java.awt.Font("宋体", java.awt.Font.BOLD, 30),
            java.awt.Color.RED,
            Positions.BOTTOM_RIGHT,
            0.7f,
            "PNG"
    );

    BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));
    assertNotNull(img);
    System.out.println("加水印后尺寸: " + img.getWidth() + "x" + img.getHeight());

    ImageIO.write(img, "png", Files.newOutputStream(Paths.get("src/main/resources/picture/test.png")));
}

在这里插入图片描述
在这里插入图片描述

7. 异常处理测试

@Test
void testInvalidFile() {
    MockMultipartFile emptyFile = new MockMultipartFile("file", new byte[0]);
    assertThrows(IllegalArgumentException.class, () -> {
        ImageUtil.compressImage(emptyFile, "JPEG");
    });
}

在这里插入图片描述

完整代码

import com.fc.utils.ImageUtil;
import net.coobird.thumbnailator.geometry.Positions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

import static org.junit.jupiter.api.Assertions.*;

public class ImageUtilTest {

    private MockMultipartFile testImage;

    @BeforeEach
    void setUp() throws IOException {
        // 读取本地测试图片(提前准备一张 test.jpg)
        byte[] imageBytes = Files.readAllBytes(Paths.get("src/main/resources/picture/test.png"));
        testImage = new MockMultipartFile(
                "file",
                "test.jpg",
                "image/jpeg",
                imageBytes
        );
    }

    @Test
    void testCompressImage() throws IOException {
        byte[] result = ImageUtil.compressImage(testImage, "JPEG");
        assertNotNull(result);
        assertTrue(result.length > 0);
        System.out.println("压缩后大小: " + result.length / 1024 + " KB");
    }

    @Test
    void testResize() throws IOException {
        byte[] result = ImageUtil.resize(testImage, 0.3f, "JPEG");
        BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));
        assertNotNull(img);
        System.out.println("缩放后尺寸: " + img.getWidth() + "x" + img.getHeight());
    }

    @Test
    void testResizeToDimension() throws IOException {
        byte[] result = ImageUtil.resizeToDimension(testImage, 200, 200, "PNG");
        BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));

        // 计算基于原图宽高比的目标尺寸
        double aspectRatio = 943.0 / 1036.0;
        int expectedWidth = (int) Math.round(200 * aspectRatio);
        int expectedHeight = 200;

        assertEquals(expectedWidth, img.getWidth(), "宽度应符合预期");
        assertEquals(expectedHeight, img.getHeight(), "高度应符合预期");
    }

    @Test
    void testRotate() throws IOException {
        byte[] result = ImageUtil.rotate(testImage, 90, "JPEG");
        BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));
        // 注意:旋转后宽高互换
        assertTrue(img.getWidth() > 0 && img.getHeight() > 0);
        // 可选:验证旋转90度后宽高互换
        BufferedImage original = ImageIO.read(new ByteArrayInputStream(testImage.getBytes()));
        assertEquals(original.getWidth(), img.getHeight(), "旋转90度后高度应等于原宽度");
        assertEquals(original.getHeight(), img.getWidth(), "旋转90度后宽度应等于原高度");
    }

    @Test
    void testAddTextWatermark() throws IOException {
        byte[] result = ImageUtil.addTextWatermark(
                testImage,
                "测试水印",
                new java.awt.Font("宋体", java.awt.Font.BOLD, 30),
                java.awt.Color.RED,
                Positions.BOTTOM_RIGHT,
                0.7f,
                "PNG"
        );

        BufferedImage img = ImageIO.read(new ByteArrayInputStream(result));
        assertNotNull(img);
        System.out.println("加水印后尺寸: " + img.getWidth() + "x" + img.getHeight());
        
        ImageIO.write(img, "png", Files.newOutputStream(Paths.get("src/main/resources/picture/test.png")));
    }

    @Test
    void testInvalidFile() {
        MockMultipartFile emptyFile = new MockMultipartFile("file", new byte[0]);
        assertThrows(IllegalArgumentException.class, () -> {
            ImageUtil.compressImage(emptyFile, "JPEG");
        });
    }
}

总结

本图片处理工具类旨在为开发者提供一个简洁、实用的图片处理解决方案。它基于成熟的Thumbnailator和Java2D技术栈,尝试覆盖常见的图片处理需求,包括基本压缩、尺寸调整、旋转和水印添加等功能。通过合理的参数校验和资源管理设计,努力确保工具的稳定性和易用性。
在常见的图片处理场景中,如用户头像上传、产品图展示等,该工具类可能提供一定的便利性。其接口设计尽量保持简洁,希望能降低开发者的使用门槛。当然,实际应用效果可能因具体业务场景而异,建议开发者根据项目需求进行必要的测试和调整。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值