"""产品推介材料宣传长图固定模板渲染器。""" from __future__ import annotations from pathlib import Path from typing import Any from app.service.promotion_renderer import STYLE_CONFIG 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, ) -> 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"]) colors = { "primary": f"#{style['primary']}", "secondary": f"#{style['secondary']}", "accent": f"#{style['accent']}", "ink": f"#{style['ink']}", "muted": f"#{style['muted']}", "paper": f"#{style['background']}", "white": f"#{style['surface']}", "line": f"#{style['secondary']}", "headline": f"#{style['headline']}", } fonts = self._fonts(ImageFont) canvas = Image.new("RGB", (self.WIDTH, self.HEIGHT), colors["paper"]) draw = ImageDraw.Draw(canvas) chapters = { str(item.get("title", "")): item for item in draft.get("chapters", []) if item.get("title") } self._draw_cover(draw, canvas, draft, colors, fonts) self._draw_section_header( draw, "产品定位与投资策略", 90, 500, colors, fonts, ) self._draw_two_columns( draw, chapters.get("产品基本信息", {}).get("body", ""), chapters.get("投资范围、策略与限制", {}).get("body", ""), 90, 590, colors, fonts, ) self._draw_section_header( draw, "基金经理与投研团队", 90, 1030, colors, fonts, ) self._draw_manager( draw, canvas, chapters.get("管理人及投研团队", {}).get("body", ""), photo_path, 90, 1115, colors, fonts, ) self._draw_section_header( draw, "历史业绩与风险指标", 90, 1550, colors, fonts, ) 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, 1635, 1620, 520) else: self._draw_note( draw, "当前未提供可展示的业绩曲线附件", 90, 1735, 1620, 260, colors, fonts, ) self._draw_metrics( draw, chapters.get("收益与风险指标", {}).get("body", ""), 90, 2185, 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) @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], ) -> None: 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], ) -> None: draw.rectangle((x, y, x + 720, y + 62), fill=colors["accent"]) draw.polygon( ((x + 720, y), (x + 760, y + 31), (x + 720, y + 62)), fill=colors["accent"], ) 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], ) -> None: column_width = 775 self._draw_text_panel( draw, left_body, x, y, column_width, 365, colors, fonts, ) self._draw_text_panel( draw, right_body, x + 850, y, column_width, 365, colors, fonts, ) 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], ) -> None: panel_width = 1620 panel_height = 355 draw.rectangle( (x, y, x + panel_width, y + panel_height), fill=colors["white"], outline=colors["line"], width=2, ) photo_box = (x + 30, y + 28, x + 330, y + 325) 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"), 300, 297) 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] + 125), "未上传头像", font=fonts["small"], fill=colors["muted"], ) draw.line((x + 365, y + 28, x + 365, y + 325), fill=colors["line"], width=2) self._draw_wrapped( draw, body, x + 410, y + 36, 1120, fonts["body"], colors["ink"], max_lines=9, line_gap=8, ) def _draw_chart( self, canvas: Any, chart_path: str, x: int, y: int, width: int, height: int, ) -> None: from PIL import Image with Image.open(chart_path) as source: chart = self._contain(source.convert("RGB"), width, height) 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: 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["white"], 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=11, 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, 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) -> 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), "#FFFFFF") canvas.paste(resized, ((width - size[0]) // 2, (height - size[1]) // 2)) return canvas @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: 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) 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