diff --git a/api/services/poster.py b/api/services/poster.py index 42bd67e..acbc43c 100644 --- a/api/services/poster.py +++ b/api/services/poster.py @@ -15,7 +15,7 @@ import importlib import base64 import binascii from io import BytesIO -from typing import List, Dict, Any, Optional, Type, Union, cast +from typing import List, Dict, Any, Optional, Type, Union, cast, Tuple from datetime import datetime from pathlib import Path from PIL import Image @@ -1069,29 +1069,37 @@ class PosterService: } 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风格的文字对象(复用VibrantTemplate的精确计算)""" text_objects = [] - # 计算内容区域边距(模拟VibrantTemplate的计算) - content_margin = max(40, int(canvas_width * 0.1)) - content_width = canvas_width - 2 * content_margin + # 复用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"): - title_size = self._calculate_vibrant_font_size(title, content_width * 0.95, 40, 140, scale_ratio) + # 使用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": "center", + "originX": "left", # 改为left,使用计算的x位置 "originY": "top", - "left": canvas_width / 2, + "left": title_x, "top": title_y, - "width": content_width, + "width": title_actual_width, "height": title_size + 20, "fill": "#ffffff", - "stroke": "#001e50", - "strokeWidth": 2, + "stroke": "#001e50", + "strokeWidth": 4, # 与VibrantTemplate一致的描边宽度 "fontFamily": "Arial Black, sans-serif", "fontWeight": "bold", "fontSize": title_size, @@ -1103,25 +1111,64 @@ class PosterService: "type": "title", "layer": "content", "level": 2, - "style": "vibrant_title" + "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"): - subtitle_size = self._calculate_vibrant_font_size(slogan, content_width * 0.9, 20, 75, scale_ratio) + # 使用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": "center", + "originX": "left", # 改为left,使用计算的x位置 "originY": "top", - "left": canvas_width / 2, + "left": subtitle_x, "top": subtitle_y, - "width": content_width, + "width": subtitle_actual_width, "height": subtitle_size + 15, "fill": "#ffffff", "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", @@ -1136,28 +1183,30 @@ class PosterService: "type": "slogan", "layer": "content", "level": 2, - "style": "vibrant_subtitle" + "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 = content_margin + left_column_width + right_column_x = left_margin + left_column_width # 左栏:内容按钮和项目列表 - left_objects = self._create_left_column_objects(content, content_margin, column_start_y, left_column_width, scale_ratio) + left_objects = self._create_left_column_objects(content, left_margin, column_start_y, left_column_width, scale_ratio) text_objects.extend(left_objects) - # 右栏:价格和票种信息 - right_objects = self._create_right_column_objects(content, right_column_x, column_start_y, content_margin + content_width, scale_ratio) + # 右栏:价格和票种信息(使用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, content_margin, canvas_height - int(30 * scale_ratio), content_width, scale_ratio) + 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 @@ -1397,4 +1446,242 @@ class PosterService: } 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 \ No newline at end of file