前言
在现代应用开发中,高效、可靠的图片处理能力是提升用户体验的关键环节。本文介绍的图片处理工具类基于 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
方法介绍
函数名 | 参数 | 功能 | 技术实现 |
---|---|---|---|
compressImage | file (图片文件), outputFormat (输出格式) | 按默认比例(0.5)压缩图片 | Thumbnails.scale(),自适应JPEG/PNG质量 |
batchCompressImages | files (图片数组), outputFormat (输出格式) | 批量压缩多张图片 | 循环调用compressImage |
resize | file (图片文件), scaleRatio (缩放比例), outputFormat (输出格式) | 按比例缩放(保持宽高比) | Thumbnails.scale(),比例钳制(0.01-10) |
resizeToDimension | file (图片文件), width (目标宽度), height (目标高度), outputFormat (输出格式) | 缩放到指定尺寸(可能变形) | Thumbnails.size() |
rotate | file (图片文件), degrees (旋转角度), outputFormat (输出格式) | 旋转任意角度 | Thumbnails.rotate() |
addTextWatermark | file (图片文件), 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技术栈,尝试覆盖常见的图片处理需求,包括基本压缩、尺寸调整、旋转和水印添加等功能。通过合理的参数校验和资源管理设计,努力确保工具的稳定性和易用性。
在常见的图片处理场景中,如用户头像上传、产品图展示等,该工具类可能提供一定的便利性。其接口设计尽量保持简洁,希望能降低开发者的使用门槛。当然,实际应用效果可能因具体业务场景而异,建议开发者根据项目需求进行必要的测试和调整。