Bläddra i källkod

完善模板生成问题

kyle 5 dagar sedan
förälder
incheckning
1f2b943df1

+ 0 - 442
generate-data-report-ppt/CUSTOM_TEMPLATE_DESIGN.md

@@ -1,442 +0,0 @@
-# 用户自定义 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` — 用户确认流程展示 | ✅ |

+ 45 - 0
generate-data-report-ppt/SKILL.md

@@ -222,3 +222,48 @@ For analytical quality, load `references/professional-data-analyst-playbook.md`
 1. 用户显式配置 `config.theme`(最高优先级)
 2. 模板提取的配色(`use_template_theme=True` 时生效)
 3. 默认商务经典主题(兜底)
+
+## 关键缺陷与修复记录
+
+> **封面页速查** — quality_inspector 自动检测以下问题(V006/C012),无需手动排查:
+> 1. `_duplicate_slide()` 不用 `source_slide.slide_layout` → 背景/Logo 丢失 ✅ 代码已修复
+> 2. `keep_shapes=False` 用于封面 → 占位符被删除 ✅ 代码已修复
+> 3. 占位符文字浅色但位置在白色背景区域 → **V006 自动检测+修复**
+> 4. 封面占位符仍为模板默认文字 → **C012 自动检测,阻断输出**
+
+### `_build_cover_page()` 双阶段占位符填充 — idx 兜底机制 (2026-05-25)
+
+**症状**: 模板封面无 `{report_title}` 等文本标记时,封面显示空白。
+
+**修复**: 两阶段策略:
+- **Pass 1**: `_replace_all_placeholders()` 文本模式匹配(兼容内置模板)
+- **Pass 2**: `placeholder_format.idx` 兜底直填(兼容 Wuling 等无标记模板)
+
+```python
+# idx 映射:0=TITLE, 10=SUBTITLE, 21/22=BODY quarter-size
+if ph.idx == 0:   _set_para_text(p, config.title, C_PRIMARY, Pt(36))
+elif ph.idx == 21: _set_para_text(p, date_text, C_PRIMARY, Pt(18))
+elif ph.idx == 22: _set_para_text(p, dept_text, C_TEXT_GRAY, Pt(12))
+```
+
+**⚠️ 文字颜色始终用深色**:很多模板封面只有上半部有彩色 banner,title 在白色区域。
+
+### `_duplicate_slide()` — 核心修复 (2026-05-25)
+
+两条关键修复,已应用到全代码库:
+
+| 修复 | 问题 | 方案 |
+|------|------|------|
+| 用 `source_slide.slide_layout` 代替 `blank_layout` | 切到错误 Master,丢失背景/Logo | `new_slide = prs.slides.add_slide(source_layout)` |
+| 增加 `keep_shapes` 参数 | 封面/目录/尾页占位符被删除 | `keep_shapes=True` 保留 layout 占位符 |
+
+OOXML 三层继承链:`Theme → Slide Master(背景+Logo) → Slide Layout(占位符) → Slide(内容)`
+
+### python-pptx 装饰图形陷阱 (2026-05-25)
+
+`add_slide(layout)` 只创建占位符,**不复制** layout 中的非占位符装饰图形(渐变矩形、Logo 等)。
+这些图形存在于 layout XML 的 `<p:spTree>` 中,PowerPoint 打开时从 layout 继承渲染,
+但 python-pptx 不保证引用正确 → 装饰图形可能丢失。
+
+**工具函数**: `copy_layout_decorative_shapes(slide, layout)` — 从 layout XML deepcopy
+所有无 `<p:ph>` 的 `<p:sp>` 到 slide XML。

+ 38 - 1
generate-data-report-ppt/references/quality-standards.md

@@ -233,7 +233,44 @@
 
 ---
 
