#!/usr/bin/env python3 """ fast_pptx_renderer.py — PPTX 渲染器 接收 DeckContract JSON,按 requiredPageTypes 生成专业 PPTX 使用方法: python fast_pptx_renderer.py --deck-json --output --theme 主题: deepblue - 深海蓝(默认) caramel - 焦糖色 chinese - 中国红 business - 商务蓝 minimal - 极简灰 ink - 水墨黑 输出: 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, MSO_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), }, "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), }, "sage": { "bg": RGBColor(248, 246, 239), "panel": RGBColor(255, 255, 255), "primary": RGBColor(31, 82, 67), "text_dark": RGBColor(34, 63, 55), "accent": RGBColor(47, 112, 91), "accent_light": RGBColor(224, 239, 232), "gold": RGBColor(204, 151, 57), "muted": RGBColor(92, 112, 104), "line": RGBColor(218, 224, 215), "good": RGBColor(47, 112, 91), "good_light": RGBColor(211, 233, 221), "alert": RGBColor(173, 68, 52), }, } FONT_CN = "Microsoft YaHei" # ─── 计算工具(移植自 baodanppt) ─────────────────────────── def money(value: float | int | None) -> str: if value is None: return "待确认" try: return f"{round(float(value)):,}" except (TypeError, ValueError): return "待确认" def _currency_label(currency) -> str: code = str(currency or "").upper() return { "USD": "US$", "HKD": "HK$", "CNY": "¥", "SGD": "S$", "EUR": "€", "GBP": "£", }.get(code, code or "币种待确认") def _money_with_currency(value, currency) -> str: formatted = money(value) return formatted if formatted == "待确认" else f"{_currency_label(currency)}{formatted}" def _number_or_none(value): try: return float(value) if value is not None else None except (TypeError, ValueError): return None def _is_positive(value) -> bool: number = _number_or_none(value) return number is not None and number > 0 def _sum_if_complete(*values): numbers = [_number_or_none(value) for value in values] return sum(numbers) if all(number is not None for number in numbers) else None def pct(value) -> str: number = _number_or_none(value) return "待确认" if number is None else f"{number:.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: value_number = _number_or_none(value) premium_number = _number_or_none(premium) if value_number is None or premium_number is None or premium_number <= 0: return None return (value_number / premium_number - 1.0) * 100.0 def compound_return(value: float, premium: float, year: int) -> float: value_number = _number_or_none(value) premium_number = _number_or_none(premium) year_number = _number_or_none(year) if ( value_number is None or premium_number is None or year_number is None or premium_number <= 0 or value_number <= 0 or year_number <= 0 ): return None return ((value_number / premium_number) ** (1.0 / year_number) - 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") or 0) sv = row.get("totalSurrenderValue") paid = row.get("totalPremiumPaid") if sv is not None and paid is not None and paid > 0 and sv >= paid 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 = (base or {}).get("totalPremiumPaid") if wr: annual_w = wr.get("annualWithdrawal") cum_w = wr.get("cumulativeWithdrawal") sv_after = wr.get("surrenderValueAfter") econ = cum_w + sv_after if cum_w is not None and sv_after is not None else None else: annual_w = None cum_w = None sv_after = base.get("totalSurrenderValue") 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) if econ is not None and paid is not None else None, "compoundRate": compound_return(econ, paid, yr) if econ is not None and paid is not None else None, }) return rows # ─── 基础绘图函数 ──────────────────────────────────────────── def add_bg(slide, colors): fill = slide.background.fill fill.solid() fill.fore_color.rgb = colors["bg"] def add_blank_slide(prs): """兼容只有少量自定义版式的源 PPTX。""" template_queue = getattr(prs, "_insurance_template_slide_queue", None) if template_queue: return template_queue.pop(0) 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) 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") py = policy.get("payYears") total = policy.get("contractualTotalPremium") if total is None: total = policy.get("totalPremium") br = product.get("benefitRows", []) payback = find_payback_year(br, ap, py) if br and ap is not None and py else None final = br[-1].get("totalSurrenderValue") if br else None mult = f"{final / total:.1f}x" if total and final is not None 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", ""), "companyName": (product.get("company") or {}).get("displayName", ""), "insured": product.get("insured", {}), "currency": policy.get("currency")} 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) 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 = [ ("年缴保费", _money_with_currency(s["annualPremium"], s["currency"])), ("缴费年期", f"{s['payYears']}年"), ("合同总保费", _money_with_currency(s["totalPremium"], s["currency"])), ("期末倍数", 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 = add_blank_slide(prs) 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 = 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"年缴 {_money_with_currency(s['annualPremium'], s['currency'])}\n缴费 {s['payYears'] or '待确认'} 年", 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"合同总保费 {_money_with_currency(s['totalPremium'], s['currency'])},长期价值以正式计划书为准。", "它负责未来的确定节奏,用时间换复利。", ] wr = s["withdrawalRows"] if wr: first_w = next((r for r in wr if _is_positive(r.get("annualWithdrawal"))), None) if first_w: points.insert(0, f"第 {int(first_w['policyYear'])} 年开始每年约 {_money_with_currency(first_w['annualWithdrawal'], s['currency'])},可按阶段释放现金流。") elif kind == "ci": policy = product.get("policy", {}) points = [ f"基础保额 {_money_with_currency(policy.get('sumInsured'), policy.get('currency'))},先把家庭底线守住。", "这张单的任务不是替代储蓄,而是保护储蓄不被提前动用。", ] else: policy = product.get("policy", {}) points = [ f"基础保额 {_money_with_currency(policy.get('sumInsured'), policy.get('currency'))},用有限保费建立更高身故保障。", "它不是给短期取用,而是给家庭做长期传承和现金价值缓冲。", ] 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 = add_blank_slide(prs) add_bg(slide, colors) product = _selected_product(deck, meta) 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 = [ ("总退保价值", [by_year[y].get("totalSurrenderValue") for y in valid_years]), ("保证现金价值", [by_year[y].get("guaranteedCashValue") for y in valid_years]), ("已交总保费", [by_year[y].get("totalPremiumPaid") 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年退保价值 {_money_with_currency(y20.get('totalSurrenderValue'), s['currency'])}") y30 = by_year.get(30) if y30: notes.append(f"第30年退保价值 {_money_with_currency(y30.get('totalSurrenderValue'), s['currency'])}") 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 = [ ("保证现金价值", [by_year[y].get("guaranteedCashValue") for y in valid_years]), ("归原红利", [by_year[y].get("reversionaryBonus") for y in valid_years]), ("终期分红", [by_year[y].get("terminalDividend") 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) 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"年缴 {_money_with_currency(s['annualPremium'], s['currency'])}", "保单生效"]}) # 第一个提领点 first_w = next((r for r in s["withdrawalRows"] if _is_positive(r.get("annualWithdrawal"))), None) if first_w: wyr = int(first_w["policyYear"]) wage = insured_age + wyr ms_list.append({"title": f"第{wyr}年({wage}岁)", "lines": [f"每年约 {_money_with_currency(first_w['annualWithdrawal'], s['currency'])}", "开始提领"]}) # 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"累计 {_money_with_currency(r.get('cumulativeWithdrawal'), s['currency'])}"]}) 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"退保价值 {_money_with_currency(r.get('totalSurrenderValue'), s['currency'])}"]}) return ms_list[:5] def add_slide_table(prs, deck, colors, meta): """数据表页:每 10 年数据 + 单利/复利 + 侧边解读。""" slide = add_blank_slide(prs) add_bg(slide, colors) 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 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"合同总保费 {_money_with_currency(s['totalPremium'], s['currency'])},回本约第 {s['paybackYear']} 年") y20 = next((r for r in rows if r["year"] == 20), None) if y20 and _is_positive(y20.get("economicTotal")) and _is_positive(s.get("totalPremium")): mult20 = y20["economicTotal"] / s["totalPremium"] 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_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']} 保单摘要" subtitle = summary["productName"] if summary.get("companyName"): subtitle = f"{summary['companyName']}|{summary['productName']}" add_title(slide, title, subtitle, 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 _is_positive(row.get("annualWithdrawal"))), None, ) last_withdraw = next( (row for row in reversed(withdrawal_rows) if _is_positive(row.get("annualWithdrawal"))), 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(_sum_if_complete( row.get("reversionaryBonus"), row.get("terminalDividend"), )), _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 "未命名计划", [ ( _number_or_none(by_year[year].get("totalSurrenderValue")) 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 _is_positive(row.get("annualWithdrawal")) ), None, ) iul_premium = _number_or_none((iul or {}).get("policy", {}).get("annualPremium")) withdrawal_amount = _number_or_none((withdrawal or {}).get("annualWithdrawal")) 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_amount is not None and iul_premium is not None 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 _compare_card_body(product, comparison_product): """按险种生成对比卡片正文。CI/IUL 使用与储蓄险不同的指标。""" kind = str(product.get("kind") or "savings").lower() policy = product.get("policy") or {} ap = policy.get("annualPremium") currency = policy.get("currency") if kind == "ci": si = policy.get("sumInsured") br = product.get("benefitRows") or [] period = int(br[-1].get("policyYear", 0)) if br else 0 return (f"年缴 {_money_with_currency(ap, currency)}\n" f"基本保额 {_money_with_currency(si, currency)}\n" f"保障期 {period} 年") if kind == "iul": # 优先使用比较契约中已计算的年份数据 yv = (comparison_product or {}).get("yearValues") or {} g10 = None ng10 = None for yr_key in ("10", "20", "30"): vals = yv.get(yr_key) if vals: g10 = vals.get("guaranteedValue") ng10 = vals.get("totalValue") break if g10 is None: br = product.get("benefitRows") or [] row10 = next((r for r in br if int(r.get("policyYear", 0)) == 10), None) if row10: g10 = row10.get("guaranteedCashValue") ng10 = row10.get("totalSurrenderValue") return (f"年缴 {_money_with_currency(ap, currency)}\n" f"第10年保证 {_money_with_currency(g10, currency)}\n" f"第10年非保证 {_money_with_currency(ng10, currency)}") # 储蓄险(默认) s = _product_summary(product) return (f"年缴 {_money_with_currency(s['annualPremium'], s['currency'])}\n" f"合同总保费 {_money_with_currency(s['totalPremium'], s['currency'])}\n" f"回本约第 {s['paybackYear'] or '?'} 年") def add_slide_compare(prs, deck, colors, meta): """对比页:多产品对比,支持 1~10 份。按险种显示不同指标。""" slide = add_blank_slide(prs) add_bg(slide, colors) products = deck.get("products", []) comparison = deck.get("comparison") or {} comp_products = comparison.get("products") or [] title = meta.get("title", "产品对比") add_title(slide, title, meta.get("narrativeHint", ""), colors=colors) if len(products) < 2: # 单产品时显示保证 vs 非保证对比 product = products[0] if products else {} kind = str(product.get("kind") or "savings").lower() s = _product_summary(product) if kind == "ci": policy = product.get("policy") or {} add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5), "重疾保障", f"基本保额 {_money_with_currency(policy.get('sumInsured'), policy.get('currency'))}\n保障期内确诊即赔\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赔付比例通常 20%~25%\n不终止主保单", fill_color=colors["good_light"], colors=colors) elif kind == "iul": add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5), "保证部分", f"保证现金价值\n最低身故保障\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) else: 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) elif len(products) <= 4: # 2~4 产品:卡片网格,按险种显示不同指标 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): cp = comp_products[i] if i < len(comp_products) else None body = _compare_card_body(product, cp) x, y = positions[i] name = product.get("productName", "") or f"产品 {i + 1}" kind_label = _kind_label(product.get("kind", "savings")) add_fact_card(slide, x, y, Inches(5.45), card_h, f"{name}({kind_label})", body, 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) else: # 5+ 产品:表格布局,按险种调整列 kinds = {str(p.get("kind") or "savings").lower() for p in products} if len(kinds) == 1 and "ci" in kinds: headers = ["产品", "年缴保费", "基本保额", "保障期", "缴费年期"] elif len(kinds) == 1 and "iul" in kinds: headers = ["产品", "年缴保费", "第10年保证", "第10年非保证", "缴费年期"] else: headers = ["产品", "险种", "年缴保费", "回本年", "期末倍数"] rows = len(products) + 1 cols = len(headers) 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 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): kind = str(product.get("kind") or "savings").lower() policy = product.get("policy") or {} s = _product_summary(product) name = product.get("productName", "") or f"产品 {r}" if len(kinds) == 1 and "ci" in kinds: br = product.get("benefitRows") or [] period = int(br[-1].get("policyYear", 0)) if br else 0 values = [name, _money_with_currency(policy.get('annualPremium'), policy.get('currency')), _money_with_currency(policy.get('sumInsured'), policy.get('currency')), f"{period} 年", f"{policy.get('payYears', '-')} 年"] elif len(kinds) == 1 and "iul" in kinds: cp = comp_products[r - 1] if (r - 1) < len(comp_products) else None yv = (cp or {}).get("yearValues") or {} v10 = yv.get("10") or {} values = [name, _money_with_currency(policy.get('annualPremium'), policy.get('currency')), _money_with_currency(v10.get('guaranteedValue'), policy.get('currency')), _money_with_currency(v10.get('totalValue'), policy.get('currency')), f"{policy.get('payYears', '-')} 年"] else: values = [name, _kind_label(kind), _money_with_currency(s['annualPremium'], s['currency']), 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): """协同关系页:多产品如何配合,支持 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: 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): """结论页:总结 + 核心数据,支持 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) <= 1: product = products[0] if products else {} s = _product_summary(product) points = [] if s["paybackYear"]: points.append(f"合同总保费 {_money_with_currency(s['totalPremium'], s['currency'])},回本约第 {s['paybackYear']} 年") points.append(f"期末退保价值约 {_money_with_currency(s['finalValue'], s['currency'])},倍数 {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 = [ ("合同总保费", _money_with_currency(s["totalPremium"], s["currency"])), ("期末价值", _money_with_currency(s["finalValue"], s["currency"])), ("倍数", 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_with_currency(s['totalPremium'], s['currency'])},回本第 {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) summaries = [_product_summary(product) for product in products] currencies = {summary["currency"] for summary in summaries if summary["currency"]} totals = [summary["totalPremium"] for summary in summaries] total_investment = sum(totals) if len(currencies) == 1 and all(value is not None for value in totals) else None aggregate_currency = next(iter(currencies), None) if len(currencies) == 1 else None cards = [ ("计划书数量", f"{len(products)} 份"), ("合同总保费合计", _money_with_currency(total_investment, aggregate_currency)), ("对比维度", "按保单年度"), ] 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)) add_footer(slide, "", colors=colors) 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) 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, "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"], } _BUILTIN_FRAME_MAPS = { # 这些映射只定义“当前场景页使用哪一张源模板页作为可编辑底稿”。 # 模板中的示例文本、图表和表格会在绘制真实数据前清空。 "single_savings.pptx": { 12: [1, 2, 3, 6, 8, 9, 10, 11, 18, 13, 17, 20], 14: [1, 2, 3, 6, 8, 9, 10, 11, 18, 14, 19, 13, 17, 20], }, "multi_savings_comparison.pptx": { 10: [1, 2, 3, 4, 15, 16, 11, 13, 14, 17], }, "savings_iul_comprehensive.pptx": { 15: [1, 2, 3, 4, 8, 9, 11, 12, 13, 14, 17, 18, 6, 16, 15], }, } _TEMPLATE_THEME_BY_ASSET = { "single_savings.pptx": "caramel", "multi_savings_comparison.pptx": "caramel", "savings_iul_comprehensive.pptx": "sage", } _PAGE_TYPE_ALIASES = { "comparison_chart": {"chart"}, "comparison_table": {"compare", "policy_summary", "table"}, "table": {"policy_summary", "chart"}, "launch_paths": {"guidance", "timeline"}, "alignment_table": {"combined_summary", "policy_summary", "closing"}, "conclusion": {"guidance", "synergy", "closing"}, } def _auto_frame_map(page_types: list[str], template_config: dict, slide_count: int) -> list[int]: """为上传模板建立确定性的逐页映射;页面不足时明确失败。""" if slide_count < len(page_types): raise ValueError( f"所选模板只有 {slide_count} 页,当前方案需要 {len(page_types)} 页," "请上传页数充足的模板或调整模板页面配置" ) source_slides = template_config.get("slidesConfig") or [] source_types = [str(item.get("pageType") or "") for item in source_slides] if len(source_types) < slide_count: source_types.extend([""] * (slide_count - len(source_types))) available = set(range(slide_count)) selected = [] strict_slots = bool(template_config.get("strictSemanticSlots")) for page_type in page_types: candidates = [ index for index in sorted(available) if source_types[index] == page_type ] if not candidates and not strict_slots: aliases = _PAGE_TYPE_ALIASES.get(page_type, set()) candidates = [ index for index in sorted(available) if source_types[index] in aliases ] if not candidates: raise ValueError(f"模板没有 page_type={page_type} 的已声明语义槽位") index = candidates[0] available.remove(index) selected.append(index + 1) return selected def _resolve_template_frame_map( source_template_path: str, page_types: list[str], template_config: dict, slide_count: int, ) -> list[int]: configured = template_config.get("frameMap") if configured: frame_map = [int(value) for value in configured] if len(frame_map) != len(page_types): raise ValueError("模板 frameMap 页数与当前方案页数不一致") if any(value < 1 or value > slide_count for value in frame_map): raise ValueError("模板 frameMap 包含无效源页码") if len(set(frame_map)) != len(frame_map): raise ValueError("模板 frameMap 不允许重复使用同一源页面") return frame_map asset_name = os.path.basename(source_template_path).lower() builtin = _BUILTIN_FRAME_MAPS.get(asset_name, {}).get(len(page_types)) if builtin: return list(builtin) return _auto_frame_map(page_types, template_config, slide_count) def _remove_shape(shape): element = shape._element element.getparent().remove(element) def _group_contains_picture(shape) -> bool: for child in shape.shapes: if child.shape_type == MSO_SHAPE_TYPE.PICTURE: return True if child.shape_type == MSO_SHAPE_TYPE.GROUP and _group_contains_picture(child): return True return False def _sanitize_template_shape(shape, slide_width: int, slide_height: int): """移除示例数据,保留背景、图片和形状几何作为模板底稿。""" if shape.shape_type == MSO_SHAPE_TYPE.GROUP: if not _group_contains_picture(shape): _remove_shape(shape) return for child in list(shape.shapes): _sanitize_template_shape(child, slide_width, slide_height) return if getattr(shape, "has_chart", False) or getattr(shape, "has_table", False): _remove_shape(shape) return if getattr(shape, "has_text_frame", False) and shape.text.strip(): _remove_shape(shape) return if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE: is_edge_or_background = ( shape.width >= slide_width * 0.9 or shape.height >= slide_height * 0.9 or shape.width <= slide_width * 0.02 or shape.height <= slide_height * 0.02 ) if not is_edge_or_background: _remove_shape(shape) def _remove_declared_business_shapes(shapes, names: set[str]): for shape in list(shapes): if shape.name in names: _remove_shape(shape) elif shape.shape_type == MSO_SHAPE_TYPE.GROUP: _remove_declared_business_shapes(shape.shapes, names) def _prepare_template_frames(prs, frame_map: list[int], template_config: dict): """保留并重排选中的源页面,随后让现有 builder 原位写入真实数据。""" slide_ids = list(prs.slides._sldIdLst) selected_ids = [slide_ids[index - 1] for index in frame_map] selected_identity = {id(item) for item in selected_ids} for slide_id in list(slide_ids): if id(slide_id) in selected_identity: continue prs.part.drop_rel(slide_id.rId) prs.slides._sldIdLst.remove(slide_id) for slide_id in selected_ids: prs.slides._sldIdLst.remove(slide_id) prs.slides._sldIdLst.append(slide_id) slides = list(prs.slides) if template_config.get("strictSemanticSlots"): declared = template_config.get("businessShapeNamesBySlide") or [] if len(declared) != len(slides): raise ValueError("模板业务 shape 槽位数量与输出页面数量不一致") for index, slide in enumerate(slides): names = {str(value) for value in declared[index] if value} if not names: raise ValueError(f"第 {index + 1} 页没有声明可替换的业务 shape") existing = {shape.name for shape in slide.shapes} missing = sorted(names - existing) if missing: raise ValueError(f"第 {index + 1} 页业务 shape 不存在: {', '.join(missing)}") _remove_declared_business_shapes(slide.shapes, names) else: for slide in slides: for shape in list(slide.shapes): _sanitize_template_shape(shape, prs.slide_width, prs.slide_height) prs._insurance_template_slide_queue = slides 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] # 其次从 requiredPageTypes(JSON 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。""" template_config = deck.get("templateConfig", {}) slides_config = deck.get("scenarioSlides") or template_config.get("slidesConfig", []) page_types = _resolve_page_types(deck) source_template_path = ( template_config.get("sourceTemplatePath") ) renderer_mode = "generic-builder-v1" if source_template_path and os.path.isfile(source_template_path): prs = Presentation(source_template_path) frame_map = _resolve_template_frame_map( source_template_path, page_types, template_config, len(prs.slides), ) _prepare_template_frames(prs, frame_map, template_config) renderer_mode = "clone-edit-v2" asset_name = os.path.basename(source_template_path).lower() theme = template_config.get("renderTheme") or _TEMPLATE_THEME_BY_ASSET.get( asset_name, theme ) else: prs = Presentation() prs.slide_width = Inches(13.33) prs.slide_height = Inches(7.5) colors = THEMES.get(theme, THEMES["deepblue"]) 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) ) 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, "rendererMode": renderer_mode, "templateFrameMap": frame_map if renderer_mode == "clone-edit-v2" else [], } # ─── 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", "business", "minimal", "ink", "sage"], 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()