# 用户自定义 PPT 模板支持 — 修改方案

## 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. 实施优先级建议

按以下顺序实施，每步可独立测试：

1. **P0 - 模板解析器**（`template_parser.py`）
   - 解析内置 3 个模板，验证输出 `TemplateProfile` 与现有硬编码逻辑一致
   - 解析用户上传模板，验证结构识别正确

2. **P0 - 动态布局上下文**（`page_layouts.py` + `ppt_builder.py` 参数传递）
   - 所有布局函数支持 `LayoutContext`
   - 内置模板走新流程，输出应与旧版本逐页一致

3. **P1 - 动态母版选择**（`ppt_builder.py`）
   - 按 `TemplateProfile` 选择母版页复制
   - 支持单页模板、多页模板的母版映射

4. **P1 - 主题色/字体提取与应用**（`theme_manager.py` + `ppt_builder.py`）
   - 从模板提取配色，应用到图表、卡片、文本
   - 用户配置覆盖机制

5. **P2 - 占位符别名与增强匹配**
   - 支持更多占位符命名变体
   - 语义匹配兜底

6. **P2 - 质量检查适配**（`quality_inspector.py`）
   - 读取实际幻灯片尺寸
   - 模板字体白名单

7. **P2 - 用户确认流程展示**（`agent_analyzer.py` + `SKILL.md`）
   - 第5项确认展示模板解析结果
   - 文档更新
