Rewrite fast_pptx_renderer.py to read requiredPageTypes from template config instead of hardcoding 5 slides. Add 10 slide builder functions (cover, company, narrative, chart, timeline, table, compare, synergy, conclusion, closing) with python-pptx native charts. Key changes: - Renderer reads templateConfig.requiredPageTypes and slidesConfig from DeckContract to determine slide sequence and per-slide metadata - routes.py loads PptTemplate and PptCompany from DB, normalizes all PDF extractions (not just the first), passes full context to renderer - renderer.py injects templateConfig, company info, and multi-product data into DeckContract - Add slides_config_json column to PptTemplate (migrate_017) for per-slide title/narrative/chartType configuration via admin UI - Admin template editor now supports drag-reorder slides, per-slide title/narrative hint, chart/table type selection - Add requiredPageTypes to savings/ink, savings/minimal, savings/business templates (were missing, causing fallback to defaults) - Fix IUL normalizer: add payYears and totalPremium to policy dict - Fix CI/IUL normalizer: add totalSurrenderValue alias to benefitRows so charts render correctly for all product types - Port calculation functions from baodanppt: decade_rows, paid_premium, simple_return, compound_return, find_payback_year Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
961 lines
39 KiB
Python
961 lines
39 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
fast_pptx_renderer.py — PPTX 渲染器
|
||
接收 DeckContract JSON,按 requiredPageTypes 生成专业 PPTX
|
||
|
||
使用方法:
|
||
python fast_pptx_renderer.py --deck-json <path> --output <path> --theme <theme>
|
||
|
||
主题:
|
||
deepblue - 深海蓝(默认)
|
||
caramel - 焦糖色
|
||
chinese - 中国红
|
||
|
||
输出:
|
||
JSON: { "ok": true, "path": "...", "size": 12345, "slides": 10 }
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from pptx import Presentation
|
||
from pptx.chart.data import CategoryChartData
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
||
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
|
||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||
from pptx.util import Inches, Pt
|
||
|
||
|
||
# ─── 主题配色 ────────────────────────────────────────────────
|
||
|
||
THEMES = {
|
||
"deepblue": {
|
||
"bg": RGBColor(10, 28, 50),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(255, 255, 255),
|
||
"text_dark": RGBColor(30, 30, 40),
|
||
"accent": RGBColor(24, 137, 141),
|
||
"accent_light": RGBColor(200, 230, 230),
|
||
"gold": RGBColor(201, 160, 39),
|
||
"muted": RGBColor(150, 160, 175),
|
||
"line": RGBColor(220, 225, 235),
|
||
"good": RGBColor(24, 168, 100),
|
||
"good_light": RGBColor(180, 230, 200),
|
||
"alert": RGBColor(200, 60, 60),
|
||
},
|
||
"caramel": {
|
||
"bg": RGBColor(248, 245, 239),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(38, 38, 38),
|
||
"text_dark": RGBColor(38, 38, 38),
|
||
"accent": RGBColor(206, 138, 44),
|
||
"accent_light": RGBColor(240, 230, 208),
|
||
"gold": RGBColor(206, 138, 44),
|
||
"muted": RGBColor(96, 96, 96),
|
||
"line": RGBColor(223, 214, 195),
|
||
"good": RGBColor(24, 108, 79),
|
||
"good_light": RGBColor(165, 204, 189),
|
||
"alert": RGBColor(173, 68, 52),
|
||
},
|
||
"chinese": {
|
||
"bg": RGBColor(180, 30, 30),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(255, 255, 255),
|
||
"text_dark": RGBColor(40, 40, 40),
|
||
"accent": RGBColor(220, 180, 60),
|
||
"accent_light": RGBColor(255, 240, 200),
|
||
"gold": RGBColor(220, 180, 60),
|
||
"muted": RGBColor(180, 160, 160),
|
||
"line": RGBColor(200, 180, 180),
|
||
"good": RGBColor(100, 180, 100),
|
||
"good_light": RGBColor(200, 230, 200),
|
||
"alert": RGBColor(255, 200, 100),
|
||
},
|
||
}
|
||
|
||
FONT_CN = "Microsoft YaHei"
|
||
|
||
|
||
# ─── 计算工具(移植自 baodanppt) ───────────────────────────
|
||
|
||
def money(value: float | int) -> str:
|
||
return f"{round(float(value)):,}"
|
||
|
||
|
||
def pct(value: float) -> str:
|
||
return f"{value:.2f}%"
|
||
|
||
|
||
def paid_premium_for_year(year: int, annual_premium: float, pay_years: int) -> float:
|
||
return float(annual_premium) * min(int(year), int(pay_years))
|
||
|
||
|
||
def simple_return(value: float, premium: float) -> float:
|
||
if premium <= 0:
|
||
return 0.0
|
||
return (float(value) / float(premium) - 1.0) * 100.0
|
||
|
||
|
||
def compound_return(value: float, premium: float, year: int) -> float:
|
||
if premium <= 0 or value <= 0 or year <= 0:
|
||
return 0.0
|
||
return ((float(value) / float(premium)) ** (1.0 / float(year)) - 1.0) * 100.0
|
||
|
||
|
||
def find_payback_year(benefit_rows: list, annual_premium: float, pay_years: int):
|
||
for row in benefit_rows:
|
||
yr = int(row.get("policyYear", 0))
|
||
sv = float(row.get("totalSurrenderValue", 0))
|
||
if sv >= paid_premium_for_year(yr, annual_premium, pay_years) and yr > 1:
|
||
return yr
|
||
return None
|
||
|
||
|
||
def decade_rows(benefit_rows, annual_premium, pay_years, withdraw_rows=None, max_year=90):
|
||
"""每 10 年数据行,支持不提领/提领两种口径。"""
|
||
targets = [y for y in [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] if y <= max_year]
|
||
by_year = {}
|
||
for r in benefit_rows:
|
||
by_year[int(r.get("policyYear", 0))] = r
|
||
w_by_year = {}
|
||
if withdraw_rows:
|
||
for r in withdraw_rows:
|
||
w_by_year[int(r.get("policyYear", 0))] = r
|
||
|
||
rows = []
|
||
for yr in targets:
|
||
base = by_year.get(yr)
|
||
wr = w_by_year.get(yr)
|
||
if not base and not wr:
|
||
continue
|
||
ref = wr or base
|
||
age = int(ref.get("age", 0))
|
||
paid = paid_premium_for_year(yr, annual_premium, pay_years)
|
||
if wr:
|
||
annual_w = float(wr.get("annualWithdrawal", 0))
|
||
cum_w = float(wr.get("cumulativeWithdrawal", 0))
|
||
sv_after = float(wr.get("surrenderValueAfter", 0))
|
||
econ = cum_w + sv_after
|
||
else:
|
||
annual_w = 0.0
|
||
cum_w = 0.0
|
||
sv_after = float(base.get("totalSurrenderValue", 0))
|
||
econ = sv_after
|
||
rows.append({
|
||
"age": age, "year": yr, "paid": paid,
|
||
"annualWithdrawal": annual_w, "cumulativeWithdrawal": cum_w,
|
||
"surrenderValue": sv_after, "economicTotal": econ,
|
||
"simpleRate": simple_return(econ, paid),
|
||
"compoundRate": compound_return(econ, paid, yr),
|
||
})
|
||
return rows
|
||
|
||
|
||
# ─── 基础绘图函数 ────────────────────────────────────────────
|
||
|
||
def add_bg(slide, colors):
|
||
fill = slide.background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = colors["bg"]
|
||
|
||
|
||
def add_textbox(slide, left, top, width, height, text, size=18, bold=False,
|
||
color=None, align=PP_ALIGN.LEFT, line_spacing=1.5, colors=None):
|
||
if color is None:
|
||
color = colors["primary"] if colors else RGBColor(255, 255, 255)
|
||
box = slide.shapes.add_textbox(left, top, width, height)
|
||
tf = box.text_frame
|
||
tf.clear()
|
||
tf.word_wrap = True
|
||
tf.vertical_anchor = MSO_ANCHOR.TOP
|
||
p = tf.paragraphs[0]
|
||
p.alignment = align
|
||
p.line_spacing = line_spacing
|
||
run = p.add_run()
|
||
run.text = str(text)
|
||
run.font.name = FONT_CN
|
||
run.font.size = Pt(size)
|
||
run.font.bold = bold
|
||
run.font.color.rgb = color
|
||
return box
|
||
|
||
|
||
def add_paragraphs(slide, left, top, width, height, lines, size=15, color=None,
|
||
bullet=True, line_spacing=1.5, colors=None):
|
||
if color is None:
|
||
color = colors["primary"] if colors else RGBColor(255, 255, 255)
|
||
box = slide.shapes.add_textbox(left, top, width, height)
|
||
tf = box.text_frame
|
||
tf.clear()
|
||
tf.word_wrap = True
|
||
tf.vertical_anchor = MSO_ANCHOR.TOP
|
||
for idx, line in enumerate(lines):
|
||
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||
p.alignment = PP_ALIGN.LEFT
|
||
p.line_spacing = line_spacing
|
||
p.text = f"• {line}" if bullet else str(line)
|
||
if p.runs:
|
||
run = p.runs[0]
|
||
run.font.name = FONT_CN
|
||
run.font.size = Pt(size)
|
||
run.font.color.rgb = color
|
||
return box
|
||
|
||
|
||
def add_card(slide, left, top, width, height, title, value, subtitle="",
|
||
accent=False, colors=None, title_size=12, value_size=20, subtitle_size=10.5):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = colors["accent_light"] if accent else colors["panel"]
|
||
shape.line.color.rgb = colors["line"]
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.12), width - Inches(0.28),
|
||
Inches(0.22), title, size=title_size, bold=True, color=colors["muted"],
|
||
line_spacing=1.2, colors=colors)
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.38), width - Inches(0.28),
|
||
Inches(0.4), value, size=value_size, bold=True,
|
||
color=colors["accent"] if accent else colors["text_dark"],
|
||
line_spacing=1.2, colors=colors)
|
||
if subtitle:
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.82), width - Inches(0.28),
|
||
Inches(0.22), subtitle, size=subtitle_size, color=colors["muted"],
|
||
line_spacing=1.2, colors=colors)
|
||
|
||
|
||
def add_fact_card(slide, left, top, width, height, title, body,
|
||
fill_color=None, colors=None, title_size=11.5, body_size=12.8):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
if fill_color is None:
|
||
fill_color = colors["panel"]
|
||
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = fill_color
|
||
shape.line.color.rgb = colors["line"]
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.12), width - Inches(0.28),
|
||
Inches(0.2), title, size=title_size, bold=True, color=colors["muted"],
|
||
line_spacing=1.2, colors=colors)
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.36), width - Inches(0.28),
|
||
height - Inches(0.48), body, size=body_size, color=colors["text_dark"],
|
||
line_spacing=1.42, colors=colors)
|
||
|
||
|
||
def add_footer(slide, text, colors=None):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
add_textbox(slide, Inches(0.65), Inches(7.0), Inches(12.0), Inches(0.2),
|
||
text, size=10, color=colors["muted"], line_spacing=1.2, colors=colors)
|
||
|
||
|
||
def add_title(slide, title, subtitle=None, colors=None):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
add_textbox(slide, Inches(0.6), Inches(0.35), Inches(8.8), Inches(0.45),
|
||
title, size=24, bold=True, line_spacing=1.2, colors=colors)
|
||
if subtitle:
|
||
add_textbox(slide, Inches(0.6), Inches(0.78), Inches(9.2), Inches(0.28),
|
||
subtitle, size=11, color=colors["muted"], line_spacing=1.2, colors=colors)
|
||
|
||
|
||
def add_line_chart(slide, left, top, width, height, categories, series_spec, colors):
|
||
chart_data = CategoryChartData()
|
||
chart_data.categories = [str(x) for x in categories]
|
||
for name, values in series_spec:
|
||
chart_data.add_series(name, values)
|
||
chart = slide.shapes.add_chart(
|
||
XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, chart_data
|
||
).chart
|
||
chart.has_legend = True
|
||
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||
chart.legend.font.size = Pt(10)
|
||
chart.value_axis.has_major_gridlines = True
|
||
chart.value_axis.tick_labels.font.size = Pt(10)
|
||
chart.category_axis.tick_labels.font.size = Pt(10)
|
||
chart.value_axis.tick_labels.number_format = '$#,##0'
|
||
palette = [colors["accent"], colors["good"], RGBColor(54, 94, 140), colors["alert"]]
|
||
for idx, series in enumerate(chart.series):
|
||
series.format.line.width = Pt(2.2)
|
||
series.format.line.color.rgb = palette[idx % len(palette)]
|
||
return chart
|
||
|
||
|
||
def add_timeline_marker(slide, left, title, lines, colors):
|
||
shape = slide.shapes.add_shape(
|
||
MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
|
||
left, Inches(2.75), Inches(2.1), Inches(2.05)
|
||
)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = colors["panel"]
|
||
shape.line.color.rgb = colors["line"]
|
||
add_textbox(slide, left + Inches(0.14), Inches(2.9), Inches(1.82), Inches(0.26),
|
||
title, size=13, bold=True, color=colors["accent"],
|
||
line_spacing=1.2, colors=colors)
|
||
add_paragraphs(slide, left + Inches(0.14), Inches(3.18), Inches(1.82), Inches(1.25),
|
||
lines, size=11, bullet=False, line_spacing=1.35, colors=colors)
|
||
|
||
|
||
# ─── 幻灯片构建函数(10 种 pageType) ───────────────────────
|
||
|
||
def _slide_meta(slides_config, idx, page_type):
|
||
"""从 slides_config 中获取第 idx 个 slide 的元数据。"""
|
||
if slides_config and idx < len(slides_config):
|
||
return slides_config[idx]
|
||
return {"pageType": page_type}
|
||
|
||
|
||
def _product_summary(product):
|
||
"""从产品数据中提取关键数字摘要。"""
|
||
policy = product.get("policy", {})
|
||
ap = policy.get("annualPremium", 0)
|
||
py = policy.get("payYears", 0)
|
||
total = ap * py
|
||
br = product.get("benefitRows", [])
|
||
payback = find_payback_year(br, ap, py) if br else None
|
||
final = br[-1].get("totalSurrenderValue", 0) if br else 0
|
||
mult = f"{final / total:.1f}x" if total > 0 and final > 0 else "-"
|
||
return {"annualPremium": ap, "payYears": py, "totalPremium": total,
|
||
"paybackYear": payback, "finalValue": final, "multiple": mult,
|
||
"benefitRows": br, "withdrawalRows": product.get("withdrawalRows", []),
|
||
"productName": product.get("productName", ""),
|
||
"insured": product.get("insured", {})}
|
||
|
||
|
||
def add_slide_cover(prs, deck, colors, meta):
|
||
"""封面页:客户名 + 产品名 + 公司名 + 核心指标卡片。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
customer = deck.get("customer", {})
|
||
products = deck.get("products", [])
|
||
company = deck.get("company", {})
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
|
||
title_text = meta.get("title", "").replace("{{customerName}}", customer.get("name", "客户"))
|
||
if not title_text or "{{" in title_text:
|
||
title_text = f"{customer.get('name', '客户')} 专属方案"
|
||
|
||
add_textbox(slide, Inches(0.8), Inches(1.2), Inches(11.7), Inches(0.8),
|
||
title_text, size=38, bold=True, colors=colors)
|
||
|
||
subtitle = meta.get("subtitle", "").replace("{{productName}}", s["productName"])
|
||
if not subtitle or "{{" in subtitle:
|
||
subtitle = s["productName"] or "财富增值与传承方案"
|
||
add_textbox(slide, Inches(0.8), Inches(2.2), Inches(11.7), Inches(0.5),
|
||
subtitle, size=20, color=colors["muted"], colors=colors)
|
||
|
||
company_name = company.get("displayName", "")
|
||
if company_name:
|
||
add_textbox(slide, Inches(0.8), Inches(2.9), Inches(11.7), Inches(0.4),
|
||
company_name, size=14, color=colors["accent"], colors=colors)
|
||
|
||
metrics = [
|
||
("年缴保费", f"${money(s['annualPremium'])}"),
|
||
("缴费年期", f"{s['payYears']}年"),
|
||
("总投入", f"${money(s['totalPremium'])}"),
|
||
("期末倍数", s["multiple"]),
|
||
]
|
||
for i, (label, value) in enumerate(metrics):
|
||
x = Inches(0.8 + i * 3.0)
|
||
add_card(slide, x, Inches(4.8), Inches(2.5), Inches(1.2),
|
||
label, value, colors=colors, accent=(i == 3))
|
||
|
||
add_footer(slide, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
|
||
def add_slide_company(prs, deck, colors, meta):
|
||
"""公司介绍页:公司简介 + 事实卡片。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
company = deck.get("company", {})
|
||
title = meta.get("title", "").replace("{{companyName}}", company.get("displayName", ""))
|
||
if not title or "{{" in title:
|
||
title = f"{company.get('displayName', '合作保司')} 公司介绍"
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
intro = company.get("companyIntro", "")
|
||
if intro:
|
||
add_paragraphs(slide, Inches(0.85), Inches(1.45), Inches(6.0), Inches(2.5),
|
||
[intro], size=13, bullet=False, line_spacing=1.5, colors=colors)
|
||
|
||
highlights = company.get("companyHighlights", [])[:4]
|
||
positions = [
|
||
(Inches(0.8), Inches(4.2)), (Inches(3.5), Inches(4.2)),
|
||
(Inches(6.2), Inches(4.2)), (Inches(8.9), Inches(4.2)),
|
||
]
|
||
for idx, h in enumerate(highlights):
|
||
if idx >= 4:
|
||
break
|
||
left, top = positions[idx]
|
||
text = h.get("text", "") if isinstance(h, dict) else str(h)
|
||
source = h.get("sourceFile", "") if isinstance(h, dict) else ""
|
||
fill = colors["accent_light"] if idx == 0 else colors["panel"]
|
||
add_fact_card(slide, left, top, Inches(2.5), Inches(1.8),
|
||
source[:20] if source else f"亮点 {idx+1}", text,
|
||
fill_color=fill, colors=colors, body_size=11)
|
||
|
||
rating = company.get("rating", "")
|
||
if rating:
|
||
add_textbox(slide, Inches(7.5), Inches(1.45), Inches(5.0), Inches(0.4),
|
||
f"评级: {rating}", size=14, bold=True, color=colors["accent"], colors=colors)
|
||
|
||
add_footer(slide, "公司事实来自内部知识库权威口径。", colors=colors)
|
||
|
||
|
||
def add_slide_narrative(prs, deck, colors, meta):
|
||
"""叙事页:受保人画像 + 方案逻辑 + 产品定位。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
insured = s["insured"]
|
||
|
||
title = meta.get("title", "方案核心逻辑")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
age = insured.get("age", "?")
|
||
name = insured.get("name", "受保人")
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(2.5), Inches(1.6),
|
||
f"{name} · {age}岁", f"年缴 US${money(s['annualPremium'])}\n缴费 {s['payYears']} 年",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
|
||
if s["paybackYear"]:
|
||
add_fact_card(slide, Inches(3.6), Inches(1.45), Inches(2.5), Inches(1.6),
|
||
"回本年份", f"约第 {s['paybackYear']} 年", colors=colors)
|
||
|
||
kind = product.get("kind", "savings")
|
||
if kind == "savings":
|
||
points = [
|
||
f"总保费约 US${money(s['totalPremium'])},长期退保价值可持续抬升。",
|
||
"它负责未来的确定节奏,用时间换复利。",
|
||
]
|
||
wr = s["withdrawalRows"]
|
||
if wr:
|
||
first_w = next((r for r in wr if float(r.get("annualWithdrawal", 0)) > 0), None)
|
||
if first_w:
|
||
points.insert(0, f"第 {int(first_w['policyYear'])} 年开始每年约 US${money(first_w['annualWithdrawal'])},可按阶段释放现金流。")
|
||
elif kind == "ci":
|
||
policy = product.get("policy", {})
|
||
points = [
|
||
f"基础保额 US${money(policy.get('sumInsured', 0))},先把家庭底线守住。",
|
||
"这张单的任务不是替代储蓄,而是保护储蓄不被提前动用。",
|
||
]
|
||
else:
|
||
policy = product.get("policy", {})
|
||
points = [
|
||
f"基础保额 US${money(policy.get('sumInsured', 0))},用有限保费建立更高身故保障。",
|
||
"它不是给短期取用,而是给家庭做长期传承和现金价值缓冲。",
|
||
]
|
||
add_paragraphs(slide, Inches(0.85), Inches(3.5), Inches(11.4), Inches(2.5),
|
||
points, size=14.5, bullet=True, colors=colors)
|
||
|
||
|
||
def add_slide_chart(prs, deck, colors, meta):
|
||
"""图表页:python-pptx 原生折线图 + 侧边解读。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
|
||
chart_type = meta.get("chartType", "growth")
|
||
title = meta.get("title", "价值增长曲线" if chart_type == "growth" else "保证/非保证构成")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
if chart_type == "stacked":
|
||
_draw_stacked_chart(slide, s, colors)
|
||
else:
|
||
_draw_growth_chart(slide, s, colors)
|
||
|
||
|
||
def _draw_growth_chart(slide, s, colors):
|
||
"""增长曲线图:退保价值 vs 保证价值 vs 已交保费。"""
|
||
br = s["benefitRows"]
|
||
if not br:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无增长数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
years = [1, 5, 10, 15, 20, 25, 30, 40, 50]
|
||
by_year = {int(r.get("policyYear", 0)): r for r in br}
|
||
valid_years = [y for y in years if y in by_year]
|
||
if not valid_years:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无增长数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
|
||
series = [
|
||
("总退保价值", [float(by_year[y].get("totalSurrenderValue", 0)) for y in valid_years]),
|
||
("保证现金价值", [float(by_year[y].get("guaranteedCashValue", 0)) for y in valid_years]),
|
||
("已交总保费", [paid_premium_for_year(y, s["annualPremium"], s["payYears"]) for y in valid_years]),
|
||
]
|
||
add_line_chart(slide, Inches(0.65), Inches(1.45), Inches(6.3), Inches(4.25),
|
||
valid_years, series, colors)
|
||
|
||
notes = []
|
||
if s["paybackYear"]:
|
||
notes.append(f"回本约第 {s['paybackYear']} 年")
|
||
y20 = by_year.get(20)
|
||
if y20:
|
||
notes.append(f"第20年退保价值 US${money(y20.get('totalSurrenderValue', 0))}")
|
||
y30 = by_year.get(30)
|
||
if y30:
|
||
notes.append(f"第30年退保价值 US${money(y30.get('totalSurrenderValue', 0))}")
|
||
notes.append(f"期末倍数约 {s['multiple']}")
|
||
|
||
add_fact_card(slide, Inches(7.35), Inches(1.6), Inches(5.0), Inches(3.5),
|
||
"图表解读", "\n".join(notes), colors=colors)
|
||
|
||
|
||
def _draw_stacked_chart(slide, s, colors):
|
||
"""保证/非保证构成图。"""
|
||
br = s["benefitRows"]
|
||
if not br:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无构成数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
years = [1, 5, 10, 15, 20, 25, 30, 40, 50]
|
||
by_year = {int(r.get("policyYear", 0)): r for r in br}
|
||
valid_years = [y for y in years if y in by_year]
|
||
if not valid_years:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无构成数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
|
||
series = [
|
||
("保证现金价值", [float(by_year[y].get("guaranteedCashValue", 0)) for y in valid_years]),
|
||
("归原红利", [float(by_year[y].get("reversionaryBonus", 0)) for y in valid_years]),
|
||
("终期分红", [float(by_year[y].get("terminalDividend", 0)) for y in valid_years]),
|
||
]
|
||
add_line_chart(slide, Inches(0.65), Inches(1.45), Inches(6.3), Inches(4.25),
|
||
valid_years, series, colors)
|
||
|
||
add_fact_card(slide, Inches(7.35), Inches(1.6), Inches(5.0), Inches(3.5),
|
||
"构成解读", "先看保证底盘,再看非保证弹性,明确长期收益主要来源。",
|
||
colors=colors)
|
||
|
||
|
||
def add_slide_timeline(prs, deck, colors, meta):
|
||
"""时间轴页:横向里程碑。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
|
||
title = meta.get("title", "家庭时间轴")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
# 画横线
|
||
line = slide.shapes.add_shape(
|
||
MSO_AUTO_SHAPE_TYPE.RECTANGLE,
|
||
Inches(1.0), Inches(2.35), Inches(10.8), Inches(0.05)
|
||
)
|
||
line.fill.solid()
|
||
line.fill.fore_color.rgb = colors["line"]
|
||
line.line.color.rgb = colors["line"]
|
||
|
||
wr = {int(r.get("policyYear", 0)): r for r in s["withdrawalRows"]}
|
||
insured_age = int(s["insured"].get("age", 1))
|
||
|
||
milestones = _build_milestones(s, wr, insured_age, meta)
|
||
positions = [Inches(0.95), Inches(3.2), Inches(5.45), Inches(7.7), Inches(9.95)]
|
||
for i, ms in enumerate(milestones[:5]):
|
||
left = positions[i] if i < len(positions) else Inches(0.95 + i * 2.3)
|
||
add_timeline_marker(slide, left, ms["title"], ms["lines"], colors)
|
||
|
||
add_footer(slide, "关键里程碑按受保人年龄和保单年度展开。", colors=colors)
|
||
|
||
|
||
def _build_milestones(s, wr, insured_age, meta):
|
||
"""构建时间轴里程碑。"""
|
||
ms_list = []
|
||
ms_list.append({"title": "现在", "lines": [f"年缴 US${money(s['annualPremium'])}", "保单生效"]})
|
||
|
||
# 第一个提领点
|
||
first_w = next((r for r in s["withdrawalRows"] if float(r.get("annualWithdrawal", 0)) > 0), None)
|
||
if first_w:
|
||
wyr = int(first_w["policyYear"])
|
||
wage = insured_age + wyr
|
||
ms_list.append({"title": f"第{wyr}年({wage}岁)",
|
||
"lines": [f"每年约 US${money(first_w['annualWithdrawal'])}", "开始提领"]})
|
||
|
||
# 18/21岁
|
||
for age_m in [18, 21]:
|
||
for yr, r in wr.items():
|
||
if int(r.get("age", 0)) == age_m:
|
||
ms_list.append({"title": f"{age_m}岁(第{yr}年)",
|
||
"lines": [f"累计 US${money(r.get('cumulativeWithdrawal', 0))}"]})
|
||
break
|
||
|
||
# 20年/30年
|
||
br_by_year = {int(r.get("policyYear", 0)): r for r in s["benefitRows"]}
|
||
for yr in [20, 30]:
|
||
r = br_by_year.get(yr)
|
||
if r:
|
||
ms_list.append({"title": f"第{yr}年",
|
||
"lines": [f"退保价值 US${money(r.get('totalSurrenderValue', 0))}"]})
|
||
|
||
return ms_list[:5]
|
||
|
||
|
||
def add_slide_table(prs, deck, colors, meta):
|
||
"""数据表页:每 10 年数据 + 单利/复利 + 侧边解读。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
|
||
table_type = meta.get("tableType", "no_withdraw")
|
||
title = meta.get("title", "不提领方案数据表(每10年)" if table_type == "no_withdraw" else "提领方案数据表(每10年)")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
has_withdraw = table_type == "withdraw" and s["withdrawalRows"]
|
||
rows = decade_rows(s["benefitRows"], s["annualPremium"], s["payYears"],
|
||
s["withdrawalRows"] if has_withdraw else None)
|
||
if not rows:
|
||
add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(1),
|
||
["暂无数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
|
||
if has_withdraw:
|
||
headers = ["年龄", "年度", "已交保费", "年领金额", "累计领取", "提后现价", "经济总值", "单利", "复利"]
|
||
col_widths = [0.6, 0.7, 1.1, 1.0, 1.1, 1.1, 1.1, 0.8, 0.8]
|
||
else:
|
||
headers = ["年龄", "年度", "已交保费", "退保价值", "单利", "复利"]
|
||
col_widths = [0.7, 0.8, 1.3, 1.5, 1.0, 1.0]
|
||
|
||
n_cols = len(headers)
|
||
n_rows = len(rows) + 1
|
||
table_width = Inches(sum(col_widths))
|
||
table = slide.shapes.add_table(
|
||
n_rows, n_cols, Inches(0.55), Inches(1.38), table_width, Inches(min(n_rows * 0.42, 5.0))
|
||
).table
|
||
|
||
for i, w in enumerate(col_widths):
|
||
table.columns[i].width = Inches(w)
|
||
|
||
# 表头
|
||
for c, h in enumerate(headers):
|
||
cell = table.cell(0, c)
|
||
cell.text = h
|
||
_style_cell(cell, bold=True, bg=colors["accent"])
|
||
|
||
# 数据行
|
||
for r_idx, row in enumerate(rows, 1):
|
||
if has_withdraw:
|
||
vals = [
|
||
str(row["age"]), str(row["year"]),
|
||
money(row["paid"]), money(row["annualWithdrawal"]),
|
||
money(row["cumulativeWithdrawal"]), money(row["surrenderValue"]),
|
||
money(row["economicTotal"]),
|
||
pct(row["simpleRate"]), pct(row["compoundRate"]),
|
||
]
|
||
else:
|
||
vals = [
|
||
str(row["age"]), str(row["year"]),
|
||
money(row["paid"]), money(row["surrenderValue"]),
|
||
pct(row["simpleRate"]), pct(row["compoundRate"]),
|
||
]
|
||
for c, v in enumerate(vals):
|
||
cell = table.cell(r_idx, c)
|
||
cell.text = v
|
||
_style_cell(cell)
|
||
|
||
# 解读面板
|
||
notes = []
|
||
if s["paybackYear"]:
|
||
notes.append(f"总保费 US${money(s['totalPremium'])},回本约第 {s['paybackYear']} 年")
|
||
y20 = next((r for r in rows if r["year"] == 20), None)
|
||
if y20:
|
||
mult20 = y20["economicTotal"] / s["totalPremium"] if s["totalPremium"] > 0 else 0
|
||
notes.append(f"20年约 {mult20:.1f} 倍")
|
||
add_fact_card(slide, Inches(10.0), Inches(1.65), Inches(2.7), Inches(3.0),
|
||
"解读", "\n".join(notes) if notes else "数据来自官方计划书",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
|
||
add_footer(slide, "表格口径:关键年度每10年展示,数字来自官方计划书标准化结果。", colors=colors)
|
||
|
||
|
||
def _style_cell(cell, bold=False, bg=None):
|
||
"""样式化表格单元格。"""
|
||
for p in cell.text_frame.paragraphs:
|
||
p.alignment = PP_ALIGN.CENTER
|
||
for run in p.runs:
|
||
run.font.name = FONT_CN
|
||
run.font.size = Pt(10)
|
||
run.font.bold = bold
|
||
if bg:
|
||
cell.fill.solid()
|
||
cell.fill.fore_color.rgb = bg
|
||
|
||
|
||
def add_slide_compare(prs, deck, colors, meta):
|
||
"""对比页:双产品对比。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
title = meta.get("title", "产品对比")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
if len(products) < 2:
|
||
# 单产品时显示保证 vs 非保证对比
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"保证部分", f"保证现金价值\n回本约第 {s['paybackYear'] or '?'} 年\n确定性高",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"非保证部分", f"归原红利 + 终期分红\n长期弹性空间大\n取决于公司投资表现",
|
||
fill_color=colors["good_light"], colors=colors)
|
||
add_paragraphs(slide, Inches(0.85), Inches(4.5), Inches(11.4), Inches(1.5),
|
||
["保证部分是底线,非保证部分是弹性。两者合计才是长期回报的完整图景。"],
|
||
size=15, bullet=True, colors=colors)
|
||
else:
|
||
# 双产品对比
|
||
left_p = _product_summary(products[0])
|
||
right_p = _product_summary(products[1])
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(3.0),
|
||
left_p["productName"], f"年缴 US${money(left_p['annualPremium'])}\n"
|
||
f"总投入 US${money(left_p['totalPremium'])}\n"
|
||
f"回本约第 {left_p['paybackYear'] or '?'} 年",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(3.0),
|
||
right_p["productName"], f"年缴 US${money(right_p['annualPremium'])}\n"
|
||
f"总投入 US${money(right_p['totalPremium'])}\n"
|
||
f"回本约第 {right_p['paybackYear'] or '?'} 年",
|
||
fill_color=colors["good_light"], colors=colors)
|
||
add_paragraphs(slide, Inches(0.85), Inches(5.0), Inches(11.4), Inches(1.5),
|
||
["组合的价值不是叠加产品数量,而是把功能拆到最清楚。"],
|
||
size=15, bullet=True, colors=colors)
|
||
|
||
add_footer(slide, "", colors=colors)
|
||
|
||
|
||
def add_slide_synergy(prs, deck, colors, meta):
|
||
"""协同关系页:多产品如何配合。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
title = meta.get("title", "协同关系")
|
||
add_title(slide, title, meta.get("narrativeHint", "功能分层,互不冲突"), colors=colors)
|
||
|
||
if len(products) >= 2:
|
||
left_p = _product_summary(products[0])
|
||
right_p = _product_summary(products[1])
|
||
left_kind = products[0].get("kind", "savings")
|
||
right_kind = products[1].get("kind", "savings")
|
||
|
||
left_label = _kind_label(left_kind)
|
||
right_label = _kind_label(right_kind)
|
||
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.05),
|
||
f"{left_label} 负责", _kind_synergy_left(left_kind, left_p),
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.05),
|
||
f"{right_label} 负责", _kind_synergy_right(right_kind, right_p),
|
||
fill_color=colors["good_light"], colors=colors)
|
||
add_paragraphs(slide, Inches(0.85), Inches(4.0), Inches(11.3), Inches(2.0),
|
||
[f"{left_label}和{right_label}不是重复配置,而是功能分层。",
|
||
"先看谁扛风险,再看谁承接未来目标。"],
|
||
size=15, bullet=True, colors=colors)
|
||
else:
|
||
add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(2),
|
||
["单产品方案,无需展示协同关系。"],
|
||
size=16, bullet=False, colors=colors)
|
||
|
||
add_footer(slide, "", colors=colors)
|
||
|
||
|
||
def _kind_label(kind):
|
||
return {"savings": "储蓄险", "ci": "重疾险", "iul": "IUL"}.get(kind, "产品")
|
||
|
||
|
||
def _kind_synergy_left(kind, s):
|
||
if kind == "ci":
|
||
return "风险来时,用重疾险扛支出\n储蓄险继续留给家庭\n教育金目标不被打断"
|
||
if kind == "iul":
|
||
return "更高身故保障底盘\n家族传承效率\n晚年流动性与应急缓冲"
|
||
return f"未来现金流节奏\n回本约第 {s['paybackYear'] or '?'} 年\n为教育/成长阶段提供稳健现金流"
|
||
|
||
|
||
def _kind_synergy_right(kind, s):
|
||
if kind == "ci":
|
||
return "家庭防火墙\n先扛风险支出\n保护储蓄不被提前动用"
|
||
if kind == "iul":
|
||
return "长期身故杠杆\n代际传承效率\n现金价值缓冲"
|
||
return f"长期财富累积\n期末倍数 {s['multiple']}\n时间换复利"
|
||
|
||
|
||
def add_slide_conclusion(prs, deck, colors, meta):
|
||
"""结论页:总结 + 核心数据。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
|
||
title = meta.get("title", "结论")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
points = []
|
||
if s["paybackYear"]:
|
||
points.append(f"总投入 US${money(s['totalPremium'])},回本约第 {s['paybackYear']} 年")
|
||
points.append(f"期末退保价值约 US${money(s['finalValue'])},倍数 {s['multiple']}")
|
||
if len(products) >= 2:
|
||
points.append("组合方案把家庭资产目标拆成不同的功能层")
|
||
|
||
add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(6.0), Inches(3.0),
|
||
points, size=15, bullet=True, colors=colors)
|
||
|
||
# 核心指标卡片
|
||
cards = [
|
||
("总投入", f"US${money(s['totalPremium'])}"),
|
||
("期末价值", f"US${money(s['finalValue'])}"),
|
||
("倍数", s["multiple"]),
|
||
]
|
||
for i, (label, value) in enumerate(cards):
|
||
x = Inches(0.9 + i * 2.8)
|
||
add_card(slide, x, Inches(5.0), Inches(2.4), Inches(1.1),
|
||
label, value, colors=colors, accent=(i == 2))
|
||
|
||
add_footer(slide, "", colors=colors)
|
||
|
||
|
||
def add_slide_closing(prs, deck, colors, meta):
|
||
"""结束页:感谢 + 声明。"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg(slide, colors)
|
||
|
||
title = meta.get("title", "感谢信任")
|
||
add_textbox(slide, Inches(0.8), Inches(2.0), Inches(11.7), Inches(1.0),
|
||
title, size=36, bold=True, align=PP_ALIGN.CENTER, colors=colors)
|
||
|
||
narrative = meta.get("narrativeHint", "方案可继续迭代优化,如有疑问请随时联系。")
|
||
add_textbox(slide, Inches(1.5), Inches(3.5), Inches(10.3), Inches(1.0),
|
||
narrative, size=18, color=colors["muted"],
|
||
align=PP_ALIGN.CENTER, colors=colors)
|
||
|
||
add_paragraphs(slide, Inches(2.5), Inches(5.0), Inches(8.3), Inches(1.5),
|
||
["本方案用于沟通理解,最终权益以保险公司正式文件为准。",
|
||
"建议尽快与您的保险经纪人预约时间完成方案确认。"],
|
||
size=12, bullet=True, color=colors["muted"], colors=colors)
|
||
|
||
|
||
# ─── pageType → builder 映射 ────────────────────────────────
|
||
|
||
SLIDE_BUILDERS = {
|
||
"cover": add_slide_cover,
|
||
"company": add_slide_company,
|
||
"narrative": add_slide_narrative,
|
||
"chart": add_slide_chart,
|
||
"timeline": add_slide_timeline,
|
||
"table": add_slide_table,
|
||
"compare": add_slide_compare,
|
||
"synergy": add_slide_synergy,
|
||
"conclusion": add_slide_conclusion,
|
||
"closing": add_slide_closing,
|
||
}
|
||
|
||
# 默认 pageType 序列(当 DeckContract 无 templateConfig 时使用)
|
||
DEFAULT_PAGE_TYPES = {
|
||
"savings": ["cover", "company", "narrative", "chart", "chart", "timeline", "timeline", "table", "table", "conclusion", "closing"],
|
||
"ci": ["cover", "company", "narrative", "chart", "table", "conclusion", "closing"],
|
||
"iul": ["cover", "company", "narrative", "chart", "compare", "table", "conclusion", "closing"],
|
||
}
|
||
|
||
|
||
def _resolve_page_types(deck):
|
||
"""从 DeckContract 中解析要渲染的页面类型列表。"""
|
||
tc = deck.get("templateConfig", {})
|
||
# 优先从 slidesConfig(管理员配置的逐页数据)中提取顺序
|
||
slides_cfg = tc.get("slidesConfig", [])
|
||
if slides_cfg and isinstance(slides_cfg, list) and len(slides_cfg) > 0:
|
||
if isinstance(slides_cfg[0], dict) and "pageType" in slides_cfg[0]:
|
||
return [s["pageType"] for s in slides_cfg]
|
||
# 其次从 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。"""
|
||
colors = THEMES.get(theme, THEMES["deepblue"])
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Inches(13.33)
|
||
prs.slide_height = Inches(7.5)
|
||
|
||
template_config = deck.get("templateConfig", {})
|
||
slides_config = template_config.get("slidesConfig", [])
|
||
page_types = _resolve_page_types(deck)
|
||
|
||
for i, page_type in enumerate(page_types):
|
||
builder = SLIDE_BUILDERS.get(page_type)
|
||
if not builder:
|
||
continue
|
||
meta = _slide_meta(slides_config, i, page_type)
|
||
try:
|
||
builder(prs, deck, colors, meta)
|
||
except Exception as e:
|
||
# 单页渲染失败不阻断整体
|
||
print(f"[warn] slide {i} ({page_type}) failed: {e}", file=sys.stderr)
|
||
|
||
prs.save(output_path)
|
||
file_size = os.path.getsize(output_path)
|
||
slide_count = len(prs.slides)
|
||
|
||
return {
|
||
"ok": True,
|
||
"path": output_path,
|
||
"size": file_size,
|
||
"slides": slide_count,
|
||
}
|
||
|
||
|
||
# ─── CLI 入口 ────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Fast PPTX Renderer")
|
||
parser.add_argument("--deck-json", required=True, help="DeckContract JSON 文件路径")
|
||
parser.add_argument("--output", required=True, help="输出 PPTX 文件路径")
|
||
parser.add_argument("--theme", default="deepblue",
|
||
choices=["deepblue", "caramel", "chinese"], help="主题配色")
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
with open(args.deck_json, "r", encoding="utf-8") as f:
|
||
deck = json.load(f)
|
||
result = render_deck(deck, args.output, args.theme)
|
||
print(json.dumps(result, ensure_ascii=False))
|
||
except Exception as e:
|
||
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|