"""产品推介材料宣传长图固定模板渲染器。""" from __future__ import annotations from pathlib import Path from typing import Any from app.service.promotion_renderer import STYLE_CONFIG, resolve_visual_palette class PromotionPosterRenderer: """把同一份结构化草稿填入竖版宣传长图模板。""" WIDTH = 1800 HEIGHT = 2600 def render( self, draft: dict[str, Any], output_path: str | Path, *, photo_path: str | None = None, chart_paths: list[str] | None = None, background_path: str | None = None, ) -> str: try: from PIL import Image, ImageDraw, ImageFont except ImportError as exc: raise RuntimeError("缺少 Pillow 依赖,无法生成宣传长图") from exc style_code = str(draft.get("style_code") or "balanced_allocation") style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"]) palette = resolve_visual_palette( style_code, background_path, draft.get("visual_palette"), ) colors = { "primary": f"#{palette['primary']}", "secondary": f"#{palette['secondary']}", "accent": f"#{style['accent']}", "ink": f"#{palette['ink']}", "muted": f"#{palette['muted']}", "paper": f"#{palette['background']}", "panel": f"#{palette['panel']}", "panel_alt": f"#{palette['panel_alt']}", "white": "#FFFFFF", "line": f"#{palette['line']}", "headline": f"#{palette['headline']}", } fonts = self._fonts(ImageFont) layout_plan = draft.get("layout_plan") or {} profile = layout_plan.get("design_profile") or {} tone = str(profile.get("design_tone") or "institutional_tech") section_color = colors["primary"] if tone == "institutional_tech" else colors["accent"] canvas = Image.new("RGB", (self.WIDTH, self.HEIGHT), colors["paper"]) if background_path and Path(background_path).exists(): with Image.open(background_path) as background: canvas.paste(self._cover(background.convert("RGB"), self.WIDTH, self.HEIGHT)) # 正文区使用主题浅色保护层,让 AI 纹理可见但不压过中文内容。 body = canvas.crop((0, 462, self.WIDTH, self.HEIGHT - 115)) protection = 0.42 if profile.get("background_role") == "soft_paper" else 0.34 body = Image.blend( body, Image.new("RGB", body.size, colors["paper"]), protection, ) canvas.paste(body, (0, 462)) draw = ImageDraw.Draw(canvas) chapters = { str(item.get("title", "")): item for item in draft.get("chapters", []) if item.get("title") } poster_layout = str(layout_plan.get("poster_layout") or "poster_balanced") chart_focus = ( poster_layout == "poster_chart_focus" or profile.get("performance_layout") == "chart_dominant" ) column_ratio = self._column_ratio(str(profile.get("column_ratio") or "50_50")) product_y = 590 product_height = 320 if chart_focus else 365 manager_header_y = 1030 manager_y = 1115 manager_height = 355 history_header_y = 1505 if chart_focus else 1550 chart_y = 1585 if chart_focus else 1635 chart_height = 560 if chart_focus else 520 metrics_y = 2225 self._draw_visual_background( draw, layout_plan.get("background_theme"), colors, fonts ) self._draw_cover( draw, canvas, draft, colors, fonts, has_ai_background=bool(background_path and Path(background_path).exists()), ) self._draw_section_header( draw, "产品定位与投资策略", 90, 500, colors, fonts, fill=section_color, ) self._draw_two_columns( draw, chapters.get("产品基本信息", {}).get("body", ""), chapters.get("投资范围、策略与限制", {}).get("body", ""), 90, product_y, colors, fonts, height=product_height, left_ratio=column_ratio, ) self._draw_section_header( draw, "基金经理与投研团队", 90, manager_header_y, colors, fonts, fill=section_color, ) self._draw_manager( draw, canvas, chapters.get("管理人及投研团队", {}).get("body", ""), photo_path, 90, manager_y, colors, fonts, panel_height=manager_height, photo_width=350 if profile.get("manager_layout") == "profile_feature" else 300, ) self._draw_section_header( draw, "历史业绩与风险指标", 90, history_header_y, colors, fonts, fill=section_color, ) chart_path = next( ( item for item in (chart_paths or []) if item and Path(item).exists() ), None, ) if chart_path: self._draw_chart( canvas, chart_path, 90, chart_y, 1620, chart_height, colors, ) else: self._draw_note( draw, "当前未提供可展示的业绩曲线附件", 90, chart_y + 100, 1620, chart_height - 100, colors, fonts, ) self._draw_metrics( draw, chapters.get("收益与风险指标", {}).get("body", ""), 90, metrics_y, colors, fonts, ) self._draw_footer(draw, colors, fonts) output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) canvas.save(output, format="PNG", optimize=True) return str(output) def _draw_visual_background( self, draw: Any, theme: Any, colors: dict[str, str], fonts: dict[str, Any], ) -> None: del fonts if theme in (None, "none"): return for index in range(7): x = 1120 + index * 90 y = 90 + index * 42 draw.line((x, y, x + 460, y + 160), fill=colors["secondary"], width=4) draw.ellipse( (x + 450, y + 150, x + 466, y + 166), fill=colors["accent"], ) if theme == "geometric_grid": for index in range(8): x = 1260 + (index % 2) * 160 y = 620 + index * 105 draw.ellipse((x, y, x + 14, y + 14), fill=colors["accent"]) @staticmethod def _column_ratio(value: str) -> float: return { "40_60": 0.40, "45_55": 0.45, "50_50": 0.50, "55_45": 0.55, "60_40": 0.60, }.get(value, 0.50) @staticmethod def _fonts(image_font: Any) -> dict[str, Any]: candidates = ( Path(r"C:\Windows\Fonts\msyh.ttc"), Path(r"C:\Windows\Fonts\simhei.ttf"), Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"), ) font_path = next((item for item in candidates if item.exists()), None) if font_path is None: return { "title": image_font.load_default(), "section": image_font.load_default(), "body": image_font.load_default(), "small": image_font.load_default(), } return { "title": image_font.truetype(str(font_path), 58), "section": image_font.truetype(str(font_path), 32), "body": image_font.truetype(str(font_path), 24), "small": image_font.truetype(str(font_path), 19), "metric": image_font.truetype(str(font_path), 29), } def _draw_cover( self, draw: Any, canvas: Any, draft: dict[str, Any], colors: dict[str, str], fonts: dict[str, Any], *, has_ai_background: bool = False, ) -> None: if has_ai_background: from PIL import Image cover = canvas.crop((0, 0, self.WIDTH, 430)) cover = Image.blend( cover, Image.new("RGB", cover.size, colors["primary"]), 0.58, ) canvas.paste(cover, (0, 0)) else: draw.rectangle((0, 0, self.WIDTH, 430), fill=colors["primary"]) draw.rectangle((0, 430, self.WIDTH, 462), fill=colors["accent"]) title = str(draft.get("title") or draft.get("subtitle") or "基金产品推介材料") self._draw_wrapped( draw, title, 90, 86, 1550, fonts["title"], colors["headline"], max_lines=2, line_gap=14, ) draw.text( (95, 300), "产品推介材料", font=fonts["section"], fill=colors["headline"], ) draw.text( (95, 350), "仅供内部审核和专业投顾使用", font=fonts["small"], fill=colors["secondary"], ) # 右上角使用抽象数据线和节点,模仿参考图的金融科技视觉。 points = [(1330, 150), (1410, 188), (1490, 146), (1570, 212), (1680, 170)] draw.line(points, fill=colors["secondary"], width=5) for x, y in points: draw.ellipse((x - 10, y - 10, x + 10, y + 10), fill=colors["accent"]) draw.line((1320, 272, 1710, 272), fill=colors["secondary"], width=3) def _draw_section_header( self, draw: Any, title: str, x: int, y: int, colors: dict[str, str], fonts: dict[str, Any], *, fill: str | None = None, ) -> None: color = fill or colors["accent"] draw.rectangle((x, y, x + 720, y + 62), fill=color) draw.polygon( ((x + 720, y), (x + 760, y + 31), (x + 720, y + 62)), fill=color, ) draw.text((x + 24, y + 14), title, font=fonts["section"], fill=colors["white"]) def _draw_two_columns( self, draw: Any, left_body: str, right_body: str, x: int, y: int, colors: dict[str, str], fonts: dict[str, Any], *, height: int = 365, left_ratio: float = 0.5, ) -> dict[str, Any]: total_width = 1620 gap = 54 resolved_ratio, body_font = self._resolve_two_column_layout( draw, left_body, right_body, total_width=total_width, height=height, requested_ratio=left_ratio, font=fonts["body"], ) left_width = int((total_width - gap) * resolved_ratio) right_width = total_width - gap - left_width self._draw_text_panel( draw, left_body, x, y, left_width, height, colors, {**fonts, "body": body_font}, ) self._draw_text_panel( draw, right_body, x + left_width + gap, y, right_width, height, colors, {**fonts, "body": body_font}, ) return { "column_ratio": resolved_ratio, "font_size": getattr(body_font, "size", None), } def _resolve_two_column_layout( self, draw: Any, left_body: str, right_body: str, *, total_width: int, height: int, requested_ratio: float, font: Any, ) -> tuple[float, Any]: """在模型版式落地前检查文字容量,避免栏位比例造成溢出。""" gap = 54 ratios = [requested_ratio] if requested_ratio == 0.5: ratios.extend((0.4, 0.6)) elif requested_ratio < 0.5: ratios.append(0.45) else: ratios.append(0.55) for ratio in ratios: left_width = int((total_width - gap) * ratio) - 68 right_width = total_width - gap - int((total_width - gap) * ratio) - 68 if self._text_fits(draw, left_body, left_width, height - 60, font) and ( self._text_fits(draw, right_body, right_width, height - 60, font) ): return ratio, font fitted_font = self._fit_font( draw, (left_body, left_width), (right_body, right_width), height=height - 60, font=font, ) if self._text_fits(draw, left_body, left_width, height - 60, fitted_font) and ( self._text_fits(draw, right_body, right_width, height - 60, fitted_font) ): return ratio, fitted_font smallest = self._font_variant(font, 15) return ratios[-1], smallest def _fit_font( self, draw: Any, *text_blocks: tuple[str, int], height: int, font: Any, ) -> Any: base_size = int(getattr(font, "size", 24)) for size in range(base_size, 14, -1): candidate = self._font_variant(font, size) if all( self._text_fits(draw, text, width, height, candidate) for text, width in text_blocks ): return candidate return self._font_variant(font, 15) @staticmethod def _font_variant(font: Any, size: int) -> Any: variant = getattr(font, "font_variant", None) return variant(size=size) if variant is not None else font @classmethod def _text_fits( cls, draw: Any, text: str, width: int, height: int, font: Any, ) -> bool: line_height = int(getattr(font, "size", 24) * 1.35) lines = cls._wrapped_lines(draw, text or "资料待补充", width, font) return len(lines) * line_height + max(0, len(lines) - 1) * 8 <= height def _draw_manager( self, draw: Any, canvas: Any, body: str, photo_path: str | None, x: int, y: int, colors: dict[str, str], fonts: dict[str, Any], *, panel_height: int = 355, photo_width: int = 300, ) -> None: panel_width = 1620 draw.rectangle( (x, y, x + panel_width, y + panel_height), fill=colors["panel"], outline=colors["line"], width=2, ) photo_height = min(297, panel_height - 56) photo_box = (x + 30, y + 28, x + 30 + photo_width, y + 28 + photo_height) if photo_path and Path(photo_path).exists(): from PIL import Image with Image.open(photo_path) as source: portrait = self._contain(source.convert("RGB"), photo_width, photo_height) canvas.paste(portrait, (photo_box[0], photo_box[1])) else: draw.rectangle(photo_box, fill=colors["secondary"]) draw.text( (photo_box[0] + 70, photo_box[1] + max(20, photo_height // 2 - 10)), "未上传头像", font=fonts["small"], fill=colors["muted"], ) text_x = x + photo_width + 95 draw.line( (text_x - 45, y + 28, text_x - 45, y + panel_height - 30), fill=colors["line"], width=2, ) self._draw_wrapped( draw, body, text_x, y + 36, panel_width - photo_width - 135, fonts["body"], colors["ink"], max_lines=7 if panel_height < 355 else 9, line_gap=8, ) def _draw_chart( self, canvas: Any, chart_path: str, x: int, y: int, width: int, height: int, colors: dict[str, str], ) -> None: from PIL import Image with Image.open(chart_path) as source: chart = self._contain( source.convert("RGB"), width, height, fill=colors["panel_alt"], ) chart = self._recolor_light_background(chart, colors["panel_alt"]) canvas.paste(chart, (x, y)) def _draw_metrics( self, draw: Any, body: str, x: int, y: int, colors: dict[str, str], fonts: dict[str, Any], ) -> None: backdrop_top = y - 34 draw.rectangle( (x, backdrop_top, x + 1620, self.HEIGHT - 115), fill=colors["panel_alt"], outline=colors["line"], width=2, ) metrics: list[str] = [] for line in body.splitlines(): if any(keyword in line for keyword in ("产品收益", "最大回撤", "波动率", "夏普比率")): metrics.extend( part.strip() for part in line.replace("。", ",").split(",") if part.strip() ) metrics = metrics[:4] if not metrics: metrics = ["收益指标待补充", "风险指标待补充"] box_width = 375 for index, text in enumerate(metrics): left = x + index * (box_width + 30) draw.rectangle( (left, y, left + box_width, y + 125), fill=colors["secondary"], outline=colors["line"], width=2, ) draw.rectangle( (left, y, left + 12, y + 125), fill=colors["accent"], ) self._draw_wrapped( draw, text, left + 20, y + 28, box_width - 40, fonts.get("metric", fonts["body"]), colors["primary"], max_lines=2, ) def _draw_text_panel( self, draw: Any, body: str, x: int, y: int, width: int, height: int, colors: dict[str, str], fonts: dict[str, Any], ) -> None: draw.rectangle( (x, y, x + width, y + height), fill=colors["panel" if x < self.WIDTH // 2 else "panel_alt"], outline=colors["line"], width=2, ) draw.rectangle((x, y, x + 16, y + height), fill=colors["accent"]) self._draw_wrapped( draw, body or "资料待补充", x + 34, y + 30, width - 68, fonts["body"], colors["ink"], max_lines=1000, line_gap=8, ) def _draw_note( self, draw: Any, text: str, x: int, y: int, width: int, height: int, colors: dict[str, str], fonts: dict[str, Any], ) -> None: draw.rectangle( (x, y, x + width, y + height), fill=colors["secondary"], ) self._draw_wrapped( draw, text, x + 40, y + 70, width - 80, fonts["body"], colors["muted"], max_lines=3, ) def _draw_footer( self, draw: Any, colors: dict[str, str], fonts: dict[str, Any], ) -> None: y = self.HEIGHT - 115 draw.rectangle( (0, y, self.WIDTH, self.HEIGHT), fill=colors["panel_alt"], ) draw.rectangle((0, y, self.WIDTH, y + 8), fill=colors["primary"]) draw.text( (90, y + 32), "基金的过往业绩并不预示其未来表现,材料使用前须完成合规审核。", font=fonts["small"], fill=colors["muted"], ) @staticmethod def _contain(image: Any, width: int, height: int, *, fill: str = "#FFFFFF") -> Any: from PIL import Image ratio = min(width / image.width, height / image.height) size = (max(1, int(image.width * ratio)), max(1, int(image.height * ratio))) resized = image.resize(size, Image.Resampling.LANCZOS) canvas = Image.new("RGB", (width, height), fill) canvas.paste(resized, ((width - size[0]) // 2, (height - size[1]) // 2)) return canvas @staticmethod def _cover(image: Any, width: int, height: int) -> Any: from PIL import Image ratio = max(width / image.width, height / image.height) size = ( max(width, int(round(image.width * ratio))), max(height, int(round(image.height * ratio))), ) resized = image.resize(size, Image.Resampling.LANCZOS) left = max(0, (resized.width - width) // 2) top = max(0, (resized.height - height) // 2) return resized.crop((left, top, left + width, top + height)) @staticmethod def _recolor_light_background(image: Any, color: str) -> Any: pixels = image.load() replacement = tuple(int(color[index:index + 2], 16) for index in (1, 3, 5)) for y in range(image.height): for x in range(image.width): red, green, blue = pixels[x, y] if red >= 242 and green >= 242 and blue >= 242: pixels[x, y] = replacement return image @staticmethod def _draw_wrapped( draw: Any, text: str, x: int, y: int, width: int, font: Any, fill: str, *, max_lines: int, line_gap: int = 6, ) -> int: lines = PromotionPosterRenderer._wrapped_lines(draw, text, width, font) lines = lines[:max_lines] cursor = y line_height = int(font.size * 1.35) for line in lines: draw.text((x, cursor), line, font=font, fill=fill) cursor += line_height + line_gap return cursor @staticmethod def _wrapped_lines(draw: Any, text: str, width: int, font: Any) -> list[str]: lines: list[str] = [] for paragraph in str(text).splitlines() or [""]: current = "" for char in paragraph: candidate = current + char if draw.textlength(candidate, font=font) > width and current: lines.append(current) current = char else: current = candidate lines.append(current) return lines