-## 九、质检流程集成
+## 九、封面页质量专项规则
+
+封面页是 PPT 的第一印象,且 python-pptx 在处理自定义模板时有多个已知陷阱。
+以下规则由 `quality_inspector._check_cover_quality()` 在生成后自动执行。
+
+### V006:封面文字颜色与背景冲突(critical,自动修复)
+
+**检测逻辑**:
+1. 扫描封面页所有 `is_placeholder=True` 的 shape
+2. 检测页面顶部的彩色 banner(y≈0 的填充矩形)底部位置
+3. 如果 placeholder 在 banner 下方(白色区域)且文字颜色 RGB 三通道均 ≥ 0xCC(近白色)→ 报 V006
+
+**自动修复**:将文字颜色改为 `theme_colors['primary']`(默认 #1E3A5F 深蓝)
+
+**典型场景**:Wuling 模板,蓝色渐变矩形覆盖 0→3441700 EMU,title placeholder 在 y=3818251。
+开发者设了 `C_WHITE` → 白字在白色背景上完全隐形。
+
+### C012:封面占位符仍为模板默认文字(critical,不可自动修复)
+
+**检测逻辑**:
+1. 检查封面 placeholder 文本是否包含模板默认文字:
+   - `"单击此处编辑母版标题样式"`
+   - `"单击此处添加标题"`
+   - `"单击此处编辑母版文本样式"`
+   - `"单击此处添加文本"`
+   - `"单击此处添加副标题"`
+
+**不可自动修复**:因为无法从上下文中获取正确的报告标题/日期/部门。
+需触发 rebuild 或返回错误让调用方处理。
+
+**典型场景**:模板占位符不含 `{report_title}` 等文本标记,
+`_replace_all_placeholders()` 找不到替换目标,占位符保持 PowerPoint 默认文字。
+`_build_cover_page` 的 Pass 2(idx 兜底)应在 build 阶段解决此问题;
+C012 是最后的防线,确保不会输出含模板默认文字的 PPT。
+
+---
+
+## 十、质检流程集成
 
 ### Agent 在生成 PPT 前必须读取本文档
 

+ 172 - 19
generate-data-report-ppt/scripts/ppt_builder.py

@@ -147,14 +147,18 @@ def _resolve_fonts(config: ReportConfig, profile) -> dict:
     return result
 
 
-def _duplicate_master_slide(prs, profile, page_type: str):
-    """Duplicate the appropriate master slide for the given page_type."""
+def _duplicate_master_slide(prs, profile, page_type: str, keep_shapes: bool = False):
+    """Duplicate the appropriate master slide for the given page_type.
+
+    keep_shapes=True: keep layout-inherited placeholders (cover/toc/end pages).
+    keep_shapes=False: remove layout placeholders and copy from source (content pages).
+    """
     idx = profile.get_master_index_for(page_type)
     if 0 <= idx < len(prs.slides):
         source = prs.slides[idx]
     else:
         source = prs.slides[0]
-    return _duplicate_slide(prs, source)
+    return _duplicate_slide(prs, source, keep_shapes=keep_shapes)
 
 
 def _is_forecast_page_type(page_type: str) -> bool:
@@ -228,13 +232,61 @@ def _delete_template_slides(prs, count=None):
         del prs.slides._sldIdLst[0]
 
 
-def _duplicate_slide(prs, source_slide):
-    # Use last available layout (typically blank) to avoid index errors on custom templates
-    layout_idx = min(6, len(prs.slide_layouts) - 1)
-    blank_layout = prs.slide_layouts[layout_idx]
-    new_slide = prs.slides.add_slide(blank_layout)
-    
-    # Copy slide background (solid, gradient, image) from source
+def copy_layout_decorative_shapes(slide, layout):
+    """Copy non-placeholder decorative shapes from a layout to a slide.
+
+    python-pptx's add_slide(layout) does NOT copy layout-level decorative
+    shapes (gradient rectangles, logos, decorative lines) to the slide's
+    spTree. PowerPoint renders them from the layout reference, but this
+    is unreliable across PowerPoint versions.
+
+    This function deep-copies all <p:sp> elements from the layout's spTree
+    that do NOT contain a <p:ph> (placeholder) element into the slide's spTree.
+
+    Args:
+        slide: The slide to add shapes to (from prs.slides.add_slide(layout)).
+        layout: The SlideLayout whose decorative shapes should be copied.
+    Returns:
+        int: Number of shapes copied.
+    """
+    from copy import deepcopy
+    from pptx.oxml.ns import qn
+
+    layout_spTree = layout._element.find(qn('p:cSld')).find(qn('p:spTree'))
+    slide_spTree = slide._element.find(qn('p:cSld')).find(qn('p:spTree'))
+
+    count = 0
+    for child in list(layout_spTree):
+        tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
+        if tag == 'sp':
+            # Check if this shape is a placeholder (has <p:ph> element)
+            ph = child.find('.//' + qn('p:ph'))
+            if ph is None:
+                new_shape = deepcopy(child)
+                slide_spTree.append(new_shape)
+                count += 1
+    return count
+
+
+def _duplicate_slide(prs, source_slide, keep_shapes: bool = False):
+    # Use the SOURCE slide's own layout to preserve:
+    #   - layout-level background (gradient, color, image)
+    #   - layout-level shapes (company logo, decorative icons)
+    #   - theme colors, fonts
+    # Previously used blank_layout which stripped all of the above.
+    source_layout = source_slide.slide_layout
+    new_slide = prs.slides.add_slide(source_layout)
+
+    if not keep_shapes:
+        # Remove layout-default shapes (placeholders) from the new slide —
+        # they'll be replaced by shapes deep-copied from the source slide.
+        # Layout-level decorative shapes (logos, backgrounds) are NOT in
+        # slide.shapes and remain intact via layout inheritance.
+        for shape in list(new_slide.shapes):
+            sp = shape._element
+            sp.getparent().remove(sp)
+
+    # Copy slide-level background override if present (rare, but safe)
     try:
         src_cSld = source_slide._element.cSld
         new_cSld = new_slide._element.cSld
@@ -245,11 +297,12 @@ def _duplicate_slide(prs, source_slide):
             new_cSld.insert(0, new_bg)
     except Exception:
         pass
-    
-    for shape in source_slide.shapes:
-        el = shape.element
-        new_el = copy.deepcopy(el)
-        new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
+
+    if not keep_shapes:
+        for shape in source_slide.shapes:
+            el = shape.element
+            new_el = copy.deepcopy(el)
+            new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
     return new_slide
 
 
@@ -1180,7 +1233,25 @@ def _build_without_save(data_file, temp_config, original_config):
 
 
 def _build_cover_page(prs, config, colors, fonts, template_profile):
-    slide = _duplicate_master_slide(prs, template_profile, 'cover')
+    """Build cover page from template.
+
+    Two-pass strategy:
+    1. Pattern-based: _replace_all_placeholders() for templates with
+       {report_title}/{date}/{department} text markers in placeholders.
+    2. Idx-based fallback: for templates where placeholders are empty or
+       have template-default text (e.g. Wuling's 封面半版), fill by
+       placeholder_format.idx directly.
+
+    IMPORTANT — text color pitfall:
+    Many template covers have a decorative gradient/banner that covers only
+    the top portion of the slide. The TITLE placeholder may be positioned
+    BELOW the colored area (on white background). Using white/light text
+    on a white background makes it invisible.
+    → Always use dark text (C_PRIMARY) in the idx fallback to avoid this.
+    """
+    slide = _duplicate_master_slide(prs, template_profile, 'cover', keep_shapes=True)
+
+    # ---- Pass 1: pattern-based replacement ----
     _replace_all_placeholders(slide, {
         '{report_title}': config.title,
         '{report_type}': '数据报告',
@@ -1190,8 +1261,90 @@ def _build_cover_page(prs, config, colors, fonts, template_profile):
         '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
     }, fonts)
     _remove_empty_cover_kpi_placeholders(slide)
+
+    # ---- Pass 2: idx-based fallback ----
+    # If the template has no {report_title} etc. text markers, pass 1 is a
+    # no-op.  Detect unfilled placeholders by idx and fill them directly.
+    # Common idx mappings (from OOXML spec + Wuling/real-world templates):
+    #   idx=0  → TITLE placeholder      → report title
+    #   idx=10 → SUBTITLE placeholder   → date / subtitle (if idx=21 absent)
+    #   idx=21 → BODY quarter-size      → date / period string
+    #   idx=22 → BODY quarter-size      → department / source
+    TEMPLATE_DEFAULT_PATTERNS = {
+        '单击此处编辑母版标题样式', '单击此处添加标题',
+        '单击此处编辑母版文本样式', '单击此处添加文本',
+        '单击此处添加副标题',
+    }
+    _colors = colors or {}
+    _C_PRIMARY = _colors.get('primary', C_PRIMARY)
+    _C_TEXT_GRAY = _colors.get('text_gray', C_TEXT_GRAY)
+    _title_font = (fonts or {}).get('title_font', '微软雅黑')
+    _body_font = (fonts or {}).get('body_font', '微软雅黑')
+
+    date_text = config.period_str or (
+        config.date_range[0].strftime('%Y年%m月') if config.date_range else ''
+    )
+    dept_text = config.source_label or ''
+
+    filled_title = False
+    filled_date = False
+    for shape in slide.shapes:
+        if not shape.is_placeholder or not shape.has_text_frame:
+            continue
+        ph = shape.placeholder_format
+        tf = shape.text_frame
+        current_text = tf.text.strip()
+        is_unfilled = (
+            not current_text
+            or current_text in TEMPLATE_DEFAULT_PATTERNS
+            or any(tpl in current_text for tpl in TEMPLATE_DEFAULT_PATTERNS)
+        )
+
+        # idx=0 TITLE — report title (highest priority)
+        if ph.idx == 0 and (is_unfilled or not filled_title):
+            p = tf.paragraphs[0]
+            _set_para_text(p, config.title, _C_PRIMARY, Pt(36),
+                           bold=True, font_name=_title_font)
+            filled_title = True
+
+        # idx=10 SUBTITLE — date (only if idx=21 was not filled)
+        elif ph.idx == 10 and (is_unfilled or not filled_date):
+            p = tf.paragraphs[0]
+            _set_para_text(p, date_text, _C_PRIMARY, Pt(18),
+                           font_name=_body_font)
+            filled_date = True
+
+        # idx=21 BODY quarter-size — date/period
+        elif ph.idx == 21 and (is_unfilled or not filled_date):
+            p = tf.paragraphs[0]
+            _set_para_text(p, date_text, _C_PRIMARY, Pt(18),
+                           font_name=_body_font)
+            filled_date = True
+
+        # idx=22 BODY quarter-size — department/source
+        elif ph.idx == 22 and is_unfilled:
+            p = tf.paragraphs[0]
+            _set_para_text(p, dept_text, _C_TEXT_GRAY, Pt(12),
+                           font_name=_body_font)
+
     total = len([p for p in config.pages if p.selected]) or len(config.pages)
-    _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{total}', slide_width=prs.slide_width, colors=colors)
+    _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{total}',
+                           slide_width=prs.slide_width, colors=colors)
+
+
+def _set_para_text(para, text, color, size, bold=False, font_name=None):
+    """Set paragraph text + formatting, reusing existing run or creating new one."""
+    para.text = ''
+    if para.runs:
+        run = para.runs[0]
+    else:
+        run = para.add_run()
+    run.text = text
+    run.font.color.rgb = color
+    run.font.size = size
+    run.font.bold = bold
+    if font_name:
+        run.font.name = font_name
 
 
 def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, colors, fonts, content_top, ctx=None):
