feat(image): 添加打印机默认水印操作器并优化图片处理逻辑- 新增 PrinterDefaultWatermarkOperator 实现自定义水印处理

- 在 ImageWatermarkOperatorEnum 中添加 PRINTER_DEFAULT 类型
- 更新 ImageWatermarkFactory 以支持新的水印操作器
- 调整日期格式为 yyyy.MM.dd 用于打印场景
-优化 ImageUtils 中的图片旋转逻辑,仅支持270度旋转
- 移除对90度旋转的支持以简化处理流程
This commit is contained in:
2025-11-07 22:38:02 +08:00
parent ea48f03bbc
commit 1821ba9f58
5 changed files with 239 additions and 29 deletions

View File

@@ -6,6 +6,7 @@ import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.image.watermark.operator.DefaultImageWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
public class ImageWatermarkFactory {
public static IOperator get(String watermarkType) {
@@ -20,6 +21,7 @@ public class ImageWatermarkFactory {
case WATERMARK -> new DefaultImageWatermarkOperator();
case NORMAL -> new NormalWatermarkOperator();
case LEICA -> new LeicaWatermarkOperator();
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
default -> throw new ImageWatermarkUnsupportedException("不支持的类型" + type.name());
};
}

View File

@@ -6,7 +6,8 @@ import lombok.Getter;
public enum ImageWatermarkOperatorEnum {
WATERMARK("defW", "jpg"),
LEICA("leica", "png"),
NORMAL("normal", "png");
NORMAL("normal", "png"),
PRINTER_DEFAULT("pDefault", "png");
private final String type;
private final String preferFileType;

View File

@@ -0,0 +1,215 @@
package com.ycwl.basic.image.watermark.operator;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
public class PrinterDefaultWatermarkOperator implements IOperator {
private static final String FONT_PATH = "/PingFang_SC.ttf";
public static String defaultFontName;
public static float FONT_GLOBAL_OFFSET_PERCENT = 0;
static {
try {
// 加载字体文件流
InputStream fontStream = LeicaWatermarkOperator.class.getResourceAsStream(FONT_PATH);
if (fontStream == null) {
throw new RuntimeException("字体文件未找到!路径:" + FONT_PATH);
}
// 创建字体对象
Font customFont = Font.createFont(Font.TRUETYPE_FONT, fontStream);
// 注册字体到系统
GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
ge.registerFont(customFont);
// 更新默认字体名称为新字体的逻辑名称
defaultFontName = customFont.getName(); // 如 "PingFang SC"
FONT_GLOBAL_OFFSET_PERCENT = -0.3f;
} catch (FontFormatException | IOException e) {
log.error("加载字体文件失败", e);
defaultFontName = "宋体";
}
}
public static int EXTRA_BORDER_PX = 0;
public static int OFFSET_Y = 20;
public static Color BG_COLOR = Color.WHITE;
public static int QRCODE_SIZE = 150;
public static double QRCODE_LEFT_MARGIN_RATIO = 0.07; // 二维码距离左边缘的图片宽度比例
public static int QRCODE_OFFSET_Y = -20;
public static int SCENIC_FONT_SIZE = 42;
public static Color scenicColor = Color.white;
public static int DATETIME_FONT_SIZE = 42;
public static Color datetimeColor = Color.white;
public static double TEXT_RIGHT_MARGIN_RATIO = 0.05; // 文字距离右边缘的图片宽度比例
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
BufferedImage baseImage;
BufferedImage qrcodeImage;
BufferedImage faceImage = null;
try {
baseImage = ImageIO.read(info.getOriginalFile());
qrcodeImage = ImageIO.read(info.getQrcodeFile());
if (info.getFaceFile() != null && info.getFaceFile().isFile()) {
try {
faceImage = ImageIO.read(info.getFaceFile());
} catch (IOException e) {
log.warn("头像文件读取失败", e);
}
}
} catch (IOException e) {
throw new ImageWatermarkException("图片打开失败");
}
// 新图像画布
BufferedImage newImage = new BufferedImage(baseImage.getWidth() + 2 * EXTRA_BORDER_PX, baseImage.getHeight() + 2 * EXTRA_BORDER_PX, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = newImage.createGraphics();
g2d.setColor(BG_COLOR);
g2d.fillRect(0, 0, newImage.getWidth(), newImage.getHeight());
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(baseImage, EXTRA_BORDER_PX, EXTRA_BORDER_PX, null);
int newQrcodeHeight = QRCODE_SIZE;
int newQrcodeWidth = (int) (newQrcodeHeight * 1.0 / qrcodeImage.getHeight() * qrcodeImage.getWidth());
Font scenicFont = new Font(defaultFontName, Font.PLAIN, SCENIC_FONT_SIZE);
Font datetimeFont = new Font(defaultFontName, Font.PLAIN, DATETIME_FONT_SIZE);
FontMetrics scenicFontMetrics = g2d.getFontMetrics(scenicFont);
FontMetrics datetimeFontMetrics = g2d.getFontMetrics(datetimeFont);
int scenicLineHeight = scenicFontMetrics.getHeight();
int dtLineHeight = datetimeFontMetrics.getHeight();
int scenicLineWidth = scenicFontMetrics.stringWidth(info.getScenicLine());
int datetimeLineWidth = datetimeFontMetrics.stringWidth(info.getDatetimeLine());
// 二维码放置在左下角,距离左边缘图片宽度的5%
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO);
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight;
Shape originalClip = g2d.getClip();
// 创建比二维码大10像素的白色圆形背景
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + 10;
int whiteCircleX = qrcodeOffsetX - (whiteCircleSize - newQrcodeWidth) / 2;
int whiteCircleY = qrcodeOffsetY + QRCODE_OFFSET_Y - (whiteCircleSize - newQrcodeHeight) / 2;
// 绘制白色圆形背景
g2d.setColor(Color.WHITE);
Ellipse2D whiteCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.fill(whiteCircle);
// 用白色圆形尺寸裁切二维码(保持二维码原始尺寸,但用大圆裁切)
Ellipse2D qrcodeCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.setClip(qrcodeCircle);
g2d.drawImage(qrcodeImage, qrcodeOffsetX, qrcodeOffsetY + QRCODE_OFFSET_Y, newQrcodeWidth, newQrcodeHeight, null);
g2d.setClip(originalClip);
// 在圆形二维码中央绘制圆形头像
if (faceImage != null) {
// 计算圆形头像的尺寸和位置
int avatarDiameter = (int) (newQrcodeHeight * 0.45);
int avatarX = qrcodeOffsetX + (newQrcodeWidth - avatarDiameter) / 2;
int avatarY = qrcodeOffsetY + QRCODE_OFFSET_Y + (newQrcodeHeight - avatarDiameter) / 2;
// 保存当前的渲染设置
RenderingHints originalHints = g2d.getRenderingHints();
// 设置高质量渲染
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 创建圆形剪切区域
Ellipse2D avatarCircle = new Ellipse2D.Double(avatarX, avatarY, avatarDiameter, avatarDiameter);
g2d.setClip(avatarCircle);
// 实现CSS cover效果的缩放逻辑
double faceWidth = faceImage.getWidth();
double faceHeight = faceImage.getHeight();
double scaleX = avatarDiameter / faceWidth;
double scaleY = avatarDiameter / faceHeight;
double scale = Math.max(scaleX, scaleY); // 使用较大的缩放比例以填满圆形
int scaledWidth = (int) (faceWidth * scale);
int scaledHeight = (int) (faceHeight * scale);
// 计算居中位置
int faceDrawX = avatarX + (avatarDiameter - scaledWidth) / 2;
int faceDrawY = avatarY + (avatarDiameter - scaledHeight) / 2;
// 绘制缩放后的头像
g2d.drawImage(faceImage, faceDrawX, faceDrawY, scaledWidth, scaledHeight, null);
// 恢复原始设置
g2d.setClip(originalClip);
g2d.setRenderingHints(originalHints);
}
// 计算文字与二维码垂直居中对齐的Y坐标
int qrcodeTop = qrcodeOffsetY + QRCODE_OFFSET_Y;
int qrcodeBottom = qrcodeTop + newQrcodeHeight;
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;
// 两行文字的总高度
int totalTextHeight = scenicLineHeight + dtLineHeight;
// 计算第一行文字的Y坐标(基线位置),使两行文字整体垂直居中于二维码
int textStartY = qrcodeCenter - totalTextHeight / 2 + scenicFontMetrics.getAscent();
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO);
g2d.setFont(scenicFont);
g2d.setColor(scenicColor);
g2d.drawString(info.getScenicLine(), textRightX - scenicLineWidth, textStartY);
g2d.setFont(datetimeFont);
g2d.setColor(datetimeColor);
g2d.drawString(info.getDatetimeLine(), textRightX - datetimeLineWidth, textStartY + scenicLineHeight);
String fileName = info.getWatermarkedFile().getName();
String formatName = "jpg"; // 默认格式为 jpg
if (fileName.endsWith(".png")) {
formatName = "png";
} else if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
formatName = "jpg";
}
ImageWriter writer = ImageIO.getImageWritersByFormatName(formatName).next();
ImageOutputStream ios;
try {
ios = ImageIO.createImageOutputStream(info.getWatermarkedFile());
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败,目标文件无法写入");
}
writer.setOutput(ios);
try {
// 使用 ImageWriter 设置写入质量
ImageWriteParam writeParam = writer.getDefaultWriteParam();
if (writeParam.canWriteCompressed()) {
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionQuality(0.75f); // 设置写入质量为 75%
}
writer.write(null, new javax.imageio.IIOImage(newImage, null, null), writeParam);
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败");
}
finally {
g2d.dispose();
try {
ios.close();
} catch (IOException ignore) {
}
writer.dispose();
}
return info.getWatermarkedFile();
}
}

