""" 抖音风格花字生成模块 使用 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