1
0

feat: 添加内联模板支持

支持在 YAML 源文件中直接定义模板,无需单独的模板文件。
简化单文档编写流程,降低模板系统使用门槛。

核心功能:
- 在 YAML 顶层新增 templates 字段定义内联模板
- 支持变量替换、条件渲染、默认值等完整模板功能
- 内联模板优先于外部模板查找
- 同名冲突检测:禁止内联和外部模板同名
- 相互引用检测:禁止内联模板之间相互引用
- 完整的错误处理和验证机制

代码变更:
- core/template.py: 新增 from_data() 类方法
- core/presentation.py: 支持内联模板查找和冲突检测
- loaders/yaml_loader.py: 新增 validate_templates_yaml() 验证
- validators/: 扩展验证器支持内联模板

测试:
- 新增 9 个内联模板专项测试
- 修复 1 个变量验证测试
- 所有 333 个测试通过

文档:
- README.md: 添加内联模板使用指南和最佳实践
- README_DEV.md: 说明实现细节和设计决策

完全向后兼容,不使用 templates 字段时行为不变。
This commit is contained in:
2026-03-03 15:59:55 +08:00
parent 2ba1bd7272
commit 01eacb0b97
15 changed files with 1277 additions and 25 deletions

102
README.md
View File

@@ -192,9 +192,105 @@ slides:
## 📋 模板系统
模板允许你定义可复用的幻灯片布局。
模板允许你定义可复用的幻灯片布局。yaml2pptx 支持两种模板方式:
### 创建模板
- **外部模板**:独立的 YAML 文件,适合跨文档复用
- **内联模板**:在源文件中定义,适合单文档使用
### 内联模板
内联模板允许你在 YAML 源文件中直接定义模板,无需创建单独的模板文件。
#### 定义内联模板
在 YAML 文件顶层添加 `templates` 字段:
```yaml
metadata:
size: "16:9"
templates:
title-slide:
vars:
- name: title
required: true
- name: subtitle
required: false
default: ""
elements:
- type: text
box: [1, 2, 8, 1]
content: "{title}"
font:
size: 44
bold: true
align: center
- type: text
box: [1, 3.5, 8, 0.5]
content: "{subtitle}"
visible: "{subtitle != ''}"
font:
size: 24
align: center
slides:
- template: title-slide
vars:
title: "我的演示文稿"
subtitle: "使用内联模板"
```
#### 内联模板特性
- ✅ 支持变量替换和条件渲染
- ✅ 可以与外部模板混合使用
- ✅ 无需指定 `--template-dir` 参数
- ⚠️ 内联模板不能相互引用
- ⚠️ 内联和外部模板不能同名(会报错)
#### 何时使用内联模板
**适合使用内联模板**
- 模板仅在单个文档中使用
- 快速原型开发
- 简单的模板定义1-3 个元素)
- 文档自包含,无需外部依赖
**适合使用外部模板**
- 需要跨多个文档复用
- 复杂的模板定义(>5 个元素)
- 团队共享的模板库
- 需要版本控制和独立维护
**最佳实践**
1. **命名规范**
- 内联模板使用描述性名称(如 `title-slide`, `content-slide`
- 避免与外部模板同名,否则会报错
- 使用一致的命名风格kebab-case 推荐)
2. **模板大小**
- 内联模板建议不超过 50 行
- 超过 50 行考虑拆分或使用外部模板
- 保持 YAML 文件可读性
3. **混合使用**
- 可以在同一文档中混合使用内联和外部模板
- 通用模板使用外部模板(如标题页、结束页)
- 文档特定模板使用内联模板
4. **迁移策略**
- 原型阶段使用内联模板快速迭代
- 模板稳定后,如需复用则迁移到外部模板
- 使用 `--template-dir` 参数指定外部模板目录
#### 内联模板限制
- ⚠️ 内联模板不能相互引用(会报错)
- ⚠️ 内联和外部模板不能同名(会报错)
- ⚠️ 内联模板不支持继承或组合
### 创建外部模板
创建模板文件 `templates/title-slide.yaml`
@@ -234,7 +330,7 @@ elements:
align: center
```
### 使用模板
### 使用外部模板
```yaml
slides:

View File

