jinye_huang dcfd820ca4 feat(poster_v2): 智能海报生成引擎 v1.0
- 新增 PosterSmartEngine,AI 生成文案 + 海报渲染
- 5 种布局支持文本换行和自适应字体
- 修复按钮/标签颜色显示问题
- 优化渐变遮罩和内容区域计算
- Prompt 优化:标题格式为产品名+描述
2025-12-10 15:04:59 +08:00

185 lines
5.4 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文字渲染器
"""
from pathlib import Path
from typing import Tuple, Optional, Union
from PIL import Image, ImageDraw, ImageFont
from ..config import get_font_path, FONT_FILES
class TextRenderer:
"""文字渲染器"""
_font_cache = {}
@classmethod
def load_font(cls, font_name: str, size: int) -> ImageFont.FreeTypeFont:
"""加载字体 (带缓存)"""
cache_key = (font_name, size)
if cache_key not in cls._font_cache:
font_path = get_font_path(font_name)
try:
cls._font_cache[cache_key] = ImageFont.truetype(str(font_path), size)
except Exception:
cls._font_cache[cache_key] = ImageFont.load_default()
return cls._font_cache[cache_key]
@staticmethod
def draw_text(
draw: ImageDraw.ImageDraw,
pos: Tuple[int, int],
text: str,
font: ImageFont.FreeTypeFont,
fill: Union[str, Tuple],
anchor: str = "lt"
) -> Tuple[int, int]:
"""
绘制文字
Returns:
(width, height) 文字尺寸
"""
draw.text(pos, text, font=font, fill=fill, anchor=anchor)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
@staticmethod
def draw_text_with_shadow(
draw: ImageDraw.ImageDraw,
pos: Tuple[int, int],
text: str,
font: ImageFont.FreeTypeFont,
fill: Union[str, Tuple],
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 60),
offset: Tuple[int, int] = (2, 2)
) -> Tuple[int, int]:
"""绘制带阴影的文字"""
x, y = pos
# 阴影
draw.text((x + offset[0], y + offset[1]), text, font=font, fill=shadow_color)
# 主文字
draw.text((x, y), text, font=font, fill=fill)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
@staticmethod
def draw_emoji(
draw: ImageDraw.ImageDraw,
pos: Tuple[int, int],
emoji: str,
size: int = 109
) -> Tuple[int, int]:
"""绘制 emoji"""
font = ImageFont.truetype(str(get_font_path("emoji")), size)
draw.text(pos, emoji, font=font, embedded_color=True)
return size, size
@staticmethod
def measure_text(
text: str,
font: ImageFont.FreeTypeFont
) -> Tuple[int, int]:
"""测量文字尺寸"""
temp = Image.new("RGBA", (1, 1))
draw = ImageDraw.Draw(temp)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
@classmethod
def get_title_font(cls, size: int = 80) -> ImageFont.FreeTypeFont:
"""获取标题字体"""
return cls.load_font("title_bold", size)
@classmethod
def get_body_font(cls, size: int = 28) -> ImageFont.FreeTypeFont:
"""获取正文字体"""
return cls.load_font("body_regular", size)
@classmethod
def get_adaptive_title_font(cls, text: str, max_width: int,
base_size: int = 96, min_size: int = 48) -> ImageFont.FreeTypeFont:
"""
获取自适应大小的标题字体
根据文本长度和最大宽度,自动调整字体大小
"""
size = base_size
while size >= min_size:
font = cls.load_font("title_bold", size)
w, _ = cls.measure_text(text, font)
if w <= max_width:
return font
size -= 4
return cls.load_font("title_bold", min_size)
@classmethod
def wrap_text(cls, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> list:
"""
文本自动换行
Args:
text: 原始文本
font: 字体
max_width: 最大宽度
Returns:
换行后的文本行列表
"""
if not text:
return []
lines = []
current_line = ""
for char in text:
test_line = current_line + char
w, _ = cls.measure_text(test_line, font)
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
return lines
@classmethod
def draw_wrapped_text(
cls,
draw: ImageDraw.ImageDraw,
pos: Tuple[int, int],
text: str,
font: ImageFont.FreeTypeFont,
fill: Union[str, Tuple],
max_width: int,
line_spacing: int = 8
) -> Tuple[int, int]:
"""
绘制自动换行的文本
Returns:
(总宽度, 总高度)
"""
lines = cls.wrap_text(text, font, max_width)
x, y = pos
total_height = 0
max_line_width = 0
for line in lines:
w, h = cls.measure_text(line, font)
draw.text((x, y), line, font=font, fill=fill)
y += h + line_spacing
total_height += h + line_spacing
max_line_width = max(max_line_width, w)
return max_line_width, total_height - line_spacing if lines else 0