585 lines
21 KiB
Python
585 lines
21 KiB
Python
"""产品推介材料安全渲染器。"""
|
|
|
|
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),
|
|
)
|