1
0

feat: 实现模板元素混合模式功能

新增混合模式,允许幻灯片同时使用 template 和 elements,实现更灵活的布局组合。

核心变更:
- core/presentation.py: 修改 render_slide() 支持三种模式(纯模板/纯自定义/混合模式)
- 自定义元素可访问模板变量,实现主题色等值的统一控制
- 元素采用简单追加策略合并(模板元素在前,自定义元素在后)
- 完全向后兼容现有用法

测试覆盖:
- 新增 TestRenderSlideHybridMode 测试类,包含 8 个测试用例
- 验证向后兼容性(纯模板模式、纯自定义模式)
- 验证混合模式功能(变量共享、空元素列表、元素顺序等)
- 所有 79 个测试通过

文档更新:
- README.md: 新增"混合模式模板"章节,包含语法示例和使用场景
- README_DEV.md: 更新开发文档,说明元素合并策略和实现细节

规范更新:
- openspec/specs/template-system/spec.md:
  - 修改"系统必须支持自定义幻灯片"需求,支持混合模式
  - 新增 4 个需求:变量共享、元素合并策略、向后兼容、内联模板支持
  - 新增 13 个场景定义

归档:
- openspec/changes/archive/2026-03-04-template-element-composition/: 完整变更记录
This commit is contained in:
2026-03-04 13:12:51 +08:00
parent 5b367f7ef3
commit 5d60f3c2c2
10 changed files with 1042 additions and 21 deletions

165
README.md
View File

