- 在所有布局类中添加 _add_object() 调用 - 渲染时同时记录元素位置到 _fabric_objects - V2 引擎直接从布局类获取 Fabric 对象 - 移除硬编码的 fallback 位置
308 lines
12 KiB
Python
308 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
布局A: 大图在上,文字在下
|
|
适用场景: 景点、美食、通用
|
|
"""
|
|
|
|
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 HeroBottomLayout(BaseLayout):
|
|
"""大图底部文字布局"""
|
|
|
|
# 字号配置
|
|
TITLE_SIZE = 96
|
|
SUBTITLE_SIZE = 36
|
|
PRICE_SIZE = 88
|
|
DETAIL_SIZE = 32
|
|
TAG_SIZE = 24
|
|
|
|
def calculate_content_height(self, content: PosterContent, theme: Theme) -> int:
|
|
"""计算内容区域高度 (与 design_samples 保持一致)"""
|
|
height = self.PADDING
|
|
|
|
# 标题
|
|
title_font = TextRenderer.get_title_font(self.TITLE_SIZE)
|
|
_, title_h = TextRenderer.measure_text(content.title, title_font)
|
|
height += title_h + 14
|
|
|
|
# 副标题 (始终计算,即使为空也保留空间)
|
|
height += 32 + 44
|
|
|
|
# 装饰线
|
|
height += 28
|
|
|
|
# 详情
|
|
if content.details:
|
|
height += len(content.details) * 44 + 20
|
|
|
|
# 价格
|
|
price_font = TextRenderer.get_title_font(self.PRICE_SIZE)
|
|
_, price_h = TextRenderer.measure_text("¥999", price_font)
|
|
height += price_h + self.PADDING
|
|
|
|
return height
|
|
|
|
def generate(self, content: PosterContent, theme: Theme, image_url: str = "") -> Image.Image:
|
|
"""生成海报 (与 design_samples 保持一致)"""
|
|
# 重置 Fabric 对象列表
|
|
self._reset_objects()
|
|
|
|
# 计算内容高度
|
|
content_height = self.calculate_content_height(content, theme)
|
|
content_y = self.height - content_height - 40
|
|
|
|
# 创建画布 (淡色底)
|
|
canvas = self.create_canvas(theme.secondary_rgb)
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# === 上半部分: 渐变或图片 ===
|
|
overlay_color = theme.primary_rgb # 默认使用主题色
|
|
|
|
if content.image:
|
|
# 有图片: 图片覆盖整个画布
|
|
img = content.image.copy().convert("RGBA")
|
|
img = img.resize(self.size, Image.LANCZOS)
|
|
canvas.paste(img, (0, 0))
|
|
|
|
# 从图片底部区域提取主色调
|
|
bottom_region = img.crop((0, int(self.height * 0.7), self.width, self.height))
|
|
bottom_small = bottom_region.resize((50, 50), Image.LANCZOS)
|
|
pixels = list(bottom_small.getdata())
|
|
r = sum(p[0] for p in pixels) // len(pixels)
|
|
g = sum(p[1] for p in pixels) // len(pixels)
|
|
b = sum(p[2] for p in pixels) // len(pixels)
|
|
overlay_color = (max(0, r - 30), max(0, g - 30), max(0, b - 30))
|
|
else:
|
|
# 无图片: 渐变色
|
|
gradient = self.effect.create_gradient(
|
|
self.size,
|
|
theme.gradient_rgb[0],
|
|
theme.gradient_rgb[1]
|
|
)
|
|
canvas.paste(gradient, (0, 0))
|
|
|
|
# 记录背景图片 Fabric 对象
|
|
self._add_object({
|
|
"id": "background_image",
|
|
"type": "image",
|
|
"src": image_url,
|
|
"left": 0, "top": 0,
|
|
"width": self.width, "height": self.height,
|
|
"scaleX": 1, "scaleY": 1,
|
|
"selectable": True,
|
|
})
|
|
|
|
# === 底部渐变遮罩 ===
|
|
solid_start = content_y + 50
|
|
solid_bg = Image.new("RGBA", (self.width, self.height - solid_start), (*overlay_color, 200))
|
|
canvas.paste(solid_bg, (0, solid_start))
|
|
|
|
fade_height = 350
|
|
fade = self.effect.create_gradient(
|
|
(self.width, fade_height),
|
|
(*overlay_color, 0),
|
|
(*overlay_color, 200)
|
|
)
|
|
canvas.paste(fade, (0, solid_start - fade_height), fade)
|
|
|
|
# 记录渐变遮罩 Fabric 对象
|
|
self._add_object({
|
|
"id": "gradient_overlay",
|
|
"type": "rect",
|
|
"left": 0, "top": solid_start - fade_height,
|
|
"width": self.width, "height": self.height - solid_start + fade_height,
|
|
"fill": {
|
|
"type": "linear",
|
|
"coords": {"x1": 0, "y1": 0, "x2": 0, "y2": self.height - solid_start + fade_height},
|
|
"colorStops": [
|
|
{"offset": 0, "color": f"rgba({overlay_color[0]},{overlay_color[1]},{overlay_color[2]},0)"},
|
|
{"offset": 0.5, "color": f"rgba({overlay_color[0]},{overlay_color[1]},{overlay_color[2]},0.6)"},
|
|
{"offset": 1, "color": f"rgba({overlay_color[0]},{overlay_color[1]},{overlay_color[2]},0.85)"},
|
|
]
|
|
},
|
|
"selectable": False,
|
|
})
|
|
|
|
# 重新获取 draw (因为 paste 后需要)
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# 字体
|
|
title_font = TextRenderer.get_title_font(self.TITLE_SIZE)
|
|
subtitle_font = TextRenderer.get_body_font(self.SUBTITLE_SIZE)
|
|
price_font = TextRenderer.get_title_font(self.PRICE_SIZE)
|
|
detail_font = TextRenderer.get_body_font(self.DETAIL_SIZE)
|
|
tag_font = TextRenderer.get_body_font(self.TAG_SIZE)
|
|
suffix_font = TextRenderer.get_body_font(28)
|
|
|
|
text_white = theme.text_rgb
|
|
accent = theme.accent_rgb
|
|
|
|
cur_y = content_y + self.PADDING
|
|
content_width = self.width - self.MARGIN * 2
|
|
|
|
# === 标题 (自适应大小,尽量单行显示) ===
|
|
adaptive_title_font = TextRenderer.get_adaptive_title_font(
|
|
content.title, content_width, base_size=self.TITLE_SIZE, min_size=56
|
|
)
|
|
title_w, title_h = TextRenderer.measure_text(content.title, adaptive_title_font)
|
|
title_font_size = adaptive_title_font.size
|
|
|
|
# 居中显示
|
|
title_x = self.MARGIN + (content_width - title_w) // 2
|
|
TextRenderer.draw_text_with_shadow(
|
|
draw, (title_x, cur_y), content.title, adaptive_title_font,
|
|
theme.text, shadow_color=(0, 0, 0, 60), offset=(2, 2)
|
|
)
|
|
|
|
# 记录标题 Fabric 对象
|
|
self._add_object({
|
|
"id": "title",
|
|
"type": "textbox",
|
|
"text": content.title,
|
|
"left": title_x, "top": cur_y,
|
|
"width": content_width,
|
|
"fontSize": title_font_size,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fontWeight": "bold",
|
|
"fill": theme.text,
|
|
"shadow": "rgba(0,0,0,0.3) 2px 2px 4px",
|
|
"selectable": True,
|
|
})
|
|
cur_y += title_h + 14
|
|
|
|
# === 副标题 (支持换行) ===
|
|
subtitle_top = cur_y
|
|
if content.subtitle:
|
|
_, sub_h = TextRenderer.draw_wrapped_text(
|
|
draw, (self.MARGIN, cur_y), content.subtitle, subtitle_font,
|
|
(*text_white, 200), content_width, line_spacing=4
|
|
)
|
|
|
|
# 记录副标题 Fabric 对象
|
|
self._add_object({
|
|
"id": "subtitle",
|
|
"type": "textbox",
|
|
"text": content.subtitle,
|
|
"left": self.MARGIN, "top": cur_y,
|
|
"width": content_width,
|
|
"fontSize": self.SUBTITLE_SIZE,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fill": f"rgba({text_white[0]},{text_white[1]},{text_white[2]},0.85)",
|
|
"selectable": True,
|
|
})
|
|
cur_y += max(sub_h, 36) + 8
|
|
|
|
# === 装饰线 ===
|
|
self.shape.draw_decorator_line(draw, (self.MARGIN, cur_y), 60, (*accent, 200))
|
|
cur_y += 28
|
|
|
|
# === 详情 ===
|
|
if content.details:
|
|
for detail in content.details:
|
|
self.shape.draw_ellipse(draw,
|
|
(self.MARGIN, cur_y + 12, self.MARGIN + 8, cur_y + 20),
|
|
fill=(*accent, 180))
|
|
draw.text((self.MARGIN + 18, cur_y), detail,
|
|
font=detail_font, fill=(*text_white, 180))
|
|
cur_y += 44
|
|
cur_y += 20
|
|
|
|
# === 价格 ===
|
|
price_top = cur_y
|
|
if content.price:
|
|
price_w, price_h = TextRenderer.measure_text(content.price, price_font)
|
|
|
|
suffix = content.price_suffix or ""
|
|
suffix_w = 0
|
|
if suffix:
|
|
suffix_w, _ = TextRenderer.measure_text(suffix, suffix_font)
|
|
|
|
# 价格背景
|
|
bg_width = price_w + suffix_w + 40 if suffix else price_w + 24
|
|
self.shape.draw_rounded_rect(draw,
|
|
(self.MARGIN - 12, cur_y - 6, self.MARGIN + bg_width, cur_y + price_h + 8),
|
|
radius=12, fill=(*accent, 40))
|
|
|
|
# 记录价格背景 Fabric 对象
|
|
self._add_object({
|
|
"id": "price_bg",
|
|
"type": "rect",
|
|
"left": self.MARGIN - 12, "top": cur_y - 6,
|
|
"width": bg_width + 12, "height": price_h + 14,
|
|
"rx": 12, "ry": 12,
|
|
"fill": f"rgba({accent[0]},{accent[1]},{accent[2]},0.25)",
|
|
"selectable": False,
|
|
})
|
|
|
|
# 价格文字
|
|
draw.text((self.MARGIN, cur_y), content.price, font=price_font, fill=text_white)
|
|
|
|
# 记录价格 Fabric 对象
|
|
self._add_object({
|
|
"id": "price",
|
|
"type": "text",
|
|
"text": content.price,
|
|
"left": self.MARGIN, "top": cur_y,
|
|
"fontSize": self.PRICE_SIZE,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fontWeight": "bold",
|
|
"fill": theme.text,
|
|
"selectable": True,
|
|
})
|
|
|
|
# 后缀
|
|
if suffix:
|
|
draw.text((self.MARGIN + price_w + 8, cur_y + price_h - 28), suffix,
|
|
font=suffix_font, fill=(*text_white, 200))
|
|
|
|
self._add_object({
|
|
"id": "price_suffix",
|
|
"type": "text",
|
|
"text": suffix,
|
|
"left": self.MARGIN + price_w + 8, "top": cur_y + price_h - 28,
|
|
"fontSize": 28,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fill": f"rgba({text_white[0]},{text_white[1]},{text_white[2]},0.85)",
|
|
"selectable": True,
|
|
})
|
|
|
|
# === 标签 ===
|
|
if content.tags:
|
|
tag_x = self.width - self.MARGIN
|
|
tag_top = cur_y + 20
|
|
for i, tag in enumerate(reversed(content.tags)):
|
|
tag_text = f"#{tag}"
|
|
tag_w, _ = TextRenderer.measure_text(tag_text, tag_font)
|
|
tag_x -= tag_w + 22
|
|
|
|
self.shape.draw_rounded_rect(draw,
|
|
(tag_x, tag_top, tag_x + tag_w + 16, tag_top + 34),
|
|
radius=17, fill=(*accent, 35))
|
|
draw.text((tag_x + 8, tag_top + 4), tag_text,
|
|
font=tag_font, fill=text_white)
|
|
|
|
# 记录标签 Fabric 对象
|
|
self._add_object({
|
|
"id": f"tag_{i}",
|
|
"type": "text",
|
|
"text": tag_text,
|
|
"left": tag_x + 8, "top": tag_top + 4,
|
|
"fontSize": self.TAG_SIZE,
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
"fill": theme.text,
|
|
"backgroundColor": f"rgba({accent[0]},{accent[1]},{accent[2]},0.25)",
|
|
"padding": 8,
|
|
"selectable": True,
|
|
})
|
|
tag_x -= 8
|
|
|
|
return canvas
|