# 用户自定义 PPT 模板支持 — 修改方案 > **实现状态**:以下模块已实现并投入使用,本文档同时作为设计文档与实现参考。 | 模块 | 状态 | 说明 | |------|------|------| | `template_parser.py` | ✅ 已实现 | 解析任意 .pptx 模板,输出 TemplateProfile | | `page_layouts.py` (LayoutContext) | ✅ 已实现 | 动态布局上下文,支持自适应尺寸 | | `ppt_builder.py` (动态母版选择) | ✅ 已实现 | 按 TemplateProfile 选择母版页复制 | | `theme_manager.py` (主题色提取) | ✅ 已实现 | 三级颜色解析:用户主题 > 模板主题 > 默认 | | 占位符别名/增强匹配 | ✅ 已实现 | 支持多种占位符命名变体 + 语义匹配 | | `quality_inspector.py` (质量检查适配) | ✅ 已实现 | 读取实际幻灯片尺寸,模板字体白名单 | | 用户确认流程展示 | ✅ 已实现 | 第5项确认展示模板解析结果 | > **注意**:`build_daily_report()`、`build_weekly_report()`、`build_monthly_report()` 及 `deep_insights.py` 等早期订单专属函数已删除。所有报告生成统一通过 `quality_assured_build()` / `build_report()` 接口,配置驱动。 ## 1. 需求概述 ### 现状 - Skill 内置 3 套固定模板:`report-master.pptx`(日报)、`weekly-master.pptx`(周报)、`monthly-master.pptx`(月报) - 模板存放于 `assets/` 目录,按 `report_type` 硬编码映射 - 构建流程:加载模板 → 复制母版幻灯片 → 替换占位符 → 插入图表/文本 → 删除原始模板页 ### 目标 - **用户可上传自定义 `.pptx` 模板**,skill 按该模板的样式(配色、字体、布局、背景)生成报告 - **工作流程完全不变**:数据探查 → 六项确认 → 生成 PPT → 质量自检 - **改动范围最小化**:仅在"模板加载与样式适配"环节做修改,不触及数据分析与洞察生成逻辑 --- ## 2. 核心设计思路 ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ 用户上传模板 │ ──→ │ TemplateParser │ ──→ │ TemplateProfile │ │ (任意.pptx) │ │ 解析结构+样式 │ │ 结构化模板描述 │ └─────────────────┘ └──────────────────┘ └─────────────────┘ │ ┌───────────────────────────┘ ▼ ┌─────────────────────┐ │ ppt_builder.py │ │ 按 TemplateProfile │ │ 动态选择母版页复制 │ │ 动态应用配色/字体 │ └─────────────────────┘ ``` **关键原则**: 1. **解析而非约束**:自动识别模板中的母版页类型、占位符、配色、字体,不强求用户按固定规范制作模板 2. **映射兜底**:若用户模板缺少某类母版页(如目录页),自动回退到内置模板或跳过该页 3. **样式继承优先**:图表、卡片、文本的颜色/字体优先使用模板提取的样式,用户显式配置可覆盖 --- ## 3. 模块级修改清单 ### 3.1 新增模块:`scripts/template_parser.py`(模板解析引擎) **职责**:读取任意 `.pptx` 模板,输出结构化的 `TemplateProfile` **核心数据结构**: ```python @dataclass class MasterSlideInfo: slide_index: int # 在 prs.slides 中的索引 master_type: str # 'cover' | 'content' | 'toc' | 'end' | 'unknown' placeholders: list[str] # 检测到的占位符列表,如 ['{report_title}', '{page_title}'] content_top: int # 内容区域起始 Y(EMU),通过 {page_title} 底部 + gap 推算 has_footer: bool # 是否自带页脚 has_background: bool # 是否有背景图/形状 @dataclass class TemplateProfile: path: str is_builtin: bool slide_width: int # 幻灯片宽度(EMU) slide_height: int # 幻灯片高度(EMU) master_slides: list[MasterSlideInfo] placeholder_map: dict # 全局占位符 → 母版页索引 映射 detected_theme: dict # 提取的颜色 {primary, accent, text, ...} detected_fonts: dict # 提取的字体 {title_font, body_font, number_font} safe_margins: dict # {left, right, top, bottom} in EMU # 快捷访问方法 def get_master_for(self, page_type: str) -> MasterSlideInfo: ... def get_content_top(self, page_type: str = 'content') -> int: ... ``` **解析逻辑**: | 检测项 | 方法 | 说明 | |--------|------|------| | 母版页类型 | `_detect_master_type(slide)` | 通过文本特征匹配:`{report_title}`→cover,`{page_title}`→content,`{chapter`→toc,末页+感谢文字→end | | 占位符 | `_scan_placeholders(slide)` | 正则匹配 `{\w+}` 形式的文本 | | 内容区域 | `_detect_content_top(slide)` | 已有 `_detect_content_top`,迁移并增强:识别标题 shape 底部 + 动态 gap | | 主题色 | `_extract_colors(slide)` | 从母版形状的 fill/line 颜色、主题色表(`slide.slide_layout.slide_master.theme.color_scheme`)提取主色、强调色 | | 字体 | `_extract_fonts(slide)` | 统计各母版页中不同字体的使用频次,取标题区(top < 1.5M EMU)最多字体为 title_font,内容区最多为 body_font | | 尺寸 | `prs.slide_width`, `prs.slide_height` | 读取实际尺寸,支持宽屏(16:9)和标准(4:3) | **内置模板适配**: - 对 `assets/` 中的 3 个内置模板也走同一套解析流程,输出 `TemplateProfile` - 这样内置/自定义模板在下游的接口完全一致 --- ### 3.2 修改模块:`scripts/report_config.py` **新增字段**: ```python @dataclass class ReportConfig: # ... 原有字段 ... template_path: str = '' # 已有,保持不变 template_profile: Optional['TemplateProfile'] = None # NEW: 解析后的模板描述 use_template_theme: bool = True # NEW: 是否用模板提取的配色覆盖默认主题 # 确认项扩展(第5项确认) # 原:"页面结构与模板方案" # 现增加展示:模板解析结果(可用母版页、检测到的配色、字体) ``` **ConfirmationSpec 扩展**(用户确认时展示模板信息): ```python @dataclass class TemplateConfirmationDetail: template_name: str detected_master_types: list[str] # ['cover', 'content', 'toc', 'end'] detected_colors: dict detected_fonts: dict warnings: list[str] # 如 "未检测到目录页母版,将自动生成" ``` --- ### 3.3 修改模块:`scripts/theme_manager.py` **新增函数**: ```python def extract_theme_from_template(template_profile: 'TemplateProfile') -> ThemeConfig: """ 从 TemplateProfile 提取的颜色构建 ThemeConfig。 映射规则: - primary → 母版中面积最大的深色填充 / 主题色表第一个颜色 - accent → 母版中出现频次最高的亮色(绿/蓝/橙) - text → 母版正文文本颜色 / 默认 #333333 - card_bg → primary 的 10% 透明度浅色变体 - chart_series → 从母版色表提取前8色 """ def merge_theme(template_theme: ThemeConfig, user_theme: ThemeConfig) -> ThemeConfig: """ 合并模板主题与用户显式配置,用户配置优先。 """ ``` **修改 `get_theme()`**: - 新增预设 `ThemePreset.FROM_TEMPLATE = 'from_template'` - 当 `config.theme.preset == FROM_TEMPLATE` 或 `config.use_template_theme == True` 时,调用 `extract_theme_from_template()` --- ### 3.4 修改模块:`scripts/page_layouts.py` **核心问题**:当前布局常量(`SLIDE_WIDTH = 16256000` 等)是硬编码的 16:9 尺寸,若用户模板是 4:3 或其他尺寸会错位。 **修改方案**: 1. 保留全局常量作为**默认值** 2. 所有计算函数增加 `slide_width` / `slide_height` 可选参数 3. 新增 `LayoutContext` 类,封装当前模板的尺寸信息 ```python @dataclass class LayoutContext: slide_width: int = SLIDE_WIDTH slide_height: int = SLIDE_HEIGHT content_top: int = int(CONTENT_TOP_BASE) footer_top: int = FOOTER_TOP margin_left: int = int(MARGIN_LEFT) margin_right: int = int(MARGIN_RIGHT) @classmethod def from_template_profile(cls, profile: 'TemplateProfile') -> 'LayoutContext': ... ``` **修改所有布局函数签名**: ```python # 修改前 def get_kpi_grid(content_top_emu: int = None, ...) -> list[LayoutZone]: # 修改后 def get_kpi_grid(content_top_emu: int = None, ctx: LayoutContext = None, ...) -> list[LayoutZone]: ctx = ctx or LayoutContext() ... ``` **调用侧修改**:`ppt_builder.py` 在构建每页前创建 `LayoutContext`,传入各布局函数。 --- ### 3.5 修改模块:`scripts/ppt_builder.py`(核心构建器) #### 3.5.1 模板解析入口 在 `build_report()` 和 `_build_without_save()` 开头增加: ```python def _resolve_template_profile(config: ReportConfig) -> TemplateProfile: if config.template_profile: return config.template_profile if config.template_path: return parse_template(config.template_path) # template_parser.parse_template # 内置模板 return parse_template(_resolve_master_template(config)) ``` #### 3.5.2 母版页选择逻辑(关键修改) **现状**:固定索引复制 ```python slide = _duplicate_slide(prs, prs.slides[0]) # 封面 slide = _duplicate_slide(prs, prs.slides[1]) # 内容 slide = _duplicate_slide(prs, prs.slides[3]) # 尾页 ``` **新设计**:通过 `TemplateProfile` 动态选择 ```python def _duplicate_master_slide(prs, profile: TemplateProfile, page_type: str): master_info = profile.get_master_for(page_type) if master_info: return _duplicate_slide(prs, prs.slides[master_info.slide_index]) # 兜底:按原硬编码索引 fallback_index = {'cover': 0, 'content': 1, 'toc': 1, 'end': 3}.get(page_type, 1) return _duplicate_slide(prs, prs.slides[fallback_index]) ``` #### 3.5.3 配色与字体应用 **现状**:硬编码颜色常量 + 主题转换 ```python C_PRIMARY = RGBColor(0x1E, 0x3A, 0x5F) # 硬编码 colors = theme_to_rgb_colors(config.theme) # 用户主题 ``` **新设计**:三层优先级 1. 用户显式配置的 `config.theme`(最高优先级) 2. 模板提取的 `detected_theme`(当 `use_template_theme=True`) 3. 默认颜色常量(兜底) ```python def _resolve_colors(config: ReportConfig, profile: TemplateProfile) -> dict: if config.theme and not config.use_template_theme: return theme_to_rgb_colors(config.theme) template_theme = extract_theme_from_template(profile) return theme_to_rgb_colors(template_theme) ``` **字体同理**:模板提取的字体 → 用户配置 → 默认 "微软雅黑"/"Arial" #### 3.5.4 布局上下文传递 ```python def build_report(data_file: str, config: ReportConfig, output_path: str) -> str: profile = _resolve_template_profile(config) ctx = LayoutContext.from_template_profile(profile) colors = _resolve_colors(config, profile) fonts = _resolve_fonts(config, profile) # 新增 # 后续所有 _build_xxx_page 调用增加 profile, ctx, fonts 参数 ... ``` #### 3.5.5 占位符兼容(关键) 用户模板中的占位符命名可能与内置模板不同。建立**占位符别名映射**: ```python PLACEHOLDER_ALIASES = { '{report_title}': ['{report_title}', '{标题}', '{title}'], '{page_title}': ['{page_title}', '{页面标题}', '{subtitle}'], '{date}': ['{date}', '{日期}', '{report_date}'], '{department}': ['{department}', '{部门}', '{source}'], '{period}': ['{period}', '{周期}', '{report_period}'], } ``` `_replace_placeholder()` 增强为支持别名匹配: ```python def _replace_placeholder(slide, placeholder, new_text, aliases=None): aliases = aliases or [] targets = [placeholder] + aliases for shape in slide.shapes: if not shape.has_text_frame: continue for para in shape.text_frame.paragraphs: for target in targets: if target in para.text: para.text = para.text.replace(target, str(new_text)) ``` --- ### 3.6 修改模块:`scripts/quality_inspector.py` **适配点**: 1. 尺寸检查不再使用硬编码 `SLIDE_WIDTH/SLIDE_HEIGHT`,改为读取实际 `slide.slide_width / slide.slide_height` 2. 字体一致性检查:允许模板提取的字体组合(如标题用 A 字体、正文用 B 字体),不强制单一字体 3. 内容区域检测:使用 `LayoutContext` 或从模板 profile 传入的 `content_top` ```python # 修改前(硬编码) sw = int(slide.slide_width) if hasattr(slide, 'slide_width') else SLIDE_WIDTH # 修改后(实际读取,已有逻辑,确认生效即可) # 补充:支持从 config 读取 template_profile 中的安全边距 ``` --- ### 3.7 修改模块:`scripts/agent_analyzer.py`(用户确认流程) **第 5 项确认**(页面结构与模板方案)增强: 当用户提供了自定义模板时,展示解析结果供确认: ``` 【模板解析结果】 - 检测到母版页:封面页(✓) 内容页(✓) 目录页(✗) 尾页(✓) - 检测到配色:主色 #1E3A5F,强调色 #10B981 - 检测到字体:标题=微软雅黑,正文=微软雅黑 - 内容区域起始:距顶部 2.1cm - ⚠️ 未检测到目录页母版,目录页将使用内容页母版替代 是否应用模板提取的配色和字体? [是/否] ``` --- ## 4. 文件修改明细表 | 文件 | 修改类型 | 修改内容 | |------|----------|----------| | `scripts/template_parser.py` | **新增** | 模板解析引擎,输出 TemplateProfile | | `scripts/report_config.py` | 修改 | 新增 `template_profile`, `use_template_theme` 字段;新增 `TemplateConfirmationDetail` | | `scripts/theme_manager.py` | 修改 | 新增 `extract_theme_from_template()`, `merge_theme()`, `ThemePreset.FROM_TEMPLATE` | | `scripts/page_layouts.py` | 修改 | 新增 `LayoutContext`,所有函数支持动态尺寸 | | `scripts/ppt_builder.py` | 修改 | 模板解析入口、动态母版选择、三层配色/字体优先级、占位符别名、LayoutContext 传递 | | `scripts/quality_inspector.py` | 修改 | 尺寸/边距读取实际值,字体检查适配模板字体 | | `scripts/agent_analyzer.py` | 修改 | 第5项确认展示模板解析结果 | | `SKILL.md` | 修改 | 更新文档:自定义模板使用说明、模板制作规范 | --- ## 5. 用户交互流程(新增) ``` 用户: 我要用我自己的模板生成报告 (上传 my-template.pptx) Agent: 收到模板,正在解析... [调用 template_parser.parse_template()] Agent: 【模板解析完成】 - 尺寸: 16:9 宽屏 - 母版页: 封面✓ 内容✓ 尾页✓ - 检测到配色: 主色 #2B579A,强调色 #FF6B35 - 检测到字体: 标题=思源黑体,正文=微软雅黑 - ⚠️ 未找到 {page_title} 占位符,将自动添加页面标题 Agent: 是否应用模板提取的配色?[是/否] 用户: 是 Agent: (继续原有6项确认流程,第5项已包含模板信息) ... Agent: (调用 build_report(),内部使用 TemplateProfile) → 生成按用户模板样式的 PPT ``` --- ## 6. 模板制作规范(面向用户) 为获得最佳效果,建议用户在模板中遵循以下规范: ### 6.1 母版页结构 建议模板包含 **4 个母版幻灯片**(至少包含封面页和内容页): | 母版页 | 建议包含的占位符 | 用途 | |--------|-----------------|------| | 封面页 | `{report_title}`, `{date}`, `{department}` | 报告封面 | | 目录页 | `{chapter1_title}`, `{chapter1_desc}`, ... | 目录/导航页 | | 内容页 | `{page_title}`, `{source}`, `{period}` | 正文页(图表、洞察) | | 尾页 | `{report_title}` | 结束页 | ### 6.2 占位符规则 - 占位符使用 `{}` 包裹,如 `{report_title}` - 不强制要求所有占位符,缺少的会自动跳过或智能补充 - 支持自定义命名,agent 会通过语义匹配和别名映射识别 ### 6.3 样式设计建议 - **主题色**:在母版中设置主题颜色(设计 → 变体 → 颜色),agent 会自动提取 - **字体**:在母版中分别设置标题和正文字体,agent 会识别并统一应用 - **背景**:可使用纯色、渐变或图片背景,复制时会完整保留 - **页眉/页脚**:模板中已有的页眉页脚图形会保留,agent 会自动检测避免重复添加 --- ## 7. 风险与兜底策略 | 风险场景 | 兜底策略 | |----------|----------| | 用户模板只有1页 | 该页同时作为封面/内容/尾页的复制源;缺失页类型跳过 | | 无法识别母版页类型 | 默认第1页=cover,最后1页=end,其余=content | | 无法提取主题色 | 回退到 `ThemePreset.BUSINESS_CLASSIC` | | 用户模板尺寸非标准 | `LayoutContext` 读取实际尺寸,布局函数自适应计算 | | 占位符命名完全自定义 | 通过语义相似度匹配(如文本框位置、内容特征) | | 模板有复杂动画/媒体 | python-pptx 复制元素时会保留 XML,通常可保留;视频等不支持元素会自动跳过 | --- ## 8. 向后兼容性 - **完全不提供模板**:走现有逻辑,使用 `assets/` 内置模板,行为与现在完全一致 - **提供 `template_path` 但无 `template_profile`**:自动调用 `template_parser` 解析,兼容旧配置 - **显式设置 `theme`** 且 `use_template_theme=False`:完全使用用户指定主题,忽略模板颜色 - **原有 API** `build_daily_report()` / `build_report()` / `quality_assured_build()` 签名不变,内部自动适配 --- ## 9. 实施优先级建议 > **全部已实施**。以下所有模块均已完成开发并投入使用。 | 优先级 | 模块 | 状态 | |--------|------|------| | P0 | `template_parser.py` — 模板解析器 | ✅ | | P0 | `page_layouts.py` + `ppt_builder.py` — 动态布局上下文 | ✅ | | P1 | `ppt_builder.py` — 动态母版选择 | ✅ | | P1 | `theme_manager.py` + `ppt_builder.py` — 主题色/字体提取与应用 | ✅ | | P2 | 占位符别名与增强匹配 | ✅ | | P2 | `quality_inspector.py` — 质量检查适配 | ✅ | | P2 | `agent_analyzer.py` + `SKILL.md` — 用户确认流程展示 | ✅ |