"""产品推介材料固定模板渲染器。""" from __future__ import annotations from pathlib import Path from typing import Any STYLE_CONFIG: dict[str, dict[str, str]] = { "steady_professional": { "primary": "C94C2F", "secondary": "F6E8D9", "accent": "E16A36", "background": "FFF9F2", "surface": "FFFFFF", "ink": "56372D", "muted": "9A7564", "headline": "FFF2DF", "font": "Microsoft YaHei", }, "balanced_allocation": { "primary": "0E3B66", "secondary": "EAF2F8", "accent": "D9A441", "background": "F6F9FC", "surface": "FFFFFF", "ink": "19324A", "muted": "65798B", "headline": "F7D98A", "font": "Microsoft YaHei", }, "growth_research": { "primary": "09294D", "secondary": "E8F3FB", "accent": "54A7D7", "background": "F4FAFE", "surface": "FFFFFF", "ink": "18344F", "muted": "66849A", "headline": "F4D889", "font": "Microsoft YaHei", }, } class PromotionPptxRenderer: """只允许结构化草稿填充固定版面,禁止模型自行决定布局。""" 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 pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN from pptx.util import Inches except ImportError as exc: raise RuntimeError("缺少 python-pptx 依赖,无法生成 PPTX") from exc style_code = str(draft.get("style_code") or "balanced_allocation") style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"]) primary = RGBColor.from_string(style["primary"]) # type: ignore[no-untyped-call] secondary = RGBColor.from_string(style["secondary"]) # type: ignore[no-untyped-call] accent = RGBColor.from_string(style["accent"]) # type: ignore[no-untyped-call] background = RGBColor.from_string(style["background"]) # type: ignore[no-untyped-call] surface = RGBColor.from_string(style["surface"]) # type: ignore[no-untyped-call] ink = RGBColor.from_string(style["ink"]) # type: ignore[no-untyped-call] muted = RGBColor.from_string(style["muted"]) # type: ignore[no-untyped-call] font_name = style["font"] presentation = Presentation() presentation.slide_width = Inches(13.333) presentation.slide_height = Inches(7.5) blank = presentation.slide_layouts[6] chapters = draft.get("chapters", []) chart_paths = chart_paths or [] for index, chapter in enumerate(chapters): slide = presentation.slides.add_slide(blank) self._paint_background(slide, background if index else primary) self._add_text( slide, chapter.get("title", ""), 0.7, 0.45, 11.8, 0.55, 28, RGBColor.from_string(style["headline"] if index == 0 else style["primary"]), # type: ignore[no-untyped-call] font_name, bold=True, ) self._decorate_slide( slide, index=index, primary=primary, secondary=secondary, accent=accent, surface=surface, muted=muted, ) if index == 0: self._add_text( slide, draft.get("subtitle", ""), 0.75, 1.6, 7.3, 0.8, 24, RGBColor.from_string(style["headline"]), # type: ignore[no-untyped-call] font_name, bold=True, ) self._add_text( slide, chapter.get("body", ""), 0.75, 2.6, 7.2, 1.4, 17, RGBColor.from_string(style["headline"]), # type: ignore[no-untyped-call] font_name, ) if photo_path and Path(photo_path).exists(): self._add_panel(slide, 8.7, 1.15, 3.35, 4.35, surface) self._add_picture_contain( slide, photo_path, left=8.95, top=1.4, width=2.85, height=3.75, ) self._add_text( slide, "仅供内部审核和专业投顾使用", 0.75, 5.95, 6.8, 0.35, 12, RGBColor.from_string(style["headline"]), # type: ignore[no-untyped-call] font_name, ) continue if chapter.get("chart_index") is not None: chart_index = int(chapter["chart_index"]) if chart_index < len(chart_paths) and Path(chart_paths[chart_index]).exists(): self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface) self._add_picture_contain( slide, chart_paths[chart_index], left=0.95, top=1.45, width=11.45, height=4.65, ) else: self._add_text( slide, "当前未提供可展示的业绩曲线附件", 0.9, 1.45, 11.4, 4.9, 18, ink, font_name, ) else: body = chapter.get("body", "") self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface) self._add_text( slide, body, 1.0, 1.48, 11.3, 4.55, 18, ink, font_name, ) self._add_text( slide, f"{index + 1:02d}", 12.1, 6.85, 0.45, 0.3, 10, muted, font_name, align=PP_ALIGN.RIGHT, ) if not chapters: slide = presentation.slides.add_slide(blank) self._paint_background(slide, background) self._add_text(slide, "产品推介材料", 0.7, 0.6, 11.5, 0.6, 28, primary, font_name) output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) presentation.save(str(output)) return str(output) @staticmethod def _paint_background(slide: Any, color: Any) -> None: from pptx.enum.shapes import MSO_SHAPE from pptx.util import Inches shape = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), Inches(13.333), Inches(7.5) ) shape.fill.solid() shape.fill.fore_color.rgb = color shape.line.fill.background() slide.shapes._spTree.remove(shape._element) slide.shapes._spTree.insert(2, shape._element) @staticmethod def _decorate_slide( slide: Any, *, index: int, primary: Any, secondary: Any, accent: Any, surface: Any, muted: Any, ) -> None: from pptx.enum.shapes import MSO_SHAPE from pptx.util import Inches if index == 0: band = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0), Inches(6.92), Inches(13.333), Inches(0.58) ) band.fill.solid() band.fill.fore_color.rgb = primary band.line.fill.background() accent_line = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0), Inches(6.88), Inches(13.333), Inches(0.04) ) accent_line.fill.solid() accent_line.fill.fore_color.rgb = accent accent_line.line.fill.background() return left_rule = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0.7), Inches(1.04), Inches(2.3), Inches(0.12) ) left_rule.fill.solid() left_rule.fill.fore_color.rgb = accent left_rule.line.fill.background() right_rule = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(3.18), Inches(1.04), Inches(9.45), Inches(0.02) ) right_rule.fill.solid() right_rule.fill.fore_color.rgb = secondary right_rule.line.fill.background() footer = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0), Inches(7.12), Inches(13.333), Inches(0.38) ) footer.fill.solid() footer.fill.fore_color.rgb = primary footer.line.fill.background() marker = slide.shapes.add_shape( MSO_SHAPE.OVAL, Inches(12.45), Inches(0.33), Inches(0.3), Inches(0.3) ) marker.fill.solid() marker.fill.fore_color.rgb = accent marker.line.fill.background() @staticmethod def _add_panel( slide: Any, left: float, top: float, width: float, height: float, color: Any, ) -> None: from pptx.enum.shapes import MSO_SHAPE from pptx.util import Inches panel = slide.shapes.add_shape( MSO_SHAPE.ROUNDED_RECTANGLE, Inches(left), Inches(top), Inches(width), Inches(height), ) panel.fill.solid() panel.fill.fore_color.rgb = color panel.line.fill.background() @staticmethod def _add_text( slide: Any, text: str, left: float, top: float, width: float, height: float, size: int, color: Any, font_name: str, *, bold: bool = False, align: Any = None, ) -> None: from pptx.enum.text import MSO_ANCHOR, PP_ALIGN from pptx.util import Inches, Pt box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height)) frame = box.text_frame frame.word_wrap = True frame.vertical_anchor = MSO_ANCHOR.TOP paragraph = frame.paragraphs[0] paragraph.alignment = align or PP_ALIGN.LEFT run = paragraph.add_run() run.text = str(text or "") run.font.name = font_name run.font.size = Pt(size) run.font.bold = bold run.font.color.rgb = color @staticmethod def _add_picture_contain( slide: Any, photo_path: str, *, left: float, top: float, width: float, height: float, ) -> None: from PIL import Image from pptx.util import Inches with Image.open(photo_path) as image: image_width, image_height = image.size source_ratio = image_width / image_height target_ratio = width / height if source_ratio >= target_ratio: picture_width = width picture_height = width / source_ratio picture_left = left picture_top = top + (height - picture_height) / 2 else: picture_height = height picture_width = height * source_ratio picture_left = left + (width - picture_width) / 2 picture_top = top slide.shapes.add_picture( photo_path, Inches(picture_left), Inches(picture_top), width=Inches(picture_width), height=Inches(picture_height), )