"""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, comparison: dict = None, generation_mode: str = None, scenario: str = None, ) -> dict: """将归一化数据转换为 DeckContract 格式。""" theme_map = { "broker": "broker", "modern": "modern", "fresh": "fresh", "warm": "warm", "deepblue": "broker", "caramel": "caramel", "chinese": "chinese", "business": "business", "minimal": "minimal", "ink": "ink", } def _extract_product(data): return { "kind": data.get("kind", "savings"), "fileId": data.get("fileId", ""), "pdfName": data.get("pdfName", ""), "productId": data.get("productId", ""), "companyId": data.get("companyId", ""), "productName": data.get("productName", ""), "rawProductName": data.get("rawProductName", ""), "productConfig": data.get("productConfig", {}), "insured": data.get("insured", {}), "policy": data.get("policy", {}), "benefitRows": data.get("benefitRows", []), "withdrawalRows": data.get("withdrawalRows", []), "withdrawalProvenance": data.get("withdrawalProvenance", "missing"), "source": data.get("source", {}), } 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) from insurance.ppt.comparison import ( build_scenario_slides, detect_generation_scenario, generation_mode_for_scenario, ) resolved_scenario = scenario or detect_generation_scenario(products) resolved_mode = generation_mode or generation_mode_for_scenario(resolved_scenario) 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, "scenario": resolved_scenario, "generationMode": resolved_mode, "comparison": comparison or {}, "scenarioSlides": build_scenario_slides(products, resolved_scenario), "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, comparison: dict = None, generation_mode: str = None, scenario: str = 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, comparison, generation_mode, scenario, ) # 写入 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", "business": "business", "minimal": "minimal", "ink": "ink", } 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), "deck": deck, } 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, "deck": deck} else: logger.error(f"渲染脚本失败: {result.stderr}") return {"ok": False, "path": "", "slideCount": 0, "error": result.stderr} else: # 回退到基础渲染 logger.warning(f"渲染脚本不存在: {render_script},使用基础渲染") result = self._render_basic(normalized_data, output_path, theme) if result.get("ok"): result["deck"] = deck return result 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"}, "business": {"primary": "172033", "accent": "2563EB", "gold": "C9A027"}, "minimal": {"primary": "0F172A", "accent": "475569", "gold": "A16207"}, "ink": {"primary": "27272A", "accent": "57534E", "gold": "B48C46"}, } 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) return {"ok": True, "previewPaths": []} except Exception as e: logger.warning(f"预览生成失败: {e}") return {"ok": False, "previewPaths": [], "error": str(e)} def parse_slides(self, pptx_path: str, output_dir: str) -> dict: """将 PPTX 解析为结构化 JSON,供前端 Canvas 渲染。 Returns: {"ok": True, "jsonPath": str, "slideCount": int} """ os.makedirs(output_dir, exist_ok=True) try: from pptx import Presentation from pptx.util import Emu from pptx.enum.shapes import MSO_SHAPE_TYPE except ImportError: return {"ok": False, "error": "python-pptx 未安装"} try: prs = Presentation(pptx_path) except Exception as e: logger.warning("parse_slides: 无法打开 PPTX: %s", e) return {"ok": False, "error": str(e)} # 幻灯片尺寸(EMU → px,假设 96 DPI) slide_w_emu = prs.slide_width or Emu(12192000) # 默认 13.33 inch slide_h_emu = prs.slide_height or Emu(6858000) # 默认 7.5 inch slide_w = round(slide_w_emu / 914400 * 96) slide_h = round(slide_h_emu / 914400 * 96) slides_data = [] for idx, slide in enumerate(prs.slides): slide_info = { "index": idx, "background": self._extract_slide_bg(slide, prs), "shapes": [], } for shape_idx, shape in enumerate(slide.shapes): shape_data = self._parse_shape(shape, shape_idx, idx) if shape_data: slide_info["shapes"].append(shape_data) slides_data.append(slide_info) result = { "width": slide_w, "height": slide_h, "slideCount": len(slides_data), "slides": slides_data, } json_path = os.path.join(output_dir, "slides.json") with open(json_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) return {"ok": True, "jsonPath": json_path, "slideCount": len(slides_data)} # -- 内部辅助方法 -- @staticmethod def _emu_to_px(emu_value) -> int: """EMU 转像素(96 DPI)。""" if emu_value is None: return 0 return round(int(emu_value) / 914400 * 96) @staticmethod def _color_to_hex(color) -> str: """将 pptx RGBColor 或主题色转为 #RRGGBB。""" if color is None: return "" try: if hasattr(color, "rgb") and color.rgb: return f"#{color.rgb}" if hasattr(color, "theme_color") and color.theme_color: # 主题色无法精确解析,返回空让调用方使用默认值 return "" except Exception: pass return "" @staticmethod def _extract_theme_color_hex(slide, theme_color) -> str: """尝试从主题解析颜色,失败返回空字符串。""" try: theme = slide.part.slide_layout.slide_master.element.find( ".//{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme" ) if theme is None: return "" except Exception: return "" return "" def _extract_slide_bg(self, slide, prs) -> str: """提取幻灯片背景色。""" try: bg = slide.background fill = bg.fill if fill.type is not None: # 尝试获取纯色 try: fg = fill.fore_color hex_color = self._color_to_hex(fg) if hex_color: return hex_color except Exception: pass # 渐变取第一色 try: if hasattr(fill, "gradient_stops") and fill.gradient_stops: first = fill.gradient_stops[0].color hex_color = self._color_to_hex(first) if hex_color: return hex_color except Exception: pass except Exception: pass # 从 slide layout/master 推断 try: layout = slide.slide_layout if layout.background.fill.type is not None: try: hex_color = self._color_to_hex(layout.background.fill.fore_color) if hex_color: return hex_color except Exception: pass except Exception: pass return "#ffffff" def _parse_shape(self, shape, shape_idx: int, slide_idx: int) -> dict | None: """解析单个形状为 JSON 结构。""" from pptx.enum.shapes import MSO_SHAPE_TYPE base = { "id": f"shape_{slide_idx}_{shape_idx}", "x": self._emu_to_px(shape.left), "y": self._emu_to_px(shape.top), "w": self._emu_to_px(shape.width), "h": self._emu_to_px(shape.height), } # 图片 if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: base["type"] = "image" return base # 表格 if shape.has_table: base["type"] = "table" base["rows"] = [] for row in shape.table.rows: row_cells = [] for cell in row.cells: row_cells.append({ "text": cell.text or "", "bold": self._is_cell_bold(cell), "fontSize": self._get_cell_font_size(cell), }) base["rows"].append(row_cells) return base # 分组形状:递归解析子形状 if shape.shape_type == MSO_SHAPE_TYPE.GROUP: base["type"] = "group" base["children"] = [] for child_idx, child in enumerate(shape.shapes): child_data = self._parse_shape(child, child_idx, slide_idx) if child_data: base["children"].append(child_data) return base # 文本框 / 自选图形 if shape.has_text_frame: base["type"] = "textbox" base["paragraphs"] = [] for para in shape.text_frame.paragraphs: para_data = self._parse_paragraph(para) base["paragraphs"].append(para_data) # 形状填充 if hasattr(shape, "fill"): try: fill_hex = self._color_to_hex(shape.fill.fore_color) if fill_hex: base["fill"] = fill_hex except Exception: pass # 边框 if hasattr(shape, "line"): try: line_hex = self._color_to_hex(shape.line.color) if line_hex: base["lineColor"] = line_hex base["lineWidth"] = self._emu_to_px(shape.line.width) except Exception: pass return base # 普通形状(矩形、圆形等) base["type"] = "rect" if hasattr(shape, "fill"): try: fill_hex = self._color_to_hex(shape.fill.fore_color) if fill_hex: base["fill"] = fill_hex except Exception: pass if hasattr(shape, "line"): try: line_hex = self._color_to_hex(shape.line.color) if line_hex: base["lineColor"] = line_hex except Exception: pass return base @staticmethod def _parse_paragraph(para) -> dict: """解析段落为 JSON(合并同格式 run)。""" runs = list(para.runs) if not runs: return {"text": para.text or "", "fontSize": 14, "bold": False, "color": "#333333", "align": "left"} # 取第一个 run 的样式作为段落默认 first_run = runs[0] font = first_run.font align_val = "left" try: if para.alignment is not None: align_map = {0: "left", 1: "center", 2: "right", 3: "justify"} align_val = align_map.get(int(para.alignment), "left") except Exception: pass text_parts = [] for run in runs: text_parts.append(run.text or "") full_text = "".join(text_parts) font_size = 14 try: if font.size: font_size = round(int(font.size) / 12700) # EMU → pt except Exception: pass bold = False try: bold = bool(font.bold) except Exception: pass color = "#333333" try: if font.color and font.color.rgb: color = f"#{font.color.rgb}" except Exception: pass return { "text": full_text, "fontSize": font_size, "fontFamily": font.name or "Microsoft YaHei", "bold": bold, "color": color, "align": align_val, } @staticmethod def _is_cell_bold(cell) -> bool: try: for para in cell.text_frame.paragraphs: for run in para.runs: if run.font.bold: return True except Exception: pass return False @staticmethod def _get_cell_font_size(cell) -> int: try: for para in cell.text_frame.paragraphs: for run in para.runs: if run.font.size: return round(int(run.font.size) / 12700) except Exception: pass return 12