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:通过
742 lines
28 KiB
Python
742 lines
28 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,
|
||
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
|