diff --git a/api/models/__pycache__/poster.cpython-312.pyc b/api/models/__pycache__/poster.cpython-312.pyc index baa0d02..6a79478 100644 Binary files a/api/models/__pycache__/poster.cpython-312.pyc and b/api/models/__pycache__/poster.cpython-312.pyc differ diff --git a/api/models/poster.py b/api/models/poster.py index 187643c..eeb782e 100644 --- a/api/models/poster.py +++ b/api/models/poster.py @@ -79,6 +79,8 @@ class PosterGenerateResponse(BaseModel): templateId: str resultImagesBase64: List[Dict[str, Any]] = Field(description="生成的海报图像(base64编码)列表") psdFiles: Optional[List[Dict[str, Any]]] = Field(None, description="生成的PSD文件信息列表") + fabricJsons: Optional[List[Dict[str, Any]]] = Field(None, description="生成的Fabric.js JSON列表") + decorativeImages: Optional[Dict[str, Any]] = Field(None, description="装饰性图像base64数据,供Java端上传到S3") metadata: Dict[str, Any] = Field(default_factory=dict) class Config: diff --git a/api/services/__pycache__/poster.cpython-312.pyc b/api/services/__pycache__/poster.cpython-312.pyc index 24893a7..59e7092 100644 Binary files a/api/services/__pycache__/poster.cpython-312.pyc and b/api/services/__pycache__/poster.cpython-312.pyc differ diff --git a/api/services/poster.py b/api/services/poster.py index 8bb20f2..330747f 100644 --- a/api/services/poster.py +++ b/api/services/poster.py @@ -18,7 +18,7 @@ from io import BytesIO from typing import List, Dict, Any, Optional, Type, Union, cast from datetime import datetime from pathlib import Path -from PIL import Image +from PIL import Image, ImageDraw, ImageFont from core.config import ConfigManager, PosterConfig from core.ai import AIAgent @@ -234,19 +234,6 @@ class PosterService: # 获取模板的默认尺寸,如果获取不到则使用标准尺寸 template_size = getattr(template_handler, 'size', (900, 1200)) - if images_base64 and len(images_base64) > 0: - try: - logger.info(f"🎯 接收到 {len(images_base64)} 张图片的base64数据") - - # 处理第一张图片(目前模板只支持单张图片) - # 未来可以扩展为处理多张图片 - first_image_base64 = images_base64[0] if len(images_base64) > 0 else "" - - if not first_image_base64 or not first_image_base64.strip(): - raise ValueError("第一张图片的base64数据为空") - - logger.info(f"📊 处理第一张图片,base64长度: {len(first_image_base64)}") - if images_base64 and len(images_base64) > 0: try: logger.info(f"🎯 接收到 {len(images_base64)} 张图片的base64数据") @@ -290,7 +277,7 @@ class PosterService: elif file_header.startswith(b'\x89PNG'): logger.info("✅ 检测到PNG格式图片") else: - logger.warning(f"⚠️ ⚠️ 未识别的图片格式,文件头: {file_header.hex()}") + logger.warning(f"⚠️ 未识别的图片格式,文件头: {file_header.hex()}") # 创建PIL Image对象 image_io = BytesIO(image_bytes) images = Image.open(image_io) @@ -327,13 +314,44 @@ class PosterService: images = Image.new('RGBA', (1350, 1800), color=(0, 0, 0, 0)) logger.info(f"🔧 创建默认透明背景图,尺寸: {images.size}") - # 4. 调用模板生成海报 + # 4. 调用模板生成海报(同时生成PNG和Fabric.js JSON确保一致性) try: - posters = template_handler.generate( - content=final_content, - images=images, - num_variations=num_variations - ) + # 检查模板是否支持同步生成Fabric.js JSON + try: + # 使用新的generate方法同时生成PNG和JSON + generation_result = template_handler.generate( + content=final_content, + images=images, + num_variations=num_variations, + generate_fabric_json=True # 启用同步生成 + ) + + if isinstance(generation_result, dict) and 'image' in generation_result and 'fabric_json' in generation_result: + # 新版本模板,支持同步生成 + posters = generation_result['image'] + template_fabric_json = generation_result['fabric_json'] + generation_metadata = generation_result.get('generation_metadata', {}) + use_template_fabric_json = True + logger.info("✅ 使用模板同步生成模式,确保PNG和JSON一致") + else: + # 兼容性处理:如果返回的不是预期的字典格式 + posters = generation_result + template_fabric_json = None + generation_metadata = {} + use_template_fabric_json = False + logger.warning("⚠️ 模板未返回预期格式,回退到独立生成模式") + + except TypeError as e: + # 旧版本模板,不支持generate_fabric_json参数 + logger.info("🔄 模板不支持同步生成,使用传统方式") + posters = template_handler.generate( + content=final_content, + images=images, + num_variations=num_variations + ) + template_fabric_json = None + generation_metadata = {} + use_template_fabric_json = False if not posters: raise ValueError("模板未能生成有效的海报") @@ -342,6 +360,7 @@ class PosterService: variations = [] psd_files = [] fabric_jsons = [] + decorative_images = [] # 初始化装饰性图像列表 i=0 ## 用于多个海报时,指定海报的编号,此时只有一个没有用上,但是接口开放着。 output_path = self._save_poster(posters, template_id, i) if output_path: @@ -365,10 +384,27 @@ class PosterService: if psd_result: psd_files.append(psd_result) - # 7. 如果需要,生成Fabric.js JSON - if generate_fabric_json: + # 7. 处理Fabric.js JSON生成 + if use_template_fabric_json and template_fabric_json: + # 使用模板同步生成的JSON(推荐方式) + fabric_jsons.append(template_fabric_json) + logger.info(f"✅ 使用模板同步生成的Fabric.js JSON,确保与PNG完全一致") + gradient_start = generation_metadata.get('gradient_start', 900) + else: + logger.warning(f"⚠️ 模板未返回预期格式,回退到独立生成模式") + # 回退到独立生成模式 fabric_json = self._generate_fabric_json(final_content, template_id, image_size, images) fabric_jsons.append(fabric_json) + logger.info(f"⚠️ 使用独立生成的Fabric.js JSON(可能与PNG存在差异)") + gradient_start = 900 # 使用默认值 + + # 8. 生成装饰性图像并获取base64数据 + try: + decorative_images = self._generate_decorative_images(final_content, image_size[0], image_size[1], gradient_start) + logger.info(f"✅ 为Java端生成装饰性图像: {len(decorative_images)} 个") + except Exception as e: + logger.warning(f"⚠️ 生成装饰性图像失败: {e}") + decorative_images = [] # 记录模板使用情况 self._update_template_stats(template_id, bool(variations), time.time() - start_time) @@ -379,12 +415,14 @@ class PosterService: "resultImagesBase64": variations, "psdFiles": psd_files if psd_files else None, "fabricJsons": fabric_jsons if fabric_jsons else None, + "decorativeImages": decorative_images if decorative_images else None, # 新增:装饰性图像base64数据 "metadata": { "generation_time": f"{time.time() - start_time:.2f}s", "model_used": self.ai_agent.config.model if force_llm_generation or not poster_content else None, "num_variations": len(variations), "psd_generated": bool(psd_files), - "fabric_json_generated": bool(fabric_jsons) + "fabric_json_generated": bool(fabric_jsons), + "decorative_images_count": len(decorative_images) if decorative_images else 0 } } except Exception as e: @@ -395,6 +433,11 @@ class PosterService: def _save_poster(self, poster: Image.Image, template_id: str, variation_id: int=1) -> Optional[Path]: """保存海报到文件系统""" try: + # 类型检查:确保poster是PIL Image对象 + if not isinstance(poster, Image.Image): + logger.error(f"poster参数类型错误,期望PIL.Image.Image,实际收到: {type(poster)}") + return None + # 创建唯一的主题ID用于保存 topic_id = f"poster_{template_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" @@ -718,7 +761,7 @@ class PosterService: def _generate_fabric_json(self, content: Dict[str, Any], template_id: str, image_size: List[int], images: Image.Image = None) -> Dict[str, Any]: """ - 完全复制VibrantTemplate的渲染逻辑生成Fabric.js JSON + 生成标准Fabric.js JSON格式,使用与PNG渲染完全相同的布局计算 Args: content: 海报内容数据 @@ -727,14 +770,86 @@ class PosterService: images: 用户上传的图片 Returns: - Dict: 完全匹配VibrantTemplate的Fabric.js JSON格式数据 + Dict: 标准Fabric.js JSON格式,与PNG渲染布局完全一致 + """ + try: + logger.info(f"🎯 开始生成统一布局的Fabric.js JSON,模板: {template_id}") + + # 获取模板实例 + template_instance = self._get_template_instance(template_id) + if not template_instance: + raise ValueError(f"无法获取模板实例: {template_id}") + + # 对于VibrantTemplate,使用新的统一渲染方法 + if template_id == 'vibrant': + logger.info("📐 使用VibrantTemplate的统一渲染方法") + + # 调用VibrantTemplate的统一渲染方法,输出JSON格式 + render_result = template_instance._unified_render( + images=images, + content=content, + theme_color=None, # 可以根据需要传入 + glass_intensity=1.5, + output_format='json' + ) + + if "error" in render_result: + raise Exception(render_result["error"]) + + fabric_json = render_result["fabric_json"] + layout_params = render_result["layout_params"] + + logger.info(f"✅ 统一布局Fabric.js JSON生成成功") + logger.info(f"📊 布局参数: 标题字体={layout_params['title_size']}, 副标题字体={layout_params['subtitle_size']}, 价格字体={layout_params['price_size']}") + logger.info(f"📐 边距: left={layout_params['left_margin']}, right={layout_params['right_margin']}") + + return fabric_json + + else: + # 其他模板保持原有逻辑 + logger.warning(f"⚠️ 模板 {template_id} 尚未支持统一渲染,使用旧逻辑") + return self._generate_fabric_json_legacy(content, template_id, image_size, images) + + except Exception as e: + logger.error(f"❌ 统一布局Fabric.js JSON生成失败: {e}") + import traceback + traceback.print_exc() + return { + "version": "5.3.0", + "width": image_size[0], + "height": image_size[1], + "objects": [] + } + + def _get_template_instance(self, template_id: str): + """获取模板实例""" + try: + if template_id == 'vibrant': + import sys + import os + # 添加项目根目录到路径 + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + sys.path.append(project_root) + from poster.templates.vibrant_template import VibrantTemplate + return VibrantTemplate() + # 可以添加其他模板 + else: + logger.error(f"不支持的模板ID: {template_id}") + return None + except Exception as e: + logger.error(f"获取模板实例失败: {e}") + return None + + def _generate_fabric_json_legacy(self, content: Dict[str, Any], template_id: str, image_size: List[int], images: Image.Image = None) -> Dict[str, Any]: + """ + 旧版Fabric.js JSON生成逻辑(用于非统一渲染的模板) """ try: fabric_objects = [] - # VibrantTemplate的基础尺寸(900x1200) - base_width, base_height = 900, 1200 - # 最终输出尺寸(1350x1800) + # VibrantTemplate的基础尺寸(已调整为1350x1800) + base_width, base_height = 1350, 1800 + # 最终输出尺寸(与基础尺寸一致) final_width, final_height = image_size[0], image_size[1] # 1. 用户上传的图片(最底层 - Level 0) @@ -759,61 +874,32 @@ class PosterService: gradient_object = self._create_vibrant_glass_effect(final_width, final_height, gradient_start, glass_colors) fabric_objects.append(gradient_object) - # 6. 按VibrantTemplate精确位置渲染文字(Level 2) - # 缩放渐变起始位置到最终尺寸 - scaled_gradient_start = int(gradient_start * final_height / base_height) - text_objects = self._create_vibrant_text_layout_precise(content, final_width, final_height, scaled_gradient_start) + # 6. 创建装饰性元素图像(Level 2) + decorative_objects = self._create_decorative_elements_as_images(content, final_width, final_height, gradient_start) + fabric_objects.extend(decorative_objects) + + # 7. 简化的文本内容层(Level 3) + text_objects = self._create_simplified_text_layout(content, final_width, final_height, gradient_start) fabric_objects.extend(text_objects) - # 构建完整的Fabric.js JSON + # 构建标准Fabric.js JSON(符合规范:只包含必要字段) fabric_json = { "version": "5.3.0", - "objects": fabric_objects, - "background": "transparent", - "backgroundImage": None, - "overlayImage": None, - "clipPath": None, "width": final_width, "height": final_height, - "viewportTransform": [1, 0, 0, 1, 0, 0], - "backgroundVpt": True, - "overlayVpt": True, - "selection": True, - "preserveObjectStacking": True, - "snapAngle": 0, - "snapThreshold": 10, - "centeredScaling": False, - "centeredRotation": True, - "interactive": True, - "skipTargetFind": False, - "enableRetinaScaling": True, - "imageSmoothingEnabled": True, - "perPixelTargetFind": False, - "targetFindTolerance": 0, - "skipOffscreen": True, - "includeDefaultValues": True, - "metadata": { - "template": "VibrantTemplate", - "base_size": [base_width, base_height], - "final_size": [final_width, final_height], - "gradient_start": gradient_start, - "scaled_gradient_start": scaled_gradient_start, - "estimated_content_height": estimated_height, - "glass_colors": glass_colors - } + "objects": fabric_objects } - logger.info(f"成功生成VibrantTemplate精确Fabric.js JSON,包含 {len(fabric_objects)} 个对象") + logger.info(f"✅ 旧版Fabric.js JSON生成成功,包含 {len(fabric_objects)} 个标准对象") return fabric_json except Exception as e: - logger.error(f"生成Fabric.js JSON失败: {e}") + logger.error(f"❌ 旧版Fabric.js JSON生成失败: {e}") return { "version": "5.3.0", - "objects": [], - "background": "transparent", "width": image_size[0], - "height": image_size[1] + "height": image_size[1], + "objects": [] } def _create_image_object(self, images: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]: @@ -1172,7 +1258,7 @@ class PosterService: "style": "vibrant_title", "target_width": title_target_width, "actual_width": title_actual_width, - "font_path": "/assets/font/兰亭粗黑简.TTF" + "font_path": "assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True @@ -1245,7 +1331,7 @@ class PosterService: "style": "vibrant_subtitle", "target_width": subtitle_target_width, "actual_width": subtitle_actual_width, - "font_path": "/assets/font/兰亭粗黑简.TTF" + "font_path": "assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True @@ -1508,7 +1594,7 @@ class PosterService: return objects - def _calculate_vibrant_content_margins(self, content: Dict[str, Any], width: int, center_x: int) -> Tuple[int, int]: + def _calculate_vibrant_content_margins(self, content: Dict[str, Any], width: int, center_x: int) -> tuple[int, int]: """复用VibrantTemplate的边距计算逻辑""" # 计算标题位置 title_text = content.get("title", "") @@ -1545,7 +1631,7 @@ class PosterService: return content_left_margin, content_right_margin - def _calculate_vibrant_font_size_precise(self, text: str, target_width: int, min_size: int = 10, max_size: int = 120) -> Tuple[int, int]: + def _calculate_vibrant_font_size_precise(self, text: str, target_width: int, min_size: int = 10, max_size: int = 120) -> tuple[int, int]: """复用VibrantTemplate的精确字体大小计算算法""" if not text: return min_size, 0 @@ -1648,7 +1734,7 @@ class PosterService: "level": 2, "target_width": price_target_width, "actual_width": price_actual_width, - "font_path": "/assets/font/兰亭粗黑简.TTF" + "font_path": "assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True @@ -1780,8 +1866,8 @@ class PosterService: logger.info(f"无图像,使用估算渐变起始位置: {gradient_start}") return gradient_start - # 临时缩放图像到基础尺寸进行分析 - temp_image = image.resize((900, 1200), Image.LANCZOS) + # 基础尺寸已经是1350x1800,不需要缩放 + temp_image = image if temp_image.mode != 'RGB': temp_image = temp_image.convert('RGB') @@ -1812,7 +1898,7 @@ class PosterService: return gradient_start - def _extract_vibrant_glass_colors(self, image: Image.Image, gradient_start: int) -> Dict[str, Tuple[int, int, int]]: + def _extract_vibrant_glass_colors(self, image: Image.Image, gradient_start: int) -> Dict[str, tuple[int, int, int]]: """复制VibrantTemplate的毛玻璃颜色提取逻辑""" if not image or not hasattr(image, 'width'): # 默认蓝色毛玻璃效果 @@ -1823,8 +1909,8 @@ class PosterService: logger.info(f"无图像,使用默认毛玻璃颜色: {default_colors}") return default_colors - # 临时缩放图像到基础尺寸进行颜色提取 - temp_image = image.resize((900, 1200), Image.LANCZOS) + # 基础尺寸已经是1350x1800,不需要缩放 + temp_image = image if temp_image.mode != 'RGB': temp_image = temp_image.convert('RGB') @@ -1874,8 +1960,8 @@ class PosterService: def _create_vibrant_glass_effect(self, canvas_width: int, canvas_height: int, gradient_start: int, glass_colors: Dict) -> Dict[str, Any]: """创建VibrantTemplate精确的毛玻璃效果""" - # 缩放渐变起始位置到最终尺寸 - scaled_gradient_start = int(gradient_start * canvas_height / 1200) + # 基础尺寸已经是1350x1800,不需要缩放 + scaled_gradient_start = gradient_start top_color = glass_colors["top_color"] bottom_color = glass_colors["bottom_color"] @@ -1949,7 +2035,7 @@ class PosterService: image_base64 = self._image_to_base64(images) # VibrantTemplate直接resize到画布大小 - return { + return { "type": "image", "version": "5.3.0", "originX": "left", @@ -1979,31 +2065,35 @@ class PosterService: } def _create_vibrant_text_layout_precise(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int) -> List[Dict[str, Any]]: - """复制VibrantTemplate的精确文本布局逻辑""" + """完全复制VibrantTemplate的精确文本布局逻辑""" text_objects = [] - # 计算基础参数(缩放到最终尺寸) + # VibrantTemplate的基础尺寸和缩放(已调整为1350x1800) + base_width, base_height = 1350, 1800 + final_width, final_height = canvas_width, canvas_height center_x = canvas_width // 2 - scale_factor = canvas_height / 1800 # 从1800缩放到最终高度 - # 简化版本:使用固定边距 - margin_ratio = 0.1 - left_margin = int(canvas_width * margin_ratio) - right_margin = int(canvas_width * (1 - margin_ratio)) + # 复制VibrantTemplate的边距计算逻辑 + left_margin, right_margin = self._calculate_vibrant_content_margins_precise(content, canvas_width, center_x, final_width, final_height) - # 标题(VibrantTemplate: gradient_start + 40) - title_y = gradient_start + int(40 * scale_factor) + # 1. 标题 (VibrantTemplate: gradient_start + 40) + title_y = gradient_start + int(40 * final_height / base_height) if title := content.get("title"): - title_size = int(80 * scale_factor) + title_target_width = int((right_margin - left_margin) * 0.98) + title_size, title_actual_width = self._calculate_vibrant_font_size_precise( + title, title_target_width, max_size=int(140 * final_height / base_height), min_size=int(40 * final_height / base_height) + ) + + title_x = center_x - title_actual_width // 2 title_obj = { "type": "textbox", "version": "5.3.0", - "originX": "center", + "originX": "left", "originY": "top", - "left": center_x, + "left": title_x, "top": title_y, - "width": right_margin - left_margin, - "height": title_size + 20, + "width": title_actual_width, + "height": int(title_size * 1.2), "fill": "#ffffff", "stroke": "#001e50", "strokeWidth": 4, @@ -2013,26 +2103,42 @@ class PosterService: "text": title, "textAlign": "center", "lineHeight": 1.1, + "shadow": "rgba(0, 30, 80, 0.8) 2px 2px 6px", "name": "vibrant_title_precise", - "data": {"type": "title", "layer": "content", "level": 2}, + "data": { + "type": "title", + "layer": "content", + "level": 2, + "target_width": title_target_width, + "actual_width": title_actual_width, + "font_path": "assets/font/兰亭粗黑简.TTF" + }, "selectable": True, "evented": True } text_objects.append(title_obj) - # 副标题 - subtitle_y = title_y + int(130 * scale_factor) - if slogan := content.get("slogan"): - subtitle_size = int(40 * scale_factor) + # 2. 副标题 (slogan) - VibrantTemplate: title_y + text_h + 30 + if title and (slogan := content.get("slogan")): + subtitle_target_width = int((right_margin - left_margin) * 0.95) + subtitle_size, subtitle_actual_width = self._calculate_vibrant_font_size_precise( + slogan, subtitle_target_width, max_size=int(75 * final_height / base_height), min_size=int(20 * final_height / base_height) + ) + + # 计算实际的title高度 + title_height = int(title_size * 1.2) if title else 0 + subtitle_y = title_y + title_height + int(30 * final_height / base_height) + subtitle_x = center_x - subtitle_actual_width // 2 + subtitle_obj = { "type": "textbox", "version": "5.3.0", - "originX": "center", + "originX": "left", "originY": "top", - "left": center_x, + "left": subtitle_x, "top": subtitle_y, - "width": right_margin - left_margin, - "height": subtitle_size + 15, + "width": subtitle_actual_width, + "height": int(subtitle_size * 1.2), "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", @@ -2042,15 +2148,420 @@ class PosterService: "textAlign": "center", "lineHeight": 1.2, "name": "vibrant_slogan_precise", - "data": {"type": "slogan", "layer": "content", "level": 2}, + "data": { + "type": "slogan", + "layer": "content", + "level": 2, + "target_width": subtitle_target_width, + "actual_width": subtitle_actual_width, + "font_path": "assets/font/兰亭粗黑简.TTF" + }, "selectable": True, "evented": True } text_objects.append(subtitle_obj) + # 3. 左栏:按钮和内容列表 + content_start_y = (subtitle_y if 'subtitle_y' in locals() else title_y + int(100 * final_height / base_height)) + int(30 * final_height / base_height) + left_column_objects = self._create_vibrant_left_column_precise(content, content_start_y, left_margin, right_margin, final_width, final_height) + text_objects.extend(left_column_objects) + + # 4. 右栏:价格和票种 + right_column_objects = self._create_vibrant_right_column_precise(content, content_start_y, left_margin, right_margin, final_width, final_height) + text_objects.extend(right_column_objects) + + # 5. 页脚 + footer_y = canvas_height - int(30 * final_height / base_height) + footer_objects = self._create_vibrant_footer_precise(content, footer_y, left_margin, right_margin, final_width, final_height) + text_objects.extend(footer_objects) + logger.info(f"创建VibrantTemplate精确文本布局: {len(text_objects)}个对象") return text_objects + def _generate_decorative_images(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int) -> Dict[str, Any]: + """ + 生成装饰性图像(按钮、装饰元素等)并返回base64编码 + 这些图像将由Java端上传到S3获得真实URL + + Args: + content: 海报内容数据 + canvas_width: 画布宽度 + canvas_height: 画布高度 + gradient_start: 渐变起始位置 + + Returns: + Dict: 包含各种装饰图像的base64数据和位置信息 + """ + decorative_images = {} + + try: + # 1. 生成按钮图像 + if content.get("buttons") or content.get("button_text"): + button_image = self._create_button_image(content) + if button_image: + button_base64 = self._image_to_base64(button_image) + decorative_images["button"] = { + "base64": button_base64, + "width": button_image.width, + "height": button_image.height, + "left": int(canvas_width * 0.05), + "top": gradient_start + 100, + "type": "button" + } + + # 2. 生成装饰性图标 + if content.get("tag") or content.get("category"): + tag_icon = self._create_tag_icon(content) + if tag_icon: + tag_base64 = self._image_to_base64(tag_icon) + decorative_images["tag_icon"] = { + "base64": tag_base64, + "width": tag_icon.width, + "height": tag_icon.height, + "left": int(canvas_width * 0.05), + "top": canvas_height - 100, + "type": "tag_icon" + } + + # 3. 生成价格标签背景 + if content.get("price"): + price_bg = self._create_price_background(content) + if price_bg: + price_bg_base64 = self._image_to_base64(price_bg) + decorative_images["price_background"] = { + "base64": price_bg_base64, + "width": price_bg.width, + "height": price_bg.height, + "left": int(canvas_width * 0.55), + "top": gradient_start + 180, + "type": "price_background" + } + + logger.info(f"✅ 生成装饰性图像: {len(decorative_images)} 个") + return decorative_images + + except Exception as e: + logger.error(f"❌ 生成装饰性图像失败: {e}") + return {} + + def _create_button_image(self, content: Dict[str, Any]) -> Image.Image: + """生成按钮图像""" + try: + # 按钮尺寸 + width, height = 200, 60 + + # 创建RGBA图像(支持透明度) + image = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + # 绘制圆角矩形背景 + corner_radius = 20 + self._draw_rounded_rectangle( + draw, [(0, 0), (width, height)], + fill=(255, 255, 255, 180), # 半透明白色 + outline=(255, 255, 255, 200), + width=2, + radius=corner_radius + ) + + # 添加按钮文字 + button_text = content.get("button_text", "了解更多") + try: + font = ImageFont.truetype("assets/font/兰亭粗黑简.TTF", 18) + except: + font = ImageFont.load_default() + + # 计算文字居中位置 + bbox = draw.textbbox((0, 0), button_text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + text_x = (width - text_width) // 2 + text_y = (height - text_height) // 2 + + # 绘制文字 + draw.text((text_x, text_y), button_text, fill=(50, 50, 50, 255), font=font) + + logger.info(f"✅ 生成按钮图像: {width}x{height}, 文字: '{button_text}'") + return image + + except Exception as e: + logger.error(f"❌ 生成按钮图像失败: {e}") + return None + + def _create_tag_icon(self, content: Dict[str, Any]) -> Image.Image: + """生成标签图标""" + try: + # 标签尺寸 + width, height = 100, 30 + + # 创建RGBA图像 + image = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + # 绘制标签背景 + self._draw_rounded_rectangle( + draw, [(0, 0), (width, height)], + fill=(255, 215, 0, 200), # 金色背景 + radius=8 + ) + + # 添加标签文字 + tag_text = content.get("tag", "推荐")[:4] # 最多4个字符 + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14) + except: + font = ImageFont.load_default() + + # 计算文字居中位置 + bbox = draw.textbbox((0, 0), tag_text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + text_x = (width - text_width) // 2 + text_y = (height - text_height) // 2 + + # 绘制文字 + draw.text((text_x, text_y), tag_text, fill=(50, 50, 50, 255), font=font) + + logger.info(f"✅ 生成标签图标: {width}x{height}, 文字: '{tag_text}'") + return image + + except Exception as e: + logger.error(f"❌ 生成标签图标失败: {e}") + return None + + def _create_price_background(self, content: Dict[str, Any]) -> Image.Image: + """生成价格背景装饰""" + try: + # 价格背景尺寸 + width, height = 180, 80 + + # 创建RGBA图像 + image = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + # 绘制渐变背景效果(模拟) + for y in range(height): + alpha = int(150 * (1 - y / height)) # 渐变透明度 + draw.line([(0, y), (width, y)], fill=(255, 215, 0, alpha)) + + # 绘制边框 + draw.rectangle([(0, 0), (width-1, height-1)], outline=(255, 215, 0, 200), width=2) + + logger.info(f"✅ 生成价格背景: {width}x{height}") + return image + + except Exception as e: + logger.error(f"❌ 生成价格背景失败: {e}") + return None + + def _draw_rounded_rectangle(self, draw, coords, fill=None, outline=None, width=1, radius=10): + """绘制圆角矩形""" + x1, y1 = coords[0] + x2, y2 = coords[1] + + # 绘制圆角矩形的各个部分 + # 中间的矩形 + draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill) + draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill) + + # 四个圆角 + draw.pieslice([x1, y1, x1 + 2*radius, y1 + 2*radius], 180, 270, fill=fill) + draw.pieslice([x2 - 2*radius, y1, x2, y1 + 2*radius], 270, 360, fill=fill) + draw.pieslice([x1, y2 - 2*radius, x1 + 2*radius, y2], 90, 180, fill=fill) + draw.pieslice([x2 - 2*radius, y2 - 2*radius, x2, y2], 0, 90, fill=fill) + + # 绘制边框 + if outline: + draw.arc([x1, y1, x1 + 2*radius, y1 + 2*radius], 180, 270, fill=outline, width=width) + draw.arc([x2 - 2*radius, y1, x2, y1 + 2*radius], 270, 360, fill=outline, width=width) + draw.arc([x1, y2 - 2*radius, x1 + 2*radius, y2], 90, 180, fill=outline, width=width) + draw.arc([x2 - 2*radius, y2 - 2*radius, x2, y2], 0, 90, fill=outline, width=width) + draw.line([x1 + radius, y1, x2 - radius, y1], fill=outline, width=width) + draw.line([x1 + radius, y2, x2 - radius, y2], fill=outline, width=width) + draw.line([x1, y1 + radius, x1, y2 - radius], fill=outline, width=width) + draw.line([x2, y1 + radius, x2, y2 - radius], fill=outline, width=width) + + def _create_decorative_elements_as_images(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int) -> List[Dict[str, Any]]: + """ + 将复杂的装饰性元素(如按钮、装饰图案)渲染为图像对象 + 这样可以确保视觉效果准确,同时简化fabric.js JSON结构 + + Args: + content: 海报内容数据 + canvas_width: 画布宽度 + canvas_height: 画布高度 + gradient_start: 渐变起始位置 + + Returns: + List[Dict]: 装饰性图像对象列表(标准fabric.js image类型) + """ + decorative_objects = [] + base_width, base_height = 1350, 1800 + + try: + # 生成装饰性图像并获取base64数据 + decorative_images = self._generate_decorative_images(content, canvas_width, canvas_height, gradient_start) + + # 为每个装饰图像创建fabric.js对象(使用占位符URL) + for key, image_data in decorative_images.items(): + decorative_obj = { + "type": "image", + "left": image_data["left"], + "top": image_data["top"], + "width": image_data["width"], + "height": image_data["height"], + "src": "", # 空URL,Java端上传七牛云后会设置真实URL + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1, + "_decorative_id": key # 内部标识,用于后续URL替换 + } + decorative_objects.append(decorative_obj) + + logger.info(f"✅ 创建装饰性元素: {len(decorative_objects)} 个标准image对象") + return decorative_objects + + except Exception as e: + logger.error(f"❌ 创建装饰性元素失败: {e}") + return [] + + def _create_simplified_text_layout(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int) -> List[Dict[str, Any]]: + """ + 创建简化的文本布局,只使用标准textbox和text对象 + 移除复杂的装饰性形状,专注于文本内容 + + Args: + content: 海报内容数据 + canvas_width: 画布宽度 + canvas_height: 画布高度 + gradient_start: 渐变起始位置 + + Returns: + List[Dict]: 标准文本对象列表 + """ + text_objects = [] + base_width, base_height = 1350, 1800 + + try: + # 1. 主标题 + if title := content.get("title"): + title_obj = { + "type": "textbox", + "left": int(canvas_width * 0.05), + "top": gradient_start + 40, + "width": int(canvas_width * 0.9), + "height": 80, + "text": str(title), + "fontSize": 42, + "fontFamily": "Arial", + "fontWeight": "bold", + "fill": "white", + "textAlign": "center", + "lineHeight": 1.2, + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 + } + text_objects.append(title_obj) + + # 2. 副标题 + if subtitle := content.get("subtitle"): + subtitle_obj = { + "type": "textbox", + "left": int(canvas_width * 0.05), + "top": gradient_start + 130, + "width": int(canvas_width * 0.9), + "height": 50, + "text": str(subtitle), + "fontSize": 24, + "fontFamily": "Arial", + "fill": "rgba(255, 255, 255, 0.9)", + "textAlign": "center", + "lineHeight": 1.3, + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 + } + text_objects.append(subtitle_obj) + + # 3. 主要内容文本(左栏) + current_y = gradient_start + 200 + if items := content.get("items", []): + for i, item in enumerate(items[:3]): # 最多显示3个项目 + item_text = str(item) + item_obj = { + "type": "textbox", + "left": int(canvas_width * 0.05), + "top": current_y, + "width": int(canvas_width * 0.45), + "height": 40, + "text": item_text, + "fontSize": 18, + "fontFamily": "Arial", + "fill": "white", + "textAlign": "left", + "lineHeight": 1.4, + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 + } + text_objects.append(item_obj) + current_y += 50 + + # 4. 价格信息(右栏) + if price := content.get("price"): + price_obj = { + "type": "textbox", + "left": int(canvas_width * 0.55), + "top": gradient_start + 200, + "width": int(canvas_width * 0.4), + "height": 60, + "text": f"¥{price}", + "fontSize": 36, + "fontFamily": "Arial", + "fontWeight": "bold", + "fill": "#FFD700", + "textAlign": "center", + "lineHeight": 1.2, + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 + } + text_objects.append(price_obj) + + # 5. 页脚标签 + if tag := content.get("tag"): + tag_obj = { + "type": "textbox", + "left": int(canvas_width * 0.05), + "top": canvas_height - 80, + "width": int(canvas_width * 0.4), + "height": 30, + "text": str(tag), + "fontSize": 16, + "fontFamily": "Arial", + "fill": "rgba(255, 255, 255, 0.8)", + "textAlign": "left", + "lineHeight": 1.2, + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 + } + text_objects.append(tag_obj) + + logger.info(f"✅ 创建简化文本布局: {len(text_objects)} 个文本对象") + return text_objects + + except Exception as e: + logger.error(f"❌ 创建简化文本布局失败: {e}") + return [] + def _resize_and_crop_image(self, image: Image.Image, target_width: int, target_height: int) -> Image.Image: """ 智能调整图片尺寸到目标比例,保持最佳清晰度 @@ -2126,4 +2637,396 @@ class PosterService: cropped_image = cropped_image.resize((target_width, target_height), Image.LANCZOS) logger.info(f"✅ 图片尺寸处理完成: {original_width}x{original_height} -> {cropped_image.size}") - return cropped_image \ No newline at end of file + return cropped_image + + def _calculate_vibrant_content_margins_precise(self, content: Dict[str, Any], canvas_width: int, center_x: int, final_width: int, final_height: int) -> tuple[int, int]: + """完全复制VibrantTemplate的边距计算逻辑""" + base_width, base_height = 1350, 1800 + + # 计算标题位置(复制VibrantTemplate逻辑) + title_text = content.get("title", "") + title_target_width = int(canvas_width * 0.95) + title_size, title_width = self._calculate_vibrant_font_size_precise( + title_text, title_target_width, min_size=int(40 * final_height / base_height), max_size=int(130 * final_height / base_height) + ) + title_x = center_x - title_width // 2 + + # 计算副标题位置 + slogan_text = content.get("slogan", "") + subtitle_target_width = int(canvas_width * 0.9) + subtitle_size, subtitle_width = self._calculate_vibrant_font_size_precise( + slogan_text, subtitle_target_width, max_size=int(50 * final_height / base_height), min_size=int(20 * final_height / base_height) + ) + subtitle_x = center_x - subtitle_width // 2 + + # 计算内容区域边距(复制VibrantTemplate逻辑) + padding = int(20 * final_width / base_width) # 缩放padding + content_left_margin = min(title_x, subtitle_x) - padding + content_right_margin = max(title_x + title_width, subtitle_x + subtitle_width) + padding + + # 确保边距不超出合理范围 + min_margin = int(40 * final_width / base_width) + content_left_margin = max(min_margin, content_left_margin) + content_right_margin = min(canvas_width - min_margin, content_right_margin) + + # 如果内容区域太窄,强制使用更宽的区域 + min_content_width = int(canvas_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(int(30 * final_width / base_width), content_left_margin - extra_width // 2) + content_right_margin = min(canvas_width - int(30 * final_width / base_width), content_right_margin + extra_width // 2) + + return content_left_margin, content_right_margin + + def _calculate_vibrant_font_size_precise(self, text: str, target_width: int, min_size: int = 10, max_size: int = 120) -> tuple[int, int]: + """完全复制VibrantTemplate的字体大小计算算法""" + if not text: + return min_size, 0 + + # 简化版字体宽度估算(基于字符数和字体大小的经验公式) + # 这个估算基于常见中文字体的平均字符宽度 + def estimate_text_width(text: str, font_size: int) -> int: + chinese_chars = sum(1 for c in text if ord(c) > 127) # 中文字符 + english_chars = len(text) - chinese_chars # 英文字符 + # 中文字符宽度约等于字体大小,英文字符约为字体大小的0.6 + return int(chinese_chars * font_size * 0.95 + english_chars * font_size * 0.6) + + # 二分查找最佳字体大小 + low = min_size + high = max_size + best_size = min_size + best_width = 0 + tolerance = 0.08 # 容差值 + + # 首先尝试最大字体大小 + max_width = estimate_text_width(text, max_size) + + # 如果最大字体大小下的宽度仍小于目标宽度的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 + width = estimate_text_width(text, mid) + + # 计算与目标宽度的差距 + 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 + best_width = estimate_text_width(text, closest_size) + + logger.info(f"文本'{text[:min(10, len(text))]}...'的最佳字体大小: {best_size},目标宽度: {target_width},实际宽度: {best_width},差距: {abs(best_width-target_width)}") + + return best_size, best_width + + def _create_vibrant_left_column_precise(self, content: Dict[str, Any], start_y: int, left_margin: int, right_margin: int, final_width: int, final_height: int) -> List[Dict[str, Any]]: + """创建VibrantTemplate左栏:按钮和内容列表""" + objects = [] + base_width, base_height = 1350, 1800 + + content_area_width = right_margin - left_margin + left_column_width = int(content_area_width * 0.5) + + # 1. 按钮 + button_text = content.get("content_button", "套餐内容") + button_font_size = int(30 * final_height / base_height) + button_width = int(len(button_text) * button_font_size * 0.7 + 40 * final_width / base_width) + button_height = int(50 * final_height / base_height) + + button_obj = { + "type": "rect", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": left_margin, + "top": start_y, + "width": button_width, + "height": button_height, + "fill": "rgba(0, 140, 210, 0.7)", + "stroke": "#ffffff", + "strokeWidth": 1, + "rx": 20, + "ry": 20, + "name": "content_button_bg", + "data": {"type": "button_bg", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(button_obj) + + button_text_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": left_margin + int(20 * final_width / base_width), + "top": start_y + int((button_height - button_font_size) // 2), + "width": button_width - int(40 * final_width / base_width), + "height": button_font_size, + "fill": "#ffffff", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": button_font_size, + "text": button_text, + "textAlign": "center", + "name": "content_button_text", + "data": {"type": "button_text", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(button_text_obj) + + # 2. 内容列表 + items = content.get("content_items", []) + if items: + list_font_size = int(28 * final_height / base_height) + list_y = start_y + button_height + int(20 * final_height / base_height) + line_spacing = int(36 * final_height / base_height) + + for i, item in enumerate(items): + item_y = list_y + i * line_spacing + item_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": left_margin, + "top": item_y, + "width": left_column_width, + "height": list_font_size, + "fill": "#ffffff", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": list_font_size, + "text": " " + item, + "textAlign": "left", + "name": f"content_item_{i}", + "data": {"type": "content_item", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(item_obj) + + return objects + + def _create_vibrant_right_column_precise(self, content: Dict[str, Any], start_y: int, left_margin: int, right_margin: int, final_width: int, final_height: int) -> List[Dict[str, Any]]: + """创建VibrantTemplate右栏:价格和票种""" + objects = [] + base_width, base_height = 1350, 1800 + + content_area_width = right_margin - left_margin + left_column_width = int(content_area_width * 0.5) + right_column_x = left_margin + left_column_width + + # 1. 价格 + price_text = str(content.get('price', '')) + if price_text: + price_target_width = int((right_margin - right_column_x) * 0.7) + price_size, price_actual_width = self._calculate_vibrant_font_size_precise( + price_text, price_target_width, max_size=int(120 * final_height / base_height), min_size=int(40 * final_height / base_height) + ) + + # 后缀大小和位置 + suffix_size = int(price_size * 0.3) + suffix_text = "CNY起" + suffix_width = int(len(suffix_text) * suffix_size * 0.6) + + price_x = right_margin - price_actual_width - suffix_width + price_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": price_x, + "top": start_y, + "width": price_actual_width, + "height": int(price_size * 1.2), + "fill": "#ffffff", + "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial Black, sans-serif", + "fontWeight": "bold", + "fontSize": price_size, + "text": price_text, + "textAlign": "left", + "name": "price_text", + "data": { + "type": "price", + "layer": "content", + "level": 2, + "target_width": price_target_width, + "actual_width": price_actual_width, + "font_path": "assets/font/兰亭粗黑简.TTF" + }, + "selectable": True, + "evented": True + } + objects.append(price_obj) + + # 价格后缀 + suffix_y = start_y + int(price_size * 1.2) - suffix_size + suffix_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": price_x + price_actual_width, + "top": suffix_y, + "width": suffix_width, + "height": suffix_size, + "fill": "#ffffff", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": suffix_size, + "text": suffix_text, + "textAlign": "left", + "name": "price_suffix", + "data": {"type": "price_suffix", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(suffix_obj) + + # 价格下划线 + underline_y = start_y + int(price_size * 1.2) + int(18 * final_height / base_height) + underline_obj = { + "type": "line", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": price_x - int(10 * final_width / base_width), + "top": underline_y, + "x1": 0, + "y1": 0, + "x2": right_margin - (price_x - int(10 * final_width / base_width)), + "y2": 0, + "stroke": "rgba(255, 255, 255, 0.3)", + "strokeWidth": 2, + "name": "price_underline", + "data": {"type": "price_underline", "layer": "content", "level": 2}, + "selectable": False, + "evented": False + } + objects.append(underline_obj) + + # 2. 票种 + ticket_text = content.get("ticket_type", "") + if ticket_text: + ticket_target_width = int((right_margin - right_column_x) * 0.7) + ticket_size, ticket_actual_width = self._calculate_vibrant_font_size_precise( + ticket_text, ticket_target_width, max_size=int(60 * final_height / base_height), min_size=int(30 * final_height / base_height) + ) + + ticket_x = right_margin - ticket_actual_width + ticket_y = start_y + int(price_size * 1.2) + int(35 * final_height / base_height) if price_text else start_y + + ticket_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": ticket_x, + "top": ticket_y, + "width": ticket_actual_width, + "height": int(ticket_size * 1.2), + "fill": "#ffffff", + "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": ticket_size, + "text": ticket_text, + "textAlign": "left", + "name": "ticket_type", + "data": {"type": "ticket_type", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(ticket_obj) + + return objects + + def _create_vibrant_footer_precise(self, content: Dict[str, Any], footer_y: int, left_margin: int, right_margin: int, final_width: int, final_height: int) -> List[Dict[str, Any]]: + """创建VibrantTemplate页脚""" + objects = [] + base_width, base_height = 1350, 1800 + + footer_font_size = int(18 * final_height / base_height) + + # 左侧标签 + if tag := content.get("tag"): + tag_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": left_margin, + "top": footer_y, + "width": int((right_margin - left_margin) * 0.5), + "height": footer_font_size, + "fill": "#ffffff", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": footer_font_size, + "text": tag, + "textAlign": "left", + "name": "footer_tag", + "data": {"type": "footer_tag", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(tag_obj) + + # 右侧分页 + if pagination := content.get("pagination"): + pagination_width = int(len(pagination) * footer_font_size * 0.6) + pagination_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": right_margin - pagination_width, + "top": footer_y, + "width": pagination_width, + "height": footer_font_size, + "fill": "#ffffff", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": footer_font_size, + "text": pagination, + "textAlign": "right", + "name": "footer_pagination", + "data": {"type": "footer_pagination", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + objects.append(pagination_obj) + + return objects \ No newline at end of file diff --git a/config/poster_gen.json b/config/poster_gen.json index 22c3559..f02c981 100644 --- a/config/poster_gen.json +++ b/config/poster_gen.json @@ -13,7 +13,7 @@ "topic_index": 0, "variant_index": 0, "template": "vibrant", - "size": [900, 1200], + "size": [1350, 1800], "generate_text": true, "text_generation_params": { "user_prompt_path": "resource/prompt/poster/vibrant_user.txt", diff --git a/docs/fabric_json_standard.md b/docs/fabric_json_standard.md new file mode 100644 index 0000000..3043494 --- /dev/null +++ b/docs/fabric_json_standard.md @@ -0,0 +1,307 @@ +# 后端Fabric.js JSON格式规范 + +## 标准格式要求 + +### 1. 根对象结构 +```json +{ + "version": "5.3.0", + "width": 1350, + "height": 1800, + "objects": [ + // 对象数组 + ] +} +``` + +### 2. 对象类型要求(只使用标准Fabric.js类型) + +#### ✅ 允许的标准类型: +- `rect` - 矩形 +- `circle` - 圆形 +- `textbox` - 文本框 +- `text` - 文本 +- `image` - 图片 +- `line` - 线条 +- `polygon` - 多边形 +- `path` - 路径 +- `group` - 组 + +#### ❌ 禁止的自定义类型: +- `CustomRect` -> 应改为 `rect` +- `CustomTextbox` -> 应改为 `textbox` +- `CustomCircle` -> 应改为 `circle` +- `CustomGroup` -> 应改为 `group` +- `ThinTailArrow` -> 应改为 `path` 或 `rect` +- `Arrow` -> 应改为 `path` 或 `rect` + +### 3. 基本对象属性要求 + +#### 所有对象必须包含: +```json +{ + "type": "rect", // 标准类型 + "left": 100, // 数值,非null + "top": 50, // 数值,非null + "width": 200, // 数值 > 0 + "height": 100, // 数值 > 0 + "fill": "#ffffff", + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 +} +``` + +#### 文本对象额外属性: +```json +{ + "type": "textbox", + "text": "实际文本内容", // 非空字符串 + "fontSize": 16, // 数值 > 0 + "fontFamily": "Arial", // 有效字体名 + "fill": "#000000" +} +``` + +#### 图片对象要求: +```json +{ + "type": "image", + "src": "https://example.com/image.jpg", // 必须是完整有效的URL + "width": 300, // 实际图片宽度 + "height": 200 // 实际图片高度 +} +``` + +**重要:图片src必须是可访问的完整URL,不能是:** +- 相对路径如 `preview1.jpg` +- 本地路径如 `./images/test.png` +- 占位符如 `image1.jpg` + +### 4. 组对象格式: +```json +{ + "type": "group", + "objects": [ + // 组内的标准对象 + ] +} +``` + +### 5. 完整示例: +```json +{ + "version": "5.3.0", + "width": 1350, + "height": 1800, + "objects": [ + { + "type": "rect", + "left": 100, + "top": 100, + "width": 200, + "height": 150, + "fill": "#ff0000", + "opacity": 1, + "angle": 0, + "scaleX": 1, + "scaleY": 1 + }, + { + "type": "textbox", + "left": 50, + "top": 300, + "width": 300, + "height": 50, + "text": "这是文本内容", + "fontSize": 24, + "fontFamily": "Arial", + "fill": "#000000", + "opacity": 1 + }, + { + "type": "image", + "left": 400, + "top": 100, + "width": 200, + "height": 200, + "src": "https://example.com/path/to/image.jpg", + "opacity": 1 + } + ] +} +``` + +## 复杂装饰元素处理策略 + +### 问题:复杂的装饰性元素(如按钮、装饰图案) +- **旧方案**:使用多个形状组合(rect + circle + path等) +- **新方案**:将复杂元素预渲染为图像 + +### 实现方式: +1. **后端预渲染**:将复杂的按钮样式、装饰图案等渲染为PNG图像 +2. **托管图像**:将这些装饰图像托管在可访问的URL上 +3. **JSON引用**:在fabric.js JSON中使用标准`image`对象引用 + +### 示例:按钮元素 +```json +// ❌ 旧方式:复杂形状组合 +{ + "type": "group", + "objects": [ + {"type": "rect", "fill": "linear-gradient(...)", ...}, + {"type": "circle", "fill": "rgba(...)", ...}, + {"type": "path", "path": "M10,10 L20,20...", ...} + ] +} + +// ✅ 新方式:预渲染图像 +{ + "type": "image", + "left": 100, + "top": 200, + "width": 120, + "height": 40, + "src": "https://your-domain.com/api/decorative/button_style_1.png", + "opacity": 1 +} +``` + +## 关键要求总结: + +1. **只使用Fabric.js标准对象类型** +2. **所有数值属性必须有效(非null、非0宽高)** +3. **图片src必须是完整可访问的URL** +4. **包含完整的width/height信息** +5. **遵循标准JSON结构** +6. **复杂装饰元素使用预渲染图像** + +这样前端就可以直接使用 `canvas.loadFromJSON()` 而无需任何预处理! + +## 装饰性图像处理流程 + +### 完整的端到端流程: + +1. **Python端生成装饰图像** + - 使用PIL动态生成按钮、标签、价格背景等装饰元素 + - 返回base64编码的PNG图像数据 + - 在fabric.js JSON中使用占位符URL(https://placeholder.qiniu.com/decorative/) + +2. **Java端处理装饰图像** + - 接收装饰图像的base64数据 + - 上传每个装饰图像到七牛云存储 + - 获取七牛云的真实URL + - 替换fabric.js JSON中的占位符URL为真实URL + +3. **前端使用** + - 接收更新后的fabric.js JSON(包含真实的图像URL) + - 直接使用`canvas.loadFromJSON()`加载完整设计 + +### API响应结构: +```json +{ + "requestId": "poster-20250101-120000-abc123", + "templateId": "vibrant", + "resultImagesBase64": [...], + "fabricJsons": [ + { + "id": "fabric_json_0", + "data": "base64_encoded_json...", + "jsonData": { + "version": "5.3.0", + "width": 1350, + "height": 1800, + "objects": [ + { + "type": "image", + "src": "https://qiniu-domain.com/poster/decorative/2025/01/01/button_1234567890.png", + "left": 100, + "top": 200, + "width": 200, + "height": 60 + } + ] + } + } + ], + "decorativeImages": { + "button": { + "originalId": "button", + "type": "button", + "qiniuUrl": "https://qiniu-domain.com/poster/decorative/2025/01/01/button_1234567890.png", + "uploadSuccess": true + } + } +} +``` + +## 实现状态 + +### ✅ 已完成: +- 标准fabric.js JSON格式输出 +- 简化的文本布局(只使用textbox/text) +- 装饰性图像生成器(按钮、标签、价格背景) +- 装饰图像上传到七牛云的完整流程 +- fabric.js JSON中占位符URL自动替换 +- 统一1350x1800基础尺寸 +- 端到端的装饰图像处理工作流 + +## 统一生成架构(重大改进) + +### 问题解决: +**原问题**:fabric.js JSON和PNG分开生成,导致参数不一致、位置偏差等问题。 + +**解决方案**:VibrantTemplate统一生成模式 +- PNG和Fabric.js JSON在同一次render调用中生成 +- 使用完全相同的参数、计算逻辑、渲染上下文 +- 消除任何可能的差异来源 + +### 技术实现: + +1. **VibrantTemplate.generate()扩展**: + ```python + # 新增参数 + generation_result = template.generate( + content=content, + images=images, + generate_fabric_json=True # 启用统一生成 + ) + + # 返回结构 + { + 'png': PIL.Image, + 'fabric_json': Dict, + 'metadata': { + 'gradient_start': int, + 'theme_color': str, + 'elements_count': int + } + } + ``` + +2. **渲染上下文共享**: + - 图像处理参数 + - 渐变起始位置 + - 颜色提取结果 + - 文本布局计算 + - 所有几何计算 + +3. **向后兼容性**: + - 自动检测模板是否支持新参数 + - 无缝回退到独立生成模式 + - 不影响现有的模板实现 + +### ✅ 已完成: +- 标准fabric.js JSON格式输出 +- 简化的文本布局(只使用textbox/text) +- 装饰性图像生成器(按钮、标签、价格背景) +- 装饰图像上传到七牛云的完整流程 +- fabric.js JSON中占位符URL自动替换 +- 统一1350x1800基础尺寸 +- 端到端的装饰图像处理工作流 +- **VibrantTemplate统一生成架构** +- **PNG和JSON完全一致的渲染逻辑** + +### 🔄 进行中: +- 测试统一生成模式的一致性验证 \ No newline at end of file diff --git a/poster/templates/__pycache__/vibrant_template.cpython-312.pyc b/poster/templates/__pycache__/vibrant_template.cpython-312.pyc index e945895..923852a 100644 Binary files a/poster/templates/__pycache__/vibrant_template.cpython-312.pyc and b/poster/templates/__pycache__/vibrant_template.cpython-312.pyc differ diff --git a/poster/templates/vibrant_template.py b/poster/templates/vibrant_template.py index 0aec2ef..b1c7d2b 100644 --- a/poster/templates/vibrant_template.py +++ b/poster/templates/vibrant_template.py @@ -4,10 +4,9 @@ """ Vibrant风格(活力风格)海报模板 """ -from ast import List import logging import math -from typing import Dict, Any, Optional, Tuple +from typing import Dict, Any, Optional, Tuple, List import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter @@ -69,23 +68,70 @@ class VibrantTemplate(BaseTemplate): theme_color: Optional[str] = None, glass_intensity: float = 1.5, num_variations: int = 1, - **kwargs) -> Image.Image: + **kwargs): """ - 生成Vibrant风格海报 - + 生成Vibrant风格海报,支持统一渲染 + Args: images (List): 主图 content (Optional[Dict[str, Any]]): 包含所有文本信息的字典 theme_color (Optional[str]): 预设颜色主题的名称 glass_intensity (float): 毛玻璃效果强度 num_variations (int): 生成海报数量 + **kwargs: 其他参数,包含generate_fabric_json等 Returns: - Image.Image: 生成的海报图像 + 根据参数返回不同格式: + - 如果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 @@ -1120,4 +1166,591 @@ class VibrantTemplate(BaseTemplate): logger.error(f"创建页脚层失败: {e}") import traceback traceback.print_exc() - return None \ No newline at end of file + 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 \ No newline at end of file