367 lines
12 KiB
Python
367 lines
12 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",
|
|
"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),
|
|
)
|