"""产品推介材料安全渲染器。""" 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", "panel": "F3E5D9", "panel_alt": "EBD8C9", "ink": "56372D", "muted": "9A7564", "headline": "FFF2DF", "font": "Microsoft YaHei", }, "balanced_allocation": { "primary": "0E3B66", "secondary": "EAF2F8", "accent": "D9A441", "background": "F6F9FC", "surface": "FFFFFF", "panel": "DCEAF3", "panel_alt": "D1E2ED", "ink": "19324A", "muted": "65798B", "headline": "F7D98A", "font": "Microsoft YaHei", }, "growth_research": { "primary": "09294D", "secondary": "E8F3FB", "accent": "54A7D7", "background": "F4FAFE", "surface": "FFFFFF", "panel": "DCEEF7", "panel_alt": "D1E5F0", "ink": "18344F", "muted": "66849A", "headline": "F4D889", "font": "Microsoft YaHei", }, } def resolve_visual_palette( style_code: str, background_path: str | Path | None = None, stored_palette: Any = None, ) -> dict[str, str]: """从 AI 底图提取协调色,供 PPTX 与长图共同使用。 模型只负责底图氛围,正文可读性仍由渲染器控制。面板色保持明亮但不取纯白, 让底图的主色能穿透到内容区,同时保证中文和图表仍有足够对比度。 """ style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"]) palette = dict(style) if isinstance(stored_palette, dict): for key in ("panel", "panel_alt", "line"): value = stored_palette.get(key) if isinstance(value, str) and _is_hex_color(value): palette[key] = value.upper() if not all(key in palette for key in ("panel", "panel_alt")): palette.setdefault("panel", style["secondary"]) palette.setdefault("panel_alt", style["background"]) source_path = Path(background_path) if background_path else None if source_path and source_path.exists() and not isinstance(stored_palette, dict): try: from PIL import Image, ImageStat with Image.open(source_path) as source: sample = source.convert("RGB") sample.thumbnail((64, 64)) average = tuple(int(value) for value in ImageStat.Stat(sample).mean) base_panel = _hex_to_rgb(palette["panel"]) secondary = _hex_to_rgb(style["secondary"]) primary = _hex_to_rgb(style["primary"]) tinted = _mix_rgb(average, base_panel, 0.55) panel = _mix_rgb(tinted, (248, 250, 250), 0.58) if _luminance(panel) > 244: panel = _mix_rgb(panel, secondary, 0.45) palette["panel"] = _rgb_to_hex(panel) palette["panel_alt"] = _rgb_to_hex(_mix_rgb(panel, secondary, 0.42)) palette["line"] = _rgb_to_hex(_mix_rgb(panel, primary, 0.48)) except (OSError, ValueError, TypeError): # 底图只影响美化,不应阻断材料生成。 pass palette.setdefault("line", palette["panel_alt"]) return palette def _hex_to_rgb(value: str) -> tuple[int, int, int]: return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4)) # type: ignore[return-value] def _is_hex_color(value: str) -> bool: return len(value) == 6 and all(char in "0123456789abcdefABCDEF" for char in value) def _rgb_to_hex(value: tuple[int, int, int]) -> str: return "".join(f"{max(0, min(255, channel)):02X}" for channel in value) def _mix_rgb( first: tuple[int, int, int], second: tuple[int, int, int], second_weight: float, ) -> tuple[int, int, int]: weight = max(0.0, min(1.0, second_weight)) return tuple( int(round(left * (1 - weight) + right * weight)) for left, right in zip(first, second) ) # type: ignore[return-value] def _luminance(value: tuple[int, int, int]) -> float: return 0.2126 * value[0] + 0.7152 * value[1] + 0.0722 * value[2] class PromotionPptxRenderer: """只允许内部白名单版式影响坐标,禁止模型自行决定自由坐标。""" 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 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"]) palette = resolve_visual_palette( style_code, background_path, draft.get("visual_palette"), ) primary = RGBColor.from_string(palette["primary"]) # type: ignore[no-untyped-call] secondary = RGBColor.from_string(palette["secondary"]) # type: ignore[no-untyped-call] accent = RGBColor.from_string(style["accent"]) # type: ignore[no-untyped-call] background = RGBColor.from_string(palette["background"]) # type: ignore[no-untyped-call] surface = RGBColor.from_string(palette["panel"]) # type: ignore[no-untyped-call] surface_alt = RGBColor.from_string(palette["panel_alt"]) # 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 [] layout_plan = draft.get("layout_plan") or {} page_plans = { str(item.get("chapter_title")): str(item.get("layout")) for item in layout_plan.get("page_plans", []) if isinstance(item, dict) } background_theme = str(layout_plan.get("background_theme") or "none") for index, chapter in enumerate(chapters): slide = presentation.slides.add_slide(blank) self._paint_background(slide, background if index else primary) if background_path and Path(background_path).exists(): self._add_background_picture(slide, background_path) self._add_color_overlay( slide, primary if index == 0 else background, transparency=46 if index == 0 else 70, ) self._paint_visual_background(slide, background_theme, primary, secondary, accent) 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, ) cover_layout = layout_plan.get("cover_layout") if cover_layout == "cover_visual_right" and not ( photo_path and Path(photo_path).exists() ): self._add_visual_panel(slide, 8.55, 1.0, 3.7, 4.7, accent, secondary) if photo_path and Path(photo_path).exists(): self._add_panel(slide, 8.7, 1.15, 3.35, 4.35, surface, transparency=18 if background_path else 0) 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, transparency=22 if background_path else 0) 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", "") page_layout = page_plans.get(str(chapter.get("title")), "single_column") if page_layout == "two_columns": self._add_panel(slide, 0.72, 1.2, 5.72, 5.15, surface, transparency=22 if background_path else 0) self._add_panel(slide, 6.89, 1.2, 5.72, 5.15, surface_alt, transparency=22 if background_path else 0) left_body, right_body = self._split_body(str(body or "")) self._add_text(slide, left_body, 1.0, 1.48, 5.15, 4.55, 16, ink, font_name) self._add_text(slide, right_body, 7.17, 1.48, 5.15, 4.55, 16, ink, font_name) elif page_layout == "manager_profile" and photo_path and Path(photo_path).exists(): self._add_panel(slide, 0.72, 1.2, 3.1, 5.15, surface_alt, transparency=18 if background_path else 0) self._add_picture_contain( slide, photo_path, left=0.98, top=1.55, width=2.58, height=4.45 ) self._add_panel(slide, 4.05, 1.2, 8.57, 5.15, surface, transparency=22 if background_path else 0) self._add_text(slide, body, 4.35, 1.48, 8.0, 4.55, 17, ink, font_name) elif page_layout == "visual_focus": # 低密度页面保留全部原文,并把背景视觉集中到右侧,避免空白堆积。 self._add_panel(slide, 0.72, 1.2, 7.15, 5.15, surface, transparency=22 if background_path else 0) self._add_text(slide, body, 1.0, 1.48, 6.55, 4.55, 18, ink, font_name) self._add_visual_panel(slide, 8.18, 1.2, 4.44, 5.15, accent, secondary) else: self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface, transparency=22 if background_path else 0) text_size = 16 if page_layout == "full_width_disclosure" else 18 self._add_text( slide, body, 1.0, 1.48, 11.3, 4.55, text_size, 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 _split_body(body: str) -> tuple[str, str]: """按原始换行切分文字,不改写任何字符。""" lines = body.splitlines() midpoint = (len(lines) + 1) // 2 return "\n".join(lines[:midpoint]), "\n".join(lines[midpoint:]) @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 _paint_visual_background( slide: Any, theme: str, primary: Any, secondary: Any, accent: Any, ) -> None: from pptx.enum.shapes import MSO_SHAPE from pptx.util import Inches if theme == "none": return for index in range(5): line = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(8.8 + index * 0.55), Inches(0.2 + index * 0.18), Inches(3.0), Inches(0.015), ) line.fill.solid() line.fill.fore_color.rgb = accent if index % 2 else secondary line.fill.transparency = 35 line.line.fill.background() if theme == "geometric_grid": for index in range(1, 7): marker = slide.shapes.add_shape( MSO_SHAPE.OVAL, Inches(11.2 + (index % 2) * 0.55), Inches(1.1 + index * 0.45), Inches(0.08), Inches(0.08), ) marker.fill.solid() marker.fill.fore_color.rgb = primary marker.line.fill.background() @staticmethod def _add_background_picture(slide: Any, background_path: str) -> None: from pptx.util import Inches picture = slide.shapes.add_picture( background_path, Inches(0), Inches(0), width=Inches(13.333), height=Inches(7.5) ) slide.shapes._spTree.remove(picture._element) slide.shapes._spTree.insert(2, picture._element) @staticmethod def _add_color_overlay(slide: Any, color: Any, *, transparency: int) -> None: from pptx.enum.shapes import MSO_SHAPE from pptx.util import Inches overlay = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), Inches(13.333), Inches(7.5) ) overlay.fill.solid() overlay.fill.fore_color.rgb = color overlay.fill.transparency = transparency overlay.line.fill.background() @staticmethod def _add_visual_panel( slide: Any, left: float, top: float, width: float, height: float, accent: Any, secondary: 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 = secondary panel.fill.transparency = 18 panel.line.color.rgb = accent panel.line.transparency = 35 @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, *, transparency: int = 0, ) -> 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.fill.transparency = transparency 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), )