baodan/api/insurance/ppt/scripts/fast_pptx_renderer.py

1656 lines
66 KiB
Python
Raw Normal View History

2026-07-23 13:10:50 +08:00
#!/usr/bin/env python3
"""
fast_pptx_renderer.py PPTX 渲染器
接收 DeckContract JSON requiredPageTypes 生成专业 PPTX
2026-07-23 13:10:50 +08:00
使用方法:
python fast_pptx_renderer.py --deck-json <path> --output <path> --theme <theme>
主题:
deepblue - 深海蓝默认
caramel - 焦糖色
chinese - 中国红
2026-07-29 12:19:26 +08:00
business - 商务蓝
minimal - 极简灰
ink - 水墨黑
2026-07-23 13:10:50 +08:00
输出:
JSON: { "ok": true, "path": "...", "size": 12345, "slides": 10 }
2026-07-23 13:10:50 +08:00
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.chart.data import CategoryChartData
2026-07-23 13:10:50 +08:00
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
2026-07-23 13:10:50 +08:00
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
# ─── 主题配色 ────────────────────────────────────────────────
THEMES = {
"deepblue": {
"bg": RGBColor(10, 28, 50),
"panel": RGBColor(255, 255, 255),
"primary": RGBColor(255, 255, 255),
"text_dark": RGBColor(30, 30, 40),
"accent": RGBColor(24, 137, 141),
"accent_light": RGBColor(200, 230, 230),
"gold": RGBColor(201, 160, 39),
"muted": RGBColor(150, 160, 175),
"line": RGBColor(220, 225, 235),
"good": RGBColor(24, 168, 100),
"good_light": RGBColor(180, 230, 200),
2026-07-23 13:10:50 +08:00
"alert": RGBColor(200, 60, 60),
},
"caramel": {
"bg": RGBColor(248, 245, 239),
"panel": RGBColor(255, 255, 255),
"primary": RGBColor(38, 38, 38),
"text_dark": RGBColor(38, 38, 38),
"accent": RGBColor(206, 138, 44),
"accent_light": RGBColor(240, 230, 208),
"gold": RGBColor(206, 138, 44),
"muted": RGBColor(96, 96, 96),
"line": RGBColor(223, 214, 195),
"good": RGBColor(24, 108, 79),
"good_light": RGBColor(165, 204, 189),
2026-07-23 13:10:50 +08:00
"alert": RGBColor(173, 68, 52),
},
"chinese": {
"bg": RGBColor(180, 30, 30),
"panel": RGBColor(255, 255, 255),
"primary": RGBColor(255, 255, 255),
"text_dark": RGBColor(40, 40, 40),
"accent": RGBColor(220, 180, 60),
"accent_light": RGBColor(255, 240, 200),
"gold": RGBColor(220, 180, 60),
"muted": RGBColor(180, 160, 160),
"line": RGBColor(200, 180, 180),
"good": RGBColor(100, 180, 100),
"good_light": RGBColor(200, 230, 200),
2026-07-23 13:10:50 +08:00
"alert": RGBColor(255, 200, 100),
},
2026-07-29 12:19:26 +08:00
"business": {
"bg": RGBColor(23, 32, 51),
"panel": RGBColor(255, 255, 255),
"primary": RGBColor(255, 255, 255),
"text_dark": RGBColor(23, 32, 51),
"accent": RGBColor(37, 99, 235),
"accent_light": RGBColor(219, 234, 254),
"gold": RGBColor(201, 160, 39),
"muted": RGBColor(148, 163, 184),
"line": RGBColor(203, 213, 225),
"good": RGBColor(5, 150, 105),
"good_light": RGBColor(209, 250, 229),
"alert": RGBColor(220, 38, 38),
},
"minimal": {
"bg": RGBColor(248, 250, 252),
"panel": RGBColor(255, 255, 255),
"primary": RGBColor(15, 23, 42),
"text_dark": RGBColor(15, 23, 42),
"accent": RGBColor(71, 85, 105),
"accent_light": RGBColor(226, 232, 240),
"gold": RGBColor(161, 98, 7),
"muted": RGBColor(100, 116, 139),
"line": RGBColor(226, 232, 240),
"good": RGBColor(22, 101, 52),
"good_light": RGBColor(220, 252, 231),
"alert": RGBColor(185, 28, 28),
},
"ink": {
"bg": RGBColor(39, 39, 42),
"panel": RGBColor(250, 250, 249),
"primary": RGBColor(250, 250, 249),
"text_dark": RGBColor(39, 39, 42),
"accent": RGBColor(87, 83, 78),
"accent_light": RGBColor(231, 229, 228),
"gold": RGBColor(180, 140, 70),
"muted": RGBColor(168, 162, 158),
"line": RGBColor(214, 211, 209),
"good": RGBColor(63, 98, 18),
"good_light": RGBColor(236, 252, 203),
"alert": RGBColor(153, 27, 27),
},
2026-07-23 13:10:50 +08:00
}
FONT_CN = "Microsoft YaHei"
# ─── 计算工具(移植自 baodanppt ───────────────────────────
2026-07-23 13:10:50 +08:00
def money(value: float | int) -> str:
return f"{round(float(value)):,}"
def pct(value: float) -> str:
return f"{value:.2f}%"
def paid_premium_for_year(year: int, annual_premium: float, pay_years: int) -> float:
return float(annual_premium) * min(int(year), int(pay_years))
def simple_return(value: float, premium: float) -> float:
if premium <= 0:
return 0.0
return (float(value) / float(premium) - 1.0) * 100.0
def compound_return(value: float, premium: float, year: int) -> float:
if premium <= 0 or value <= 0 or year <= 0:
return 0.0
return ((float(value) / float(premium)) ** (1.0 / float(year)) - 1.0) * 100.0
def find_payback_year(benefit_rows: list, annual_premium: float, pay_years: int):
for row in benefit_rows:
yr = int(row.get("policyYear", 0))
sv = float(row.get("totalSurrenderValue", 0))
if sv >= paid_premium_for_year(yr, annual_premium, pay_years) and yr > 1:
return yr
return None
def decade_rows(benefit_rows, annual_premium, pay_years, withdraw_rows=None, max_year=90):
"""每 10 年数据行,支持不提领/提领两种口径。"""
targets = [y for y in [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] if y <= max_year]
by_year = {}
for r in benefit_rows:
by_year[int(r.get("policyYear", 0))] = r
w_by_year = {}
if withdraw_rows:
for r in withdraw_rows:
w_by_year[int(r.get("policyYear", 0))] = r
rows = []
for yr in targets:
base = by_year.get(yr)
wr = w_by_year.get(yr)
if not base and not wr:
continue
ref = wr or base
age = int(ref.get("age", 0))
paid = paid_premium_for_year(yr, annual_premium, pay_years)
if wr:
annual_w = float(wr.get("annualWithdrawal", 0))
cum_w = float(wr.get("cumulativeWithdrawal", 0))
sv_after = float(wr.get("surrenderValueAfter", 0))
econ = cum_w + sv_after
else:
annual_w = 0.0
cum_w = 0.0
sv_after = float(base.get("totalSurrenderValue", 0))
econ = sv_after
rows.append({
"age": age, "year": yr, "paid": paid,
"annualWithdrawal": annual_w, "cumulativeWithdrawal": cum_w,
"surrenderValue": sv_after, "economicTotal": econ,
"simpleRate": simple_return(econ, paid),
"compoundRate": compound_return(econ, paid, yr),
})
return rows
# ─── 基础绘图函数 ────────────────────────────────────────────
def add_bg(slide, colors):
2026-07-23 13:10:50 +08:00
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = colors["bg"]
def add_blank_slide(prs):
"""兼容只有少量自定义版式的源 PPTX。"""
layouts = list(prs.slide_layouts)
if not layouts:
raise ValueError("源模板没有可用幻灯片版式")
layout = next(
(
item for item in layouts
if str(getattr(item, "name", "")).lower() in {"blank", "空白"}
),
layouts[-1],
)
return prs.slides.add_slide(layout)
2026-07-23 13:10:50 +08:00
def add_textbox(slide, left, top, width, height, text, size=18, bold=False,
color=None, align=PP_ALIGN.LEFT, line_spacing=1.5, colors=None):
if color is None:
color = colors["primary"] if colors else RGBColor(255, 255, 255)
box = slide.shapes.add_textbox(left, top, width, height)
tf = box.text_frame
tf.clear()
tf.word_wrap = True
tf.vertical_anchor = MSO_ANCHOR.TOP
p = tf.paragraphs[0]
p.alignment = align
p.line_spacing = line_spacing
run = p.add_run()
run.text = str(text)
run.font.name = FONT_CN
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
return box
def add_paragraphs(slide, left, top, width, height, lines, size=15, color=None,
bullet=True, line_spacing=1.5, colors=None):
2026-07-23 13:10:50 +08:00
if color is None:
color = colors["primary"] if colors else RGBColor(255, 255, 255)
box = slide.shapes.add_textbox(left, top, width, height)
tf = box.text_frame
tf.clear()
tf.word_wrap = True
tf.vertical_anchor = MSO_ANCHOR.TOP
for idx, line in enumerate(lines):
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
p.alignment = PP_ALIGN.LEFT
p.line_spacing = line_spacing
p.text = f"{line}" if bullet else str(line)
if p.runs:
run = p.runs[0]
run.font.name = FONT_CN
run.font.size = Pt(size)
run.font.color.rgb = color
return box
def add_card(slide, left, top, width, height, title, value, subtitle="",
accent=False, colors=None, title_size=12, value_size=20, subtitle_size=10.5):
if colors is None:
colors = THEMES["deepblue"]
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = colors["accent_light"] if accent else colors["panel"]
shape.line.color.rgb = colors["line"]
add_textbox(slide, left + Inches(0.14), top + Inches(0.12), width - Inches(0.28),
Inches(0.22), title, size=title_size, bold=True, color=colors["muted"],
line_spacing=1.2, colors=colors)
add_textbox(slide, left + Inches(0.14), top + Inches(0.38), width - Inches(0.28),
Inches(0.4), value, size=value_size, bold=True,
color=colors["accent"] if accent else colors["text_dark"],
line_spacing=1.2, colors=colors)
if subtitle:
add_textbox(slide, left + Inches(0.14), top + Inches(0.82), width - Inches(0.28),
Inches(0.22), subtitle, size=subtitle_size, color=colors["muted"],
line_spacing=1.2, colors=colors)
def add_fact_card(slide, left, top, width, height, title, body,
fill_color=None, colors=None, title_size=11.5, body_size=12.8):
if colors is None:
colors = THEMES["deepblue"]
if fill_color is None:
fill_color = colors["panel"]
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = fill_color
shape.line.color.rgb = colors["line"]
add_textbox(slide, left + Inches(0.14), top + Inches(0.12), width - Inches(0.28),
Inches(0.2), title, size=title_size, bold=True, color=colors["muted"],
line_spacing=1.2, colors=colors)
add_textbox(slide, left + Inches(0.14), top + Inches(0.36), width - Inches(0.28),
height - Inches(0.48), body, size=body_size, color=colors["text_dark"],
line_spacing=1.42, colors=colors)
2026-07-23 13:10:50 +08:00
def add_footer(slide, text, colors=None):
if colors is None:
colors = THEMES["deepblue"]
add_textbox(slide, Inches(0.65), Inches(7.0), Inches(12.0), Inches(0.2),
text, size=10, color=colors["muted"], line_spacing=1.2, colors=colors)
def add_title(slide, title, subtitle=None, colors=None):
if colors is None:
colors = THEMES["deepblue"]
add_textbox(slide, Inches(0.6), Inches(0.35), Inches(8.8), Inches(0.45),
title, size=24, bold=True, line_spacing=1.2, colors=colors)
if subtitle:
add_textbox(slide, Inches(0.6), Inches(0.78), Inches(9.2), Inches(0.28),
subtitle, size=11, color=colors["muted"], line_spacing=1.2, colors=colors)
def add_line_chart(slide, left, top, width, height, categories, series_spec, colors):
chart_data = CategoryChartData()
chart_data.categories = [str(x) for x in categories]
for name, values in series_spec:
chart_data.add_series(name, values)
chart = slide.shapes.add_chart(
XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, chart_data
).chart
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.font.size = Pt(10)
chart.value_axis.has_major_gridlines = True
chart.value_axis.tick_labels.font.size = Pt(10)
chart.category_axis.tick_labels.font.size = Pt(10)
chart.value_axis.tick_labels.number_format = '$#,##0'
palette = [colors["accent"], colors["good"], RGBColor(54, 94, 140), colors["alert"]]
for idx, series in enumerate(chart.series):
series.format.line.width = Pt(2.2)
series.format.line.color.rgb = palette[idx % len(palette)]
return chart
def add_timeline_marker(slide, left, title, lines, colors):
shape = slide.shapes.add_shape(
MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
left, Inches(2.75), Inches(2.1), Inches(2.05)
)
shape.fill.solid()
shape.fill.fore_color.rgb = colors["panel"]
shape.line.color.rgb = colors["line"]
add_textbox(slide, left + Inches(0.14), Inches(2.9), Inches(1.82), Inches(0.26),
title, size=13, bold=True, color=colors["accent"],
line_spacing=1.2, colors=colors)
add_paragraphs(slide, left + Inches(0.14), Inches(3.18), Inches(1.82), Inches(1.25),
lines, size=11, bullet=False, line_spacing=1.35, colors=colors)
# ─── 幻灯片构建函数10 种 pageType ───────────────────────
def _slide_meta(slides_config, idx, page_type):
"""从 slides_config 中获取第 idx 个 slide 的元数据。"""
if slides_config and idx < len(slides_config):
return slides_config[idx]
return {"pageType": page_type}
2026-07-23 13:10:50 +08:00
def _product_summary(product):
"""从产品数据中提取关键数字摘要。"""
policy = product.get("policy", {})
ap = policy.get("annualPremium", 0)
py = policy.get("payYears", 0)
total = ap * py
br = product.get("benefitRows", [])
payback = find_payback_year(br, ap, py) if br else None
final = br[-1].get("totalSurrenderValue", 0) if br else 0
mult = f"{final / total:.1f}x" if total > 0 and final > 0 else "-"
return {"annualPremium": ap, "payYears": py, "totalPremium": total,
"paybackYear": payback, "finalValue": final, "multiple": mult,
"benefitRows": br, "withdrawalRows": product.get("withdrawalRows", []),
"productName": product.get("productName", ""),
"insured": product.get("insured", {})}
def _selected_product(deck, meta):
products = deck.get("products", [])
if not products:
return {}
try:
index = int(meta.get("productIndex", 0))
except (TypeError, ValueError):
index = 0
return products[index] if 0 <= index < len(products) else products[0]
def _source_pages(rows):
pages = sorted({
int(row["sourcePage"])
for row in rows
if row.get("sourcePage")
})
return "".join(str(page) for page in pages) if pages else "待正式计划书确认"
def _pending_money(value):
if value is None:
return "待确认"
return money(value)
def add_slide_cover(prs, deck, colors, meta):
"""封面页:客户名 + 产品名 + 公司名 + 核心指标卡片。"""
slide = add_blank_slide(prs)
2026-07-23 13:10:50 +08:00
add_bg(slide, colors)
customer = deck.get("customer", {})
products = deck.get("products", [])
company = deck.get("company", {})
product = products[0] if products else {}
s = _product_summary(product)
2026-07-23 13:10:50 +08:00
title_text = meta.get("title", "").replace("{{customerName}}", customer.get("name", "客户"))
if not title_text or "{{" in title_text:
title_text = f"{customer.get('name', '客户')} 专属方案"
2026-07-23 13:10:50 +08:00
add_textbox(slide, Inches(0.8), Inches(1.2), Inches(11.7), Inches(0.8),
title_text, size=38, bold=True, colors=colors)
2026-07-23 13:10:50 +08:00
subtitle = meta.get("subtitle", "").replace("{{productName}}", s["productName"])
if not subtitle or "{{" in subtitle:
subtitle = s["productName"] or "财富增值与传承方案"
add_textbox(slide, Inches(0.8), Inches(2.2), Inches(11.7), Inches(0.5),
subtitle, size=20, color=colors["muted"], colors=colors)
2026-07-23 13:10:50 +08:00
company_name = company.get("displayName", "")
2026-07-23 13:10:50 +08:00
if company_name:
add_textbox(slide, Inches(0.8), Inches(2.9), Inches(11.7), Inches(0.4),
2026-07-23 13:10:50 +08:00
company_name, size=14, color=colors["accent"], colors=colors)
metrics = [
("年缴保费", f"${money(s['annualPremium'])}"),
("缴费年期", f"{s['payYears']}"),
("总投入", f"${money(s['totalPremium'])}"),
("期末倍数", s["multiple"]),
2026-07-23 13:10:50 +08:00
]
for i, (label, value) in enumerate(metrics):
x = Inches(0.8 + i * 3.0)
add_card(slide, x, Inches(4.8), Inches(2.5), Inches(1.2),
2026-07-23 13:10:50 +08:00
label, value, colors=colors, accent=(i == 3))
add_footer(slide, meta.get("narrativeHint", ""), colors=colors)
2026-07-23 13:10:50 +08:00
def add_slide_company(prs, deck, colors, meta):
"""公司介绍页:公司简介 + 事实卡片。"""
slide = add_blank_slide(prs)
2026-07-23 13:10:50 +08:00
add_bg(slide, colors)
company = deck.get("company", {})
title = meta.get("title", "").replace("{{companyName}}", company.get("displayName", ""))
if not title or "{{" in title:
title = f"{company.get('displayName', '合作保司')} 公司介绍"
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
intro = company.get("companyIntro", "")
if intro:
add_paragraphs(slide, Inches(0.85), Inches(1.45), Inches(6.0), Inches(2.5),
[intro], size=13, bullet=False, line_spacing=1.5, colors=colors)
highlights = company.get("companyHighlights", [])[:4]
positions = [
(Inches(0.8), Inches(4.2)), (Inches(3.5), Inches(4.2)),
(Inches(6.2), Inches(4.2)), (Inches(8.9), Inches(4.2)),
2026-07-23 13:10:50 +08:00
]
for idx, h in enumerate(highlights):
if idx >= 4:
break
left, top = positions[idx]
text = h.get("text", "") if isinstance(h, dict) else str(h)
source = h.get("sourceFile", "") if isinstance(h, dict) else ""
fill = colors["accent_light"] if idx == 0 else colors["panel"]
add_fact_card(slide, left, top, Inches(2.5), Inches(1.8),
source[:20] if source else f"亮点 {idx+1}", text,
fill_color=fill, colors=colors, body_size=11)
rating = company.get("rating", "")
if rating:
add_textbox(slide, Inches(7.5), Inches(1.45), Inches(5.0), Inches(0.4),
f"评级: {rating}", size=14, bold=True, color=colors["accent"], colors=colors)
add_footer(slide, "公司事实来自内部知识库权威口径。", colors=colors)
def add_slide_narrative(prs, deck, colors, meta):
"""叙事页:受保人画像 + 方案逻辑 + 产品定位。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
product = _selected_product(deck, meta)
s = _product_summary(product)
insured = s["insured"]
title = meta.get("title", "方案核心逻辑")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
age = insured.get("age", "?")
name = insured.get("name", "受保人")
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(2.5), Inches(1.6),
f"{name} · {age}", f"年缴 US${money(s['annualPremium'])}\n缴费 {s['payYears']}",
fill_color=colors["accent_light"], colors=colors)
if s["paybackYear"]:
add_fact_card(slide, Inches(3.6), Inches(1.45), Inches(2.5), Inches(1.6),
"回本年份", f"约第 {s['paybackYear']}", colors=colors)
kind = product.get("kind", "savings")
if kind == "savings":
points = [
f"总保费约 US${money(s['totalPremium'])},长期退保价值可持续抬升。",
"它负责未来的确定节奏,用时间换复利。",
]
wr = s["withdrawalRows"]
if wr:
first_w = next((r for r in wr if float(r.get("annualWithdrawal", 0)) > 0), None)
if first_w:
points.insert(0, f"{int(first_w['policyYear'])} 年开始每年约 US${money(first_w['annualWithdrawal'])},可按阶段释放现金流。")
elif kind == "ci":
policy = product.get("policy", {})
points = [
f"基础保额 US${money(policy.get('sumInsured', 0))},先把家庭底线守住。",
"这张单的任务不是替代储蓄,而是保护储蓄不被提前动用。",
]
else:
policy = product.get("policy", {})
points = [
f"基础保额 US${money(policy.get('sumInsured', 0))},用有限保费建立更高身故保障。",
"它不是给短期取用,而是给家庭做长期传承和现金价值缓冲。",
]
add_paragraphs(slide, Inches(0.85), Inches(3.5), Inches(11.4), Inches(2.5),
points, size=14.5, bullet=True, colors=colors)
2026-07-23 13:10:50 +08:00
def add_slide_chart(prs, deck, colors, meta):
"""图表页python-pptx 原生折线图 + 侧边解读。"""
slide = add_blank_slide(prs)
2026-07-23 13:10:50 +08:00
add_bg(slide, colors)
product = _selected_product(deck, meta)
s = _product_summary(product)
2026-07-23 13:10:50 +08:00
chart_type = meta.get("chartType", "growth")
title = meta.get("title", "价值增长曲线" if chart_type == "growth" else "保证/非保证构成")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
2026-07-23 13:10:50 +08:00
if chart_type == "stacked":
_draw_stacked_chart(slide, s, colors)
else:
_draw_growth_chart(slide, s, colors)
2026-07-23 13:10:50 +08:00
def _draw_growth_chart(slide, s, colors):
"""增长曲线图:退保价值 vs 保证价值 vs 已交保费。"""
br = s["benefitRows"]
if not br:
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
["暂无增长数据"], size=16, bullet=False, colors=colors)
return
years = [1, 5, 10, 15, 20, 25, 30, 40, 50]
by_year = {int(r.get("policyYear", 0)): r for r in br}
valid_years = [y for y in years if y in by_year]
if not valid_years:
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
["暂无增长数据"], size=16, bullet=False, colors=colors)
2026-07-23 13:10:50 +08:00
return
series = [
("总退保价值", [float(by_year[y].get("totalSurrenderValue", 0)) for y in valid_years]),
("保证现金价值", [float(by_year[y].get("guaranteedCashValue", 0)) for y in valid_years]),
("已交总保费", [paid_premium_for_year(y, s["annualPremium"], s["payYears"]) for y in valid_years]),
]
add_line_chart(slide, Inches(0.65), Inches(1.45), Inches(6.3), Inches(4.25),
valid_years, series, colors)
notes = []
if s["paybackYear"]:
notes.append(f"回本约第 {s['paybackYear']}")
y20 = by_year.get(20)
if y20:
notes.append(f"第20年退保价值 US${money(y20.get('totalSurrenderValue', 0))}")
y30 = by_year.get(30)
if y30:
notes.append(f"第30年退保价值 US${money(y30.get('totalSurrenderValue', 0))}")
notes.append(f"期末倍数约 {s['multiple']}")
add_fact_card(slide, Inches(7.35), Inches(1.6), Inches(5.0), Inches(3.5),
"图表解读", "\n".join(notes), colors=colors)
def _draw_stacked_chart(slide, s, colors):
"""保证/非保证构成图。"""
br = s["benefitRows"]
if not br:
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
["暂无构成数据"], size=16, bullet=False, colors=colors)
return
years = [1, 5, 10, 15, 20, 25, 30, 40, 50]
by_year = {int(r.get("policyYear", 0)): r for r in br}
valid_years = [y for y in years if y in by_year]
if not valid_years:
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
["暂无构成数据"], size=16, bullet=False, colors=colors)
return
series = [
("保证现金价值", [float(by_year[y].get("guaranteedCashValue", 0)) for y in valid_years]),
("归原红利", [float(by_year[y].get("reversionaryBonus", 0)) for y in valid_years]),
("终期分红", [float(by_year[y].get("terminalDividend", 0)) for y in valid_years]),
]
add_line_chart(slide, Inches(0.65), Inches(1.45), Inches(6.3), Inches(4.25),
valid_years, series, colors)
add_fact_card(slide, Inches(7.35), Inches(1.6), Inches(5.0), Inches(3.5),
"构成解读", "先看保证底盘,再看非保证弹性,明确长期收益主要来源。",
colors=colors)
def add_slide_timeline(prs, deck, colors, meta):
"""时间轴页:横向里程碑。"""
slide = add_blank_slide(prs)
2026-07-23 13:10:50 +08:00
add_bg(slide, colors)
product = _selected_product(deck, meta)
s = _product_summary(product)
title = meta.get("title", "家庭时间轴")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
# 画横线
line = slide.shapes.add_shape(
MSO_AUTO_SHAPE_TYPE.RECTANGLE,
Inches(1.0), Inches(2.35), Inches(10.8), Inches(0.05)
)
line.fill.solid()
line.fill.fore_color.rgb = colors["line"]
line.line.color.rgb = colors["line"]
wr = {int(r.get("policyYear", 0)): r for r in s["withdrawalRows"]}
insured_age = int(s["insured"].get("age", 1))
milestones = _build_milestones(s, wr, insured_age, meta)
positions = [Inches(0.95), Inches(3.2), Inches(5.45), Inches(7.7), Inches(9.95)]
for i, ms in enumerate(milestones[:5]):
left = positions[i] if i < len(positions) else Inches(0.95 + i * 2.3)
add_timeline_marker(slide, left, ms["title"], ms["lines"], colors)
add_footer(slide, "关键里程碑按受保人年龄和保单年度展开。", colors=colors)
def _build_milestones(s, wr, insured_age, meta):
"""构建时间轴里程碑。"""
ms_list = []
ms_list.append({"title": "现在", "lines": [f"年缴 US${money(s['annualPremium'])}", "保单生效"]})
# 第一个提领点
first_w = next((r for r in s["withdrawalRows"] if float(r.get("annualWithdrawal", 0)) > 0), None)
if first_w:
wyr = int(first_w["policyYear"])
wage = insured_age + wyr
ms_list.append({"title": f"{wyr}年({wage}岁)",
"lines": [f"每年约 US${money(first_w['annualWithdrawal'])}", "开始提领"]})
# 18/21岁
for age_m in [18, 21]:
for yr, r in wr.items():
if int(r.get("age", 0)) == age_m:
ms_list.append({"title": f"{age_m}岁(第{yr}年)",
"lines": [f"累计 US${money(r.get('cumulativeWithdrawal', 0))}"]})
break
# 20年/30年
br_by_year = {int(r.get("policyYear", 0)): r for r in s["benefitRows"]}
for yr in [20, 30]:
r = br_by_year.get(yr)
if r:
ms_list.append({"title": f"{yr}",
"lines": [f"退保价值 US${money(r.get('totalSurrenderValue', 0))}"]})
return ms_list[:5]
def add_slide_table(prs, deck, colors, meta):
"""数据表页:每 10 年数据 + 单利/复利 + 侧边解读。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
2026-07-23 13:10:50 +08:00
product = _selected_product(deck, meta)
s = _product_summary(product)
table_type = meta.get("tableType", "no_withdraw")
title = meta.get("title", "不提领方案数据表每10年" if table_type == "no_withdraw" else "提领方案数据表每10年")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
has_withdraw = table_type == "withdraw" and s["withdrawalRows"]
rows = decade_rows(s["benefitRows"], s["annualPremium"], s["payYears"],
s["withdrawalRows"] if has_withdraw else None)
if not rows:
add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(1),
["暂无数据"], size=16, bullet=False, colors=colors)
return
2026-07-23 13:10:50 +08:00
if has_withdraw:
headers = ["年龄", "年度", "已交保费", "年领金额", "累计领取", "提后现价", "经济总值", "单利", "复利"]
col_widths = [0.6, 0.7, 1.1, 1.0, 1.1, 1.1, 1.1, 0.8, 0.8]
else:
headers = ["年龄", "年度", "已交保费", "退保价值", "单利", "复利"]
col_widths = [0.7, 0.8, 1.3, 1.5, 1.0, 1.0]
n_cols = len(headers)
n_rows = len(rows) + 1
table_width = Inches(sum(col_widths))
table = slide.shapes.add_table(
n_rows, n_cols, Inches(0.55), Inches(1.38), table_width, Inches(min(n_rows * 0.42, 5.0))
).table
for i, w in enumerate(col_widths):
table.columns[i].width = Inches(w)
# 表头
for c, h in enumerate(headers):
cell = table.cell(0, c)
cell.text = h
_style_cell(cell, bold=True, bg=colors["accent"])
2026-07-23 13:10:50 +08:00
# 数据行
for r_idx, row in enumerate(rows, 1):
if has_withdraw:
vals = [
str(row["age"]), str(row["year"]),
money(row["paid"]), money(row["annualWithdrawal"]),
money(row["cumulativeWithdrawal"]), money(row["surrenderValue"]),
money(row["economicTotal"]),
pct(row["simpleRate"]), pct(row["compoundRate"]),
]
else:
vals = [
str(row["age"]), str(row["year"]),
money(row["paid"]), money(row["surrenderValue"]),
pct(row["simpleRate"]), pct(row["compoundRate"]),
]
for c, v in enumerate(vals):
cell = table.cell(r_idx, c)
cell.text = v
_style_cell(cell)
# 解读面板
notes = []
if s["paybackYear"]:
notes.append(f"总保费 US${money(s['totalPremium'])},回本约第 {s['paybackYear']}")
y20 = next((r for r in rows if r["year"] == 20), None)
if y20:
mult20 = y20["economicTotal"] / s["totalPremium"] if s["totalPremium"] > 0 else 0
notes.append(f"20年约 {mult20:.1f}")
add_fact_card(slide, Inches(10.0), Inches(1.65), Inches(2.7), Inches(3.0),
"解读", "\n".join(notes) if notes else "数据来自官方计划书",
fill_color=colors["accent_light"], colors=colors)
add_footer(slide, "表格口径关键年度每10年展示数字来自官方计划书标准化结果。", colors=colors)
def _style_cell(cell, bold=False, bg=None):
"""样式化表格单元格。"""
for p in cell.text_frame.paragraphs:
p.alignment = PP_ALIGN.CENTER
for run in p.runs:
run.font.name = FONT_CN
run.font.size = Pt(10)
run.font.bold = bold
if bg:
cell.fill.solid()
cell.fill.fore_color.rgb = bg
2026-07-23 13:10:50 +08:00
def add_slide_guidance(prs, deck, colors, meta):
"""场景叙事页:只展示规则化、面向客户的说明,不补造业务数字。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "方案说明"), meta.get("narrativeHint", ""), colors=colors)
bullets = [str(item) for item in meta.get("bullets", []) if item]
if not bullets:
bullets = ["具体结论以正式计划书及客户确认资料为准。"]
add_paragraphs(
slide,
Inches(1.0),
Inches(1.65),
Inches(11.1),
Inches(4.6),
bullets,
size=18,
bullet=True,
line_spacing=1.65,
colors=colors,
)
add_footer(slide, "缺失数据统一标记待确认,不根据其他情景自行推算。", colors=colors)
def add_slide_policy_summary(prs, deck, colors, meta):
"""每份正式计划书对应一页摘要;提取与不提取情景分开。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
product = _selected_product(deck, meta)
summary = _product_summary(product)
mode = meta.get("summaryMode", "no_withdraw")
title = meta.get("title") or f"{summary['productName']} 保单摘要"
add_title(slide, title, summary["productName"], colors=colors)
policy = product.get("policy", {})
currency = policy.get("currency") or "USD"
withdrawal_rows = summary["withdrawalRows"]
benefit_rows = summary["benefitRows"]
first_withdraw = next(
(row for row in withdrawal_rows if float(row.get("annualWithdrawal", 0)) > 0),
None,
)
last_withdraw = next(
(row for row in reversed(withdrawal_rows) if float(row.get("annualWithdrawal", 0)) > 0),
None,
)
metrics = [
("投保年龄", f"{summary['insured'].get('age') or '待确认'}"),
("年缴保费", f"{currency} {_pending_money(summary['annualPremium'])}"),
("缴费年限", f"{summary['payYears'] or '待确认'}"),
("总计划保费", f"{currency} {_pending_money(summary['totalPremium'])}"),
]
if first_withdraw:
metrics.extend([
("开始提取", f"{first_withdraw.get('policyYear')} 保单年度"),
("年度提取", f"{currency} {_pending_money(first_withdraw.get('annualWithdrawal'))}"),
])
for index, (label, value) in enumerate(metrics[:6]):
left = Inches(0.7 + (index % 3) * 4.15)
top = Inches(1.25 + (index // 3) * 0.9)
add_card(
slide,
left,
top,
Inches(3.75),
Inches(0.72),
label,
value,
colors=colors,
title_size=9.5,
value_size=15,
)
if mode == "withdraw" and withdrawal_rows:
source_rows = withdrawal_rows
milestone_years = {
int(row.get("policyYear", 0))
for row in [first_withdraw, last_withdraw]
if row
} | {10, 20, 30}
rows_by_year = {
int(row.get("policyYear", 0)): row
for row in source_rows
if int(row.get("policyYear", 0)) > 0
}
selected_rows = [
rows_by_year.get(year, {"policyYear": year})
for year in sorted(year for year in milestone_years if year > 0)
]
headers = ["年龄", "保单年度", "年度提取", "累计提取", "提取后价值", "保证价值", "来源页"]
values_for = lambda row: [
str(row.get("age") or ""),
str(row.get("policyYear") or ""),
_pending_money(row.get("annualWithdrawal")),
_pending_money(row.get("cumulativeWithdrawal")),
_pending_money(row.get("surrenderValueAfter")),
_pending_money(row.get("guaranteedValueAfter")),
str(row.get("sourcePage") or "待确认"),
]
else:
source_rows = benefit_rows
milestone_years = {summary["payYears"], 10, 20, 30}
rows_by_year = {
int(row.get("policyYear", 0)): row
for row in source_rows
if int(row.get("policyYear", 0)) > 0
}
selected_rows = [
rows_by_year.get(year, {"policyYear": year})
for year in sorted(
int(year) for year in milestone_years
if year is not None and int(year) > 0
)
]
kind = product.get("kind")
if kind == "iul":
headers = ["年龄", "保单年度", "累计保费", "账户/退保价值", "身故保障", "保证现金价值", "来源页"]
values_for = lambda row: [
str(row.get("age") or ""),
str(row.get("policyYear") or ""),
_pending_money(row.get("totalPremiumPaid")),
_pending_money(row.get("totalSurrenderValue")),
_pending_money(
row.get("nonGuaranteedDeathBenefit")
or row.get("guaranteedDeathBenefit")
),
_pending_money(row.get("guaranteedCashValue")),
str(row.get("sourcePage") or "待确认"),
]
else:
headers = ["年龄", "保单年度", "累计保费", "保证价值", "非保证利益", "总退保价值", "来源页"]
values_for = lambda row: [
str(row.get("age") or ""),
str(row.get("policyYear") or ""),
_pending_money(row.get("totalPremiumPaid")),
_pending_money(row.get("guaranteedCashValue")),
_pending_money(
float(row.get("reversionaryBonus", 0))
+ float(row.get("terminalDividend", 0))
),
_pending_money(row.get("totalSurrenderValue")),
str(row.get("sourcePage") or "待确认"),
]
if not selected_rows:
add_paragraphs(
slide,
Inches(1.0),
Inches(4.0),
Inches(11.0),
Inches(1.0),
["关键年度数据待正式计划书确认"],
size=17,
bullet=False,
colors=colors,
)
else:
table = slide.shapes.add_table(
len(selected_rows) + 1,
len(headers),
Inches(0.55),
Inches(3.15),
Inches(12.2),
Inches(min(0.43 * (len(selected_rows) + 1), 2.8)),
).table
widths = [0.8, 1.0, 1.55, 1.55, 1.7, 1.7, 1.05]
for index, width in enumerate(widths):
table.columns[index].width = Inches(width)
for column, header in enumerate(headers):
table.cell(0, column).text = header
_style_cell(table.cell(0, column), bold=True, bg=colors["accent"])
for row_index, row in enumerate(selected_rows, 1):
for column, value in enumerate(values_for(row)):
table.cell(row_index, column).text = value
_style_cell(table.cell(row_index, column))
add_footer(
slide,
f"数据来源页:{_source_pages(source_rows)};保证与非保证口径不得混用。",
colors=colors,
)
def add_slide_comparison_chart(prs, deck, colors, meta):
"""多份储蓄计划按相同保单年度比较总退保价值。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "同年度价值走势"), "", colors=colors)
products = deck.get("products", [])
preferred_years = [5, 10, 20, 25, 30]
series = []
valid_years = []
for year in preferred_years:
if any(
any(int(row.get("policyYear", 0)) == year for row in product.get("benefitRows", []))
for product in products
):
valid_years.append(year)
for product in products:
by_year = {
int(row.get("policyYear", 0)): row
for row in product.get("benefitRows", [])
}
series.append((
product.get("productName") or "未命名计划",
[
(
float(by_year[year].get("totalSurrenderValue", 0))
if year in by_year
else None
)
for year in valid_years
],
))
if valid_years and series:
add_line_chart(
slide,
Inches(0.7),
Inches(1.4),
Inches(11.9),
Inches(4.8),
valid_years,
series,
colors,
)
else:
add_paragraphs(
slide,
Inches(1.0),
Inches(3.0),
Inches(11.0),
Inches(1.0),
["缺少共同关键年度,待正式计划书确认"],
size=17,
bullet=False,
colors=colors,
)
add_footer(slide, "只比较正式计划书已提供的相同保单年度。", colors=colors)
def add_slide_comparison_table(prs, deck, colors, meta):
"""在同一表中拆分各产品关键年度的保证价值和总演示价值。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "关键年度价值对比"), "", colors=colors)
products = deck.get("products", [])
years = [5, 10, 20, 30]
headers = ["产品"] + [f"{year}\n保证/总值" for year in years]
table = slide.shapes.add_table(
len(products) + 1,
len(headers),
Inches(0.55),
Inches(1.55),
Inches(12.2),
Inches(min(0.75 * (len(products) + 1), 4.7)),
).table
table.columns[0].width = Inches(2.6)
for index in range(1, len(headers)):
table.columns[index].width = Inches(2.35)
for column, header in enumerate(headers):
table.cell(0, column).text = header
_style_cell(table.cell(0, column), bold=True, bg=colors["accent"])
for row_index, product in enumerate(products, 1):
by_year = {
int(row.get("policyYear", 0)): row
for row in product.get("benefitRows", [])
}
values = [product.get("productName") or f"方案 {row_index}"]
for year in years:
row = by_year.get(year)
values.append(
"待确认" if not row else
f"{_pending_money(row.get('guaranteedCashValue'))} / "
f"{_pending_money(row.get('totalSurrenderValue'))}"
)
for column, value in enumerate(values):
table.cell(row_index, column).text = value
_style_cell(table.cell(row_index, column), bold=(column == 0))
add_footer(
slide,
"前值为保证现金价值,后值为总演示退保价值;币种和投入条件不一致时不做排名。",
colors=colors,
)
def _portfolio_products(deck):
products = deck.get("products", [])
savings = next((product for product in products if product.get("kind") == "savings"), None)
iul = next((product for product in products if product.get("kind") == "iul"), None)
return savings, iul
def _portfolio_cashflow(deck):
savings, iul = _portfolio_products(deck)
withdrawal = None
if savings:
withdrawal = next(
(
row for row in savings.get("withdrawalRows", [])
if float(row.get("annualWithdrawal", 0)) > 0
),
None,
)
iul_premium = float((iul or {}).get("policy", {}).get("annualPremium", 0))
withdrawal_amount = float((withdrawal or {}).get("annualWithdrawal", 0))
currencies = {
(product.get("policy", {}).get("currency") or "").upper()
for product in (savings, iul)
if product
}
same_currency = len(currencies) == 1
remainder = withdrawal_amount - iul_premium if same_currency and withdrawal and iul_premium else None
return savings, iul, withdrawal, iul_premium, remainder, next(iter(currencies), "USD")
def add_slide_cashflow_bridge(prs, deck, colors, meta):
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "现金流接力"), "", colors=colors)
_savings, _iul, withdrawal, iul_premium, remainder, currency = _portfolio_cashflow(deck)
items = [
("储蓄计划年度提取", f"{currency} {_pending_money((withdrawal or {}).get('annualWithdrawal'))}"),
("IUL 年度计划保费", f"{currency} {_pending_money(iul_premium or None)}"),
("覆盖保费后年度余量", f"{currency} {_pending_money(remainder)}"),
]
for index, (label, value) in enumerate(items):
add_card(
slide,
Inches(0.8 + index * 4.15),
Inches(2.0),
Inches(3.65),
Inches(1.45),
label,
value,
colors=colors,
accent=(index == 2),
)
add_paragraphs(
slide,
Inches(1.0),
Inches(4.25),
Inches(11.0),
Inches(1.3),
["年度余量 = 正式计划提取金额 IUL 正式计划保费;币种不一致时不计算。"],
size=16,
bullet=False,
colors=colors,
)
add_footer(slide, "计算只使用两份正式计划书已经提供的金额。", colors=colors)
def add_slide_launch_paths(prs, deck, colors, meta):
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "两种启动路径"), "", colors=colors)
_savings, iul, withdrawal, _premium, _remainder, _currency = _portfolio_cashflow(deck)
start_year = int((withdrawal or {}).get("policyYear", 0))
pay_years = int((iul or {}).get("policy", {}).get("payYears", 0))
funding_years = max(0, min(pay_years, start_year - 1)) if start_year else None
early_text = (
f"IUL 从第1年启动{funding_years} 年保费由客户自有资金支付,"
f"{start_year} 年起才可由储蓄提取承接。"
if funding_years is not None else
"IUL 从第1年启动储蓄正式提取前的保费由客户自有资金支付具体年数待确认。"
)
sync_text = (
f"前期先完成储蓄安排;第 {start_year} 年正式开始提取时再启动 IUL。"
if start_year else
"前期先完成储蓄安排;待正式计划书确认可提取年度后再启动 IUL。"
)
add_fact_card(
slide,
Inches(0.8),
Inches(1.55),
Inches(5.65),
Inches(3.7),
"路径一|保障先行",
early_text + "\n优点:保障建立较早。\n代价:前期现金流支出较多。",
fill_color=colors["accent_light"],
colors=colors,
body_size=14,
)
add_fact_card(
slide,
Inches(6.85),
Inches(1.55),
Inches(5.65),
Inches(3.7),
"路径二|现金流同步",
sync_text + "\n优点:无需额外准备前期 IUL 保费。\n代价:保障建立时间较晚。",
fill_color=colors["good_light"],
colors=colors,
body_size=14,
)
add_footer(slide, "两条路径没有绝对好坏,选择取决于保障紧迫度和前期现金流。", colors=colors)
def add_slide_combined_summary(prs, deck, colors, meta):
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "组合保单摘要"), "", colors=colors)
savings, iul, withdrawal, iul_premium, remainder, currency = _portfolio_cashflow(deck)
savings_summary = _product_summary(savings or {})
iul_summary = _product_summary(iul or {})
blocks = [
(
"储蓄计划",
f"年缴 {currency} {_pending_money(savings_summary['annualPremium'])} × "
f"{savings_summary['payYears'] or '待确认'}\n"
f"开始提取:第 {(withdrawal or {}).get('policyYear') or '待确认'} 保单年度\n"
f"年度提取:{currency} {_pending_money((withdrawal or {}).get('annualWithdrawal'))}",
),
(
"IUL 计划",
f"年缴 {currency} {_pending_money(iul_summary['annualPremium'])} × "
f"{iul_summary['payYears'] or '待确认'}\n"
f"总计划保费:{currency} {_pending_money(iul_summary['totalPremium'])}\n"
f"初始身故保障:{currency} "
f"{_pending_money((iul or {}).get('policy', {}).get('sumInsured'))}",
),
(
"组合结果",
f"IUL 年度保费:{currency} {_pending_money(iul_premium or None)}\n"
f"覆盖后年度余量:{currency} {_pending_money(remainder)}\n"
f"启动年度是否一致:{'' if (withdrawal or {}).get('policyYear') == 1 else ''}",
),
]
for index, (label, body) in enumerate(blocks):
add_fact_card(
slide,
Inches(0.65 + index * 4.2),
Inches(1.65),
Inches(3.85),
Inches(3.75),
label,
body,
fill_color=colors["accent_light"] if index == 2 else colors["panel"],
colors=colors,
body_size=13.2,
)
add_footer(slide, "组合总投入仅在币种一致时具有可加总意义。", colors=colors)
def add_slide_alignment_table(prs, deck, colors, meta):
slide = add_blank_slide(prs)
add_bg(slide, colors)
add_title(slide, meta.get("title", "两份计划书对应表"), "", colors=colors)
savings, iul, withdrawal, _premium, _remainder, _currency = _portfolio_cashflow(deck)
milestones = {
1,
5,
10,
20,
30,
int((withdrawal or {}).get("policyYear", 0)),
}
years = sorted(year for year in milestones if year > 0)
savings_benefits = {
int(row.get("policyYear", 0)): row
for row in (savings or {}).get("benefitRows", [])
}
savings_withdrawals = {
int(row.get("policyYear", 0)): row
for row in (savings or {}).get("withdrawalRows", [])
}
iul_benefits = {
int(row.get("policyYear", 0)): row
for row in (iul or {}).get("benefitRows", [])
}
headers = ["保单年度", "储蓄累计保费", "储蓄计划提取", "储蓄退保价值", "IUL累计保费", "IUL账户价值", "IUL身故保障"]
table = slide.shapes.add_table(
len(years) + 1,
len(headers),
Inches(0.35),
Inches(1.35),
Inches(12.6),
Inches(min(0.52 * (len(years) + 1), 4.9)),
).table
widths = [1.0, 1.65, 1.65, 1.65, 1.65, 1.65, 1.75]
for index, width in enumerate(widths):
table.columns[index].width = Inches(width)
for column, header in enumerate(headers):
table.cell(0, column).text = header
_style_cell(table.cell(0, column), bold=True, bg=colors["accent"])
for row_index, year in enumerate(years, 1):
savings_row = savings_benefits.get(year, {})
withdrawal_row = savings_withdrawals.get(year, {})
iul_row = iul_benefits.get(year, {})
values = [
str(year),
_pending_money(savings_row.get("totalPremiumPaid")),
_pending_money(withdrawal_row.get("annualWithdrawal")),
_pending_money(
withdrawal_row.get("surrenderValueAfter")
if withdrawal_row else savings_row.get("totalSurrenderValue")
),
_pending_money(iul_row.get("totalPremiumPaid")),
_pending_money(iul_row.get("totalSurrenderValue")),
_pending_money(
iul_row.get("nonGuaranteedDeathBenefit")
or iul_row.get("guaranteedDeathBenefit")
),
]
for column, value in enumerate(values):
table.cell(row_index, column).text = value
_style_cell(table.cell(row_index, column), bold=(column == 0))
add_footer(
slide,
"以保单年度和现金流发生年度为主对应;不同受保人年龄不强行对齐。",
colors=colors,
)
def add_slide_compare(prs, deck, colors, meta):
2026-07-30 10:30:33 +08:00
"""对比页:多产品对比,支持 110 份。"""
slide = add_blank_slide(prs)
2026-07-23 13:10:50 +08:00
add_bg(slide, colors)
products = deck.get("products", [])
title = meta.get("title", "产品对比")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
if len(products) < 2:
# 单产品时显示保证 vs 非保证对比
product = products[0] if products else {}
s = _product_summary(product)
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
"保证部分", f"保证现金价值\n回本约第 {s['paybackYear'] or '?'}\n确定性高",
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
"非保证部分", f"归原红利 + 终期分红\n长期弹性空间大\n取决于公司投资表现",
fill_color=colors["good_light"], colors=colors)
add_paragraphs(slide, Inches(0.85), Inches(4.5), Inches(11.4), Inches(1.5),
["保证部分是底线,非保证部分是弹性。两者合计才是长期回报的完整图景。"],
size=15, bullet=True, colors=colors)
2026-07-30 10:30:33 +08:00
elif len(products) <= 4:
# 24 产品:卡片网格布局
card_w = Inches(5.45) if len(products) == 2 else Inches(5.45)
card_h = Inches(2.5) if len(products) <= 2 else Inches(2.0)
fill_colors = [colors["accent_light"], colors["good_light"],
colors["accent_light"], colors["good_light"]]
positions = [
(Inches(0.8), Inches(1.45)),
(Inches(6.95), Inches(1.45)),
(Inches(0.8), Inches(3.7)),
(Inches(6.95), Inches(3.7)),
]
for i, product in enumerate(products):
s = _product_summary(product)
x, y = positions[i]
add_fact_card(slide, x, y, card_w, card_h,
s["productName"] or f"产品 {i + 1}",
f"年缴 ${money(s['annualPremium'])}\n"
f"总投入 ${money(s['totalPremium'])}\n"
f"回本约第 {s['paybackYear'] or '?'}",
fill_color=fill_colors[i % len(fill_colors)], colors=colors)
bottom_y = Inches(6.0) if len(products) <= 2 else Inches(6.0)
add_paragraphs(slide, Inches(0.85), bottom_y, Inches(11.4), Inches(1.0),
["对比重点在相同保单年度下的保证价值、总退保价值和回本时间。"],
size=15, bullet=True, colors=colors)
2026-07-30 10:30:33 +08:00
else:
# 5+ 产品:表格布局
from pptx.util import Emu
rows = len(products) + 1
cols = 5
table_left, table_top = Inches(0.6), Inches(1.5)
table_w, table_h = Inches(12.0), Inches(0.4 * rows + 0.2)
table_shape = slide.shapes.add_table(rows, cols, table_left, table_top, table_w, table_h)
table = table_shape.table
headers = ["产品", "年缴保费", "总投入", "回本年", "期末倍数"]
for c, h in enumerate(headers):
table.cell(0, c).text = h
_style_cell(table.cell(0, c), bold=True, bg=colors.get("accent", RGBColor(59, 122, 87)))
for r, product in enumerate(products, 1):
s = _product_summary(product)
values = [
s["productName"] or f"产品 {r}",
f"${money(s['annualPremium'])}",
f"${money(s['totalPremium'])}",
str(s["paybackYear"] or "-"),
s["multiple"],
]
for c, v in enumerate(values):
table.cell(r, c).text = v
_style_cell(table.cell(r, c), bold=(c == 0))
add_paragraphs(slide, Inches(0.85), Inches(5.5), Inches(11.4), Inches(1.0),
["以上为各产品在相同口径下的核心指标汇总,具体差异请结合正式计划书逐项核对。"],
size=14, bullet=True, colors=colors)
add_footer(slide, "", colors=colors)
def add_slide_synergy(prs, deck, colors, meta):
2026-07-30 10:30:33 +08:00
"""协同关系页:多产品如何配合,支持 N 份。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
products = deck.get("products", [])
title = meta.get("title", "协同关系")
add_title(slide, title, meta.get("narrativeHint", "功能分层,互不冲突"), colors=colors)
if len(products) >= 2:
2026-07-30 10:30:33 +08:00
fill_colors = [colors["accent_light"], colors["good_light"],
colors["accent_light"], colors["good_light"]]
if len(products) <= 2:
# 双产品:左右卡片
left_p = _product_summary(products[0])
right_p = _product_summary(products[1])
left_kind = products[0].get("kind", "savings")
right_kind = products[1].get("kind", "savings")
left_label = _kind_label(left_kind)
right_label = _kind_label(right_kind)
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.05),
f"{left_label} 负责", _kind_synergy_left(left_kind, left_p),
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.05),
f"{right_label} 负责", _kind_synergy_right(right_kind, right_p),
fill_color=colors["good_light"], colors=colors)
add_paragraphs(slide, Inches(0.85), Inches(4.0), Inches(11.3), Inches(2.0),
[f"{left_label}{right_label}不是重复配置,而是功能分层。",
"先看谁扛风险,再看谁承接未来目标。"],
size=15, bullet=True, colors=colors)
else:
# 3+ 产品:垂直列表
card_h = min(1.3, 4.5 / len(products))
for i, product in enumerate(products):
s = _product_summary(product)
kind = product.get("kind", "savings")
label = _kind_label(kind)
y = 1.45 + i * (card_h + 0.15)
body = _kind_synergy_left(kind, s) if i % 2 == 0 else _kind_synergy_right(kind, s)
add_fact_card(slide, Inches(0.8), Inches(y), Inches(11.5), Inches(card_h),
f"{label}{s['productName'] or f'产品 {i + 1}'}",
body.replace("\n", " · "),
fill_color=fill_colors[i % len(fill_colors)], colors=colors)
add_paragraphs(slide, Inches(0.85), Inches(5.8), Inches(11.3), Inches(0.8),
["各产品按功能分层配置,不重复承担相同风险。"],
size=15, bullet=True, colors=colors)
else:
add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(2),
["单产品方案,无需展示协同关系。"],
size=16, bullet=False, colors=colors)
add_footer(slide, "", colors=colors)
def _kind_label(kind):
return {"savings": "储蓄险", "ci": "重疾险", "iul": "IUL"}.get(kind, "产品")
def _kind_synergy_left(kind, s):
if kind == "ci":
return "风险来时,用重疾险扛支出\n储蓄险继续留给家庭\n教育金目标不被打断"
if kind == "iul":
return "更高身故保障底盘\n家族传承效率\n晚年流动性与应急缓冲"
return f"未来现金流节奏\n回本约第 {s['paybackYear'] or '?'}\n为教育/成长阶段提供稳健现金流"
def _kind_synergy_right(kind, s):
if kind == "ci":
return "家庭防火墙\n先扛风险支出\n保护储蓄不被提前动用"
if kind == "iul":
return "长期身故杠杆\n代际传承效率\n现金价值缓冲"
return f"长期财富累积\n期末倍数 {s['multiple']}\n时间换复利"
def add_slide_conclusion(prs, deck, colors, meta):
2026-07-30 10:30:33 +08:00
"""结论页:总结 + 核心数据,支持 N 份产品。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
2026-07-23 13:10:50 +08:00
products = deck.get("products", [])
title = meta.get("title", "结论")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
2026-07-30 10:30:33 +08:00
if len(products) <= 1:
product = products[0] if products else {}
s = _product_summary(product)
points = []
if s["paybackYear"]:
points.append(f"总投入 ${money(s['totalPremium'])},回本约第 {s['paybackYear']}")
points.append(f"期末退保价值约 ${money(s['finalValue'])},倍数 {s['multiple']}")
add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(6.0), Inches(3.0),
points, size=15, bullet=True, colors=colors)
cards = [
("总投入", f"${money(s['totalPremium'])}"),
("期末价值", f"${money(s['finalValue'])}"),
("倍数", s["multiple"]),
]
for i, (label, value) in enumerate(cards):
x = Inches(0.9 + i * 2.8)
add_card(slide, x, Inches(5.0), Inches(2.4), Inches(1.1),
label, value, colors=colors, accent=(i == 2))
else:
# 多产品:逐产品摘要 + 总览卡片
lines = []
for i, product in enumerate(products):
s = _product_summary(product)
name = s["productName"] or f"产品 {i + 1}"
line = f"{name}:投入 ${money(s['totalPremium'])},回本第 {s['paybackYear'] or '?'} 年,倍数 {s['multiple']}"
lines.append(line)
if len(products) >= 2:
lines.append("组合方案把家庭资产目标拆成不同的功能层")
add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(11.4), Inches(3.0),
lines, size=14, bullet=True, colors=colors)
# 指标卡片:展示总投入和产品数
total_investment = sum(_product_summary(p)["totalPremium"] for p in products)
cards = [
("计划书数量", f"{len(products)}"),
("总投入", f"${money(total_investment)}"),
("对比维度", "按保单年度"),
]
for i, (label, value) in enumerate(cards):
x = Inches(0.9 + i * 3.8)
add_card(slide, x, Inches(5.0), Inches(3.2), Inches(1.1),
label, value, colors=colors, accent=(i == 2))
2026-07-23 13:10:50 +08:00
add_footer(slide, "", colors=colors)
2026-07-23 13:10:50 +08:00
def add_slide_closing(prs, deck, colors, meta):
"""结束页:感谢 + 声明。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
title = meta.get("title", "感谢信任")
add_textbox(slide, Inches(0.8), Inches(2.0), Inches(11.7), Inches(1.0),
title, size=36, bold=True, align=PP_ALIGN.CENTER, colors=colors)
2026-07-23 13:10:50 +08:00
narrative = meta.get("narrativeHint", "方案可继续迭代优化,如有疑问请随时联系。")
add_textbox(slide, Inches(1.5), Inches(3.5), Inches(10.3), Inches(1.0),
narrative, size=18, color=colors["muted"],
2026-07-23 13:10:50 +08:00
align=PP_ALIGN.CENTER, colors=colors)
add_paragraphs(slide, Inches(2.5), Inches(5.0), Inches(8.3), Inches(1.5),
["本方案用于沟通理解,最终权益以保险公司正式文件为准。",
"建议尽快与您的保险经纪人预约时间完成方案确认。"],
size=12, bullet=True, color=colors["muted"], colors=colors)
# ─── pageType → builder 映射 ────────────────────────────────
SLIDE_BUILDERS = {
"cover": add_slide_cover,
"company": add_slide_company,
"narrative": add_slide_narrative,
"guidance": add_slide_guidance,
"chart": add_slide_chart,
"comparison_chart": add_slide_comparison_chart,
"comparison_table": add_slide_comparison_table,
"timeline": add_slide_timeline,
"table": add_slide_table,
"policy_summary": add_slide_policy_summary,
"compare": add_slide_compare,
"synergy": add_slide_synergy,
"cashflow_bridge": add_slide_cashflow_bridge,
"launch_paths": add_slide_launch_paths,
"combined_summary": add_slide_combined_summary,
"alignment_table": add_slide_alignment_table,
"conclusion": add_slide_conclusion,
"closing": add_slide_closing,
}
# 默认 pageType 序列(当 DeckContract 无 templateConfig 时使用)
DEFAULT_PAGE_TYPES = {
"savings": ["cover", "company", "narrative", "chart", "chart", "timeline", "timeline", "table", "table", "conclusion", "closing"],
"ci": ["cover", "company", "narrative", "chart", "table", "conclusion", "closing"],
"iul": ["cover", "company", "narrative", "chart", "compare", "table", "conclusion", "closing"],
}
def _resolve_page_types(deck):
"""从 DeckContract 中解析要渲染的页面类型列表。"""
scenario_slides = deck.get("scenarioSlides", [])
if scenario_slides:
return [slide["pageType"] for slide in scenario_slides]
tc = deck.get("templateConfig", {})
# 优先从 slidesConfig管理员配置的逐页数据中提取顺序
slides_cfg = tc.get("slidesConfig", [])
if slides_cfg and isinstance(slides_cfg, list) and len(slides_cfg) > 0:
if isinstance(slides_cfg[0], dict) and "pageType" in slides_cfg[0]:
return [s["pageType"] for s in slides_cfg]
# 其次从 requiredPageTypesJSON seed 数据)中读取
page_types = tc.get("requiredPageTypes", [])
if page_types:
return page_types
# 无配置时根据产品类型生成默认序列
products = deck.get("products", [])
kind = products[0].get("kind", "savings") if products else "savings"
return DEFAULT_PAGE_TYPES.get(kind, DEFAULT_PAGE_TYPES["savings"])
2026-07-23 13:10:50 +08:00
# ─── 主渲染函数 ──────────────────────────────────────────────
def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict:
"""渲染 DeckContract 为 PPTX。"""
colors = THEMES.get(theme, THEMES["deepblue"])
source_template_path = (
deck.get("templateConfig", {}).get("sourceTemplatePath")
)
if source_template_path and os.path.isfile(source_template_path):
prs = Presentation(source_template_path)
while len(prs.slides):
slide_id = prs.slides._sldIdLst[0]
prs.part.drop_rel(slide_id.rId)
del prs.slides._sldIdLst[0]
else:
prs = Presentation()
2026-07-23 13:10:50 +08:00
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
template_config = deck.get("templateConfig", {})
slides_config = deck.get("scenarioSlides") or template_config.get("slidesConfig", [])
page_types = _resolve_page_types(deck)
slide_errors = []
for i, page_type in enumerate(page_types):
builder = SLIDE_BUILDERS.get(page_type)
if not builder:
continue
meta = _slide_meta(slides_config, i, page_type)
try:
builder(prs, deck, colors, meta)
except Exception as e:
print(f"[warn] slide {i} ({page_type}) failed: {e}", file=sys.stderr)
slide_errors.append(f"{page_type}: {e}")
if deck.get("scenarioSlides") and slide_errors:
raise RuntimeError(
"required scenario slides failed: " + "; ".join(slide_errors)
)
2026-07-23 13:10:50 +08:00
prs.save(output_path)
file_size = os.path.getsize(output_path)
slide_count = len(prs.slides)
return {
"ok": True,
"path": output_path,
"size": file_size,
"slides": slide_count,
}
# ─── CLI 入口 ────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Fast PPTX Renderer")
parser.add_argument("--deck-json", required=True, help="DeckContract JSON 文件路径")
parser.add_argument("--output", required=True, help="输出 PPTX 文件路径")
parser.add_argument("--theme", default="deepblue",
2026-07-29 12:19:26 +08:00
choices=["deepblue", "caramel", "chinese", "business", "minimal", "ink"],
help="主题配色")
2026-07-23 13:10:50 +08:00
args = parser.parse_args()
try:
with open(args.deck_json, "r", encoding="utf-8") as f:
deck = json.load(f)
result = render_deck(deck, args.output, args.theme)
print(json.dumps(result, ensure_ascii=False))
except Exception as e:
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
sys.exit(1)
if __name__ == "__main__":
main()