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

471 lines
17 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生成专业 PPTX
使用方法:
python fast_pptx_renderer.py --deck-json <path> --output <path> --theme <theme>
主题:
deepblue - 深海蓝(默认)
caramel - 焦糖色
chinese - 中国红
输出:
JSON 格式: { "ok": true, "path": "...", "size": 12345, "slides": 7 }
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.dml.color import RGBColor
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),
"bg_gradient_end": RGBColor(15, 40, 70),
"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),
"alert": RGBColor(200, 60, 60),
},
"caramel": {
"bg": RGBColor(248, 245, 239),
"bg_gradient_end": RGBColor(240, 235, 225),
"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),
"alert": RGBColor(173, 68, 52),
},
"chinese": {
"bg": RGBColor(180, 30, 30),
"bg_gradient_end": RGBColor(140, 20, 20),
"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),
"alert": RGBColor(255, 200, 100),
},
}
FONT_CN = "Microsoft YaHei"
# ─── 工具函数 ────────────────────────────────────────────────
def money(value: float | int) -> str:
"""格式化金额。"""
return f"{round(float(value)):,}"
def pct(value: float) -> str:
"""格式化百分比。"""
return f"{value:.2f}%"
def add_bg(slide, colors: dict):
"""设置幻灯片背景色。"""
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=False, 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_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_cover_slide(prs: Presentation, product: dict, customer: dict, company: dict, colors: dict):
"""封面页。"""
slide = prs.slides.add_slide(prs.slide_layouts[6]) # 空白布局
add_bg(slide, colors)
product_name = product.get("productName", "保险计划书")
customer_name = customer.get("name", "客户")
company_name = company.get("displayName", "")
# 客户姓名
add_textbox(slide, Inches(0.8), Inches(1.5), Inches(11.7), Inches(1.0),
customer_name, font_size=42, bold=True, colors=colors)
# 副标题
add_textbox(slide, Inches(0.8), Inches(2.5), Inches(11.7), Inches(0.6),
"财富增值与传承方案", size=24, color=colors["muted"], colors=colors)
# 产品名称
add_textbox(slide, Inches(0.8), Inches(3.5), Inches(11.7), Inches(0.5),
product_name, size=16, color=colors["muted"], colors=colors)
# 公司名称
if company_name:
add_textbox(slide, Inches(0.8), Inches(4.2), Inches(11.7), Inches(0.4),
company_name, size=14, color=colors["accent"], colors=colors)
# 关键指标卡片
policy = product.get("policy", {})
annual_premium = policy.get("annualPremium", 0)
pay_years = policy.get("payYears", 0)
benefit_rows = product.get("benefitRows", [])
total_premium = annual_premium * pay_years
multiple = "-"
if benefit_rows:
last_row = benefit_rows[-1]
final_value = last_row.get("totalSurrenderValue", 0)
if total_premium > 0:
multiple = f"{final_value / total_premium:.1f}x"
metrics = [
("年缴保费", f"${money(annual_premium)}"),
("缴费年期", f"{pay_years}"),
("总投入", f"${money(total_premium)}"),
("期末倍数", multiple),
]
for i, (label, value) in enumerate(metrics):
x = Inches(0.8 + i * 3.0)
add_card(slide, x, Inches(5.0), Inches(2.5), Inches(1.2),
label, value, colors=colors, accent=(i == 3))
def add_overview_slide(prs: Presentation, product: dict, company: dict, colors: dict):
"""数据总览页。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
# 标题
add_textbox(slide, Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.6),
"保单数据总览", size=28, bold=True, colors=colors)
# 基本信息
insured = product.get("insured", {})
policy = product.get("policy", {})
info_items = [
("产品名称", product.get("productName", "")),
("受保人", f"{insured.get('name', '')} ({insured.get('age', '')}岁)"),
("保单货币", policy.get("currency", "USD")),
("年缴保费", f"${money(policy.get('annualPremium', 0))}"),
("缴费年期", f"{policy.get('payYears', 0)}"),
("保障期间", policy.get("coveragePeriod", "")),
]
for i, (label, value) in enumerate(info_items):
y = Inches(1.2 + i * 0.6)
# 标签
add_textbox(slide, Inches(1.0), y, Inches(3.0), Inches(0.4),
label, size=14, color=colors["muted"], colors=colors)
# 值
add_textbox(slide, Inches(4.0), y, Inches(8.0), Inches(0.4),
str(value), size=14, bold=True, color=colors["text_dark"], colors=colors)
# 销售洞察
sales_insights = product.get("salesInsights", {})
if sales_insights:
add_textbox(slide, Inches(0.5), Inches(5.0), Inches(12.3), Inches(0.5),
"销售洞察", size=20, bold=True, colors=colors)
key_points = sales_insights.get("keySellingPoints", [])
if key_points:
add_paragraphs(slide, Inches(1.0), Inches(5.5), Inches(11.0), Inches(1.5),
key_points[:4], size=13, bullet=True, colors=colors)
def add_benefit_table_slide(prs: Presentation, product: dict, colors: dict):
"""利益演示页。"""
benefit_rows = product.get("benefitRows", [])
if not benefit_rows:
return
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
# 标题
add_textbox(slide, Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.6),
"逐年利益演示", size=28, bold=True, colors=colors)
# 表头
headers = ["保单年度", "年龄", "已缴保费", "保证现金价值", "归原红利", "终期分红", "退保发还总额"]
col_widths = [1.5, 1.2, 1.8, 2.0, 1.8, 1.8, 2.2]
col_x = [0.5]
for w in col_widths[:-1]:
col_x.append(col_x[-1] + w)
for header, x in zip(headers, col_x):
add_textbox(slide, Inches(x), Inches(1.1), Inches(col_widths[headers.index(header)]),
Inches(0.4), header, size=11, bold=True, color=colors["accent"], colors=colors)
# 数据行最多20行
display_rows = benefit_rows[:20]
for row_idx, row in enumerate(display_rows):
y = Inches(1.5 + row_idx * 0.35)
values = [
str(row.get("policyYear", "")),
str(row.get("age", "")),
f"${money(row.get('totalPremiumPaid', 0))}",
f"${money(row.get('guaranteedCashValue', 0))}",
f"${money(row.get('reversionaryBonus', 0))}",
f"${money(row.get('terminalDividend', 0))}",
f"${money(row.get('totalSurrenderValue', 0))}",
]
for value, x in zip(values, col_x):
add_textbox(slide, Inches(x), y, Inches(1.5), Inches(0.3),
value, size=10, color=colors["text_dark"], colors=colors)
add_footer(slide, f"{len(benefit_rows)} 年数据,显示前 20 年", colors=colors)
def add_withdrawal_slide(prs: Presentation, product: dict, colors: dict):
"""退保演示页。"""
withdrawal_rows = product.get("withdrawalRows", [])
if not withdrawal_rows:
return
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
# 标题
add_textbox(slide, Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.6),
"退保/提取演示", size=28, bold=True, colors=colors)
# 表头
headers = ["保单年度", "年龄", "当年提取", "累计提取", "提取后退保价值"]
col_widths = [1.8, 1.5, 2.0, 2.0, 2.5]
col_x = [0.8]
for w in col_widths[:-1]:
col_x.append(col_x[-1] + w)
for header, x in zip(headers, col_x):
add_textbox(slide, Inches(x), Inches(1.1), Inches(col_widths[headers.index(header)]),
Inches(0.4), header, size=12, bold=True, color=colors["accent"], colors=colors)
# 数据行
display_rows = withdrawal_rows[:15]
for row_idx, row in enumerate(display_rows):
y = Inches(1.6 + row_idx * 0.38)
values = [
str(row.get("policyYear", "")),
str(row.get("age", "")),
f"${money(row.get('annualWithdrawal', 0))}",
f"${money(row.get('cumulativeWithdrawal', 0))}",
f"${money(row.get('surrenderValueAfter', 0))}",
]
for value, x in zip(values, col_x):
add_textbox(slide, Inches(x), y, Inches(2.0), Inches(0.3),
value, size=11, color=colors["text_dark"], colors=colors)
def add_summary_slide(prs: Presentation, product: dict, colors: dict):
"""总结页。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide, colors)
policy = product.get("policy", {})
benefit_rows = product.get("benefitRows", [])
annual_premium = policy.get("annualPremium", 0)
pay_years = policy.get("payYears", 0)
total_premium = annual_premium * pay_years
final_value = 0
multiple = "-"
if benefit_rows:
last_row = benefit_rows[-1]
final_value = last_row.get("totalSurrenderValue", 0)
if total_premium > 0:
multiple = f"{final_value / total_premium:.1f}x"
# 标题
add_textbox(slide, Inches(0.8), Inches(2.0), Inches(11.7), Inches(1.0),
"方案总结", size=36, bold=True, align=PP_ALIGN.CENTER, colors=colors)
# 核心数据
add_textbox(slide, Inches(0.8), Inches(3.2), Inches(11.7), Inches(0.6),
f"总投入 ${money(total_premium)} → 期末价值 ${money(final_value)}",
size=24, color=colors["muted"], align=PP_ALIGN.CENTER, colors=colors)
add_textbox(slide, Inches(0.8), Inches(4.0), Inches(11.7), Inches(0.5),
f"倍数增长 {multiple}", size=28, bold=True, color=colors["gold"],
align=PP_ALIGN.CENTER, colors=colors)
# 销售洞察
sales_insights = product.get("salesInsights", {})
if sales_insights:
narrative = sales_insights.get("suggestedNarrative", "")
if narrative:
add_textbox(slide, Inches(1.5), Inches(5.0), Inches(10.3), Inches(1.0),
narrative, size=16, color=colors["muted"],
align=PP_ALIGN.CENTER, colors=colors)
# ─── 主渲染函数 ──────────────────────────────────────────────
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)
products = deck.get("products", [])
customer = deck.get("customer", {})
company = deck.get("company", {})
for product in products:
# 封面页
add_cover_slide(prs, product, customer, company, colors)
# 数据总览
add_overview_slide(prs, product, company, colors)
# 利益演示
add_benefit_table_slide(prs, product, colors)
# 退保演示(如果有)
add_withdrawal_slide(prs, product, colors)
# 总结页
add_summary_slide(prs, product, colors)
# 保存
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:
# 读取 DeckContract JSON
with open(args.deck_json, "r", encoding="utf-8") as f:
deck = json.load(f)
# 渲染
result = render_deck(deck, args.output, args.theme)
# 输出结果到 stdout
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()