feat(watermark): 添加拼图水印模板构建器

- 实现拼图默认水印模板构建器,支持原图区域和底部信息区域布局
- 实现拼图打印水印模板构建器,增加四周白边设计
- 配置二维码、头像、景区名和日期时间的文字布局
- 支持动态数据绑定和图片元素的COVER模式显示
- 提供可选的头像圆形裁剪功能和右对齐文字显示
This commit is contained in:
2026-01-16 15:50:43 +08:00
parent 0235d1d121
commit 8198b0c537
3 changed files with 299 additions and 3 deletions

View File

@@ -0,0 +1,141 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 拼图默认水印模板构建器
*
* 布局说明:
* - 白色背景
* - 顶部90%为原图区域(COVER模式)
* - 底部10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@Component
public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "puzzle_default";
// 布局比例配置
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占90%高度
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
// 文字配置
private static final int SCENIC_FONT_SIZE = 52;
private static final int DATETIME_FONT_SIZE = 42;
private static final String SCENIC_COLOR = "#333333";
private static final String DATETIME_COLOR = "#999999";
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 画布尺寸 = 原图尺寸
int canvasWidth = imageWidth;
int canvasHeight = imageHeight;
// 原图区域占90%高度,底部信息区占10%高度
int originalImageHeight = (int) (imageHeight * IMAGE_HEIGHT_RATIO);
int bottomAreaHeight = imageHeight - originalImageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_puzzle_default_" + System.currentTimeMillis(),
canvasWidth,
canvasHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 1. 原图元素(顶部90%区域,COVER模式)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
0, 0,
canvasWidth, originalImageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 2. 计算底部区域元素位置
int marginX = (int) (canvasWidth * MARGIN_X_RATIO);
int qrcodeSize = (int) (canvasHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
// 二维码垂直居中于底部区域
int qrcodeX = marginX;
int qrcodeY = originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
// 3. 二维码元素
PuzzleElementEntity qrcodeElement = createImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY,
qrcodeSize, qrcodeSize, 10,
FIT_MODE_CONTAIN, null, null
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 4. 头像元素(二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeSize * 0.45);
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 5. 计算右侧文字区域
int textRightX = canvasWidth - marginX;
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
// 文字与二维码垂直居中
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
int textY = originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
// 6. 景区名文字(右对齐)
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
qrcodeX + qrcodeSize + marginX, textY,
textWidth, SCENIC_FONT_SIZE + 10, 30,
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 7. 日期时间文字(右对齐)
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
qrcodeX + qrcodeSize + marginX, datetimeY,
textWidth, DATETIME_FONT_SIZE + 10, 30,
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -0,0 +1,155 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 拼图打印水印模板构建器
*
* 布局说明:
* - 白色背景
* - 四周留1%白边
* - 内部区域:顶部90%为原图区域(COVER模式)
* - 底部10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@Component
public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "puzzle_print";
// 布局比例配置
private static final double BORDER_RATIO = 0.01; // 四周白边为1%
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占内容区90%高度
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
// 文字配置
private static final int SCENIC_FONT_SIZE = 52;
private static final int DATETIME_FONT_SIZE = 42;
private static final String SCENIC_COLOR = "#333333";
private static final String DATETIME_COLOR = "#999999";
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 计算白边尺寸(基于原图尺寸的1%)
int borderX = (int) (imageWidth * BORDER_RATIO);
int borderY = (int) (imageHeight * BORDER_RATIO);
// 画布尺寸 = 原图尺寸 + 四周白边
int canvasWidth = imageWidth + borderX * 2;
int canvasHeight = imageHeight + borderY * 2;
// 内容区起始位置(白边内)
int contentStartX = borderX;
int contentStartY = borderY;
// 内容区尺寸 = 原图尺寸
int contentWidth = imageWidth;
int contentHeight = imageHeight;
// 原图区域占90%高度,底部信息区占10%高度
int originalImageHeight = (int) (contentHeight * IMAGE_HEIGHT_RATIO);
int bottomAreaHeight = contentHeight - originalImageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_puzzle_print_" + System.currentTimeMillis(),
canvasWidth,
canvasHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 1. 原图元素(内容区顶部90%,COVER模式)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
contentStartX, contentStartY,
contentWidth, originalImageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 2. 计算底部区域元素位置(相对于内容区)
int marginX = (int) (contentWidth * MARGIN_X_RATIO);
int qrcodeSize = (int) (contentHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
// 二维码垂直居中于底部区域
int qrcodeX = contentStartX + marginX;
int qrcodeY = contentStartY + originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
// 3. 二维码元素
PuzzleElementEntity qrcodeElement = createImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY,
qrcodeSize, qrcodeSize, 10,
FIT_MODE_CONTAIN, null, null
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 4. 头像元素(二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeSize * 0.45);
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 5. 计算右侧文字区域
int textRightX = contentStartX + contentWidth - marginX;
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
// 文字与二维码垂直居中
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
int textY = contentStartY + originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
// 6. 景区名文字(右对齐)
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
qrcodeX + qrcodeSize + marginX, textY,
textWidth, SCENIC_FONT_SIZE + 10, 30,
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 7. 日期时间文字(右对齐)
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
qrcodeX + qrcodeSize + marginX, datetimeY,
textWidth, DATETIME_FONT_SIZE + 10, 30,
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -727,7 +727,7 @@ public class FaceServiceImpl implements FaceService {
String dt = DateUtil.format(face.getCreateAt(), "yyyyMMdd");
String path = "pages/videoSynthesis/bind_face";
String filePath = "wxa_face/"+dt+"/f" + faceId + ".jpg";
String filePath = "f" + faceId + ".jpg";
IStorageAdapter adapter = StorageFactory.use();
if (adapter.isExists(filePath)) {
String url = adapter.getUrl(filePath);
@@ -743,13 +743,13 @@ public class FaceServiceImpl implements FaceService {
try {
File file = new File(filePath);
WxMpUtil.generateUnlimitedWXAQRCode(appId, appSecret, path, faceId.toString(), file);
String url = adapter.uploadFile(null, file, filePath);
String url = adapter.uploadFile(null, file, "wxa_face", dt, filePath);
file.delete();
adapter.setAcl(StorageAcl.PUBLIC_READ, filePath);
url = url.replace("-internal.aliyuncs.com", ".aliyuncs.com");
return url;
} catch (Exception e) {
throw new BaseException("生成二维码失败");
throw new BaseException("生成二维码失败"+e.getMessage());
}
}