TravelContentCreator/domain/poster/fabric_generator.py

455 lines
13 KiB
Python
Raw Normal View History

2025-12-08 14:58:35 +08:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Fabric.js JSON 生成器
负责生成 Fabric.js 兼容的 JSON 格式
"""
import logging
import base64
from io import BytesIO
from typing import Dict, Any, Optional, List, Tuple
logger = logging.getLogger(__name__)
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
class FabricGenerator:
"""
Fabric.js JSON 生成器
职责
- 生成 Fabric.js 兼容的 JSON
- 创建各种图形对象图片文本形状
- 处理布局计算
"""
# Fabric.js 版本
FABRIC_VERSION = "5.3.0"
# 基础画布尺寸
BASE_WIDTH = 1350
BASE_HEIGHT = 1800
def __init__(self):
self.logger = logging.getLogger(f"{__name__}.FabricGenerator")
def generate(
self,
content: Dict[str, Any],
template_id: str,
image_size: Tuple[int, int],
background_image: Optional['Image.Image'] = None,
template_handler=None
) -> Dict[str, Any]:
"""
生成 Fabric.js JSON
Args:
content: 海报内容
template_id: 模板 ID
image_size: 画布尺寸 (width, height)
background_image: 背景图片
template_handler: 模板处理器实例
Returns:
Fabric.js JSON 字典
"""
try:
self.logger.info(f"开始生成 Fabric.js JSON模板: {template_id}")
# 如果有模板处理器且支持统一渲染
if template_handler and hasattr(template_handler, '_unified_render'):
return self._generate_with_template(
content, template_handler, background_image
)
# 否则使用通用逻辑
return self._generate_generic(
content, template_id, image_size, background_image
)
except Exception as e:
self.logger.error(f"Fabric.js JSON 生成失败: {e}")
return self._create_empty_json(image_size)
def _generate_with_template(
self,
content: Dict[str, Any],
template_handler,
background_image: Optional['Image.Image']
) -> Dict[str, Any]:
"""使用模板处理器生成"""
try:
render_result = template_handler._unified_render(
images=background_image,
content=content,
theme_color=None,
glass_intensity=1.5,
output_format='json'
)
if "error" in render_result:
raise Exception(render_result["error"])
return render_result["fabric_json"]
except Exception as e:
self.logger.error(f"模板渲染失败: {e}")
raise
def _generate_generic(
self,
content: Dict[str, Any],
template_id: str,
image_size: Tuple[int, int],
background_image: Optional['Image.Image']
) -> Dict[str, Any]:
"""通用生成逻辑"""
width, height = image_size
objects = []
# 1. 背景图片
if background_image:
objects.append(self.create_image_object(
background_image, width, height, is_background=True
))
else:
objects.append(self.create_placeholder_object(width, height))
# 2. 渐变遮罩
gradient_start = self._calculate_gradient_start(content, height)
objects.append(self.create_gradient_object(width, height, gradient_start))
# 3. 文本对象
text_objects = self.create_text_objects(content, width, height, gradient_start)
objects.extend(text_objects)
return {
"version": self.FABRIC_VERSION,
"width": width,
"height": height,
"objects": objects
}
def _create_empty_json(self, image_size: Tuple[int, int]) -> Dict[str, Any]:
"""创建空的 Fabric.js JSON"""
return {
"version": self.FABRIC_VERSION,
"width": image_size[0],
"height": image_size[1],
"objects": []
}
def _calculate_gradient_start(self, content: Dict[str, Any], height: int) -> int:
"""计算渐变起始位置"""
# 估算内容高度
estimated_height = self._estimate_content_height(content)
# 渐变从内容区域上方开始
return max(int(height * 0.4), height - estimated_height - 100)
def _estimate_content_height(self, content: Dict[str, Any]) -> int:
"""估算内容高度"""
height = 0
# 标题
if content.get('title'):
height += 80
# 副标题
if content.get('subtitle'):
height += 50
# 价格
if content.get('price'):
height += 60
# 描述
if content.get('description'):
lines = len(content['description']) // 20 + 1
height += lines * 30
# 标签
if content.get('tags'):
height += 40
return height + 100 # 额外边距
# ========== 对象创建方法 ==========
def create_image_object(
self,
image: 'Image.Image',
canvas_width: int,
canvas_height: int,
is_background: bool = False
) -> Dict[str, Any]:
"""
创建图片对象
Args:
image: PIL Image
canvas_width: 画布宽度
canvas_height: 画布高度
is_background: 是否为背景图片
"""
# 转换为 base64
image_base64 = self._image_to_base64(image)
# 计算缩放
scale_x = canvas_width / image.width
scale_y = canvas_height / image.height
scale = max(scale_x, scale_y) if is_background else min(scale_x, scale_y)
return {
"type": "image",
"version": self.FABRIC_VERSION,
"left": 0,
"top": 0,
"width": image.width,
"height": image.height,
"scaleX": scale,
"scaleY": scale,
"src": f"data:image/png;base64,{image_base64}",
"selectable": not is_background,
"evented": not is_background,
}
def create_placeholder_object(
self,
canvas_width: int,
canvas_height: int
) -> Dict[str, Any]:
"""创建占位符对象"""
return {
"type": "rect",
"version": self.FABRIC_VERSION,
"left": 0,
"top": 0,
"width": canvas_width,
"height": canvas_height,
"fill": "#f0f0f0",
"selectable": False,
"evented": False,
}
def create_gradient_object(
self,
canvas_width: int,
canvas_height: int,
gradient_start: int,
colors: Optional[Dict[str, Tuple[int, int, int]]] = None
) -> Dict[str, Any]:
"""
创建渐变遮罩对象
Args:
canvas_width: 画布宽度
canvas_height: 画布高度
gradient_start: 渐变起始位置
colors: 渐变颜色
"""
if colors is None:
colors = {
'top': (255, 255, 255),
'bottom': (240, 240, 240)
}
top_color = colors.get('top', (255, 255, 255))
bottom_color = colors.get('bottom', (240, 240, 240))
return {
"type": "rect",
"version": self.FABRIC_VERSION,
"left": 0,
"top": gradient_start,
"width": canvas_width,
"height": canvas_height - gradient_start,
"fill": {
"type": "linear",
"coords": {
"x1": 0,
"y1": 0,
"x2": 0,
"y2": canvas_height - gradient_start
},
"colorStops": [
{"offset": 0, "color": f"rgba({top_color[0]},{top_color[1]},{top_color[2]},0.8)"},
{"offset": 1, "color": f"rgba({bottom_color[0]},{bottom_color[1]},{bottom_color[2]},0.95)"}
]
},
"selectable": False,
"evented": False,
}
def create_text_object(
self,
text: str,
left: int,
top: int,
font_size: int = 24,
font_family: str = "Arial",
fill: str = "#000000",
font_weight: str = "normal",
text_align: str = "left",
**kwargs
) -> Dict[str, Any]:
"""
创建文本对象
Args:
text: 文本内容
left: 左边距
top: 上边距
font_size: 字体大小
font_family: 字体
fill: 填充颜色
font_weight: 字重
text_align: 对齐方式
"""
obj = {
"type": "text",
"version": self.FABRIC_VERSION,
"left": left,
"top": top,
"text": text,
"fontSize": font_size,
"fontFamily": font_family,
"fill": fill,
"fontWeight": font_weight,
"textAlign": text_align,
"selectable": True,
"evented": True,
}
obj.update(kwargs)
return obj
def create_text_objects(
self,
content: Dict[str, Any],
canvas_width: int,
canvas_height: int,
gradient_start: int
) -> List[Dict[str, Any]]:
"""
创建所有文本对象
Args:
content: 内容字典
canvas_width: 画布宽度
canvas_height: 画布高度
gradient_start: 渐变起始位置
"""
objects = []
margin = 40
current_y = gradient_start + 60
# 标题
if content.get('title'):
objects.append(self.create_text_object(
text=content['title'],
left=margin,
top=current_y,
font_size=48,
font_weight="bold",
fill="#333333"
))
current_y += 70
# 副标题
if content.get('subtitle'):
objects.append(self.create_text_object(
text=content['subtitle'],
left=margin,
top=current_y,
font_size=28,
fill="#666666"
))
current_y += 50
# 价格
if content.get('price'):
price_text = f"¥{content['price']}"
if content.get('original_price'):
price_text += f" 原价¥{content['original_price']}"
objects.append(self.create_text_object(
text=price_text,
left=margin,
top=current_y,
font_size=36,
font_weight="bold",
fill="#ff4444"
))
current_y += 60
# 描述
if content.get('description'):
objects.append(self.create_text_object(
text=content['description'],
left=margin,
top=current_y,
font_size=20,
fill="#888888"
))
return objects
def create_rect_object(
self,
left: int,
top: int,
width: int,
height: int,
fill: str = "#ffffff",
stroke: Optional[str] = None,
stroke_width: int = 1,
rx: int = 0,
ry: int = 0,
**kwargs
) -> Dict[str, Any]:
"""
创建矩形对象
Args:
left: 左边距
top: 上边距
width: 宽度
height: 高度
fill: 填充颜色
stroke: 边框颜色
stroke_width: 边框宽度
rx: 圆角 X
ry: 圆角 Y
"""
obj = {
"type": "rect",
"version": self.FABRIC_VERSION,
"left": left,
"top": top,
"width": width,
"height": height,
"fill": fill,
"rx": rx,
"ry": ry,
}
if stroke:
obj["stroke"] = stroke
obj["strokeWidth"] = stroke_width
obj.update(kwargs)
return obj
# ========== 工具方法 ==========
def _image_to_base64(self, image: 'Image.Image', format: str = 'PNG') -> str:
"""将 PIL Image 转换为 base64"""
buffer = BytesIO()
image.save(buffer, format=format)
return base64.b64encode(buffer.getvalue()).decode('utf-8')