jinye_huang 2d21647f10 fix(poster_v2): 确保 Fabric JSON 和 PNG 位置一致
- 在所有布局类中添加 _add_object() 调用
- 渲染时同时记录元素位置到 _fabric_objects
- V2 引擎直接从布局类获取 Fabric 对象
- 移除硬编码的 fallback 位置
2025-12-10 16:28:25 +08:00

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