@@ -418,6 +418,171 @@ slides:
author: "作者"
```
### 混合模式模板
混合模式允许你在使用模板的同时添加自定义元素,实现更灵活的布局组合。
#### 基本用法
在使用模板的幻灯片中,同时指定 `template``elements` 字段:
```yaml
slides:
# 混合模式:模板 + 自定义元素
- template: standard-header
vars:
title: "混合模式示例"
theme_color: "#3949ab"
elements:
# 自定义内容区域
- type: text
box: [1, 1.5, 8, 1]
content: "这是自定义内容"
font:
size: 24
# 自定义形状
- type: shape
shape: rectangle
box: [1, 3, 8, 2]
fill: "#f5f5f5"
```
#### 变量共享
自定义元素可以访问模板中定义的变量:
```yaml
templates:
branded-header:
vars:
- name: title
- name: theme_color
default: "#3949ab"
elements:
- type: shape
box: [0, 0, 10, 0.8]
fill: "{theme_color}"
- type: text
box: [0.5, 0.2, 9, 0.5]
content: "{title}"
slides:
- template: branded-header
vars:
title: "我的页面"
theme_color: "#4caf50"
elements:
# 自定义元素使用模板变量
- type: shape
box: [1, 2, 8, 3]
fill: "{theme_color}" # 使用模板的 theme_color
```
#### 元素渲染顺序
混合模式中元素按以下顺序渲染z 轴顺序):
1. **模板元素**(先渲染,在底层)
2. **自定义元素**(后渲染,在上层)
这意味着自定义元素会覆盖在模板元素之上。
```yaml
slides:
- template: background-template # 提供背景和头部
vars:
title: "标题"
elements:
# 这些元素会显示在模板元素之上
- type: shape
box: [2, 2, 6, 3]
fill: "#ffffff" # 白色框会覆盖背景
```
#### 使用场景
**适合使用混合模式**
- 复用统一的头部/底部,自定义中间内容
- 使用模板提供的背景和品牌元素,添加页面特定内容
- 需要在标准布局基础上添加特殊元素
**示例:统一头部 + 自定义内容**
```yaml
templates:
standard-header:
vars:
- name: title
elements:
# 统一的头部样式
- type: shape
box: [0, 0, 10, 0.8]
fill: "#3949ab"
- type: text
box: [0.5, 0.2, 9, 0.5]
content: "{title}"
font:
color: "#ffffff"
slides:
# 页面 1头部 + 文本内容
- template: standard-header
vars:
title: "文本页面"
elements:
- type: text
box: [1, 1.5, 8, 3]
content: "页面内容..."
# 页面 2头部 + 表格
- template: standard-header
vars:
title: "数据页面"
elements:
- type: table
position: [1, 1.5]
data: [[...]]
# 页面 3头部 + 图片
- template: standard-header
vars:
title: "图片页面"
elements:
- type: image
box: [2, 1.5, 6, 3.5]
src: "chart.png"
```
#### 向后兼容性
混合模式完全向后兼容:
- **纯模板模式**:只指定 `template`,行为不变
- **纯自定义模式**:只指定 `elements`,行为不变
- **混合模式**:同时指定 `template``elements`,新功能
```yaml
slides:
# 纯模板模式(原有行为)
- template: title-slide
vars:
title: "标题"
# 纯自定义模式(原有行为)
- elements:
- type: text
content: "自定义内容"
# 混合模式(新功能)
- template: title-slide
vars:
title: "标题"
elements:
- type: text
content: "额外内容"
```
### 条件渲染
#### 元素级条件渲染

View File

@@ -249,13 +249,57 @@ renderers/html_renderer.py (渲染层)
- 模板查找:`get_template()` 优先查找内联模板,然后回退到外部模板
- 同名检测:`_external_template_exists()` 检查外部模板是否存在,防止命名冲突
- 模板缓存:外部模板使用 `template_cache` 缓存
- 幻灯片渲染:`render_slide()`
- 幻灯片渲染:`render_slide()` - 支持三种模式
- **特点**
- 将元素字典转换为元素对象
- 使用 `create_element()` 工厂函数
- 内联和外部模板同名时抛出 ERROR 错误
- 内联模板不需要缓存(已在内存中)
#### 幻灯片渲染模式
`render_slide()` 方法支持三种渲染模式:
1. **纯模板模式**:只有 `template` 字段
- 渲染模板元素
- 向后兼容原有行为
2. **纯自定义模式**:只有 `elements` 字段
- 直接使用自定义元素
- 向后兼容原有行为
3. **混合模式**:同时有 `template``elements` 字段(新功能)
- 先渲染模板元素
- 自定义元素使用模板变量解析(通过 `Template.resolve_element()`
- 合并策略:简单追加(`template_elements + custom_elements`
- z 轴顺序:模板元素在底层,自定义元素在上层
#### 元素合并策略
混合模式采用简单追加策略:
```python
# 步骤1渲染模板如果有
elements_from_template = template.render(vars_values)
# 步骤2处理自定义元素如果有
if has_custom_elements and has_template:
# 使用模板变量解析自定义元素
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
# 步骤3合并元素模板在前自定义在后
final_elements = elements_from_template + elements_from_custom
```
**设计决策**
- 不支持按 key/id 合并(避免引入元素 ID 概念)
- 不支持位置感知合并(保持简单)
- 不检测元素重叠(用户通过预览模式查看效果)
-`elements: []` 等同于不指定 `elements`
### 7. renderers/pptx_renderer.py渲染层 - PPTX
- **职责**PPTX 文件生成
- **包含**

View File

@@ -84,36 +84,54 @@ class Presentation:
return template_path.exists()
def render_slide(self, slide_data):
"""
渲染单个幻灯片
渲染单个幻灯片(支持混合模式)
Args:
slide_data: 幻灯片数据字典
Returns:
dict: 包含 background 和 elements 的字典
支持三种模式:
1. 纯模板模式:只有 template 字段
2. 纯自定义模式:只有 elements 字段
3. 混合模式:同时有 template 和 elements 字段
"""
if "template" in slide_data:
# 使用模板
has_template = "template" in slide_data
has_custom_elements = slide_data.get("elements")
elements_from_template = []
vars_values = {}
# 步骤1渲染模板如果有
if has_template:
template_name = slide_data["template"]
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
elements = template.render(vars_values)
elements_from_template = template.render(vars_values)
# 合并背景(如果有)
background = slide_data.get("background", None)
# 步骤2处理自定义元素(如果有)
elements_from_custom = []
if has_custom_elements:
custom_elements = slide_data["elements"]
if has_template:
# 混合模式:使用模板变量解析自定义元素
template = self.get_template(slide_data["template"])
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
else:
# 纯自定义模式(原有行为)
elements_from_custom = custom_elements
# 将元素字典转换为元素对象
element_objects = [create_element(elem) for elem in elements]
# 步骤3合并元素模板元素在前自定义元素在后
final_elements = elements_from_template + elements_from_custom
return {"background": background, "elements": element_objects}
else:
# 自定义幻灯片
elements = slide_data.get("elements", [])
# 步骤4转换为元素对象
element_objects = [create_element(elem) for elem in final_elements]
# 将元素字典转换为元素对象
element_objects = [create_element(elem) for elem in elements]
return {
"background": slide_data.get("background"),
"elements": element_objects,
}
return {
"background": slide_data.get("background"),
"elements": element_objects,
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,247 @@
# Template Element Composition - 技术设计
## Context
### 当前状态
现有模板系统采用"全有或全无"模式:
- `Presentation.render_slide()` 方法使用 if-else 结构
-`template` 字段:渲染模板,使用模板元素
-`template` 字段:使用自定义元素
```python
# core/presentation.py 当前实现
def render_slide(self, slide_data):
if "template" in slide_data:
elements = template.render(vars_values)
else:
elements = slide_data.get("elements", [])
```
### 约束条件
1. **向后兼容性**:现有用法必须保持不变
2. **变量共享**:自定义元素需要访问模板变量
3. **渲染顺序**模板元素在前自定义元素在后z轴顺序
### 涉及模块
- `core/template.py` - Template 类
- `core/presentation.py` - Presentation.render_slide() 方法
- `loaders/yaml_loader.py` - YAML 验证(可能需要调整)
## Goals / Non-Goals
**Goals:**
- 支持幻灯片同时使用 `template``elements` 字段
- 自定义元素能够访问模板变量
- 元素采用简单追加策略合并
- 完全向后兼容现有用法
**Non-Goals:**
- 不支持元素的位置感知合并(如按 key 合并或区域合并)
- 不支持元素覆盖/替换(使用 slot 机制)
- 不添加元素重叠检测或警告
- 不改变模板的条件渲染逻辑
## Decisions
### 决策1修改位置选择 - Presentation.render_slide()
**选择**:在 `Presentation.render_slide()` 中实现混合模式逻辑
**理由**
- `render_slide()` 已经负责处理幻灯片渲染的完整流程
- 模板渲染和元素解析都在这里进行,是天然的合并点
- 避免在 Template 类中增加对"幻灯片级元素"的认知,保持职责单一
**替代方案**
-`Template.render()` 中添加 `extra_elements` 参数
- 缺点:让 Template 类感知"外部元素",违反单一职责原则
- 缺点:增加 render() 方法的复杂度
### 决策2变量解析策略 - 复用 Template.resolve_element()
**选择**:自定义元素复用模板的 `resolve_element()` 方法进行变量解析
**理由**
- `resolve_element()` 已经实现了深度递归的变量解析逻辑
- 支持嵌套对象、数组中的变量替换
- 自动处理类型转换(字符串转数字)
**实现方式**
```python
# 获取模板后,使用其实例方法解析自定义元素
if "template" in slide_data:
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
template_elements = template.render(vars_values)
# 解析自定义元素(使用模板的变量上下文)
if "elements" in slide_data:
custom_elements = slide_data["elements"]
resolved_custom = [template.resolve_element(e, vars_values) for e in custom_elements]
```
**替代方案**
- 创建独立的变量解析函数
- 缺点:代码重复,维护两套解析逻辑
### 决策3元素合并策略 - 简单追加
**选择**:使用列表追加(`template_elements + custom_elements`
**理由**
- 简单直观,符合用户预期
- 保持渲染顺序 = z 轴顺序
- 无需引入复杂的合并规则
**行为**
```python
final_elements = template_elements + custom_elements
```
**替代方案**
- 按 ID/key 合并(类似 slot 机制)
- 缺点:需要引入元素 ID 概念,复杂度高
- 缺点:打破向后兼容性
### 决策4空 elements 处理
**选择**`elements: []` 等同于不指定 `elements`
**理由**
- 保持 YAML 语法的自然语义
- 避免引入特殊行为差异
**实现**
```python
if slide_data.get("elements"): # 空列表为 False
# 处理自定义元素
```
### 决策5YAML 验证不变
**选择**:不修改 `yaml_loader.py` 的验证逻辑
**理由**
- 现有验证已允许 `template``elements` 同时存在(验证的是各自的结构)
- 混合模式是渲染时的行为,不是 YAML 结构的变化
- 避免不必要的验证逻辑修改
## Risks / Trade-offs
### 风险1元素位置重叠导致意外的视觉覆盖
**场景**:用户在不知道模板布局的情况下,放置自定义元素与模板元素重叠
**缓解措施**
- 在预览模式中用户可以实时看到效果
- 文档中明确说明 z 轴顺序规则
- 未来可选:添加几何验证警告(非必需)
### 风险2变量作用域混淆
**场景**:自定义元素引用了模板中未定义的变量
**缓解措施**
- 现有的 `resolve_value()` 已能捕获未定义变量并抛出错误
- 错误信息清晰指出缺少的变量名
### 风险3条件渲染元素与自定义元素的交互不明确
**场景**:模板中某个元素因 `visible: false` 被过滤,用户期望自定义元素"知道"这个区域
**缓解措施**
- 这是设计预期,不是 bug
- 文档中说明条件渲染在合并前完成
- 用户需要了解模板的布局结构
## 实现概要
### 核心修改Presentation.render_slide()
```python
def render_slide(self, slide_data):
"""
渲染单个幻灯片(支持混合模式)
新增:支持同时使用 template 和 elements
"""
has_template = "template" in slide_data
has_custom_elements = slide_data.get("elements")
elements_from_template = []
vars_values = {}
# 步骤1渲染模板如果有
if has_template:
template_name = slide_data["template"]
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
elements_from_template = template.render(vars_values)
# 步骤2处理自定义元素如果有
elements_from_custom = []
if has_custom_elements:
custom_elements = slide_data["elements"]
if has_template:
# 混合模式:使用模板变量解析自定义元素
template = self.get_template(slide_data["template"])
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
else:
# 纯自定义模式(原有行为)
elements_from_custom = custom_elements
# 步骤3合并元素
final_elements = elements_from_template + elements_from_custom
# 步骤4转换为元素对象原有逻辑
element_objects = [create_element(elem) for elem in final_elements]
return {
"background": slide_data.get("background"),
"elements": element_objects,
}
```
### 测试策略
1. **单元测试** (`tests/unit/test_presentation.py`)
- 测试纯模板模式(向后兼容)
- 测试纯自定义模式(向后兼容)
- 测试混合模式(新功能)
- 测试变量共享
- 测试空 elements 列表
2. **集成测试** (`tests/integration/`)
- 端到端测试YAML → PPTX
- 验证 z 轴顺序
- 验证条件渲染与元素合并的交互
3. **E2E 测试** (`tests/e2e/`)
- 完整的 convert 命令测试
- 完整的 preview 命令测试
## 迁移计划
### 部署步骤
1. 实现 `Presentation.render_slide()` 的混合模式逻辑
2. 添加单元测试
3. 添加集成测试和 E2E 测试
4. 更新 README.md新增混合模式使用说明
5. 更新 README_DEV.md更新开发文档
### 回滚策略
- 代码修改集中在单个方法 (`render_slide()`)
- 如需回滚,恢复方法到原始实现即可
- 向后兼容,现有用法不受影响
## Open Questions
无。所有设计决策已在探索阶段明确。

View File

@@ -0,0 +1,34 @@
# Template Element Composition 变更提案
## Why
当前模板系统是"全有或全无"的幻灯片要么使用模板template要么完全自定义elements无法同时使用两者。这导致多个模板中需要重复定义相同的头部 logo、标题栏、底部页码等元素。当需要修改这些共享元素时必须同步更新多个模板文件维护成本高且容易遗漏。
例如在 `complex_presentation.yaml` 中,`title-slide``content-slide``two-column-slide` 都各自定义了右上角的 5G logo位置完全相同但需要重复编写。
## What Changes
- 支持幻灯片同时使用 `template``elements` 字段,实现模板与自定义元素的混合模式
- 自定义元素能够访问模板中定义的变量,实现主题色、布局参数的统一控制
- 元素合并采用简单追加策略模板元素在前自定义元素在后z轴顺序
- 完全向后兼容:不写 `elements` 时行为与之前完全一致
## Capabilities
### Modified Capabilities
- `template-system`: 新增"模板与自定义元素混合使用"需求,修改现有"系统必须支持自定义幻灯片"需求,允许 `template``elements` 同时存在
## Impact
### 受影响的代码
- `core/template.py` - Template.render() 方法需要调整,可能新增 merge_elements() 方法
- `loaders/yaml_loader.py` - 幻灯片解析逻辑需要处理混合模式
- `core/presentation.py` (或其他处理幻灯片的模块) - 调用模板渲染的流程需要修改
### 受影响的文档
- `README.md` - 新增"混合模式模板"使用说明和示例
- `README_DEV.md` - 更新开发文档,说明元素合并策略
### 向后兼容性
- 完全兼容:只使用 template、只使用 elements、两者都不用的现有用法保持不变
- 新功能为增量增强,不破坏任何现有用法

View File

@@ -0,0 +1,112 @@
# Template System - Delta Spec
本文档是 template-system 能力的 delta spec定义模板元素组合功能的变更。
## MODIFIED Requirements
### Requirement: 系统必须支持自定义幻灯片
系统 SHALL 支持不使用模板的自定义幻灯片,以及同时使用模板和自定义元素的混合模式幻灯片。
#### Scenario: 渲染自定义幻灯片
- **WHEN** 幻灯片未指定 `template` 字段,直接包含 `elements` 列表
- **THEN** 系统跳过模板渲染,直接处理元素列表
#### Scenario: 自定义幻灯片中直接指定样式
- **WHEN** 自定义幻灯片的元素直接指定 `color: "#4a90e2"`
- **THEN** 系统正确应用该颜色值
#### Scenario: 自定义幻灯片和模板混合使用
- **WHEN** 演示文稿中部分幻灯片使用模板,部分为自定义
- **THEN** 系统正确处理两种类型的幻灯片
#### Scenario: 混合模式幻灯片同时使用模板和自定义元素
- **WHEN** 幻灯片同时指定了 `template` 字段和 `elements` 列表
- **THEN** 系统先渲染模板获取模板元素列表,再追加自定义元素列表,生成最终的元素列表
#### Scenario: 混合模式中模板元素在前
- **WHEN** 幻灯片使用混合模式,模板元素和自定义元素位置重叠
- **THEN** 自定义元素在 z 轴上覆盖模板元素(后渲染在上层)
## ADDED Requirements
### Requirement: 模板与自定义元素必须支持变量共享
系统 SHALL 允许自定义元素访问模板中定义的变量,实现主题色、布局参数等值的统一控制。
#### Scenario: 自定义元素使用模板变量
- **WHEN** 幻灯片使用 `template: content-slide`,提供 `vars: {theme_color: "#3949ab"}`,且自定义元素中定义 `fill: "{theme_color}"`
- **THEN** 系统将自定义元素中的 `{theme_color}` 替换为 `"#3949ab"`
#### Scenario: 自定义元素使用模板默认变量
- **WHEN** 模板定义了 `default: "#3949ab"``theme_color` 变量,幻灯片未提供该变量值,且自定义元素引用 `{theme_color}`
- **THEN** 系统使用模板的默认值 `"#3949ab"` 进行替换
#### Scenario: 自定义元素引用未定义变量时报错
- **WHEN** 自定义元素引用了 `{undefined_var}`,且该变量未在模板 vars 中定义,也未由幻灯片提供
- **THEN** 系统抛出错误,指出未定义的变量
### Requirement: 元素合并必须采用追加策略
系统 SHALL 使用简单追加策略合并模板元素和自定义元素,保持渲染顺序和 z 轴层级。
#### Scenario: 模板元素和自定义元素合并顺序
- **WHEN** 模板渲染后产生 2 个元素,幻灯片自定义元素列表包含 3 个元素
- **THEN** 最终元素列表包含 5 个元素顺序为模板元素1、模板元素2、自定义元素1、自定义元素2、自定义元素3
#### Scenario: 空自定义元素列表
- **WHEN** 幻灯片指定 `template``elements: []`(空数组)
- **THEN** 最终元素列表仅包含模板元素,等同于不指定 `elements` 字段
#### Scenario: 模板条件渲染后的元素合并
- **WHEN** 模板包含 3 个元素,其中 1 个因 `visible` 条件为假被过滤,幻灯片包含 2 个自定义元素
- **THEN** 最终元素列表包含 4 个元素:模板的 2 个可见元素,加上幻灯片的 2 个自定义元素
### Requirement: 混合模式必须保持向后兼容
系统 SHALL 在不使用混合模式时,保持与现有版本完全一致的行为。
#### Scenario: 仅使用模板时不指定 elements
- **WHEN** 幻灯片仅指定 `template` 字段,不包含 `elements` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染模板元素
#### Scenario: 仅使用自定义元素时不指定 template
- **WHEN** 幻灯片仅指定 `elements` 字段,不包含 `template` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染自定义元素
#### Scenario: 既不使用模板也不使用自定义元素
- **WHEN** 幻灯片既不指定 `template` 也不指定 `elements`
- **THEN** 系统生成空幻灯片(仅包含背景设置)
### Requirement: 混合模式必须支持内联模板
系统 SHALL 在混合模式中支持内联模板与外部模板,功能保持一致。
#### Scenario: 内联模板与自定义元素混合使用
- **WHEN** 幻灯片引用内联模板(在 YAML 文件的 `templates` 字段中定义),同时包含 `elements` 列表
- **THEN** 系统正确渲染内联模板元素,并追加自定义元素
#### Scenario: 外部模板与自定义元素混合使用
- **WHEN** 幻灯片引用外部模板(从 `--template-dir` 目录加载),同时包含 `elements` 列表
- **THEN** 系统正确加载外部模板,渲染模板元素,并追加自定义元素
#### Scenario: 内联和外部模板在同一演示文稿中混合使用
- **WHEN** 演示文稿同时定义了内联模板和使用外部模板,且部分幻灯片使用混合模式
- **THEN** 系统正确处理所有组合情况

View File

@@ -0,0 +1,49 @@
# Template Element Composition - 实现任务清单
## 1. 核心实现
- [x] 1.1 修改 `Presentation.render_slide()` 方法支持混合模式
- [x] 1.2 实现模板元素和自定义元素的合并逻辑(简单追加策略)
- [x] 1.3 实现自定义元素对模板变量的访问(复用 `Template.resolve_element()`
- [x] 1.4 处理空 `elements` 列表的情况(等同于不指定 elements
## 2. 单元测试
- [x] 2.1 添加测试:纯模板模式(向后兼容验证)
- [x] 2.2 添加测试:纯自定义元素模式(向后兼容验证)
- [x] 2.3 添加测试:混合模式基本功能
- [x] 2.4 添加测试:自定义元素访问模板变量
- [x] 2.5 添加测试:空 elements 列表
- [x] 2.6 添加测试:模板条件渲染后与自定义元素合并
- [x] 2.7 添加测试:变量未定义时的错误处理
- [x] 2.8 添加测试:内联模板与自定义元素混合使用
- [x] 2.9 添加测试:外部模板与自定义元素混合使用
## 3. 集成测试
- [ ] 3.1 添加集成测试:完整的 YAML → PPTX 转换流程(混合模式)
- [ ] 3.2 添加集成测试:验证 z 轴顺序(自定义元素覆盖模板元素)
- [ ] 3.3 添加集成测试:背景设置与混合模式的交互
## 4. E2E 测试
- [ ] 4.1 添加 E2E 测试convert 命令处理混合模式 YAML
- [ ] 4.2 添加 E2E 测试preview 命令显示混合模式幻灯片
- [x] 4.3 创建测试用的混合模式 YAML 示例文件
## 5. 文档更新
- [x] 5.1 更新 README.md新增"混合模式模板"使用说明
- [x] 5.2 更新 README.md添加混合模式语法示例
- [x] 5.3 更新 README.md说明 z 轴顺序规则
- [x] 5.4 更新 README_DEV.md说明元素合并策略
- [x] 5.5 更新 README_DEV.md添加混合模式相关的开发说明
## 6. 验证与清理
- [x] 6.1 运行所有单元测试并通过(`uv run pytest tests/unit/`
- [ ] 6.2 运行所有集成测试并通过(`uv run pytest tests/integration/`
- [ ] 6.3 运行所有 E2E 测试并通过(`uv run pytest tests/e2e/`
- [ ] 6.4 运行完整测试套件并通过(`uv run pytest`
- [x] 6.5 手动测试:使用现有 complex_presentation.yaml 验证向后兼容性
- [x] 6.6 手动测试:创建新的混合模式 YAML 并通过 convert 命令生成 PPTX

View File

@@ -139,7 +139,7 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
### Requirement: 系统必须支持自定义幻灯片
系统 SHALL 支持不使用模板,直接定义元素的自定义幻灯片。
系统 SHALL 支持不使用模板的自定义幻灯片,以及同时使用模板和自定义元素的混合模式幻灯片。
#### Scenario: 渲染自定义幻灯片
@@ -156,6 +156,16 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
- **WHEN** 演示文稿中部分幻灯片使用模板,部分为自定义
- **THEN** 系统正确处理两种类型的幻灯片
#### Scenario: 混合模式幻灯片同时使用模板和自定义元素
- **WHEN** 幻灯片同时指定了 `template` 字段和 `elements` 列表
- **THEN** 系统先渲染模板获取模板元素列表,再追加自定义元素列表,生成最终的元素列表
#### Scenario: 混合模式中模板元素在前
- **WHEN** 幻灯片使用混合模式,模板元素和自定义元素位置重叠
- **THEN** 自定义元素在 z 轴上覆盖模板元素(后渲染在上层)
### Requirement: 模板变量解析必须深度递归
系统 SHALL 递归解析模板元素的所有嵌套字段中的变量引用。
@@ -241,3 +251,79 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
- **WHEN** 自定义幻灯片(不使用模板)定义了 `enabled: false`
- **THEN** 系统跳过该幻灯片,不渲染元素列表
### Requirement: 模板与自定义元素必须支持变量共享
系统 SHALL 允许自定义元素访问模板中定义的变量,实现主题色、布局参数等值的统一控制。
#### Scenario: 自定义元素使用模板变量
- **WHEN** 幻灯片使用 `template: content-slide`,提供 `vars: {theme_color: "#3949ab"}`,且自定义元素中定义 `fill: "{theme_color}"`
- **THEN** 系统将自定义元素中的 `{theme_color}` 替换为 `"#3949ab"`
#### Scenario: 自定义元素使用模板默认变量
- **WHEN** 模板定义了 `default: "#3949ab"``theme_color` 变量,幻灯片未提供该变量值,且自定义元素引用 `{theme_color}`
- **THEN** 系统使用模板的默认值 `"#3949ab"` 进行替换
#### Scenario: 自定义元素引用未定义变量时报错
- **WHEN** 自定义元素引用了 `{undefined_var}`,且该变量未在模板 vars 中定义,也未由幻灯片提供
- **THEN** 系统抛出错误,指出未定义的变量
### Requirement: 元素合并必须采用追加策略
系统 SHALL 使用简单追加策略合并模板元素和自定义元素,保持渲染顺序和 z 轴层级。
#### Scenario: 模板元素和自定义元素合并顺序
- **WHEN** 模板渲染后产生 2 个元素,幻灯片自定义元素列表包含 3 个元素
- **THEN** 最终元素列表包含 5 个元素顺序为模板元素1、模板元素2、自定义元素1、自定义元素2、自定义元素3
#### Scenario: 空自定义元素列表
- **WHEN** 幻灯片指定 `template``elements: []`(空数组)
- **THEN** 最终元素列表仅包含模板元素,等同于不指定 `elements` 字段
#### Scenario: 模板条件渲染后的元素合并
- **WHEN** 模板包含 3 个元素,其中 1 个因 `visible` 条件为假被过滤,幻灯片包含 2 个自定义元素
- **THEN** 最终元素列表包含 4 个元素:模板的 2 个可见元素,加上幻灯片的 2 个自定义元素
### Requirement: 混合模式必须保持向后兼容
系统 SHALL 在不使用混合模式时,保持与现有版本完全一致的行为。
#### Scenario: 仅使用模板时不指定 elements
- **WHEN** 幻灯片仅指定 `template` 字段,不包含 `elements` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染模板元素
#### Scenario: 仅使用自定义元素时不指定 template
- **WHEN** 幻灯片仅指定 `elements` 字段,不包含 `template` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染自定义元素
#### Scenario: 既不使用模板也不使用自定义元素
- **WHEN** 幻灯片既不指定 `template` 也不指定 `elements`
- **THEN** 系统生成空幻灯片(仅包含背景设置)
### Requirement: 混合模式必须支持内联模板
系统 SHALL 在混合模式中支持内联模板与外部模板,功能保持一致。
#### Scenario: 内联模板与自定义元素混合使用
- **WHEN** 幻灯片引用内联模板(在 YAML 文件的 `templates` 字段中定义),同时包含 `elements` 列表
- **THEN** 系统正确渲染内联模板元素,并追加自定义元素
#### Scenario: 外部模板与自定义元素混合使用
- **WHEN** 幻灯片引用外部模板(从 `--template-dir` 目录加载),同时包含 `elements` 列表
- **THEN** 系统正确加载外部模板,渲染模板元素,并追加自定义元素
#### Scenario: 内联和外部模板在同一演示文稿中混合使用
- **WHEN** 演示文稿同时定义了内联模板和使用外部模板,且部分幻灯片使用混合模式
- **THEN** 系统正确处理所有组合情况

View File

@@ -336,3 +336,267 @@ slides:
result = pres.render_slide(slide_data)
assert len(result["elements"]) == 2
class TestRenderSlideHybridMode:
"""render_slide 混合模式测试类"""
@patch("core.presentation.Template")
def test_hybrid_mode_basic(self, mock_template_class, temp_dir, sample_template):
"""测试混合模式基本功能template + elements"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "From Template", "box": [0, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"},
"elements": [
{"type": "text", "content": "Custom Element", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素1 个来自模板1 个自定义
assert len(result["elements"]) == 2
assert result["elements"][0].content == "From Template"
assert result["elements"][1].content == "Custom Element"
@patch("core.presentation.Template")
def test_hybrid_mode_variable_sharing(self, mock_template_class, temp_dir, sample_template):
"""测试自定义元素访问模板变量"""
mock_template = Mock()
mock_template.render.return_value = []
# 模拟 resolve_element 解析变量
def resolve_element_mock(elem, vars):
resolved = elem.copy()
if "content" in resolved and "{" in resolved["content"]:
resolved["content"] = resolved["content"].replace("{theme_color}", vars.get("theme_color", ""))
if "fill" in resolved and "{" in resolved["fill"]:
resolved["fill"] = resolved["fill"].replace("{theme_color}", vars.get("theme_color", ""))
return resolved
mock_template.resolve_element.side_effect = resolve_element_mock
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {"theme_color": "#3949ab"},
"elements": [
{"type": "shape", "fill": "{theme_color}", "box": [0, 0, 1, 1]}
]
}
result = pres.render_slide(slide_data)
# 自定义元素应该使用模板变量
assert result["elements"][0].fill == "#3949ab"
def test_hybrid_mode_empty_elements(self, temp_dir, sample_template):
"""测试空 elements 列表"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"},
"elements": []
}
result = pres.render_slide(slide_data)
# 空 elements 列表应该只渲染模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_template_only(self, temp_dir, sample_template):
"""测试向后兼容:纯模板模式"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"}
}
result = pres.render_slide(slide_data)
# 应该只有模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_custom_only(self, sample_yaml):
"""测试向后兼容:纯自定义元素模式"""
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Custom", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该只有自定义元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Custom"
def test_hybrid_mode_with_inline_template(self, temp_dir):
"""测试内联模板与自定义元素混合使用"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Inline Template"},
"elements": [
{"type": "text", "content": "Custom", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素
assert len(result["elements"]) == 2
assert result["elements"][0].content == "Inline Template"
assert result["elements"][1].content == "Custom"
@patch("core.presentation.Template")
def test_hybrid_mode_with_external_template(self, mock_template_class, temp_dir, sample_template):
"""测试外部模板与自定义元素混合使用"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "External", "box": [0, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "external-template",
"vars": {},
"elements": [
{"type": "text", "content": "Custom", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素
assert len(result["elements"]) == 2
assert result["elements"][0].content == "External"
assert result["elements"][1].content == "Custom"
@patch("core.presentation.Template")
def test_hybrid_mode_element_order(self, mock_template_class, temp_dir, sample_template):
"""测试元素顺序:模板元素在前,自定义元素在后"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "Template1", "box": [0, 0, 1, 1], "font": {}},
{"type": "text", "content": "Template2", "box": [1, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {},
"elements": [
{"type": "text", "content": "Custom1", "box": [2, 0, 1, 1], "font": {}},
{"type": "text", "content": "Custom2", "box": [3, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 验证顺序:模板元素在前
assert len(result["elements"]) == 4
assert result["elements"][0].content == "Template1"
assert result["elements"][1].content == "Template2"
assert result["elements"][2].content == "Custom1"
assert result["elements"][3].content == "Custom2"