chore: sync code and project files

This commit is contained in:
Tony Zhang
2026-01-09 14:09:16 +08:00
parent 3d1fb37769
commit 30d7eb4b35
94 changed files with 12706 additions and 255 deletions

View File

@@ -90,6 +90,79 @@ class TextRenderer:
return color
return (0, 0, 0, 255)
def _wrap_text_to_width(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
"""
将文本按最大宽度自动换行(支持中英文混排)。
- 保留原始换行符为段落边界
- 英文优先按空格断词;中文按字符贪心换行
"""
try:
mw = int(max_width or 0)
except Exception:
mw = 0
if mw <= 0:
return text
# 兼容:去掉末尾多余空行
raw_paras = (text or "").split("\n")
out_lines: List[str] = []
# 1x1 dummy draw 用于测量
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
def text_w(s: str) -> float:
try:
return float(dummy_draw.textlength(s, font=font))
except Exception:
bbox = dummy_draw.textbbox((0, 0), s, font=font)
return float((bbox[2] - bbox[0]) if bbox else 0)
for para in raw_paras:
p = (para or "").rstrip()
if not p:
out_lines.append("")
continue
# 英文/混排:尝试按空格分词,否则按字符
use_words = (" " in p)
tokens = p.split(" ") if use_words else list(p)
cur = ""
for tok in tokens:
cand = (cur + (" " if (use_words and cur) else "") + tok) if cur else tok
if text_w(cand) <= mw:
cur = cand
continue
# 当前行放不下:先落一行
if cur:
out_lines.append(cur)
cur = tok
else:
# 单 token 超宽:强制按字符拆
if use_words:
chars = list(tok)
else:
chars = [tok]
buf = ""
for ch in chars:
cand2 = buf + ch
if text_w(cand2) <= mw or not buf:
buf = cand2
else:
out_lines.append(buf)
buf = ch
cur = buf
if cur:
out_lines.append(cur)
# 去掉尾部空行(保持中间空行)
while out_lines and out_lines[-1] == "":
out_lines.pop()
return "\n".join(out_lines)
def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str:
"""
渲染文本并返回图片路径
@@ -122,14 +195,29 @@ class TextRenderer:
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"))
bold = bool(style.get("bold", False))
italic = bool(style.get("italic", False))
underline = bool(style.get("underline", False))
# 3. 测量文本尺寸
# 3. 自动换行(可选)
max_width = style.get("max_width") or style.get("maxWidth") or style.get("text_box_width")
try:
max_width = int(max_width) if max_width is not None else 0
except Exception:
max_width = 0
if max_width > 0:
text = self._wrap_text_to_width(text, font, max_width)
# 4. 测量文本尺寸(支持多行)
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]
try:
bbox = dummy_draw.multiline_textbbox((0, 0), text, font=font, spacing=int(font_size * 0.25), align="center")
except Exception:
bbox = dummy_draw.textbbox((0, 0), text, font=font)
text_w = (bbox[2] - bbox[0]) if bbox else 0
text_h = (bbox[3] - bbox[1]) if bbox else 0
# 4. 计算总尺寸 (包含 padding, stroke, shadow)
# 5. 计算总尺寸 (包含 padding, stroke, shadow)
strokes = style.get("stroke", [])
if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式
@@ -156,7 +244,7 @@ class TextRenderer:
canvas_w = content_w + extra_margin * 2
canvas_h = content_h + extra_margin * 2
# 5. 创建画布
# 6. 创建画布
img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
@@ -164,7 +252,7 @@ class TextRenderer:
center_x = canvas_w // 2
center_y = canvas_h // 2
# 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
# 7. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
# --- 绘制阴影 (针对整个块) ---
if shadow:
@@ -183,7 +271,11 @@ class TextRenderer:
# 文字阴影
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)
# 多行阴影
try:
shadow_draw.multiline_text((txt_x, txt_y), text, font=font, fill=shadow_color, spacing=int(font_size * 0.25), align="center")
except Exception:
shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color)
# 描边阴影
for s in strokes:
width = s.get("width", 0)
@@ -217,10 +309,55 @@ class TextRenderer:
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)
try:
draw.multiline_text((txt_x, txt_y), text, font=font, fill=color, spacing=int(font_size * 0.25), align="center", stroke_width=width, stroke_fill=color)
except Exception:
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)
# italic通过仿射变换做简单斜体先绘制到单独图层再 shear
# bold通过多次微小偏移叠加模拟加粗比改 stroke 更接近“字重”)
if italic:
text_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
text_draw = ImageDraw.Draw(text_layer)
if bold:
for dx in (0, 1):
try:
text_draw.multiline_text((txt_x + dx, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
text_draw.text((txt_x + dx, txt_y), text, font=font, fill=font_color)
else:
try:
text_draw.multiline_text((txt_x, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
text_draw.text((txt_x, txt_y), text, font=font, fill=font_color)
shear = 0.22 # 经验值:适中倾斜
text_layer = text_layer.transform(
text_layer.size,
Image.AFFINE,
(1, shear, 0, 0, 1, 0),
resample=Image.BICUBIC
)
img = Image.alpha_composite(img, text_layer)
draw = ImageDraw.Draw(img)
else:
if bold:
for dx in (0, 1):
try:
draw.multiline_text((txt_x + dx, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
draw.text((txt_x + dx, txt_y), text, font=font, fill=font_color)
else:
try:
draw.multiline_text((txt_x, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
draw.text((txt_x, txt_y), text, font=font, fill=font_color)
# underline在文本底部画线与字号相关
if underline:
line_y = txt_y + text_h + max(2, int(font_size * 0.08))
line_th = max(2, int(font_size * 0.06))
draw.rectangle([txt_x, line_y, txt_x + text_w, line_y + line_th], fill=font_color)
# 7. 裁剪多余透明区域
bbox = img.getbbox()