Files
group_fqcd_jr/app/service/promotion_renderer.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

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