471 lines
17 KiB
Python
471 lines
17 KiB
Python
#!/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()
|