TravelContentCreator/domain/poster/fabric_generator.py

455 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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