2025-12-10 15:04:59 +08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""
|
|
|
|
|
布局C: 底部毛玻璃叠加
|
|
|
|
|
适用场景: 美食探店、带emoji
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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 OverlayBottomLayout(BaseLayout):
|
|
|
|
|
"""底部毛玻璃布局"""
|
|
|
|
|
|
|
|
|
|
# 字号配置
|
|
|
|
|
EMOJI_SIZE = 109
|
|
|
|
|
TITLE_SIZE = 88
|
|
|
|
|
SUBTITLE_SIZE = 34
|
|
|
|
|
PRICE_SIZE = 84
|
|
|
|
|
TAG_SIZE = 24
|
|
|
|
|
HL_SIZE = 28
|
|
|
|
|
DETAIL_SIZE = 30
|
|
|
|
|
|
|
|
|
|
def calculate_content_height(self, content: PosterContent, theme: Theme) -> int:
|
|
|
|
|
"""计算内容区域高度"""
|
|
|
|
|
height = self.PADDING
|
|
|
|
|
|
|
|
|
|
# emoji
|
|
|
|
|
if content.emoji:
|
|
|
|
|
height += self.EMOJI_SIZE + 16
|
|
|
|
|
|
|
|
|
|
# 标题
|
|
|
|
|
title_font = TextRenderer.get_title_font(self.TITLE_SIZE)
|
|
|
|
|
_, title_h = TextRenderer.measure_text(content.title, title_font)
|
|
|
|
|
height += title_h + 12
|
|
|
|
|
|
|
|
|
|
# 副标题
|
|
|
|
|
if content.subtitle:
|
|
|
|
|
height += 34 + 24
|
|
|
|
|
|
|
|
|
|
# 亮点标签
|
|
|
|
|
if content.highlights:
|
|
|
|
|
height += 44 + 20
|
|
|
|
|
|
|
|
|
|
# 装饰线
|
|
|
|
|
height += 24
|
|
|
|
|
|
|
|
|
|
# 详情
|
|
|
|
|
if content.details:
|
|
|
|
|
height += len(content.details) * 44 + 16
|
|
|
|
|
|
|
|
|
|
# 价格
|
|
|
|
|
height += 84 + self.PADDING
|
|
|
|
|
|
|
|
|
|
return height
|
|
|
|
|
|
2025-12-10 16:28:25 +08:00
|
|
|
def generate(self, content: PosterContent, theme: Theme, image_url: str = "") -> Image.Image:
|
2025-12-10 15:04:59 +08:00
|
|
|
"""生成海报"""
|
2025-12-10 16:28:25 +08:00
|
|
|
self._reset_objects()
|
|
|
|
|
|
2025-12-10 15:04:59 +08:00
|
|
|
# 计算内容高度
|
|
|
|
|
content_height = self.calculate_content_height(content, theme)
|
|
|
|
|
glass_y = self.height - content_height - 60
|
|
|
|
|
glass_height = content_height + 60
|
|
|
|
|
|
|
|
|
|
# 创建渐变背景
|
|
|
|
|
canvas = self.create_gradient_background(theme)
|
|
|
|
|
|
|
|
|
|
# 如果有真实图片
|
|
|
|
|
if content.image:
|
|
|
|
|
img = content.image.copy().resize(self.size, Image.LANCZOS)
|
|
|
|
|
canvas = img.convert("RGBA")
|
|
|
|
|
|
2025-12-10 16:28:25 +08:00
|
|
|
# 背景图片 Fabric 对象
|
|
|
|
|
self._add_object({
|
|
|
|
|
"id": "background_image",
|
|
|
|
|
"type": "image",
|
|
|
|
|
"src": image_url,
|
|
|
|
|
"left": 0, "top": 0,
|
|
|
|
|
"width": self.width, "height": self.height,
|
|
|
|
|
"selectable": True,
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-10 15:04:59 +08:00
|
|
|
# 毛玻璃效果
|
|
|
|
|
glass = self.effect.create_frosted_glass(
|
|
|
|
|
canvas, (0, glass_y, self.width, self.height),
|
|
|
|
|
blur_radius=25, overlay_alpha=230
|
|
|
|
|
)
|
|
|
|
|
canvas.paste(glass, (0, glass_y))
|
|
|
|
|
|
2025-12-10 16:28:25 +08:00
|
|
|
# 毛玻璃区域 Fabric 对象
|
|
|
|
|
self._add_object({
|
|
|
|
|
"id": "glass_bg",
|
|
|
|
|
"type": "rect",
|
|
|
|
|
"left": 0, "top": glass_y,
|
|
|
|
|
"width": self.width, "height": glass_height,
|
|
|
|
|
"fill": "rgba(255,255,255,0.92)",
|
|
|
|
|
"selectable": False,
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-10 15:04:59 +08:00
|
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
|
|
|
|
|
|
# 颜色
|
|
|
|
|
text_dark = theme.text_dark_rgb
|
|
|
|
|
accent = theme.accent_rgb
|
|
|
|
|
|
|
|
|
|
# 字体
|
|
|
|
|
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)
|
|
|
|
|
hl_font = TextRenderer.get_body_font(self.HL_SIZE)
|
|
|
|
|
detail_font = TextRenderer.get_body_font(self.DETAIL_SIZE)
|
|
|
|
|
tag_font = TextRenderer.get_body_font(self.TAG_SIZE)
|
|
|
|
|
|
|
|
|
|
cur_y = glass_y + self.PADDING
|
|
|
|
|
|
|
|
|
|
# === Emoji ===
|
|
|
|
|
if content.emoji:
|
|
|
|
|
TextRenderer.draw_emoji(draw, (self.MARGIN, cur_y), content.emoji, self.EMOJI_SIZE)
|
|
|
|
|
cur_y += self.EMOJI_SIZE + 16
|
|
|
|
|
|
|
|
|
|
# === 标题 (支持换行) ===
|
|
|
|
|
content_width = self.width - self.MARGIN * 2
|
|
|
|
|
_, title_h = TextRenderer.draw_wrapped_text(
|
|
|
|
|
draw, (self.MARGIN, cur_y), content.title, title_font,
|
|
|
|
|
text_dark, content_width, line_spacing=4
|
|
|
|
|
)
|
2025-12-10 16:28:25 +08:00
|
|
|
|
|
|
|
|
# 标题 Fabric 对象
|
|
|
|
|
self._add_object({
|
|
|
|
|
"id": "title",
|
|
|
|
|
"type": "textbox",
|
|
|
|
|
"text": content.title,
|
|
|
|
|
"left": self.MARGIN, "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,
|
|
|
|
|
})
|
2025-12-10 15:04:59 +08:00
|
|
|
cur_y += title_h + 12
|
|
|
|
|
|
|
|
|
|
# === 副标题 (支持换行) ===
|
|
|
|
|
if content.subtitle:
|
|
|
|
|
_, sub_h = TextRenderer.draw_wrapped_text(
|
|
|
|
|
draw, (self.MARGIN, cur_y), content.subtitle, subtitle_font,
|
|
|
|
|
(*text_dark, 150), content_width, line_spacing=4
|
|
|
|
|
)
|
2025-12-10 16:28:25 +08:00
|
|
|
|
|
|
|
|
# 副标题 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_dark[0]},{text_dark[1]},{text_dark[2]},0.7)",
|
|
|
|
|
"selectable": True,
|
|
|
|
|
})
|
2025-12-10 15:04:59 +08:00
|
|
|
cur_y += max(sub_h, 34) + 16
|
|
|
|
|
|
|
|
|
|
# === 亮点标签 (胶囊,自动换行) ===
|
|
|
|
|
max_y = self.height - 150 # 预留底部价格区域
|
|
|
|
|
if content.highlights:
|
|
|
|
|
hl_x = self.MARGIN
|
2025-12-10 16:28:25 +08:00
|
|
|
hl_start_y = cur_y
|
|
|
|
|
for i, hl in enumerate(content.highlights[:4]): # 最多4个
|
2025-12-10 15:04:59 +08:00
|
|
|
hl_w, _ = TextRenderer.measure_text(hl, hl_font)
|
|
|
|
|
# 换行检测
|
|
|
|
|
if hl_x + hl_w + 36 > self.width - self.MARGIN:
|
|
|
|
|
hl_x = self.MARGIN
|
|
|
|
|
cur_y += 44
|
|
|
|
|
if cur_y < max_y:
|
|
|
|
|
self.shape.draw_capsule(draw, (hl_x, cur_y), hl_w, 40, (*accent, 50))
|
|
|
|
|
draw.text((hl_x + 14, cur_y + 6), hl, font=hl_font, fill=text_dark)
|
2025-12-10 16:28:25 +08:00
|
|
|
|
|
|
|
|
# 亮点 Fabric 对象
|
|
|
|
|
self._add_object({
|
|
|
|
|
"id": f"highlight_{i}",
|
|
|
|
|
"type": "textbox",
|
|
|
|
|
"text": hl,
|
|
|
|
|
"left": hl_x + 14, "top": cur_y + 6,
|
|
|
|
|
"fontSize": self.HL_SIZE,
|
|
|
|
|
"fontFamily": "PingFang SC, Microsoft YaHei, sans-serif",
|
|
|
|
|
"fill": theme.text_dark,
|
|
|
|
|
"backgroundColor": f"rgba({accent[0]},{accent[1]},{accent[2]},0.25)",
|
|
|
|
|
"padding": 10,
|
|
|
|
|
"selectable": True,
|
|
|
|
|
})
|
2025-12-10 15:04:59 +08:00
|
|
|
hl_x += hl_w + 28
|
|
|
|
|
cur_y += 44 + 16
|
|
|
|
|
|
|
|
|
|
# === 装饰线 ===
|
|
|
|
|
if cur_y < max_y:
|
|
|
|
|
self.shape.draw_line(draw, (self.MARGIN, cur_y),
|
|
|
|
|
(self.width - self.MARGIN, cur_y), (*text_dark, 30), 1)
|
|
|
|
|
cur_y += 20
|
|
|
|
|
|
|
|
|
|
# === 详情 ===
|
|
|
|
|
if content.details and cur_y < max_y:
|
|
|
|
|
for detail in content.details[:2]: # 最多2条
|
|
|
|
|
if cur_y >= max_y:
|
|
|
|
|
break
|
|
|
|
|
draw.text((self.MARGIN, cur_y), f"· {detail}",
|
|
|
|
|
font=detail_font, fill=(*text_dark, 160))
|
|
|
|
|
cur_y += 40
|
2025-12-10 15:33:53 +08:00
|
|
|
cur_y += 16 # 增加间距
|
2025-12-10 15:04:59 +08:00
|
|
|
|
|
|
|
|
# === 价格区 ===
|
|
|
|
|
if content.price:
|
|
|
|
|
price_w, price_h = TextRenderer.measure_text(content.price, price_font)
|
|
|
|
|
|
2025-12-10 16:28:25 +08:00
|
|
|
# 价格背景
|
2025-12-10 15:04:59 +08:00
|
|
|
self.shape.draw_rounded_rect(draw,
|
|
|
|
|
(self.MARGIN - 10, cur_y - 6, self.MARGIN + price_w + 20, cur_y + price_h + 10),
|
|
|
|
|
radius=12, fill=(*accent, 60))
|
|
|
|
|
|
|
|
|
|
draw.text((self.MARGIN, cur_y), content.price, font=price_font, fill=text_dark)
|
2025-12-10 16:28:25 +08:00
|
|
|
|
|
|
|
|
# 价格 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_dark,
|
|
|
|
|
"selectable": True,
|
|
|
|
|
})
|
2025-12-10 15:04:59 +08:00
|
|
|
|
|
|
|
|
# === 标签 ===
|
|
|
|
|
if content.tags:
|
|
|
|
|
tag_x = self.width - self.MARGIN
|
|
|
|
|
for tag in reversed(content.tags):
|
|
|
|
|
tag_text = f"#{tag}"
|
|
|
|
|
tag_w, _ = TextRenderer.measure_text(tag_text, tag_font)
|
|
|
|
|
tag_x -= tag_w + 16
|
|
|
|
|
# 添加标签背景
|
|
|
|
|
self.shape.draw_rounded_rect(draw,
|
|
|
|
|
(tag_x - 6, cur_y + 16, tag_x + tag_w + 6, cur_y + 52),
|
|
|
|
|
radius=18, fill=(*accent, 40))
|
|
|
|
|
draw.text((tag_x, cur_y + 20), tag_text,
|
|
|
|
|
font=tag_font, fill=text_dark)
|
|
|
|
|
tag_x -= 12
|
|
|
|
|
|
|
|
|
|
return canvas
|