diff --git a/domain/aigc/engines/__pycache__/poster_smart_v2.cpython-312.pyc b/domain/aigc/engines/__pycache__/poster_smart_v2.cpython-312.pyc index 389c79f..9f07f16 100644 Binary files a/domain/aigc/engines/__pycache__/poster_smart_v2.cpython-312.pyc and b/domain/aigc/engines/__pycache__/poster_smart_v2.cpython-312.pyc differ diff --git a/domain/aigc/engines/poster_smart_v2.py b/domain/aigc/engines/poster_smart_v2.py index 35ba517..1895bf2 100644 --- a/domain/aigc/engines/poster_smart_v2.py +++ b/domain/aigc/engines/poster_smart_v2.py @@ -110,11 +110,21 @@ class PosterSmartEngineV2(BaseEngine): theme = THEMES[theme_name] - # 4. 生成预览 PNG (无底图) - preview_base64 = self._generate_preview(content, layout, theme) + # 4. 生成预览 PNG 和 Fabric 对象 + preview_base64, fabric_objects = self._generate_preview(content, layout, theme, image_url) - # 5. 生成 Fabric JSON - fabric_json = self._generate_fabric_json(content, layout, theme, image_url) + # 5. 构建 Fabric JSON + fabric_json = { + "version": "5.3.0", + "canvas": { + "width": self.CANVAS_WIDTH, + "height": self.CANVAS_HEIGHT, + "backgroundColor": theme.secondary, + }, + "layout": layout, + "theme": theme.name, + "objects": fabric_objects if fabric_objects else self._generate_fallback_objects(content, layout, theme, image_url), + } return EngineResult( success=True, @@ -229,10 +239,12 @@ class PosterSmartEngineV2(BaseEngine): "suggested_theme": self.CATEGORY_THEME_MAP.get(category, "sunset"), } - def _generate_preview(self, content: dict, layout: str, theme: Theme) -> str: - """生成预览 PNG (无底图)""" - factory = self._get_poster_factory() + def _generate_preview(self, content: dict, layout: str, theme: Theme, image_url: str = "") -> tuple: + """生成预览 PNG (无底图) 并返回 Fabric 对象 + Returns: + (preview_base64, fabric_objects) + """ # 构建 PosterContent poster_content = PosterContent( title=content.get("title", ""), @@ -247,38 +259,56 @@ class PosterSmartEngineV2(BaseEngine): image=None, # 无底图 ) - # 生成海报 - poster_image = factory.generate_from_content(poster_content, layout=layout, theme=theme.name) + # 获取布局实例 + from poster_v2.layouts import ( + HeroBottomLayout, OverlayCenterLayout, OverlayBottomLayout, + SplitVerticalLayout, CardFloatLayout + ) + + layout_map = { + "hero_bottom": HeroBottomLayout, + "overlay_center": OverlayCenterLayout, + "overlay_bottom": OverlayBottomLayout, + "split_vertical": SplitVerticalLayout, + "card_float": CardFloatLayout, + } + + layout_class = layout_map.get(layout, HeroBottomLayout) + layout_instance = layout_class() + + # 生成海报 (同时记录 Fabric 对象) + # 所有布局都支持 image_url 参数 + try: + poster_image = layout_instance.generate(poster_content, theme, image_url=image_url) + except TypeError: + # 兼容旧版布局 + poster_image = layout_instance.generate(poster_content, theme) + + # 获取 Fabric 对象 + fabric_objects = layout_instance.get_fabric_objects() # 转 Base64 buffer = io.BytesIO() poster_image.convert("RGB").save(buffer, format="PNG") - return base64.b64encode(buffer.getvalue()).decode("utf-8") - - def _generate_fabric_json(self, content: dict, layout: str, theme: Theme, image_url: str = "") -> dict: - """生成 Fabric.js JSON""" - objects = [] + preview_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") - # 通用配置 + return preview_base64, fabric_objects + + def _generate_fallback_objects(self, content: dict, layout: str, theme: Theme, image_url: str = "") -> list: + """生成后备 Fabric.js 对象 (当布局类没有提供时)""" margin = 48 content_width = self.CANVAS_WIDTH - margin * 2 - # 1. 背景图片占位 - objects.append({ + objects = [{ "id": "background_image", "type": "image", "src": image_url or "", - "left": 0, - "top": 0, - "width": self.CANVAS_WIDTH, - "height": self.CANVAS_HEIGHT, - "scaleX": 1, - "scaleY": 1, + "left": 0, "top": 0, + "width": self.CANVAS_WIDTH, "height": self.CANVAS_HEIGHT, "selectable": True, - "evented": True, - }) + }] - # 2. 根据布局生成不同的结构 + # 简化的后备对象 if layout == "hero_bottom": objects.extend(self._fabric_hero_bottom(content, theme, margin, content_width)) elif layout == "overlay_center": @@ -290,17 +320,7 @@ class PosterSmartEngineV2(BaseEngine): elif layout == "card_float": objects.extend(self._fabric_card_float(content, theme, margin, content_width)) - return { - "version": "5.3.0", - "canvas": { - "width": self.CANVAS_WIDTH, - "height": self.CANVAS_HEIGHT, - "backgroundColor": theme.secondary, - }, - "layout": layout, - "theme": theme.name, - "objects": objects, - } + return objects def _fabric_hero_bottom(self, content: dict, theme: Theme, margin: int, content_width: int) -> List[dict]: """hero_bottom 布局的 Fabric 对象""" diff --git a/poster_v2/layouts/__pycache__/base.cpython-312.pyc b/poster_v2/layouts/__pycache__/base.cpython-312.pyc index 840fb39..a0ae6e4 100644 Binary files a/poster_v2/layouts/__pycache__/base.cpython-312.pyc and b/poster_v2/layouts/__pycache__/base.cpython-312.pyc differ diff --git a/poster_v2/layouts/__pycache__/card_float.cpython-312.pyc b/poster_v2/layouts/__pycache__/card_float.cpython-312.pyc index 36c608a..6840e57 100644 Binary files a/poster_v2/layouts/__pycache__/card_float.cpython-312.pyc and b/poster_v2/layouts/__pycache__/card_float.cpython-312.pyc differ diff --git a/poster_v2/layouts/__pycache__/hero_bottom.cpython-312.pyc b/poster_v2/layouts/__pycache__/hero_bottom.cpython-312.pyc index 5250b05..a115515 100644 Binary files a/poster_v2/layouts/__pycache__/hero_bottom.cpython-312.pyc and b/poster_v2/layouts/__pycache__/hero_bottom.cpython-312.pyc differ diff --git a/poster_v2/layouts/__pycache__/overlay_bottom.cpython-312.pyc b/poster_v2/layouts/__pycache__/overlay_bottom.cpython-312.pyc index 2b1a3a3..2fd308c 100644 Binary files a/poster_v2/layouts/__pycache__/overlay_bottom.cpython-312.pyc and b/poster_v2/layouts/__pycache__/overlay_bottom.cpython-312.pyc differ diff --git a/poster_v2/layouts/__pycache__/overlay_center.cpython-312.pyc b/poster_v2/layouts/__pycache__/overlay_center.cpython-312.pyc index 86f84b9..605489e 100644 Binary files a/poster_v2/layouts/__pycache__/overlay_center.cpython-312.pyc and b/poster_v2/layouts/__pycache__/overlay_center.cpython-312.pyc differ diff --git a/poster_v2/layouts/__pycache__/split_vertical.cpython-312.pyc b/poster_v2/layouts/__pycache__/split_vertical.cpython-312.pyc index 1627180..6311d4a 100644 Binary files a/poster_v2/layouts/__pycache__/split_vertical.cpython-312.pyc and b/poster_v2/layouts/__pycache__/split_vertical.cpython-312.pyc differ diff --git a/poster_v2/layouts/base.py b/poster_v2/layouts/base.py index 3deb234..627450c 100644 --- a/poster_v2/layouts/base.py +++ b/poster_v2/layouts/base.py @@ -23,6 +23,7 @@ class BaseLayout(ABC): 1. 计算内容区域位置和尺寸 2. 协调各渲染器绘制内容 3. 生成最终海报图像 + 4. 记录元素位置 (用于 Fabric.js) """ # 默认尺寸 (小红书 3:4) @@ -40,6 +41,21 @@ class BaseLayout(ABC): self.text = TextRenderer() self.shape = ShapeRenderer() self.effect = EffectRenderer() + + # 元素记录 (用于 Fabric.js) + self._fabric_objects = [] + + def _reset_objects(self): + """重置元素记录""" + self._fabric_objects = [] + + def _add_object(self, obj: dict): + """添加元素""" + self._fabric_objects.append(obj) + + def get_fabric_objects(self) -> list: + """获取 Fabric.js 对象列表""" + return self._fabric_objects.copy() @abstractmethod def generate(self, content: PosterContent, theme: Theme) -> Image.Image: diff --git a/poster_v2/layouts/card_float.py b/poster_v2/layouts/card_float.py index 0a1d6c8..80adc09 100644 --- a/poster_v2/layouts/card_float.py +++ b/poster_v2/layouts/card_float.py @@ -64,8 +64,10 @@ class CardFloatLayout(BaseLayout): # 限制范围 return max(500, min(height, 800)) - def generate(self, content: PosterContent, theme: Theme) -> Image.Image: + 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 @@ -79,6 +81,16 @@ class CardFloatLayout(BaseLayout): 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), @@ -95,6 +107,18 @@ class CardFloatLayout(BaseLayout): (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 @@ -128,6 +152,20 @@ class CardFloatLayout(BaseLayout): 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 # === 副标题 (支持换行) === @@ -178,13 +216,26 @@ class CardFloatLayout(BaseLayout): 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 diff --git a/poster_v2/layouts/hero_bottom.py b/poster_v2/layouts/hero_bottom.py index 04858b6..d112e37 100644 --- a/poster_v2/layouts/hero_bottom.py +++ b/poster_v2/layouts/hero_bottom.py @@ -50,8 +50,11 @@ class HeroBottomLayout(BaseLayout): return height - def generate(self, content: PosterContent, theme: Theme) -> Image.Image: + 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 @@ -73,11 +76,9 @@ class HeroBottomLayout(BaseLayout): 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: # 无图片: 渐变色 @@ -88,23 +89,48 @@ class HeroBottomLayout(BaseLayout): ) 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_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_height = 350 fade = self.effect.create_gradient( (self.width, fade_height), - (*overlay_color, 0), # 顶部:完全透明 - (*overlay_color, 200) # 底部:与实色底衔接 + (*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) @@ -127,6 +153,7 @@ class HeroBottomLayout(BaseLayout): 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 @@ -134,14 +161,43 @@ class HeroBottomLayout(BaseLayout): 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 # === 装饰线 === @@ -160,42 +216,92 @@ class HeroBottomLayout(BaseLayout): 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 - for tag in reversed(content.tags): + 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, cur_y + 20, tag_x + tag_w + 16, cur_y + 54), + (tag_x, tag_top, tag_x + tag_w + 16, tag_top + 34), radius=17, fill=(*accent, 35)) - draw.text((tag_x + 8, cur_y + 24), tag_text, + 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 diff --git a/poster_v2/layouts/overlay_bottom.py b/poster_v2/layouts/overlay_bottom.py index 20eac40..ab0b438 100644 --- a/poster_v2/layouts/overlay_bottom.py +++ b/poster_v2/layouts/overlay_bottom.py @@ -59,8 +59,10 @@ class OverlayBottomLayout(BaseLayout): return height - def generate(self, content: PosterContent, theme: Theme) -> Image.Image: + def generate(self, content: PosterContent, theme: Theme, image_url: str = "") -> Image.Image: """生成海报""" + self._reset_objects() + # 计算内容高度 content_height = self.calculate_content_height(content, theme) glass_y = self.height - content_height - 60 @@ -74,6 +76,16 @@ class OverlayBottomLayout(BaseLayout): 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, + }) + # 毛玻璃效果 glass = self.effect.create_frosted_glass( canvas, (0, glass_y, self.width, self.height), @@ -81,6 +93,16 @@ class OverlayBottomLayout(BaseLayout): ) canvas.paste(glass, (0, glass_y)) + # 毛玻璃区域 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, + }) + draw = ImageDraw.Draw(canvas) # 颜色 @@ -108,6 +130,20 @@ class OverlayBottomLayout(BaseLayout): draw, (self.MARGIN, 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": 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, + }) cur_y += title_h + 12 # === 副标题 (支持换行) === @@ -116,13 +152,27 @@ class OverlayBottomLayout(BaseLayout): draw, (self.MARGIN, cur_y), content.subtitle, subtitle_font, (*text_dark, 150), 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_dark[0]},{text_dark[1]},{text_dark[2]},0.7)", + "selectable": True, + }) cur_y += max(sub_h, 34) + 16 # === 亮点标签 (胶囊,自动换行) === max_y = self.height - 150 # 预留底部价格区域 if content.highlights: hl_x = self.MARGIN - for hl in content.highlights[:4]: # 最多4个 + hl_start_y = cur_y + for i, hl in enumerate(content.highlights[:4]): # 最多4个 hl_w, _ = TextRenderer.measure_text(hl, hl_font) # 换行检测 if hl_x + hl_w + 36 > self.width - self.MARGIN: @@ -131,6 +181,20 @@ class OverlayBottomLayout(BaseLayout): 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) + + # 亮点 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, + }) hl_x += hl_w + 28 cur_y += 44 + 16 @@ -154,12 +218,25 @@ class OverlayBottomLayout(BaseLayout): if content.price: price_w, price_h = TextRenderer.measure_text(content.price, price_font) - # 价格背景 (更明显的颜色) + # 价格背景 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) + + # 价格 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, + }) # === 标签 === if content.tags: diff --git a/poster_v2/layouts/overlay_center.py b/poster_v2/layouts/overlay_center.py index e411cf8..21a028d 100644 --- a/poster_v2/layouts/overlay_center.py +++ b/poster_v2/layouts/overlay_center.py @@ -41,8 +41,10 @@ class OverlayCenterLayout(BaseLayout): return height - def generate(self, content: PosterContent, theme: Theme) -> Image.Image: + def generate(self, content: PosterContent, theme: Theme, image_url: str = "") -> Image.Image: """生成海报""" + self._reset_objects() + # 创建渐变背景 canvas = self.create_gradient_background(theme) @@ -51,10 +53,30 @@ class OverlayCenterLayout(BaseLayout): 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, + }) + # 暗化叠加 canvas = self.effect.darken_overlay(canvas, alpha=60) draw = ImageDraw.Draw(canvas) + # 暗化遮罩 Fabric 对象 + self._add_object({ + "id": "dark_overlay", + "type": "rect", + "left": 0, "top": 0, + "width": self.width, "height": self.height, + "fill": "rgba(0,0,0,0.35)", + "selectable": False, + }) + text_white = theme.text_rgb accent = theme.accent_rgb @@ -85,6 +107,22 @@ class OverlayCenterLayout(BaseLayout): draw, (title_x, center_y), content.title, adaptive_font, theme.text, shadow_color=(0, 0, 0, 80), offset=(3, 3) ) + + # 标题 Fabric 对象 + self._add_object({ + "id": "title", + "type": "textbox", + "text": content.title, + "left": title_x, "top": center_y, + "width": content_width, + "fontSize": adaptive_font.size, + "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", + "fontWeight": "bold", + "fill": theme.text, + "textAlign": "center", + "shadow": "rgba(0,0,0,0.5) 3px 3px 6px", + "selectable": True, + }) center_y += title_h + 24 # === 副标题 (居中) === @@ -93,6 +131,20 @@ class OverlayCenterLayout(BaseLayout): sub_x = (self.width - sub_w) // 2 draw.text((sub_x, center_y), content.subtitle, font=subtitle_font, fill=(*text_white, 200)) + + # 副标题 Fabric 对象 + self._add_object({ + "id": "subtitle", + "type": "textbox", + "text": content.subtitle, + "left": sub_x, "top": center_y, + "width": content_width, + "fontSize": self.SUBTITLE_SIZE, + "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", + "fill": "rgba(255,255,255,0.85)", + "textAlign": "center", + "selectable": True, + }) center_y += sub_h + 40 # === 装饰线 (副标题下方) === diff --git a/poster_v2/layouts/split_vertical.py b/poster_v2/layouts/split_vertical.py index 928cac6..0159839 100644 --- a/poster_v2/layouts/split_vertical.py +++ b/poster_v2/layouts/split_vertical.py @@ -29,8 +29,10 @@ class SplitVerticalLayout(BaseLayout): """计算内容区域高度 (此布局不需要动态高度)""" return self.height - def generate(self, content: PosterContent, theme: Theme) -> Image.Image: + def generate(self, content: PosterContent, theme: Theme, image_url: str = "") -> Image.Image: """生成海报""" + self._reset_objects() + split = self.width // 2 # 左右各50% # 创建画布 @@ -51,6 +53,16 @@ class SplitVerticalLayout(BaseLayout): img = img.resize((split, self.height), Image.LANCZOS) canvas.paste(img, (0, 0)) + # 左侧图片 Fabric 对象 + self._add_object({ + "id": "background_image", + "type": "image", + "src": image_url, + "left": 0, "top": 0, + "width": split, "height": self.height, + "selectable": True, + }) + draw = ImageDraw.Draw(canvas) # 颜色 @@ -84,10 +96,25 @@ class SplitVerticalLayout(BaseLayout): cur_y += 44 # === 标题 (支持换行) === + title_top = cur_y _, title_h = TextRenderer.draw_wrapped_text( draw, (content_x, cur_y), content.title, title_font, text_dark, content_width, line_spacing=6 ) + + # 标题 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 + 16 # === 装饰线 (标题下方) === @@ -100,6 +127,19 @@ class SplitVerticalLayout(BaseLayout): draw, (content_x, cur_y), content.subtitle, subtitle_font, (*text_dark, 130), content_width, line_spacing=8 ) + + # 副标题 Fabric 对象 + self._add_object({ + "id": "subtitle", + "type": "textbox", + "text": content.subtitle, + "left": content_x, "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.6)", + "selectable": True, + }) cur_y += sub_h + 24 # === 特色亮点 (横排标签) === @@ -152,10 +192,34 @@ class SplitVerticalLayout(BaseLayout): draw.text((content_x, price_y), content.price, font=price_font, fill=primary) + # 价格 Fabric 对象 + self._add_object({ + "id": "price", + "type": "text", + "text": content.price, + "left": content_x, "top": price_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, price_y + price_h - 28), suffix, font=small_font, fill=(*text_dark, 120)) + + self._add_object({ + "id": "price_suffix", + "type": "text", + "text": suffix, + "left": content_x + price_w + 8, "top": price_y + price_h - 28, + "fontSize": self.SMALL_SIZE, + "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", + "fill": f"rgba({text_dark[0]},{text_dark[1]},{text_dark[2]},0.6)", + "selectable": True, + }) return canvas