#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Vibrant风格(活力风格)海报模板 """ import logging import math from typing import Dict, Any, Optional, Tuple, List import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter from .base_template import BaseTemplate from ..utils import ColorExtractor logger = logging.getLogger(__name__) class VibrantTemplate(BaseTemplate): """ 活力风格模板,适用于色彩鲜艳、内容丰富的场景,如旅游、美食分享。 特点是底部有毛玻璃效果的文案区域。 """ def __init__(self, size: Tuple[int, int] = (900, 1200)): super().__init__(size) self.config = { 'colors': { 'ocean_deep': [(0, 30, 80), (20, 120, 220)], 'sunset_warm': [(255, 94, 77), (255, 154, 0)], 'cool_mint': [(64, 224, 208), (127, 255, 212)], 'royal_purple': [(75, 0, 130), (138, 43, 226)], 'forest_green': [(34, 139, 34), (144, 238, 144)], 'fire_red': [(220, 20, 60), (255, 69, 0)], "gray_gradient": [(128, 128, 128), (211, 211, 211)], "dark_gray": [(15, 15, 15), (30, 30, 30)] }, 'glass_effect': { 'max_opacity': 240, 'blur_radius': 22, 'transition_height': 80, 'intensity_multiplier': 1.5 }, } # 设置中文字体路径 self.chinese_font_path = "/root/TravelContentCreator/assets/font/兰亭粗黑简.TTF" # 重写text_renderer的字体加载方法以支持中文 self._patch_text_renderer_for_chinese() def _patch_text_renderer_for_chinese(self): """重写text_renderer的字体加载方法以支持中文""" original_load_font = self.text_renderer._load_default_font def load_chinese_font(size: int): try: return ImageFont.truetype(self.chinese_font_path, size) except: logger.warning(f"无法加载中文字体,使用默认字体") return original_load_font(size) # 替换字体加载方法 self.text_renderer._load_default_font = load_chinese_font def generate(self, images: List, content: Optional[Dict[str, Any]] = None, theme_color: Optional[str] = None, glass_intensity: float = 1.5, num_variations: int = 1, **kwargs): """ 生成Vibrant风格海报,支持统一渲染 Args: images (List): 主图 content (Optional[Dict[str, Any]]): 包含所有文本信息的字典 theme_color (Optional[str]): 预设颜色主题的名称 glass_intensity (float): 毛玻璃效果强度 num_variations (int): 生成海报数量 **kwargs: 其他参数,包含generate_fabric_json等 Returns: 根据参数返回不同格式: - 如果generate_fabric_json=True:返回包含image和fabric_json的字典 - 否则:返回Image.Image对象 """ if content is None: content = self._get_default_content() # 检查是否需要同时生成JSON generate_fabric_json = kwargs.get('generate_fabric_json', False) if generate_fabric_json: # 使用统一渲染方法,同时生成PNG和JSON logger.info("🔄 使用统一渲染方法同时生成PNG和JSON") # PNG渲染 png_result = self._unified_render( images=images, content=content, theme_color=theme_color, glass_intensity=glass_intensity, output_format='png' ) # JSON渲染 json_result = self._unified_render( images=images, content=content, theme_color=theme_color, glass_intensity=glass_intensity, output_format='json' ) if "error" in png_result or "error" in json_result: logger.error("统一渲染失败,回退到传统方法") return self._generate_legacy(images, content, theme_color, glass_intensity) return { "image": png_result["image"], "fabric_json": json_result["fabric_json"], "generation_metadata": { "gradient_start": png_result["layout_params"]["gradient_start"], "layout_params": png_result["layout_params"], "unified_render": True } } else: # 传统模式,只生成PNG return self._generate_legacy(images, content, theme_color, glass_intensity) def _generate_legacy(self, images, content: Dict[str, Any], theme_color: Optional[str], glass_intensity: float) -> Image.Image: """传统的PNG生成方法(保持向后兼容)""" self.config['glass_effect']['intensity_multiplier'] = glass_intensity main_image = images logger.info(f"main_image的类型: {np.shape(main_image)}") if not main_image: logger.error(f"无法加载图片: ") return None main_image = self.image_processor.resize_image(image=main_image, target_size=self.size) estimated_height = self._estimate_content_height(content) gradient_start = self._detect_gradient_start_position(main_image, estimated_height) canvas = self._create_composite_image(main_image, gradient_start, theme_color) canvas = self._render_texts(canvas, content, gradient_start) final_image = canvas.resize((1350, 1800), Image.LANCZOS) return final_image def _get_default_content(self) -> Dict[str, Any]: """获取默认的测试内容""" return { "title": "正佳极地海洋世界", "slogan": "都说海洋馆是约会圣地!那锦峰夜场将是绝杀!", "price": "199", "ticket_type": "夜场票", "content_button": "套餐内容", "content_items": [ "正佳极地海洋世界夜场票1张", "有效期至2025.06.02", "多种动物表演全部免费" ], "remarks": [ "工作日可直接入园", "周末请提前1天预约" ], "tag": "#520特惠", "pagination": "" } def _estimate_content_height(self, content: Dict[str, Any]) -> int: """预估内容高度""" standard_margin = 25 title_height = 100 subtitle_height = 80 button_height = 40 content_items = content.get("content_items", []) content_line_height = 32 content_list_height = len(content_items) * content_line_height price_height = 90 ticket_height = 60 remarks = content.get("remarks", []) if isinstance(remarks, str): remarks = [remarks] remarks_height = len(remarks) * 25 + 10 footer_height = 40 total_height = ( 20 + title_height + standard_margin + subtitle_height + standard_margin + button_height + 15 + content_list_height + price_height + ticket_height + remarks_height + footer_height + 30 ) return total_height def _detect_gradient_start_position(self, image: Image.Image, estimated_height: int) -> int: """动态检测渐变起始位置""" width, height = image.size center_x = width // 2 gradient_start = None for y in range(height // 2, height): try: pixel = image.getpixel((center_x, y)) if isinstance(pixel, (tuple, list)) and len(pixel) >= 3: brightness = sum(pixel[:3]) / 3 if brightness > 50: gradient_start = max(y - 20, height // 2) break except: continue if gradient_start is None: bottom_margin = 60 gradient_start = max(height - estimated_height - bottom_margin, height // 2) return gradient_start def _create_composite_image(self, main_image: Image.Image, gradient_start: int, theme_color: Optional[str]) -> Image.Image: """创建毛玻璃背景和复合图像""" if theme_color and theme_color in self.config['colors']: top_color, bottom_color = self.config['colors'][theme_color] else: top_color, bottom_color = self._extract_glass_colors_from_image(main_image, gradient_start) logger.info(f"使用毛玻璃颜色: 顶部={top_color}, 底部={bottom_color}") gradient_overlay = self._create_frosted_glass_overlay(top_color, bottom_color, gradient_start) composite_img = Image.new('RGBA', self.size, (0, 0, 0, 0)) composite_img.paste(main_image, (0, 0)) composite_img = Image.alpha_composite(composite_img, gradient_overlay) return composite_img def _create_frosted_glass_overlay(self, top_color: Tuple[int, int, int], bottom_color: Tuple[int, int, int], gradient_start: int) -> Image.Image: """创建高级毛玻璃效果覆盖层""" overlay = Image.new('RGBA', self.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) gradient_height = self.height - gradient_start glass_config = self.config['glass_effect'] intensity = glass_config['intensity_multiplier'] enhanced_opacity = min(255, int(glass_config['max_opacity'] * intensity)) enhanced_blur = int(glass_config['blur_radius'] * intensity) def enhance_color(color, multiplier): factor = min(1.5, 1.0 + (multiplier - 1.0) * 0.3) return tuple(min(255, max(0, int(c * factor))) for c in color) top_c = enhance_color(top_color, intensity) bottom_c = enhance_color(bottom_color, intensity) top_c_arr = np.array(top_c) bottom_c_arr = np.array(bottom_c) for y in range(gradient_start, self.height): ratio = (y - gradient_start) / gradient_height if gradient_height > 0 else 0 smooth_ratio = 0.5 - 0.5 * math.cos(ratio * math.pi) color = (1 - smooth_ratio) * top_c_arr + smooth_ratio * bottom_c_arr alpha_smooth = ratio ** (1.1 / intensity) alpha = int(enhanced_opacity * (0.02 + 0.98 * alpha_smooth)) if (y - gradient_start) < glass_config['transition_height']: transition_ratio = (y - gradient_start) / glass_config['transition_height'] alpha = int(alpha * (0.5 - 0.5 * math.cos(transition_ratio * math.pi))) color_tuple = tuple(int(c) for c in color) + (alpha,) draw.line([(0, y), (self.width, y)], fill=color_tuple) return overlay.filter(ImageFilter.GaussianBlur(radius=enhanced_blur)) def _extract_glass_colors_from_image(self, image: Image.Image, gradient_start: int) -> tuple: """从图片中提取用于毛玻璃背景的颜色""" if image.mode != 'RGB': image = image.convert('RGB') width, height = image.size top_samples, bottom_samples = [], [] top_y = min(gradient_start + 20, height - 1) for x in range(0, width, 20): if sum(pixel := image.getpixel((x, top_y))) > 30: top_samples.append(pixel) bottom_y = min(height - 50, height - 1) for x in range(0, width, 20): if sum(pixel := image.getpixel((x, bottom_y))) > 30: bottom_samples.append(pixel) top_color = tuple(max(0, int(c * 0.1)) for c in np.mean(top_samples, axis=0)) if top_samples else (0, 5, 15) bottom_color = tuple(max(0, int(c * 0.2)) for c in np.mean(bottom_samples, axis=0)) if bottom_samples else (0, 25, 50) return top_color, bottom_color def _calculate_optimal_font_size_enhanced(self, text: str, target_width: int, max_size: int = 120, min_size: int = 10) -> Tuple[int, int]: """ 计算文本的最佳字体大小,使其宽度接近目标宽度(增强版本,与demo一致) 返回: (字体大小, 实际文本宽度) """ # 二分查找最佳字体大小 low = min_size high = max_size best_size = min_size best_width = 0 tolerance = 0.08 # 容差值,使文本宽度更接近目标值 # 首先尝试最大字体大小 try: font = self.text_renderer._load_default_font(max_size) max_width, _ = self.text_renderer.get_text_size(text, font) except: max_width = target_width * 2 # 如果出错,设置一个大值 # 如果最大字体大小下的宽度仍小于目标宽度的108%,直接使用最大字体 if max_width < target_width * (1 + tolerance): best_size = max_size best_width = max_width else: # 记录最接近目标宽度的字体大小 closest_size = min_size closest_diff = target_width while low <= high: mid = (low + high) // 2 try: font = self.text_renderer._load_default_font(mid) width, _ = self.text_renderer.get_text_size(text, font) except: width = target_width * 2 # 如果出错,设置一个大值 # 计算与目标宽度的差距 diff = abs(width - target_width) # 更新最接近的字体大小 if diff < closest_diff: closest_diff = diff closest_size = mid # 如果宽度在目标宽度的允许范围内,认为找到了最佳匹配 if target_width * (1 - tolerance) <= width <= target_width * (1 + tolerance): best_size = mid best_width = width break # 如果当前宽度小于目标宽度,尝试更大的字体 if width < target_width: if width > best_width: best_width = width best_size = mid low = mid + 1 else: # 如果当前宽度大于目标宽度,尝试更小的字体 high = mid - 1 # 如果没有找到在容差范围内的字体大小,使用最接近的字体大小 if best_width == 0: best_size = closest_size # 确保返回的宽度是使用最终字体计算的实际宽度 try: best_font = self.text_renderer._load_default_font(best_size) final_width, _ = self.text_renderer.get_text_size(text, best_font) except: final_width = best_width logger.info(f"文本'{text[:min(10, len(text))]}...'的最佳字体大小: {best_size},目标宽度: {target_width},实际宽度: {final_width},差距: {abs(final_width-target_width)}") return best_size, final_width def _render_texts(self, canvas: Image.Image, content: Dict[str, Any], gradient_start: int) -> Image.Image: """渲染所有文本元素,采用双栏布局""" draw = ImageDraw.Draw(canvas) width, height = canvas.size center_x = width // 2 left_margin, right_margin = self._calculate_content_margins(content, width, center_x) footer_y = height - 30 self._render_footer(draw, content, footer_y, left_margin, right_margin) title_y = gradient_start + 40 current_y = self._render_title_subtitle(draw, content, title_y, center_x, left_margin, right_margin) content_area_width = right_margin - left_margin left_column_width = int(content_area_width * 0.5) right_column_x = left_margin + left_column_width content_start_y = current_y + 30 self._render_left_column(draw, content, content_start_y, left_margin, left_column_width, height) self._render_right_column(draw, content, content_start_y, right_column_x, right_margin) return canvas def _calculate_content_margins(self, content: Dict[str, Any], width: int, center_x: int) -> Tuple[int, int]: """计算内容区域的左右边距(增强版本,与demo一致)""" # 计算标题位置 title_text = content.get("title", "") title_target_width = int(width * 0.95) title_size, title_width = self._calculate_optimal_font_size_enhanced( title_text, title_target_width, min_size=40, max_size=130 ) title_x = center_x - title_width // 2 # 计算副标题位置 slogan_text = content.get("slogan", "") subtitle_target_width = int(width * 0.9) subtitle_size, subtitle_width = self._calculate_optimal_font_size_enhanced( slogan_text, subtitle_target_width, max_size=50, min_size=20 ) subtitle_x = center_x - subtitle_width // 2 # 计算内容区域边距 - 减小额外的边距,让内容区域更宽 padding = 20 # 从30减小到20 content_left_margin = min(title_x, subtitle_x) - padding content_right_margin = max(title_x + title_width, subtitle_x + subtitle_width) + padding # 确保边距不超出合理范围,但允许更宽的内容区域 content_left_margin = max(40, content_left_margin) content_right_margin = min(width - 40, content_right_margin) # 如果内容区域太窄,强制使用更宽的区域 min_content_width = int(width * 0.75) # 至少使用75%的宽度 current_width = content_right_margin - content_left_margin if current_width < min_content_width: extra_width = min_content_width - current_width content_left_margin = max(30, content_left_margin - extra_width // 2) content_right_margin = min(width - 30, content_right_margin + extra_width // 2) return content_left_margin, content_right_margin def _render_footer(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, left: int, right: int): """渲染页脚文本""" font = self.text_renderer._load_default_font(18) if tag := content.get("tag"): draw.text((left, y), tag, font=font, fill=(255, 255, 255)) if pagination := content.get("pagination"): width, _ = self.text_renderer.get_text_size(pagination, font) draw.text((right - width, y), pagination, font=font, fill=(255, 255, 255)) def _render_title_subtitle(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, center_x: int, left: int, right: int) -> int: """渲染标题和副标题(增强版本,与demo一致)""" # 标题 title_text = content.get("title", "默认标题") title_target_width = int((right - left) * 0.98) title_size, title_actual_width = self._calculate_optimal_font_size_enhanced( title_text, title_target_width, max_size=140, min_size=40 ) title_font = self.text_renderer._load_default_font(title_size) text_w, text_h = self.text_renderer.get_text_size(title_text, title_font) title_x = center_x - text_w // 2 self.text_renderer.draw_text_with_outline(draw, (title_x, y), title_text, title_font, text_color=(255, 255, 255, 255), outline_color=(0, 30, 80, 200), outline_width=4) # 副标题 (slogan) subtitle_text = content.get("slogan", "") subtitle_target_width = int((right - left) * 0.95) subtitle_size, subtitle_actual_width = self._calculate_optimal_font_size_enhanced( subtitle_text, subtitle_target_width, max_size=75, min_size=20 ) subtitle_font = self.text_renderer._load_default_font(subtitle_size) sub_text_w, sub_text_h = self.text_renderer.get_text_size(subtitle_text, subtitle_font) subtitle_x = center_x - sub_text_w // 2 title_spacing = 30 # 在标题下方添加装饰线 line_y = y + text_h + 5 line_start_x = title_x - text_w * 0.025 line_end_x = title_x + text_w * 1.025 draw.line([(line_start_x, line_y), (line_end_x, line_y)], fill=(215, 215, 215, 80), width=3) subtitle_y = y + text_h + title_spacing self.text_renderer.draw_text_with_shadow(draw, (subtitle_x, subtitle_y), subtitle_text, subtitle_font, text_color=(255, 255, 255, 255), shadow_color=(0, 0, 0, 180), shadow_offset=(2, 2)) return subtitle_y + sub_text_h def _render_left_column(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, x: int, width: int, canvas_height: int): """渲染左栏内容:按钮和项目列表""" button_font = self.text_renderer._load_default_font(30) button_text = content.get("content_button", "套餐内容") button_width, _ = self.text_renderer.get_text_size(button_text, button_font) button_width += 40 button_height = 50 self.text_renderer.draw_rounded_rectangle(draw=draw, position=(x, y), size=(button_width, button_height), radius=20, fill_color=(0, 140, 210, 180), outline_color=(255, 255, 255, 255), outline_width=1) draw.text((x + 20, y + (button_height - 30) // 2), button_text, font=button_font, fill=(255, 255, 255)) items = content.get("content_items", []) if not items: return font = self.text_renderer._load_default_font(28) list_y = y + button_height + 20 available_h = canvas_height - 30 - (len(content.get("remarks", [])) * 25 + 10) - list_y - 20 total_items_h = len(items) * 36 extra_space_per_item = max(0, min(17, (available_h - total_items_h) / max(1, len(items) - 1))) line_spacing = 8 + extra_space_per_item for i, item in enumerate(items): item_y = list_y + i * (28 + line_spacing) draw.text((x, item_y), " " + item, font=font, fill=(255, 255, 255)) def _render_right_column(self, draw: ImageDraw.Draw, content: Dict[str, Any], y: int, x: int, right_margin: int): """渲染右栏内容:价格、票种和备注(增强版本,与demo一致)""" price_text = str(content.get('price', '')) # 确保价格是字符串类型 price_target_width = int((right_margin - x) * 0.7) price_size, price_actual_width = self._calculate_optimal_font_size_enhanced( price_text, price_target_width, max_size=120, min_size=40 ) price_font = self.text_renderer._load_default_font(price_size) suffix_font = self.text_renderer._load_default_font(int(price_size * 0.3)) _, price_height = self.text_renderer.get_text_size(price_text, price_font) suffix_width, suffix_height = self.text_renderer.get_text_size("CNY起", suffix_font) price_x = right_margin - price_actual_width - suffix_width self.text_renderer.draw_text_with_shadow(draw, (price_x, y), price_text, price_font) suffix_y = y + price_height - suffix_height draw.text((price_x + price_actual_width, suffix_y), "CNY起", font=suffix_font, fill=(255, 255, 255)) underline_y = y + price_height + 18 draw.line([(price_x - 10, underline_y), (right_margin, underline_y)], fill=(255, 255, 255, 80), width=2) ticket_text = content.get("ticket_type", "") ticket_target_width = int((right_margin - x) * 0.7) ticket_size, ticket_actual_width = self._calculate_optimal_font_size_enhanced( ticket_text, ticket_target_width, max_size=60, min_size=30 ) ticket_font = self.text_renderer._load_default_font(ticket_size) ticket_x = right_margin - ticket_actual_width ticket_y = y + price_height + 35 self.text_renderer.draw_text_with_shadow(draw, (ticket_x, ticket_y), ticket_text, ticket_font) _, ticket_height = self.text_renderer.get_text_size(ticket_text, ticket_font) remarks = content.get("remarks", []) if remarks: remarks_font = self.text_renderer._load_default_font(16) remarks_y = ticket_y + ticket_height + 30 for i, remark in enumerate(remarks): remark_width, _ = self.text_renderer.get_text_size(remark, remarks_font) draw.text((right_margin - remark_width, remarks_y + i * 21), remark, font=remarks_font, fill=(255, 255, 255, 200)) def generate_layered_psd(self, images, content: Optional[Dict[str, Any]] = None, theme_color: Optional[str] = None, glass_intensity: float = 1.5, output_path: str = "layered_poster.psd", **kwargs) -> str: """ 生成分层的PSD文件,方便后续修改 Args: images: 主图 content: 包含所有文本信息的字典 theme_color: 预设颜色主题的名称 glass_intensity: 毛玻璃效果强度 output_path: PSD文件输出路径 Returns: str: 生成的PSD文件路径 """ try: from psd_tools import PSDImage from psd_tools.api.layers import PixelLayer except ImportError: logger.error("需要安装psd-tools库: pip install psd-tools") return None logger.info("开始生成分层PSD文件...") if content is None: content = self._get_default_content() self.config['glass_effect']['intensity_multiplier'] = glass_intensity main_image = images if not main_image: logger.error("无法加载图片") return None # 参考原始合成模式的PSD版本实现 main_image = self.image_processor.resize_image(image=main_image, target_size=self.size) estimated_height = self._estimate_content_height(content) gradient_start = self._detect_gradient_start_position(main_image, estimated_height) # === 第一步:创建与常规模式完全相同的最终结果作为Reference === canvas = self._create_composite_image(main_image, gradient_start, theme_color) canvas = self._render_texts(canvas, content, gradient_start) final_canvas = canvas.resize((1350, 1800), Image.LANCZOS) # === 第二步:为PSD创建可编辑的图层版本 === final_size = (1350, 1800) psd = PSDImage.new("RGBA", final_size, color=(0, 0, 0, 0)) logger.info(f"创建PSD文档,尺寸: {final_size}") # 图层1:完美一致的参考图层 composite_layer = PixelLayer.frompil(final_canvas, psd, "Perfect Reference") psd.append(composite_layer) logger.info("✓ 添加完美参考图层") # === 第三步:提供丰富的可编辑图层 === # 图层2:纯背景图层(无任何效果) background_only = main_image.resize(final_size, Image.LANCZOS) bg_layer = PixelLayer.frompil(background_only, psd, "Background Image") psd.append(bg_layer) logger.info("✓ 添加纯背景图层") # 图层3:毛玻璃效果图层 glass_overlay = self._create_glass_overlay_layer(main_image, gradient_start, theme_color) if glass_overlay: glass_scaled = glass_overlay.resize(final_size, Image.LANCZOS) glass_layer = PixelLayer.frompil(glass_scaled, psd, "Glass Overlay") psd.append(glass_layer) logger.info("✓ 添加毛玻璃效果图层") # 图层4-9:分离的文字图层 detailed_text_layers = self._create_detailed_text_layers(content, gradient_start, self.size) for layer_name, layer_image in detailed_text_layers.items(): if layer_image: text_scaled = layer_image.resize(final_size, Image.LANCZOS) text_layer = PixelLayer.frompil(text_scaled, psd, layer_name) psd.append(text_layer) logger.info(f"✓ 添加文字图层: {layer_name}") # 图层10:整体文字图层(备用) text_only_canvas = self._create_text_only_layer(content, gradient_start, self.size) if text_only_canvas: text_only_scaled = text_only_canvas.resize(final_size, Image.LANCZOS) all_text_layer = PixelLayer.frompil(text_only_scaled, psd, "All Text (Backup)") psd.append(all_text_layer) logger.info("✓ 添加整体文字备用图层") logger.info(f"PSD包含{len(list(psd))}个图层:1个参考层+{len(list(psd))-1}个编辑层") # 保存PSD文件 psd.save(output_path) logger.info(f"✓ PSD文件已保存: {output_path} (尺寸: {psd.size})") return output_path def _create_glass_overlay_layer(self, main_image: Image.Image, gradient_start: int, theme_color: Optional[str]) -> Optional[Image.Image]: """创建毛玻璃效果的独立图层""" try: if theme_color and theme_color in self.config['colors']: top_color, bottom_color = self.config['colors'][theme_color] else: top_color, bottom_color = self._extract_glass_colors_from_image(main_image, gradient_start) # 创建透明背景的毛玻璃层 overlay = self._create_frosted_glass_overlay(top_color, bottom_color, gradient_start) return overlay except Exception as e: logger.error(f"创建毛玻璃层失败: {e}") return None def _create_text_layers(self, content: Dict[str, Any], gradient_start: int) -> Dict[str, Optional[Image.Image]]: """创建各个文字图层""" layers = {} try: # 创建透明画布 canvas_size = self.size # 计算布局参数 width, height = canvas_size center_x = width // 2 left_margin, right_margin = self._calculate_content_margins(content, width, center_x) # 1. 标题层 layers["Title Text"] = self._create_title_layer(content, gradient_start, center_x, left_margin, right_margin, canvas_size) # 2. 副标题层 layers["Subtitle Text"] = self._create_subtitle_layer(content, gradient_start, center_x, left_margin, right_margin, canvas_size) # 3. 装饰线层 layers["Decorations"] = self._create_decoration_layer(content, gradient_start, center_x, left_margin, right_margin, canvas_size) # 4. 左栏内容层 title_y = gradient_start + 40 subtitle_height = 80 + 30 # 预估副标题高度 content_start_y = title_y + subtitle_height + 30 content_area_width = right_margin - left_margin left_column_width = int(content_area_width * 0.5) layers["Left Content"] = self._create_left_column_layer(content, content_start_y, left_margin, left_column_width, canvas_size) # 5. 右栏内容层 right_column_x = left_margin + left_column_width layers["Right Content"] = self._create_right_column_layer(content, content_start_y, right_column_x, right_margin, canvas_size) # 6. 页脚层 footer_y = height - 30 layers["Footer Info"] = self._create_footer_layer(content, footer_y, left_margin, right_margin, canvas_size) except Exception as e: logger.error(f"创建文字图层失败: {e}") return layers def _create_text_only_layer(self, content: Dict[str, Any], gradient_start: int, canvas_size: tuple) -> Optional[Image.Image]: """创建纯文字图层(透明背景),模拟_render_texts的渲染逻辑""" try: # 创建透明画布 text_canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(text_canvas) width, height = canvas_size center_x = width // 2 # 使用与原始方法相同的布局计算 left_margin, right_margin = self._calculate_content_margins(content, width, center_x) # 页脚 footer_y = height - 30 self._render_footer(draw, content, footer_y, left_margin, right_margin) # 标题和副标题 title_y = gradient_start + 40 current_y = self._render_title_subtitle(draw, content, title_y, center_x, left_margin, right_margin) # 左右栏内容 content_area_width = right_margin - left_margin left_column_width = int(content_area_width * 0.5) right_column_x = left_margin + left_column_width content_start_y = current_y + 30 self._render_left_column(draw, content, content_start_y, left_margin, left_column_width, height) self._render_right_column(draw, content, content_start_y, right_column_x, right_margin) return text_canvas except Exception as e: logger.error(f"创建纯文字图层失败: {e}") return None def _create_text_only_layer(self, content: Dict[str, Any], gradient_start: int, canvas_size: tuple) -> Optional[Image.Image]: """创建纯文字图层(透明背景),模拟_render_texts的渲染逻辑""" try: # 创建透明画布 text_canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(text_canvas) width, height = canvas_size center_x = width // 2 # 使用与原始方法相同的布局计算 left_margin, right_margin = self._calculate_content_margins(content, width, center_x) # 页脚 footer_y = height - 30 self._render_footer(draw, content, footer_y, left_margin, right_margin) # 标题和副标题 title_y = gradient_start + 40 current_y = self._render_title_subtitle(draw, content, title_y, center_x, left_margin, right_margin) # 左右栏内容 content_area_width = right_margin - left_margin left_column_width = int(content_area_width * 0.5) right_column_x = left_margin + left_column_width content_start_y = current_y + 30 self._render_left_column(draw, content, content_start_y, left_margin, left_column_width, height) self._render_right_column(draw, content, content_start_y, right_column_x, right_margin) return text_canvas except Exception as e: logger.error(f"创建纯文字图层失败: {e}") return None def _create_detailed_text_layers(self, content: Dict[str, Any], gradient_start: int, canvas_size: tuple) -> Dict[str, Optional[Image.Image]]: """创建详细的分离文字图层,每个文字元素独立可编辑""" layers = {} try: width, height = canvas_size center_x = width // 2 left_margin, right_margin = self._calculate_content_margins(content, width, center_x) # 1. 标题图层 title_canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) title_draw = ImageDraw.Draw(title_canvas) title_y = gradient_start + 40 title_text = content.get("title", "默认标题") title_target_width = int((right_margin - left_margin) * 0.98) title_size, title_actual_width = self._calculate_optimal_font_size_enhanced( title_text, title_target_width, max_size=140, min_size=40 ) try: title_font = ImageFont.truetype(self.chinese_font_path, title_size) except: title_font = self.text_renderer._load_default_font(title_size) text_w, text_h = self.text_renderer.get_text_size(title_text, title_font) title_x = center_x - text_w // 2 self.text_renderer.draw_text_with_outline(title_draw, (title_x, title_y), title_text, title_font, text_color=(255, 255, 255, 255), outline_color=(0, 30, 80, 200), outline_width=4) layers["Title Text"] = title_canvas # 2. 副标题图层 subtitle_canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) subtitle_draw = ImageDraw.Draw(subtitle_canvas) subtitle_text = content.get("slogan", "") if subtitle_text: subtitle_target_width = int((right_margin - left_margin) * 0.95) subtitle_size, subtitle_actual_width = self._calculate_optimal_font_size_enhanced( subtitle_text, subtitle_target_width, max_size=75, min_size=20 ) try: subtitle_font = ImageFont.truetype(self.chinese_font_path, subtitle_size) except: subtitle_font = self.text_renderer._load_default_font(subtitle_size) sub_text_w, sub_text_h = self.text_renderer.get_text_size(subtitle_text, subtitle_font) subtitle_x = center_x - sub_text_w // 2 subtitle_y = title_y + text_h + 30 self.text_renderer.draw_text_with_shadow(subtitle_draw, (subtitle_x, subtitle_y), subtitle_text, subtitle_font, text_color=(255, 255, 255, 255), shadow_color=(0, 0, 0, 180), shadow_offset=(2, 2)) layers["Subtitle Text"] = subtitle_canvas # 3. 页脚图层 footer_canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) footer_draw = ImageDraw.Draw(footer_canvas) footer_y = height - 30 try: footer_font = ImageFont.truetype(self.chinese_font_path, 18) except: footer_font = self.text_renderer._load_default_font(18) if tag := content.get("tag"): footer_draw.text((left_margin, footer_y), tag, font=footer_font, fill=(255, 255, 255)) if pagination := content.get("pagination"): width_pg, _ = self.text_renderer.get_text_size(pagination, footer_font) footer_draw.text((right_margin - width_pg, footer_y), pagination, font=footer_font, fill=(255, 255, 255)) layers["Footer"] = footer_canvas except Exception as e: logger.error(f"创建详细文字图层失败: {e}") return {k: v for k, v in layers.items() if v is not None} def _draw_text_with_outline_simple(self, draw: ImageDraw.Draw, position: Tuple[int, int], text: str, font: ImageFont.FreeTypeFont, text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), outline_color: Tuple[int, int, int, int] = (0, 0, 0, 255), outline_width: int = 2): """简单的文本描边绘制方法""" x, y = position # 绘制描边 for dx in range(-outline_width, outline_width + 1): for dy in range(-outline_width, outline_width + 1): if dx == 0 and dy == 0: continue draw.text((x + dx, y + dy), text, font=font, fill=outline_color) # 绘制主文本 draw.text((x, y), text, font=font, fill=text_color) def _draw_text_with_shadow_simple(self, draw: ImageDraw.Draw, position: Tuple[int, int], text: str, font: ImageFont.FreeTypeFont, text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128), shadow_offset: Tuple[int, int] = (2, 2)): """简单的文本阴影绘制方法""" x, y = position shadow_x, shadow_y = shadow_offset # 绘制阴影 draw.text((x + shadow_x, y + shadow_y), text, font=font, fill=shadow_color) # 绘制主文本 draw.text((x, y), text, font=font, fill=text_color) def _create_title_layer(self, content: Dict[str, Any], gradient_start: int, center_x: int, left_margin: int, right_margin: int, canvas_size: Tuple[int, int]) -> Optional[Image.Image]: """创建标题图层""" try: canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) title_text = content.get("title", "默认标题") title_target_width = int((right_margin - left_margin) * 0.98) # 使用指定的中文字体 title_size, title_actual_width = self._calculate_optimal_font_size_enhanced( title_text, title_target_width, max_size=140, min_size=40 ) try: title_font = ImageFont.truetype(self.chinese_font_path, title_size) except: title_font = self.text_renderer._load_default_font(title_size) # 重新计算实际尺寸 bbox = title_font.getbbox(title_text) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] title_x = center_x - text_w // 2 title_y = gradient_start + 40 # 绘制带描边的标题 self._draw_text_with_outline_simple( draw, (title_x, title_y), title_text, title_font, text_color=(255, 255, 255, 255), outline_color=(0, 30, 80, 200), outline_width=4 ) return canvas except Exception as e: logger.error(f"创建标题层失败: {e}") import traceback traceback.print_exc() return None def _create_subtitle_layer(self, content: Dict[str, Any], gradient_start: int, center_x: int, left_margin: int, right_margin: int, canvas_size: Tuple[int, int]) -> Optional[Image.Image]: """创建副标题图层""" try: canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) subtitle_text = content.get("slogan", "") if not subtitle_text: return None subtitle_target_width = int((right_margin - left_margin) * 0.95) subtitle_size, subtitle_actual_width = self._calculate_optimal_font_size_enhanced( subtitle_text, subtitle_target_width, max_size=75, min_size=20 ) # 使用指定的中文字体 try: subtitle_font = ImageFont.truetype(self.chinese_font_path, subtitle_size) except: subtitle_font = self.text_renderer._load_default_font(subtitle_size) bbox = subtitle_font.getbbox(subtitle_text) sub_text_w = bbox[2] - bbox[0] sub_text_h = bbox[3] - bbox[1] subtitle_x = center_x - sub_text_w // 2 subtitle_y = gradient_start + 40 + 100 + 30 # title_y + title_height + spacing # 绘制带阴影的副标题 self._draw_text_with_shadow_simple( draw, (subtitle_x, subtitle_y), subtitle_text, subtitle_font, text_color=(255, 255, 255, 255), shadow_color=(0, 0, 0, 180), shadow_offset=(2, 2) ) return canvas except Exception as e: logger.error(f"创建副标题层失败: {e}") import traceback traceback.print_exc() return None def _create_decoration_layer(self, content: Dict[str, Any], gradient_start: int, center_x: int, left_margin: int, right_margin: int, canvas_size: Tuple[int, int]) -> Optional[Image.Image]: """创建装饰元素图层""" try: canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) # 获取标题信息用于装饰线定位 title_text = content.get("title", "默认标题") title_target_width = int((right_margin - left_margin) * 0.98) title_size, title_actual_width = self._calculate_optimal_font_size_enhanced( title_text, title_target_width, max_size=140, min_size=40 ) # 使用中文字体计算标题尺寸 try: title_font = ImageFont.truetype(self.chinese_font_path, title_size) except: title_font = self.text_renderer._load_default_font(title_size) bbox = title_font.getbbox(title_text) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] title_x = center_x - text_w // 2 title_y = gradient_start + 40 # 在标题下方添加装饰线 line_y = title_y + text_h + 5 line_start_x = title_x - text_w * 0.025 line_end_x = title_x + text_w * 1.025 draw.line([(line_start_x, line_y), (line_end_x, line_y)], fill=(215, 215, 215, 80), width=3) return canvas except Exception as e: logger.error(f"创建装饰层失败: {e}") import traceback traceback.print_exc() return None def _create_left_column_layer(self, content: Dict[str, Any], y: int, x: int, width: int, canvas_size: Tuple[int, int]) -> Optional[Image.Image]: """创建左栏内容图层""" try: canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) # 使用中文字体 # 按钮 try: button_font = ImageFont.truetype(self.chinese_font_path, 30) except: button_font = self.text_renderer._load_default_font(30) button_text = content.get("content_button", "套餐内容") bbox = button_font.getbbox(button_text) button_text_width = bbox[2] - bbox[0] button_width = button_text_width + 40 button_height = 50 # 绘制简单的矩形按钮 draw.rounded_rectangle([x, y, x + button_width, y + button_height], radius=20, fill=(0, 140, 210, 180), outline=(255, 255, 255, 255), width=1) draw.text((x + 20, y + (button_height - 30) // 2), button_text, font=button_font, fill=(255, 255, 255)) # 项目列表 items = content.get("content_items", []) if items: try: list_font = ImageFont.truetype(self.chinese_font_path, 28) except: list_font = self.text_renderer._load_default_font(28) list_y = y + button_height + 20 line_spacing = 36 for i, item in enumerate(items): item_y = list_y + i * line_spacing draw.text((x, item_y), "• " + item, font=list_font, fill=(255, 255, 255)) return canvas except Exception as e: logger.error(f"创建左栏内容层失败: {e}") import traceback traceback.print_exc() return None def _create_right_column_layer(self, content: Dict[str, Any], y: int, x: int, right_margin: int, canvas_size: Tuple[int, int]) -> Optional[Image.Image]: """创建右栏内容图层""" try: canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) # 使用中文字体 # 价格 price_text = str(content.get('price', '')) # 确保价格是字符串类型 if price_text: price_target_width = int((right_margin - x) * 0.7) price_size, price_actual_width = self._calculate_optimal_font_size_enhanced( price_text, price_target_width, max_size=120, min_size=40 ) try: price_font = ImageFont.truetype(self.chinese_font_path, price_size) suffix_font = ImageFont.truetype(self.chinese_font_path, int(price_size * 0.3)) except: price_font = self.text_renderer._load_default_font(price_size) suffix_font = self.text_renderer._load_default_font(int(price_size * 0.3)) price_bbox = price_font.getbbox(price_text) price_height = price_bbox[3] - price_bbox[1] suffix_bbox = suffix_font.getbbox("CNY起") suffix_width = suffix_bbox[2] - suffix_bbox[0] suffix_height = suffix_bbox[3] - suffix_bbox[1] price_x = right_margin - price_actual_width - suffix_width self._draw_text_with_shadow_simple(draw, (price_x, y), price_text, price_font) suffix_y = y + price_height - suffix_height draw.text((price_x + price_actual_width, suffix_y), "CNY起", font=suffix_font, fill=(255, 255, 255)) # 下划线 underline_y = y + price_height + 18 draw.line([(price_x - 10, underline_y), (right_margin, underline_y)], fill=(255, 255, 255, 80), width=2) # 票种 ticket_text = content.get("ticket_type", "") if ticket_text: ticket_target_width = int((right_margin - x) * 0.7) ticket_size, ticket_actual_width = self._calculate_optimal_font_size_enhanced( ticket_text, ticket_target_width, max_size=60, min_size=30 ) try: ticket_font = ImageFont.truetype(self.chinese_font_path, ticket_size) except: ticket_font = self.text_renderer._load_default_font(ticket_size) ticket_x = right_margin - ticket_actual_width ticket_y = y + price_height + 35 self._draw_text_with_shadow_simple(draw, (ticket_x, ticket_y), ticket_text, ticket_font) ticket_bbox = ticket_font.getbbox(ticket_text) ticket_height = ticket_bbox[3] - ticket_bbox[1] # 备注 remarks = content.get("remarks", []) if remarks: try: remarks_font = ImageFont.truetype(self.chinese_font_path, 16) except: remarks_font = self.text_renderer._load_default_font(16) remarks_y = ticket_y + ticket_height + 30 for i, remark in enumerate(remarks): remark_bbox = remarks_font.getbbox(remark) remark_width = remark_bbox[2] - remark_bbox[0] draw.text((right_margin - remark_width, remarks_y + i * 21), remark, font=remarks_font, fill=(255, 255, 255, 200)) return canvas except Exception as e: logger.error(f"创建右栏内容层失败: {e}") import traceback traceback.print_exc() return None def _create_footer_layer(self, content: Dict[str, Any], footer_y: int, left_margin: int, right_margin: int, canvas_size: Tuple[int, int]) -> Optional[Image.Image]: """创建页脚图层""" try: canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) # 使用中文字体 try: font = ImageFont.truetype(self.chinese_font_path, 18) except: font = self.text_renderer._load_default_font(18) # 标签(左下角) tag_text = content.get("tag", "") if tag_text: draw.text((left_margin, footer_y), tag_text, font=font, fill=(255, 255, 255)) # 分页信息(右下角) pagination_text = content.get("pagination", "") if pagination_text: pagination_bbox = font.getbbox(pagination_text) pagination_width = pagination_bbox[2] - pagination_bbox[0] draw.text((right_margin - pagination_width, footer_y), pagination_text, font=font, fill=(255, 255, 255)) return canvas except Exception as e: logger.error(f"创建页脚层失败: {e}") import traceback traceback.print_exc() return None def _unified_render(self, images, content: Optional[Dict[str, Any]] = None, theme_color: Optional[str] = None, glass_intensity: float = 1.5, output_format: str = 'png', **kwargs) -> Dict[str, Any]: """ 统一的渲染方法,PNG和JSON使用完全相同的布局计算 Args: images: 主图 content: 包含所有文本信息的字典 theme_color: 预设颜色主题的名称 glass_intensity: 毛玻璃效果强度 output_format: 输出格式 'png' 或 'json' Returns: Dict[str, Any]: 包含渲染结果和布局信息 """ if content is None: content = self._get_default_content() self.config['glass_effect']['intensity_multiplier'] = glass_intensity main_image = images if not main_image: logger.error("无法加载图片") return {"error": "无法加载图片"} # === 第一步:统一的预处理 === main_image = self.image_processor.resize_image(image=main_image, target_size=self.size) estimated_height = self._estimate_content_height(content) gradient_start = self._detect_gradient_start_position(main_image, estimated_height) # === 第二步:统一的布局计算 === layout_params = self._calculate_unified_layout(content, self.size, gradient_start) # === 第三步:根据输出格式生成结果 === if output_format == 'png': return self._render_to_png(main_image, content, theme_color, gradient_start, layout_params) elif output_format == 'json': return self._render_to_json(main_image, content, theme_color, gradient_start, layout_params) else: raise ValueError(f"不支持的输出格式: {output_format}") def _calculate_unified_layout(self, content: Dict[str, Any], canvas_size: Tuple[int, int], gradient_start: int) -> Dict[str, Any]: """ 统一的布局计算方法,PNG和JSON使用相同的逻辑 Returns: Dict: 包含所有布局参数的字典 """ width, height = canvas_size center_x = width // 2 # 使用PNG渲染相同的边距计算 left_margin, right_margin = self._calculate_content_margins(content, width, center_x) # 标题布局计算 title_text = content.get("title", "") title_target_width = int((right_margin - left_margin) * 0.98) title_size, title_actual_width = self._calculate_optimal_font_size_enhanced( title_text, title_target_width, max_size=140, min_size=40 ) title_x = center_x - title_actual_width // 2 title_y = gradient_start + 40 # 副标题布局计算 subtitle_text = content.get("slogan", "") subtitle_target_width = int((right_margin - left_margin) * 0.95) subtitle_size, subtitle_actual_width = self._calculate_optimal_font_size_enhanced( subtitle_text, subtitle_target_width, max_size=75, min_size=20 ) subtitle_x = center_x - subtitle_actual_width // 2 subtitle_y = title_y + 100 + 30 # 标题高度 + 间距 # 内容区域布局 content_area_width = right_margin - left_margin left_column_width = int(content_area_width * 0.5) right_column_x = left_margin + left_column_width content_start_y = subtitle_y + 80 + 30 # 副标题高度 + 间距 # 价格布局计算 price_text = str(content.get('price', '')) price_target_width = int((right_margin - right_column_x) * 0.7) price_size, price_actual_width = self._calculate_optimal_font_size_enhanced( price_text, price_target_width, max_size=120, min_size=40 ) # 票种布局计算 ticket_text = content.get("ticket_type", "") ticket_target_width = int((right_margin - right_column_x) * 0.7) ticket_size, ticket_actual_width = self._calculate_optimal_font_size_enhanced( ticket_text, ticket_target_width, max_size=60, min_size=30 ) layout_params = { # 基础参数 "width": width, "height": height, "center_x": center_x, "gradient_start": gradient_start, # 边距 "left_margin": left_margin, "right_margin": right_margin, # 标题 "title_text": title_text, "title_size": title_size, "title_width": title_actual_width, "title_x": title_x, "title_y": title_y, # 副标题 "subtitle_text": subtitle_text, "subtitle_size": subtitle_size, "subtitle_width": subtitle_actual_width, "subtitle_x": subtitle_x, "subtitle_y": subtitle_y, # 内容区域 "content_start_y": content_start_y, "left_column_width": left_column_width, "right_column_x": right_column_x, # 价格 "price_text": price_text, "price_size": price_size, "price_width": price_actual_width, # 票种 "ticket_text": ticket_text, "ticket_size": ticket_size, "ticket_width": ticket_actual_width, # 页脚 "footer_y": height - 30 } logger.info(f"统一布局计算完成,标题字体大小: {title_size}, 副标题字体大小: {subtitle_size}, 价格字体大小: {price_size}") return layout_params def _render_to_png(self, main_image: Image.Image, content: Dict[str, Any], theme_color: Optional[str], gradient_start: int, layout_params: Dict[str, Any]) -> Dict[str, Any]: """使用统一布局参数渲染PNG""" canvas = self._create_composite_image(main_image, gradient_start, theme_color) canvas = self._render_texts(canvas, content, gradient_start) final_image = canvas.resize((1350, 1800), Image.LANCZOS) return { "image": final_image, "layout_params": layout_params, "format": "png" } def _render_to_json(self, main_image: Image.Image, content: Dict[str, Any], theme_color: Optional[str], gradient_start: int, layout_params: Dict[str, Any]) -> Dict[str, Any]: """使用统一布局参数渲染JSON""" final_width, final_height = 1350, 1800 fabric_objects = [] # 1. 背景图片 if main_image and hasattr(main_image, 'width'): image_object = self._create_precise_image_object(main_image, final_width, final_height) fabric_objects.append(image_object) # 2. 毛玻璃效果 glass_overlay = self._create_precise_glass_overlay(main_image, gradient_start, theme_color, final_width, final_height) if glass_overlay: fabric_objects.append(glass_overlay) # 3. 使用统一布局参数的文本元素 text_objects = self._create_precise_text_objects(content, layout_params, final_width, final_height) fabric_objects.extend(text_objects) # 构建Fabric.js JSON fabric_json = { "version": "5.3.0", "width": final_width, "height": final_height, "objects": fabric_objects } return { "fabric_json": fabric_json, "layout_params": layout_params, "format": "json" } def _create_precise_image_object(self, main_image: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]: """创建精确的背景图片对象""" import base64 import io # 调整图片尺寸以匹配PNG渲染 resized_image = self.image_processor.resize_image(image=main_image, target_size=(canvas_width, canvas_height)) # 转换为base64 buffer = io.BytesIO() resized_image.save(buffer, format='PNG') image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') return { "type": "image", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": 0, "width": canvas_width, "height": canvas_height, "scaleX": 1, "scaleY": 1, "angle": 0, "opacity": 1, "src": f"data:image/png;base64,{image_base64}", "filters": [], "selectable": False, "evented": False } def _create_precise_glass_overlay(self, main_image: Image.Image, gradient_start: int, theme_color: Optional[str], canvas_width: int, canvas_height: int) -> Optional[Dict[str, Any]]: """创建精确的毛玻璃效果对象""" try: import base64 import io # 使用与PNG相同的颜色提取逻辑 if theme_color and theme_color in self.config['colors']: top_color, bottom_color = self.config['colors'][theme_color] else: top_color, bottom_color = self._extract_glass_colors_from_image(main_image, gradient_start) # 创建毛玻璃效果图像 overlay_canvas = Image.new('RGBA', (canvas_width, canvas_height), (0, 0, 0, 0)) glass_overlay = self._create_frosted_glass_overlay(top_color, bottom_color, gradient_start) # 缩放到最终尺寸 glass_scaled = glass_overlay.resize((canvas_width, canvas_height), Image.LANCZOS) # 转换为base64 buffer = io.BytesIO() glass_scaled.save(buffer, format='PNG') glass_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') return { "type": "image", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": 0, "width": canvas_width, "height": canvas_height, "scaleX": 1, "scaleY": 1, "angle": 0, "opacity": 1, "src": f"data:image/png;base64,{glass_base64}", "filters": [], "selectable": False, "evented": False } except Exception as e: logger.error(f"创建精确毛玻璃效果失败: {e}") return None def _create_precise_text_objects(self, content: Dict[str, Any], layout_params: Dict[str, Any], canvas_width: int, canvas_height: int) -> List[Dict[str, Any]]: """使用精确布局参数创建文本对象""" text_objects = [] try: # 1. 主标题 - 使用计算出的精确位置和字体大小 if layout_params["title_text"]: title_obj = { "type": "textbox", "left": layout_params["title_x"], "top": layout_params["title_y"], "width": layout_params["title_width"], "height": 100, "text": layout_params["title_text"], "fontSize": layout_params["title_size"], "fontFamily": "Arial Black", "fontWeight": "bold", "fill": "white", "textAlign": "center", "lineHeight": 1.2, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "stroke": "rgba(0, 30, 80, 0.8)", "strokeWidth": 3, "paintFirst": "stroke" } text_objects.append(title_obj) # 2. 副标题 - 使用计算出的精确位置和字体大小 if layout_params["subtitle_text"]: subtitle_obj = { "type": "textbox", "left": layout_params["subtitle_x"], "top": layout_params["subtitle_y"], "width": layout_params["subtitle_width"], "height": 80, "text": layout_params["subtitle_text"], "fontSize": layout_params["subtitle_size"], "fontFamily": "Arial", "fill": "white", "textAlign": "center", "lineHeight": 1.3, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "shadow": { "color": "rgba(0, 0, 0, 0.7)", "blur": 2, "offsetX": 2, "offsetY": 2 } } text_objects.append(subtitle_obj) # 3. 装饰线 line_y = layout_params["title_y"] + 100 + 5 line_start_x = layout_params["title_x"] - layout_params["title_width"] * 0.025 line_end_x = layout_params["title_x"] + layout_params["title_width"] * 1.025 line_obj = { "type": "line", "left": line_start_x, "top": line_y, "x1": 0, "y1": 0, "x2": line_end_x - line_start_x, "y2": 0, "stroke": "rgba(215, 215, 215, 0.3)", "strokeWidth": 3, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "selectable": False, "evented": False } text_objects.append(line_obj) # 4. 左栏按钮 button_text = content.get("content_button", "套餐内容") button_obj = { "type": "rect", "left": layout_params["left_margin"], "top": layout_params["content_start_y"], "width": 200, "height": 50, "fill": "rgba(0, 140, 210, 0.7)", "stroke": "white", "strokeWidth": 1, "rx": 20, "ry": 20, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "selectable": False, "evented": False } text_objects.append(button_obj) button_text_obj = { "type": "textbox", "left": layout_params["left_margin"] + 20, "top": layout_params["content_start_y"] + 10, "width": 160, "height": 30, "text": button_text, "fontSize": 30, "fontFamily": "Arial", "fontWeight": "bold", "fill": "white", "textAlign": "center", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1 } text_objects.append(button_text_obj) # 5. 左栏内容列表 content_items = content.get("content_items", []) list_y = layout_params["content_start_y"] + 70 for i, item in enumerate(content_items): item_obj = { "type": "textbox", "left": layout_params["left_margin"], "top": list_y + i * 40, "width": layout_params["left_column_width"], "height": 35, "text": f"• {item}", "fontSize": 28, "fontFamily": "Arial", "fill": "white", "textAlign": "left", "lineHeight": 1.2, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1 } text_objects.append(item_obj) # 6. 价格 - 使用计算出的精确位置和字体大小 if layout_params["price_text"]: price_x = layout_params["right_margin"] - layout_params["price_width"] - 60 # 给CNY起留空间 price_obj = { "type": "textbox", "left": price_x, "top": layout_params["content_start_y"], "width": layout_params["price_width"], "height": 80, "text": layout_params["price_text"], "fontSize": layout_params["price_size"], "fontFamily": "Arial Black", "fontWeight": "bold", "fill": "white", "textAlign": "right", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "shadow": { "color": "rgba(0, 0, 0, 0.5)", "blur": 2, "offsetX": 2, "offsetY": 2 } } text_objects.append(price_obj) # CNY起后缀 suffix_obj = { "type": "textbox", "left": price_x + layout_params["price_width"], "top": layout_params["content_start_y"] + 50, "width": 60, "height": 30, "text": "CNY起", "fontSize": int(layout_params["price_size"] * 0.3), "fontFamily": "Arial", "fill": "white", "textAlign": "left", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1 } text_objects.append(suffix_obj) # 价格下划线 underline_y = layout_params["content_start_y"] + 80 + 18 underline_obj = { "type": "line", "left": price_x - 10, "top": underline_y, "x1": 0, "y1": 0, "x2": layout_params["right_margin"] - (price_x - 10), "y2": 0, "stroke": "rgba(255, 255, 255, 0.3)", "strokeWidth": 2, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "selectable": False, "evented": False } text_objects.append(underline_obj) # 7. 票种 - 使用计算出的精确位置和字体大小 if layout_params["ticket_text"]: ticket_x = layout_params["right_margin"] - layout_params["ticket_width"] ticket_obj = { "type": "textbox", "left": ticket_x, "top": layout_params["content_start_y"] + 115, "width": layout_params["ticket_width"], "height": 60, "text": layout_params["ticket_text"], "fontSize": layout_params["ticket_size"], "fontFamily": "Arial", "fontWeight": "bold", "fill": "white", "textAlign": "right", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1, "shadow": { "color": "rgba(0, 0, 0, 0.5)", "blur": 2, "offsetX": 2, "offsetY": 2 } } text_objects.append(ticket_obj) # 8. 备注信息 remarks = content.get("remarks", []) if remarks: remarks_y = layout_params["content_start_y"] + 205 for i, remark in enumerate(remarks): remark_obj = { "type": "textbox", "left": layout_params["right_column_x"], "top": remarks_y + i * 25, "width": layout_params["right_margin"] - layout_params["right_column_x"], "height": 20, "text": remark, "fontSize": 16, "fontFamily": "Arial", "fill": "rgba(255, 255, 255, 0.8)", "textAlign": "right", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1 } text_objects.append(remark_obj) # 9. 页脚 footer_y = layout_params["footer_y"] if tag := content.get("tag"): tag_obj = { "type": "textbox", "left": layout_params["left_margin"], "top": footer_y, "width": 200, "height": 25, "text": tag, "fontSize": 18, "fontFamily": "Arial", "fill": "white", "textAlign": "left", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1 } text_objects.append(tag_obj) if pagination := content.get("pagination"): pagination_obj = { "type": "textbox", "left": layout_params["right_margin"] - 200, "top": footer_y, "width": 200, "height": 25, "text": pagination, "fontSize": 18, "fontFamily": "Arial", "fill": "white", "textAlign": "right", "lineHeight": 1.0, "opacity": 1, "angle": 0, "scaleX": 1, "scaleY": 1 } text_objects.append(pagination_obj) except Exception as e: logger.error(f"创建精确文本对象失败: {e}") return text_objects