You've already forked FrameTour-BE
refactor(puzzle): 重构元素DTO及新增元素基类
- 将ElementCreateRequest和PuzzleElementDTO中的elementType从Integer改为String - 删除所有类型特定字段,新增config和configMap支持JSON配置 - 新增BaseElement抽象基类定义元素通用行为 - 添加ElementConfig接口和具体实现类ImageConfig、TextConfig - 创建ElementFactory工厂类和ElementRegistrar注册器 - 新增ElementType枚举和ElementValidationException异常类 - 实现ImageElement和TextElement具体元素类 - 添加Position位置信息封装类
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
package com.ycwl.basic.puzzle.element.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.config.ImageConfig;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* 图片元素实现
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
public class ImageElement extends BaseElement {
|
||||
|
||||
private ImageConfig imageConfig;
|
||||
|
||||
@Override
|
||||
public void loadConfig(String configJson) {
|
||||
this.imageConfig = parseConfig(configJson, ImageConfig.class);
|
||||
this.config = imageConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws ElementValidationException {
|
||||
try {
|
||||
if (imageConfig == null) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"图片配置不能为空"
|
||||
);
|
||||
}
|
||||
imageConfig.validate();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"配置验证失败: " + e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderContext context) {
|
||||
Graphics2D g2d = context.getGraphics();
|
||||
|
||||
// 获取图片URL(优先使用动态数据)
|
||||
String imageUrl = context.getDynamicData(elementKey, imageConfig.getDefaultImageUrl());
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
log.warn("图片元素没有图片URL: elementKey={}", elementKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 下载图片
|
||||
BufferedImage image = downloadImage(imageUrl);
|
||||
if (image == null) {
|
||||
log.error("图片下载失败: imageUrl={}", imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 应用透明度
|
||||
Composite originalComposite = applyOpacity(g2d);
|
||||
|
||||
// 缩放图片(根据适配模式)
|
||||
BufferedImage scaledImage = scaleImage(image);
|
||||
|
||||
// 绘制图片(支持圆角)
|
||||
if (imageConfig.getBorderRadius() != null && imageConfig.getBorderRadius() > 0) {
|
||||
drawRoundedImage(g2d, scaledImage);
|
||||
} else {
|
||||
// 直接绘制
|
||||
g2d.drawImage(scaledImage, position.getX(), position.getY(), null);
|
||||
}
|
||||
|
||||
// 恢复透明度
|
||||
restoreOpacity(g2d, originalComposite);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("图片元素渲染失败: elementKey={}, imageUrl={}", elementKey, imageUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return imageConfig != null ? imageConfig.getConfigSchema() : "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片
|
||||
*
|
||||
* @param imageUrl 图片URL或本地文件路径
|
||||
* @return BufferedImage对象
|
||||
*/
|
||||
private BufferedImage downloadImage(String imageUrl) {
|
||||
try {
|
||||
log.debug("下载图片: url={}", imageUrl);
|
||||
|
||||
// 判断是否为本地文件路径
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
// 网络图片
|
||||
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
|
||||
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
} else {
|
||||
// 本地文件
|
||||
File file = new File(imageUrl);
|
||||
if (file.exists()) {
|
||||
return ImageIO.read(file);
|
||||
} else {
|
||||
log.error("本地图片文件不存在: path={}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("图片下载失败: url={}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放图片(根据适配模式)
|
||||
*
|
||||
* @param source 原始图片
|
||||
* @return 缩放后的图片
|
||||
*/
|
||||
private BufferedImage scaleImage(BufferedImage source) {
|
||||
int targetWidth = position.getWidth();
|
||||
int targetHeight = position.getHeight();
|
||||
String fitMode = StrUtil.isNotBlank(imageConfig.getImageFitMode())
|
||||
? imageConfig.getImageFitMode().toUpperCase()
|
||||
: "FILL";
|
||||
|
||||
switch (fitMode) {
|
||||
case "COVER":
|
||||
// 等比缩放填充(可能裁剪)- 使用较大的比例
|
||||
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
|
||||
|
||||
case "CONTAIN":
|
||||
// 等比缩放适应(可能留白)- 使用较小的比例
|
||||
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
|
||||
|
||||
case "SCALE_DOWN":
|
||||
// 缩小适应(不放大)
|
||||
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
|
||||
return source; // 原图已小于目标,不处理
|
||||
}
|
||||
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
|
||||
|
||||
case "FILL":
|
||||
default:
|
||||
// 拉伸填充到目标尺寸(不保持宽高比,可能变形)
|
||||
BufferedImage scaled = new BufferedImage(
|
||||
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = scaled.createGraphics();
|
||||
enableHighQualityRendering(g);
|
||||
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
|
||||
g.dispose();
|
||||
return scaled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等比缩放图片(保持宽高比)
|
||||
*
|
||||
* @param source 原始图片
|
||||
* @param targetWidth 目标宽度
|
||||
* @param targetHeight 目标高度
|
||||
* @param cover true-COVER模式,false-CONTAIN模式
|
||||
* @return 缩放后的图片
|
||||
*/
|
||||
private BufferedImage scaleImageKeepRatio(BufferedImage source,
|
||||
int targetWidth, int targetHeight,
|
||||
boolean cover) {
|
||||
int sourceWidth = source.getWidth();
|
||||
int sourceHeight = source.getHeight();
|
||||
|
||||
double widthRatio = (double) targetWidth / sourceWidth;
|
||||
double heightRatio = (double) targetHeight / sourceHeight;
|
||||
|
||||
// cover模式使用较大比例(填充),contain模式使用较小比例(适应)
|
||||
double ratio = cover
|
||||
? Math.max(widthRatio, heightRatio)
|
||||
: Math.min(widthRatio, heightRatio);
|
||||
|
||||
int scaledWidth = (int) (sourceWidth * ratio);
|
||||
int scaledHeight = (int) (sourceHeight * ratio);
|
||||
|
||||
// 创建目标尺寸的画布
|
||||
BufferedImage result = new BufferedImage(
|
||||
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = result.createGraphics();
|
||||
enableHighQualityRendering(g);
|
||||
|
||||
// 居中绘制缩放后的图片
|
||||
int x = (targetWidth - scaledWidth) / 2;
|
||||
int y = (targetHeight - scaledHeight) / 2;
|
||||
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
|
||||
g.dispose();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角图片
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param image 图片
|
||||
*/
|
||||
private void drawRoundedImage(Graphics2D g2d, BufferedImage image) {
|
||||
int width = position.getWidth();
|
||||
int height = position.getHeight();
|
||||
int radius = imageConfig.getBorderRadius();
|
||||
|
||||
// 创建圆角遮罩
|
||||
BufferedImage rounded = new BufferedImage(
|
||||
width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = rounded.createGraphics();
|
||||
enableHighQualityRendering(g);
|
||||
|
||||
// 判断是否需要绘制圆形(当圆角半径>=最小边长的一半时)
|
||||
boolean isCircle = (radius * 2 >= Math.min(width, height));
|
||||
|
||||
if (isCircle) {
|
||||
// 绘制圆形遮罩
|
||||
g.setColor(Color.WHITE);
|
||||
g.fill(new Ellipse2D.Float(0, 0, width, height));
|
||||
} else {
|
||||
// 绘制圆角矩形遮罩
|
||||
g.setColor(Color.WHITE);
|
||||
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
|
||||
}
|
||||
|
||||
// 应用遮罩
|
||||
g.setComposite(AlphaComposite.SrcAtop);
|
||||
g.drawImage(image, 0, 0, width, height, null);
|
||||
g.dispose();
|
||||
|
||||
// 绘制到主画布
|
||||
g2d.drawImage(rounded, position.getX(), position.getY(), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.ycwl.basic.puzzle.element.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.config.TextConfig;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.font.LineMetrics;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
|
||||
/**
|
||||
* 文字元素实现
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
public class TextElement extends BaseElement {
|
||||
|
||||
private TextConfig textConfig;
|
||||
|
||||
@Override
|
||||
public void loadConfig(String configJson) {
|
||||
this.textConfig = parseConfig(configJson, TextConfig.class);
|
||||
this.config = textConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws ElementValidationException {
|
||||
try {
|
||||
if (textConfig == null) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"文字配置不能为空"
|
||||
);
|
||||
}
|
||||
textConfig.validate();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"配置验证失败: " + e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderContext context) {
|
||||
Graphics2D g2d = context.getGraphics();
|
||||
|
||||
// 获取文本内容(优先使用动态数据)
|
||||
String text = context.getDynamicData(elementKey, textConfig.getDefaultText());
|
||||
if (StrUtil.isBlank(text)) {
|
||||
log.debug("文字元素没有文本内容: elementKey={}", elementKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置字体
|
||||
Font font = createFont();
|
||||
g2d.setFont(font);
|
||||
|
||||
// 设置颜色
|
||||
g2d.setColor(parseColor(textConfig.getFontColor()));
|
||||
|
||||
// 应用透明度
|
||||
Composite originalComposite = applyOpacity(g2d);
|
||||
|
||||
// 应用旋转
|
||||
if (position.hasRotation()) {
|
||||
applyRotation(g2d);
|
||||
}
|
||||
|
||||
// 绘制文本
|
||||
drawText(g2d, text);
|
||||
|
||||
// 恢复透明度
|
||||
restoreOpacity(g2d, originalComposite);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("文字元素渲染失败: elementKey={}, text={}", elementKey, text, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return textConfig != null ? textConfig.getConfigSchema() : "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建字体
|
||||
*
|
||||
* @return Font对象
|
||||
*/
|
||||
private Font createFont() {
|
||||
int fontStyle = Font.PLAIN;
|
||||
|
||||
// 处理字重(BOLD)
|
||||
if ("BOLD".equalsIgnoreCase(textConfig.getFontWeight())) {
|
||||
fontStyle |= Font.BOLD;
|
||||
}
|
||||
|
||||
// 处理字体样式(ITALIC)
|
||||
if ("ITALIC".equalsIgnoreCase(textConfig.getFontStyle())) {
|
||||
fontStyle |= Font.ITALIC;
|
||||
}
|
||||
|
||||
return new Font(
|
||||
textConfig.getFontFamily(),
|
||||
fontStyle,
|
||||
textConfig.getFontSize()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制文本(支持多行、对齐、行高、最大行数)
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param text 文本内容
|
||||
*/
|
||||
private void drawText(Graphics2D g2d, String text) {
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
|
||||
// 计算行高
|
||||
float lineHeightMultiplier = textConfig.getLineHeight() != null
|
||||
? textConfig.getLineHeight().floatValue()
|
||||
: 1.5f;
|
||||
int lineHeight = (int) (fm.getHeight() * lineHeightMultiplier);
|
||||
|
||||
// 分行
|
||||
String[] lines = text.split("\\n");
|
||||
Integer maxLines = textConfig.getMaxLines();
|
||||
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
|
||||
|
||||
// 获取对齐方式
|
||||
String textAlign = StrUtil.isNotBlank(textConfig.getTextAlign())
|
||||
? textConfig.getTextAlign().toUpperCase()
|
||||
: "LEFT";
|
||||
|
||||
// 起始Y坐标
|
||||
int y = position.getY() + fm.getAscent();
|
||||
|
||||
// 逐行绘制
|
||||
for (int i = 0; i < actualLines; i++) {
|
||||
String line = lines[i];
|
||||
|
||||
// 计算X坐标(根据对齐方式)
|
||||
int x = calculateTextX(line, fm, textAlign);
|
||||
|
||||
// 绘制文本
|
||||
g2d.drawString(line, x, y);
|
||||
|
||||
// 绘制文本装饰(下划线、删除线)
|
||||
if (StrUtil.isNotBlank(textConfig.getTextDecoration())) {
|
||||
drawTextDecoration(g2d, line, x, y, fm);
|
||||
}
|
||||
|
||||
// 移动到下一行
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文本X坐标(根据对齐方式)
|
||||
*
|
||||
* @param text 文本
|
||||
* @param fm 字体度量
|
||||
* @param textAlign 对齐方式
|
||||
* @return X坐标
|
||||
*/
|
||||
private int calculateTextX(String text, FontMetrics fm, String textAlign) {
|
||||
int textWidth = fm.stringWidth(text);
|
||||
|
||||
switch (textAlign) {
|
||||
case "CENTER":
|
||||
return position.getX() + (position.getWidth() - textWidth) / 2;
|
||||
case "RIGHT":
|
||||
return position.getX() + position.getWidth() - textWidth;
|
||||
case "LEFT":
|
||||
default:
|
||||
return position.getX();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制文本装饰(下划线、删除线)
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param text 文本
|
||||
* @param x 文本X坐标
|
||||
* @param y 文本Y坐标
|
||||
* @param fm 字体度量
|
||||
*/
|
||||
private void drawTextDecoration(Graphics2D g2d, String text, int x, int y, FontMetrics fm) {
|
||||
String decoration = textConfig.getTextDecoration().toUpperCase();
|
||||
int textWidth = fm.stringWidth(text);
|
||||
|
||||
switch (decoration) {
|
||||
case "UNDERLINE":
|
||||
// 下划线(在文本下方)
|
||||
int underlineY = y + fm.getDescent() / 2;
|
||||
g2d.drawLine(x, underlineY, x + textWidth, underlineY);
|
||||
break;
|
||||
|
||||
case "LINE_THROUGH":
|
||||
// 删除线(在文本中间)
|
||||
int lineThroughY = y - fm.getAscent() / 2;
|
||||
g2d.drawLine(x, lineThroughY, x + textWidth, lineThroughY);
|
||||
break;
|
||||
|
||||
case "NONE":
|
||||
default:
|
||||
// 无装饰
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user