- 在所有布局类中添加 _add_object() 调用 - 渲染时同时记录元素位置到 _fabric_objects - V2 引擎直接从布局类获取 Fabric 对象 - 移除硬编码的 fallback 位置
248 lines
8.4 KiB
Python
248 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
布局E: 悬浮卡片
|
|
适用场景: 酒店、精品推荐
|
|
"""
|
|
|
|
from typing import Tuple
|
|
from PIL import Image, ImageDraw
|
|
|
|
from .base import BaseLayout
|
|
from ..schemas.content import PosterContent
|
|
from ..schemas.theme import Theme
|
|
from ..renderers.text import TextRenderer
|
|
|
|
|
|
class CardFloatLayout(BaseLayout):
|
|
"""悬浮卡片布局"""
|
|
|
|
# 字号配置
|
|
TITLE_SIZE = 80
|
|
SUBTITLE_SIZE = 32
|
|
LIST_SIZE = 30
|
|
PRICE_SIZE = 80
|
|
SMALL_SIZE = 24
|
|
DETAIL_SIZE = 28
|
|
|
|
# 卡片配置
|
|
CARD_MARGIN = 40
|
|
CARD_PADDING = 32
|
|
CARD_RADIUS = 28
|
|
|
|
def calculate_content_height(self, content: PosterContent, theme: Theme) -> int:
|
|
"""计算卡片内容高度 (使用固定范围)"""
|
|
# 基础高度
|
|
height = self.CARD_PADDING * 2 # 上下 padding
|
|
|
|
# 标签
|
|
if content.label:
|
|
height += 44
|
|
|
|
# 标题 (预估 1-2 行)
|
|
height += 90
|
|
|
|
# 副标题
|
|
if content.subtitle:
|
|
height += 60
|
|
|
|
# 装饰线
|
|
height += 28
|
|
|
|
# 特色 (最多2行)
|
|
if content.features:
|
|
rows = min((len(content.features) + 1) // 2, 2)
|
|
height += rows * 48 + 16
|
|
|
|
# 详情 (最多2条)
|
|
if content.details:
|
|
height += min(len(content.details), 2) * 40 + 16
|
|
|
|
# 价格区域
|
|
height += 80
|
|
|
|
# 限制范围
|
|
return max(500, min(height, 800))
|
|
|
|
def generate(self, content: PosterContent, theme: Theme, image_url: str = "") -> Image.Image:
|
|
"""生成海报"""
|
|
self._reset_objects()
|
|
|
|
# 计算卡片高度
|
|
card_height = self.calculate_content_height(content, theme)
|
|
card_y = self.height - card_height - 100
|
|
card_width = self.width - self.CARD_MARGIN * 2
|
|
|
|
# 创建渐变背景
|
|
canvas = self.create_gradient_background(theme)
|
|
|
|
# 如果有真实图片
|
|
if content.image:
|
|
img = content.image.copy().resize(self.size, Image.LANCZOS)
|
|
canvas = img.convert("RGBA")
|
|
|
|
# 背景图片 Fabric 对象
|
|
self._add_object({
|
|
"id": "background_image",
|
|
"type": "image",
|
|
"src": image_url,
|
|
"left": 0, "top": 0,
|
|
"width": self.width, "height": self.height,
|
|
"selectable": True,
|
|
})
|
|
|
|
# 卡片阴影
|
|
shadow = self.effect.create_shadow(
|
|
(card_width, card_height),
|
|
radius=self.CARD_RADIUS, blur=15, alpha=20, offset=(0, 8)
|
|
)
|
|
shadow_x = self.CARD_MARGIN - 30
|
|
shadow_y = card_y - 22
|
|
canvas.paste(shadow, (shadow_x, shadow_y), shadow)
|
|
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# 卡片背景
|
|
self.shape.draw_rounded_rect(draw,
|
|
(self.CARD_MARGIN, card_y, self.width - self.CARD_MARGIN, card_y + card_height),
|
|
radius=self.CARD_RADIUS, fill=(255, 255, 255, 250))
|
|
|
|
# 卡片 Fabric 对象
|
|
self._add_object({
|
|
"id": "card_bg",
|
|
"type": "rect",
|
|
"left": self.CARD_MARGIN, "top": card_y,
|
|
"width": card_width, "height": card_height,
|
|
"rx": self.CARD_RADIUS, "ry": self.CARD_RADIUS,
|
|
"fill": "rgba(255,255,255,0.98)",
|
|
"shadow": "rgba(0,0,0,0.1) 0 8px 30px",
|
|
"selectable": False,
|
|
})
|
|
|
|
# 颜色
|
|
text_dark = theme.text_dark_rgb
|
|
accent = theme.accent_rgb
|
|
primary = theme.primary_rgb
|
|
|
|
# 字体
|
|
title_font = TextRenderer.get_title_font(self.TITLE_SIZE)
|
|
subtitle_font = TextRenderer.get_body_font(self.SUBTITLE_SIZE)
|
|
list_font = TextRenderer.get_body_font(self.LIST_SIZE)
|
|
price_font = TextRenderer.get_title_font(self.PRICE_SIZE)
|
|
small_font = TextRenderer.get_body_font(self.SMALL_SIZE)
|
|
detail_font = TextRenderer.get_body_font(self.DETAIL_SIZE)
|
|
|
|
content_x = self.CARD_MARGIN + self.CARD_PADDING
|
|
content_right = self.width - self.CARD_MARGIN - self.CARD_PADDING
|
|
content_width = content_right - content_x
|
|
cur_y = card_y + self.CARD_PADDING
|
|
|
|
# === 标签 ===
|
|
if content.label:
|
|
label_w, _ = TextRenderer.measure_text(content.label, small_font)
|
|
self.shape.draw_rounded_rect(draw,
|
|
(content_x, cur_y, content_x + label_w + 24, cur_y + 38),
|
|
radius=19, fill=(*accent, 30))
|
|
draw.text((content_x + 12, cur_y + 8), content.label,
|
|
font=small_font, fill=accent)
|
|
cur_y += 46
|
|
|
|
# === 标题 (支持换行) ===
|
|
_, title_h = TextRenderer.draw_wrapped_text(
|
|
draw, (content_x, cur_y), content.title, title_font,
|
|
text_dark, content_width, line_spacing=4
|
|
)
|
|
|
|
# 标题 Fabric 对象
|
|
self._add_object({
|
|
"id": "title",
|
|
"type": "textbox",
|
|
"text": content.title,
|
|
"left": content_x, "top": cur_y,
|
|
"width": content_width,
|
|
"fontSize": self.TITLE_SIZE,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fontWeight": "bold",
|
|
"fill": theme.text_dark,
|
|
"selectable": True,
|
|
})
|
|
cur_y += title_h + 12
|
|
|
|
# === 副标题 (支持换行) ===
|
|
if content.subtitle:
|
|
_, sub_h = TextRenderer.draw_wrapped_text(
|
|
draw, (content_x, cur_y), content.subtitle, subtitle_font,
|
|
(*text_dark, 140), content_width, line_spacing=4
|
|
)
|
|
cur_y += max(sub_h, 32) + 20
|
|
|
|
# === 装饰线 ===
|
|
self.shape.draw_decorator_line(draw, (content_x, cur_y), 50, (*accent, 180))
|
|
cur_y += 28
|
|
|
|
# === 特色 (两列) ===
|
|
if content.features:
|
|
col_width = (content_right - content_x) // 2
|
|
for i, feature in enumerate(content.features):
|
|
col = i % 2
|
|
row = i // 2
|
|
fx = content_x + col * col_width
|
|
fy = cur_y + row * 48
|
|
|
|
self.shape.draw_ellipse(draw,
|
|
(fx, fy + 12, fx + 8, fy + 20),
|
|
fill=(*accent, 180))
|
|
draw.text((fx + 16, fy), feature,
|
|
font=list_font, fill=(*text_dark, 150))
|
|
|
|
rows = (len(content.features) + 1) // 2
|
|
cur_y += rows * 48 + 16
|
|
|
|
# === 详情 ===
|
|
if content.details:
|
|
for detail in content.details:
|
|
draw.text((content_x, cur_y), f"· {detail}",
|
|
font=detail_font, fill=(*text_dark, 120))
|
|
cur_y += 40
|
|
cur_y += 16
|
|
|
|
# === 价格区 ===
|
|
if content.price:
|
|
# 分隔线
|
|
self.shape.draw_line(draw, (content_x, cur_y),
|
|
(content_right, cur_y), (*text_dark, 15), 1)
|
|
cur_y += 20
|
|
|
|
price_w, price_h = TextRenderer.measure_text(content.price, price_font)
|
|
draw.text((content_x, cur_y), content.price, font=price_font, fill=primary)
|
|
|
|
# 价格 Fabric 对象
|
|
self._add_object({
|
|
"id": "price",
|
|
"type": "text",
|
|
"text": content.price,
|
|
"left": content_x, "top": cur_y,
|
|
"fontSize": self.PRICE_SIZE,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fontWeight": "bold",
|
|
"fill": theme.primary,
|
|
"selectable": True,
|
|
})
|
|
|
|
# 后缀 (如果有)
|
|
suffix = content.price_suffix or ""
|
|
if suffix:
|
|
draw.text((content_x + price_w + 8, cur_y + price_h - 28), suffix,
|
|
font=small_font, fill=(*text_dark, 120))
|
|
|
|
# 查看详情按钮
|
|
link_text = "查看详情"
|
|
link_w, _ = TextRenderer.measure_text(link_text, subtitle_font)
|
|
link_x = content_right - link_w - 28
|
|
self.shape.draw_rounded_rect(draw,
|
|
(link_x - 18, cur_y + 8, content_right, cur_y + 54),
|
|
radius=23, fill=(*accent, 180))
|
|
draw.text((link_x, cur_y + 17), link_text, font=subtitle_font, fill=(255, 255, 255))
|
|
|
|
return canvas
|