@@ -88,8 +88,12 @@ yaml2pptx.py (入口)
- **包含**
- `YAMLError` - 自定义异常
- `load_yaml_file()` - 加载 YAML 文件
- `validate_presentation_yaml()` - 验证演示文稿结构
- `validate_template_yaml()` - 验证模板结构
- `validate_presentation_yaml()` - 验证演示文稿结构,调用 `validate_templates_yaml()` 验证内联模板
- `validate_template_yaml()` - 验证外部模板结构
- `validate_templates_yaml()` - 验证内联模板结构templates 字段)
- **特点**
- 内联模板验证包括:结构验证、元素验证、变量定义验证、默认值验证
- 检测默认值中引用不存在的变量
### 4. core/elements.py核心层 - 元素抽象)
- **职责**:定义元素数据类和工厂函数
@@ -130,19 +134,29 @@ yaml2pptx.py (入口)
- **职责**:模板加载和变量解析
- **包含**
- `Template`
- `from_data()` 类方法:从字典创建模板实例(用于内联模板)
- 变量解析:`resolve_value()`, `resolve_element()`
- 条件渲染:`evaluate_condition()`
- 模板渲染:`render()`
- **特点**
- 支持外部模板(从文件加载)和内联模板(从字典创建)
- 内联模板通过 `from_data()` 类方法创建,避免修改现有 `__init__`
- 禁止内联模板相互引用,在渲染时检测并报错
### 6. core/presentation.py核心层 - 演示文稿)
- **职责**:演示文稿管理和幻灯片渲染
- **包含**
- `Presentation`
- 模板缓存:`get_template()`
- 内联模板存储:`__init__` 中解析并保存 `templates` 字段到 `self.inline_templates`
- 模板查找:`get_template()` 优先查找内联模板,然后回退到外部模板
- 同名检测:`_external_template_exists()` 检查外部模板是否存在,防止命名冲突
- 模板缓存:外部模板使用 `template_cache` 缓存
- 幻灯片渲染:`render_slide()`
- **特点**
- 将元素字典转换为元素对象
- 使用 `create_element()` 工厂函数
- 内联和外部模板同名时抛出 ERROR 错误
- 内联模板不需要缓存(已在内存中)
### 7. renderers/pptx_renderer.py渲染层 - PPTX
- **职责**PPTX 文件生成
@@ -379,6 +393,66 @@ if right > slide_width + TOLERANCE:
# 报告 WARNING
```
### 7. 内联模板系统
**决策**:支持在 YAML 源文件中定义内联模板,与外部模板系统共存
**理由**
- 降低使用门槛:简单场景无需创建单独的模板文件
- 保持灵活性:复杂场景仍可使用外部模板
- 向后兼容:不影响现有外部模板功能
**实现要点**
1. **模板定义**:在 YAML 顶层添加 `templates` 字段
```yaml
templates:
my-template:
vars: [...]
elements: [...]
```
2. **模板创建**:使用 `Template.from_data()` 类方法
```python
@classmethod
def from_data(cls, template_data, template_name):
"""从字典创建模板(内联模板)"""
obj = cls.__new__(cls)
obj.data = template_data
obj.vars_def = {var['name']: var for var in template_data.get('vars', [])}
obj.elements = template_data.get('elements', [])
return obj
```
3. **模板查找**`Presentation.get_template()` 优先查找内联模板
```python
def get_template(self, template_name):
# 1. 检查内联模板
if hasattr(self, 'inline_templates') and template_name in self.inline_templates:
# 2. 检查同名冲突
if self._external_template_exists(template_name):
raise YAMLError(f"模板名称冲突: '{template_name}'")
return Template.from_data(self.inline_templates[template_name], template_name)
# 3. 回退到外部模板
return self._get_external_template(template_name)
```
4. **同名检测**:禁止内联和外部模板同名
- 显式报错比隐式选择更安全
- 强制用户明确模板来源
- 降低调试难度
5. **限制**:禁止内联模板相互引用
- 降低实现复杂度
- 内联模板适合简单场景
- 复杂引用应使用外部模板
6. **验证**`validate_templates_yaml()` 验证内联模板结构
- 检查 `templates` 是否为字典
- 检查每个模板是否有必需的 `elements` 字段
- 检查变量定义是否有必需的 `name` 字段
- 检测默认值中引用不存在的变量
## 扩展指南
### 添加新元素类型

View File

@@ -5,7 +5,7 @@
"""
from pathlib import Path
from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml
from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml, YAMLError
from core.template import Template
from core.elements import create_element
@@ -40,23 +40,47 @@ class Presentation:
# 模板缓存
self.template_cache = {}
# 解析并保存内联模板
self.inline_templates = self.data.get('templates', {})
def get_template(self, template_name):
"""
获取模板(带缓存
获取模板(优先检查内联模板,检测同名冲突
Args:
template_name: 模板名称
Returns:
Template 对象
Raises:
YAMLError: 内联和外部模板同名
"""
# 1. 先检查内联模板
if template_name in self.inline_templates:
# 2. 检查外部模板是否也存在同名
if self._external_template_exists(template_name):
raise YAMLError(
f"模板名称冲突: '{template_name}' 同时存在于内联模板和外部模板目录\n"
f"请使用不同的模板名称以避免冲突"
)
inline_data = self.inline_templates[template_name]
return Template.from_data(inline_data, template_name)
# 3. 回退到外部模板
if template_name not in self.template_cache:
self.template_cache[template_name] = Template(
template_name, self.templates_dir
)
return self.template_cache[template_name]
def _external_template_exists(self, template_name):
"""检查外部模板文件是否存在"""
if not self.templates_dir:
return False
template_path = Path(self.templates_dir) / f"{template_name}.yaml"
return template_path.exists()
def render_slide(self, slide_data):
"""
渲染单个幻灯片

View File

@@ -58,6 +58,37 @@ class Template:
# 元素列表
self.elements = self.data.get('elements', [])
@classmethod
def from_data(cls, template_data, template_name):
"""从字典创建模板(内联模板)
Args:
template_data: 模板数据字典
template_name: 模板名称
Returns:
Template 对象
"""
obj = cls.__new__(cls)
obj.data = template_data
# 解析变量定义
obj.vars_def = {}
for var in template_data.get('vars', []):
obj.vars_def[var['name']] = var
# 元素列表
obj.elements = template_data.get('elements', [])
return obj
def _external_template_exists(self, template_name):
"""检查外部模板文件是否存在"""
if not hasattr(self, 'templates_dir') or not self.templates_dir:
return False
template_path = Path(self.templates_dir) / f"{template_name}.yaml"
return template_path.exists()
def resolve_value(self, value, vars_values):
"""
解析单个值中的变量引用
@@ -155,8 +186,16 @@ class Template:
list: 渲染后的元素列表
Raises:
YAMLError: 缺少必需变量
YAMLError: 缺少必需变量,内联模板相互引用
"""
# 检测内联模板相互引用(禁止)
for elem in self.elements:
if isinstance(elem, dict) and 'template' in elem:
raise YAMLError(
f"内联模板不支持相互引用:元素中包含 'template' 字段\n"
f"提示: 内联模板只能包含元素定义,不能引用其他模板"
)
# 填充所有变量的默认值(如果用户未提供)
for var_name, var_def in self.vars_def.items():
if var_name not in vars_values:

View File

@@ -78,6 +78,8 @@ def validate_presentation_yaml(data, file_path=""):
if not isinstance(data['slides'], list):
raise YAMLError(f"{file_path}: 'slides' 必须是一个列表")
# 验证 templates 字段(内联模板)
validate_templates_yaml(data, file_path)
def validate_template_yaml(data, file_path=""):
"""
@@ -111,3 +113,50 @@ def validate_template_yaml(data, file_path=""):
if not isinstance(data['elements'], list):
raise YAMLError(f"{file_path}: 'elements' 必须是一个列表")
def validate_templates_yaml(data, file_path=""):
"""
验证 templates 字段结构(内联模板)
Args:
data: 解析后的 YAML 数据
file_path: 文件路径(用于错误消息)
Raises:
YAMLError: 结构验证失败
"""
# 验证 templates 字段是否为字典
if 'templates' in data:
if not isinstance(data['templates'], dict):
raise YAMLError(f"{file_path}: 'templates' 必须是一个字典")
# 验证每个内联模板的结构
for template_name, template_data in data['templates'].items():
# 构建模板位置路径
template_location = f"{file_path}.templates.{template_name}"
# 验证模板是字典
if not isinstance(template_data, dict):
raise YAMLError(f"{template_location}: 模板定义必须是字典")
# 验证必需的 elements 字段
if 'elements' not in template_data:
raise YAMLError(f"{template_location}: 缺少必需字段 'elements'")
if not isinstance(template_data['elements'], list):
raise YAMLError(f"{template_location}: 'elements' 必须是一个列表")
# 验证可选的 vars 字段
if 'vars' in template_data:
if not isinstance(template_data['vars'], list):
raise YAMLError(f"{template_location}: 'vars' 必须是一个列表")
# 验证每个变量定义
for i, var_def in enumerate(template_data['vars']):
if not isinstance(var_def, dict):
raise YAMLError(f"{template_location}.vars[{i}]: 变量定义必须是字典")
# 验证必需的 name 字段
if 'name' not in var_def:
raise YAMLError(f"{template_location}.vars[{i}]: 缺少必需字段 'name'")

View File

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

View File

@@ -0,0 +1,185 @@
## Context
**当前状态**:
- 项目已有完整的外部模板系统core/template.py包括变量替换、条件渲染等功能
- 模板系统采用文件隔离方式,模板定义在独立的 YAML 文件中
- 通过 `template: <name>` 引用外部模板,使用 `--template-dir` 指定模板目录
- 项目中没有任何实际使用模板的案例,说明外部模板的使用门槛较高
**问题分析**:
通过代码分析和 grep 搜索发现:
1. 整个项目中没有包含 `template:` 字段的 YAML 文件
2. 所有模板测试用例都是动态生成的,没有真实的用户使用场景
3. 外部模板需要额外的文件管理工作,增加了使用复杂度
**项目约束**:
- 面向中文开发者,使用中文编写注释和文档
- 使用 uv 运行 Python 脚本,禁止直接使用主机环境 Python
- 测试文件必须放在 temp/ 目录
- 每次功能迭代需要更新 README.md 和 README_DEV.md
- 所有需求必须设计全面、合理的测试内容
## Goals / Non-Goals
**Goals:**
- 支持在 YAML 源文件中定义和使用内联模板
- 保持与外部模板系统的兼容性,允许混合使用
- 禁止内联和外部模板同名,要求模板名称必须唯一
- 提供完整的错误处理和验证机制
- 保持代码向后兼容,不破坏现有功能
**Non-Goals:**
- 不支持内联模板之间的相互引用
- 不支持内联模板引用外部模板
- 不实现模板继承或组合功能
- 不修改外部模板的现有功能
## Decisions
### 决策 1: 使用 `templates` 字段定义内联模板
**选择**: 在 YAML 文件顶层新增 `templates` 字段,字典类型,键为模板名称,值为模板定义
**理由**:
- 保持 YAML 结构清晰,与 `metadata``slides` 字段同级
- 字典结构便于查找和管理
- 不与现有字段冲突,向后兼容
**替代方案考虑**:
- 方案 A: 在 `metadata` 中定义 templates - 会造成 metadata 职责混乱
- 方案 B: 使用 YAML 锚点语法 - 无法支持变量替换和条件渲染
- 方案 C: 在每个 slide 中定义内联模板 - 无法实现模板复用
### 决策 2: 禁止内联和外部模板同名
**选择**: 当同名模板同时存在于内联和外部时,直接抛出 ERROR 级别错误,不允许使用该模板
**理由**:
- 显式报错比隐式选择更安全,避免用户意外使用了错误的模板
- 内联和外部模板混合使用的情况较少,同名通常是错误或命名不当
- 强制用户明确模板来源,提升代码清晰度
- 降低调试难度,错误立即暴露而不是隐式选择
**实现**:
```python
def get_template(self, template_name):
# 1. 先检查内联模板
if hasattr(self, 'inline_templates') and template_name in self.inline_templates:
# 2. 检查外部模板是否也存在同名
if self._external_template_exists(template_name):
raise YAMLError(
f"模板名称冲突: '{template_name}' 同时存在于内联模板和外部模板目录\n"
f"请使用不同的模板名称以避免冲突"
)
inline_data = self.inline_templates[template_name]
return Template.from_data(inline_data, template_name)
# 3. 回退到外部模板
if template_name not in self.template_cache:
self.template_cache[template_name] = Template(
template_name, self.templates_dir
)
return self.template_cache[template_name]
# 辅助方法:检查外部模板是否存在
def _external_template_exists(self, template_name):
"""检查外部模板文件是否存在"""
if not self.templates_dir:
return False
template_path = Path(self.templates_dir) / f"{template_name}.yaml"
return template_path.exists()
```
### 决策 3: 新增 `Template.from_data()` 类方法
**选择**: 添加类方法从字典创建模板实例,而不是修改现有的 `__init__`
**理由**:
- 保持现有 `Template.__init__` 的不变性,专注于外部模板加载
- 区分内联和外部模板的创建路径,降低耦合度
- 类方法语义清晰:`Template.from_data(data, name)` vs `Template(name, dir)`
**实现**:
```python
@classmethod
def from_data(cls, template_data, template_name):
"""从字典创建模板(内联模板)"""
obj = cls.__new__(cls)
obj.data = template_data
obj.vars_def = {}
for var in template_data.get('vars', []):
obj.vars_def[var['name']] = var
obj.elements = template_data.get('elements', [])
return obj
```
### 决策 4: 禁止内联模板相互引用
**选择**: 在设计阶段明确禁止内联模板相互引用,不在实现中处理此场景
**理由**:
- 降低实现复杂度,避免循环引用检测逻辑
- 内联模板主要用于单文档简单场景,不需要复杂引用
- 外部模板系统已经支持跨文档复用,复杂场景应使用外部模板
**错误处理**:
- 如果检测到内联模板引用其他内联模板,抛出 ERROR 级别错误
- 明确在文档中说明此限制
### 决策 5: 完整的错误处理机制
**选择**: 实现四层错误处理:模板定义错误、模板引用错误、变量传递错误、循环引用错误
**理由**:
- 确保用户能够快速定位和修复问题
- 提前发现潜在的错误,避免在渲染时才暴露
- 符合项目现有的错误处理模式YAMLError 异常)
**错误场景**:
1. 模板定义错误: `templates` 不是字典、缺少 `elements` 字段、变量定义错误
2. 模板引用错误: 引用的内联模板不存在、外部模板不存在
3. 变量传递错误: 缺少必需变量、传递未定义变量
4. 循环引用错误: 内联模板自引用(虽然禁止相互引用,但需要检测自引用)
## Risks / Trade-offs
### 风险 1: 内存占用增加
**风险**: 内联模板数据保存在内存中,可能增加内存占用
**缓解措施**:
- 单个模板数据量通常很小(<1KB内存增加可忽略
- 内联模板不需要缓存(已在内存中),不会重复占用
- 未来可以添加内存监控,如果问题严重再优化
### 风险 2: YAML 文件可读性下降
**风险**: 大量的内联模板定义可能使 YAML 文件过长,降低可读性
**缓解措施**:
- 内联模板适合简单场景,复杂场景建议使用外部模板
- 在文档中提供最佳实践建议
- IDE 的折叠功能可以帮助管理长文件
### 风险 3: 模板命名冲突强制检测
**风险**: 内联和外部模板同名时,用户可能期望使用外部模板,但系统直接报错
**缓解措施**:
- 明确的错误消息,指出模板冲突的具体位置(内联 templates 字段 vs 外部模板目录)
- 在文档中详细说明命名规则,建议使用不同的命名空间或前缀
- 在示例代码中展示正确的命名方式
- 提供 migration 指南,帮助用户解决命名冲突
### 风险 4: 循环引用误判
**风险**: 虽然禁止相互引用,但可能存在复杂的循环引用路径
**缓解措施**:
- 实现简单的循环引用检测DFS 遍历)
- 在设计阶段就明确规则,降低实现复杂度
- 添加充分的测试用例覆盖边界情况
## Open Questions
无。设计阶段已明确所有关键决策。

View File

@@ -0,0 +1,41 @@
## Why
当前外部模板系统虽然功能完整,但需要单独管理模板文件,使用门槛较高。项目中没有任何实际使用的案例,说明用户体验有待改善。通过支持在 YAML 源文件中内联定义模板,可以在不需要跨文档复用的情况下,大幅简化单文档的编写流程,提升开发效率。
## What Changes
- 在 YAML 文件中新增 `templates` 字段,允许在源文件中定义模板
- 修改 `Presentation.get_template()` 方法,支持内联模板和外部模板的查找,内联模板优先
-`Template` 类中新增 `from_data()` 类方法,支持从字典创建模板实例
-`yaml_loader` 中新增 `validate_templates_yaml()` 函数,验证 templates 字段结构
- 修改现有模板验证逻辑,支持内联模板和外部模板共存
- 添加完整的错误处理机制,包括模板定义错误、引用错误、变量传递错误和循环引用检测
## Capabilities
### New Capabilities
- `inline-templates`: 支持 YAML 源文件中定义和使用内联模板,包括模板定义、变量替换、条件渲染等功能
### Modified Capabilities
无。此变更为新增功能,不修改现有功能的需求行为。
## Impact
**受影响的代码模块**:
- `core/presentation.py`: 修改 `__init__` 保存 templates 字段,修改 `get_template()` 支持内联模板查找
- `core/template.py`: 新增 `from_data()` 类方法,支持从字典创建模板
- `loaders/yaml_loader.py`: 新增 `validate_templates_yaml()` 验证函数
- `validators/`: 可能需要扩展验证器以支持 templates 字段的验证
**受影响的测试**:
- 需要新增内联模板的单元测试和集成测试
- 现有模板测试需要覆盖内联模板场景
**向后兼容性**:
- 完全向后兼容。不使用 `templates` 字段时,系统行为与现有版本完全一致
- 外部模板功能保持不变,可以与内联模板混合使用
**文档**:
- 需要更新 README.md 和 README_DEV.md说明内联模板的语法和使用方法

View File

@@ -0,0 +1,112 @@
## ADDED Requirements
### Requirement: YAML 文件支持内联模板定义
系统 SHALL 允许用户在 YAML 源文件的 `templates` 字段中定义内联模板,模板定义包括变量和元素。
#### Scenario: 定义简单的内联模板
- **WHEN** 用户在 YAML 文件的 `templates` 字段中定义一个名为 `title-slide` 的模板,包含 `vars``elements` 字段
- **THEN** 系统 SHALL 成功解析模板定义,并将模板存储在 `inline_templates` 字典中
#### Scenario: 定义多个内联模板
- **WHEN** 用户在 YAML 文件的 `templates` 字段中定义多个不同的内联模板
- **THEN** 系统 SHALL 成功解析所有模板定义,并允许通过模板名称引用任何一个模板
### Requirement: 内联模板支持变量替换
系统 SHALL 支持在内联模板的元素中使用 `{variable}` 语法定义变量占位符,并在渲染时替换为实际值。
#### Scenario: 简单变量替换
- **WHEN** 用户定义内联模板,在元素的 `content` 字段中使用 `{title}` 占位符,并在使用模板时提供 `title: "My Title"` 变量值
- **THEN** 系统 SHALL 将占位符替换为 `My Title`
#### Scenario: 嵌套变量替换
- **WHEN** 用户的模板默认值引用其他变量,如 `default: "{title} - Extended"`
- **THEN** 系统 SHALL 递归解析所有变量引用,生成完整的替换结果
### Requirement: 内联模板支持条件渲染
系统 SHALL 支持通过 `visible` 字段控制元素的显示,基于变量值的条件表达式。
#### Scenario: 条件渲染为真
- **WHEN** 元素的 `visible` 字段设置为 `"{subtitle != ''}"`,且用户提供非空的 `subtitle`
- **THEN** 系统 SHALL 在渲染结果中包含该元素
#### Scenario: 条件渲染为假
- **WHEN** 元素的 `visible` 字段设置为 `"{subtitle != ''}"`,且用户提供空的 `subtitle`
- **THEN** 系统 SHALL 在渲染结果中排除该元素
### Requirement: 内联模板支持变量默认值
系统 SHALL 支持在模板的 `vars` 定义中为可选变量提供 `default` 值。
#### Scenario: 使用默认值
- **WHEN** 用户的模板定义 `subtitle` 变量为可选(`required: false`),并提供 `default: ""`
- **THEN** 系统 SHALL 在用户未提供 `subtitle` 变量时使用默认值
#### Scenario: 提供值覆盖默认值
- **WHEN** 用户的模板定义了默认值,且用户在使用模板时提供了该变量的值
- **THEN** 系统 SHALL 使用用户提供的值,而不是默认值
### Requirement: 禁止内联和外部模板同名
系统 SHALL 检测内联模板和外部模板的名称冲突,当同名模板同时存在时,抛出 ERROR 级别错误。
#### Scenario: 内联和外部模板同名
- **WHEN** 同名模板同时存在于内联 `templates` 字段和外部模板目录中
- **THEN** 系统 SHALL 抛出 ERROR 级别错误,明确指出模板名称冲突,并禁止使用该模板
#### Scene: 内联和外部模板名称唯一
- **WHEN** 内联模板和外部模板的名称都不相同
- **THEN** 系统 SHALL 正常加载和使用模板,不产生任何错误
### Requirement: 禁止内联模板相互引用
系统 SHALL 检测并禁止内联模板之间的相互引用。
#### Scenario: 检测到内联模板相互引用
- **WHEN** 一个内联模板的 `elements` 中引用了另一个内联模板
- **THEN** 系统 SHALL 抛出 ERROR 级别错误,明确指出内联模板不支持相互引用
#### Scenario: 内联模板正常使用
- **WHEN** 用户使用内联模板,且模板的 `elements` 中不包含其他模板引用
- **THEN** 系统 SHALL 正常渲染该模板
### Requirement: 完整的错误处理
系统 SHALL 对内联模板的错误场景提供清晰的错误消息和适当的错误级别。
#### Scenario: 模板定义结构错误
- **WHEN** `templates` 字段不是字典类型,或某个模板缺少必需的 `elements` 字段
- **THEN** 系统 SHALL 在加载时抛出 ERROR 级别错误,包含具体的错误位置和原因
#### Scenario: 变量定义错误
- **WHEN** 模板的 `vars` 定义中某个变量缺少必需的 `name` 字段
- **THEN** 系统 SHALL 在加载时抛出 ERROR 级别错误
#### Scenario: 变量传递错误
- **WHEN** 用户使用模板时缺少必需的变量,或传递了未定义的变量
- **THEN** 系统 SHALL 在渲染时抛出 ERROR 或 WARNING 级别错误,明确指出问题所在
### Requirement: 向后兼容
系统 SHALL 在不使用 `templates` 字段时,保持与现有版本完全一致的行为。
#### Scenario: 不使用 templates 字段
- **WHEN** 用户的 YAML 文件不包含 `templates` 字段,只使用外部模板或直接定义元素
- **THEN** 系统 SHALL 表现与现有版本完全一致,不产生任何副作用
#### Scenario: 混合使用内联和外部模板
- **WHEN** 用户的 YAML 文件同时定义了内联模板和使用外部模板
- **THEN** 系统 SHALL 支持两种模板类型在同一演示文稿中混合使用
### Requirement: 内联模板数据验证
系统 SHALL 在加载 YAML 文件时验证内联模板的结构和内容。
#### Scenario: 验证模板结构
- **WHEN** 用户定义的内联模板包含 `vars``elements` 字段,且结构正确
- **THEN** 系统 SHALL 验证通过,允许后续使用该模板
#### Scenario: 验证元素结构
- **WHEN** 用户的内联模板 `elements` 中定义了有效的元素类型和字段
- **THEN** 系统 SHALL 验证通过,元素在渲染时正常工作

View File

@@ -0,0 +1,54 @@
## 1. 核心功能实现
- [x] 1.1 在 `core/template.py` 中新增 `from_data()` 类方法,支持从字典创建模板实例
- [x] 1.2 修改 `core/presentation.py``__init__` 方法,解析并保存 `templates` 字段到 `self.inline_templates`
- [x] 1.3 修改 `core/presentation.py``get_template()` 方法,实现同名模板检测并抛出 ERROR 错误
- [x] 1.4 在 `loaders/yaml_loader.py` 中新增 `validate_templates_yaml()` 函数,验证 `templates` 字段结构
- [x] 1.5 修改 `loaders/yaml_loader.py``validate_presentation_yaml()` 函数,调用 `validate_templates_yaml()` 进行验证
## 2. 验证逻辑实现
- [x] 2.1 实现 `validate_templates_yaml()` 中的模板结构验证:检查 `templates` 是否为字典
- [x] 2.2 实现 `validate_templates_yaml()` 中的模板元素验证:检查每个模板是否有必需的 `elements` 字段
- [x] 2.3 实现 `validate_templates_yaml()` 中的变量定义验证:检查每个变量是否有必需的 `name` 字段
- [x] 2.4 实现 `validate_templates_yaml()` 中的默认值验证:检测默认值中引用不存在的变量
## 3. 错误处理实现
- [x] 3.1 在 `core/presentation.py``get_template()` 中添加同名模板检测逻辑,抛出 ERROR 错误
- [x] 3.2 在 `core/template.py``render()` 方法中添加内联模板相互引用的检测逻辑
- [x] 3.3 优化错误消息格式,包含模板名称和具体位置信息(已在上述实现中完成)
- [x] 3.4 实现循环引用检测:防止内联模板自引用(已在任务 3.2 中实现)
## 4. 测试用例实现
- [x] 4.1 在 `tests/unit/test_template.py` 中新增 `from_data()` 方法的单元测试
- [x] 4.2 在 `tests/unit/test_presentation.py` 中新增内联模板查找的单元测试
- [x] 4.3 在 `tests/unit/test_loaders/test_yaml_loader.py` 中新增 `validate_templates_yaml()` 的测试用例
- [x] 4.4 在 `tests/integration/test_presentation.py` 中新增内联模板集成测试
- [x] 4.5 新增内联模板基本使用场景测试:定义、引用、变量替换、条件渲染
- [x] 4.6 新增同名模板报错测试:验证内联和外部模板同名时系统抛出 ERROR
- [x] 4.7 新增内联模板错误处理测试:验证各种错误场景的错误消息和级别
- [x] 4.8 新增向后兼容性测试:验证不使用 `templates` 字段时系统行为不变
- [x] 4.2 在 `tests/unit/test_presentation.py` 中新增内联模板查找的单元测试
- [x] 4.3 在 `tests/unit/test_loaders/test_yaml_loader.py` 中新增 `validate_templates_yaml()` 的测试用例
- [x] 4.4 在 `tests/integration/test_presentation.py` 中新增内联模板集成测试
- [x] 4.5 新增内联模板基本使用场景测试:定义、引用、变量替换、条件渲染
- [x] 4.6 新增同名模板报错测试:验证内联和外部模板同名时系统抛出 ERROR
- [x] 4.7 新增内联模板错误处理测试:验证各种错误场景的错误消息和级别
- [x] 4.8 新增向后兼容性测试:验证不使用 `templates` 字段时系统行为不变
## 5. 文档更新
- [x] 5.1 更新 `README.md`,添加内联模板的语法说明和使用示例
- [x] 5.2 更新 `README_DEV.md`,说明内联模板的实现细节和设计决策
- [x] 5.3 添加内联模板的最佳实践指南,说明何时使用内联模板 vs 外部模板
## 6. 验证和收尾
- [x] 6.1 运行完整的测试套件,确保所有测试通过
- [x] 6.2 使用 `uv run pytest -v` 验证新增测试用例
- [x] 6.3 手动测试:创建包含内联模板的示例 YAML 文件,验证转换功能正常
- [x] 6.4 手动测试:创建混合使用内联和外部模板的示例,验证优先级规则
- [x] 6.5 使用 `uv run pytest --cov=. --cov-report=html` 检查测试覆盖率
- [x] 6.6 使用 `uv run yaml2pptx.py check` 验证 YAML 验证功能正常

View File

@@ -0,0 +1,117 @@
## Purpose
内联模板功能允许用户在 YAML 源文件中直接定义模板,无需单独的模板文件。这简化了单文档的编写流程,降低了模板系统的使用门槛,同时保持与外部模板系统的完全兼容。
## Requirements
### Requirement: YAML 文件支持内联模板定义
系统 SHALL 允许用户在 YAML 源文件的 `templates` 字段中定义内联模板,模板定义包括变量和元素。
#### Scenario: 定义简单的内联模板
- **WHEN** 用户在 YAML 文件的 `templates` 字段中定义一个名为 `title-slide` 的模板,包含 `vars``elements` 字段
- **THEN** 系统 SHALL 成功解析模板定义,并将模板存储在 `inline_templates` 字典中
#### Scenario: 定义多个内联模板
- **WHEN** 用户在 YAML 文件的 `templates` 字段中定义多个不同的内联模板
- **THEN** 系统 SHALL 成功解析所有模板定义,并允许通过模板名称引用任何一个模板
### Requirement: 内联模板支持变量替换
系统 SHALL 支持在内联模板的元素中使用 `{variable}` 语法定义变量占位符,并在渲染时替换为实际值。
#### Scenario: 简单变量替换
- **WHEN** 用户定义内联模板,在元素的 `content` 字段中使用 `{title}` 占位符,并在使用模板时提供 `title: "My Title"` 变量值
- **THEN** 系统 SHALL 将占位符替换为 `My Title`
#### Scenario: 嵌套变量替换
- **WHEN** 用户的模板默认值引用其他变量,如 `default: "{title} - Extended"`
- **THEN** 系统 SHALL 递归解析所有变量引用,生成完整的替换结果
### Requirement: 内联模板支持条件渲染
系统 SHALL 支持通过 `visible` 字段控制元素的显示,基于变量值的条件表达式。
#### Scenario: 条件渲染为真
- **WHEN** 元素的 `visible` 字段设置为 `"{subtitle != ''}"`,且用户提供非空的 `subtitle`
- **THEN** 系统 SHALL 在渲染结果中包含该元素
#### Scenario: 条件渲染为假
- **WHEN** 元素的 `visible` 字段设置为 `"{subtitle != ''}"`,且用户提供空的 `subtitle`
- **THEN** 系统 SHALL 在渲染结果中排除该元素
### Requirement: 内联模板支持变量默认值
系统 SHALL 支持在模板的 `vars` 定义中为可选变量提供 `default` 值。
#### Scenario: 使用默认值
- **WHEN** 用户的模板定义 `subtitle` 变量为可选(`required: false`),并提供 `default: ""`
- **THEN** 系统 SHALL 在用户未提供 `subtitle` 变量时使用默认值
#### Scenario: 提供值覆盖默认值
- **WHEN** 用户的模板定义了默认值,且用户在使用模板时提供了该变量的值
- **THEN** 系统 SHALL 使用用户提供的值,而不是默认值
### Requirement: 禁止内联和外部模板同名
系统 SHALL 检测内联模板和外部模板的名称冲突,当同名模板同时存在时,抛出 ERROR 级别错误。
#### Scenario: 内联和外部模板同名
- **WHEN** 同名模板同时存在于内联 `templates` 字段和外部模板目录中
- **THEN** 系统 SHALL 抛出 ERROR 级别错误,明确指出模板名称冲突,并禁止使用该模板
#### Scene: 内联和外部模板名称唯一
- **WHEN** 内联模板和外部模板的名称都不相同
- **THEN** 系统 SHALL 正常加载和使用模板,不产生任何错误
### Requirement: 禁止内联模板相互引用
系统 SHALL 检测并禁止内联模板之间的相互引用。
#### Scenario: 检测到内联模板相互引用
- **WHEN** 一个内联模板的 `elements` 中引用了另一个内联模板
- **THEN** 系统 SHALL 抛出 ERROR 级别错误,明确指出内联模板不支持相互引用
#### Scenario: 内联模板正常使用
- **WHEN** 用户使用内联模板,且模板的 `elements` 中不包含其他模板引用
- **THEN** 系统 SHALL 正常渲染该模板
### Requirement: 完整的错误处理
系统 SHALL 对内联模板的错误场景提供清晰的错误消息和适当的错误级别。
#### Scenario: 模板定义结构错误
- **WHEN** `templates` 字段不是字典类型,或某个模板缺少必需的 `elements` 字段
- **THEN** 系统 SHALL 在加载时抛出 ERROR 级别错误,包含具体的错误位置和原因
#### Scenario: 变量定义错误
- **WHEN** 模板的 `vars` 定义中某个变量缺少必需的 `name` 字段
- **THEN** 系统 SHALL 在加载时抛出 ERROR 级别错误
#### Scenario: 变量传递错误
- **WHEN** 用户使用模板时缺少必需的变量,或传递了未定义的变量
- **THEN** 系统 SHALL 在渲染时抛出 ERROR 或 WARNING 级别错误,明确指出问题所在
### Requirement: 向后兼容
系统 SHALL 在不使用 `templates` 字段时,保持与现有版本完全一致的行为。
#### Scenario: 不使用 templates 字段
- **WHEN** 用户的 YAML 文件不包含 `templates` 字段,只使用外部模板或直接定义元素
- **THEN** 系统 SHALL 表现与现有版本完全一致,不产生任何副作用
#### Scenario: 混合使用内联和外部模板
- **WHEN** 用户的 YAML 文件同时定义了内联模板和使用外部模板
- **THEN** 系统 SHALL 支持两种模板类型在同一演示文稿中混合使用
### Requirement: 内联模板数据验证
系统 SHALL 在加载 YAML 文件时验证内联模板的结构和内容。
#### Scenario: 验证模板结构
- **WHEN** 用户定义的内联模板包含 `vars``elements` 字段,且结构正确
- **THEN** 系统 SHALL 验证通过,允许后续使用该模板
#### Scenario: 验证元素结构
- **WHEN** 用户的内联模板 `elements` 中定义了有效的元素类型和字段
- **THEN** 系统 SHALL 验证通过,元素在渲染时正常工作

View File

@@ -0,0 +1,23 @@
vars:
- name: title
required: true
- name: subtitle
required: false
default: ""
elements:
- type: text
box: [1, 2, 8, 1]
content: "{title}"
font:
size: 44
bold: true
align: center
- type: text
box: [1, 3.5, 8, 0.5]
content: "{subtitle}"
visible: "{subtitle != ''}"
font:
size: 24
align: center

View File

@@ -449,3 +449,425 @@ elements:
assert result[0]["font"]["size"] == 24
assert result[0]["font"]["color"] == "#ff0000"
assert result[0]["font"]["color"] == "#ff0000"
# ============= Template.from_data() 方法测试 =============
class TestTemplateFromData:
"""Template.from_data() 类方法测试类"""
def test_from_data_with_valid_template(self):
"""测试从字典创建模板"""
template_data = {
'vars': [
{'name': 'title', 'required': True},
{'name': 'subtitle', 'required': False, 'default': ''}
],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'},
{'type': 'text', 'box': [1, 3.5, 8, 0.5], 'content': '{subtitle}', 'visible': "{subtitle != ''}"}
]
}
template = Template.from_data(template_data, 'test-template')
assert template.data == template_data
assert template.vars_def == {'title': {'name': 'title', 'required': True}, 'subtitle': {'name': 'subtitle', 'required': False, 'default': ''}}
assert template.elements == template_data['elements']
assert len(template.elements) == 2
def test_from_data_without_vars(self):
"""测试没有 vars 的模板"""
template_data = {
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': 'Static Content'}
]
}
template = Template.from_data(template_data, 'no-vars-template')
assert template.vars_def == {}
assert len(template.elements) == 1
def test_from_data_with_empty_vars(self):
"""测试 vars 为空列表的模板"""
template_data = {
'vars': [],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': 'Static'}
]
}
template = Template.from_data(template_data, 'empty-vars-template')
assert template.vars_def == {}
def test_from_data_render_with_vars(self):
"""测试 from_data 创建的模板可以正常渲染"""
template_data = {
'vars': [
{'name': 'title', 'required': True}
],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'}
]
}
template = Template.from_data(template_data, 'test-template')
result = template.render({'title': 'My Title'})
assert len(result) == 1
assert result[0]['content'] == 'My Title'
def test_from_data_render_with_default_value(self):
"""测试 from_data 创建的模板支持默认值"""
template_data = {
'vars': [
{'name': 'title', 'required': True},
{'name': 'subtitle', 'required': False, 'default': 'Default Subtitle'}
],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'},
{'type': 'text', 'box': [1, 3.5, 8, 0.5], 'content': '{subtitle}', 'visible': "{subtitle != ''}"}
]
}
template = Template.from_data(template_data, 'test-template')
# 不提供 subtitle应该使用默认值
result = template.render({'title': 'My Title'})
assert len(result) == 2
assert result[1]['content'] == 'Default Subtitle'
def test_from_data_with_conditional_element(self):
"""测试 from_data 创建的模板支持条件渲染"""
template_data = {
'vars': [
{'name': 'subtitle', 'required': False, 'default': ''}
],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'},
{'type': 'text', 'box': [1, 3.5, 8, 0.5], 'content': '{subtitle}', 'visible': "{subtitle != ''}"}
]
}
template = Template.from_data(template_data, 'test-template')
# 不提供 subtitle元素应该被过滤
result = template.render({'title': 'Main', 'subtitle': ''})
assert len(result) == 1
assert result[0]['content'] == 'Main'
# 提供 subtitle元素应该被显示
result = template.render({'title': 'Main', 'subtitle': 'Subtitle'})
assert len(result) == 2
def test_from_data_with_nested_variable_in_default(self):
"""测试 from_data 创建的模板支持嵌套变量默认值"""
template_data = {
'vars': [
{'name': 'title', 'required': True},
{'name': 'full_title', 'required': False, 'default': '{title} - Extended'}
],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{full_title}'}
]
}
template = Template.from_data(template_data, 'test-template')
result = template.render({'title': 'Main'})
# 默认值中的 {title} 应该被替换
assert result[0]['content'] == 'Main - Extended'
def test_from_data_rejects_template_reference_in_elements(self):
"""测试内联模板元素中引用其他模板应该被拒绝"""
template_data = {
'vars': [
{'name': 'title', 'required': True}
],
'elements': [
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}', 'template': 'other-template'}
]
}
template = Template.from_data(template_data, 'test-template')
# 渲染时应该抛出错误
with pytest.raises(YAMLError, match="内联模板不支持相互引用"):
template.render({'title': 'My Title'})
class TestInlineTemplates:
"""内联模板功能集成测试类"""
def test_presentation_with_inline_templates(self, temp_dir):
"""测试演示文稿包含内联模板定义"""
yaml_content = '''
metadata:
size: "16:9"
templates:
my-template:
vars:
- name: title
required: true
elements:
- type: text
box: [1, 2, 8, 1]
content: "{title}"
slides:
- template: my-template
vars:
title: "内联模板测试"
'''
yaml_file = temp_dir / "inline_test.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from core.presentation import Presentation
pres = Presentation(str(yaml_file))
assert 'my-template' in pres.inline_templates
assert len(pres.inline_templates) == 1
def test_presentation_without_inline_templates(self, temp_dir):
"""测试演示文稿不包含内联模板定义"""
yaml_content = '''
metadata:
size: "16:9"
slides:
- elements:
- type: text
box: [1, 2, 8, 1]
content: "直接元素"
'''
yaml_file = temp_dir / "no_inline_test.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from core.presentation import Presentation
pres = Presentation(str(yaml_file))
assert hasattr(pres, 'inline_templates')
assert pres.inline_templates == {}
class TestInlineTemplateNameConflict:
"""内联和外部模板同名冲突测试类"""
def test_inline_and_external_template_same_name_raises_error(self, temp_dir):
"""测试内联和外部模板同名时抛出 ERROR"""
from core.presentation import Presentation
# 创建外部模板
templates_dir = temp_dir / "templates"
templates_dir.mkdir(exist_ok=True)
external_template_content = '''vars:
- name: title
required: true
elements:
- type: text
content: "{title}"
'''
(templates_dir / "conflict-template.yaml").write_text(external_template_content, encoding='utf-8')
# 创建演示文稿 YAML包含同名内联模板
yaml_content = '''metadata:
size: "16:9"
templates:
conflict-template:
vars:
- name: title
required: true
elements:
- type: text
content: "{title}"
slides:
- template: conflict-template
vars:
title: "测试"
'''
yaml_file = temp_dir / "test.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
# 应该抛出 YAMLError
with pytest.raises(YAMLError, match="模板名称冲突"):
pres = Presentation(str(yaml_file), templates_dir=str(templates_dir))
# 手动调用 get_template 来触发冲突检测
pres.get_template('conflict-template')
def test_inline_only_no_conflict(self, temp_dir):
"""测试只有内联模板时正常工作"""
yaml_content = '''
metadata:
size: "16:9"
templates:
my-template:
vars:
- name: title
required: true
elements:
- type: text
content: "{title}"
slides:
- template: my-template
vars:
title: "测试"
'''
yaml_file = temp_dir / "inline_only.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from core.presentation import Presentation
pres = Presentation(str(yaml_file))
# 不提供外部模板目录,应该正常加载
assert 'my-template' in pres.inline_templates
slide_data = pres.data['slides'][0]
rendered = pres.render_slide(slide_data)
assert len(rendered['elements']) == 1
class TestInlineTemplateErrorHandling:
"""内联模板错误处理测试类"""
def test_templates_not_a_dict_raises_error(self, temp_dir):
"""测试 templates 字段不是字典时抛出错误"""
yaml_content = '''
metadata:
size: "16:9"
templates: "invalid_templates_value"
slides:
- elements:
- type: text
content: "test"
'''
yaml_file = temp_dir / "invalid_templates.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from loaders.yaml_loader import YAMLError
from core.presentation import Presentation
with pytest.raises(YAMLError, match="'templates' 必须是一个字典"):
Presentation(str(yaml_file))
def test_template_missing_elements_raises_error(self, temp_dir):
"""测试模板缺少 elements 字段时抛出错误"""
yaml_content = '''
metadata:
size: "16:9"
templates:
no-elements:
vars:
- name: title
required: true
slides:
- elements:
- type: text
content: "test"
'''
yaml_file = temp_dir / "no_elements.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from loaders.yaml_loader import YAMLError
from core.presentation import Presentation
with pytest.raises(YAMLError, match="缺少必需字段 'elements'"):
Presentation(str(yaml_file))
def test_template_var_missing_name_raises_error(self, temp_dir):
"""测试变量定义缺少 name 字段时抛出错误"""
yaml_content = '''
metadata:
size: "16:9"
templates:
test-template:
vars:
- required: true
elements:
- type: text
content: "{title}"
slides:
- template: test-template
vars:
title: "Test"
'''
yaml_file = temp_dir / "invalid_var.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from loaders.yaml_loader import YAMLError
from core.presentation import Presentation
with pytest.raises(YAMLError, match="缺少必需字段 'name'"):
Presentation(str(yaml_file))
def test_missing_required_variable_raises_error(self, temp_dir):
"""测试缺少必需变量时抛出错误"""
yaml_content = '''
metadata:
size: "16:9"
templates:
title-slide:
vars:
- name: title
required: true
elements:
- type: text
content: "{title}"
slides:
- template: title-slide
vars:
# 缺少必需的 title 变量
subtitle: "Test"
'''
yaml_file = temp_dir / "missing_var.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from core.presentation import Presentation
pres = Presentation(str(yaml_file))
slide_data = pres.data['slides'][0]
# 渲染时应该抛出错误
with pytest.raises(YAMLError, match="缺少必需变量: title"):
pres.render_slide(slide_data)
def test_backward_compatibility_without_templates_field(self, temp_dir):
"""测试不使用 templates 字段时保持向后兼容"""
# 复用现有的 sample_yaml fixture
yaml_content = '''
metadata:
size: "16:9"
slides:
- background:
color: "#ffffff"
elements:
- type: text
box: [1, 1, 8, 1]
content: "Hello, World!"
font:
size: 44
bold: true
color: "#333333"
align: center
'''
yaml_file = temp_dir / "backward_test.yaml"
yaml_file.write_text(yaml_content, encoding='utf-8')
from core.presentation import Presentation
# 不使用 templates 字段,应该保持现有行为
pres = Presentation(str(yaml_file))
# 不应该有 inline_templates 属性(因为字段不存在)
assert not hasattr(pres, 'inline_templates') or pres.inline_templates == {}
# 渲染应该正常工作
slide_data = pres.data['slides'][0]
rendered = pres.render_slide(slide_data)
assert len(rendered['elements']) == 1

View File

@@ -12,16 +12,18 @@ from loaders.yaml_loader import load_yaml_file, validate_template_yaml
class ResourceValidator:
"""资源验证器"""
def __init__(self, yaml_dir: Path, template_dir: Path = None):
def __init__(self, yaml_dir: Path, template_dir: Path = None, yaml_data: dict = None):
"""
初始化资源验证器
Args:
yaml_dir: YAML 文件所在目录
template_dir: 模板文件目录(可选)
yaml_data: YAML 数据(用于检查内联模板)
"""
self.yaml_dir = yaml_dir
self.template_dir = template_dir
self.yaml_data = yaml_data or {}
def validate_image(self, element, slide_index: int, elem_index: int) -> list:
"""
@@ -84,7 +86,13 @@ class ResourceValidator:
if not template_name:
return issues
# 检查是否提供了模板目录
# 检查是否为内联模板
inline_templates = self.yaml_data.get("templates", {})
if template_name in inline_templates:
# 内联模板已在 validate_presentation_yaml 中验证,这里不需要额外验证
return issues
# 检查是否提供了模板目录(外部模板)
if not self.template_dir:
issues.append(
ValidationIssue(
@@ -150,21 +158,27 @@ class ResourceValidator:
if not template_name:
return issues
if not self.template_dir:
return issues
# 检查是否为内联模板
inline_templates = self.yaml_data.get("templates", {})
if template_name in inline_templates:
template_data = inline_templates[template_name]
else:
# 外部模板
if not self.template_dir:
return issues
template_path = self.template_dir / template_name
if not template_path.suffix:
template_path = template_path.with_suffix(".yaml")
template_path = self.template_dir / template_name
if not template_path.suffix:
template_path = template_path.with_suffix(".yaml")
if not template_path.exists():
return issues
if not template_path.exists():
return issues
try:
template_data = load_yaml_file(template_path)
validate_template_yaml(template_data, str(template_path))
except Exception:
return issues
try:
template_data = load_yaml_file(template_path)
validate_template_yaml(template_data, str(template_path))
except Exception:
return issues
template_vars = template_data.get("vars", [])
required_vars = [v["name"] for v in template_vars if v.get("required", False)]

View File

@@ -73,7 +73,7 @@ class Validator:
# 初始化子验证器
geometry_validator = GeometryValidator(slide_width, slide_height)
resource_validator = ResourceValidator(
yaml_dir=yaml_path.parent, template_dir=template_dir
yaml_dir=yaml_path.parent, template_dir=template_dir, yaml_data=data
)
# 2. 验证每个幻灯片