2129 lines
90 KiB
Python
2129 lines
90 KiB
Python
#!/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 |