#!/usr/bin/env python3 """ fast_pptx_renderer.py — 极速 PPTX 渲染器 接收 DeckContract JSON,生成专业 PPTX 使用方法: python fast_pptx_renderer.py --deck-json --output --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()