#!/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