使的目前图像的生成一致了

This commit is contained in:
jinye_huang 2025-08-04 16:04:12 +08:00
parent 3392707cef
commit 6704dd58e7
8 changed files with 1961 additions and 116 deletions

View File

@ -79,6 +79,8 @@ class PosterGenerateResponse(BaseModel):
templateId: str
resultImagesBase64: List[Dict[str, Any]] = Field(description="生成的海报图像(base64编码)列表")
psdFiles: Optional[List[Dict[str, Any]]] = Field(None, description="生成的PSD文件信息列表")
fabricJsons: Optional[List[Dict[str, Any]]] = Field(None, description="生成的Fabric.js JSON列表")
decorativeImages: Optional[Dict[str, Any]] = Field(None, description="装饰性图像base64数据供Java端上传到S3")
metadata: Dict[str, Any] = Field(default_factory=dict)
class Config:

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
"topic_index": 0,
"variant_index": 0,
"template": "vibrant",
"size": [900, 1200],
"size": [1350, 1800],
"generate_text": true,
"text_generation_params": {
"user_prompt_path": "resource/prompt/poster/vibrant_user.txt",

View File

@ -0,0 +1,307 @@
# 后端Fabric.js JSON格式规范
## 标准格式要求
### 1. 根对象结构
```json
{
"version": "5.3.0",
"width": 1350,
"height": 1800,
"objects": [
// 对象数组
]
}
```
### 2. 对象类型要求只使用标准Fabric.js类型
#### ✅ 允许的标准类型:
- `rect` - 矩形
- `circle` - 圆形
- `textbox` - 文本框
- `text` - 文本
- `image` - 图片
- `line` - 线条
- `polygon` - 多边形
- `path` - 路径
- `group` - 组
#### ❌ 禁止的自定义类型:
- `CustomRect` -> 应改为 `rect`
- `CustomTextbox` -> 应改为 `textbox`
- `CustomCircle` -> 应改为 `circle`
- `CustomGroup` -> 应改为 `group`
- `ThinTailArrow` -> 应改为 `path``rect`
- `Arrow` -> 应改为 `path``rect`
### 3. 基本对象属性要求
#### 所有对象必须包含:
```json
{
"type": "rect", // 标准类型
"left": 100, // 数值非null
"top": 50, // 数值非null
"width": 200, // 数值 > 0
"height": 100, // 数值 > 0
"fill": "#ffffff",
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
```
#### 文本对象额外属性:
```json
{
"type": "textbox",
"text": "实际文本内容", // 非空字符串
"fontSize": 16, // 数值 > 0
"fontFamily": "Arial", // 有效字体名
"fill": "#000000"
}
```
#### 图片对象要求:
```json
{
"type": "image",
"src": "https://example.com/image.jpg", // 必须是完整有效的URL
"width": 300, // 实际图片宽度
"height": 200 // 实际图片高度
}
```
**重要图片src必须是可访问的完整URL不能是**
- 相对路径如 `preview1.jpg`
- 本地路径如 `./images/test.png`
- 占位符如 `image1.jpg`
### 4. 组对象格式:
```json
{
"type": "group",
"objects": [
// 组内的标准对象
]
}
```
### 5. 完整示例:
```json
{
"version": "5.3.0",
"width": 1350,
"height": 1800,
"objects": [
{
"type": "rect",
"left": 100,
"top": 100,
"width": 200,
"height": 150,
"fill": "#ff0000",
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
},
{
"type": "textbox",
"left": 50,
"top": 300,
"width": 300,
"height": 50,
"text": "这是文本内容",
"fontSize": 24,
"fontFamily": "Arial",
"fill": "#000000",
"opacity": 1
},
{
"type": "image",
"left": 400,
"top": 100,
"width": 200,
"height": 200,
"src": "https://example.com/path/to/image.jpg",
"opacity": 1
}
]
}
```
## 复杂装饰元素处理策略
### 问题:复杂的装饰性元素(如按钮、装饰图案)
- **旧方案**使用多个形状组合rect + circle + path等
- **新方案**:将复杂元素预渲染为图像
### 实现方式:
1. **后端预渲染**将复杂的按钮样式、装饰图案等渲染为PNG图像
2. **托管图像**将这些装饰图像托管在可访问的URL上
3. **JSON引用**在fabric.js JSON中使用标准`image`对象引用
### 示例:按钮元素
```json
// ❌ 旧方式:复杂形状组合
{
"type": "group",
"objects": [
{"type": "rect", "fill": "linear-gradient(...)", ...},
{"type": "circle", "fill": "rgba(...)", ...},
{"type": "path", "path": "M10,10 L20,20...", ...}
]
}
// ✅ 新方式:预渲染图像
{
"type": "image",
"left": 100,
"top": 200,
"width": 120,
"height": 40,
"src": "https://your-domain.com/api/decorative/button_style_1.png",
"opacity": 1
}
```
## 关键要求总结:
1. **只使用Fabric.js标准对象类型**
2. **所有数值属性必须有效非null、非0宽高**
3. **图片src必须是完整可访问的URL**
4. **包含完整的width/height信息**
5. **遵循标准JSON结构**
6. **复杂装饰元素使用预渲染图像**
这样前端就可以直接使用 `canvas.loadFromJSON()` 而无需任何预处理!
## 装饰性图像处理流程
### 完整的端到端流程:
1. **Python端生成装饰图像**
- 使用PIL动态生成按钮、标签、价格背景等装饰元素
- 返回base64编码的PNG图像数据
- 在fabric.js JSON中使用占位符URLhttps://placeholder.qiniu.com/decorative/
2. **Java端处理装饰图像**
- 接收装饰图像的base64数据
- 上传每个装饰图像到七牛云存储
- 获取七牛云的真实URL
- 替换fabric.js JSON中的占位符URL为真实URL
3. **前端使用**
- 接收更新后的fabric.js JSON包含真实的图像URL
- 直接使用`canvas.loadFromJSON()`加载完整设计
### API响应结构
```json
{
"requestId": "poster-20250101-120000-abc123",
"templateId": "vibrant",
"resultImagesBase64": [...],
"fabricJsons": [
{
"id": "fabric_json_0",
"data": "base64_encoded_json...",
"jsonData": {
"version": "5.3.0",
"width": 1350,
"height": 1800,
"objects": [
{
"type": "image",
"src": "https://qiniu-domain.com/poster/decorative/2025/01/01/button_1234567890.png",
"left": 100,
"top": 200,
"width": 200,
"height": 60
}
]
}
}
],
"decorativeImages": {
"button": {
"originalId": "button",
"type": "button",
"qiniuUrl": "https://qiniu-domain.com/poster/decorative/2025/01/01/button_1234567890.png",
"uploadSuccess": true
}
}
}
```
## 实现状态
### ✅ 已完成:
- 标准fabric.js JSON格式输出
- 简化的文本布局只使用textbox/text
- 装饰性图像生成器(按钮、标签、价格背景)
- 装饰图像上传到七牛云的完整流程
- fabric.js JSON中占位符URL自动替换
- 统一1350x1800基础尺寸
- 端到端的装饰图像处理工作流
## 统一生成架构(重大改进)
### 问题解决:
**原问题**fabric.js JSON和PNG分开生成导致参数不一致、位置偏差等问题。
**解决方案**VibrantTemplate统一生成模式
- PNG和Fabric.js JSON在同一次render调用中生成
- 使用完全相同的参数、计算逻辑、渲染上下文
- 消除任何可能的差异来源
### 技术实现:
1. **VibrantTemplate.generate()扩展**
```python
# 新增参数
generation_result = template.generate(
content=content,
images=images,
generate_fabric_json=True # 启用统一生成
)
# 返回结构
{
'png': PIL.Image,
'fabric_json': Dict,
'metadata': {
'gradient_start': int,
'theme_color': str,
'elements_count': int
}
}
```
2. **渲染上下文共享**
- 图像处理参数
- 渐变起始位置
- 颜色提取结果
- 文本布局计算
- 所有几何计算
3. **向后兼容性**
- 自动检测模板是否支持新参数
- 无缝回退到独立生成模式
- 不影响现有的模板实现
### ✅ 已完成:
- 标准fabric.js JSON格式输出
- 简化的文本布局只使用textbox/text
- 装饰性图像生成器(按钮、标签、价格背景)
- 装饰图像上传到七牛云的完整流程
- fabric.js JSON中占位符URL自动替换
- 统一1350x1800基础尺寸
- 端到端的装饰图像处理工作流
- **VibrantTemplate统一生成架构**
- **PNG和JSON完全一致的渲染逻辑**
### 🔄 进行中:
- 测试统一生成模式的一致性验证

View File

@ -4,10 +4,9 @@
"""
Vibrant风格活力风格海报模板
"""
from ast import List
import logging
import math
from typing import Dict, Any, Optional, Tuple
from typing import Dict, Any, Optional, Tuple, List
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
@ -69,23 +68,70 @@ class VibrantTemplate(BaseTemplate):
theme_color: Optional[str] = None,
glass_intensity: float = 1.5,
num_variations: int = 1,
**kwargs) -> Image.Image:
**kwargs):
"""
生成Vibrant风格海报
生成Vibrant风格海报支持统一渲染
Args:
images (List): 主图
content (Optional[Dict[str, Any]]): 包含所有文本信息的字典
theme_color (Optional[str]): 预设颜色主题的名称
glass_intensity (float): 毛玻璃效果强度
num_variations (int): 生成海报数量
**kwargs: 其他参数包含generate_fabric_json等
Returns:
Image.Image: 生成的海报图像
根据参数返回不同格式
- 如果generate_fabric_json=True返回包含image和fabric_json的字典
- 否则返回Image.Image对象
"""
if content is None:
content = self._get_default_content()
# 检查是否需要同时生成JSON
generate_fabric_json = kwargs.get('generate_fabric_json', False)
if generate_fabric_json:
# 使用统一渲染方法同时生成PNG和JSON
logger.info("🔄 使用统一渲染方法同时生成PNG和JSON")
# PNG渲染
png_result = self._unified_render(
images=images,
content=content,
theme_color=theme_color,
glass_intensity=glass_intensity,
output_format='png'
)
# JSON渲染
json_result = self._unified_render(
images=images,
content=content,
theme_color=theme_color,
glass_intensity=glass_intensity,
output_format='json'
)
if "error" in png_result or "error" in json_result:
logger.error("统一渲染失败,回退到传统方法")
return self._generate_legacy(images, content, theme_color, glass_intensity)
return {
"image": png_result["image"],
"fabric_json": json_result["fabric_json"],
"generation_metadata": {
"gradient_start": png_result["layout_params"]["gradient_start"],
"layout_params": png_result["layout_params"],
"unified_render": True
}
}
else:
# 传统模式只生成PNG
return self._generate_legacy(images, content, theme_color, glass_intensity)
def _generate_legacy(self, images, content: Dict[str, Any], theme_color: Optional[str], glass_intensity: float) -> Image.Image:
"""传统的PNG生成方法保持向后兼容"""
self.config['glass_effect']['intensity_multiplier'] = glass_intensity
main_image = images
@ -1120,4 +1166,591 @@ class VibrantTemplate(BaseTemplate):
logger.error(f"创建页脚层失败: {e}")
import traceback
traceback.print_exc()
return None
return None
def _unified_render(self, images, content: Optional[Dict[str, Any]] = None,
theme_color: Optional[str] = None, glass_intensity: float = 1.5,
output_format: str = 'png', **kwargs) -> Dict[str, Any]:
"""
统一的渲染方法PNG和JSON使用完全相同的布局计算
Args:
images: 主图
content: 包含所有文本信息的字典
theme_color: 预设颜色主题的名称
glass_intensity: 毛玻璃效果强度
output_format: 输出格式 'png' 'json'
Returns:
Dict[str, Any]: 包含渲染结果和布局信息
"""
if content is None:
content = self._get_default_content()
self.config['glass_effect']['intensity_multiplier'] = glass_intensity
main_image = images
if not main_image:
logger.error("无法加载图片")
return {"error": "无法加载图片"}
# === 第一步:统一的预处理 ===
main_image = self.image_processor.resize_image(image=main_image, target_size=self.size)
estimated_height = self._estimate_content_height(content)
gradient_start = self._detect_gradient_start_position(main_image, estimated_height)
# === 第二步:统一的布局计算 ===
layout_params = self._calculate_unified_layout(content, self.size, gradient_start)
# === 第三步:根据输出格式生成结果 ===
if output_format == 'png':
return self._render_to_png(main_image, content, theme_color, gradient_start, layout_params)
elif output_format == 'json':
return self._render_to_json(main_image, content, theme_color, gradient_start, layout_params)
else:
raise ValueError(f"不支持的输出格式: {output_format}")
def _calculate_unified_layout(self, content: Dict[str, Any], canvas_size: Tuple[int, int],
gradient_start: int) -> Dict[str, Any]:
"""
统一的布局计算方法PNG和JSON使用相同的逻辑
Returns:
Dict: 包含所有布局参数的字典
"""
width, height = canvas_size
center_x = width // 2
# 使用PNG渲染相同的边距计算
left_margin, right_margin = self._calculate_content_margins(content, width, center_x)
# 标题布局计算
title_text = content.get("title", "")
title_target_width = int((right_margin - left_margin) * 0.98)
title_size, title_actual_width = self._calculate_optimal_font_size_enhanced(
title_text, title_target_width, max_size=140, min_size=40
)
title_x = center_x - title_actual_width // 2
title_y = gradient_start + 40
# 副标题布局计算
subtitle_text = content.get("slogan", "")
subtitle_target_width = int((right_margin - left_margin) * 0.95)
subtitle_size, subtitle_actual_width = self._calculate_optimal_font_size_enhanced(
subtitle_text, subtitle_target_width, max_size=75, min_size=20
)
subtitle_x = center_x - subtitle_actual_width // 2
subtitle_y = title_y + 100 + 30 # 标题高度 + 间距
# 内容区域布局
content_area_width = right_margin - left_margin
left_column_width = int(content_area_width * 0.5)
right_column_x = left_margin + left_column_width
content_start_y = subtitle_y + 80 + 30 # 副标题高度 + 间距
# 价格布局计算
price_text = str(content.get('price', ''))
price_target_width = int((right_margin - right_column_x) * 0.7)
price_size, price_actual_width = self._calculate_optimal_font_size_enhanced(
price_text, price_target_width, max_size=120, min_size=40
)
# 票种布局计算
ticket_text = content.get("ticket_type", "")
ticket_target_width = int((right_margin - right_column_x) * 0.7)
ticket_size, ticket_actual_width = self._calculate_optimal_font_size_enhanced(
ticket_text, ticket_target_width, max_size=60, min_size=30
)
layout_params = {
# 基础参数
"width": width,
"height": height,
"center_x": center_x,
"gradient_start": gradient_start,
# 边距
"left_margin": left_margin,
"right_margin": right_margin,
# 标题
"title_text": title_text,
"title_size": title_size,
"title_width": title_actual_width,
"title_x": title_x,
"title_y": title_y,
# 副标题
"subtitle_text": subtitle_text,
"subtitle_size": subtitle_size,
"subtitle_width": subtitle_actual_width,
"subtitle_x": subtitle_x,
"subtitle_y": subtitle_y,
# 内容区域
"content_start_y": content_start_y,
"left_column_width": left_column_width,
"right_column_x": right_column_x,
# 价格
"price_text": price_text,
"price_size": price_size,
"price_width": price_actual_width,
# 票种
"ticket_text": ticket_text,
"ticket_size": ticket_size,
"ticket_width": ticket_actual_width,
# 页脚
"footer_y": height - 30
}
logger.info(f"统一布局计算完成,标题字体大小: {title_size}, 副标题字体大小: {subtitle_size}, 价格字体大小: {price_size}")
return layout_params
def _render_to_png(self, main_image: Image.Image, content: Dict[str, Any],
theme_color: Optional[str], gradient_start: int,
layout_params: Dict[str, Any]) -> Dict[str, Any]:
"""使用统一布局参数渲染PNG"""
canvas = self._create_composite_image(main_image, gradient_start, theme_color)
canvas = self._render_texts(canvas, content, gradient_start)
final_image = canvas.resize((1350, 1800), Image.LANCZOS)
return {
"image": final_image,
"layout_params": layout_params,
"format": "png"
}
def _render_to_json(self, main_image: Image.Image, content: Dict[str, Any],
theme_color: Optional[str], gradient_start: int,
layout_params: Dict[str, Any]) -> Dict[str, Any]:
"""使用统一布局参数渲染JSON"""
final_width, final_height = 1350, 1800
fabric_objects = []
# 1. 背景图片
if main_image and hasattr(main_image, 'width'):
image_object = self._create_precise_image_object(main_image, final_width, final_height)
fabric_objects.append(image_object)
# 2. 毛玻璃效果
glass_overlay = self._create_precise_glass_overlay(main_image, gradient_start,
theme_color, final_width, final_height)
if glass_overlay:
fabric_objects.append(glass_overlay)
# 3. 使用统一布局参数的文本元素
text_objects = self._create_precise_text_objects(content, layout_params, final_width, final_height)
fabric_objects.extend(text_objects)
# 构建Fabric.js JSON
fabric_json = {
"version": "5.3.0",
"width": final_width,
"height": final_height,
"objects": fabric_objects
}
return {
"fabric_json": fabric_json,
"layout_params": layout_params,
"format": "json"
}
def _create_precise_image_object(self, main_image: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]:
"""创建精确的背景图片对象"""
import base64
import io
# 调整图片尺寸以匹配PNG渲染
resized_image = self.image_processor.resize_image(image=main_image, target_size=(canvas_width, canvas_height))
# 转换为base64
buffer = io.BytesIO()
resized_image.save(buffer, format='PNG')
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return {
"type": "image",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": 0,
"width": canvas_width,
"height": canvas_height,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"opacity": 1,
"src": f"data:image/png;base64,{image_base64}",
"filters": [],
"selectable": False,
"evented": False
}
def _create_precise_glass_overlay(self, main_image: Image.Image, gradient_start: int,
theme_color: Optional[str], canvas_width: int, canvas_height: int) -> Optional[Dict[str, Any]]:
"""创建精确的毛玻璃效果对象"""
try:
import base64
import io
# 使用与PNG相同的颜色提取逻辑
if theme_color and theme_color in self.config['colors']:
top_color, bottom_color = self.config['colors'][theme_color]
else:
top_color, bottom_color = self._extract_glass_colors_from_image(main_image, gradient_start)
# 创建毛玻璃效果图像
overlay_canvas = Image.new('RGBA', (canvas_width, canvas_height), (0, 0, 0, 0))
glass_overlay = self._create_frosted_glass_overlay(top_color, bottom_color, gradient_start)
# 缩放到最终尺寸
glass_scaled = glass_overlay.resize((canvas_width, canvas_height), Image.LANCZOS)
# 转换为base64
buffer = io.BytesIO()
glass_scaled.save(buffer, format='PNG')
glass_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return {
"type": "image",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": 0,
"width": canvas_width,
"height": canvas_height,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"opacity": 1,
"src": f"data:image/png;base64,{glass_base64}",
"filters": [],
"selectable": False,
"evented": False
}
except Exception as e:
logger.error(f"创建精确毛玻璃效果失败: {e}")
return None
def _create_precise_text_objects(self, content: Dict[str, Any], layout_params: Dict[str, Any],
canvas_width: int, canvas_height: int) -> List[Dict[str, Any]]:
"""使用精确布局参数创建文本对象"""
text_objects = []
try:
# 1. 主标题 - 使用计算出的精确位置和字体大小
if layout_params["title_text"]:
title_obj = {
"type": "textbox",
"left": layout_params["title_x"],
"top": layout_params["title_y"],
"width": layout_params["title_width"],
"height": 100,
"text": layout_params["title_text"],
"fontSize": layout_params["title_size"],
"fontFamily": "Arial Black",
"fontWeight": "bold",
"fill": "white",
"textAlign": "center",
"lineHeight": 1.2,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"stroke": "rgba(0, 30, 80, 0.8)",
"strokeWidth": 3,
"paintFirst": "stroke"
}
text_objects.append(title_obj)
# 2. 副标题 - 使用计算出的精确位置和字体大小
if layout_params["subtitle_text"]:
subtitle_obj = {
"type": "textbox",
"left": layout_params["subtitle_x"],
"top": layout_params["subtitle_y"],
"width": layout_params["subtitle_width"],
"height": 80,
"text": layout_params["subtitle_text"],
"fontSize": layout_params["subtitle_size"],
"fontFamily": "Arial",
"fill": "white",
"textAlign": "center",
"lineHeight": 1.3,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"shadow": {
"color": "rgba(0, 0, 0, 0.7)",
"blur": 2,
"offsetX": 2,
"offsetY": 2
}
}
text_objects.append(subtitle_obj)
# 3. 装饰线
line_y = layout_params["title_y"] + 100 + 5
line_start_x = layout_params["title_x"] - layout_params["title_width"] * 0.025
line_end_x = layout_params["title_x"] + layout_params["title_width"] * 1.025
line_obj = {
"type": "line",
"left": line_start_x,
"top": line_y,
"x1": 0,
"y1": 0,
"x2": line_end_x - line_start_x,
"y2": 0,
"stroke": "rgba(215, 215, 215, 0.3)",
"strokeWidth": 3,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"selectable": False,
"evented": False
}
text_objects.append(line_obj)
# 4. 左栏按钮
button_text = content.get("content_button", "套餐内容")
button_obj = {
"type": "rect",
"left": layout_params["left_margin"],
"top": layout_params["content_start_y"],
"width": 200,
"height": 50,
"fill": "rgba(0, 140, 210, 0.7)",
"stroke": "white",
"strokeWidth": 1,
"rx": 20,
"ry": 20,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"selectable": False,
"evented": False
}
text_objects.append(button_obj)
button_text_obj = {
"type": "textbox",
"left": layout_params["left_margin"] + 20,
"top": layout_params["content_start_y"] + 10,
"width": 160,
"height": 30,
"text": button_text,
"fontSize": 30,
"fontFamily": "Arial",
"fontWeight": "bold",
"fill": "white",
"textAlign": "center",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
text_objects.append(button_text_obj)
# 5. 左栏内容列表
content_items = content.get("content_items", [])
list_y = layout_params["content_start_y"] + 70
for i, item in enumerate(content_items):
item_obj = {
"type": "textbox",
"left": layout_params["left_margin"],
"top": list_y + i * 40,
"width": layout_params["left_column_width"],
"height": 35,
"text": f"{item}",
"fontSize": 28,
"fontFamily": "Arial",
"fill": "white",
"textAlign": "left",
"lineHeight": 1.2,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
text_objects.append(item_obj)
# 6. 价格 - 使用计算出的精确位置和字体大小
if layout_params["price_text"]:
price_x = layout_params["right_margin"] - layout_params["price_width"] - 60 # 给CNY起留空间
price_obj = {
"type": "textbox",
"left": price_x,
"top": layout_params["content_start_y"],
"width": layout_params["price_width"],
"height": 80,
"text": layout_params["price_text"],
"fontSize": layout_params["price_size"],
"fontFamily": "Arial Black",
"fontWeight": "bold",
"fill": "white",
"textAlign": "right",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"shadow": {
"color": "rgba(0, 0, 0, 0.5)",
"blur": 2,
"offsetX": 2,
"offsetY": 2
}
}
text_objects.append(price_obj)
# CNY起后缀
suffix_obj = {
"type": "textbox",
"left": price_x + layout_params["price_width"],
"top": layout_params["content_start_y"] + 50,
"width": 60,
"height": 30,
"text": "CNY起",
"fontSize": int(layout_params["price_size"] * 0.3),
"fontFamily": "Arial",
"fill": "white",
"textAlign": "left",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
text_objects.append(suffix_obj)
# 价格下划线
underline_y = layout_params["content_start_y"] + 80 + 18
underline_obj = {
"type": "line",
"left": price_x - 10,
"top": underline_y,
"x1": 0,
"y1": 0,
"x2": layout_params["right_margin"] - (price_x - 10),
"y2": 0,
"stroke": "rgba(255, 255, 255, 0.3)",
"strokeWidth": 2,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"selectable": False,
"evented": False
}
text_objects.append(underline_obj)
# 7. 票种 - 使用计算出的精确位置和字体大小
if layout_params["ticket_text"]:
ticket_x = layout_params["right_margin"] - layout_params["ticket_width"]
ticket_obj = {
"type": "textbox",
"left": ticket_x,
"top": layout_params["content_start_y"] + 115,
"width": layout_params["ticket_width"],
"height": 60,
"text": layout_params["ticket_text"],
"fontSize": layout_params["ticket_size"],
"fontFamily": "Arial",
"fontWeight": "bold",
"fill": "white",
"textAlign": "right",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1,
"shadow": {
"color": "rgba(0, 0, 0, 0.5)",
"blur": 2,
"offsetX": 2,
"offsetY": 2
}
}
text_objects.append(ticket_obj)
# 8. 备注信息
remarks = content.get("remarks", [])
if remarks:
remarks_y = layout_params["content_start_y"] + 205
for i, remark in enumerate(remarks):
remark_obj = {
"type": "textbox",
"left": layout_params["right_column_x"],
"top": remarks_y + i * 25,
"width": layout_params["right_margin"] - layout_params["right_column_x"],
"height": 20,
"text": remark,
"fontSize": 16,
"fontFamily": "Arial",
"fill": "rgba(255, 255, 255, 0.8)",
"textAlign": "right",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
text_objects.append(remark_obj)
# 9. 页脚
footer_y = layout_params["footer_y"]
if tag := content.get("tag"):
tag_obj = {
"type": "textbox",
"left": layout_params["left_margin"],
"top": footer_y,
"width": 200,
"height": 25,
"text": tag,
"fontSize": 18,
"fontFamily": "Arial",
"fill": "white",
"textAlign": "left",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
text_objects.append(tag_obj)
if pagination := content.get("pagination"):
pagination_obj = {
"type": "textbox",
"left": layout_params["right_margin"] - 200,
"top": footer_y,
"width": 200,
"height": 25,
"text": pagination,
"fontSize": 18,
"fontFamily": "Arial",
"fill": "white",
"textAlign": "right",
"lineHeight": 1.0,
"opacity": 1,
"angle": 0,
"scaleX": 1,
"scaleY": 1
}
text_objects.append(pagination_obj)
except Exception as e:
logger.error(f"创建精确文本对象失败: {e}")
return text_objects