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>
408 lines
17 KiB
Python
408 lines
17 KiB
Python
"""PPT 渲染服务 — 调用 python-pptx / insurance-deck 生成 PPT。"""
|
||
import os
|
||
import json
|
||
import uuid
|
||
import subprocess
|
||
import logging
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 默认 Python 路径
|
||
DEFAULT_PYTHON = "python"
|
||
|
||
|
||
def _resolve_python() -> str:
|
||
"""查找可用的 Python 解释器。
|
||
|
||
优先使用虚拟环境中的 Python,然后是系统 Python。
|
||
"""
|
||
import shutil
|
||
import sys
|
||
|
||
# 优先使用当前运行的 Python 解释器
|
||
current_python = sys.executable
|
||
if current_python and os.path.exists(current_python):
|
||
return current_python
|
||
|
||
# 查找虚拟环境中的 Python
|
||
venv_python = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".venv", "bin", "python")
|
||
if os.path.exists(venv_python):
|
||
return venv_python
|
||
|
||
# 查找系统 Python
|
||
for candidate in ["python3.12", "python3.11", "python3", "python"]:
|
||
path = shutil.which(candidate)
|
||
if path:
|
||
return path
|
||
|
||
return DEFAULT_PYTHON
|
||
|
||
|
||
def _build_deck_contract(
|
||
normalized_data: dict,
|
||
company_info: dict = None,
|
||
theme: str = "broker",
|
||
template_config: dict = None,
|
||
all_products: list = None,
|
||
) -> dict:
|
||
"""将归一化数据转换为 DeckContract 格式。"""
|
||
theme_map = {
|
||
"broker": "broker",
|
||
"modern": "modern",
|
||
"fresh": "fresh",
|
||
"warm": "warm",
|
||
"deepblue": "broker",
|
||
"caramel": "caramel",
|
||
"chinese": "chinese",
|
||
}
|
||
|
||
def _extract_product(data):
|
||
return {
|
||
"kind": data.get("kind", "savings"),
|
||
"productName": data.get("productName", ""),
|
||
"rawProductName": data.get("rawProductName", ""),
|
||
"insured": data.get("insured", {}),
|
||
"policy": data.get("policy", {}),
|
||
"benefitRows": data.get("benefitRows", []),
|
||
"withdrawalRows": data.get("withdrawalRows", []),
|
||
"withdrawalProvenance": data.get("withdrawalProvenance", "missing"),
|
||
}
|
||
|
||
products = [_extract_product(normalized_data)]
|
||
if all_products:
|
||
for extra in all_products:
|
||
if extra is not normalized_data:
|
||
products.append(_extract_product(extra))
|
||
|
||
# 公司信息(扩展:传递 companyIntro, companyHighlights 等用于公司介绍页)
|
||
company = {
|
||
"id": "",
|
||
"displayName": "",
|
||
"shortEn": "",
|
||
"evidence": [],
|
||
"companyIntro": "",
|
||
"companyHighlights": [],
|
||
"rating": "",
|
||
"logoUrl": "",
|
||
}
|
||
if company_info:
|
||
company.update(company_info)
|
||
|
||
deck = {
|
||
"id": f"deck_{uuid.uuid4().hex[:12]}",
|
||
"generatedAt": __import__("datetime").datetime.now().isoformat(),
|
||
"customer": {
|
||
"name": normalized_data.get("insured", {}).get("name", "客户"),
|
||
"age": normalized_data.get("insured", {}).get("age"),
|
||
"gender": normalized_data.get("insured", {}).get("gender", ""),
|
||
},
|
||
"tenantId": "default",
|
||
"stylePreset": theme_map.get(theme, "broker"),
|
||
"quality": "standard",
|
||
"outputFormat": "pptx",
|
||
"outputStem": "insurance_plan",
|
||
"products": products,
|
||
"company": company,
|
||
"templateConfig": template_config or {},
|
||
"meta": normalized_data.get("source", {
|
||
"pdfHash": "",
|
||
"pdfPath": "",
|
||
"parser": "llm-json",
|
||
"extractedAt": __import__("datetime").datetime.now().isoformat(),
|
||
}),
|
||
"fidelity": {
|
||
"passed": True,
|
||
"issueCount": 0,
|
||
"errors": 0,
|
||
"warnings": 0,
|
||
},
|
||
}
|
||
|
||
return deck
|
||
|
||
|
||
class PptRenderer:
|
||
"""PPT 渲染器。"""
|
||
|
||
def __init__(self, work_dir: str = None):
|
||
self.work_dir = work_dir or os.path.join(os.getcwd(), "ppt_work")
|
||
os.makedirs(self.work_dir, exist_ok=True)
|
||
|
||
def render_enhanced(
|
||
self,
|
||
normalized_data: dict,
|
||
output_path: str,
|
||
theme: str = "broker",
|
||
company_id: str = None,
|
||
company_info: dict = None,
|
||
template_config: dict = None,
|
||
all_products: list = None,
|
||
) -> dict:
|
||
"""增强渲染(使用 fast_pptx_renderer.py)。
|
||
|
||
Args:
|
||
normalized_data: 归一化后的计划数据
|
||
output_path: 输出 PPTX 路径
|
||
theme: 视觉主题
|
||
company_id: 公司 ID
|
||
company_info: 公司信息 dict
|
||
|
||
Returns:
|
||
{"ok": bool, "path": str, "slideCount": int}
|
||
"""
|
||
try:
|
||
# 准备工作目录
|
||
session_dir = os.path.join(self.work_dir, uuid.uuid4().hex[:8])
|
||
os.makedirs(session_dir, exist_ok=True)
|
||
|
||
# 转换为 DeckContract 格式
|
||
deck = _build_deck_contract(normalized_data, company_info, theme,
|
||
template_config, all_products)
|
||
|
||
# 写入 DeckContract JSON
|
||
deck_path = os.path.join(session_dir, "deck.json")
|
||
with open(deck_path, "w", encoding="utf-8") as f:
|
||
json.dump(deck, f, ensure_ascii=False, indent=2)
|
||
|
||
# 查找渲染脚本
|
||
python = _resolve_python()
|
||
script_dir = os.path.join(os.path.dirname(__file__), "scripts")
|
||
render_script = os.path.join(script_dir, "fast_pptx_renderer.py")
|
||
|
||
if os.path.exists(render_script):
|
||
# 映射主题到渲染脚本支持的主题
|
||
theme_map = {
|
||
"broker": "deepblue",
|
||
"modern": "deepblue",
|
||
"fresh": "deepblue",
|
||
"warm": "caramel",
|
||
"deepblue": "deepblue",
|
||
"caramel": "caramel",
|
||
"chinese": "chinese",
|
||
}
|
||
script_theme = theme_map.get(theme, "deepblue")
|
||
|
||
# 调用 fast_pptx_renderer.py
|
||
cmd = [
|
||
python, render_script,
|
||
"--deck-json", deck_path,
|
||
"--output", output_path,
|
||
"--theme", script_theme,
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
|
||
if result.returncode == 0:
|
||
# 解析脚本输出
|
||
try:
|
||
script_result = json.loads(result.stdout.strip())
|
||
if script_result.get("ok"):
|
||
return {
|
||
"ok": True,
|
||
"path": output_path,
|
||
"slideCount": script_result.get("slides", 0),
|
||
}
|
||
else:
|
||
return {
|
||
"ok": False,
|
||
"path": "",
|
||
"slideCount": 0,
|
||
"error": script_result.get("error", "渲染脚本返回失败"),
|
||
}
|
||
except json.JSONDecodeError:
|
||
# 脚本输出不是 JSON,尝试统计幻灯片数
|
||
slide_count = self._count_slides(output_path)
|
||
return {"ok": True, "path": output_path, "slideCount": slide_count}
|
||
else:
|
||
logger.error(f"渲染脚本失败: {result.stderr}")
|
||
return {"ok": False, "path": "", "slideCount": 0, "error": result.stderr}
|
||
else:
|
||
# 回退到基础渲染
|
||
logger.warning(f"渲染脚本不存在: {render_script},使用基础渲染")
|
||
return self._render_basic(normalized_data, output_path, theme)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染失败: {e}")
|
||
return {"ok": False, "path": "", "slideCount": 0, "error": str(e)}
|
||
|
||
def _render_basic(self, data: dict, output_path: str, theme: str) -> dict:
|
||
"""基础 python-pptx 渲染(无 insurance-deck 时的回退)。"""
|
||
try:
|
||
from pptx import Presentation
|
||
from pptx.util import Inches, Pt, Emu
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.text import PP_ALIGN
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Inches(13.33)
|
||
prs.slide_height = Inches(7.5)
|
||
|
||
# 主题配色
|
||
themes = {
|
||
"broker": {"primary": "0A3C5F", "accent": "18898D", "gold": "C9A027"},
|
||
"modern": {"primary": "1A1A2E", "accent": "1A73E8", "gold": "FFD700"},
|
||
"fresh": {"primary": "2E7D32", "accent": "4CAF50", "gold": "8BC34A"},
|
||
"warm": {"primary": "5D4037", "accent": "FF9800", "gold": "FFC107"},
|
||
}
|
||
colors = themes.get(theme, themes["broker"])
|
||
|
||
def hex_to_rgb(hex_color: str) -> RGBColor:
|
||
h = hex_color.lstrip("#")
|
||
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||
|
||
def add_text_box(slide, left, top, width, height, text, font_size=18, bold=False, color="FFFFFF", align=PP_ALIGN.LEFT):
|
||
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
||
tf = txBox.text_frame
|
||
tf.word_wrap = True
|
||
p = tf.paragraphs[0]
|
||
p.text = text
|
||
p.font.size = Pt(font_size)
|
||
p.font.bold = bold
|
||
p.font.color.rgb = hex_to_rgb(color)
|
||
p.alignment = align
|
||
return txBox
|
||
|
||
# ─── 封面页 ─────────────────────────────────────
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6]) # 空白布局
|
||
# 背景色
|
||
background = slide.background
|
||
fill = background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = hex_to_rgb(colors["primary"])
|
||
|
||
product_name = data.get("productName", "保险计划书")
|
||
insured = data.get("insured", {})
|
||
customer_name = insured.get("name", "客户")
|
||
|
||
add_text_box(slide, 0.8, 1.5, 11.7, 1.0, customer_name, font_size=42, bold=True)
|
||
add_text_box(slide, 0.8, 2.5, 11.7, 0.6, "财富增值与传承方案", font_size=24, color="90CAF9")
|
||
add_text_box(slide, 0.8, 3.5, 11.7, 0.5, product_name, font_size=16, color="8899AA")
|
||
|
||
# 关键指标卡片
|
||
policy = data.get("policy", {})
|
||
annual_premium = policy.get("annualPremium", 0)
|
||
pay_years = policy.get("payYears", 0)
|
||
benefit_rows = data.get("benefitRows", [])
|
||
|
||
total_premium = annual_premium * pay_years
|
||
multiple = "-"
|
||
final_value = 0
|
||
if benefit_rows:
|
||
last_row = benefit_rows[-1]
|
||
final_value = last_row.get("totalSurrenderValue", 0)
|
||
if total_premium > 0:
|
||
multiple = f"{final_value / total_premium:.1f}x"
|
||
|
||
metrics = [
|
||
("年缴保费", f"${annual_premium:,.0f}"),
|
||
("缴费年期", f"{pay_years}年"),
|
||
("总投入", f"${total_premium:,.0f}"),
|
||
("期末倍数", multiple),
|
||
]
|
||
for i, (label, value) in enumerate(metrics):
|
||
x = 0.8 + i * 3.0
|
||
add_text_box(slide, x, 4.5, 2.5, 0.4, label, font_size=12, color="8899AA")
|
||
add_text_box(slide, x, 4.9, 2.5, 0.5, value, font_size=24, bold=True, color=colors["accent"])
|
||
|
||
# ─── 数据总览页 ─────────────────────────────────
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
background = slide.background
|
||
fill = background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = hex_to_rgb("FFFFFF")
|
||
|
||
add_text_box(slide, 0.5, 0.3, 12.3, 0.6, "保单数据总览", font_size=28, bold=True, color=colors["primary"])
|
||
|
||
# 基本信息表
|
||
info_items = [
|
||
("产品名称", product_name),
|
||
("受保人", f"{customer_name} ({insured.get('age', '')}岁)"),
|
||
("保单货币", policy.get("currency", "USD")),
|
||
("年缴保费", f"${annual_premium:,.0f}"),
|
||
("缴费年期", f"{pay_years}年"),
|
||
("保障期间", policy.get("coveragePeriod", "")),
|
||
]
|
||
for i, (label, value) in enumerate(info_items):
|
||
y = 1.2 + i * 0.5
|
||
add_text_box(slide, 1.0, y, 3.0, 0.4, label, font_size=14, color="666666")
|
||
add_text_box(slide, 4.0, y, 8.0, 0.4, str(value), font_size=14, bold=True, color="333333")
|
||
|
||
# ─── 利益演示页 ─────────────────────────────────
|
||
if benefit_rows:
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
background = slide.background
|
||
fill = background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = hex_to_rgb("FFFFFF")
|
||
|
||
add_text_box(slide, 0.5, 0.3, 12.3, 0.6, "逐年利益演示", font_size=28, bold=True, color=colors["primary"])
|
||
|
||
# 表头
|
||
headers = ["保单年度", "年龄", "已缴保费", "保证现金价值", "归原红利", "终期分红", "退保发还总额"]
|
||
col_widths = [1.5, 1.2, 1.8, 2.0, 1.8, 1.8, 2.2]
|
||
x = 0.5
|
||
for header, width in zip(headers, col_widths):
|
||
add_text_box(slide, x, 1.1, width, 0.4, header, font_size=11, bold=True, color=colors["primary"])
|
||
x += width
|
||
|
||
# 数据行(最多显示20行)
|
||
display_rows = benefit_rows[:20]
|
||
for row_idx, row in enumerate(display_rows):
|
||
y = 1.5 + row_idx * 0.35
|
||
values = [
|
||
str(row.get("policyYear", "")),
|
||
str(row.get("age", "")),
|
||
f"${row.get('totalPremiumPaid', 0):,.0f}",
|
||
f"${row.get('guaranteedCashValue', 0):,.0f}",
|
||
f"${row.get('reversionaryBonus', 0):,.0f}",
|
||
f"${row.get('terminalDividend', 0):,.0f}",
|
||
f"${row.get('totalSurrenderValue', 0):,.0f}",
|
||
]
|
||
x = 0.5
|
||
for value, width in zip(values, col_widths):
|
||
add_text_box(slide, x, y, width, 0.3, value, font_size=10, color="333333")
|
||
x += width
|
||
|
||
# ─── 总结页 ─────────────────────────────────────
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
background = slide.background
|
||
fill = background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = hex_to_rgb(colors["primary"])
|
||
|
||
add_text_box(slide, 0.8, 2.0, 11.7, 1.0, "方案总结", font_size=36, bold=True, align=PP_ALIGN.CENTER)
|
||
add_text_box(slide, 0.8, 3.2, 11.7, 0.6, f"总投入 ${total_premium:,.0f} → 期末价值 ${final_value:,.0f}", font_size=24, color="90CAF9", align=PP_ALIGN.CENTER)
|
||
add_text_box(slide, 0.8, 4.0, 11.7, 0.5, f"倍数增长 {multiple}", font_size=28, bold=True, color=colors["gold"], align=PP_ALIGN.CENTER)
|
||
|
||
prs.save(output_path)
|
||
slide_count = len(prs.slides)
|
||
return {"ok": True, "path": output_path, "slideCount": slide_count}
|
||
|
||
except ImportError:
|
||
return {"ok": False, "path": "", "slideCount": 0, "error": "python-pptx 未安装"}
|
||
|
||
def _count_slides(self, pptx_path: str) -> int:
|
||
"""统计 PPTX 幻灯片数量。"""
|
||
try:
|
||
from pptx import Presentation
|
||
prs = Presentation(pptx_path)
|
||
return len(prs.slides)
|
||
except Exception:
|
||
return 0
|
||
|
||
def generate_previews(self, pptx_path: str, output_dir: str) -> dict:
|
||
"""生成幻灯片预览图(需要 LibreOffice)。"""
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
try:
|
||
soffice = os.environ.get("SOFFICE_BIN", "/usr/bin/soffice")
|
||
cmd = [
|
||
soffice, "--headless", "--convert-to", "pdf",
|
||
"--outdir", output_dir, pptx_path,
|
||
]
|
||
subprocess.run(cmd, capture_output=True, timeout=60)
|
||
# 预览图生成需要额外的 PDF→PNG 转换
|
||
return {"ok": True, "previewPaths": []}
|
||
except Exception as e:
|
||
logger.warning(f"预览生成失败: {e}")
|
||
return {"ok": False, "previewPaths": [], "error": str(e)}
|