"""PPT 模板资产保存、解析与路径解析。""" from __future__ import annotations import os import re import uuid import hashlib 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 template_asset_sha256(path: str) -> str: """计算模板资产指纹,供任务快照和 Worker 二次校验。""" digest = hashlib.sha256() with open(path, "rb") as source: for chunk in iter(lambda: source.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() 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"