feat: video-flow initial commit
- app.py: Streamlit UI for video generation workflow - main_flow.py: CLI tool with argparse support - modules/: Business logic modules (script_gen, image_gen, video_gen, composer, etc.) - config.py: Configuration with API keys and paths - requirements.txt: Python dependencies - docs/: System prompt documentation
This commit is contained in:
708
modules/fancy_text.py
Normal file
708
modules/fancy_text.py
Normal file
@@ -0,0 +1,708 @@
|
||||
"""
|
||||
抖音风格花字生成模块
|
||||
使用 Pillow 生成透明 PNG 图片,支持描边、渐变、气泡框等效果
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Tuple, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 花字缓存目录
|
||||
FANCY_TEXT_CACHE_DIR = config.TEMP_DIR / "fancy_text_cache"
|
||||
FANCY_TEXT_CACHE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def _get_font(font_name: str = None, size: int = 48) -> ImageFont.FreeTypeFont:
|
||||
"""获取字体对象,遇到无效字体会继续尝试下一候选,最后才降级为默认字体"""
|
||||
candidates = []
|
||||
if font_name and os.path.exists(font_name):
|
||||
candidates.append(font_name)
|
||||
else:
|
||||
candidates.extend([
|
||||
config.FONTS_DIR / "AlibabaPuHuiTi-Bold.ttf",
|
||||
config.FONTS_DIR / "AlibabaPuHuiTi-Regular.ttf",
|
||||
config.FONTS_DIR / "NotoSansSC-Bold.otf",
|
||||
config.FONTS_DIR / "NotoSansSC-Regular.otf",
|
||||
])
|
||||
candidates.extend([
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/simhei.ttf",
|
||||
])
|
||||
for path in candidates:
|
||||
if not path:
|
||||
continue
|
||||
p = str(path)
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
if isinstance(path, Path) and path.stat().st_size < 10000:
|
||||
continue
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load font {p}: {e}")
|
||||
continue
|
||||
logger.warning("No suitable font found, using default")
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
|
||||
"""十六进制颜色转 RGB"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _get_text_size(text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
|
||||
"""获取文字尺寸"""
|
||||
# 创建临时图像来测量文字
|
||||
dummy_img = Image.new("RGBA", (1, 1))
|
||||
draw = ImageDraw.Draw(dummy_img)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
|
||||
def _cache_key(text: str, style: Dict) -> str:
|
||||
"""生成缓存键"""
|
||||
content = f"{text}_{str(sorted(style.items()))}"
|
||||
return hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_text_with_stroke(
|
||||
text: str,
|
||||
font_size: int = 60,
|
||||
font_color: str = "#FFFFFF",
|
||||
stroke_color: str = "#000000",
|
||||
stroke_width: int = 4,
|
||||
font_name: str = None,
|
||||
padding: int = 20
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建带描边的文字图片
|
||||
|
||||
Args:
|
||||
text: 文字内容
|
||||
font_size: 字体大小
|
||||
font_color: 字体颜色(十六进制)
|
||||
stroke_color: 描边颜色
|
||||
stroke_width: 描边宽度
|
||||
font_name: 字体路径
|
||||
padding: 内边距
|
||||
|
||||
Returns:
|
||||
透明 PNG 图片
|
||||
"""
|
||||
font = _get_font(font_name, font_size)
|
||||
text_w, text_h = _get_text_size(text, font)
|
||||
|
||||
# 图片尺寸(加上描边和内边距)
|
||||
img_w = text_w + stroke_width * 2 + padding * 2
|
||||
img_h = text_h + stroke_width * 2 + padding * 2
|
||||
|
||||
# 创建透明图片
|
||||
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 文字位置
|
||||
x = padding + stroke_width
|
||||
y = padding + stroke_width
|
||||
|
||||
# 绘制描边(通过偏移绘制多次)
|
||||
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx * dx + dy * dy <= stroke_width * stroke_width:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=stroke_rgb)
|
||||
|
||||
# 绘制主文字
|
||||
font_rgb = _hex_to_rgb(font_color) + (255,)
|
||||
draw.text((x, y), text, font=font, fill=font_rgb)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_text_with_shadow(
|
||||
text: str,
|
||||
font_size: int = 60,
|
||||
font_color: str = "#FFFFFF",
|
||||
shadow_color: str = "#000000",
|
||||
shadow_offset: Tuple[int, int] = (4, 4),
|
||||
shadow_blur: int = 5,
|
||||
font_name: str = None,
|
||||
padding: int = 30,
|
||||
stroke_color: str = None,
|
||||
stroke_width: int = 0
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建带阴影的文字图片,可选描边(用于双层安全描边)
|
||||
"""
|
||||
font = _get_font(font_name, font_size)
|
||||
text_w, text_h = _get_text_size(text, font)
|
||||
|
||||
# 图片尺寸
|
||||
extra = max(shadow_blur, stroke_width * 2)
|
||||
img_w = text_w + abs(shadow_offset[0]) + extra * 2 + padding * 2
|
||||
img_h = text_h + abs(shadow_offset[1]) + extra * 2 + padding * 2
|
||||
|
||||
shadow_img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
shadow_draw = ImageDraw.Draw(shadow_img)
|
||||
|
||||
x = padding + extra
|
||||
y = padding + extra
|
||||
|
||||
# 阴影
|
||||
shadow_rgb = _hex_to_rgb(shadow_color) + (180,)
|
||||
shadow_draw.text((x + shadow_offset[0], y + shadow_offset[1]), text, font=font, fill=shadow_rgb)
|
||||
shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(shadow_blur))
|
||||
|
||||
draw = ImageDraw.Draw(shadow_img)
|
||||
|
||||
# 可选描边(外层深色或浅色)
|
||||
if stroke_color and stroke_width > 0:
|
||||
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx * dx + dy * dy <= stroke_width * stroke_width:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=stroke_rgb)
|
||||
|
||||
# 主文字
|
||||
font_rgb = _hex_to_rgb(font_color) + (255,)
|
||||
draw.text((x, y), text, font=font, fill=font_rgb)
|
||||
|
||||
return shadow_img
|
||||
|
||||
|
||||
def create_text_with_gradient(
|
||||
text: str,
|
||||
font_size: int = 60,
|
||||
gradient_colors: List[str] = None,
|
||||
gradient_direction: str = "vertical", # vertical, horizontal
|
||||
stroke_color: str = "#000000",
|
||||
stroke_width: int = 3,
|
||||
font_name: str = None,
|
||||
padding: int = 20
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建渐变色文字图片
|
||||
|
||||
Args:
|
||||
gradient_colors: 渐变颜色列表,如 ["#FF6B6B", "#FFE66D"]
|
||||
gradient_direction: 渐变方向
|
||||
"""
|
||||
if not gradient_colors:
|
||||
gradient_colors = ["#FF6B6B", "#FFE66D"] # 默认红黄渐变
|
||||
|
||||
font = _get_font(font_name, font_size)
|
||||
text_w, text_h = _get_text_size(text, font)
|
||||
|
||||
img_w = text_w + stroke_width * 2 + padding * 2
|
||||
img_h = text_h + stroke_width * 2 + padding * 2
|
||||
|
||||
# 创建渐变图层
|
||||
gradient = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
gradient_draw = ImageDraw.Draw(gradient)
|
||||
|
||||
# 生成渐变
|
||||
colors = [_hex_to_rgb(c) for c in gradient_colors]
|
||||
|
||||
for i in range(img_h if gradient_direction == "vertical" else img_w):
|
||||
ratio = i / (img_h if gradient_direction == "vertical" else img_w)
|
||||
# 线性插值颜色
|
||||
if ratio < 0.5:
|
||||
r = ratio * 2
|
||||
c1, c2 = colors[0], colors[min(1, len(colors) - 1)]
|
||||
else:
|
||||
r = (ratio - 0.5) * 2
|
||||
c1 = colors[min(1, len(colors) - 1)]
|
||||
c2 = colors[min(2, len(colors) - 1)] if len(colors) > 2 else c1
|
||||
|
||||
color = tuple(int(c1[j] + (c2[j] - c1[j]) * r) for j in range(3)) + (255,)
|
||||
|
||||
if gradient_direction == "vertical":
|
||||
gradient_draw.line([(0, i), (img_w, i)], fill=color)
|
||||
else:
|
||||
gradient_draw.line([(i, 0), (i, img_h)], fill=color)
|
||||
|
||||
# 创建文字蒙版
|
||||
mask = Image.new("L", (img_w, img_h), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
|
||||
x = padding + stroke_width
|
||||
y = padding + stroke_width
|
||||
|
||||
# 先绘制描边蒙版
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx * dx + dy * dy <= stroke_width * stroke_width:
|
||||
mask_draw.text((x + dx, y + dy), text, font=font, fill=128)
|
||||
|
||||
# 主文字蒙版
|
||||
mask_draw.text((x, y), text, font=font, fill=255)
|
||||
|
||||
# 创建结果图片
|
||||
result = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
|
||||
# 绘制描边
|
||||
stroke_img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
stroke_draw = ImageDraw.Draw(stroke_img)
|
||||
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
|
||||
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx * dx + dy * dy <= stroke_width * stroke_width:
|
||||
stroke_draw.text((x + dx, y + dy), text, font=font, fill=stroke_rgb)
|
||||
|
||||
result = Image.alpha_composite(result, stroke_img)
|
||||
|
||||
# 应用渐变到文字
|
||||
text_mask = Image.new("L", (img_w, img_h), 0)
|
||||
ImageDraw.Draw(text_mask).text((x, y), text, font=font, fill=255)
|
||||
|
||||
gradient_text = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
gradient_text.paste(gradient, mask=text_mask)
|
||||
|
||||
result = Image.alpha_composite(result, gradient_text)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_bubble_text(
|
||||
text: str,
|
||||
font_size: int = 48,
|
||||
font_color: str = "#333333",
|
||||
bg_color: str = "#FFFFFF",
|
||||
border_color: str = "#CCCCCC",
|
||||
border_width: int = 2,
|
||||
corner_radius: int = 20,
|
||||
padding: Tuple[int, int] = (30, 15),
|
||||
font_name: str = None,
|
||||
tail_direction: str = None # "left", "right", "bottom", None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建气泡框文字(对话框效果)
|
||||
|
||||
Args:
|
||||
tail_direction: 气泡尾巴方向
|
||||
"""
|
||||
font = _get_font(font_name, font_size)
|
||||
text_w, text_h = _get_text_size(text, font)
|
||||
|
||||
# 气泡尺寸
|
||||
bubble_w = text_w + padding[0] * 2
|
||||
bubble_h = text_h + padding[1] * 2
|
||||
|
||||
# 增加尾巴空间
|
||||
tail_size = 20 if tail_direction else 0
|
||||
|
||||
if tail_direction in ["left", "right"]:
|
||||
img_w = bubble_w + tail_size
|
||||
img_h = bubble_h
|
||||
elif tail_direction == "bottom":
|
||||
img_w = bubble_w
|
||||
img_h = bubble_h + tail_size
|
||||
else:
|
||||
img_w = bubble_w
|
||||
img_h = bubble_h
|
||||
|
||||
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 气泡位置
|
||||
if tail_direction == "left":
|
||||
bx = tail_size
|
||||
else:
|
||||
bx = 0
|
||||
by = 0
|
||||
|
||||
# 绘制圆角矩形
|
||||
bg_rgb = _hex_to_rgb(bg_color) + (255,)
|
||||
border_rgb = _hex_to_rgb(border_color) + (255,)
|
||||
|
||||
# 使用圆角矩形
|
||||
draw.rounded_rectangle(
|
||||
[bx, by, bx + bubble_w, by + bubble_h],
|
||||
radius=corner_radius,
|
||||
fill=bg_rgb,
|
||||
outline=border_rgb,
|
||||
width=border_width
|
||||
)
|
||||
|
||||
# 绘制尾巴
|
||||
if tail_direction == "left":
|
||||
points = [
|
||||
(bx, bubble_h // 2 - 10),
|
||||
(0, bubble_h // 2),
|
||||
(bx, bubble_h // 2 + 10)
|
||||
]
|
||||
draw.polygon(points, fill=bg_rgb, outline=border_rgb)
|
||||
# 覆盖边框内部分
|
||||
draw.polygon(points, fill=bg_rgb)
|
||||
elif tail_direction == "right":
|
||||
points = [
|
||||
(bx + bubble_w, bubble_h // 2 - 10),
|
||||
(img_w, bubble_h // 2),
|
||||
(bx + bubble_w, bubble_h // 2 + 10)
|
||||
]
|
||||
draw.polygon(points, fill=bg_rgb, outline=border_rgb)
|
||||
draw.polygon(points, fill=bg_rgb)
|
||||
elif tail_direction == "bottom":
|
||||
points = [
|
||||
(bubble_w // 2 - 10, bubble_h),
|
||||
(bubble_w // 2, img_h),
|
||||
(bubble_w // 2 + 10, bubble_h)
|
||||
]
|
||||
draw.polygon(points, fill=bg_rgb, outline=border_rgb)
|
||||
draw.polygon(points, fill=bg_rgb)
|
||||
|
||||
# 绘制文字
|
||||
font_rgb = _hex_to_rgb(font_color) + (255,)
|
||||
text_x = bx + padding[0]
|
||||
text_y = by + padding[1]
|
||||
draw.text((text_x, text_y), text, font=font, fill=font_rgb)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_price_tag(
|
||||
price: str,
|
||||
currency: str = "¥",
|
||||
font_size: int = 72,
|
||||
price_color: str = "#FF4444",
|
||||
currency_color: str = "#FF4444",
|
||||
stroke_color: str = "#FFFFFF",
|
||||
stroke_width: int = 4,
|
||||
font_name: str = None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建价格标签(电商风格)
|
||||
"""
|
||||
font_large = _get_font(font_name, font_size)
|
||||
font_small = _get_font(font_name, int(font_size * 0.5))
|
||||
|
||||
# 测量尺寸
|
||||
currency_w, currency_h = _get_text_size(currency, font_small)
|
||||
price_w, price_h = _get_text_size(price, font_large)
|
||||
|
||||
total_w = currency_w + price_w + 5
|
||||
total_h = max(currency_h, price_h)
|
||||
|
||||
padding = stroke_width + 10
|
||||
img_w = total_w + padding * 2
|
||||
img_h = total_h + padding * 2
|
||||
|
||||
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 绘制描边
|
||||
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx * dx + dy * dy <= stroke_width * stroke_width:
|
||||
# 货币符号
|
||||
draw.text(
|
||||
(padding + dx, padding + (total_h - currency_h) // 2 + dy),
|
||||
currency, font=font_small, fill=stroke_rgb
|
||||
)
|
||||
# 价格
|
||||
draw.text(
|
||||
(padding + currency_w + 5 + dx, padding + (total_h - price_h) // 2 + dy),
|
||||
price, font=font_large, fill=stroke_rgb
|
||||
)
|
||||
|
||||
# 绘制文字
|
||||
currency_rgb = _hex_to_rgb(currency_color) + (255,)
|
||||
price_rgb = _hex_to_rgb(price_color) + (255,)
|
||||
|
||||
draw.text(
|
||||
(padding, padding + (total_h - currency_h) // 2),
|
||||
currency, font=font_small, fill=currency_rgb
|
||||
)
|
||||
draw.text(
|
||||
(padding + currency_w + 5, padding + (total_h - price_h) // 2),
|
||||
price, font=font_large, fill=price_rgb
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_button(
|
||||
text: str,
|
||||
font_size: int = 36,
|
||||
font_color: str = "#FFFFFF",
|
||||
bg_color: str = "#FF6B35",
|
||||
corner_radius: int = 25,
|
||||
padding: Tuple[int, int] = (40, 15),
|
||||
font_name: str = None,
|
||||
shadow: bool = True
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建按钮样式文字(如"立即抢购")
|
||||
"""
|
||||
font = _get_font(font_name, font_size)
|
||||
text_w, text_h = _get_text_size(text, font)
|
||||
|
||||
btn_w = text_w + padding[0] * 2
|
||||
btn_h = text_h + padding[1] * 2
|
||||
|
||||
shadow_offset = 4 if shadow else 0
|
||||
img_w = btn_w + shadow_offset
|
||||
img_h = btn_h + shadow_offset
|
||||
|
||||
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 绘制阴影
|
||||
if shadow:
|
||||
shadow_color = (0, 0, 0, 80)
|
||||
draw.rounded_rectangle(
|
||||
[shadow_offset, shadow_offset, btn_w + shadow_offset, btn_h + shadow_offset],
|
||||
radius=corner_radius,
|
||||
fill=shadow_color
|
||||
)
|
||||
|
||||
# 绘制按钮背景
|
||||
bg_rgb = _hex_to_rgb(bg_color) + (255,)
|
||||
draw.rounded_rectangle(
|
||||
[0, 0, btn_w, btn_h],
|
||||
radius=corner_radius,
|
||||
fill=bg_rgb
|
||||
)
|
||||
|
||||
# 绘制文字
|
||||
font_rgb = _hex_to_rgb(font_color) + (255,)
|
||||
text_x = padding[0]
|
||||
text_y = padding[1]
|
||||
draw.text((text_x, text_y), text, font=font, fill=font_rgb)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_comparison_text(
|
||||
left_text: str,
|
||||
right_text: str,
|
||||
vs_text: str = "vs",
|
||||
font_size: int = 48,
|
||||
left_color: str = "#666666",
|
||||
right_color: str = "#FF6B35",
|
||||
vs_color: str = "#FF0000",
|
||||
font_name: str = None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
创建对比文字(如"塌马尾 vs 高颅顶")
|
||||
"""
|
||||
font = _get_font(font_name, font_size)
|
||||
font_vs = _get_font(font_name, int(font_size * 0.8))
|
||||
|
||||
left_w, left_h = _get_text_size(left_text, font)
|
||||
vs_w, vs_h = _get_text_size(vs_text, font_vs)
|
||||
right_w, right_h = _get_text_size(right_text, font)
|
||||
|
||||
spacing = 15
|
||||
total_w = left_w + vs_w + right_w + spacing * 2
|
||||
total_h = max(left_h, vs_h, right_h)
|
||||
|
||||
padding = 20
|
||||
stroke_width = 3
|
||||
img_w = total_w + padding * 2 + stroke_width * 2
|
||||
img_h = total_h + padding * 2 + stroke_width * 2
|
||||
|
||||
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
x = padding + stroke_width
|
||||
y = padding + stroke_width
|
||||
|
||||
# 描边
|
||||
stroke_color = (0, 0, 0, 255)
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx * dx + dy * dy <= stroke_width * stroke_width:
|
||||
draw.text((x + dx, y + (total_h - left_h) // 2 + dy), left_text, font=font, fill=stroke_color)
|
||||
draw.text((x + left_w + spacing + dx, y + (total_h - vs_h) // 2 + dy), vs_text, font=font_vs, fill=stroke_color)
|
||||
draw.text((x + left_w + spacing + vs_w + spacing + dx, y + (total_h - right_h) // 2 + dy), right_text, font=font, fill=stroke_color)
|
||||
|
||||
# 绘制文字
|
||||
left_rgb = _hex_to_rgb(left_color) + (255,)
|
||||
vs_rgb = _hex_to_rgb(vs_color) + (255,)
|
||||
right_rgb = _hex_to_rgb(right_color) + (255,)
|
||||
|
||||
draw.text((x, y + (total_h - left_h) // 2), left_text, font=font, fill=left_rgb)
|
||||
draw.text((x + left_w + spacing, y + (total_h - vs_h) // 2), vs_text, font=font_vs, fill=vs_rgb)
|
||||
draw.text((x + left_w + spacing + vs_w + spacing, y + (total_h - right_h) // 2), right_text, font=font, fill=right_rgb)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预设样式
|
||||
# ============================================================
|
||||
|
||||
PRESET_STYLES = {
|
||||
"subtitle": {
|
||||
"font_size": 48,
|
||||
"font_color": "#FFFFFF",
|
||||
"stroke_color": "#000000",
|
||||
"stroke_width": 3,
|
||||
"version": "v2"
|
||||
},
|
||||
"highlight": {
|
||||
# 暖米白主色 + 浅描边 + 暗色阴影,匹配浅棕背景
|
||||
"font_size": 90,
|
||||
"font_color": "#F7E7D3",
|
||||
"stroke_color": "#C9B59A", # 浅描边
|
||||
"stroke_width": 4,
|
||||
"type": "shadow",
|
||||
"shadow_color": "#3A2C1F", # 暗棕阴影
|
||||
"shadow_offset": (3, 3),
|
||||
"shadow_blur": 10,
|
||||
"padding": 32,
|
||||
"version": "gloda"
|
||||
},
|
||||
"warning": {
|
||||
# 低饱和陶土红 + 米色描边 + 暗棕阴影
|
||||
"font_size": 80,
|
||||
"font_color": "#D96B4F",
|
||||
"stroke_color": "#F6E5D6",
|
||||
"stroke_width": 4,
|
||||
"type": "shadow",
|
||||
"shadow_color": "#3A2C1F",
|
||||
"shadow_offset": (3, 3),
|
||||
"shadow_blur": 10,
|
||||
"padding": 30,
|
||||
"version": "gloda"
|
||||
},
|
||||
"success": {
|
||||
"font_size": 52,
|
||||
"font_color": "#4CAF50",
|
||||
"stroke_color": "#FFFFFF",
|
||||
"stroke_width": 4,
|
||||
"version": "v2"
|
||||
},
|
||||
"price": {
|
||||
# 价格标签:温暖红 + 米白货币符号 + 暗描边
|
||||
"font_size": 110,
|
||||
"price_color": "#E25B4F",
|
||||
"currency_color": "#F6E5D6",
|
||||
"stroke_color": "#3A2C1F",
|
||||
"stroke_width": 8,
|
||||
"type": "price",
|
||||
"version": "gloda"
|
||||
},
|
||||
"cta_button": {
|
||||
# 暖橙按钮,轻阴影
|
||||
"font_size": 46,
|
||||
"font_color": "#FFFFFF",
|
||||
"bg_color": "#E6763A",
|
||||
"corner_radius": 32,
|
||||
"type": "button",
|
||||
"shadow": True,
|
||||
"version": "gloda"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_fancy_text(
|
||||
text: str,
|
||||
style: str = "subtitle",
|
||||
custom_style: Dict[str, Any] = None,
|
||||
cache: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
创建花字图片的统一入口
|
||||
|
||||
Args:
|
||||
text: 文字内容
|
||||
style: 预设样式名称
|
||||
custom_style: 自定义样式(覆盖预设)
|
||||
cache: 是否缓存
|
||||
|
||||
Returns:
|
||||
PNG 图片路径
|
||||
"""
|
||||
# 合并样式
|
||||
base_style = PRESET_STYLES.get(style, PRESET_STYLES["subtitle"]).copy()
|
||||
if custom_style:
|
||||
base_style.update(custom_style)
|
||||
|
||||
# 检查缓存
|
||||
if cache:
|
||||
cache_name = _cache_key(text, base_style)
|
||||
cache_path = FANCY_TEXT_CACHE_DIR / f"{cache_name}.png"
|
||||
if cache_path.exists():
|
||||
return str(cache_path)
|
||||
|
||||
# 根据样式类型创建图片
|
||||
style_type = base_style.pop("type", None)
|
||||
|
||||
if style == "price" or style_type == "price":
|
||||
img = create_price_tag(text, **{k: v for k, v in base_style.items() if k in [
|
||||
"currency", "font_size", "price_color", "currency_color", "stroke_color", "stroke_width", "font_name"
|
||||
]})
|
||||
elif style == "cta_button" or style_type == "button":
|
||||
img = create_button(text, **{k: v for k, v in base_style.items() if k in [
|
||||
"font_size", "font_color", "bg_color", "corner_radius", "padding", "font_name", "shadow"
|
||||
]})
|
||||
elif style_type == "bubble":
|
||||
img = create_bubble_text(text, **{k: v for k, v in base_style.items() if k in [
|
||||
"font_size", "font_color", "bg_color", "border_color", "border_width",
|
||||
"corner_radius", "padding", "font_name", "tail_direction"
|
||||
]})
|
||||
elif style_type == "gradient":
|
||||
img = create_text_with_gradient(text, **{k: v for k, v in base_style.items() if k in [
|
||||
"font_size", "gradient_colors", "gradient_direction", "stroke_color", "stroke_width", "font_name", "padding"
|
||||
]})
|
||||
elif style_type == "shadow":
|
||||
img = create_text_with_shadow(text, **{k: v for k, v in base_style.items() if k in [
|
||||
"font_size", "font_color", "shadow_color", "shadow_offset", "shadow_blur", "font_name", "padding"
|
||||
]})
|
||||
else:
|
||||
# 默认带描边文字
|
||||
img = create_text_with_stroke(text, **{k: v for k, v in base_style.items() if k in [
|
||||
"font_size", "font_color", "stroke_color", "stroke_width", "font_name", "padding"
|
||||
]})
|
||||
|
||||
# 保存
|
||||
if cache:
|
||||
output_path = str(cache_path)
|
||||
else:
|
||||
output_path = str(config.TEMP_DIR / f"fancy_{hash(text)}_{os.getpid()}.png")
|
||||
|
||||
img.save(output_path, "PNG")
|
||||
logger.info(f"Created fancy text: '{text[:20]}...' -> {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def batch_create_fancy_texts(
|
||||
configs: List[Dict[str, Any]]
|
||||
) -> List[str]:
|
||||
"""
|
||||
批量创建花字图片
|
||||
|
||||
Args:
|
||||
configs: 配置列表 [{text, style, custom_style}]
|
||||
|
||||
Returns:
|
||||
PNG 图片路径列表
|
||||
"""
|
||||
paths = []
|
||||
for cfg in configs:
|
||||
path = create_fancy_text(
|
||||
text=cfg.get("text", ""),
|
||||
style=cfg.get("style", "subtitle"),
|
||||
custom_style=cfg.get("custom_style")
|
||||
)
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
Reference in New Issue
Block a user