@@ -1314,7 +1467,7 @@ def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, c
 
 
 def _build_toc_page(prs, config, colors, fonts, template_profile):
-    slide = _duplicate_master_slide(prs, template_profile, 'toc')
+    slide = _duplicate_master_slide(prs, template_profile, 'toc', keep_shapes=True)
     active_pages = [p for p in config.pages if p.selected and p.page_type not in ('cover', 'toc', 'end')]
     _replace_all_placeholders(slide, {
         '{report_title}': config.title,
@@ -1844,7 +1997,7 @@ def _build_summary_page(prs, config, metrics, profile, colors, fonts, content_to
 
 
 def _build_end_page(prs, config, colors, fonts, template_profile):
-    slide = _duplicate_master_slide(prs, template_profile, "end")
+    slide = _duplicate_master_slide(prs, template_profile, "end", keep_shapes=True)
     total = len([p for p in config.pages if p.selected])
     _add_footer_if_missing(slide, f'数据来源:{config.source_label} | {total}/{total}', colors=colors)
     _replace_all_placeholders(slide, {

+ 97 - 0
generate-data-report-ppt/scripts/quality_inspector.py

@@ -397,6 +397,8 @@ class QualityInspector:
 
         if page_type in ('cover', 'end'):
             issues += self._check_text_overflow(slide, page_idx)
+            if page_type == 'cover':
+                issues += self._check_cover_quality(slide, page_idx)
             return issues
 
         issues += self._check_dynamic_page_fit(page_idx, page_type, config)
@@ -508,6 +510,92 @@ class QualityInspector:
 
         return issues
 
+    # ---- Cover page quality checks (V006, C012) ----
+    # These catch the most common python-pptx template pitfalls before the
+    # user sees the output: white text on white background, and unfilled
+    # template default text in placeholders.
+
+    _COVER_TEMPLATE_DEFAULT_PATTERNS = [
+        '单击此处编辑母版标题样式', '单击此处添加标题',
+        '单击此处编辑母版文本样式', '单击此处添加文本',
+        '单击此处添加副标题',
+    ]
+
+    # Light/pale colors that are invisible on white backgrounds
+    _LIGHT_COLOR_THRESHOLD = 0xCC  # RGB channels above this = "very light"
+
+    def _check_cover_quality(self, slide, page_idx) -> list[QualityIssue]:
+        """Check cover page for common template rendering issues.
+
+        V006: placeholder text is white/light but positioned on light
+              background (e.g. below a colored banner). Auto-fixable.
+        C012: placeholder still contains template default text like
+              "单击此处编辑母版标题样式". Not auto-fixable — needs rebuild.
+        """
+        issues = []
+        slide_h = int(slide.slide_height) if hasattr(slide, 'slide_height') else SLIDE_HEIGHT
+
+        # Detect the approximate end of the colored banner area.
+        # Heuristic: find the tallest filled rectangle that starts at y=0.
+        banner_bottom = 0
+        for shape in slide.shapes:
+            try:
+                sy = int(shape.top)
+                sh = int(shape.height)
+                if sy < Emu(50000):  # starts near top
+                    if sh < slide_h * 0.7:  # not full-slide background
+                        banner_bottom = max(banner_bottom, sy + sh)
+            except Exception:
+                pass
+
+        for shape in slide.shapes:
+            if not shape.is_placeholder or not shape.has_text_frame:
+                continue
+
+            tf = shape.text_frame
+            text = tf.text.strip()
+
+            # --- C012: template default text ---
+            if text and any(p in text for p in self._COVER_TEMPLATE_DEFAULT_PATTERNS):
+                issues.append(QualityIssue(
+                    'critical', 'content', page_idx,
+                    f'封面占位符仍为模板默认文字 "{text[:30]}",idx={shape.placeholder_format.idx}',
+                    'C012', False,
+                    {'type': 'cover_template_text', 'shape': shape}
+                ))
+                continue
+
+            # --- V006: white/light text on light background ---
+            if text and banner_bottom > 0:
+                try:
+                    sy = int(shape.top)
+                except Exception:
+                    continue
+
+                # Only flag text BELOW the banner (on white area)
+                if sy < banner_bottom:
+                    continue
+
+                # Check if text color is very light / near-white
+                for para in tf.paragraphs:
+                    for run in para.runs:
+                        if run.font.color and run.font.color.rgb:
+                            rgb = run.font.color.rgb
+                            if (int(str(rgb)[:2], 16) >= self._LIGHT_COLOR_THRESHOLD and
+                                int(str(rgb)[2:4], 16) >= self._LIGHT_COLOR_THRESHOLD and
+                                int(str(rgb)[4:6], 16) >= self._LIGHT_COLOR_THRESHOLD):
+                                issues.append(QualityIssue(
+                                    'critical', 'visual', page_idx,
+                                    f'封面文字 "{text[:20]}" 颜色过浅 (#{rgb}) '
+                                    f'位于白色背景区域(>y={banner_bottom})将不可见',
+                                    'V006', True,
+                                    {'type': 'cover_text_invisible', 'shape': shape,
+                                     'banner_bottom': banner_bottom}
+                                ))
+                                break
+
+        return issues
+
     def _check_text_overflow(self, slide, page_idx) -> list[QualityIssue]:
         issues = []
         for shape in slide.shapes:
@@ -694,6 +782,15 @@ class QualityInspector:
                             run.font.name = DEFAULT_FONT
             fd['fixed'] = True
 
+        elif fd.get('type') == 'cover_text_invisible':
+            shape = fd.get('shape')
+            if shape and shape.has_text_frame:
+                dark_color = self.theme_colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
+                for para in shape.text_frame.paragraphs:
+                    for run in para.runs:
+                        run.font.color.rgb = dark_color
+                fd['fixed'] = True
+
     def _fix_content(self, slide, issue, prs):
         fd = issue.fix_data
         if fd.get('type') == 'sparse':

+ 4 - 0
generate-data-report-ppt/scripts/quality_rules.py

@@ -32,6 +32,8 @@ QUALITY_RULES = [
     QualityRule('V003', 'visual', '字号过大(>60pt)', 'major', True, '_check_font_too_large', '_fix_font_too_large'),
     QualityRule('V004', 'visual', '颜色对比度不足', 'major', True, '_check_contrast', '_fix_contrast'),
     QualityRule('V005', 'visual', '图片拉伸变形', 'major', True, '_check_image_aspect', '_fix_image_aspect'),
+    QualityRule('V006', 'visual', '封面文字颜色与背景冲突(白色文字在浅色背景上不可见)', 'critical', True,
+                '_check_cover_text_visibility', '_fix_cover_text_visibility'),
 
     QualityRule('C001', 'content', '页面留白过多(填充率<35%)', 'critical', True, '_check_sparse_page', '_fix_sparse_page'),
     QualityRule('C002', 'content', 'KPI卡片数值为空', 'critical', True, '_check_empty_kpi', '_fix_empty_kpi'),
@@ -42,6 +44,8 @@ QUALITY_RULES = [
     QualityRule('C007', 'content', '分析段数不足', 'critical', True, '_check_insight_count', '_fix_insight_count'),
     QualityRule('C008', 'content', '页面内容为空(<50字)', 'critical', True, '_check_empty_page', '_fix_empty_page'),
     QualityRule('C009', 'content', '图表缺少分析文本', 'critical', True, '_check_chart_no_text', '_fix_chart_no_text'),
+    QualityRule('C012', 'content', '封面占位符仍为模板默认文字(未替换)', 'critical', False,
+                '_check_cover_template_text', None),
 
     QualityRule('D001', 'data', '图表数据与文本矛盾', 'critical', False, '_check_data_text_contradiction', None),
     QualityRule('D002', 'data', '页码错乱', 'major', True, '_check_page_numbers', '_fix_page_numbers'),