已完成约 40%(阶段 1 + 阶段 2 + 阶段 3 基础编辑),覆盖了计划书中最核心的 P0 需求: ✅ 真实预览(Canvas 方案替代了 LibreOffice 方案) ✅ 质量检查(6 自动 + 4 人工) ✅ 三栏结果工作台 ✅ 基础文字编辑
155 lines
5.8 KiB
Python
155 lines
5.8 KiB
Python
"""PPT 模板资产保存、解析与路径解析。"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import uuid
|
||
|
||
|
||
BUILTIN_SCHEME = "builtin://"
|
||
STORED_SCHEME = "stored://"
|
||
MAX_TEMPLATE_BYTES = 30 * 1024 * 1024
|
||
BUILTIN_TEMPLATE_ROOT = os.path.join(os.path.dirname(__file__), "template_assets")
|
||
|
||
|
||
def resolve_template_asset(asset_id: str | None) -> str | None:
|
||
"""将数据库中的模板资产标识解析为受控的本地绝对路径。"""
|
||
value = str(asset_id or "")
|
||
if value.startswith(BUILTIN_SCHEME):
|
||
return _safe_join(BUILTIN_TEMPLATE_ROOT, value[len(BUILTIN_SCHEME):])
|
||
if value.startswith(STORED_SCHEME):
|
||
from insurance.config import get_storage_root
|
||
root = os.path.join(get_storage_root(), "ppt_templates")
|
||
return _safe_join(root, value[len(STORED_SCHEME):])
|
||
return None
|
||
|
||
|
||
def save_uploaded_template(file_storage, template_id: str) -> tuple[str, str]:
|
||
"""校验并保存上传的 PPTX,返回资产标识和绝对路径。"""
|
||
if not file_storage or not file_storage.filename:
|
||
raise ValueError("请选择 PPTX 模板文件")
|
||
if not file_storage.filename.lower().endswith(".pptx"):
|
||
raise ValueError("仅支持 .pptx 格式")
|
||
|
||
stream = file_storage.stream
|
||
stream.seek(0, os.SEEK_END)
|
||
size = stream.tell()
|
||
stream.seek(0)
|
||
if size <= 0:
|
||
raise ValueError("模板文件为空")
|
||
if size > MAX_TEMPLATE_BYTES:
|
||
raise ValueError("模板文件不能超过 30MB")
|
||
|
||
safe_id = re.sub(r"[^a-zA-Z0-9_-]", "_", template_id)[:50] or "template"
|
||
relative_path = f"{safe_id}/{uuid.uuid4().hex}.pptx"
|
||
from insurance.config import get_storage_root
|
||
root = os.path.join(get_storage_root(), "ppt_templates")
|
||
absolute_path = _safe_join(root, relative_path)
|
||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||
file_storage.save(absolute_path)
|
||
return f"{STORED_SCHEME}{relative_path.replace(os.sep, '/')}", absolute_path
|
||
|
||
|
||
def parse_template_pptx(path: str) -> dict:
|
||
"""提取幻灯片标题、页面类型和基础版式信息。"""
|
||
try:
|
||
from pptx import Presentation
|
||
except ImportError as exc:
|
||
raise ValueError("服务端缺少 PPTX 解析组件") from exc
|
||
|
||
try:
|
||
presentation = Presentation(path)
|
||
except Exception as exc:
|
||
raise ValueError(f"PPTX 文件无法解析:{exc}") from exc
|
||
|
||
slides = []
|
||
total = len(presentation.slides)
|
||
for index, slide in enumerate(presentation.slides):
|
||
title = _extract_slide_title(slide) or f"第 {index + 1} 页"
|
||
slides.append({
|
||
"pageType": _infer_page_type(slide, title, index, total),
|
||
"title": title[:120],
|
||
"narrativeHint": "由源 PPTX 自动解析",
|
||
"sourceSlide": index + 1,
|
||
})
|
||
|
||
if not slides:
|
||
raise ValueError("模板中没有可用幻灯片")
|
||
return {
|
||
"slideCount": total,
|
||
"width": round(presentation.slide_width / 914400, 2),
|
||
"height": round(presentation.slide_height / 914400, 2),
|
||
"slidesConfig": slides,
|
||
"requiredPageTypes": [slide["pageType"] for slide in slides],
|
||
}
|
||
|
||
|
||
def delete_stored_template(asset_id: str | None) -> None:
|
||
"""删除管理员上传的旧资产;内置模板永不删除。"""
|
||
if not str(asset_id or "").startswith(STORED_SCHEME):
|
||
return
|
||
path = resolve_template_asset(asset_id)
|
||
if path and os.path.isfile(path):
|
||
try:
|
||
os.remove(path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _safe_join(root: str, relative_path: str) -> str:
|
||
normalized_root = os.path.abspath(root)
|
||
target = os.path.abspath(os.path.join(normalized_root, relative_path))
|
||
try:
|
||
if os.path.commonpath([normalized_root, target]) != normalized_root:
|
||
raise ValueError("模板资产路径非法")
|
||
except ValueError as exc:
|
||
raise ValueError("模板资产路径非法") from exc
|
||
return target
|
||
|
||
|
||
def _extract_slide_title(slide) -> str:
|
||
title_shape = slide.shapes.title
|
||
if title_shape is not None and title_shape.has_text_frame:
|
||
value = title_shape.text.strip()
|
||
if value:
|
||
return value
|
||
for shape in slide.shapes:
|
||
if getattr(shape, "has_text_frame", False):
|
||
value = shape.text.strip()
|
||
if value:
|
||
return value.splitlines()[0].strip()
|
||
return ""
|
||
|
||
|
||
def _infer_page_type(slide, title: str, index: int, total: int) -> str:
|
||
text = title.lower()
|
||
if index == 0:
|
||
return "cover"
|
||
if index == total - 1 or any(word in text for word in ("感谢", "下一步", "thank")):
|
||
return "closing"
|
||
if "组合" in text and "摘要" in text:
|
||
return "combined_summary"
|
||
if "摘要" in text:
|
||
return "policy_summary"
|
||
if any(word in text for word in ("对应表", "年度对应", "计划书对应")):
|
||
return "alignment_table"
|
||
if any(word in text for word in ("启动路径", "两种路径")):
|
||
return "launch_paths"
|
||
if any(word in text for word in ("承接", "现金流接力")):
|
||
return "cashflow_bridge"
|
||
if any(word in text for word in ("时间轴", "时间表", "阶段")):
|
||
return "timeline"
|
||
if any(word in text for word in ("保司", "公司介绍", "公司实力")):
|
||
return "company"
|
||
if any(getattr(shape, "has_chart", False) for shape in slide.shapes):
|
||
return "comparison_chart" if "对比" in text else "chart"
|
||
if any(getattr(shape, "has_table", False) for shape in slide.shapes):
|
||
return "comparison_table" if "对比" in text else "table"
|
||
if any(word in text for word in ("对比", "比较")):
|
||
return "compare"
|
||
if any(word in text for word in ("组合", "协同", "分工")):
|
||
return "synergy"
|
||
if any(word in text for word in ("结论", "建议", "适合")):
|
||
return "conclusion"
|
||
return "guidance"
|