"""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)}