#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 海报服务层 - 重构版本 封装核心功能,支持基于模板的动态内容生成和海报创建 """ import logging import uuid import time import json import importlib import base64 import binascii 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 core.config import ConfigManager, PosterConfig from core.ai import AIAgent from utils.file_io import OutputManager from utils.image_processor import ImageProcessor from poster.templates.base_template import BaseTemplate from api.services.database_service import DatabaseService logger = logging.getLogger(__name__) class PosterService: """海报服务类""" def __init__(self, ai_agent: AIAgent, config_manager: ConfigManager, output_manager: OutputManager): """初始化海报服务""" self.ai_agent = ai_agent self.config_manager = config_manager self.output_manager = output_manager self.db_service = DatabaseService(config_manager) self._templates = {} self._template_instances = {} self._image_usage_tracker = {} self._init_templates() def _init_templates(self): """从数据库加载模板配置""" try: db_templates = self.db_service.get_active_poster_templates() if db_templates: self._templates = {t['id']: t for t in db_templates} logger.info(f"从数据库加载了 {len(self._templates)} 个模板") else: self._load_default_templates() logger.info("数据库无模板,使用默认模板配置") except Exception as e: logger.error(f"从数据库加载模板失败: {e}", exc_info=True) self._load_default_templates() def _load_default_templates(self): """加载默认模板配置""" self._templates = { 'vibrant': { 'id': 'vibrant', 'name': '活力风格', 'handler_path': 'poster.templates.vibrant_template', 'class_name': 'VibrantTemplate', 'description': '适合景点、活动等充满活力的场景', 'is_active': True }, 'business': { 'id': 'business', 'name': '商务风格', 'handler_path': 'poster.templates.business_template', 'class_name': 'BusinessTemplate', 'description': '适合酒店、房地产等商务场景', 'is_active': True } } def _load_template_handler(self, template_id: str) -> Optional[BaseTemplate]: """动态加载模板处理器""" if template_id not in self._templates: logger.error(f"未找到模板: {template_id}") return None # 如果已经实例化过,直接返回缓存的实例 if template_id in self._template_instances: return self._template_instances[template_id] template_info = self._templates[template_id] handler_path = template_info.get('handler_path') class_name = template_info.get('class_name') if not handler_path or not class_name: logger.error(f"模板 {template_id} 缺少 handler_path 或 class_name") return None try: # 动态导入模块和类 module = importlib.import_module(handler_path) template_class = getattr(module, class_name) # 实例化模板 template_instance = template_class() # 设置字体目录(如果配置了) from core.config import PosterConfig # poster_config = self.config_manager.get_config('poster', PosterConfig) # if poster_config: # font_dir = poster_config.font_dir # if font_dir and hasattr(template_instance, 'set_font_dir'): # template_instance.set_font_dir(font_dir) # 缓存实例以便重用 self._template_instances[template_id] = template_instance logger.info(f"成功加载模板处理器: {template_id} ({handler_path}.{class_name})") return template_instance except (ImportError, AttributeError) as e: logger.error(f"加载模板处理器失败: {e}", exc_info=True) return None def reload_templates(self): """重新加载模板信息""" logger.info("重新加载模板信息...") self._init_templates() # 清除缓存的模板实例,以便重新加载 self._template_instances = {} def get_available_templates(self) -> List[Dict[str, Any]]: """获取所有可用的模板信息""" result = [] for tid in self._templates: template = self._templates[tid] if template.get('is_active', True): # 默认为激活状态 template_info = { "id": template["id"], "name": template["name"], "description": template["description"], "handlerPath": template.get("handler_path", ""), "className": template.get("class_name", ""), "isActive": template.get("is_active", True) } result.append(template_info) return result def get_template_info(self, template_id: str) -> Optional[Dict[str, Any]]: """获取指定模板的简化信息""" template = self._templates.get(template_id) if not template: return None return { "id": template["id"], "name": template["name"], "description": template["description"], "has_prompts": bool(template.get("system_prompt") and template.get("user_prompt_template")), "input_format": template.get("input_format", {}), "output_format": template.get("output_format", {}), "is_active": template.get("is_active", False) } async def generate_poster(self, template_id: str, poster_content: Optional[Dict[str, Any]], content_id: Optional[str], product_id: Optional[str], scenic_spot_id: Optional[str], images_base64: Optional[List[str]] , num_variations: int = 1, force_llm_generation: bool = False, generate_psd: bool = False, psd_output_path: Optional[str] = None, generate_fabric_json: bool = False) -> Dict[str, Any]: """ 统一的海报生成入口 Args: template_id: 模板ID poster_content: 用户提供的海报内容(可选) content_id: 内容ID,用于从数据库获取内容(可选) product_id: 产品ID,用于从数据库获取产品信息(可选) scenic_spot_id: 景点ID,用于从数据库获取景点信息(可选) images_base64: 图片base64编码,用于生成海报(可选) num_variations: 需要生成的变体数量 force_llm_generation: 是否强制使用LLM生成内容 generate_psd: 是否生成PSD分层文件 psd_output_path: PSD文件输出路径(可选,默认自动生成) generate_fabric_json: 是否生成Fabric.js JSON格式 Returns: 生成结果字典,包含PNG图像和可选的PSD文件、Fabric.js JSON """ start_time = time.time() # 添加参数调试信息 logger.info("=" * 100) logger.info("海报生成服务 - 接收到的参数:") logger.info(f" template_id: {template_id} (类型: {type(template_id)})") logger.info(f" content_id: {content_id} (类型: {type(content_id)})") logger.info(f" product_id: {product_id} (类型: {type(product_id)})") logger.info(f" scenic_spot_id: {scenic_spot_id} (类型: {type(scenic_spot_id)})") logger.info(f" poster_content: {poster_content is not None}") logger.info(f" poster_content详细内容: {poster_content}") logger.info(f" force_llm_generation: {force_llm_generation}") logger.info("=" * 100) # 1. 动态加载模板处理器 template_handler = self._load_template_handler(template_id) if not template_handler: raise ValueError(f"无法为模板ID '{template_id}' 加载处理器。") # 2. 准备内容 (LLM或用户提供) final_content = poster_content if force_llm_generation or not final_content: logger.info(f"为模板 {template_id} 按需生成内容...") final_content = await self._generate_content_with_llm(template_id, content_id, product_id, scenic_spot_id, poster_content) if not final_content: raise ValueError("无法获取用于生成海报的内容") # # 3. 准备图片 # images = [] # if image_ids: # images = self.db_service.get_images_by_ids(image_ids) # if not images: # raise ValueError("无法获取指定的图片") # # 3. 图片解码 images = None # 获取模板的默认尺寸,如果获取不到则使用标准尺寸 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数据") # 处理第一张图片(目前模板只支持单张图片) # 未来可以扩展为处理多张图片 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)}") # 移除可能存在的MIME类型前缀 if first_image_base64.startswith("data:"): first_image_base64 = first_image_base64.split(",", 1)[1] # 彻底清理base64字符串 - 移除所有空白字符 first_image_base64 = ''.join(first_image_base64.split()) # 验证base64字符串长度(应该是4的倍数) if len(first_image_base64) % 4 != 0: # 添加必要的填充 first_image_base64 += '=' * (4 - len(first_image_base64) % 4) logger.info(f"为base64字符串添加了填充,最终长度: {len(first_image_base64)}") logger.info(f"准备解码base64数据,长度: {len(first_image_base64)}, 前20字符: {first_image_base64[:20]}...") # 解码base64 image_bytes = base64.b64decode(first_image_base64) logger.info(f"✅ base64解码成功,图片数据大小: {len(image_bytes)} bytes") # 验证解码后的数据不为空 if len(image_bytes) == 0: raise ValueError("解码后的图片数据为空") # 检查文件头判断图片格式 file_header = image_bytes[:10] if file_header.startswith(b'\xff\xd8\xff'): logger.info("✅ 检测到JPEG格式图片") elif file_header.startswith(b'\x89PNG'): logger.info("✅ 检测到PNG格式图片") else: logger.warning(f"⚠️ ⚠️ 未识别的图片格式,文件头: {file_header.hex()}") # 创建PIL Image对象 image_io = BytesIO(image_bytes) images = Image.open(image_io) # 验证图片是否成功打开 images.verify() # 验证图片完整性 # 重新打开图片(verify后需要重新打开) image_io.seek(0) images = Image.open(image_io) logger.info(f"✅ 图片解码成功,格式: {images.format}, 原始尺寸: {images.size}, 模式: {images.mode}") # 处理图片尺寸,调整到目标尺寸 1350x1800 images = self._resize_and_crop_image(images, target_width=1350, target_height=1800) except binascii.Error as e: logger.error(f"❌ Base64解码失败: {e}") logger.error(f"问题数据长度: {len(first_image_base64) if 'first_image_base64' in locals() else 'unknown'}") # 创建一个与目标大小一致的透明底图 (1350x1800) images = Image.new('RGBA', (1350, 1800), color=(0, 0, 0, 0)) logger.info(f"🔧 创建默认透明背景图,尺寸: {images.size}") except Exception as e: logger.error(f"❌ 图片处理失败: {e}") logger.error(f"错误类型: {type(e).__name__}") if 'image_bytes' in locals(): logger.error(f"图片数据大小: {len(image_bytes)} bytes, 前20字节: {image_bytes[:20].hex()}") # 创建一个与目标大小一致的透明底图 (1350x1800) images = Image.new('RGBA', (1350, 1800), color=(0, 0, 0, 0)) logger.info(f"🔧 创建默认透明背景图,尺寸: {images.size}") else: logger.warning("⚠️ 未提供图片数据,使用默认透明背景图") # 创建一个与目标大小一致的透明底图 (1350x1800) images = Image.new('RGBA', (1350, 1800), color=(0, 0, 0, 0)) logger.info(f"🔧 创建默认透明背景图,尺寸: {images.size}") # 4. 调用模板生成海报 try: posters = template_handler.generate( content=final_content, images=images, num_variations=num_variations ) if not posters: raise ValueError("模板未能生成有效的海报") # 5. 保存海报并返回结果 variations = [] psd_files = [] fabric_jsons = [] i=0 ## 用于多个海报时,指定海报的编号,此时只有一个没有用上,但是接口开放着。 output_path = self._save_poster(posters, template_id, i) if output_path: # 获取图像尺寸 image_size = [posters.width, posters.height] if hasattr(posters, 'width') else [1350, 1800] variations.append({ "id": f"{template_id}_v{i}", "image": self._image_to_base64(posters), "format": "PNG", "size": image_size, "file_path": str(output_path) }) # 6. 如果需要,生成PSD分层文件 if generate_psd: psd_result = self._generate_psd_file( template_handler, images, final_content, template_id, i, psd_output_path ) if psd_result: psd_files.append(psd_result) # 7. 如果需要,生成Fabric.js JSON if generate_fabric_json: fabric_json = self._generate_fabric_json(final_content, template_id, image_size, images) fabric_jsons.append(fabric_json) # 记录模板使用情况 self._update_template_stats(template_id, bool(variations), time.time() - start_time) return { "requestId": f"poster-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{str(uuid.uuid4())[:8]}", "templateId": template_id, "resultImagesBase64": variations, "psdFiles": psd_files if psd_files else None, "fabricJsons": fabric_jsons if fabric_jsons else None, "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) } } except Exception as e: logger.error(f"生成海报时发生错误: {e}", exc_info=True) self._update_template_stats(template_id, False, time.time() - start_time) raise ValueError(f"生成海报失败: {str(e)}") def _save_poster(self, poster: Image.Image, template_id: str, variation_id: int=1) -> Optional[Path]: """保存海报到文件系统""" try: # 创建唯一的主题ID用于保存 topic_id = f"poster_{template_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # 获取输出目录 output_dir = self.output_manager.get_topic_dir(topic_id) # 生成文件名 file_name = f"{template_id}_v{variation_id}.png" file_path = output_dir / file_name # 保存图像 poster.save(file_path, format="PNG") logger.info(f"海报已保存: {file_path}") return file_path except Exception as e: logger.error(f"保存海报失败: {e}", exc_info=True) return None def _image_to_base64(self, image: Image.Image) -> str: """将PIL图像转换为base64字符串""" buffer = BytesIO() image.save(buffer, format="PNG") return base64.b64encode(buffer.getvalue()).decode('utf-8') def _update_template_stats(self, template_id: str, success: bool, duration: float): """更新模板使用统计""" try: # 调用数据库服务的方法更新统计 self.db_service.update_template_usage_stats( template_id=template_id, success=success, processing_time=duration ) except Exception as e: logger.warning(f"更新模板统计失败: {e}") async def _generate_content_with_llm(self, template_id: str, content_id: Optional[str], product_id: Optional[str], scenic_spot_id: Optional[str], poster_content: Optional[Any] = None) -> Optional[Dict[str, Any]]: """使用LLM生成海报内容""" # 获取提示词 - 直接从数据库模板信息中获取 template_info = self._templates.get(template_id, {}) system_prompt = template_info.get('system_prompt', "") user_prompt_template = template_info.get('user_prompt_template', "") if not system_prompt or not user_prompt_template: logger.error(f"模板 {template_id} 缺少提示词配置") logger.debug(f"模板信息: {template_info}") return None logger.info(f"成功加载模板 {template_id} 的提示词配置") # 获取相关数据 - 将字符串ID转换为整数 data = {} def safe_int_convert(id_str: Optional[str]) -> Optional[int]: """安全将字符串ID转换为整数,避免大整数精度丢失""" if not id_str: return None try: # 去除前后空格 id_str = id_str.strip() # 如果ID包含非数字字符,只提取数字部分或返回None if id_str.isdigit(): # 直接转换纯数字字符串,避免精度丢失 converted_id = int(id_str) logger.debug(f"成功转换ID: {id_str} -> {converted_id}") return converted_id else: # 对于类似 "generated_note_1753693091224_0" 的ID,提取数字部分 import re numbers = re.findall(r'\d+', id_str) if numbers: # 使用第一个数字序列,但要验证它是有效的大整数 first_number = numbers[0] converted_id = int(first_number) logger.debug(f"从复合ID中提取数字: {id_str} -> {converted_id}") return converted_id logger.warning(f"无法从ID中提取有效数字: {id_str}") return None except (ValueError, TypeError, OverflowError) as e: logger.error(f"无法转换ID为整数: {id_str}, 类型: {type(id_str)}, 错误: {e}") return None # 添加详细的数据获取调试信息 logger.info(f"开始获取数据 - content_id: {content_id}, product_id: {product_id}, scenic_spot_id: {scenic_spot_id}") if content_id: logger.info(f"处理content_id: {content_id} (类型: {type(content_id)})") # content_id直接用字符串查询,不需要转换 content_data = self.db_service.get_content_by_id(content_id) logger.info(f"从数据库获取的content数据: {content_data is not None}") if content_data: logger.info(f"content数据预览: {list(content_data.keys()) if isinstance(content_data, dict) else type(content_data)}") data['content'] = content_data else: logger.info("未提供content_id") if product_id: logger.info(f"处理product_id: {product_id} (类型: {type(product_id)})") product_id_int = safe_int_convert(product_id) logger.info(f"转换后的product_id_int: {product_id_int}") if product_id_int: product_data = self.db_service.get_product_by_id(product_id_int) logger.info(f"从数据库获取的product数据: {product_data is not None}") if product_data: logger.info(f"product数据预览: {list(product_data.keys()) if isinstance(product_data, dict) else type(product_data)}") data['product'] = product_data else: logger.warning(f"product_id转换失败: {product_id}") else: logger.info("未提供product_id") if scenic_spot_id: logger.info(f"处理scenic_spot_id: {scenic_spot_id} (类型: {type(scenic_spot_id)})") scenic_spot_id_int = safe_int_convert(scenic_spot_id) logger.info(f"转换后的scenic_spot_id_int: {scenic_spot_id_int}") if scenic_spot_id_int: scenic_spot_data = self.db_service.get_scenic_spot_by_id(scenic_spot_id_int) logger.info(f"从数据库获取的scenic_spot数据: {scenic_spot_data is not None}") if scenic_spot_data: logger.info(f"scenic_spot数据预览: {list(scenic_spot_data.keys()) if isinstance(scenic_spot_data, dict) else type(scenic_spot_data)}") data['scenic_spot'] = scenic_spot_data else: logger.warning(f"scenic_spot_id转换失败: {scenic_spot_id}") else: logger.info("未提供scenic_spot_id") logger.info(f"获取到的数据: content={data.get('content') is not None}, product={data.get('product') is not None}, scenic_spot={data.get('scenic_spot') is not None}") # 格式化数据为简洁的文本格式,参考其他模块的做法 try: logger.info("开始格式化数据...") # 景区信息格式化 scenic_info = "无相关景区信息" if data.get('scenic_spot'): logger.info("正在格式化景区信息...") spot = data['scenic_spot'] scenic_info = f"""景区名称: {spot.get('name', '')} 地址: {spot.get('address', '')} 描述: {spot.get('description', '')} 优势: {spot.get('advantage', '')} 亮点: {spot.get('highlight', '')} 交通信息: {spot.get('trafficInfo', '')}""" logger.info("景区信息格式化完成") # 产品信息格式化 product_info = "无相关产品信息" if data.get('product'): logger.info("正在格式化产品信息...") product = data['product'] product_info = f"""产品名称: {product.get('productName', '')} 原价: {product.get('originPrice', '')} 实际价格: {product.get('realPrice', '')} 套餐信息: {product.get('packageInfo', '')} 核心优势: {product.get('keyAdvantages', '')} 亮点: {product.get('highlights', '')} 详细描述: {product.get('detailedDescription', '')}""" logger.info("产品信息格式化完成") # 内容信息格式化 - 优先使用poster_content tweet_info = "无相关内容信息" if poster_content is not None: logger.info(f"使用poster_content作为文章内容,类型: {type(poster_content)}") if isinstance(poster_content, str): tweet_info = f"文章内容: {poster_content}" elif isinstance(poster_content, dict): tweet_info = f"文章内容: {str(poster_content)}" else: tweet_info = f"文章内容: {str(poster_content)}" logger.info(f"poster_content格式化完成,长度: {len(tweet_info)}") elif data.get('content'): logger.info("正在格式化数据库内容信息...") content = data['content'] tweet_info = f"""标题: {content.get('title', '')} 内容: {content.get('content', '')}""" logger.info("数据库内容信息格式化完成") logger.info("开始构建用户提示词...") # 构建用户提示词 user_prompt = user_prompt_template.format( scenic_info=scenic_info, product_info=product_info, tweet_info=tweet_info ) logger.info(f"用户提示词构建成功,长度: {len(user_prompt)}") # 输出系统提示词和用户提示词内容以供调试 logger.info("=" * 80) logger.info("系统提示词内容:") logger.info(system_prompt) logger.info("=" * 80) logger.info("用户提示词内容:") logger.info(user_prompt) logger.info("=" * 80) except Exception as e: logger.error(f"格式化提示词时发生错误: {e}", exc_info=True) # 提供兜底方案 user_prompt = f"""{user_prompt_template} 当前可用数据: - 景区信息: {'有' if data.get('scenic_spot') else '无'} - 产品信息: {'有' if data.get('product') else '无'} - 内容信息: {'有' if data.get('content') else '无'} 请根据可用信息生成海报内容。""" try: response, _, _, _ = await self.ai_agent.generate_text( system_prompt=system_prompt, user_prompt=user_prompt, use_stream=True ) # 提取JSON响应 json_start = response.find('{') json_end = response.rfind('}') + 1 if json_start != -1 and json_end != -1: result = json.loads(response[json_start:json_end]) logger.info(f"LLM生成内容成功: {list(result.keys())}") return result else: logger.error(f"LLM响应中未找到JSON格式内容: {response[:200]}...") return None except json.JSONDecodeError as e: logger.error(f"解析LLM响应JSON失败: {e}") return None except Exception as e: logger.error(f"生成内容时发生错误: {e}", exc_info=True) return None def _generate_psd_file(self, template_handler: BaseTemplate, images: Image.Image, content: Dict[str, Any], template_id: str, variation_id: int, custom_output_path: Optional[str] = None) -> Optional[Dict[str, Any]]: """ 生成Fabric.js JSON文件(保持接口兼容性,实际生成JSON而非PSD) Args: template_handler: 模板处理器实例 images: 图像数据 content: 海报内容 template_id: 模板ID variation_id: 变体ID custom_output_path: 自定义输出路径 Returns: JSON文件信息字典,包含文件路径、base64编码等 """ try: # 获取图像尺寸 image_size = [images.width, images.height] if hasattr(images, 'width') else [900, 1200] # 生成Fabric.js JSON数据 fabric_json = self._generate_fabric_json(content, template_id, image_size, images) # 生成JSON文件路径 if custom_output_path: json_filename = custom_output_path if not json_filename.endswith('.json'): json_filename += '.json' else: timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') json_filename = f"{template_id}_fabric_v{variation_id}_{timestamp}.json" # 获取输出目录 topic_id = f"poster_{template_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" output_dir = self.output_manager.get_topic_dir(topic_id) json_path = output_dir / json_filename # 保存JSON文件 logger.info(f"开始生成Fabric.js JSON文件: {json_path}") try: with open(json_path, 'w', encoding='utf-8') as f: json.dump(fabric_json, f, ensure_ascii=False, indent=2) logger.info(f"Fabric.js JSON文件保存成功: {json_path}") except Exception as e: logger.error(f"保存JSON文件失败: {e}") return None # 获取文件信息 file_size = Path(json_path).stat().st_size # 生成JSON的base64编码 json_base64 = None try: json_string = json.dumps(fabric_json, ensure_ascii=False) json_base64 = base64.b64encode(json_string.encode('utf-8')).decode('utf-8') except Exception as e: logger.warning(f"生成JSON base64编码失败: {e}") logger.info(f"Fabric.js JSON文件生成成功: {json_path} ({file_size/1024:.1f}KB)") return { "id": f"{template_id}_v{variation_id}_fabric", "filename": json_filename, "data": json_base64, "size": file_size, "format": "JSON", "json_data": fabric_json # 添加原始JSON数据 } except Exception as e: logger.error(f"生成Fabric.js JSON文件时发生错误: {e}", exc_info=True) return None def _get_json_object_count(self, json_path: str) -> Optional[int]: """获取Fabric.js JSON文件的对象数量""" try: with open(json_path, 'r', encoding='utf-8') as f: fabric_data = json.load(f) return len(fabric_data.get('objects', [])) except Exception as e: logger.warning(f"获取JSON对象数量失败: {e}") return None 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 Args: content: 海报内容数据 template_id: 模板ID image_size: 图像尺寸 [width, height] images: 用户上传的图片 Returns: Dict: 完全匹配VibrantTemplate的Fabric.js JSON格式数据 """ try: fabric_objects = [] # VibrantTemplate的基础尺寸(900x1200) base_width, base_height = 900, 1200 # 最终输出尺寸(1350x1800) final_width, final_height = image_size[0], image_size[1] # 1. 用户上传的图片(最底层 - Level 0) if images and hasattr(images, 'width'): # 按VibrantTemplate方式缩放到基础尺寸 image_object = self._create_vibrant_image_object_precise(images, final_width, final_height) fabric_objects.append(image_object) else: placeholder_object = self._create_placeholder_object(final_width, final_height) fabric_objects.append(placeholder_object) # 2. 估算内容高度(复制VibrantTemplate逻辑) estimated_height = self._estimate_vibrant_content_height(content) # 3. 动态检测渐变起始位置(复制VibrantTemplate逻辑) gradient_start = self._detect_vibrant_gradient_start_position(images, estimated_height, base_height) # 4. 提取毛玻璃颜色(复制VibrantTemplate逻辑) glass_colors = self._extract_vibrant_glass_colors(images, gradient_start) # 5. 创建精确的毛玻璃效果(Level 1) 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) fabric_objects.extend(text_objects) # 构建完整的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 } } logger.info(f"成功生成VibrantTemplate精确Fabric.js JSON,包含 {len(fabric_objects)} 个对象") return fabric_json except Exception as e: logger.error(f"生成Fabric.js JSON失败: {e}") return { "version": "5.3.0", "objects": [], "background": "transparent", "width": image_size[0], "height": image_size[1] } def _create_image_object(self, images: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]: """创建用户上传的图片对象(最底层)""" # 将PIL图像转换为base64 image_base64 = self._image_to_base64(images) # 计算图片的缩放比例,保持宽高比 image_width, image_height = images.width, images.height scale_x = canvas_width / image_width scale_y = canvas_height / image_height scale = min(scale_x, scale_y) # 保持宽高比的适应缩放 # 计算居中位置 scaled_width = image_width * scale scaled_height = image_height * scale left = (canvas_width - scaled_width) / 2 top = (canvas_height - scaled_height) / 2 return { "type": "image", "version": "5.3.0", "originX": "left", "originY": "top", "left": left, "top": top, "width": image_width, "height": image_height, "scaleX": scale, "scaleY": scale, "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "src": f"data:image/png;base64,{image_base64}", "crossOrigin": "anonymous", "name": "user_uploaded_image", "data": { "type": "user_image", "layer": "image", "level": 0, "replaceable": True, "original_size": [image_width, image_height], "scale_ratio": scale }, "selectable": True, "evented": True } def _create_placeholder_object(self, canvas_width: int, canvas_height: int) -> Dict[str, Any]: """创建图片占位符对象""" return { "type": "rect", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": 0, "width": canvas_width, "height": canvas_height, "fill": "#f8f9fa", "stroke": "#dee2e6", "strokeWidth": 2, "strokeDashArray": [10, 5], "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "name": "image_placeholder", "data": { "type": "placeholder", "layer": "image", "level": 0, "replaceable": True, "placeholder_text": "点击上传图片" }, "selectable": True, "evented": True } def _create_background_object(self, canvas_width: int, canvas_height: int) -> Dict[str, Any]: """创建半透明背景对象""" return { "type": "rect", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": 0, "width": canvas_width, "height": canvas_height, "fill": "rgba(255, 255, 255, 0.8)", "stroke": None, "angle": 0, "flipX": False, "flipY": False, "opacity": 0.8, "visible": True, "name": "background_overlay", "data": { "type": "background", "layer": "background", "level": 1 }, "selectable": False, "evented": False } def _create_text_objects(self, content: Dict[str, Any], canvas_width: int, canvas_height: int) -> List[Dict[str, Any]]: """创建文本对象列表""" text_objects = [] # 文本元素配置 text_configs = { 'title': {'fontSize': 48, 'top': 100, 'fontWeight': 'bold', 'fill': '#2c3e50'}, 'slogan': {'fontSize': 24, 'top': 180, 'fontWeight': 'normal', 'fill': '#7f8c8d'}, 'content': {'fontSize': 18, 'top': 250, 'fontWeight': 'normal', 'fill': '#34495e'}, 'price': {'fontSize': 36, 'top': 400, 'fontWeight': 'bold', 'fill': '#e74c3c'}, 'remarks': {'fontSize': 14, 'top': 500, 'fontWeight': 'normal', 'fill': '#95a5a6'} } for key, config in text_configs.items(): if key in content and content[key]: text_content = content[key] if isinstance(text_content, list): text_content = '\n'.join(text_content) elif not isinstance(text_content, str): text_content = str(text_content) text_object = { "type": "textbox", "version": "5.3.0", "originX": "left", "originY": "top", "left": 50, "top": config['top'], "width": canvas_width - 100, "height": 60 if key != 'content' else 120, "fill": config['fill'], "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": config['fontWeight'], "fontSize": config['fontSize'], "text": text_content, "textAlign": "center" if key in ['title', 'slogan', 'price'] else "left", "lineHeight": 1.2, "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "name": f"text_{key}", "data": { "type": "text", "layer": "content", "level": 2, "content_type": key, "priority": "high" if key in ['title', 'price'] else "medium" }, "selectable": True, "evented": True } text_objects.append(text_object) return text_objects def _create_decoration_objects(self, canvas_width: int, canvas_height: int) -> List[Dict[str, Any]]: """创建装饰对象列表""" decoration_objects = [] # 装饰边框 border_object = { "type": "rect", "version": "5.3.0", "originX": "left", "originY": "top", "left": 20, "top": 20, "width": canvas_width - 40, "height": canvas_height - 40, "fill": "transparent", "stroke": "#3498db", "strokeWidth": 3, "strokeDashArray": [10, 5], "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "name": "decoration_border", "data": { "type": "decoration", "layer": "decoration", "level": 3 }, "selectable": False, "evented": False } decoration_objects.append(border_object) # 角落装饰 corner_object = { "type": "circle", "version": "5.3.0", "originX": "center", "originY": "center", "left": canvas_width - 50, "top": 50, "radius": 20, "fill": "#f39c12", "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "name": "corner_decoration", "data": { "type": "decoration", "layer": "decoration", "level": 3 }, "selectable": False, "evented": False } decoration_objects.append(corner_object) return decoration_objects def _calculate_gradient_start_position(self, canvas_height: int) -> int: """计算毛玻璃渐变开始位置(模拟VibrantTemplate的逻辑)""" # VibrantTemplate中大约从画布的60%位置开始渐变 return int(canvas_height * 0.6) def _create_vibrant_image_object(self, images: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]: """创建VibrantTemplate风格的图片对象""" # 将PIL图像转换为base64 image_base64 = self._image_to_base64(images) # VibrantTemplate将图片resize到画布大小 return { "type": "image", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": 0, "width": images.width, "height": images.height, "scaleX": canvas_width / images.width, "scaleY": canvas_height / images.height, "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "src": f"data:image/png;base64,{image_base64}", "crossOrigin": "anonymous", "name": "background_image", "data": { "type": "background_image", "layer": "image", "level": 0, "replaceable": True }, "selectable": True, "evented": True } def _create_gradient_background_object(self, canvas_width: int, canvas_height: int, gradient_start: int) -> Dict[str, Any]: """创建VibrantTemplate风格的毛玻璃渐变背景""" return { "type": "rect", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": gradient_start, "width": canvas_width, "height": canvas_height - gradient_start, "fill": { "type": "linear", "coords": { "x1": 0, "y1": 0, "x2": 0, "y2": canvas_height - gradient_start }, "colorStops": [ {"offset": 0, "color": "rgba(0, 30, 80, 0.3)"}, {"offset": 0.5, "color": "rgba(0, 50, 120, 0.7)"}, {"offset": 1, "color": "rgba(0, 30, 80, 0.9)"} ] }, "stroke": "", "strokeWidth": 0, "angle": 0, "flipX": False, "flipY": False, "opacity": 0.85, "visible": True, "name": "glass_gradient", "data": { "type": "glass_effect", "layer": "background", "level": 1, "effect": "vibrant_glass" }, "selectable": False, "evented": False } def _create_vibrant_text_objects(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int, scale_ratio: float) -> List[Dict[str, Any]]: """创建VibrantTemplate风格的文字对象(复用VibrantTemplate的精确计算)""" text_objects = [] # 复用VibrantTemplate的边距计算逻辑 left_margin, right_margin = self._calculate_vibrant_content_margins(content, canvas_width, canvas_width // 2) content_width = right_margin - left_margin # 标题位置和样式(使用VibrantTemplate的精确参数) title_y = gradient_start + int(40 * scale_ratio) if title := content.get("title"): # 使用VibrantTemplate的精确计算:目标宽度为内容区域的98%,字体范围40-140 title_target_width = int(content_width * 0.98) title_size, title_actual_width = self._calculate_vibrant_font_size_precise( title, title_target_width, min_size=40, max_size=140 ) # 居中计算,与VibrantTemplate一致 title_x = canvas_width // 2 - title_actual_width // 2 title_obj = { "type": "textbox", "version": "5.3.0", "originX": "left", # 改为left,使用计算的x位置 "originY": "top", "left": title_x, "top": title_y, "width": title_actual_width, "height": title_size + 20, "fill": "#ffffff", "stroke": "#001e50", "strokeWidth": 4, # 与VibrantTemplate一致的描边宽度 "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial Black, sans-serif", "fontWeight": "bold", "fontSize": title_size, "text": title, "textAlign": "center", "lineHeight": 1.1, "name": "vibrant_title", "data": { "type": "title", "layer": "content", "level": 2, "style": "vibrant_title", "target_width": title_target_width, "actual_width": title_actual_width, "font_path": "/assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True } text_objects.append(title_obj) # 添加标题下方装饰线(与VibrantTemplate一致) line_y = title_y + title_size + 5 line_start_x = title_x - title_actual_width * 0.025 line_end_x = title_x + title_actual_width * 1.025 decoration_line = { "type": "line", "version": "5.3.0", "originX": "center", "originY": "center", "left": (line_start_x + line_end_x) / 2, "top": line_y, "x1": line_start_x - (line_start_x + line_end_x) / 2, "y1": 0, "x2": line_end_x - (line_start_x + line_end_x) / 2, "y2": 0, "stroke": "rgba(215, 215, 215, 0.3)", "strokeWidth": 3, "name": "title_decoration_line", "data": { "type": "decoration", "layer": "content", "level": 2 }, "selectable": False, "evented": False } text_objects.append(decoration_line) # 副标题位置和样式(使用VibrantTemplate的精确参数) subtitle_y = title_y + int(100 * scale_ratio) if slogan := content.get("slogan"): # 使用VibrantTemplate的精确计算:目标宽度为内容区域的95%,字体范围20-75 subtitle_target_width = int(content_width * 0.95) subtitle_size, subtitle_actual_width = self._calculate_vibrant_font_size_precise( slogan, subtitle_target_width, min_size=20, max_size=75 ) # 居中计算,与VibrantTemplate一致 subtitle_x = canvas_width // 2 - subtitle_actual_width // 2 subtitle_obj = { "type": "textbox", "version": "5.3.0", "originX": "left", # 改为left,使用计算的x位置 "originY": "top", "left": subtitle_x, "top": subtitle_y, "width": subtitle_actual_width, "height": subtitle_size + 15, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": subtitle_size, "text": slogan, "textAlign": "center", "lineHeight": 1.2, "name": "vibrant_slogan", "data": { "type": "slogan", "layer": "content", "level": 2, "style": "vibrant_subtitle", "target_width": subtitle_target_width, "actual_width": subtitle_actual_width, "font_path": "/assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True } text_objects.append(subtitle_obj) # 双栏布局(使用VibrantTemplate的精确边距) column_start_y = subtitle_y + int(80 * scale_ratio) left_column_width = int(content_width * 0.5) right_column_x = left_margin + left_column_width # 左栏:内容按钮和项目列表 left_objects = self._create_left_column_objects(content, left_margin, column_start_y, left_column_width, scale_ratio) text_objects.extend(left_objects) # 右栏:价格和票种信息(使用VibrantTemplate的精确参数) right_objects = self._create_right_column_objects_precise(content, right_column_x, column_start_y, right_margin, scale_ratio) text_objects.extend(right_objects) # 底部标签和分页 footer_objects = self._create_footer_objects(content, left_margin, canvas_height - int(30 * scale_ratio), content_width, scale_ratio) text_objects.extend(footer_objects) return text_objects def _calculate_vibrant_font_size(self, text: str, target_width: int, min_size: int, max_size: int, scale_ratio: float) -> int: """计算VibrantTemplate风格的字体大小""" # 简化的字体大小计算,基于文本长度和目标宽度 char_count = len(text) if char_count == 0: return int(min_size * scale_ratio) # 估算字符宽度(中文字符按1.5倍计算) avg_char_width = target_width / max(1, char_count * 1.2) estimated_font_size = int(avg_char_width * 0.8) # 应用缩放比例并限制在范围内 scaled_size = int(estimated_font_size * scale_ratio) return max(int(min_size * scale_ratio), min(int(max_size * scale_ratio), scaled_size)) def _create_left_column_objects(self, content: Dict[str, Any], x: int, y: int, width: int, scale_ratio: float) -> List[Dict[str, Any]]: """创建左栏对象:按钮和项目列表""" objects = [] # 内容按钮 button_text = content.get("content_button", "套餐内容") button_width = min(width - 20, int(len(button_text) * 20 * scale_ratio + 40)) button_height = int(50 * scale_ratio) # 按钮背景 button_bg = { "type": "rect", "version": "5.3.0", "originX": "left", "originY": "top", "left": x, "top": 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", "layer": "content", "level": 2}, "selectable": False, "evented": False } objects.append(button_bg) # 按钮文字 button_text_obj = { "type": "textbox", "version": "5.3.0", "originX": "center", "originY": "center", "left": x + button_width / 2, "top": y + button_height / 2, "width": button_width - 20, "height": button_height, "fill": "#ffffff", "fontFamily": "Arial, sans-serif", "fontWeight": "bold", "fontSize": int(30 * scale_ratio), "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) # 项目列表 items = content.get("content_items", []) list_y = y + button_height + int(20 * scale_ratio) font_size = int(28 * scale_ratio) line_spacing = int(36 * scale_ratio) for i, item in enumerate(items): item_obj = { "type": "textbox", "version": "5.3.0", "originX": "left", "originY": "top", "left": x, "top": list_y + i * line_spacing, "width": width, "height": font_size + 10, "fill": "#ffffff", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": font_size, "text": f"• {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_right_column_objects(self, content: Dict[str, Any], x: int, y: int, right_margin: int, scale_ratio: float) -> List[Dict[str, Any]]: """创建右栏对象:价格和票种""" objects = [] column_width = right_margin - x # 价格 if price := content.get("price"): price_size = self._calculate_vibrant_font_size(str(price), column_width * 0.7, 40, 120, scale_ratio) price_obj = { "type": "textbox", "version": "5.3.0", "originX": "right", "originY": "top", "left": right_margin, "top": y, "width": column_width, "height": price_size + 20, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial Black, sans-serif", "fontWeight": "bold", "fontSize": price_size, "text": f"¥{price}", "textAlign": "right", "name": "vibrant_price", "data": {"type": "price", "layer": "content", "level": 2}, "selectable": True, "evented": True } objects.append(price_obj) # 价格下划线 underline_y = y + price_size + int(18 * scale_ratio) underline = { "type": "line", "version": "5.3.0", "originX": "center", "originY": "center", "left": right_margin - column_width * 0.5, "top": underline_y, "x1": -column_width * 0.4, "y1": 0, "x2": column_width * 0.4, "y2": 0, "stroke": "rgba(255, 255, 255, 0.3)", "strokeWidth": 2, "name": "price_underline", "data": {"type": "decoration", "layer": "content", "level": 2}, "selectable": False, "evented": False } objects.append(underline) # 票种 if ticket_type := content.get("ticket_type"): ticket_y = y + price_size + int(35 * scale_ratio) ticket_size = self._calculate_vibrant_font_size(ticket_type, column_width * 0.7, 30, 60, scale_ratio) ticket_obj = { "type": "textbox", "version": "5.3.0", "originX": "right", "originY": "top", "left": right_margin, "top": ticket_y, "width": column_width, "height": ticket_size + 10, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.5) 1px 1px 3px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": ticket_size, "text": ticket_type, "textAlign": "right", "name": "ticket_type", "data": {"type": "ticket_type", "layer": "content", "level": 2}, "selectable": True, "evented": True } objects.append(ticket_obj) return objects def _create_footer_objects(self, content: Dict[str, Any], x: int, y: int, width: int, scale_ratio: float) -> List[Dict[str, Any]]: """创建底部对象:标签和分页""" objects = [] font_size = int(18 * scale_ratio) # 左侧标签 if tag := content.get("tag"): tag_obj = { "type": "textbox", "version": "5.3.0", "originX": "left", "originY": "top", "left": x, "top": y, "width": width // 2, "height": font_size + 5, "fill": "#ffffff", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": font_size, "text": tag, "textAlign": "left", "name": "footer_tag", "data": {"type": "tag", "layer": "content", "level": 2}, "selectable": True, "evented": True } objects.append(tag_obj) # 右侧分页 if pagination := content.get("pagination"): pagination_obj = { "type": "textbox", "version": "5.3.0", "originX": "right", "originY": "top", "left": x + width, "top": y, "width": width // 2, "height": font_size + 5, "fill": "#ffffff", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": font_size, "text": pagination, "textAlign": "right", "name": "footer_pagination", "data": {"type": "pagination", "layer": "content", "level": 2}, "selectable": True, "evented": True } objects.append(pagination_obj) return objects def _calculate_vibrant_content_margins(self, content: Dict[str, Any], width: int, center_x: int) -> Tuple[int, int]: """复用VibrantTemplate的边距计算逻辑""" # 计算标题位置 title_text = content.get("title", "") title_target_width = int(width * 0.95) title_size, title_width = self._calculate_vibrant_font_size_precise( title_text, title_target_width, min_size=40, max_size=130 ) title_x = center_x - title_width // 2 # 计算副标题位置 slogan_text = content.get("slogan", "") subtitle_target_width = int(width * 0.9) subtitle_size, subtitle_width = self._calculate_vibrant_font_size_precise( slogan_text, subtitle_target_width, max_size=50, min_size=20 ) subtitle_x = center_x - subtitle_width // 2 # 计算内容区域边距 - 与VibrantTemplate一致 padding = 20 content_left_margin = min(title_x, subtitle_x) - padding content_right_margin = max(title_x + title_width, subtitle_x + subtitle_width) + padding # 确保边距不超出合理范围 content_left_margin = max(40, content_left_margin) content_right_margin = min(width - 40, content_right_margin) # 如果内容区域太窄,强制使用更宽的区域 min_content_width = int(width * 0.75) # 至少使用75%的宽度 current_width = content_right_margin - content_left_margin if current_width < min_content_width: extra_width = min_content_width - current_width content_left_margin = max(30, content_left_margin - extra_width // 2) content_right_margin = min(width - 30, content_right_margin + extra_width // 2) return content_left_margin, content_right_margin def _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 # 简化的二分查找算法,估算字体大小 tolerance = 0.08 # 容差值 # 使用字符估算来模拟精确计算 avg_char_width_factor = { '中': 1.5, # 中文字符通常比英文宽 '英': 0.6, # 英文字符相对较窄 } # 分析文本,统计中英文字符 chinese_chars = sum(1 for char in text if '\u4e00' <= char <= '\u9fff') english_chars = len(text) - chinese_chars # 估算平均字符宽度 estimated_char_width = (chinese_chars * avg_char_width_factor['中'] + english_chars * avg_char_width_factor['英']) / max(1, len(text)) # 二分查找最佳字体大小 low = min_size high = max_size best_size = min_size best_width = 0 for _ in range(20): # 限制迭代次数 mid = (low + high) // 2 # 估算当前字体大小下的文本宽度 estimated_width = len(text) * estimated_char_width * mid * 0.6 # 检查是否在容差范围内 if target_width * (1 - tolerance) <= estimated_width <= target_width * (1 + tolerance): best_size = mid best_width = int(estimated_width) break if estimated_width < target_width: if estimated_width > best_width: best_width = int(estimated_width) best_size = mid low = mid + 1 else: high = mid - 1 # 确保在范围内 best_size = max(min_size, min(max_size, best_size)) # 重新计算最终宽度 final_width = int(len(text) * estimated_char_width * best_size * 0.6) logger.info(f"精确字体计算 - 文本:'{text[:10]}...', 目标宽度:{target_width}, 字体大小:{best_size}, 实际宽度:{final_width}") return best_size, final_width def _create_right_column_objects_precise(self, content: Dict[str, Any], x: int, y: int, right_margin: int, scale_ratio: float) -> List[Dict[str, Any]]: """创建右栏对象(使用VibrantTemplate的精确价格计算)""" objects = [] column_width = right_margin - x # 价格(使用VibrantTemplate的精确参数) if price := content.get("price"): # VibrantTemplate参数:目标宽度为栏宽的70%,字体范围40-120 price_target_width = int(column_width * 0.7) price_size, price_actual_width = self._calculate_vibrant_font_size_precise( str(price), price_target_width, min_size=40, max_size=120 ) # 计算"CNY起"后缀 suffix_text = "CNY起" suffix_size = int(price_size * 0.3) # VibrantTemplate中后缀是价格字体的30% suffix_estimated_width = len(suffix_text) * suffix_size * 0.6 # 右对齐价格和后缀 price_x = right_margin - price_actual_width - suffix_estimated_width # 价格文本 price_obj = { "type": "textbox", "version": "5.3.0", "originX": "left", "originY": "top", "left": price_x, "top": y, "width": price_actual_width, "height": price_size + 20, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial Black, sans-serif", "fontWeight": "bold", "fontSize": price_size, "text": f"¥{price}", "textAlign": "left", "name": "vibrant_price", "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) # "CNY起"后缀 suffix_y = y + price_size - 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_estimated_width, "height": suffix_size + 5, "fill": "#ffffff", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, 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) # 价格下划线(与VibrantTemplate一致) underline_y = y + price_size + int(18 * scale_ratio) underline = { "type": "line", "version": "5.3.0", "originX": "center", "originY": "center", "left": (price_x - 10 + right_margin) / 2, "top": underline_y, "x1": (price_x - 10) - (price_x - 10 + right_margin) / 2, "y1": 0, "x2": right_margin - (price_x - 10 + right_margin) / 2, "y2": 0, "stroke": "rgba(255, 255, 255, 0.3)", "strokeWidth": 2, "name": "price_underline", "data": {"type": "decoration", "layer": "content", "level": 2}, "selectable": False, "evented": False } objects.append(underline) # 票种(使用VibrantTemplate的精确参数) if ticket_type := content.get("ticket_type"): ticket_y = y + price_size + int(35 * scale_ratio) # VibrantTemplate参数:目标宽度为栏宽的70%,字体范围30-60 ticket_target_width = int(column_width * 0.7) ticket_size, ticket_actual_width = self._calculate_vibrant_font_size_precise( ticket_type, ticket_target_width, min_size=30, max_size=60 ) ticket_x = right_margin - ticket_actual_width ticket_obj = { "type": "textbox", "version": "5.3.0", "originX": "left", "originY": "top", "left": ticket_x, "top": ticket_y, "width": ticket_actual_width, "height": ticket_size + 10, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.5) 1px 1px 3px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": ticket_size, "text": ticket_type, "textAlign": "left", "name": "ticket_type", "data": { "type": "ticket_type", "layer": "content", "level": 2, "target_width": ticket_target_width, "actual_width": ticket_actual_width }, "selectable": True, "evented": True } objects.append(ticket_obj) return objects def _estimate_vibrant_content_height(self, content: Dict[str, Any]) -> int: """复制VibrantTemplate的内容高度估算逻辑""" standard_margin = 25 title_height = 100 subtitle_height = 80 button_height = 40 content_items = content.get("content_items", []) content_line_height = 32 content_list_height = len(content_items) * content_line_height price_height = 90 ticket_height = 60 remarks = content.get("remarks", []) if isinstance(remarks, str): remarks = [remarks] remarks_height = len(remarks) * 25 + 10 footer_height = 40 total_height = ( 20 + title_height + standard_margin + subtitle_height + standard_margin + button_height + 15 + content_list_height + price_height + ticket_height + remarks_height + footer_height + 30 ) logger.info(f"VibrantTemplate估算内容高度: {total_height}") return total_height def _detect_vibrant_gradient_start_position(self, image: Image.Image, estimated_height: int, base_height: int) -> int: """复制VibrantTemplate的动态渐变起始位置检测""" if not image or not hasattr(image, 'width'): # 如果没有图像,使用估算位置 bottom_margin = 60 gradient_start = max(base_height - estimated_height - bottom_margin, base_height // 2) logger.info(f"无图像,使用估算渐变起始位置: {gradient_start}") return gradient_start # 临时缩放图像到基础尺寸进行分析 temp_image = image.resize((900, 1200), Image.LANCZOS) if temp_image.mode != 'RGB': temp_image = temp_image.convert('RGB') width, height = temp_image.size center_x = width // 2 gradient_start = None # 从中央开始扫描,寻找亮度>50的像素 for y in range(height // 2, height): try: pixel = temp_image.getpixel((center_x, y)) if isinstance(pixel, (tuple, list)) and len(pixel) >= 3: brightness = sum(pixel[:3]) / 3 if brightness > 50: gradient_start = max(y - 20, height // 2) logger.info(f"检测到亮度>50的像素位置: y={y}, brightness={brightness:.1f}") break except: continue # 如果没有找到合适位置,使用估算位置 if gradient_start is None: bottom_margin = 60 gradient_start = max(height - estimated_height - bottom_margin, height // 2) logger.info(f"未找到合适像素,使用估算渐变起始位置: {gradient_start}") else: logger.info(f"动态检测到渐变起始位置: {gradient_start}") return gradient_start 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'): # 默认蓝色毛玻璃效果 default_colors = { "top_color": (0, 5, 15), "bottom_color": (0, 25, 50) } logger.info(f"无图像,使用默认毛玻璃颜色: {default_colors}") return default_colors # 临时缩放图像到基础尺寸进行颜色提取 temp_image = image.resize((900, 1200), Image.LANCZOS) if temp_image.mode != 'RGB': temp_image = temp_image.convert('RGB') width, height = temp_image.size top_samples, bottom_samples = [], [] # 在渐变起始位置+20px采样顶部颜色 top_y = min(gradient_start + 20, height - 1) for x in range(0, width, 20): try: pixel = temp_image.getpixel((x, top_y)) if sum(pixel) > 30: # 排除过暗像素 top_samples.append(pixel) except: continue # 在底部-50px采样底部颜色 bottom_y = min(height - 50, height - 1) for x in range(0, width, 20): try: pixel = temp_image.getpixel((x, bottom_y)) if sum(pixel) > 30: # 排除过暗像素 bottom_samples.append(pixel) except: continue # 计算平均颜色并降低亮度(复制VibrantTemplate逻辑) import numpy as np if top_samples: top_avg = np.mean(top_samples, axis=0) top_color = tuple(max(0, int(c * 0.1)) for c in top_avg) # 10%亮度 else: top_color = (0, 5, 15) if bottom_samples: bottom_avg = np.mean(bottom_samples, axis=0) bottom_color = tuple(max(0, int(c * 0.2)) for c in bottom_avg) # 20%亮度 else: bottom_color = (0, 25, 50) colors = { "top_color": top_color, "bottom_color": bottom_color } logger.info(f"提取毛玻璃颜色: 顶部={top_color}({len(top_samples)}样本), 底部={bottom_color}({len(bottom_samples)}样本)") return colors 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) top_color = glass_colors["top_color"] bottom_color = glass_colors["bottom_color"] # 创建复杂的渐变效果,模拟VibrantTemplate的数学公式 gradient_stops = [] # 生成多个渐变停止点以模拟复杂的数学渐变 import math for i in range(10): ratio = i / 9 # 0到1 # 复制VibrantTemplate的smooth_ratio公式 smooth_ratio = 0.5 - 0.5 * math.cos(ratio * math.pi) # 插值颜色 r = int((1 - smooth_ratio) * top_color[0] + smooth_ratio * bottom_color[0]) g = int((1 - smooth_ratio) * top_color[1] + smooth_ratio * bottom_color[1]) b = int((1 - smooth_ratio) * top_color[2] + smooth_ratio * bottom_color[2]) # 复制VibrantTemplate的透明度计算 alpha_smooth = ratio ** (1.1 / 1.5) # intensity=1.5 alpha = 0.02 + 0.98 * alpha_smooth gradient_stops.append({ "offset": ratio, "color": f"rgba({r}, {g}, {b}, {alpha:.3f})" }) logger.info(f"创建毛玻璃效果: 起始位置={scaled_gradient_start}, 渐变点={len(gradient_stops)}") return { "type": "rect", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": scaled_gradient_start, "width": canvas_width, "height": canvas_height - scaled_gradient_start, "fill": { "type": "linear", "coords": { "x1": 0, "y1": 0, "x2": 0, "y2": canvas_height - scaled_gradient_start }, "colorStops": gradient_stops }, "stroke": "", "strokeWidth": 0, "angle": 0, "flipX": False, "flipY": False, "opacity": 0.85, "visible": True, "name": "vibrant_glass_effect", "data": { "type": "glass_effect", "layer": "background", "level": 1, "effect": "vibrant_template_precise" }, "selectable": False, "evented": False } def _create_vibrant_image_object_precise(self, images: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]: """创建VibrantTemplate精确的图像对象""" # 将PIL图像转换为base64 image_base64 = self._image_to_base64(images) # VibrantTemplate直接resize到画布大小 return { "type": "image", "version": "5.3.0", "originX": "left", "originY": "top", "left": 0, "top": 0, "width": images.width, "height": images.height, "scaleX": canvas_width / images.width, "scaleY": canvas_height / images.height, "angle": 0, "flipX": False, "flipY": False, "opacity": 1, "visible": True, "src": f"data:image/png;base64,{image_base64}", "crossOrigin": "anonymous", "name": "vibrant_background_image", "data": { "type": "background_image", "layer": "image", "level": 0, "replaceable": True }, "selectable": True, "evented": True } 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的精确文本布局逻辑""" text_objects = [] # 计算基础参数(缩放到最终尺寸) 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: gradient_start + 40) title_y = gradient_start + int(40 * scale_factor) if title := content.get("title"): title_size = int(80 * scale_factor) title_obj = { "type": "textbox", "version": "5.3.0", "originX": "center", "originY": "top", "left": center_x, "top": title_y, "width": right_margin - left_margin, "height": title_size + 20, "fill": "#ffffff", "stroke": "#001e50", "strokeWidth": 4, "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial Black, sans-serif", "fontWeight": "bold", "fontSize": title_size, "text": title, "textAlign": "center", "lineHeight": 1.1, "name": "vibrant_title_precise", "data": {"type": "title", "layer": "content", "level": 2}, "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) subtitle_obj = { "type": "textbox", "version": "5.3.0", "originX": "center", "originY": "top", "left": center_x, "top": subtitle_y, "width": right_margin - left_margin, "height": subtitle_size + 15, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", "fontWeight": "normal", "fontSize": subtitle_size, "text": slogan, "textAlign": "center", "lineHeight": 1.2, "name": "vibrant_slogan_precise", "data": {"type": "slogan", "layer": "content", "level": 2}, "selectable": True, "evented": True } text_objects.append(subtitle_obj) logger.info(f"创建VibrantTemplate精确文本布局: {len(text_objects)}个对象") return text_objects def _resize_and_crop_image(self, image: Image.Image, target_width: int, target_height: int) -> Image.Image: """ 智能调整图片尺寸到目标比例,保持最佳清晰度 Args: image: 原始PIL图像 target_width: 目标宽度(1350) target_height: 目标高度(1800) Returns: 调整后的PIL图像 """ original_width, original_height = image.size target_ratio = target_width / target_height # 1350/1800 = 0.75 original_ratio = original_width / original_height logger.info(f"📐 图片尺寸处理 - 原始: {original_width}x{original_height} (比例: {original_ratio:.3f}), 目标: {target_width}x{target_height} (比例: {target_ratio:.3f})") # 如果尺寸已经匹配,直接返回 if original_width == target_width and original_height == target_height: logger.info("✅ 图片尺寸已经匹配目标尺寸,无需调整") return image # 计算缩放策略 if abs(original_ratio - target_ratio) < 0.01: # 比例接近,直接缩放 logger.info("📏 图片比例接近目标比例,直接缩放") resized_image = image.resize((target_width, target_height), Image.LANCZOS) logger.info(f"✅ 直接缩放完成: {resized_image.size}") return resized_image # 需要裁剪的情况 if original_ratio > target_ratio: # 原图更宽,以高度为准进行缩放 scale_factor = target_height / original_height new_width = int(original_width * scale_factor) new_height = target_height logger.info(f"🔧 原图偏宽,以高度为准缩放: {scale_factor:.3f}x -> {new_width}x{new_height}") # 等比例缩放 scaled_image = image.resize((new_width, new_height), Image.LANCZOS) # 水平居中裁剪 crop_x = (new_width - target_width) // 2 crop_box = (crop_x, 0, crop_x + target_width, target_height) cropped_image = scaled_image.crop(crop_box) logger.info(f"✂️ 水平居中裁剪: 从({crop_x}, 0)到({crop_x + target_width}, {target_height})") else: # 原图更高,以宽度为准进行缩放 scale_factor = target_width / original_width new_width = target_width new_height = int(original_height * scale_factor) logger.info(f"🔧 原图偏高,以宽度为准缩放: {scale_factor:.3f}x -> {new_width}x{new_height}") # 等比例缩放 scaled_image = image.resize((new_width, new_height), Image.LANCZOS) # 垂直居中裁剪 crop_y = (new_height - target_height) // 2 crop_box = (0, crop_y, target_width, crop_y + target_height) cropped_image = scaled_image.crop(crop_box) logger.info(f"✂️ 垂直居中裁剪: 从(0, {crop_y})到({target_width}, {crop_y + target_height})") # 确保最终尺寸正确 final_width, final_height = cropped_image.size if final_width != target_width or final_height != target_height: logger.warning(f"⚠️ 裁剪后尺寸不匹配,强制调整: {final_width}x{final_height} -> {target_width}x{target_height}") 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