View File

@@ -646,7 +646,7 @@ public class PrinterServiceImpl implements PrinterService {
watermarkInfo.setWatermarkedFile(watermarkedFile);
watermarkInfo.setQrcodeFile(qrCodeFile);
watermarkInfo.setDatetime(new Date());
watermarkInfo.setDtFormat("yyyy-MM-dd");
watermarkInfo.setDtFormat("yyyy.MM.dd");
operator.process(watermarkInfo);

View File

@@ -287,10 +287,9 @@ public class ImageUtils {
best.rotationDegrees = 0;
best.pixelsLost = Integer.MAX_VALUE;
// 测试种情况: 不旋转、旋转90度、旋转270度
// 测试种情况: 不旋转、旋转270度
int[][] scenarios = {
{0, srcWidth, srcHeight}, // 不旋转
{90, srcHeight, srcWidth}, // 旋转90度
{270, srcHeight, srcWidth} // 旋转270度
};
@@ -331,35 +330,28 @@ public class ImageUtils {
return source;
}
if (degrees != 270) {
throw new IllegalArgumentException("仅支持270度旋转");
}
int width = source.getWidth();
int height = source.getHeight();
// 90度和270度会交换宽高
BufferedImage rotated;
// 270度会交换宽高
BufferedImage rotated = new BufferedImage(height, width, source.getType());
Graphics2D g2d = null;
try {
if (degrees == 90 || degrees == 270) {
rotated = new BufferedImage(height, width, source.getType());
g2d = rotated.createGraphics();
AffineTransform transform = new AffineTransform();
if (degrees == 90) {
transform.translate(height / 2.0, width / 2.0);
transform.rotate(Math.PI / 2);
transform.translate(-width / 2.0, -height / 2.0);
} else { // 270度
transform.translate(height / 2.0, width / 2.0);
transform.rotate(-Math.PI / 2);
transform.translate(-width / 2.0, -height / 2.0);
}
g2d.setTransform(transform);
g2d.drawImage(source, 0, 0, null);
} else {
throw new IllegalArgumentException("仅支持90度和270度旋转");
}
g2d = rotated.createGraphics();
AffineTransform transform = new AffineTransform();
transform.translate(height / 2.0, width / 2.0);
transform.rotate(-Math.PI / 2);
transform.translate(-width / 2.0, -height / 2.0);
g2d.setTransform(transform);
g2d.drawImage(source, 0, 0, null);
return rotated;
} finally {
if (g2d != null) {