使的目前图像的生成一致了
This commit is contained in:
parent
3392707cef
commit
6704dd58e7
Binary file not shown.
@ -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:
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
307
docs/fabric_json_standard.md
Normal file
307
docs/fabric_json_standard.md
Normal 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中使用占位符URL(https://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完全一致的渲染逻辑**
|
||||
|
||||
### 🔄 进行中:
|
||||
- 测试统一生成模式的一致性验证
|
||||
Binary file not shown.
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user