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,219 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.awt.*;
|
||||
|
||||
/**
|
||||
* 元素抽象基类
|
||||
* 定义所有Element的通用行为和属性
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Data
|
||||
public abstract class BaseElement {
|
||||
|
||||
/**
|
||||
* 元素ID
|
||||
*/
|
||||
protected Long id;
|
||||
|
||||
/**
|
||||
* 元素类型
|
||||
*/
|
||||
protected ElementType elementType;
|
||||
|
||||
/**
|
||||
* 元素标识(用于动态数据映射)
|
||||
*/
|
||||
protected String elementKey;
|
||||
|
||||
/**
|
||||
* 元素名称(便于管理识别)
|
||||
*/
|
||||
protected String elementName;
|
||||
|
||||
/**
|
||||
* 位置信息
|
||||
*/
|
||||
protected Position position;
|
||||
|
||||
/**
|
||||
* JSON配置字符串(原始)
|
||||
*/
|
||||
protected String configJson;
|
||||
|
||||
/**
|
||||
* 解析后的配置对象(子类特定)
|
||||
*/
|
||||
protected ElementConfig config;
|
||||
|
||||
// ========== 抽象方法(子类必须实现) ==========
|
||||
|
||||
/**
|
||||
* 加载并解析JSON配置
|
||||
* 子类需要将configJson解析为具体的Config对象
|
||||
*
|
||||
* @param configJson JSON配置字符串
|
||||
*/
|
||||
public abstract void loadConfig(String configJson);
|
||||
|
||||
/**
|
||||
* 验证元素配置是否合法
|
||||
*
|
||||
* @throws ElementValidationException 配置不合法时抛出
|
||||
*/
|
||||
public abstract void validate() throws ElementValidationException;
|
||||
|
||||
/**
|
||||
* 渲染元素到画布
|
||||
* 这是元素的核心方法,负责将元素绘制到Graphics2D上
|
||||
*
|
||||
* @param context 渲染上下文
|
||||
*/
|
||||
public abstract void render(RenderContext context);
|
||||
|
||||
/**
|
||||
* 获取配置的JSON Schema或说明
|
||||
*
|
||||
* @return 配置说明
|
||||
*/
|
||||
public abstract String getConfigSchema();
|
||||
|
||||
// ========== 通用方法 ==========
|
||||
|
||||
/**
|
||||
* 初始化元素(加载配置并验证)
|
||||
* 在创建Element实例后必须调用此方法
|
||||
*/
|
||||
public void initialize() {
|
||||
if (StrUtil.isNotBlank(configJson)) {
|
||||
loadConfig(configJson);
|
||||
}
|
||||
validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用透明度
|
||||
* 如果元素有透明度设置,则应用到Graphics2D上
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @return 原始的Composite对象(用于恢复)
|
||||
*/
|
||||
protected Composite applyOpacity(Graphics2D g2d) {
|
||||
Composite originalComposite = g2d.getComposite();
|
||||
if (position != null && position.hasOpacity()) {
|
||||
g2d.setComposite(AlphaComposite.getInstance(
|
||||
AlphaComposite.SRC_OVER,
|
||||
position.getOpacityFloat()
|
||||
));
|
||||
}
|
||||
return originalComposite;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复透明度
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param originalComposite 原始的Composite对象
|
||||
*/
|
||||
protected void restoreOpacity(Graphics2D g2d, Composite originalComposite) {
|
||||
if (originalComposite != null) {
|
||||
g2d.setComposite(originalComposite);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用旋转
|
||||
* 如果元素有旋转设置,则应用到Graphics2D上
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
*/
|
||||
protected void applyRotation(Graphics2D g2d) {
|
||||
if (position != null && position.hasRotation()) {
|
||||
// 以元素中心点为旋转中心
|
||||
int centerX = position.getX() + position.getWidth() / 2;
|
||||
int centerY = position.getY() + position.getHeight() / 2;
|
||||
g2d.rotate(position.getRotationRadians(), centerX, centerY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析颜色字符串(支持hex格式)
|
||||
*
|
||||
* @param colorStr 颜色字符串(如#FFFFFF)
|
||||
* @return Color对象
|
||||
*/
|
||||
protected Color parseColor(String colorStr) {
|
||||
if (StrUtil.isBlank(colorStr)) {
|
||||
return Color.BLACK;
|
||||
}
|
||||
try {
|
||||
// 移除#号
|
||||
String hex = colorStr.startsWith("#") ? colorStr.substring(1) : colorStr;
|
||||
// 解析RGB
|
||||
return new Color(
|
||||
Integer.valueOf(hex.substring(0, 2), 16),
|
||||
Integer.valueOf(hex.substring(2, 4), 16),
|
||||
Integer.valueOf(hex.substring(4, 6), 16)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("颜色解析失败: {}, 使用默认黑色", colorStr);
|
||||
return Color.BLACK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析JSON配置
|
||||
*
|
||||
* @param configJson JSON字符串
|
||||
* @param configClass 配置类
|
||||
* @param <T> 配置类型
|
||||
* @return 配置对象
|
||||
*/
|
||||
protected <T extends ElementConfig> T parseConfig(String configJson, Class<T> configClass) {
|
||||
try {
|
||||
if (StrUtil.isBlank(configJson)) {
|
||||
// 返回默认实例
|
||||
return configClass.getDeclaredConstructor().newInstance();
|
||||
}
|
||||
return JacksonUtil.fromJson(configJson, configClass);
|
||||
} catch (Exception e) {
|
||||
throw new ElementValidationException(
|
||||
elementType != null ? elementType.getCode() : "UNKNOWN",
|
||||
elementKey,
|
||||
"JSON配置解析失败: " + e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用高质量渲染
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
*/
|
||||
protected void enableHighQualityRendering(Graphics2D g2d) {
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("Element[type=%s, key=%s, name=%s, position=%s]",
|
||||
elementType != null ? elementType.getCode() : "null",
|
||||
elementKey,
|
||||
elementName,
|
||||
position);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
/**
|
||||
* 元素配置接口
|
||||
* 所有Element的配置类都需要实现此接口
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
public interface ElementConfig {
|
||||
|
||||
/**
|
||||
* 获取配置说明(JSON Schema或描述)
|
||||
*
|
||||
* @return 配置说明
|
||||
*/
|
||||
default String getConfigSchema() {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置是否合法
|
||||
* 子类应该重写此方法实现自己的验证逻辑
|
||||
*
|
||||
* @throws IllegalArgumentException 配置不合法时抛出
|
||||
*/
|
||||
default void validate() {
|
||||
// 默认不做验证
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 元素工厂类
|
||||
* 负责根据类型创建Element实例
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
public class ElementFactory {
|
||||
|
||||
/**
|
||||
* Element类型注册表
|
||||
* key: ElementType枚举
|
||||
* value: Element实现类的Class对象
|
||||
*/
|
||||
private static final Map<ElementType, Class<? extends BaseElement>> ELEMENT_REGISTRY = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 构造器缓存(性能优化)
|
||||
* key: Element实现类
|
||||
* value: 无参构造器
|
||||
*/
|
||||
private static final Map<Class<? extends BaseElement>, Constructor<? extends BaseElement>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册Element类型
|
||||
*
|
||||
* @param type 元素类型
|
||||
* @param elementClass Element实现类
|
||||
*/
|
||||
public static void register(ElementType type, Class<? extends BaseElement> elementClass) {
|
||||
if (type == null || elementClass == null) {
|
||||
throw new IllegalArgumentException("注册参数不能为null");
|
||||
}
|
||||
ELEMENT_REGISTRY.put(type, elementClass);
|
||||
log.info("注册Element类型: {} -> {}", type.getCode(), elementClass.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据Entity创建Element实例
|
||||
*
|
||||
* @param entity PuzzleElementEntity
|
||||
* @return Element实例
|
||||
*/
|
||||
public static BaseElement create(PuzzleElementEntity entity) {
|
||||
if (entity == null) {
|
||||
throw new IllegalArgumentException("Entity不能为null");
|
||||
}
|
||||
|
||||
// 解析元素类型
|
||||
ElementType type;
|
||||
try {
|
||||
type = ElementType.fromCode(entity.getElementType());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ElementValidationException(
|
||||
entity.getElementType(),
|
||||
entity.getElementKey(),
|
||||
"未知的元素类型: " + entity.getElementType()
|
||||
);
|
||||
}
|
||||
|
||||
// 检查类型是否已实现
|
||||
if (!type.isImplemented()) {
|
||||
throw new ElementValidationException(
|
||||
type.getCode(),
|
||||
entity.getElementKey(),
|
||||
"元素类型尚未实现: " + type.getName()
|
||||
);
|
||||
}
|
||||
|
||||
// 获取Element实现类
|
||||
Class<? extends BaseElement> elementClass = ELEMENT_REGISTRY.get(type);
|
||||
if (elementClass == null) {
|
||||
throw new ElementValidationException(
|
||||
type.getCode(),
|
||||
entity.getElementKey(),
|
||||
"元素类型未注册: " + type.getCode()
|
||||
);
|
||||
}
|
||||
|
||||
// 创建Element实例
|
||||
BaseElement element = createInstance(elementClass);
|
||||
|
||||
// 填充基本属性
|
||||
element.setId(entity.getId());
|
||||
element.setElementType(type);
|
||||
element.setElementKey(entity.getElementKey());
|
||||
element.setElementName(entity.getElementName());
|
||||
element.setConfigJson(entity.getConfig());
|
||||
|
||||
// 填充位置信息
|
||||
Position position = new Position(
|
||||
entity.getXPosition(),
|
||||
entity.getYPosition(),
|
||||
entity.getWidth(),
|
||||
entity.getHeight(),
|
||||
entity.getZIndex(),
|
||||
entity.getRotation(),
|
||||
entity.getOpacity()
|
||||
);
|
||||
element.setPosition(position);
|
||||
|
||||
// 初始化(加载配置并验证)
|
||||
element.initialize();
|
||||
|
||||
log.debug("创建Element成功: type={}, key={}", type.getCode(), entity.getElementKey());
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Element实例(使用反射)
|
||||
*
|
||||
* @param elementClass Element类
|
||||
* @return Element实例
|
||||
*/
|
||||
private static BaseElement createInstance(Class<? extends BaseElement> elementClass) {
|
||||
try {
|
||||
// 从缓存获取构造器
|
||||
Constructor<? extends BaseElement> constructor = CONSTRUCTOR_CACHE.get(elementClass);
|
||||
if (constructor == null) {
|
||||
constructor = elementClass.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
CONSTRUCTOR_CACHE.put(elementClass, constructor);
|
||||
}
|
||||
return constructor.newInstance();
|
||||
} catch (Exception e) {
|
||||
throw new ElementValidationException(
|
||||
"Element实例创建失败: " + elementClass.getName() + ", 原因: " + e.getMessage(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的Element类型列表
|
||||
*
|
||||
* @return Element类型列表
|
||||
*/
|
||||
public static Map<ElementType, Class<? extends BaseElement>> getRegisteredTypes() {
|
||||
return new ConcurrentHashMap<>(ELEMENT_REGISTRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查类型是否已注册
|
||||
*
|
||||
* @param type 元素类型
|
||||
* @return true-已注册,false-未注册
|
||||
*/
|
||||
public static boolean isRegistered(ElementType type) {
|
||||
return ELEMENT_REGISTRY.containsKey(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空注册表(主要用于测试)
|
||||
*/
|
||||
public static void clearRegistry() {
|
||||
ELEMENT_REGISTRY.clear();
|
||||
CONSTRUCTOR_CACHE.clear();
|
||||
log.warn("Element注册表已清空");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.element.impl.ImageElement;
|
||||
import com.ycwl.basic.puzzle.element.impl.TextElement;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* Element注册器
|
||||
* 在Spring容器初始化时自动注册所有Element类型
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ElementRegistrar {
|
||||
|
||||
@PostConstruct
|
||||
public void registerElements() {
|
||||
log.info("开始注册Element类型...");
|
||||
|
||||
// 注册文字元素
|
||||
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||
|
||||
// 注册图片元素
|
||||
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||
|
||||
// 未来扩展的Element类型在这里注册
|
||||
// ElementFactory.register(ElementType.QRCODE, QRCodeElement.class);
|
||||
// ElementFactory.register(ElementType.GRADIENT, GradientElement.class);
|
||||
// ElementFactory.register(ElementType.SHAPE, ShapeElement.class);
|
||||
// ElementFactory.register(ElementType.DYNAMIC_IMAGE, DynamicImageElement.class);
|
||||
|
||||
log.info("Element类型注册完成,共注册{}种类型", ElementFactory.getRegisteredTypes().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 元素位置信息
|
||||
* 封装所有与位置、大小、变换相关的属性
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Position {
|
||||
|
||||
/**
|
||||
* X坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
private Integer x;
|
||||
|
||||
/**
|
||||
* Y坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
private Integer y;
|
||||
|
||||
/**
|
||||
* 宽度(像素)
|
||||
*/
|
||||
private Integer width;
|
||||
|
||||
/**
|
||||
* 高度(像素)
|
||||
*/
|
||||
private Integer height;
|
||||
|
||||
/**
|
||||
* 层级(数值越大越靠上,决定绘制顺序)
|
||||
*/
|
||||
private Integer zIndex;
|
||||
|
||||
/**
|
||||
* 旋转角度(0-360度,顺时针)
|
||||
*/
|
||||
private Integer rotation;
|
||||
|
||||
/**
|
||||
* 不透明度(0-100,100为完全不透明)
|
||||
*/
|
||||
private Integer opacity;
|
||||
|
||||
/**
|
||||
* 获取不透明度的浮点数表示(0.0-1.0)
|
||||
*
|
||||
* @return 不透明度(0.0-1.0)
|
||||
*/
|
||||
public float getOpacityFloat() {
|
||||
if (opacity == null) {
|
||||
return 1.0f;
|
||||
}
|
||||
return Math.max(0, Math.min(100, opacity)) / 100.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旋转角度的弧度值
|
||||
*
|
||||
* @return 弧度值
|
||||
*/
|
||||
public double getRotationRadians() {
|
||||
if (rotation == null || rotation == 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.toRadians(rotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要旋转
|
||||
*
|
||||
* @return true-需要旋转,false-不需要
|
||||
*/
|
||||
public boolean hasRotation() {
|
||||
return rotation != null && rotation != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有透明度
|
||||
*
|
||||
* @return true-有透明度,false-完全不透明
|
||||
*/
|
||||
public boolean hasOpacity() {
|
||||
return opacity != null && opacity < 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ycwl.basic.puzzle.element.config;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementConfig;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 图片元素配置
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
public class ImageConfig implements ElementConfig {
|
||||
|
||||
/**
|
||||
* 默认图片URL
|
||||
*/
|
||||
private String defaultImageUrl;
|
||||
|
||||
/**
|
||||
* 图片适配模式
|
||||
* CONTAIN - 等比缩放适应(保持宽高比,可能留白)
|
||||
* COVER - 等比缩放填充(保持宽高比,可能裁剪)
|
||||
* FILL - 拉伸填充(不保持宽高比,可能变形)
|
||||
* SCALE_DOWN - 缩小适应(类似CONTAIN,但不放大)
|
||||
*/
|
||||
private String imageFitMode = "FILL";
|
||||
|
||||
/**
|
||||
* 圆角半径(像素,0为直角)
|
||||
*/
|
||||
private Integer borderRadius = 0;
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
// 校验圆角半径
|
||||
if (borderRadius != null && borderRadius < 0) {
|
||||
throw new IllegalArgumentException("圆角半径不能为负数: " + borderRadius);
|
||||
}
|
||||
|
||||
// 校验图片适配模式
|
||||
if (StrUtil.isNotBlank(imageFitMode)) {
|
||||
String mode = imageFitMode.toUpperCase();
|
||||
if (!"CONTAIN".equals(mode) &&
|
||||
!"COVER".equals(mode) &&
|
||||
!"FILL".equals(mode) &&
|
||||
!"SCALE_DOWN".equals(mode)) {
|
||||
throw new IllegalArgumentException("图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + imageFitMode);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验图片URL格式(可选)
|
||||
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return "{\n" +
|
||||
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
|
||||
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
|
||||
" \"borderRadius\": 0\n" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.ycwl.basic.puzzle.element.config;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementConfig;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 文字元素配置
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
public class TextConfig implements ElementConfig {
|
||||
|
||||
/**
|
||||
* 默认文本内容
|
||||
*/
|
||||
private String defaultText;
|
||||
|
||||
/**
|
||||
* 字体名称(如"微软雅黑"、"PingFang SC")
|
||||
*/
|
||||
private String fontFamily = "微软雅黑";
|
||||
|
||||
/**
|
||||
* 字号(像素,范围10-200)
|
||||
*/
|
||||
private Integer fontSize = 14;
|
||||
|
||||
/**
|
||||
* 字体颜色(hex格式,如#000000)
|
||||
*/
|
||||
private String fontColor = "#000000";
|
||||
|
||||
/**
|
||||
* 字重:NORMAL-正常 BOLD-粗体
|
||||
*/
|
||||
private String fontWeight = "NORMAL";
|
||||
|
||||
/**
|
||||
* 字体样式:NORMAL-正常 ITALIC-斜体
|
||||
*/
|
||||
private String fontStyle = "NORMAL";
|
||||
|
||||
/**
|
||||
* 对齐方式:LEFT-左对齐 CENTER-居中 RIGHT-右对齐
|
||||
*/
|
||||
private String textAlign = "LEFT";
|
||||
|
||||
/**
|
||||
* 行高倍数(如1.5表示1.5倍行距)
|
||||
*/
|
||||
private BigDecimal lineHeight = new BigDecimal("1.5");
|
||||
|
||||
/**
|
||||
* 最大行数(超出后截断,NULL表示不限制)
|
||||
*/
|
||||
private Integer maxLines;
|
||||
|
||||
/**
|
||||
* 文本装饰:NONE-无 UNDERLINE-下划线 LINE_THROUGH-删除线
|
||||
*/
|
||||
private String textDecoration = "NONE";
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
// 校验字号范围
|
||||
if (fontSize != null && (fontSize < 10 || fontSize > 200)) {
|
||||
throw new IllegalArgumentException("字号必须在10-200之间: " + fontSize);
|
||||
}
|
||||
|
||||
// 校验行高
|
||||
if (lineHeight != null && lineHeight.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("行高必须大于0: " + lineHeight);
|
||||
}
|
||||
|
||||
// 校验最大行数
|
||||
if (maxLines != null && maxLines <= 0) {
|
||||
throw new IllegalArgumentException("最大行数必须大于0: " + maxLines);
|
||||
}
|
||||
|
||||
// 校验字重
|
||||
if (StrUtil.isNotBlank(fontWeight)) {
|
||||
if (!"NORMAL".equalsIgnoreCase(fontWeight) && !"BOLD".equalsIgnoreCase(fontWeight)) {
|
||||
throw new IllegalArgumentException("字重只能是NORMAL或BOLD: " + fontWeight);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验字体样式
|
||||
if (StrUtil.isNotBlank(fontStyle)) {
|
||||
if (!"NORMAL".equalsIgnoreCase(fontStyle) && !"ITALIC".equalsIgnoreCase(fontStyle)) {
|
||||
throw new IllegalArgumentException("字体样式只能是NORMAL或ITALIC: " + fontStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验对齐方式
|
||||
if (StrUtil.isNotBlank(textAlign)) {
|
||||
if (!"LEFT".equalsIgnoreCase(textAlign) &&
|
||||
!"CENTER".equalsIgnoreCase(textAlign) &&
|
||||
!"RIGHT".equalsIgnoreCase(textAlign)) {
|
||||
throw new IllegalArgumentException("对齐方式只能是LEFT、CENTER或RIGHT: " + textAlign);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验文本装饰
|
||||
if (StrUtil.isNotBlank(textDecoration)) {
|
||||
if (!"NONE".equalsIgnoreCase(textDecoration) &&
|
||||
!"UNDERLINE".equalsIgnoreCase(textDecoration) &&
|
||||
!"LINE_THROUGH".equalsIgnoreCase(textDecoration)) {
|
||||
throw new IllegalArgumentException("文本装饰只能是NONE、UNDERLINE或LINE_THROUGH: " + textDecoration);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验颜色格式
|
||||
if (StrUtil.isNotBlank(fontColor)) {
|
||||
String hex = fontColor.startsWith("#") ? fontColor.substring(1) : fontColor;
|
||||
if (hex.length() != 6 || !hex.matches("[0-9A-Fa-f]{6}")) {
|
||||
throw new IllegalArgumentException("颜色格式必须是hex格式(如#FFFFFF): " + fontColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return "{\n" +
|
||||
" \"defaultText\": \"默认文本\",\n" +
|
||||
" \"fontFamily\": \"微软雅黑\",\n" +
|
||||
" \"fontSize\": 14,\n" +
|
||||
" \"fontColor\": \"#000000\",\n" +
|
||||
" \"fontWeight\": \"NORMAL|BOLD\",\n" +
|
||||
" \"fontStyle\": \"NORMAL|ITALIC\",\n" +
|
||||
" \"textAlign\": \"LEFT|CENTER|RIGHT\",\n" +
|
||||
" \"lineHeight\": 1.5,\n" +
|
||||
" \"maxLines\": null,\n" +
|
||||
" \"textDecoration\": \"NONE|UNDERLINE|LINE_THROUGH\"\n" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ycwl.basic.puzzle.element.enums;
|
||||
|
||||
/**
|
||||
* 元素类型枚举
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
public enum ElementType {
|
||||
|
||||
/**
|
||||
* 文字元素
|
||||
*/
|
||||
TEXT("TEXT", "文字元素", "com.ycwl.basic.puzzle.element.impl.TextElement"),
|
||||
|
||||
/**
|
||||
* 固定图片元素
|
||||
*/
|
||||
IMAGE("IMAGE", "图片元素", "com.ycwl.basic.puzzle.element.impl.ImageElement"),
|
||||
|
||||
/**
|
||||
* 二维码元素(未来扩展)
|
||||
*/
|
||||
QRCODE("QRCODE", "二维码元素", "com.ycwl.basic.puzzle.element.impl.QRCodeElement"),
|
||||
|
||||
/**
|
||||
* 渐变元素(未来扩展)
|
||||
*/
|
||||
GRADIENT("GRADIENT", "渐变元素", "com.ycwl.basic.puzzle.element.impl.GradientElement"),
|
||||
|
||||
/**
|
||||
* 形状元素(未来扩展)
|
||||
*/
|
||||
SHAPE("SHAPE", "形状元素", "com.ycwl.basic.puzzle.element.impl.ShapeElement"),
|
||||
|
||||
/**
|
||||
* 动态图片元素(未来扩展)
|
||||
*/
|
||||
DYNAMIC_IMAGE("DYNAMIC_IMAGE", "动态图片元素", "com.ycwl.basic.puzzle.element.impl.DynamicImageElement");
|
||||
|
||||
/**
|
||||
* 类型代码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 类型名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 实现类全限定名
|
||||
*/
|
||||
private final String implementationClass;
|
||||
|
||||
ElementType(String code, String name, String implementationClass) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
this.implementationClass = implementationClass;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getImplementationClass() {
|
||||
return implementationClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据code获取枚举
|
||||
*
|
||||
* @param code 类型代码
|
||||
* @return 枚举实例
|
||||
*/
|
||||
public static ElementType fromCode(String code) {
|
||||
for (ElementType type : values()) {
|
||||
if (type.code.equalsIgnoreCase(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未知的元素类型: " + code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查类型是否已实现
|
||||
*
|
||||
* @return true-已实现,false-未实现
|
||||
*/
|
||||
public boolean isImplemented() {
|
||||
return this == TEXT || this == IMAGE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ycwl.basic.puzzle.element.exception;
|
||||
|
||||
/**
|
||||
* 元素验证异常
|
||||
* 当元素配置不合法时抛出
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
public class ElementValidationException extends RuntimeException {
|
||||
|
||||
private final String elementKey;
|
||||
private final String elementType;
|
||||
|
||||
public ElementValidationException(String message) {
|
||||
super(message);
|
||||
this.elementKey = null;
|
||||
this.elementType = null;
|
||||
}
|
||||
|
||||
public ElementValidationException(String elementType, String elementKey, String message) {
|
||||
super(String.format("[%s:%s] %s", elementType, elementKey, message));
|
||||
this.elementKey = elementKey;
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
public ElementValidationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.elementKey = null;
|
||||
this.elementType = null;
|
||||
}
|
||||
|
||||
public String getElementKey() {
|
||||
return elementKey;
|
||||
}
|
||||
|
||||
public String getElementType() {
|
||||
return elementType;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ycwl.basic.puzzle.element.renderer;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.awt.*;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 渲染上下文
|
||||
* 封装渲染时需要的所有上下文信息
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
public class RenderContext {
|
||||
|
||||
/**
|
||||
* 图形上下文
|
||||
*/
|
||||
private Graphics2D graphics;
|
||||
|
||||
/**
|
||||
* 动态数据(key=elementKey, value=实际值)
|
||||
*/
|
||||
private Map<String, String> dynamicData;
|
||||
|
||||
/**
|
||||
* 画布宽度
|
||||
*/
|
||||
private Integer canvasWidth;
|
||||
|
||||
/**
|
||||
* 画布高度
|
||||
*/
|
||||
private Integer canvasHeight;
|
||||
|
||||
/**
|
||||
* 是否启用抗锯齿
|
||||
*/
|
||||
private boolean antiAliasing = true;
|
||||
|
||||
/**
|
||||
* 是否启用高质量渲染
|
||||
*/
|
||||
private boolean highQuality = true;
|
||||
|
||||
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData) {
|
||||
this.graphics = graphics;
|
||||
this.dynamicData = dynamicData;
|
||||
}
|
||||
|
||||
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData,
|
||||
Integer canvasWidth, Integer canvasHeight) {
|
||||
this.graphics = graphics;
|
||||
this.dynamicData = dynamicData;
|
||||
this.canvasWidth = canvasWidth;
|
||||
this.canvasHeight = canvasHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动态数据(带默认值)
|
||||
*
|
||||
* @param key 数据key
|
||||
* @param defaultValue 默认值
|
||||
* @return 数据值
|
||||
*/
|
||||
public String getDynamicData(String key, String defaultValue) {
|
||||
if (dynamicData == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return dynamicData.getOrDefault(key, defaultValue);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user