From 6704dd58e7f04367223ee65a2e26ecd7c881a553 Mon Sep 17 00:00:00 2001 From: jinye_huang Date: Mon, 4 Aug 2025 16:04:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=9A=84=E7=9B=AE=E5=89=8D=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E7=9A=84=E7=94=9F=E6=88=90=E4=B8=80=E8=87=B4=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models/__pycache__/poster.cpython-312.pyc | Bin 5819 -> 6116 bytes api/models/poster.py | 2 + .../__pycache__/poster.cpython-312.pyc | Bin 66448 -> 116257 bytes api/services/poster.py | 1119 +++++++++++++++-- config/poster_gen.json | 2 +- docs/fabric_json_standard.md | 307 +++++ .../vibrant_template.cpython-312.pyc | Bin 55028 -> 72170 bytes poster/templates/vibrant_template.py | 647 +++++++++- 8 files changed, 1961 insertions(+), 116 deletions(-) create mode 100644 docs/fabric_json_standard.md diff --git a/api/models/__pycache__/poster.cpython-312.pyc b/api/models/__pycache__/poster.cpython-312.pyc index baa0d02f87808514096141f0da96eb606b22349f..6a794781798d267ae961971e68abad04b8d5ffb4 100644 GIT binary patch delta 531 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 z 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 z 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 CuMMvN 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 e945895a43cf72f30b37d05a20922948fceacef4..923852a04db6e663cb742e00198b043f27f0afa2 100644 GIT binary patch delta 22762 zcmbt+33OD~k?4E9ujI{76%eaf#9|h+2!mL~B3gPaA<*X6Ewi{q;~3j=jAP-4 zgTUB`jN_398{{aq<(R~B5+^v0J^d_VdODsnj*BP*&NXf-;hJb%&-w)1ht9c4RbUbm$s% zkS}Fq?fQ<)hD^ZajG{fOBfBA+rbLvF-dBQMi2pc8sl3Q%xIhc<4Mq{Ql46v{C`RSW zydaLJnhuFP>J!m9;yRC%v(t82RI;|sAK>QFoyI{WMLhvS@KMlp3cKAPW;Sq_X%D@T zyGfUZT2zecn6x2-k@-|UwNKL`vH)2|?o;~I1XK95jM}H$=ZyC$?a`uOG{+yl(_{^r(QK-)5fWp1FH`!(tcCJ)M5nS7sZ-(2Jp zQnwU9xkC6?1fy3m#ZbaAxkN6OKx*+)gyR`Z36ykBF6qFM9{7jtmjXQvQwF76_b63P zTC9K)ZY3rBVK#C9o6a{>0+-Z2HT0(n_>ub!^Que=M4EU}6wV?D*)*V?H-$F>H&N`q zsV)&_n7QddAwNYSM8pG0%h$wY`Q0Qvm9|#KYn{+AO;W+2HsJx;OXXCGzELT(zrSMlvPLduDZ=D$*N0N$SP(POwF_@jZPkDYCXlb09beT1MTUlRf~FNPD&eNjn(Oj9~M%2 zs~Tnv)SvPHQGYGeuTIs^Uk5FMKAO^wf~kXYGgIZJ>>8hbX@Up2~+P))SC$U5$1Pqe$2jG{p@b zu`D4~FG!FSMb%S51*1gf1eJ`6JQa*!31}w0X_<_(Vn%mXTu-RvOrhe@_dF%y@^T6c zUtB(Z^W>$Q#~-=%&bb?-M{kb);O04wqaQ3C*_&gcm%7t_rKS51c-dBejeFv;H*dW2 z-HB(ub@RDlATV+Kv5Ec*t-ekl>karCTf9wdTXW++e^=+N*Pi5#7+j&7gKyt_`l3+b z^LI~9Jpba2(WfW+kKY`;JaPG%FD{=^rM2U(a8JDat(z~Ny&J`ao7f&7R1l~LEw^=e zTYVGH9#3!h%(02rPE9;EN(ggZ4=O@Hwg2UbzSGId_hYq60w*rL{rT(Xlbwj>+^y*q zJ=MVY*twI(+&6yw?yZwU=`6eX-M4R!JcmO%_2#XU=Wh1(KTx+4`n0-!eO*-C-Ps!Q zNZCzLpWTdL3xcfxqS~&WKzC1|v89Xc@CKq1WLs3~W7#gYrxXgLH!|_k>!1Jd`^fR< zU%Y+v=tSS}jmv{jKxjNFC5(uw)A$h8wtEjj3ys|&FYE2_M>ASkFVp7h3^e)!UN*4F zBW9bhFJA0RMo(v3OPh~rWPP2CkA=KO%-e%`*>RS%b#*p&_yS(W8}NGM(VWIsU!alk zwRn5l1C7mHodKX76+{yVknh9>KoHBYVyo7Y{{)Ik}m^O~iCw^T$-j)>V6u@^_&RS{=?OifuU?@%&p zM&IgK7G-l?vsUuf%E_e)pp>l&O4%~{*2MIb-ZW6k>k5YC!}Ix2$)eBn##5RTny};k ztNPUulkJ+RoHvyZZ5iG=y8lY;xM@dNzXN+vFu0Pplt)~pLkoG=yz52NhFyHolJL@v zd{KR*uxePx7cPzzm3}Fglw^G=SL9^GD1|m7W~6e=*Ro6a?2;kluxYexEHs|IHLTgn z-j6IRlCi!(58K&HR1c)!5A_Al)ksh&Z~&E@H%7 z?lUfjG@*o^r(O^xaGDBA{YEGVdWw6sTO&Rs4RRau4cv9( zal5lsQdyANXW+c+3>w)55wrxqUU`sv@0?LD4a$S!#3`v)5#)}ZFzAB{P@nQtDtGRJ zF@uo@>_T0Nf|JLI{%lU4qgbE}DnX|!dZt01uncaUIR=YPSbf5h_~LTkjdx!F?KaVW zVxs>$6OUb2#z$RZu zcRMJ`%Bj--7?ko~1#pxa`MK#|MYN9_vU=z_GH$yO;Obz@5In`5uv?^T z3*g)(dlUU_F3Yioeu~@eSS>n0anmjuxOW{U`Vx1|@s*6MX5<*+L*xJsBEvVt#xl8s%VCF?qeGdvQ1pf zy@)=@Rprj4f59=iw`9FovuR{Q-f|J$%AG3M5KrO$y=ZzDK{xy;SyX`Y&8n`2yMdnE49# zV7Z09!aY^)qyNl(S$@Uz3rzkc06z+x+bvAM2VA&fxkyKIrpnsQZ)1@x1aSTa6?Gr$ zJ-}YXcOnCXa6P{IFEwP z4!7$)6Zhh3Gks#@t<{gyGBUj3k!5Qiq(jG{>MLRvjm8v!4k(d0kEu@s0G}1R0be#E zAYC9#7eT3niXc=84~MbPEF>@+0U?F$5K;nP)sW!_! z**BNb?~bh6tP#Tdlo|W$(iZ%MlRCoQGHq0^DxuZtLyvV4p%jw8nGX${ocs z2!~JxqDBUEXCvFy1NI`Cjm-gXXKTBUB~pTGRnTGYbAvmK)+sFeGiDQk^>X1I+@Phzxx}8DV#2w#RLf;yBW9P>-Z5=tbd$x4x6IeHGz1^&3O_-Dtji15m zA7lWQ)ouU$jknm;$mU`@&!Ob#i5!rDug1haE|OjT^knA^z1mteS zx@5!=EZhOFo?Jmi-EGKL?h><0Mn-#%%ksS}8^l^qaX;}n=mXqmzU4AAP&izG3xlg| z(a`@rvbN<3jpz?q+!qFeneE2nEP^)>ya~WVv%eY<`TJ@68K@e~>Gm^?fv&E0f2Fs( ztrCs^KGx4Z&s_`{H>2TAEF9K{*fIn?2p&R^h2Q{!g9vc5W>EpM4w81X z9bGNuo_M5&eqrRNk4(_?FGq$RZ56qa(=4n}(sLXL+0c3%+`EsNxfPFRhKOB*CJj4| zAO{;k9m^UK5Ie$z7>XA=dAP4+PZ=)zODF_eI4G({VZcP6+V1a%00jDV;T zvM0@>f$@Uoe46{>*_nle`((jPnU-{2!kzh{eH$uLb_0TxBAGlpEG{ii+1;jy{RLLM zs~B>ZIj4tANZM|MrRx!FLa-UZ76cwne6ql^711&Ta;t}0*>m>#sQX)pe zGQ@?3|1Gn}00oi&? zeSrJJ^S@Xq(53%pB!zluB>#B!VY;0QywC*h!)q^$h}Nbg?ry?Hq>xA)*P6&LzCNmncLf zglw8XU_Bx|xN6y3T=;yxYz)xD`KVMif+0h6;=?13@9mfDOmQQ*km{zcqpgfsTtv1Y{ZIz|(uiwlP_iM#roUZievhMBfz7N$unNIyPXF3plbkqjh$Vg>#2$gYdumZoWRA`~fG zIJKaN+`NozF(PP2xqEW@F_61XK6Rv}I2A03y`i%=41F^4y9Yn8NdKVcMezjv;sq!Q%*? z0016oxY!8!8i}er2c3(m{XH!$Z3l&vJ}meof^Q>u3IX09u}>rT4uWS8^dmTd;8_G5 z0I;;$n)ibzosb^HaT8XZ!~)MDIDp{00Gv@Z>+A5c`-QTnFg>-BO2|qYJDG_V7X5Q@ z0aS?!BqQH>Z?%NBjr`O5Pl*ar3Mgg15UVFOolkRpS7uBV{Y_*h2+xV8y{974rLmh> zx)$u?-PjE$yxAWtk;SF7_XCIQeJBb?A2i!&LO(0vLOre4r- z_h;F%X$#azI=JOQx!;2KblKtyPy>Gu-XmeeZ8Thwc^gZ`;EzUcwgxWgaI1!2ubktb zMn`rAr_VO370GlZI=Dq5Qr;R=CjHf_AjA&sFM+`th~qmm+hE6=H)T*ITn3UGJP4YJ zmqAB(NKS4HClv``*U67j1f}Ud_;1l3WzRb>0F^ub6Yht=^(|c8CH&sUQ`b+zW!1#R z;}ee!-Fki$?x>*j#OR|FuOI#V;(76{i>KheZ=z7RfU0qS{u6HEi603!bM9iegDH03 z?E9{}7;j*TIhrjC-PhI}*a0r%a`%$XL%ZNQklg5yOSlt@X2kEg;HcaP+G6{1Y7z+TmcY@yme^aDC;5>#O42h{4vU`kl`58QfQ` zeR6HsQgKyN8OgDQ9n-_vGs2n~+#?6pge;D0=GnY?_V9r*$uE`fDX%OVH#hWYV=_u( z8Cd>_IyaJSx|UtUXBQ0~9-cG0=87}Cy=6SRHLPikXsrV+pJ)q6iDEvxct|z8a&*U) z%5X#Lc=q0~X77zGd)PH^v|`i+S6Jg&tHYYr$<%d_Xwj!*qf6WLa!mMB?FT&sSP7&8Y3xh z3g$4nwEE@%p5Do29anqCG?&q*I-lf+C5^NqXd)cToYa672Vt=UtxVR}IA-g$3w+5= zk?wW;RgzA^7e~;^SpctSfCBLDz?g#1xz2DIh1t)b>nl z`eLkBg201-SnlK?M0^4jm?k&@5X%JscPc5@7O|kfcnX0{gXWSiZmJR+2+f};936?N zBbcygAoNs0|AZMxXFqIN5J!O#D&z(L_;usQ+{8ebT;%BH8AFB`0e76tH&`dae~L;|rFJt-P{1ylLln!7g5r+o$f|$-TGQ zm{C4#o@8I=tiQ`PWv-Z7KUn%O6hWVr`t@L7d@av*1MpHrV! z53U+&8aGex(|+d8AKW>-hA*rgo5>ff9Cxqc=4~m@GdjcWS)-Y}yM{N^^r<2S`+)z% zuECAGp}0>4RGg*UAD0)*iC7B;+0#`+OCcJgPxqCKGFk>q=WM5KgIj^VW%{^bMxW|4 zhkLMm*vaSBj#cxyE5;owv77lS%H%w?|HS^mis7trwwGZry z6(e3E;2y+Fq2;i%I#z}+9%1r6q+H#ZV_#OGz8z;QNa8P0HDap zPY65sFB&+9H=LF%Vq&_17k;Dyt5Csxc)$}f+D~JUauH8F78vq%ouYc zj?#$H2GKuyRg97-Re}vBgQ|(291y}*RMqeq1Wha=S+Bg6f^Zs|ofd#};TMF@BN9ds zTL4EBtVe#F>Wms<7G-HcH%i#5->Wfju5(K8OcPTAO*kRhUD9X>sV~I40mm&u10onDXC+{`XNN+--C zYT-XgXH`~LPV-2ja)?~;;}ua-IfJjKX&>B0`q^E$4ZxJ|fd`RHRw{x7SyaB)>-Po% zY}DS^%=++f&<(ejZT@&HN~1u4{ST-YbxbYN+V1uH8@r%gP_AF6KrT(orX-ugM)Ozp5dc1WqJ1yt!tyX6#6$X8G8|;Z1w)h-pg`-KYJX z+MFsMf&6HyqiDIC1QG0=WhQ3hRu;5>sn zV$6$}3cplJO)6L&dQ@S!I_y(cM^qEPnIncp>bwHOOsXLBPGAKt7WiHW&MV{z(QlxW zx@Y8Y|B;u&Ej}X66t{?8O`RHKVAF2+hj`?#98?mBb*7v|_<}6q4@^o4cm9}xmUA2S zkb)32$9?nzTPSID_A0xKe=`OBG{XsKYIM; z=}QyO|K!$_PY408B!r-j9KfSu(2}pvWO@8a+7oE0m>ZP@4ng!=BMgvLz(`p+09aaV zTWeeAHWKWRc-tNoL$FWU0+jjT)a~0AB5uQ|@U|U7sW3PIDs_9C+X9Cm+>CAhlMy3H zYlvi5!6|%xlh^N?J+mBcm)qJQCd40A`F-seSkTn&TM?D}9>Tyg#-ohNgpyHF8-&VY zC-DY5Dqngi;Pc3b;FY2oLm88RNqguv=GPJ+dUTafDu| z!xkb|gk#l0^eNo(GV%B{Sra(>d~9MF3eEfR$d+7gg4$O@<-_H0`sZ$(G=%P^ST^DU zTmY>drT$f6_^l!nw_{jD<-EE4nt2{?o;RinZ}){;1L18wVe`Ck^F!o-pOtgUdcw-t z`(zP?;hMtAE35;vhYG_A>s7@xh*Q00D&|eaL$XV{S9Ieg^Ttgz*FvTxylKf;@wjPq zpC%^B)T<(ztp1fTF<{p<2CzD$fI<6Ujyx2TBM$bCvA-v#L>%m$?EWP&HR2jdo7F!( zrbRr1(&qHuJIK)A5z9mz>>xwGH86%m`-{@%zfuYSEd4-e4?b`qJ|^xhCK#3DHBj|zj~3_E@BNFhIv6l$iP*hf#BHO z<2ibAN{9a94|Iu)`|^T;)^d-WNa~W@erqW8X+UoVD3hl_(P%V~3Isg!NQD6_36}IWK_7s$L!UU*}vQXHb{! zn53!g;B4*H9nl>U3C{!c0qUT5J9U8exOxhpGU%gpb#(L6^S8e9&aG!3z4=@px(rDO zeq1BfLh)2|3u^%0u<&^$oMsW20feULCDwvDvvB=d5wjsk8U(BzU-3$abs*+M;6mU= zkc%J>K|X*LYyrTY{3%_Y+~3eyAs8P#l9?%uA%xSA_!Tq;Ntg_YWD(+Lf|yo}?VxgE zOAzxQC`B;YpYGA8DW@i^NWxqaYHDqoo=U~OL^I+>Ng`$gZ3Ol;1m(~~R3lg}g61N( z27Wz|=DJrKMH&%zy~;i54cw5!5 zeY7_`ZQZYX{;~Ii-f`QmzO|5PG@oiX(ZKEOTM^NiuW1~-#xc+`v^lJCT-8)ZY`NEL zWxTCyXvL-VudE+0UpQ_HExKk~&fAucwT|2BfP^G7CnKUa4#+{t z8I=k85|kX^3dCoDk^@{tWts+PP;!84s7%yNTEsJ`O!I&plpNrCD$_Dh0ZI<=EW~p` z$pM~2Wrpy@fGRx9rI^^k^|gHWjY3Y zpyU8|3mwZvoXXT@{PC*-N^ki+B~qFbeqr09(FaGH!Hw$1l!wYDS6XL9*nbB#ms;WM#0#@d#Va5iObW@R0D&o zkY{pfDrN;hI6VT?5?q+Ky9wAzC^NNh>UigRGlbbv2Q!lW5xBIf zl}d66tnqj+?kDr7PxF-P67}w*cFmzEVlBWO0i1H<{0!=Ynat|M975_EIwH63XNWrzR38?{&i*`B9OlIn4$S^T?V`^f;%a|>bR)itGA|8HP zwVRy(a}qs3ZUQdM@B(~;5BFCvTLb9UAiqR!m~H8o8*z@UOyiiY7jGRAeb#l zwwRFJPA1L-3n&Lf3GRr3CbEnYER3hdmsA7vf9jGlC&>RKzOHwWS@1zaiCNg0n1x`u-CYNYf@m}6l6_F1qB{Z|LANqG_^ zy)jMFZ9yCR0JJU4$R5Hpdz!S{5_Lh^Ym(CLAkyx|{=$_^CexHA?FrtHT*BVPK?lidNP5o9C&D6-&5M)8ey4XkUmzKw^)BgCM|m?e`tdVOyKLCDY2?)$ngF{F_!e*F)lu zJR*p-A%(V*@6c4;-ZtoP8w-i#aw>J3f;Utl;T{YxTcT1jocQ-PU)4fmd%aB8fx}se z#;_f1YL188!FeR?uaHBMruH5doE0rCeqUf)0@=<^3Btqpqr+xGYsU0hexB!odp-jE{+Igzl*__wG8zqDY< z?NcrckzI`7W$cJi7*wd-J$)t&YR2@$$dGkw$%#YYXeI=Z-r3C*lHZpOawf>&PA*S!QIA5F1$#PkO+&A+e#9I zH5=Oc`ee=WutDs}&j@8K5}$)vI0qpD5#SsM0a#)ndmBIL6QZy%zUuZz`07SF{fL2S zIhjrx84yIgt4zf>_qd@-`k^*$fric}j)!${-H2XfA&#vBDUe9~DW|TB!@K@NrvMpYzguoHd8QC%EQxQvC+ zM;5zu0Kq}5pulf$us8`~iz*VzYlexfUliEsUg__P;iX>*mxq~- zYx;a%pFap6rdPs9^^SqYPjnSAkC&4s`c}crX)V_@E?(mrR9w|~fRruoT-oWeaMA41jB)Eq_>TrenO5S{kbzIb z3OrR!J9#H;T>v#*P=IEIGZ=k0sjW1fPce0kPZF~z`?&^8OQ_w2H@b|Fon(2 zz`+48;NY+f6oZ2UaBy%Sqcaf)2Zy1*2^<`NgM$Nc1LEM{K%WK#z=eo|1Dzcf%m4=m zIzeoRgJT162jWS`2H;7@2KoOL3Q}WMUDcF<)5b*xsDuH!V1SA74YS}4he5g~4>B~G z$D0sGRP8a+I zS+i&LD?rwgD`3@c^cAr}3bbD-ssmJz5o_^K25+4+T5(0o*KFafTf*CS@YWsu@|eh! zRGol@P*bdoNdX1nL$w+9)bO_XF*&A8(wmr&&=H7FQk$4CNo`_6LTw_RP@9M+)F$8| zL2sI3Cd^3aO~ey=6Y+%JL_DD*5KpL0#8GX2U0>uw7U<1sDQ*$Hsh}JXJ&`}y5gJ~{ z7cJ%OOF>x(BOh(zZHprg;I%tqFTjA8b;IR+LG4&RU$A`Kz2X}t#+lWxN}rh3SM_+U z>M9SHFXWwz#+Qp-rBmW`Wes~NWiX7bY-agoV;!B=)x;be*QMzwhe?vWO61p3BYjb zL#tr!os;K&w3)Xp0Re#IxB!5B*2I*UkQ4yG;{t#Z9n)fFTmU+F_kx%X6B3&Q;zuyiPT@kKa#Jg)Fu9BhYL+mTF zdDrwvZspJsK6lZWBfP$mU$TeK-4n?xh`5U)1x1mfiio==Qdk}-^58t?!n)3fzc77y z#{Si@ybx@W*K&&a90=k4O-?l|<2+o(=EA`S-ZZUmRmA8ZQBQ^A#*(nEq|Xmvk|xQI zI6!ZJsJgycY1p!0?WZY?l~?uU*Bu3+b30D&7_yH$W`}jN`wjz$Q?KOh^G28P_S%Ro z*+fxV6D42QOLbUDa2Ez~~{U$o9vtpB~h z`g2T<1q2WHu?OaFpedeKZm1CRD)WXCF<&CxP{9>_SP1{0#M%$@=|x=khq(kAEEQ;N zuLJwkBdM!n!L&U=+bC2*~wN7Bnct8&W z{Yz_#P zKep|?j5{PDwu$!AQpa%4aPw&O@ZQ&*F$&+W_`;jEhPUs8h{@0gT|!!9T6Ii}nUT2* z67Ak039%JojZ`&cxMX|97NYYBFk39#PGacR60~WEFPMB zzB4Ap_jwCPo5!k0_rC4CgYU6bv{R}Y?s@Cs*B_2ifQC127M?9_!n2bN`yUD)d^qfX znC}eU!7`!PV!A?V9+keUep?-*01a>6MxK%B^G5Su2Wf${NZkhE+2j+R`+(8`pak)R zN9ZLOR(J>=FuwtMDL(e&G-J)!Zu((~)INOQX!dCHsQLA}7=`cQ`mNz@JHos6_`=)v z@bz9HH8Oo>OpMu)<*VYzx((rtTf$o#!aEzoTO0X}d-!$U|0HFEz{N}uaF3bYjgl{K z?`nl}Xe;|0TwpQ?guxeF{G9qzJMHI6K3%2XfeU3Fq_J3){SJ5ZQ*&V(B*Idn{3`*( r#57HRsSweUJ2Hya{gzVy3swCas+t@7G(U5djkfo<|AE3M>B0X4W*{=o delta 7990 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 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