Files
group_fqcd_jr/app/service/promotion_renderer.py
T

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),
)