baodan/api/insurance/ppt/renderer.py
wsb1224 cf77d40569 本轮新增:
PPT 对象选择和绿色选中框。
双击编辑文本框、表格单元格。
文字字体、字号、加粗、颜色、对齐属性。
Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z 撤销重做。
PPT 缩略图/网格总览切换。
历史版本只读查看。
历史版本“恢复为新版本”,不覆盖旧文件。
海报区块显示/隐藏。
长图区块上移、下移排序。
新背景候选确认,可选择使用新背景或保留原背景。
合规问题“一键采用建议”。
新增 warn 合规级别。
解析失败人工填写入口。
展示解析方法和低质量页诊断。
同步更新了[API 接口文档](/D:/work/code/python/coding/baodanagent/docs/保险智能客服系统_API接口文档.md)。
验证结果:
相关后端测试:28 passed, 1 skipped
Python 编译检查:通过
前端生产构建:通过
git diff --check:通过
2026-07-31 15:41:58 +08:00

742 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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