""" 通用文本渲染引擎 支持原子化设计参数,供上游 Design Agent 灵活调用 """ import os import hashlib import logging from pathlib import Path from typing import Dict, Any, List, Tuple, Union, Optional from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor import config from modules.styles import get_style logger = logging.getLogger(__name__) # 缓存目录 CACHE_DIR = config.TEMP_DIR / "text_renderer_cache" CACHE_DIR.mkdir(exist_ok=True) class TextRenderer: """ 通用文本渲染器 基于原子化参数渲染文本图片 (PNG) """ def __init__(self): self.default_font_path = self._resolve_font_path(None) def _resolve_font_path(self, font_family: Optional[str]) -> str: """解析字体路径,支持多级回退""" candidates = [] if font_family: # 1. 尝试作为绝对路径 candidates.append(font_family) # 2. 尝试在 assets/fonts 下查找 candidates.append(str(config.FONTS_DIR / font_family)) if not font_family.endswith(".ttf") and not font_family.endswith(".otf"): candidates.append(str(config.FONTS_DIR / f"{font_family}.ttf")) candidates.append(str(config.FONTS_DIR / f"{font_family}.otf")) # 3. 预设项目字体 candidates.extend([ str(config.FONTS_DIR / "SmileySans-Oblique.ttf"), str(config.FONTS_DIR / "AlibabaPuHuiTi-Bold.ttf"), str(config.FONTS_DIR / "AlibabaPuHuiTi-Regular.ttf"), str(config.FONTS_DIR / "NotoSansSC-Bold.otf"), # 假如有效 ]) # 4. 系统字体回退 candidates.extend([ "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/STHeiti Medium.ttc", "C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simhei.ttf", ]) for path in candidates: if path and os.path.exists(path): # 简单验证文件大小 try: if os.path.getsize(path) > 10000: return path except: continue logger.warning("No valid font found, using default load_default()") return None def _get_font(self, font_path: str, size: int) -> ImageFont.FreeTypeFont: try: if font_path: return ImageFont.truetype(font_path, size) except Exception as e: logger.warning(f"Failed to load font {font_path}: {e}") return ImageFont.load_default() def _parse_color(self, color: Union[str, Tuple]) -> Tuple[int, int, int, int]: """解析颜色为 RGBA""" if isinstance(color, str): if color.startswith("#"): rgb = ImageColor.getrgb(color) return rgb + (255,) # TODO: 支持 'rgba(r,g,b,a)' 格式 if isinstance(color, tuple): if len(color) == 3: return color + (255,) return color return (0, 0, 0, 255) def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str: """ 渲染文本并返回图片路径 style 结构: { "font_family": str, "font_size": int, "font_color": str, "stroke": [{"color": str, "width": int}, ...], "shadow": {"color": str, "blur": int, "offset": [x, y], "opacity": float}, "background": { "type": "box", "color": str/list, "corner_radius": int, "padding": [t, r, b, l] } } """ # 0. 解析样式 if isinstance(style, str): style = get_style(style) # 1. 缓存检查 cache_key = hashlib.md5(f"{text}_{str(style)}".encode()).hexdigest() if cache: cache_path = CACHE_DIR / f"{cache_key}.png" if cache_path.exists(): return str(cache_path) # 2. 解析基本参数 font_path = self._resolve_font_path(style.get("font_family")) font_size = style.get("font_size", 60) font = self._get_font(font_path, font_size) font_color = self._parse_color(style.get("font_color", "#FFFFFF")) # 3. 测量文本尺寸 dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1))) bbox = dummy_draw.textbbox((0, 0), text, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] # 4. 计算总尺寸 (包含 padding, stroke, shadow) strokes = style.get("stroke", []) if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式 max_stroke = 0 for s in strokes: max_stroke = max(max_stroke, s.get("width", 0)) shadow = style.get("shadow", {}) shadow_blur = shadow.get("blur", 0) shadow_offset = shadow.get("offset", [0, 0]) bg = style.get("background", {}) padding = bg.get("padding", [0, 0, 0, 0]) if isinstance(padding, int): padding = [padding] * 4 if len(padding) == 2: padding = [padding[0], padding[1], padding[0], padding[1]] # v, h -> t, r, b, l # 内容区域尺寸 (文本 + padding) content_w = text_w + padding[1] + padding[3] content_h = text_h + padding[0] + padding[2] # 扩展区域 (描边 + 阴影) extra_margin = max_stroke + shadow_blur + max(abs(shadow_offset[0]), abs(shadow_offset[1])) + 10 canvas_w = content_w + extra_margin * 2 canvas_h = content_h + extra_margin * 2 # 5. 创建画布 img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # 锚点位置 (文本中心点) center_x = canvas_w // 2 center_y = canvas_h // 2 # 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本 # --- 绘制阴影 (针对整个块) --- if shadow: shadow_color = self._parse_color(shadow.get("color", "#000000")) opacity = shadow.get("opacity", 0.5) shadow_color = (shadow_color[0], shadow_color[1], shadow_color[2], int(255 * opacity)) # 临时画布绘制形状用于生成阴影 shadow_layer = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0)) shadow_draw = ImageDraw.Draw(shadow_layer) # 如果有背景,阴影跟随背景形状;否则跟随文字 if bg and bg.get("type") != "none": self._draw_background(shadow_draw, bg, center_x, center_y, content_w, content_h, shadow_color) else: # 文字阴影 txt_x = center_x - text_w / 2 txt_y = center_y - text_h / 2 shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color) # 描边阴影 for s in strokes: width = s.get("width", 0) # 简单模拟描边阴影:多次绘制 # (略: 完整描边阴影开销大,暂只做文字阴影) # 应用模糊 if shadow_blur > 0: shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(shadow_blur)) # 应用偏移 final_shadow = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0)) final_shadow.paste(shadow_layer, (int(shadow_offset[0]), int(shadow_offset[1])), mask=shadow_layer) img = Image.alpha_composite(final_shadow, img) draw = ImageDraw.Draw(img) # 重置 draw # --- 绘制背景 --- if bg and bg.get("type") in ["box", "circle"]: bg_color = self._parse_color(bg.get("color", "#000000")) # TODO: 支持渐变背景 self._draw_background(draw, bg, center_x, center_y, content_w, content_h, bg_color) # --- 绘制描边 (仅针对文字) --- # 从外向内绘制 txt_x = center_x - text_w / 2 txt_y = center_y - text_h / 2 for s in reversed(strokes): color = self._parse_color(s.get("color", "#000000")) width = s.get("width", 0) if width > 0: # 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数) draw.text((txt_x, txt_y), text, font=font, fill=color, stroke_width=width, stroke_fill=color) # --- 绘制文字 --- draw.text((txt_x, txt_y), text, font=font, fill=font_color) # 7. 裁剪多余透明区域 bbox = img.getbbox() if bbox: img = img.crop(bbox) # 8. 保存 output_path = str(CACHE_DIR / f"{cache_key}.png") img.save(output_path) logger.info(f"Rendered text: {text} -> {output_path}") return output_path def _draw_background(self, draw, bg, cx, cy, w, h, color): """绘制背景形状""" corner_radius = bg.get("corner_radius", 0) x0 = cx - w / 2 y0 = cy - h / 2 x1 = cx + w / 2 y1 = cy + h / 2 if bg.get("type") == "box": draw.rounded_rectangle([x0, y0, x1, y1], radius=corner_radius, fill=color) elif bg.get("type") == "circle": draw.ellipse([x0, y0, x1, y1], fill=color) # 全局单例 renderer = TextRenderer()