1687 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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, Tuple
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 images_base64.strip():
try:
# 移除可能存在的MIME类型前缀
if images_base64.startswith("data:"):
images_base64 = images_base64.split(",", 1)[1]
# 彻底清理base64字符串 - 移除所有空白字符
images_base64 = ''.join(images_base64.split())
# 验证base64字符串长度应该是4的倍数
if len(images_base64) % 4 != 0:
# 添加必要的填充
images_base64 += '=' * (4 - len(images_base64) % 4)
logger.info(f"为base64字符串添加了填充最终长度: {len(images_base64)}")
logger.info(f"准备解码base64数据长度: {len(images_base64)}, 前20字符: {images_base64[:20]}...")
# 解码base64
image_bytes = base64.b64decode(images_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}")
except binascii.Error as e:
logger.error(f"Base64解码失败: {e}")
logger.error(f"问题数据长度: {len(images_base64) if 'images_base64' in locals() else 'unknown'}")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_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()}")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")
else:
logger.warning("未提供图片数据,使用默认透明背景图")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_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: 扁平化的Fabric.js JSON格式数据
"""
try:
fabric_objects = []
# 基础画布尺寸VibrantTemplate使用900x1200最终resize到1350x1800
canvas_width, canvas_height = image_size[0], image_size[1]
# 计算缩放比例以匹配VibrantTemplate的最终输出
scale_ratio = min(canvas_width / 900, canvas_height / 1200)
# 1. 用户上传的图片(最底层 - Level 0
if images and hasattr(images, 'width'):
image_object = self._create_vibrant_image_object(images, canvas_width, canvas_height)
fabric_objects.append(image_object)
else:
# 占位符
placeholder_object = self._create_placeholder_object(canvas_width, canvas_height)
fabric_objects.append(placeholder_object)
# 2. 毛玻璃渐变背景Level 1
gradient_start = self._calculate_gradient_start_position(canvas_height)
gradient_object = self._create_gradient_background_object(canvas_width, canvas_height, gradient_start)
fabric_objects.append(gradient_object)
# 3. VibrantTemplate风格的文字布局Level 2
text_objects = self._create_vibrant_text_objects(content, canvas_width, canvas_height, gradient_start, scale_ratio)
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": canvas_width,
"height": canvas_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
}
logger.info(f"成功生成扁平化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": "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]:
"""创建模拟毛玻璃效果的渐变背景(简化版本,提高兼容性)"""
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": "rgba(0, 50, 120, 0.6)", # 简化为单一颜色,提高兼容性
"stroke": "",
"strokeWidth": 0,
"angle": 0,
"flipX": False,
"flipY": False,
"opacity": 0.8,
"visible": True,
"name": "glass_gradient",
"data": {
"type": "glass_effect",
"layer": "background",
"level": 1
},
"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": "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
},
"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": "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
},
"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": "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": "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": "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": "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": "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": "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
},
"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": "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": "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