From 1821ba9f584b7240acc91214c0d2b7c050976fa3 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 7 Nov 2025 22:38:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(image):=20=E6=B7=BB=E5=8A=A0=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E6=9C=BA=E9=BB=98=E8=AE=A4=E6=B0=B4=E5=8D=B0=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=99=A8=E5=B9=B6=E4=BC=98=E5=8C=96=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91-=20=E6=96=B0=E5=A2=9E=20Pr?= =?UTF-8?q?interDefaultWatermarkOperator=20=E5=AE=9E=E7=8E=B0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=B0=B4=E5=8D=B0=E5=A4=84=E7=90=86=20-=20?= =?UTF-8?q?=E5=9C=A8=20ImageWatermarkOperatorEnum=20=E4=B8=AD=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20PRINTER=5FDEFAULT=20=E7=B1=BB=E5=9E=8B=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20ImageWatermarkFactory=20=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=96=B0=E7=9A=84=E6=B0=B4=E5=8D=B0=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=99=A8=20-=20=E8=B0=83=E6=95=B4=E6=97=A5=E6=9C=9F=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E4=B8=BA=20yyyy.MM.dd=20=E7=94=A8=E4=BA=8E=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E5=9C=BA=E6=99=AF=20-=E4=BC=98=E5=8C=96=20ImageUtils?= =?UTF-8?q?=20=E4=B8=AD=E7=9A=84=E5=9B=BE=E7=89=87=E6=97=8B=E8=BD=AC?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BB=85=E6=94=AF=E6=8C=81270?= =?UTF-8?q?=E5=BA=A6=E6=97=8B=E8=BD=AC=20-=20=E7=A7=BB=E9=99=A4=E5=AF=B990?= =?UTF-8?q?=E5=BA=A6=E6=97=8B=E8=BD=AC=E7=9A=84=E6=94=AF=E6=8C=81=E4=BB=A5?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E5=A4=84=E7=90=86=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../watermark/ImageWatermarkFactory.java | 2 + .../enums/ImageWatermarkOperatorEnum.java | 3 +- .../PrinterDefaultWatermarkOperator.java | 215 ++++++++++++++++++ .../printer/impl/PrinterServiceImpl.java | 2 +- .../java/com/ycwl/basic/utils/ImageUtils.java | 46 ++-- 5 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/ycwl/basic/image/watermark/operator/PrinterDefaultWatermarkOperator.java diff --git a/src/main/java/com/ycwl/basic/image/watermark/ImageWatermarkFactory.java b/src/main/java/com/ycwl/basic/image/watermark/ImageWatermarkFactory.java index c9b1eacf..f2915257 100644 --- a/src/main/java/com/ycwl/basic/image/watermark/ImageWatermarkFactory.java +++ b/src/main/java/com/ycwl/basic/image/watermark/ImageWatermarkFactory.java @@ -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()); }; } diff --git a/src/main/java/com/ycwl/basic/image/watermark/enums/ImageWatermarkOperatorEnum.java b/src/main/java/com/ycwl/basic/image/watermark/enums/ImageWatermarkOperatorEnum.java index 77593560..4a3e34ac 100644 --- a/src/main/java/com/ycwl/basic/image/watermark/enums/ImageWatermarkOperatorEnum.java +++ b/src/main/java/com/ycwl/basic/image/watermark/enums/ImageWatermarkOperatorEnum.java @@ -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; diff --git a/src/main/java/com/ycwl/basic/image/watermark/operator/PrinterDefaultWatermarkOperator.java b/src/main/java/com/ycwl/basic/image/watermark/operator/PrinterDefaultWatermarkOperator.java new file mode 100644 index 00000000..422c36f0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/operator/PrinterDefaultWatermarkOperator.java @@ -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(); + } +} diff --git a/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java b/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java index cdb694c4..3a350404 100644 --- a/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/printer/impl/PrinterServiceImpl.java @@ -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); diff --git a/src/main/java/com/ycwl/basic/utils/ImageUtils.java b/src/main/java/com/ycwl/basic/utils/ImageUtils.java index b2a02dea..fc62ba96 100644 --- a/src/main/java/com/ycwl/basic/utils/ImageUtils.java +++ b/src/main/java/com/ycwl/basic/utils/ImageUtils.java @@ -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) {