baodan/api/insurance/ppt/scripts/fast_pptx_renderer.py
wsb1224 1debd7df39 feat(ppt): upgrade renderer from 5 fixed slides to 10+ configurable pages
Rewrite fast_pptx_renderer.py to read requiredPageTypes from template
config instead of hardcoding 5 slides. Add 10 slide builder functions
(cover, company, narrative, chart, timeline, table, compare, synergy,
conclusion, closing) with python-pptx native charts.

Key changes:
- Renderer reads templateConfig.requiredPageTypes and slidesConfig
  from DeckContract to determine slide sequence and per-slide metadata
- routes.py loads PptTemplate and PptCompany from DB, normalizes all
  PDF extractions (not just the first), passes full context to renderer
- renderer.py injects templateConfig, company info, and multi-product
  data into DeckContract
- Add slides_config_json column to PptTemplate (migrate_017) for
  per-slide title/narrative/chartType configuration via admin UI
- Admin template editor now supports drag-reorder slides, per-slide
  title/narrative hint, chart/table type selection
- Add requiredPageTypes to savings/ink, savings/minimal, savings/business
  templates (were missing, causing fallback to defaults)
- Fix IUL normalizer: add payYears and totalPremium to policy dict
- Fix CI/IUL normalizer: add totalSurrenderValue alias to benefitRows
  so charts render correctly for all product types
- Port calculation functions from baodanppt: decade_rows, paid_premium,
  simple_return, compound_return, find_payback_year

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:49:09 +08:00

961 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
fast_pptx_renderer.py — PPTX 渲染器
接收 DeckContract JSON按 requiredPageTypes 生成专业 PPTX
使用方法:
python fast_pptx_renderer.py --deck-json <path> --output <path> --theme <theme>
主题:
deepblue - 深海蓝(默认)
caramel - 焦糖色
chinese - 中国红
输出:
JSON: { "ok": true, "path": "...", "size": 12345, "slides": 10 }
"""
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
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
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),
"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),
"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),
"alert": RGBColor(255, 200, 100),
},
}
FONT_CN = "Microsoft YaHei"
# ─── 计算工具(移植自 baodanppt ───────────────────────────
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):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = colors["bg"]
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):
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)
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}
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 add_slide_cover(prs, deck, colors, meta):
"""封面页:客户名 + 产品名 + 公司名 + 核心指标卡片。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
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)
title_text = meta.get("title", "").replace("{{customerName}}", customer.get("name", "客户"))
if not title_text or "{{" in title_text:
title_text = f"{customer.get('name', '客户')} 专属方案"
add_textbox(slide, Inches(0.8), Inches(1.2), Inches(11.7), Inches(0.8),
title_text, size=38, bold=True, colors=colors)
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)
company_name = company.get("displayName", "")
if company_name:
add_textbox(slide, Inches(0.8), Inches(2.9), Inches(11.7), Inches(0.4),
company_name, size=14, color=colors["accent"], colors=colors)
metrics = [
("年缴保费", f"${money(s['annualPremium'])}"),
("缴费年期", f"{s['payYears']}"),
("总投入", f"${money(s['totalPremium'])}"),
("期末倍数", s["multiple"]),
]
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),
label, value, colors=colors, accent=(i == 3))
add_footer(slide, meta.get("narrativeHint", ""), colors=colors)
def add_slide_company(prs, deck, colors, meta):
"""公司介绍页:公司简介 + 事实卡片。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
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)),
]
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 = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
products = deck.get("products", [])
product = products[0] if products else {}
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)
def add_slide_chart(prs, deck, colors, meta):
"""图表页python-pptx 原生折线图 + 侧边解读。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
products = deck.get("products", [])
product = products[0] if products else {}
s = _product_summary(product)
chart_type = meta.get("chartType", "growth")
title = meta.get("title", "价值增长曲线" if chart_type == "growth" else "保证/非保证构成")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
if chart_type == "stacked":
_draw_stacked_chart(slide, s, colors)
else:
_draw_growth_chart(slide, s, colors)
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)
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 = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
products = deck.get("products", [])
product = products[0] if products else {}
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 = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
products = deck.get("products", [])
product = products[0] if products else {}
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
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"])
# 数据行
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
def add_slide_compare(prs, deck, colors, meta):
"""对比页:双产品对比。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
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)
else:
# 双产品对比
left_p = _product_summary(products[0])
right_p = _product_summary(products[1])
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(3.0),
left_p["productName"], f"年缴 US${money(left_p['annualPremium'])}\n"
f"总投入 US${money(left_p['totalPremium'])}\n"
f"回本约第 {left_p['paybackYear'] or '?'}",
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(3.0),
right_p["productName"], f"年缴 US${money(right_p['annualPremium'])}\n"
f"总投入 US${money(right_p['totalPremium'])}\n"
f"回本约第 {right_p['paybackYear'] or '?'}",
fill_color=colors["good_light"], colors=colors)
add_paragraphs(slide, Inches(0.85), Inches(5.0), Inches(11.4), Inches(1.5),
["组合的价值不是叠加产品数量,而是把功能拆到最清楚。"],
size=15, bullet=True, colors=colors)
add_footer(slide, "", colors=colors)
def add_slide_synergy(prs, deck, colors, meta):
"""协同关系页:多产品如何配合。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
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:
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:
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):
"""结论页:总结 + 核心数据。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
products = deck.get("products", [])
product = products[0] if products else {}
s = _product_summary(product)
title = meta.get("title", "结论")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
points = []
if s["paybackYear"]:
points.append(f"总投入 US${money(s['totalPremium'])},回本约第 {s['paybackYear']}")
points.append(f"期末退保价值约 US${money(s['finalValue'])},倍数 {s['multiple']}")
if len(products) >= 2:
points.append("组合方案把家庭资产目标拆成不同的功能层")
add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(6.0), Inches(3.0),
points, size=15, bullet=True, colors=colors)
# 核心指标卡片
cards = [
("总投入", f"US${money(s['totalPremium'])}"),
("期末价值", f"US${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))
add_footer(slide, "", colors=colors)
def add_slide_closing(prs, deck, colors, meta):
"""结束页:感谢 + 声明。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
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)
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"],
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,
"chart": add_slide_chart,
"timeline": add_slide_timeline,
"table": add_slide_table,
"compare": add_slide_compare,
"synergy": add_slide_synergy,
"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 中解析要渲染的页面类型列表。"""
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"])
# ─── 主渲染函数 ──────────────────────────────────────────────
def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict:
"""渲染 DeckContract 为 PPTX。"""
colors = THEMES.get(theme, THEMES["deepblue"])
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
template_config = deck.get("templateConfig", {})
slides_config = template_config.get("slidesConfig", [])
page_types = _resolve_page_types(deck)
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)
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",
choices=["deepblue", "caramel", "chinese"], help="主题配色")
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()