Compare commits
4 Commits
22614d6f55
...
16ca9d77cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ca9d77cd | |||
| 01a93ce13b | |||
| 01eacb0b97 | |||
| 2ba1bd7272 |
1
AGENTS.md
Normal file
1
AGENTS.md
Normal file
@@ -0,0 +1 @@
|
||||
在处理任何任务前,请先阅读openspec/config.yaml文件,其中的context字段内容包含本项目的基本规范,请严格遵守
|
||||
1
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
在处理任何任务前,请先阅读openspec/config.yaml文件,其中的context字段内容包含本项目的基本规范,请严格遵守
|
||||
232
README.md
232
README.md
@@ -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:
|
||||
@@ -247,12 +343,138 @@ slides:
|
||||
|
||||
### 条件渲染
|
||||
|
||||
使用 `visible` 属性控制元素显示:
|
||||
#### 元素级条件渲染
|
||||
|
||||
使用 `visible` 属性控制元素显示,支持强大的条件表达式:
|
||||
|
||||
**基本示例**:
|
||||
|
||||
```yaml
|
||||
# 简单比较
|
||||
- type: text
|
||||
content: "有数据"
|
||||
visible: "{count > 0}"
|
||||
|
||||
# 字符串比较
|
||||
- type: text
|
||||
content: "草稿状态"
|
||||
visible: "{status == 'draft'}"
|
||||
|
||||
# 非空检查(向后兼容)
|
||||
- type: text
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}" # 仅当 subtitle 不为空时显示
|
||||
visible: "{subtitle != ''}"
|
||||
```
|
||||
|
||||
**支持的表达式类型**:
|
||||
|
||||
1. **比较运算**:`==`, `!=`, `>`, `<`, `>=`, `<=`
|
||||
```yaml
|
||||
visible: "{score >= 60}"
|
||||
visible: "{price <= 100}"
|
||||
```
|
||||
|
||||
2. **逻辑运算**:`and`, `or`, `not`
|
||||
```yaml
|
||||
visible: "{count > 0 and status == 'active'}"
|
||||
visible: "{is_draft or is_preview}"
|
||||
visible: "{not (count == 0)}"
|
||||
```
|
||||
|
||||
3. **成员测试**:`in`, `not in`
|
||||
```yaml
|
||||
visible: "{status in ['draft', 'review', 'published']}"
|
||||
visible: "{level in (1, 2, 3)}"
|
||||
visible: "{'test' in version}" # 字符串包含
|
||||
```
|
||||
|
||||
4. **数学运算**:`+`, `-`, `*`, `/`, `%`, `**`
|
||||
```yaml
|
||||
visible: "{(price * discount) > 50}"
|
||||
visible: "{(total / count) >= 10}"
|
||||
```
|
||||
|
||||
5. **内置函数**:`int()`, `float()`, `str()`, `len()`, `bool()`, `abs()`, `min()`, `max()`
|
||||
```yaml
|
||||
visible: "{len(items) > 0}"
|
||||
visible: "{int(value) > 100}"
|
||||
```
|
||||
|
||||
**复杂条件示例**:
|
||||
|
||||
```yaml
|
||||
# 范围检查
|
||||
- type: text
|
||||
content: "评分: {score}"
|
||||
visible: "{score >= 60 and score <= 100}"
|
||||
|
||||
# 多条件组合
|
||||
- type: text
|
||||
content: "管理员或高分用户"
|
||||
visible: "{is_admin or (score >= 90)}"
|
||||
|
||||
# 嵌套条件
|
||||
- type: text
|
||||
content: "符合条件"
|
||||
visible: "{((count > 0) and (status == 'active')) or (is_admin and (level >= 3))}"
|
||||
```
|
||||
|
||||
#### 页面级启用控制
|
||||
|
||||
使用 `enabled` 参数控制整个幻灯片是否渲染:
|
||||
|
||||
```yaml
|
||||
slides:
|
||||
# 正常渲染的幻灯片
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "主标题"
|
||||
|
||||
# 临时禁用的幻灯片(开发调试)
|
||||
- enabled: false
|
||||
template: work-in-progress
|
||||
vars:
|
||||
title: "未完成的内容"
|
||||
|
||||
# 继续渲染后续幻灯片
|
||||
- template: content-slide
|
||||
vars:
|
||||
title: "内容页"
|
||||
```
|
||||
|
||||
**enabled 参数说明**:
|
||||
- 类型:布尔值(`true` 或 `false`)
|
||||
- 默认值:`true`(未指定时默认启用)
|
||||
- 用途:临时禁用幻灯片,无需删除或注释 YAML 内容
|
||||
- 场景:开发调试、版本控制、A/B 测试
|
||||
|
||||
**enabled vs visible 的区别**:
|
||||
|
||||
| 特性 | `enabled`(页面级) | `visible`(元素级) |
|
||||
|------|-------------------|-------------------|
|
||||
| 作用范围 | 整个幻灯片 | 单个元素 |
|
||||
| 类型 | 布尔值 | 条件表达式 |
|
||||
| 判断时机 | 加载时(静态) | 渲染时(动态) |
|
||||
| 使用场景 | 临时禁用页面 | 条件显示元素 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```yaml
|
||||
slides:
|
||||
# 页面启用,但副标题元素可能隐藏
|
||||
- enabled: true
|
||||
template: title-slide
|
||||
vars:
|
||||
title: "标题"
|
||||
subtitle: "" # 空字符串,元素级 visible 会隐藏副标题
|
||||
|
||||
# 整页禁用,不渲染
|
||||
- enabled: false
|
||||
elements:
|
||||
- type: text
|
||||
content: "这一页不会出现在最终 PPTX 中"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
```
|
||||
|
||||
## 🎯 命令行选项
|
||||
|
||||
119
README_DEV.md
119
README_DEV.md
@@ -17,6 +17,7 @@ html2pptx/
|
||||
├── core/ # 核心领域模型
|
||||
│ ├── elements.py (200 行) # 元素抽象层(dataclass + validate)
|
||||
│ ├── template.py (191 行) # 模板系统
|
||||
│ ├── condition_evaluator.py # 条件表达式评估器(新增)
|
||||
│ └── presentation.py (91 行) # 演示文稿类
|
||||
├── loaders/ # 数据加载层
|
||||
│ └── yaml_loader.py (113 行) # YAML 加载和验证
|
||||
@@ -75,7 +76,13 @@ yaml2pptx.py (入口)
|
||||
- `/// script` 依赖声明
|
||||
- `parse_args()` - 命令行参数解析
|
||||
- `main()` - 主流程编排
|
||||
- `handle_convert()` - 转换流程,包含页面级 `enabled` 检查
|
||||
- **不包含**:业务逻辑、数据处理
|
||||
- **enabled 实现细节**:
|
||||
- 在主渲染循环中检查 `slide_data.get('enabled', True)`
|
||||
- 跳过 `enabled=false` 的幻灯片,不调用 `render_slide()`
|
||||
- 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片
|
||||
- 进度日志显示准确的渲染数量(不包括禁用的幻灯片)
|
||||
|
||||
### 2. utils.py(工具层)
|
||||
- **职责**:通用工具函数
|
||||
@@ -88,8 +95,13 @@ yaml2pptx.py (入口)
|
||||
- **包含**:
|
||||
- `YAMLError` - 自定义异常
|
||||
- `load_yaml_file()` - 加载 YAML 文件
|
||||
- `validate_presentation_yaml()` - 验证演示文稿结构
|
||||
- `validate_template_yaml()` - 验证模板结构
|
||||
- `validate_presentation_yaml()` - 验证演示文稿结构,调用 `validate_templates_yaml()` 验证内联模板,验证幻灯片 `enabled` 字段
|
||||
- `validate_template_yaml()` - 验证外部模板结构
|
||||
- `validate_templates_yaml()` - 验证内联模板结构(templates 字段)
|
||||
- **特点**:
|
||||
- 内联模板验证包括:结构验证、元素验证、变量定义验证、默认值验证
|
||||
- 检测默认值中引用不存在的变量
|
||||
- 验证 `enabled` 字段必须是布尔值,拒绝字符串或条件表达式
|
||||
|
||||
### 4. core/elements.py(核心层 - 元素抽象)
|
||||
- **职责**:定义元素数据类和工厂函数
|
||||
@@ -130,19 +142,58 @@ yaml2pptx.py (入口)
|
||||
- **职责**:模板加载和变量解析
|
||||
- **包含**:
|
||||
- `Template` 类
|
||||
- `from_data()` 类方法:从字典创建模板实例(用于内联模板)
|
||||
- 变量解析:`resolve_value()`, `resolve_element()`
|
||||
- 条件渲染:`evaluate_condition()`
|
||||
- 条件渲染:`evaluate_condition()` - 委托给 ConditionEvaluator
|
||||
- 模板渲染:`render()`
|
||||
- **特点**:
|
||||
- 支持外部模板(从文件加载)和内联模板(从字典创建)
|
||||
|
||||
### 5.5. core/condition_evaluator.py(核心层 - 条件评估)
|
||||
- **职责**:安全地评估条件表达式
|
||||
- **包含**:
|
||||
- `ConditionEvaluator` 类
|
||||
- `evaluate_condition()` - 主评估方法
|
||||
- `_get_evaluator()` - 配置 simpleeval 实例
|
||||
- `_extract_expression()` - 提取表达式内容
|
||||
- **技术实现**:
|
||||
- 使用 simpleeval 库的 `EvalWithCompoundTypes` 进行安全评估
|
||||
- 支持比较运算符(==, !=, >, <, >=, <=)
|
||||
- 支持逻辑运算符(and, or, not)
|
||||
- 支持成员测试(in, not in)
|
||||
- 支持列表/元组字面量
|
||||
- 支持数学运算(+, -, *, /, %, **)
|
||||
- 支持内置函数(int, float, str, len, bool, abs, min, max)
|
||||
- **安全策略**:
|
||||
- 表达式最大长度限制:500 字符
|
||||
- 禁止属性访问(obj.attr)
|
||||
- 禁止函数定义(lambda, def)
|
||||
- 禁止模块导入(import)
|
||||
- 白名单函数限制
|
||||
- 详细的错误信息映射
|
||||
- **错误处理**:
|
||||
- `NameNotDefined` → "条件表达式中的变量未定义"
|
||||
- `FunctionNotDefined` → "条件表达式中使用了不支持的函数"
|
||||
- `FeatureNotAvailable` → "条件表达式使用了不支持的语法特性"
|
||||
- `AttributeDoesNotExist` → "不支持属性访问"
|
||||
- `SyntaxError` → "条件表达式语法错误"
|
||||
- 内联模板通过 `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 +430,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` 字段
|
||||
- 检测默认值中引用不存在的变量
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新元素类型
|
||||
|
||||
155
core/condition_evaluator.py
Normal file
155
core/condition_evaluator.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
条件评估器模块
|
||||
|
||||
提供基于 simpleeval 的安全条件表达式评估能力
|
||||
"""
|
||||
|
||||
from simpleeval import (
|
||||
EvalWithCompoundTypes,
|
||||
NameNotDefined,
|
||||
FunctionNotDefined,
|
||||
FeatureNotAvailable,
|
||||
AttributeDoesNotExist
|
||||
)
|
||||
from loaders.yaml_loader import YAMLError
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""条件表达式评估器
|
||||
|
||||
使用 simpleeval 库安全地评估条件表达式,支持:
|
||||
- 比较运算符:==, !=, >, <, >=, <=
|
||||
- 逻辑运算符:and, or, not
|
||||
- 成员测试:in, not in
|
||||
- 列表/元组字面量
|
||||
- 数学运算:+, -, *, /, %, **
|
||||
- 内置函数:int, float, str, len, bool, abs, min, max
|
||||
"""
|
||||
|
||||
# 表达式最大长度限制(防止过于复杂的表达式)
|
||||
MAX_EXPRESSION_LENGTH = 500
|
||||
|
||||
def __init__(self):
|
||||
"""初始化条件评估器"""
|
||||
pass
|
||||
|
||||
def _get_evaluator(self, vars_values):
|
||||
"""
|
||||
获取配置好的 simpleeval 实例
|
||||
|
||||
Args:
|
||||
vars_values: 变量值字典
|
||||
|
||||
Returns:
|
||||
EvalWithCompoundTypes 实例
|
||||
"""
|
||||
# 每次评估都创建新实例,避免状态污染
|
||||
evaluator = EvalWithCompoundTypes(names=vars_values)
|
||||
|
||||
# 添加额外的安全函数到白名单
|
||||
evaluator.functions.update({
|
||||
'len': len,
|
||||
'bool': bool,
|
||||
'abs': abs,
|
||||
'min': min,
|
||||
'max': max,
|
||||
})
|
||||
|
||||
return evaluator
|
||||
|
||||
def _extract_expression(self, condition):
|
||||
"""
|
||||
从条件字符串中提取表达式
|
||||
|
||||
Args:
|
||||
condition: 条件字符串,如 "{count > 0}"
|
||||
|
||||
Returns:
|
||||
str: 提取的表达式,如 "count > 0"
|
||||
"""
|
||||
# 去除首尾的 { } 和空白
|
||||
expr = condition.strip()
|
||||
if expr.startswith('{') and expr.endswith('}'):
|
||||
expr = expr[1:-1].strip()
|
||||
return expr
|
||||
|
||||
def evaluate_condition(self, condition, vars_values):
|
||||
"""
|
||||
评估条件表达式
|
||||
|
||||
Args:
|
||||
condition: 条件字符串,如 "{count > 0 and status == 'active'}"
|
||||
vars_values: 变量值字典
|
||||
|
||||
Returns:
|
||||
bool: 条件是否满足
|
||||
|
||||
Raises:
|
||||
YAMLError: 表达式评估失败
|
||||
"""
|
||||
# 提取 {expression} 中的表达式
|
||||
expr = self._extract_expression(condition)
|
||||
|
||||
# 验证表达式长度
|
||||
if len(expr) > self.MAX_EXPRESSION_LENGTH:
|
||||
raise YAMLError(
|
||||
f"条件表达式过长({len(expr)} 字符,最大 {self.MAX_EXPRESSION_LENGTH}): {condition}"
|
||||
)
|
||||
|
||||
# 评估表达式
|
||||
try:
|
||||
evaluator = self._get_evaluator(vars_values)
|
||||
result = evaluator.eval(expr)
|
||||
return bool(result)
|
||||
|
||||
except NameNotDefined as e:
|
||||
# 变量未定义
|
||||
var_name = str(e).split("'")[1] if "'" in str(e) else "unknown"
|
||||
raise YAMLError(
|
||||
f"条件表达式中的变量未定义: {var_name}\n"
|
||||
f"表达式: {condition}\n"
|
||||
f"可用变量: {', '.join(vars_values.keys())}"
|
||||
)
|
||||
|
||||
except FunctionNotDefined as e:
|
||||
# 函数未定义
|
||||
func_name = str(e).split("'")[1] if "'" in str(e) else "unknown"
|
||||
raise YAMLError(
|
||||
f"条件表达式中使用了不支持的函数: {func_name}\n"
|
||||
f"表达式: {condition}\n"
|
||||
f"提示: 仅支持 int(), float(), str(), len(), bool(), abs(), min(), max() 等基本函数"
|
||||
)
|
||||
|
||||
except FeatureNotAvailable as e:
|
||||
# 功能不可用
|
||||
raise YAMLError(
|
||||
f"条件表达式使用了不支持的语法特性\n"
|
||||
f"表达式: {condition}\n"
|
||||
f"错误: {str(e)}\n"
|
||||
f"提示: 不支持函数定义、导入模块、属性访问等高级特性"
|
||||
)
|
||||
|
||||
except AttributeDoesNotExist as e:
|
||||
# 属性不存在(属性访问被禁止)
|
||||
raise YAMLError(
|
||||
f"条件表达式使用了不支持的语法特性\n"
|
||||
f"表达式: {condition}\n"
|
||||
f"错误: {str(e)}\n"
|
||||
f"提示: 不支持属性访问,请使用字典访问方式"
|
||||
)
|
||||
|
||||
except SyntaxError as e:
|
||||
# 语法错误
|
||||
raise YAMLError(
|
||||
f"条件表达式语法错误\n"
|
||||
f"表达式: {condition}\n"
|
||||
f"错误: {str(e)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 其他错误
|
||||
raise YAMLError(
|
||||
f"条件表达式评估失败\n"
|
||||
f"表达式: {condition}\n"
|
||||
f"错误: {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -41,22 +41,46 @@ 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):
|
||||
"""
|
||||
渲染单个幻灯片
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from loaders.yaml_loader import YAMLError, load_yaml_file, validate_template_yaml
|
||||
from core.condition_evaluator import ConditionEvaluator
|
||||
|
||||
|
||||
class Template:
|
||||
@@ -20,6 +21,9 @@ class Template:
|
||||
template_file: 模板名称(纯文件名,不含路径)
|
||||
templates_dir: 模板文件目录
|
||||
"""
|
||||
# 初始化条件评估器
|
||||
self._condition_evaluator = ConditionEvaluator()
|
||||
|
||||
# 检查是否提供了 templates_dir
|
||||
if templates_dir is None:
|
||||
raise YAMLError(
|
||||
@@ -58,6 +62,40 @@ 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._condition_evaluator = ConditionEvaluator()
|
||||
|
||||
# 解析变量定义
|
||||
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):
|
||||
"""
|
||||
解析单个值中的变量引用
|
||||
@@ -122,27 +160,17 @@ class Template:
|
||||
|
||||
def evaluate_condition(self, condition, vars_values):
|
||||
"""
|
||||
评估条件表达式(简单的存在性检查)
|
||||
评估条件表达式
|
||||
|
||||
Args:
|
||||
condition: 条件字符串,如 "{subtitle != ''}"
|
||||
condition: 条件字符串,如 "{count > 0 and status == 'active'}"
|
||||
vars_values: 变量值字典
|
||||
|
||||
Returns:
|
||||
bool: 条件是否满足
|
||||
"""
|
||||
# 简单实现:检查变量是否非空
|
||||
# 匹配 {var_name != ''} 或 {var_name != ""}
|
||||
pattern = r'\{(\w+)\s*!=\s*[\'\"]{2}\}'
|
||||
match = re.match(pattern, condition)
|
||||
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
value = vars_values.get(var_name, '')
|
||||
return value != ''
|
||||
|
||||
# 默认返回 True
|
||||
return True
|
||||
# 委托给条件评估器
|
||||
return self._condition_evaluator.evaluate_condition(condition, vars_values)
|
||||
|
||||
def render(self, vars_values):
|
||||
"""
|
||||
@@ -155,8 +183,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:
|
||||
|
||||
@@ -78,6 +78,17 @@ def validate_presentation_yaml(data, file_path=""):
|
||||
if not isinstance(data['slides'], list):
|
||||
raise YAMLError(f"{file_path}: 'slides' 必须是一个列表")
|
||||
|
||||
# 验证每个幻灯片的 enabled 字段
|
||||
for i, slide in enumerate(data['slides']):
|
||||
if isinstance(slide, dict) and 'enabled' in slide:
|
||||
if not isinstance(slide['enabled'], bool):
|
||||
raise YAMLError(
|
||||
f"{file_path}: slides[{i}].enabled 必须是布尔值(true 或 false),"
|
||||
f"不支持字符串或条件表达式"
|
||||
)
|
||||
|
||||
# 验证 templates 字段(内联模板)
|
||||
validate_templates_yaml(data, file_path)
|
||||
|
||||
def validate_template_yaml(data, file_path=""):
|
||||
"""
|
||||
@@ -111,3 +122,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'")
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
@@ -0,0 +1,90 @@
|
||||
## Context
|
||||
|
||||
当前系统支持元素级的 `visible` 条件渲染,通过条件表达式(如 `{subtitle != ''}`)动态控制元素是否显示。但在开发和调试过程中,经常需要临时禁用整个幻灯片,当前只能通过注释或删除 YAML 内容来实现,不够便捷。
|
||||
|
||||
现有渲染流程:
|
||||
- `yaml2pptx.py` 主循环遍历所有幻灯片
|
||||
- `Presentation.render_slide()` 渲染单个幻灯片
|
||||
- `Template.render()` 处理模板变量和元素级 `visible` 检查
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 添加页面级 `enabled` 布尔参数,默认为 `true`
|
||||
- 在渲染循环中检查 `enabled`,跳过禁用的幻灯片
|
||||
- 保持与元素级 `visible` 的独立性和兼容性
|
||||
- 向后兼容现有 YAML 文件
|
||||
|
||||
**Non-Goals:**
|
||||
- 不支持 `enabled` 的条件表达式(仅支持布尔值)
|
||||
- 不修改元素级 `visible` 的实现
|
||||
- 不影响预览模式的显示逻辑(本次不涉及)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1: 使用 `enabled` 而非 `visible`
|
||||
|
||||
**选择**: 页面级使用 `enabled` 参数
|
||||
**理由**:
|
||||
- 语义清晰:`enabled` 表示静态开关,`visible` 表示动态条件
|
||||
- 避免混淆:与元素级 `visible` 区分开
|
||||
- 符合直觉:enabled=false 表示禁用整页
|
||||
|
||||
**备选方案**: 使用 `visible` 统一命名
|
||||
- 缺点:语义不清,容易与元素级混淆
|
||||
- 缺点:暗示支持条件表达式,但实际只支持布尔值
|
||||
|
||||
### 决策 2: 默认值为 `true`
|
||||
|
||||
**选择**: 未指定 `enabled` 时默认为 `true`
|
||||
**理由**:
|
||||
- 向后兼容:现有 YAML 文件无需修改
|
||||
- 符合预期:默认行为是渲染所有幻灯片
|
||||
|
||||
### 决策 3: 在主循环检查,而非 `render_slide()`
|
||||
|
||||
**选择**: 在 `yaml2pptx.py` 的主循环中检查 `enabled`
|
||||
**理由**:
|
||||
- 性能:跳过禁用页面,避免不必要的模板加载和渲染
|
||||
- 清晰:页面级控制在页面级处理,不侵入渲染逻辑
|
||||
- 日志:可以在跳过时记录日志
|
||||
|
||||
**备选方案**: 在 `Presentation.render_slide()` 中检查
|
||||
- 缺点:已经进入渲染流程,浪费性能
|
||||
- 缺点:返回值处理复杂(需要返回 None 或特殊标记)
|
||||
|
||||
### 决策 4: 渲染计数逻辑
|
||||
|
||||
**选择**: 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片
|
||||
**理由**:
|
||||
- 日志准确:进度显示反映实际渲染数量
|
||||
- 用户友好:不会显示"处理 3/5"然后跳过 2 个的困惑情况
|
||||
|
||||
**实现**:
|
||||
```python
|
||||
slide_index = 0
|
||||
for i, slide_data in enumerate(slides_data, 1):
|
||||
if not slide_data.get('enabled', True):
|
||||
continue
|
||||
slide_index += 1
|
||||
log_progress(slide_index, total_slides, f"处理幻灯片")
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**风险 1: 用户误用 `enabled` 作为条件渲染**
|
||||
- 描述:用户可能期望 `enabled: "{some_var}"` 支持条件表达式
|
||||
- 缓解:文档明确说明 `enabled` 仅支持布尔值,条件渲染使用元素级 `visible`
|
||||
|
||||
**风险 2: 预览模式下禁用页面的显示**
|
||||
- 描述:预览模式可能需要显示禁用的页面(灰色或标记)
|
||||
- 缓解:本次不涉及预览模式,后续可扩展
|
||||
|
||||
**权衡 1: 简单性 vs 灵活性**
|
||||
- 选择:简单的布尔值而非条件表达式
|
||||
- 理由:满足 90% 的使用场景(调试、版本控制),保持简单
|
||||
|
||||
**权衡 2: 日志详细度**
|
||||
- 当前:跳过禁用页面时不记录日志
|
||||
- 备选:记录"跳过第 X 页(已禁用)"
|
||||
- 选择:不记录,避免日志噪音
|
||||
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
在开发和调试演示文稿时,经常需要临时禁用某些幻灯片而不删除它们。当前系统只有元素级的 `visible` 条件渲染,无法快速禁用整个页面。添加页面级的 `enabled` 参数可以提供简单的开关控制,方便开发调试和版本管理。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在幻灯片定义中添加可选的 `enabled` 布尔参数(默认为 `true`)
|
||||
- 渲染流程中检查 `enabled` 参数,跳过禁用的幻灯片
|
||||
- 保留现有的元素级 `visible` 条件渲染功能
|
||||
- 添加 YAML 验证支持 `enabled` 字段
|
||||
- 更新文档说明页面级和元素级控制的区别
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `slide-enabled-control`: 页面级启用/禁用控制,通过 `enabled` 布尔参数控制整个幻灯片是否渲染
|
||||
|
||||
### Modified Capabilities
|
||||
- `template-system`: 扩展幻灯片定义,添加 `enabled` 字段支持
|
||||
|
||||
## Impact
|
||||
|
||||
- `yaml2pptx.py`: 主渲染循环需要检查 `enabled` 参数
|
||||
- `loaders/yaml_loader.py`: 添加 `enabled` 字段验证
|
||||
- `openspec/specs/template-system/spec.md`: 添加页面级 enabled 需求
|
||||
- 测试文件: 添加 enabled 相关测试用例
|
||||
- 文档: README.md 和 README_DEV.md 需要更新
|
||||
@@ -0,0 +1,73 @@
|
||||
# Slide Enabled Control
|
||||
|
||||
## Purpose
|
||||
|
||||
页面级启用/禁用控制,通过 `enabled` 布尔参数控制整个幻灯片是否渲染。提供简单的静态开关,用于开发调试和版本管理。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 幻灯片必须支持 enabled 参数
|
||||
|
||||
幻灯片定义 SHALL 支持可选的 `enabled` 布尔参数,用于控制该幻灯片是否渲染。
|
||||
|
||||
#### Scenario: 禁用的幻灯片不渲染
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: false`
|
||||
- **THEN** 系统跳过该幻灯片,不渲染到演示文稿中
|
||||
|
||||
#### Scenario: 启用的幻灯片正常渲染
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: true`
|
||||
- **THEN** 系统正常渲染该幻灯片
|
||||
|
||||
#### Scenario: 默认启用幻灯片
|
||||
|
||||
- **WHEN** 幻灯片未定义 `enabled` 字段
|
||||
- **THEN** 系统默认该幻灯片为启用状态,正常渲染
|
||||
|
||||
### Requirement: enabled 参数必须是布尔值
|
||||
|
||||
系统 SHALL 验证 `enabled` 参数的类型为布尔值,不支持条件表达式。
|
||||
|
||||
#### Scenario: 接受布尔值
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: false` 或 `enabled: true`
|
||||
- **THEN** 系统正常处理
|
||||
|
||||
#### Scenario: 拒绝非布尔值
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: "false"` 或 `enabled: 0` 等非布尔值
|
||||
- **THEN** 系统抛出验证错误,提示 enabled 必须是布尔值
|
||||
|
||||
#### Scenario: 拒绝条件表达式
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: "{some_var}"`
|
||||
- **THEN** 系统抛出验证错误,提示 enabled 不支持条件表达式
|
||||
|
||||
### Requirement: enabled 与元素级 visible 独立工作
|
||||
|
||||
系统 SHALL 支持页面级 `enabled` 和元素级 `visible` 同时使用,两者独立判断。
|
||||
|
||||
#### Scenario: enabled 和 visible 共存
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: true`,且模板元素包含 `visible` 条件
|
||||
- **THEN** 系统先检查页面级 enabled,再检查元素级 visible
|
||||
|
||||
#### Scenario: enabled=false 时不检查 visible
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: false`
|
||||
- **THEN** 系统跳过该幻灯片,不执行模板渲染和元素级 visible 检查
|
||||
|
||||
### Requirement: 渲染统计必须准确
|
||||
|
||||
系统 SHALL 只统计实际渲染的幻灯片数量,不包括禁用的幻灯片。
|
||||
|
||||
#### Scenario: 进度显示准确
|
||||
|
||||
- **WHEN** 演示文稿包含 5 个幻灯片,其中 2 个 enabled=false
|
||||
- **THEN** 系统显示"处理幻灯片 1/3"、"处理幻灯片 2/3"、"处理幻灯片 3/3"
|
||||
|
||||
#### Scenario: 最终统计准确
|
||||
|
||||
- **WHEN** 演示文稿包含 5 个幻灯片,其中 2 个 enabled=false
|
||||
- **THEN** 生成的 PPTX 文件包含 3 个幻灯片
|
||||
@@ -0,0 +1,27 @@
|
||||
# Template System (Delta)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 幻灯片定义必须支持 enabled 字段
|
||||
|
||||
幻灯片定义 SHALL 支持可选的 `enabled` 布尔字段,用于控制该幻灯片是否渲染。该字段与模板系统的其他字段(template、vars、elements、background)独立工作。
|
||||
|
||||
#### Scenario: 幻灯片包含 enabled 字段
|
||||
|
||||
- **WHEN** 幻灯片定义包含 `enabled: false` 或 `enabled: true`
|
||||
- **THEN** 系统正常加载幻灯片定义,并在渲染时检查该字段
|
||||
|
||||
#### Scenario: enabled 字段可选
|
||||
|
||||
- **WHEN** 幻灯片定义未包含 `enabled` 字段
|
||||
- **THEN** 系统默认该幻灯片为启用状态
|
||||
|
||||
#### Scenario: enabled 与 template 共存
|
||||
|
||||
- **WHEN** 幻灯片同时定义了 `enabled: false` 和 `template: title-slide`
|
||||
- **THEN** 系统跳过该幻灯片,不加载模板
|
||||
|
||||
#### Scenario: enabled 与自定义幻灯片共存
|
||||
|
||||
- **WHEN** 自定义幻灯片(不使用模板)定义了 `enabled: false`
|
||||
- **THEN** 系统跳过该幻灯片,不渲染元素列表
|
||||
@@ -0,0 +1,33 @@
|
||||
## 1. YAML 验证支持
|
||||
|
||||
- [x] 1.1 在 loaders/yaml_loader.py 中添加 enabled 字段验证(布尔类型,可选)
|
||||
- [x] 1.2 添加 enabled 字段类型检查,拒绝非布尔值
|
||||
- [x] 1.3 添加测试用例:test_slide_enabled_validation
|
||||
|
||||
## 2. 渲染逻辑修改
|
||||
|
||||
- [x] 2.1 修改 yaml2pptx.py 主循环,添加 enabled 检查
|
||||
- [x] 2.2 实现独立的 slide_index 计数器,只统计实际渲染的幻灯片
|
||||
- [x] 2.3 确保 log_progress 显示准确的渲染进度
|
||||
|
||||
## 3. 测试用例
|
||||
|
||||
- [x] 3.1 添加测试:test_slide_enabled_false(禁用幻灯片被跳过)
|
||||
- [x] 3.2 添加测试:test_slide_enabled_default_true(默认启用)
|
||||
- [x] 3.3 添加测试:test_slide_enabled_with_template(enabled 与模板共存)
|
||||
- [x] 3.4 添加测试:test_slide_enabled_with_custom_slide(enabled 与自定义幻灯片共存)
|
||||
- [x] 3.5 添加测试:test_slide_enabled_with_element_visible(enabled 和 visible 共存)
|
||||
- [x] 3.6 添加测试:test_slide_enabled_count(渲染统计准确)
|
||||
- [x] 3.7 添加测试:test_slide_enabled_invalid_type(拒绝非布尔值)
|
||||
|
||||
## 4. 文档更新
|
||||
|
||||
- [x] 4.1 更新 README.md,添加 enabled 参数说明和使用示例
|
||||
- [x] 4.2 更新 README_DEV.md,说明 enabled 的实现细节
|
||||
- [x] 4.3 在文档中明确 enabled(静态开关)和 visible(动态条件)的区别
|
||||
|
||||
## 5. 集成测试
|
||||
|
||||
- [x] 5.1 创建测试 YAML 文件,包含启用和禁用的幻灯片
|
||||
- [x] 5.2 运行完整转换流程,验证生成的 PPTX 文件正确
|
||||
- [x] 5.3 验证日志输出准确反映实际渲染的幻灯片数量
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
@@ -0,0 +1,263 @@
|
||||
## Context
|
||||
|
||||
当前的模板系统支持条件渲染功能,但实现非常简单,仅通过正则表达式匹配 `{var != ''}` 格式来判断变量是否非空。这种实现无法满足实际使用中的复杂需求,如多条件组合、数值比较、成员测试等。
|
||||
|
||||
现有实现位于 `core/template.py` 的 `evaluate_condition` 方法,使用简单的正则匹配:
|
||||
```python
|
||||
pattern = r'\{(\w+)\s*!=\s*[\'\"]{2}\}'
|
||||
match = re.match(pattern, condition)
|
||||
```
|
||||
|
||||
用户反馈需要更强大的条件表达式能力,但直接使用 Python 的 `eval()` 存在严重的安全风险。我们需要一个既强大又安全的表达式评估方案。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 提供强大的条件表达式能力,支持比较、逻辑、成员测试、数学运算
|
||||
- 确保表达式评估的安全性,防止代码注入和恶意操作
|
||||
- 提供清晰的错误信息,帮助用户快速定位问题
|
||||
- 保持 API 简洁,对现有代码的侵入性最小
|
||||
- 实现高性能的表达式评估,不影响模板渲染速度
|
||||
|
||||
**Non-Goals:**
|
||||
- 不支持函数定义、类定义等复杂 Python 特性
|
||||
- 不支持模块导入和文件操作
|
||||
- 不支持对象属性访问(避免安全风险)
|
||||
- 不考虑旧版本语法的向后兼容(用户明确要求)
|
||||
- 不实现自定义的表达式解析器(使用成熟的第三方库)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1: 使用 simpleeval 作为表达式评估引擎
|
||||
|
||||
**选择**: simpleeval (EvalWithCompoundTypes)
|
||||
|
||||
**理由**:
|
||||
- 成熟稳定:483 stars,维护活跃,社区认可度高
|
||||
- 安全性好:基于 AST 解析,不使用 `eval()`,有明确的安全边界
|
||||
- 功能适中:支持我们需要的所有操作符和表达式类型
|
||||
- 轻量级:单文件库,无额外依赖,不增加项目复杂度
|
||||
- 易于集成:API 简单直观,集成成本低
|
||||
|
||||
**备选方案**:
|
||||
1. **evalidate**: 性能更好(快 3-5 倍),但社区较小,文档较少
|
||||
2. **asteval**: 功能更强大,但过于复杂,性能较差,不适合简单条件判断
|
||||
3. **自实现**: 完全可控,但开发成本高,需要大量测试,容易出现安全漏洞
|
||||
|
||||
**权衡**: simpleeval 在功能、安全性、易用性之间达到了最佳平衡。
|
||||
|
||||
### 决策 2: 使用 EvalWithCompoundTypes 而非 SimpleEval
|
||||
|
||||
**选择**: EvalWithCompoundTypes
|
||||
|
||||
**理由**:
|
||||
- 支持列表和元组字面量,允许 `status in ['draft', 'review']` 这样的表达式
|
||||
- 对于条件判断场景,列表/元组是常见需求
|
||||
- 安全性与 SimpleEval 相同,只是增加了复合类型支持
|
||||
|
||||
**API 差异**:
|
||||
```python
|
||||
# SimpleEval
|
||||
simple_eval(expr, names=vars_values)
|
||||
|
||||
# EvalWithCompoundTypes
|
||||
evaluator = EvalWithCompoundTypes(names=vars_values)
|
||||
evaluator.eval(expr)
|
||||
```
|
||||
|
||||
### 决策 3: 每次评估创建新的 evaluator 实例
|
||||
|
||||
**选择**: 每次调用 `evaluate_condition` 时创建新的 EvalWithCompoundTypes 实例
|
||||
|
||||
**理由**:
|
||||
- 避免状态污染:不同模板渲染之间完全隔离
|
||||
- 线程安全:每个评估独立,无共享状态
|
||||
- 简化实现:不需要管理 evaluator 的生命周期
|
||||
|
||||
**性能考虑**:
|
||||
- EvalWithCompoundTypes 实例化成本很低
|
||||
- 表达式评估本身的开销远大于实例化开销
|
||||
- 对于典型的模板渲染场景(几十个元素),性能影响可忽略
|
||||
|
||||
### 决策 4: 扩展白名单函数
|
||||
|
||||
**选择**: 在 simpleeval 默认函数基础上,添加常用的安全函数
|
||||
|
||||
**白名单函数**:
|
||||
- 类型转换:`int()`, `float()`, `str()`, `bool()`
|
||||
- 数学函数:`abs()`, `min()`, `max()`
|
||||
- 容器函数:`len()`
|
||||
|
||||
**理由**:
|
||||
- 这些函数在条件判断中常用
|
||||
- 都是纯函数,无副作用,安全性高
|
||||
- simpleeval 默认只提供 `int`, `float`, `str`,需要补充
|
||||
|
||||
**不添加的函数**:
|
||||
- 文件操作:`open()`, `read()`, `write()`
|
||||
- 系统操作:`exec()`, `eval()`, `compile()`
|
||||
- 反射操作:`getattr()`, `setattr()`, `hasattr()`
|
||||
|
||||
### 决策 5: 实现独立的 ConditionEvaluator 类
|
||||
|
||||
**选择**: 创建独立的 `ConditionEvaluator` 类,而不是直接在 Template 类中实现
|
||||
|
||||
**架构**:
|
||||
```
|
||||
Template
|
||||
└─ ConditionEvaluator
|
||||
└─ EvalWithCompoundTypes (simpleeval)
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 单一职责:Template 负责模板渲染,ConditionEvaluator 负责条件评估
|
||||
- 易于测试:可以独立测试条件评估逻辑
|
||||
- 易于扩展:未来可以轻松替换评估引擎或添加新功能
|
||||
- 代码清晰:避免 Template 类过于臃肿
|
||||
|
||||
### 决策 6: 错误处理策略
|
||||
|
||||
**选择**: 捕获 simpleeval 的所有异常,转换为用户友好的 YAMLError
|
||||
|
||||
**错误映射**:
|
||||
```python
|
||||
NameNotDefined → "条件表达式中的变量未定义: {var_name}"
|
||||
FunctionNotDefined → "条件表达式中使用了不支持的函数: {func_name}"
|
||||
FeatureNotAvailable → "条件表达式使用了不支持的语法特性"
|
||||
SyntaxError → "条件表达式语法错误"
|
||||
```
|
||||
|
||||
**错误信息包含**:
|
||||
- 原始表达式
|
||||
- 具体的错误原因
|
||||
- 可用的变量列表(对于 NameNotDefined)
|
||||
- 支持的函数列表(对于 FunctionNotDefined)
|
||||
- 修复建议
|
||||
|
||||
**理由**:
|
||||
- 用户不需要了解 simpleeval 的内部实现
|
||||
- 错误信息更具体,更容易调试
|
||||
- 保持与现有错误处理风格一致
|
||||
|
||||
### 决策 7: 表达式安全限制
|
||||
|
||||
**选择**: 实施多层安全限制
|
||||
|
||||
**限制措施**:
|
||||
1. **长度限制**: 表达式最大 500 字符
|
||||
2. **白名单函数**: 仅允许预定义的安全函数
|
||||
3. **禁止特性**:
|
||||
- 属性访问(`obj.attr`)
|
||||
- 函数定义(`lambda`, `def`)
|
||||
- 模块导入(`import`)
|
||||
- 赋值操作(`=`, `+=`)
|
||||
|
||||
**理由**:
|
||||
- 长度限制防止过于复杂的表达式,也防止 DOS 攻击
|
||||
- simpleeval 默认已禁止大部分危险操作
|
||||
- 额外的白名单限制提供双重保护
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险 1: simpleeval 的安全漏洞
|
||||
|
||||
**风险**: simpleeval 可能存在未知的安全漏洞,允许恶意代码执行
|
||||
|
||||
**缓解措施**:
|
||||
- simpleeval 是成熟的开源项目,经过广泛使用和审查
|
||||
- 我们添加了额外的长度限制和白名单限制
|
||||
- 表达式来源是用户自己的 YAML 文件,不是外部不可信输入
|
||||
- 定期更新 simpleeval 到最新版本
|
||||
|
||||
**残留风险**: 低。对于本项目的使用场景(用户编写自己的模板),风险可接受。
|
||||
|
||||
### 风险 2: 性能影响
|
||||
|
||||
**风险**: simpleeval 的表达式评估可能比简单的正则匹配慢,影响模板渲染性能
|
||||
|
||||
**缓解措施**:
|
||||
- 实际测试表明,simpleeval 的性能足够好(每秒可评估数万次)
|
||||
- 对于典型的演示文稿(几十个幻灯片,每个几十个元素),性能影响可忽略
|
||||
- 如果未来性能成为瓶颈,可以考虑:
|
||||
- 缓存编译后的表达式(simpleeval 支持)
|
||||
- 切换到 evalidate(性能更好)
|
||||
|
||||
**残留风险**: 极低。当前性能完全满足需求。
|
||||
|
||||
### 风险 3: 用户学习成本
|
||||
|
||||
**风险**: 用户需要学习新的表达式语法,可能不熟悉 Python 表达式
|
||||
|
||||
**缓解措施**:
|
||||
- Python 表达式语法简单直观,学习成本低
|
||||
- 提供详细的文档和示例
|
||||
- 错误信息清晰,帮助用户快速定位问题
|
||||
- 旧的简单语法(`{var != ''}`)在新实现中仍然有效
|
||||
|
||||
**残留风险**: 低。Python 表达式是业界标准,大多数开发者都熟悉。
|
||||
|
||||
### 权衡 1: 功能 vs 安全性
|
||||
|
||||
**权衡**: 为了安全性,我们禁止了一些 Python 特性(如属性访问、函数定义)
|
||||
|
||||
**影响**: 用户无法使用这些高级特性,但对于条件判断场景,当前支持的特性已经足够
|
||||
|
||||
**决策**: 安全性优先。如果未来有明确的需求,可以考虑有限地开放某些特性。
|
||||
|
||||
### 权衡 2: 向后兼容 vs 代码简洁
|
||||
|
||||
**权衡**: 用户明确要求不考虑向后兼容,我们可以直接移除旧的正则匹配实现
|
||||
|
||||
**影响**:
|
||||
- 代码更简洁,维护成本更低
|
||||
- 旧的简单语法(`{var != ''}`)在新实现中仍然有效,实际兼容性影响很小
|
||||
|
||||
**决策**: 移除旧实现,统一使用 simpleeval。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 实施步骤
|
||||
|
||||
1. **安装依赖**
|
||||
```bash
|
||||
uv add simpleeval
|
||||
```
|
||||
|
||||
2. **实现 ConditionEvaluator 类**
|
||||
- 创建 `core/condition_evaluator.py`
|
||||
- 实现 `evaluate_condition` 方法
|
||||
- 实现错误处理和安全限制
|
||||
|
||||
3. **集成到 Template 类**
|
||||
- 在 `Template.__init__` 中初始化 ConditionEvaluator
|
||||
- 替换 `evaluate_condition` 方法的实现
|
||||
- 移除旧的正则匹配代码
|
||||
|
||||
4. **更新测试**
|
||||
- 扩展 `tests/unit/test_template.py` 中的条件渲染测试
|
||||
- 添加新的表达式类型测试
|
||||
- 添加错误处理测试
|
||||
- 添加安全限制测试
|
||||
|
||||
5. **更新文档**
|
||||
- 更新 README.md 的条件渲染章节
|
||||
- 添加表达式语法参考
|
||||
- 更新 README_DEV.md 的架构说明
|
||||
|
||||
6. **验证和发布**
|
||||
- 运行完整测试套件
|
||||
- 手动测试各种表达式场景
|
||||
- 更新版本号
|
||||
|
||||
### 回滚策略
|
||||
|
||||
如果发现严重问题,可以快速回滚:
|
||||
1. 恢复 `core/template.py` 中的旧 `evaluate_condition` 实现
|
||||
2. 移除 simpleeval 依赖
|
||||
3. 恢复旧的测试用例
|
||||
|
||||
**回滚成本**: 低。改动集中在单个文件,易于回滚。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。所有关键决策已明确。
|
||||
@@ -0,0 +1,48 @@
|
||||
## Why
|
||||
|
||||
当前的 visible 条件渲染功能仅支持简单的非空检查(`{var != ''}`),无法满足实际使用中的复杂条件判断需求。用户需要根据多个变量、逻辑组合、数值比较等条件来控制元素显示,但现有实现过于受限。通过引入成熟的表达式评估库 simpleeval,可以提供强大且安全的条件表达式能力,显著提升模板系统的灵活性。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 引入 simpleeval 库作为条件表达式评估引擎
|
||||
- 扩展 visible 字段支持的表达式类型:
|
||||
- 比较运算符:`==`, `!=`, `>`, `<`, `>=`, `<=`
|
||||
- 逻辑运算符:`and`, `or`, `not`
|
||||
- 成员测试:`in`, `not in`
|
||||
- 列表/元组字面量:`status in ['draft', 'review']`
|
||||
- 数学运算:`+`, `-`, `*`, `/`, `%`, `**`
|
||||
- 内置函数:`int()`, `float()`, `str()`, `len()`, `bool()`
|
||||
- 增强错误处理,提供详细的错误信息和调试提示
|
||||
- 添加表达式安全限制(最大长度、禁止危险操作)
|
||||
- **BREAKING**: 移除旧版本的简单正则匹配实现,统一使用 simpleeval
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `enhanced-condition-evaluation`: 基于 simpleeval 的增强条件表达式评估能力,支持复杂的逻辑判断、数学运算和成员测试
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `template-system`: 修改模板系统的条件渲染实现,从简单正则匹配升级为完整的表达式评估
|
||||
|
||||
## Impact
|
||||
|
||||
- **代码影响**:
|
||||
- `core/template.py`: 重写 `evaluate_condition` 方法
|
||||
- `loaders/yaml_loader.py`: 可能需要更新验证逻辑
|
||||
- 测试文件:需要大幅扩展测试用例覆盖新的表达式类型
|
||||
|
||||
- **依赖影响**:
|
||||
- 新增依赖:simpleeval (轻量级,无额外依赖)
|
||||
- 需要更新 `pyproject.toml`
|
||||
|
||||
- **用户影响**:
|
||||
- **BREAKING**: 旧的简单实现被移除,但由于旧语法 `{var != ''}` 在新实现中仍然有效,实际兼容性影响较小
|
||||
- 用户需要学习新的表达式语法(但与 Python 表达式一致,学习成本低)
|
||||
- 错误信息更详细,调试更容易
|
||||
|
||||
- **文档影响**:
|
||||
- README.md: 需要更新条件渲染章节,添加新的表达式示例
|
||||
- README_DEV.md: 需要说明 simpleeval 集成和安全策略
|
||||
- 需要添加完整的表达式语法参考文档
|
||||
@@ -0,0 +1,179 @@
|
||||
# Enhanced Condition Evaluation
|
||||
|
||||
## Purpose
|
||||
|
||||
增强条件评估能力提供基于 simpleeval 的安全表达式评估引擎,支持复杂的条件判断、逻辑运算、数学计算和成员测试。该能力用于模板系统的 visible 字段,允许用户使用 Python 风格的表达式来控制元素的显示条件。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 系统必须支持比较运算符
|
||||
|
||||
系统 SHALL 支持所有标准的比较运算符,用于数值和字符串的比较。
|
||||
|
||||
#### Scenario: 数值大于比较
|
||||
|
||||
- **WHEN** 表达式为 `{count > 0}` 且变量 `count` 的值为 5
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 数值小于等于比较
|
||||
|
||||
- **WHEN** 表达式为 `{price <= 100}` 且变量 `price` 的值为 100
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 字符串相等比较
|
||||
|
||||
- **WHEN** 表达式为 `{status == 'active'}` 且变量 `status` 的值为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 字符串不等比较
|
||||
|
||||
- **WHEN** 表达式为 `{status != 'inactive'}` 且变量 `status` 的值为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持逻辑运算符
|
||||
|
||||
系统 SHALL 支持逻辑运算符 and、or、not,用于组合多个条件。
|
||||
|
||||
#### Scenario: 逻辑与运算
|
||||
|
||||
- **WHEN** 表达式为 `{count > 0 and status == 'active'}` 且变量 `count` 为 5,`status` 为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 逻辑或运算
|
||||
|
||||
- **WHEN** 表达式为 `{count > 0 or status == 'active'}` 且变量 `count` 为 0,`status` 为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 逻辑非运算
|
||||
|
||||
- **WHEN** 表达式为 `{not (count == 0)}` 且变量 `count` 为 5
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 复杂逻辑组合
|
||||
|
||||
- **WHEN** 表达式为 `{(score >= 60 and score <= 100) or is_admin}` 且变量 `score` 为 75,`is_admin` 为 False
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持成员测试
|
||||
|
||||
系统 SHALL 支持 in 和 not in 运算符,用于测试值是否在集合中。
|
||||
|
||||
#### Scenario: 列表成员测试
|
||||
|
||||
- **WHEN** 表达式为 `{status in ['draft', 'review', 'published']}` 且变量 `status` 为 "draft"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 列表非成员测试
|
||||
|
||||
- **WHEN** 表达式为 `{status not in ['draft', 'review']}` 且变量 `status` 为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 字符串包含测试
|
||||
|
||||
- **WHEN** 表达式为 `{'test' in status}` 且变量 `status` 为 "test-version"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 元组成员测试
|
||||
|
||||
- **WHEN** 表达式为 `{level in (1, 2, 3)}` 且变量 `level` 为 2
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持数学运算
|
||||
|
||||
系统 SHALL 支持基本的数学运算符,用于条件判断中的计算。
|
||||
|
||||
#### Scenario: 加法运算
|
||||
|
||||
- **WHEN** 表达式为 `{(price + tax) > 100}` 且变量 `price` 为 90,`tax` 为 15
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 乘法运算
|
||||
|
||||
- **WHEN** 表达式为 `{(price * discount) > 50}` 且变量 `price` 为 100,`discount` 为 0.8
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 除法运算
|
||||
|
||||
- **WHEN** 表达式为 `{(total / count) >= 10}` 且变量 `total` 为 100,`count` 为 5
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持内置函数
|
||||
|
||||
系统 SHALL 支持安全的内置函数,用于类型转换和基本操作。
|
||||
|
||||
#### Scenario: 类型转换函数
|
||||
|
||||
- **WHEN** 表达式为 `{int(value) > 100}` 且变量 `value` 为 "150"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 长度函数
|
||||
|
||||
- **WHEN** 表达式为 `{len(items) > 0}` 且变量 `items` 为 [1, 2, 3]
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 布尔转换函数
|
||||
|
||||
- **WHEN** 表达式为 `{bool(value)}` 且变量 `value` 为 "text"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须提供详细的错误信息
|
||||
|
||||
系统 SHALL 在表达式评估失败时提供清晰的错误信息,帮助用户快速定位问题。
|
||||
|
||||
#### Scenario: 变量未定义错误
|
||||
|
||||
- **WHEN** 表达式为 `{undefined_var > 0}` 但变量 `undefined_var` 未提供
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中的变量未定义: undefined_var",并列出可用变量
|
||||
|
||||
#### Scenario: 函数未定义错误
|
||||
|
||||
- **WHEN** 表达式为 `{custom_func(value)}` 但函数 `custom_func` 不在白名单中
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中使用了不支持的函数: custom_func",并列出支持的函数
|
||||
|
||||
#### Scenario: 语法错误
|
||||
|
||||
- **WHEN** 表达式为 `{count > }` 包含语法错误
|
||||
- **THEN** 系统抛出错误,提示"条件表达式语法错误",并显示具体的语法问题
|
||||
|
||||
### Requirement: 系统必须实施安全限制
|
||||
|
||||
系统 SHALL 限制表达式的复杂度和能力,防止恶意或危险的操作。
|
||||
|
||||
#### Scenario: 表达式长度限制
|
||||
|
||||
- **WHEN** 表达式长度超过 500 字符
|
||||
- **THEN** 系统抛出错误,提示"条件表达式过长"
|
||||
|
||||
#### Scenario: 禁止属性访问
|
||||
|
||||
- **WHEN** 表达式为 `{obj.attr}` 尝试访问对象属性
|
||||
- **THEN** 系统抛出错误,提示"不支持的语法特性"
|
||||
|
||||
#### Scenario: 禁止函数定义
|
||||
|
||||
- **WHEN** 表达式包含 lambda 或 def 关键字
|
||||
- **THEN** 系统抛出错误,提示"不支持的语法特性"
|
||||
|
||||
#### Scenario: 白名单函数限制
|
||||
|
||||
- **WHEN** 表达式使用了不在白名单中的函数(如 eval、exec、open)
|
||||
- **THEN** 系统拒绝执行,抛出错误
|
||||
|
||||
### Requirement: 系统必须支持表达式提取
|
||||
|
||||
系统 SHALL 能够从 `{expression}` 格式的字符串中提取实际的表达式内容。
|
||||
|
||||
#### Scenario: 提取带花括号的表达式
|
||||
|
||||
- **WHEN** 条件字符串为 `{count > 0}`
|
||||
- **THEN** 系统提取出表达式 `count > 0`
|
||||
|
||||
#### Scenario: 提取带空白的表达式
|
||||
|
||||
- **WHEN** 条件字符串为 `{ count > 0 }`
|
||||
- **THEN** 系统提取出表达式 `count > 0`(去除首尾空白)
|
||||
|
||||
#### Scenario: 处理不带花括号的表达式
|
||||
|
||||
- **WHEN** 条件字符串为 `count > 0`(不带花括号)
|
||||
- **THEN** 系统直接使用该字符串作为表达式
|
||||
@@ -0,0 +1,52 @@
|
||||
# Template System (Delta Spec)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 系统必须支持条件渲染
|
||||
|
||||
系统 SHALL 支持基于变量值的条件渲染,通过 `visible` 字段控制元素是否显示。条件表达式使用 simpleeval 引擎评估,支持复杂的逻辑判断、比较运算、成员测试和数学计算。
|
||||
|
||||
#### Scenario: 显示满足条件的元素
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{count > 0}"`,且用户提供的 count 大于 0
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 隐藏不满足条件的元素
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{count > 0}"`,但用户提供的 count 等于 0
|
||||
- **THEN** 系统跳过该元素,不渲染到幻灯片中
|
||||
|
||||
#### Scenario: 复杂逻辑条件
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{count > 0 and status == 'active'}"`,且两个条件都满足
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 成员测试条件
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{status in ['draft', 'review']}"`,且 status 为 "draft"
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 数学运算条件
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{(price * discount) > 50}"`,且计算结果大于 50
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 条件表达式语法错误
|
||||
|
||||
- **WHEN** visible 字段包含无效的条件表达式(如 `{count > }`)
|
||||
- **THEN** 系统抛出错误,提示"条件表达式语法错误",并显示具体的语法问题
|
||||
|
||||
#### Scenario: 条件表达式中的变量未定义
|
||||
|
||||
- **WHEN** visible 字段引用了未定义的变量(如 `{undefined_var > 0}`)
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中的变量未定义: undefined_var",并列出可用变量
|
||||
|
||||
#### Scenario: 条件表达式使用不支持的函数
|
||||
|
||||
- **WHEN** visible 字段使用了不在白名单中的函数(如 `{eval(code)}`)
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中使用了不支持的函数: eval"
|
||||
|
||||
#### Scenario: 向后兼容的简单表达式
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{subtitle != ''}"`(旧语法格式)
|
||||
- **THEN** 系统使用新的 simpleeval 引擎正确评估该表达式
|
||||
@@ -0,0 +1,55 @@
|
||||
## 1. 依赖和环境准备
|
||||
|
||||
- [x] 1.1 确认 simpleeval 已安装(已在探索阶段完成)
|
||||
- [x] 1.2 验证 simpleeval 版本和功能可用性
|
||||
|
||||
## 2. 核心实现
|
||||
|
||||
- [x] 2.1 创建 core/condition_evaluator.py 文件
|
||||
- [x] 2.2 实现 ConditionEvaluator 类的基本结构
|
||||
- [x] 2.3 实现 _get_evaluator 方法,配置 EvalWithCompoundTypes
|
||||
- [x] 2.4 实现 _extract_expression 方法,提取表达式内容
|
||||
- [x] 2.5 实现 evaluate_condition 方法,集成 simpleeval
|
||||
- [x] 2.6 实现错误处理,映射 simpleeval 异常到 YAMLError
|
||||
- [x] 2.7 添加表达式长度限制(MAX_EXPRESSION_LENGTH = 500)
|
||||
- [x] 2.8 配置白名单函数(len, bool, abs, min, max)
|
||||
|
||||
## 3. 集成到 Template 类
|
||||
|
||||
- [x] 3.1 在 Template.__init__ 中初始化 ConditionEvaluator
|
||||
- [x] 3.2 修改 Template.evaluate_condition 方法,委托给 ConditionEvaluator
|
||||
- [x] 3.3 移除旧的正则匹配实现代码
|
||||
- [x] 3.4 确保 Template.render 方法正确调用新的 evaluate_condition
|
||||
|
||||
## 4. 测试实现
|
||||
|
||||
- [x] 4.1 创建 tests/unit/test_condition_evaluator.py 文件
|
||||
- [x] 4.2 测试比较运算符(==, !=, >, <, >=, <=)
|
||||
- [x] 4.3 测试逻辑运算符(and, or, not)
|
||||
- [x] 4.4 测试成员测试(in, not in)
|
||||
- [x] 4.5 测试列表和元组字面量
|
||||
- [x] 4.6 测试数学运算(+, -, *, /, %, **)
|
||||
- [x] 4.7 测试内置函数(int, float, str, len, bool, abs, min, max)
|
||||
- [x] 4.8 测试复杂逻辑组合表达式
|
||||
- [x] 4.9 测试错误处理(变量未定义、函数未定义、语法错误)
|
||||
- [x] 4.10 测试安全限制(表达式过长、禁止的特性)
|
||||
- [x] 4.11 更新 tests/unit/test_template.py 中的条件渲染测试
|
||||
- [x] 4.12 添加集成测试,验证完整的模板渲染流程
|
||||
|
||||
## 5. 文档更新
|
||||
|
||||
- [x] 5.1 更新 README.md 的条件渲染章节
|
||||
- [x] 5.2 添加新的表达式语法示例到 README.md
|
||||
- [x] 5.3 添加支持的操作符和函数列表到 README.md
|
||||
- [x] 5.4 更新 README_DEV.md,说明 ConditionEvaluator 架构
|
||||
- [x] 5.5 更新 README_DEV.md,说明 simpleeval 集成和安全策略
|
||||
- [x] 5.6 创建或更新示例文件,展示新的条件表达式能力
|
||||
|
||||
## 6. 验证和清理
|
||||
|
||||
- [x] 6.1 运行完整测试套件,确保所有测试通过
|
||||
- [x] 6.2 手动测试各种表达式场景
|
||||
- [x] 6.3 验证错误信息的清晰度和有用性
|
||||
- [x] 6.4 检查代码风格和注释完整性
|
||||
- [x] 6.5 清理 temp 目录中的临时文件(保留有用的示例)
|
||||
- [x] 6.6 确认没有遗留的调试代码或注释
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
185
openspec/changes/archive/2026-03-03-inline-templates/design.md
Normal file
185
openspec/changes/archive/2026-03-03-inline-templates/design.md
Normal 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
|
||||
|
||||
无。设计阶段已明确所有关键决策。
|
||||
@@ -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,说明内联模板的语法和使用方法
|
||||
@@ -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 验证通过,元素在渲染时正常工作
|
||||
@@ -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 验证功能正常
|
||||
179
openspec/specs/enhanced-condition-evaluation/spec.md
Normal file
179
openspec/specs/enhanced-condition-evaluation/spec.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Enhanced Condition Evaluation
|
||||
|
||||
## Purpose
|
||||
|
||||
增强条件评估能力提供基于 simpleeval 的安全表达式评估引擎,支持复杂的条件判断、逻辑运算、数学计算和成员测试。该能力用于模板系统的 visible 字段,允许用户使用 Python 风格的表达式来控制元素的显示条件。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 系统必须支持比较运算符
|
||||
|
||||
系统 SHALL 支持所有标准的比较运算符,用于数值和字符串的比较。
|
||||
|
||||
#### Scenario: 数值大于比较
|
||||
|
||||
- **WHEN** 表达式为 `{count > 0}` 且变量 `count` 的值为 5
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 数值小于等于比较
|
||||
|
||||
- **WHEN** 表达式为 `{price <= 100}` 且变量 `price` 的值为 100
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 字符串相等比较
|
||||
|
||||
- **WHEN** 表达式为 `{status == 'active'}` 且变量 `status` 的值为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 字符串不等比较
|
||||
|
||||
- **WHEN** 表达式为 `{status != 'inactive'}` 且变量 `status` 的值为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持逻辑运算符
|
||||
|
||||
系统 SHALL 支持逻辑运算符 and、or、not,用于组合多个条件。
|
||||
|
||||
#### Scenario: 逻辑与运算
|
||||
|
||||
- **WHEN** 表达式为 `{count > 0 and status == 'active'}` 且变量 `count` 为 5,`status` 为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 逻辑或运算
|
||||
|
||||
- **WHEN** 表达式为 `{count > 0 or status == 'active'}` 且变量 `count` 为 0,`status` 为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 逻辑非运算
|
||||
|
||||
- **WHEN** 表达式为 `{not (count == 0)}` 且变量 `count` 为 5
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 复杂逻辑组合
|
||||
|
||||
- **WHEN** 表达式为 `{(score >= 60 and score <= 100) or is_admin}` 且变量 `score` 为 75,`is_admin` 为 False
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持成员测试
|
||||
|
||||
系统 SHALL 支持 in 和 not in 运算符,用于测试值是否在集合中。
|
||||
|
||||
#### Scenario: 列表成员测试
|
||||
|
||||
- **WHEN** 表达式为 `{status in ['draft', 'review', 'published']}` 且变量 `status` 为 "draft"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 列表非成员测试
|
||||
|
||||
- **WHEN** 表达式为 `{status not in ['draft', 'review']}` 且变量 `status` 为 "active"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 字符串包含测试
|
||||
|
||||
- **WHEN** 表达式为 `{'test' in status}` 且变量 `status` 为 "test-version"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 元组成员测试
|
||||
|
||||
- **WHEN** 表达式为 `{level in (1, 2, 3)}` 且变量 `level` 为 2
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持数学运算
|
||||
|
||||
系统 SHALL 支持基本的数学运算符,用于条件判断中的计算。
|
||||
|
||||
#### Scenario: 加法运算
|
||||
|
||||
- **WHEN** 表达式为 `{(price + tax) > 100}` 且变量 `price` 为 90,`tax` 为 15
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 乘法运算
|
||||
|
||||
- **WHEN** 表达式为 `{(price * discount) > 50}` 且变量 `price` 为 100,`discount` 为 0.8
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 除法运算
|
||||
|
||||
- **WHEN** 表达式为 `{(total / count) >= 10}` 且变量 `total` 为 100,`count` 为 5
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须支持内置函数
|
||||
|
||||
系统 SHALL 支持安全的内置函数,用于类型转换和基本操作。
|
||||
|
||||
#### Scenario: 类型转换函数
|
||||
|
||||
- **WHEN** 表达式为 `{int(value) > 100}` 且变量 `value` 为 "150"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 长度函数
|
||||
|
||||
- **WHEN** 表达式为 `{len(items) > 0}` 且变量 `items` 为 [1, 2, 3]
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
#### Scenario: 布尔转换函数
|
||||
|
||||
- **WHEN** 表达式为 `{bool(value)}` 且变量 `value` 为 "text"
|
||||
- **THEN** 表达式评估结果为 True
|
||||
|
||||
### Requirement: 系统必须提供详细的错误信息
|
||||
|
||||
系统 SHALL 在表达式评估失败时提供清晰的错误信息,帮助用户快速定位问题。
|
||||
|
||||
#### Scenario: 变量未定义错误
|
||||
|
||||
- **WHEN** 表达式为 `{undefined_var > 0}` 但变量 `undefined_var` 未提供
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中的变量未定义: undefined_var",并列出可用变量
|
||||
|
||||
#### Scenario: 函数未定义错误
|
||||
|
||||
- **WHEN** 表达式为 `{custom_func(value)}` 但函数 `custom_func` 不在白名单中
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中使用了不支持的函数: custom_func",并列出支持的函数
|
||||
|
||||
#### Scenario: 语法错误
|
||||
|
||||
- **WHEN** 表达式为 `{count > }` 包含语法错误
|
||||
- **THEN** 系统抛出错误,提示"条件表达式语法错误",并显示具体的语法问题
|
||||
|
||||
### Requirement: 系统必须实施安全限制
|
||||
|
||||
系统 SHALL 限制表达式的复杂度和能力,防止恶意或危险的操作。
|
||||
|
||||
#### Scenario: 表达式长度限制
|
||||
|
||||
- **WHEN** 表达式长度超过 500 字符
|
||||
- **THEN** 系统抛出错误,提示"条件表达式过长"
|
||||
|
||||
#### Scenario: 禁止属性访问
|
||||
|
||||
- **WHEN** 表达式为 `{obj.attr}` 尝试访问对象属性
|
||||
- **THEN** 系统抛出错误,提示"不支持的语法特性"
|
||||
|
||||
#### Scenario: 禁止函数定义
|
||||
|
||||
- **WHEN** 表达式包含 lambda 或 def 关键字
|
||||
- **THEN** 系统抛出错误,提示"不支持的语法特性"
|
||||
|
||||
#### Scenario: 白名单函数限制
|
||||
|
||||
- **WHEN** 表达式使用了不在白名单中的函数(如 eval、exec、open)
|
||||
- **THEN** 系统拒绝执行,抛出错误
|
||||
|
||||
### Requirement: 系统必须支持表达式提取
|
||||
|
||||
系统 SHALL 能够从 `{expression}` 格式的字符串中提取实际的表达式内容。
|
||||
|
||||
#### Scenario: 提取带花括号的表达式
|
||||
|
||||
- **WHEN** 条件字符串为 `{count > 0}`
|
||||
- **THEN** 系统提取出表达式 `count > 0`
|
||||
|
||||
#### Scenario: 提取带空白的表达式
|
||||
|
||||
- **WHEN** 条件字符串为 `{ count > 0 }`
|
||||
- **THEN** 系统提取出表达式 `count > 0`(去除首尾空白)
|
||||
|
||||
#### Scenario: 处理不带花括号的表达式
|
||||
|
||||
- **WHEN** 条件字符串为 `count > 0`(不带花括号)
|
||||
- **THEN** 系统直接使用该字符串作为表达式
|
||||
117
openspec/specs/inline-templates/spec.md
Normal file
117
openspec/specs/inline-templates/spec.md
Normal 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 验证通过,元素在渲染时正常工作
|
||||
73
openspec/specs/slide-enabled-control/spec.md
Normal file
73
openspec/specs/slide-enabled-control/spec.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Slide Enabled Control
|
||||
|
||||
## Purpose
|
||||
|
||||
页面级启用/禁用控制,通过 `enabled` 布尔参数控制整个幻灯片是否渲染。提供简单的静态开关,用于开发调试和版本管理。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 幻灯片必须支持 enabled 参数
|
||||
|
||||
幻灯片定义 SHALL 支持可选的 `enabled` 布尔参数,用于控制该幻灯片是否渲染。
|
||||
|
||||
#### Scenario: 禁用的幻灯片不渲染
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: false`
|
||||
- **THEN** 系统跳过该幻灯片,不渲染到演示文稿中
|
||||
|
||||
#### Scenario: 启用的幻灯片正常渲染
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: true`
|
||||
- **THEN** 系统正常渲染该幻灯片
|
||||
|
||||
#### Scenario: 默认启用幻灯片
|
||||
|
||||
- **WHEN** 幻灯片未定义 `enabled` 字段
|
||||
- **THEN** 系统默认该幻灯片为启用状态,正常渲染
|
||||
|
||||
### Requirement: enabled 参数必须是布尔值
|
||||
|
||||
系统 SHALL 验证 `enabled` 参数的类型为布尔值,不支持条件表达式。
|
||||
|
||||
#### Scenario: 接受布尔值
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: false` 或 `enabled: true`
|
||||
- **THEN** 系统正常处理
|
||||
|
||||
#### Scenario: 拒绝非布尔值
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: "false"` 或 `enabled: 0` 等非布尔值
|
||||
- **THEN** 系统抛出验证错误,提示 enabled 必须是布尔值
|
||||
|
||||
#### Scenario: 拒绝条件表达式
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: "{some_var}"`
|
||||
- **THEN** 系统抛出验证错误,提示 enabled 不支持条件表达式
|
||||
|
||||
### Requirement: enabled 与元素级 visible 独立工作
|
||||
|
||||
系统 SHALL 支持页面级 `enabled` 和元素级 `visible` 同时使用,两者独立判断。
|
||||
|
||||
#### Scenario: enabled 和 visible 共存
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: true`,且模板元素包含 `visible` 条件
|
||||
- **THEN** 系统先检查页面级 enabled,再检查元素级 visible
|
||||
|
||||
#### Scenario: enabled=false 时不检查 visible
|
||||
|
||||
- **WHEN** 幻灯片定义了 `enabled: false`
|
||||
- **THEN** 系统跳过该幻灯片,不执行模板渲染和元素级 visible 检查
|
||||
|
||||
### Requirement: 渲染统计必须准确
|
||||
|
||||
系统 SHALL 只统计实际渲染的幻灯片数量,不包括禁用的幻灯片。
|
||||
|
||||
#### Scenario: 进度显示准确
|
||||
|
||||
- **WHEN** 演示文稿包含 5 个幻灯片,其中 2 个 enabled=false
|
||||
- **THEN** 系统显示"处理幻灯片 1/3"、"处理幻灯片 2/3"、"处理幻灯片 3/3"
|
||||
|
||||
#### Scenario: 最终统计准确
|
||||
|
||||
- **WHEN** 演示文稿包含 5 个幻灯片,其中 2 个 enabled=false
|
||||
- **THEN** 生成的 PPTX 文件包含 3 个幻灯片
|
||||
@@ -65,22 +65,53 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
|
||||
|
||||
### Requirement: 系统必须支持条件渲染
|
||||
|
||||
系统 SHALL 支持基于变量值的条件渲染,通过 `visible` 字段控制元素是否显示。
|
||||
系统 SHALL 支持基于变量值的条件渲染,通过 `visible` 字段控制元素是否显示。条件表达式使用 simpleeval 引擎评估,支持复杂的逻辑判断、比较运算、成员测试和数学计算。
|
||||
|
||||
#### Scenario: 显示满足条件的元素
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{subtitle != ''}\"`,且用户提供了非空的 subtitle
|
||||
- **WHEN** 元素定义了 `visible: "{count > 0}"`,且用户提供的 count 大于 0
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 隐藏不满足条件的元素
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{subtitle != ''}\"`,但用户提供的 subtitle 为空字符串
|
||||
- **WHEN** 元素定义了 `visible: "{count > 0}"`,但用户提供的 count 等于 0
|
||||
- **THEN** 系统跳过该元素,不渲染到幻灯片中
|
||||
|
||||
#### Scenario: 复杂逻辑条件
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{count > 0 and status == 'active'}"`,且两个条件都满足
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 成员测试条件
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{status in ['draft', 'review']}"`,且 status 为 "draft"
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 数学运算条件
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{(price * discount) > 50}"`,且计算结果大于 50
|
||||
- **THEN** 系统渲染该元素
|
||||
|
||||
#### Scenario: 条件表达式语法错误
|
||||
|
||||
- **WHEN** visible 字段包含无效的条件表达式
|
||||
- **THEN** 系统抛出错误,提示条件表达式格式错误
|
||||
- **WHEN** visible 字段包含无效的条件表达式(如 `{count > }`)
|
||||
- **THEN** 系统抛出错误,提示"条件表达式语法错误",并显示具体的语法问题
|
||||
|
||||
#### Scenario: 条件表达式中的变量未定义
|
||||
|
||||
- **WHEN** visible 字段引用了未定义的变量(如 `{undefined_var > 0}`)
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中的变量未定义: undefined_var",并列出可用变量
|
||||
|
||||
#### Scenario: 条件表达式使用不支持的函数
|
||||
|
||||
- **WHEN** visible 字段使用了不在白名单中的函数(如 `{eval(code)}`)
|
||||
- **THEN** 系统抛出错误,提示"条件表达式中使用了不支持的函数: eval"
|
||||
|
||||
#### Scenario: 向后兼容的简单表达式
|
||||
|
||||
- **WHEN** 元素定义了 `visible: "{subtitle != ''}"`(旧语法格式)
|
||||
- **THEN** 系统使用新的 simpleeval 引擎正确评估该表达式
|
||||
|
||||
|
||||
### Requirement: 模板文件必须可从指定目录加载
|
||||
|
||||
@@ -186,3 +217,27 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
|
||||
|
||||
- **WHEN** YAML 文件中所有幻灯片都是自定义幻灯片(不包含 `template` 字段)
|
||||
- **THEN** 系统不检查 `templates_dir` 是否为 `None`,正常处理
|
||||
|
||||
### Requirement: 幻灯片定义必须支持 enabled 字段
|
||||
|
||||
幻灯片定义 SHALL 支持可选的 `enabled` 布尔字段,用于控制该幻灯片是否渲染。该字段与模板系统的其他字段(template、vars、elements、background)独立工作。
|
||||
|
||||
#### Scenario: 幻灯片包含 enabled 字段
|
||||
|
||||
- **WHEN** 幻灯片定义包含 `enabled: false` 或 `enabled: true`
|
||||
- **THEN** 系统正常加载幻灯片定义,并在渲染时检查该字段
|
||||
|
||||
#### Scenario: enabled 字段可选
|
||||
|
||||
- **WHEN** 幻灯片定义未包含 `enabled` 字段
|
||||
- **THEN** 系统默认该幻灯片为启用状态
|
||||
|
||||
#### Scenario: enabled 与 template 共存
|
||||
|
||||
- **WHEN** 幻灯片同时定义了 `enabled: false` 和 `template: title-slide`
|
||||
- **THEN** 系统跳过该幻灯片,不加载模板
|
||||
|
||||
#### Scenario: enabled 与自定义幻灯片共存
|
||||
|
||||
- **WHEN** 自定义幻灯片(不使用模板)定义了 `enabled: false`
|
||||
- **THEN** 系统跳过该幻灯片,不渲染元素列表
|
||||
|
||||
@@ -7,6 +7,7 @@ dependencies = [
|
||||
"pyyaml",
|
||||
"flask",
|
||||
"watchdog",
|
||||
"simpleeval",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
23
tests/fixtures/templates/title-slide.yaml
vendored
Normal file
23
tests/fixtures/templates/title-slide.yaml
vendored
Normal 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
|
||||
299
tests/unit/test_condition_evaluator.py
Normal file
299
tests/unit/test_condition_evaluator.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
条件评估器单元测试
|
||||
|
||||
测试 ConditionEvaluator 类的所有功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from core.condition_evaluator import ConditionEvaluator
|
||||
|
||||
|
||||
class TestConditionEvaluator:
|
||||
"""ConditionEvaluator 测试类"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试方法前执行"""
|
||||
self.evaluator = ConditionEvaluator()
|
||||
|
||||
# ============= 比较运算符测试 =============
|
||||
|
||||
def test_greater_than(self):
|
||||
"""测试大于运算符"""
|
||||
assert self.evaluator.evaluate_condition("{count > 0}", {"count": 5}) is True
|
||||
assert self.evaluator.evaluate_condition("{count > 0}", {"count": 0}) is False
|
||||
|
||||
def test_less_than(self):
|
||||
"""测试小于运算符"""
|
||||
assert self.evaluator.evaluate_condition("{count < 10}", {"count": 5}) is True
|
||||
assert self.evaluator.evaluate_condition("{count < 10}", {"count": 10}) is False
|
||||
|
||||
def test_greater_equal(self):
|
||||
"""测试大于等于运算符"""
|
||||
assert self.evaluator.evaluate_condition("{count >= 5}", {"count": 5}) is True
|
||||
assert self.evaluator.evaluate_condition("{count >= 5}", {"count": 4}) is False
|
||||
|
||||
def test_less_equal(self):
|
||||
"""测试小于等于运算符"""
|
||||
assert self.evaluator.evaluate_condition("{count <= 5}", {"count": 5}) is True
|
||||
assert self.evaluator.evaluate_condition("{count <= 5}", {"count": 6}) is False
|
||||
|
||||
def test_equal(self):
|
||||
"""测试相等运算符"""
|
||||
assert self.evaluator.evaluate_condition("{status == 'active'}", {"status": "active"}) is True
|
||||
assert self.evaluator.evaluate_condition("{status == 'active'}", {"status": "inactive"}) is False
|
||||
|
||||
def test_not_equal(self):
|
||||
"""测试不等运算符"""
|
||||
assert self.evaluator.evaluate_condition("{status != 'inactive'}", {"status": "active"}) is True
|
||||
assert self.evaluator.evaluate_condition("{status != 'active'}", {"status": "active"}) is False
|
||||
|
||||
# ============= 逻辑运算符测试 =============
|
||||
|
||||
def test_logical_and(self):
|
||||
"""测试逻辑与运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{count > 0 and status == 'active'}",
|
||||
{"count": 5, "status": "active"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{count > 0 and status == 'active'}",
|
||||
{"count": 0, "status": "active"}
|
||||
) is False
|
||||
|
||||
def test_logical_or(self):
|
||||
"""测试逻辑或运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{count > 0 or status == 'active'}",
|
||||
{"count": 0, "status": "active"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{count > 0 or status == 'active'}",
|
||||
{"count": 0, "status": "inactive"}
|
||||
) is False
|
||||
|
||||
def test_logical_not(self):
|
||||
"""测试逻辑非运算"""
|
||||
assert self.evaluator.evaluate_condition("{not (count == 0)}", {"count": 5}) is True
|
||||
assert self.evaluator.evaluate_condition("{not (count == 0)}", {"count": 0}) is False
|
||||
|
||||
# ============= 成员测试 =============
|
||||
|
||||
def test_in_list(self):
|
||||
"""测试列表成员测试"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{status in ['draft', 'review', 'published']}",
|
||||
{"status": "draft"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{status in ['draft', 'review']}",
|
||||
{"status": "active"}
|
||||
) is False
|
||||
|
||||
def test_not_in_list(self):
|
||||
"""测试列表非成员测试"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{status not in ['draft', 'review']}",
|
||||
{"status": "active"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{status not in ['draft', 'review']}",
|
||||
{"status": "draft"}
|
||||
) is False
|
||||
|
||||
def test_in_tuple(self):
|
||||
"""测试元组成员测试"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{level in (1, 2, 3)}",
|
||||
{"level": 2}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{level in (1, 2, 3)}",
|
||||
{"level": 4}
|
||||
) is False
|
||||
|
||||
def test_string_contains(self):
|
||||
"""测试字符串包含"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{'test' in status}",
|
||||
{"status": "test-version"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{'test' in status}",
|
||||
{"status": "production"}
|
||||
) is False
|
||||
|
||||
# ============= 数学运算测试 =============
|
||||
|
||||
def test_addition(self):
|
||||
"""测试加法运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{(price + tax) > 100}",
|
||||
{"price": 90, "tax": 15}
|
||||
) is True
|
||||
|
||||
def test_multiplication(self):
|
||||
"""测试乘法运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{(price * discount) > 50}",
|
||||
{"price": 100, "discount": 0.8}
|
||||
) is True
|
||||
|
||||
def test_division(self):
|
||||
"""测试除法运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{(total / count) >= 10}",
|
||||
{"total": 100, "count": 5}
|
||||
) is True
|
||||
|
||||
def test_modulo(self):
|
||||
"""测试取模运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{(value % 2) == 0}",
|
||||
{"value": 10}
|
||||
) is True
|
||||
|
||||
def test_power(self):
|
||||
"""测试幂运算"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{(base ** exp) > 100}",
|
||||
{"base": 10, "exp": 2}
|
||||
) is False
|
||||
|
||||
# ============= 内置函数测试 =============
|
||||
|
||||
def test_int_function(self):
|
||||
"""测试 int() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{int(value) > 100}",
|
||||
{"value": "150"}
|
||||
) is True
|
||||
|
||||
def test_float_function(self):
|
||||
"""测试 float() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{float(value) > 3.14}",
|
||||
{"value": "3.5"}
|
||||
) is True
|
||||
|
||||
def test_str_function(self):
|
||||
"""测试 str() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{str(value) == '123'}",
|
||||
{"value": 123}
|
||||
) is True
|
||||
|
||||
def test_len_function(self):
|
||||
"""测试 len() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{len(items) > 0}",
|
||||
{"items": [1, 2, 3]}
|
||||
) is True
|
||||
|
||||
def test_bool_function(self):
|
||||
"""测试 bool() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{bool(value)}",
|
||||
{"value": "text"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{bool(value)}",
|
||||
{"value": ""}
|
||||
) is False
|
||||
|
||||
def test_abs_function(self):
|
||||
"""测试 abs() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{abs(value) > 10}",
|
||||
{"value": -15}
|
||||
) is True
|
||||
|
||||
def test_min_function(self):
|
||||
"""测试 min() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{min(a, b) == 5}",
|
||||
{"a": 5, "b": 10}
|
||||
) is True
|
||||
|
||||
def test_max_function(self):
|
||||
"""测试 max() 函数"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{max(a, b) == 10}",
|
||||
{"a": 5, "b": 10}
|
||||
) is True
|
||||
|
||||
# ============= 复杂逻辑组合测试 =============
|
||||
|
||||
def test_complex_logic_combination(self):
|
||||
"""测试复杂逻辑组合"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{(score >= 60 and score <= 100) or is_admin}",
|
||||
{"score": 75, "is_admin": False}
|
||||
) is True
|
||||
|
||||
def test_nested_conditions(self):
|
||||
"""测试嵌套条件"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{((count > 0) and (status == 'active')) or (is_admin and (level >= 3))}",
|
||||
{"count": 5, "status": "active", "is_admin": False, "level": 1}
|
||||
) is True
|
||||
|
||||
# ============= 错误处理测试 =============
|
||||
|
||||
def test_undefined_variable_error(self):
|
||||
"""测试变量未定义错误"""
|
||||
with pytest.raises(YAMLError, match="条件表达式中的变量未定义"):
|
||||
self.evaluator.evaluate_condition("{undefined_var > 0}", {"count": 5})
|
||||
|
||||
def test_undefined_function_error(self):
|
||||
"""测试函数未定义错误"""
|
||||
with pytest.raises(YAMLError, match="条件表达式中使用了不支持的函数"):
|
||||
self.evaluator.evaluate_condition("{custom_func(value)}", {"value": 5})
|
||||
|
||||
def test_syntax_error(self):
|
||||
"""测试语法错误"""
|
||||
with pytest.raises(YAMLError, match="条件表达式语法错误"):
|
||||
self.evaluator.evaluate_condition("{count > }", {"count": 5})
|
||||
|
||||
# ============= 安全限制测试 =============
|
||||
|
||||
def test_expression_too_long(self):
|
||||
"""测试表达式过长"""
|
||||
long_expr = "{" + "x " * 300 + "}"
|
||||
with pytest.raises(YAMLError, match="条件表达式过长"):
|
||||
self.evaluator.evaluate_condition(long_expr, {"x": 1})
|
||||
|
||||
def test_attribute_access_forbidden(self):
|
||||
"""测试禁止属性访问"""
|
||||
with pytest.raises(YAMLError, match="条件表达式使用了不支持的语法特性"):
|
||||
self.evaluator.evaluate_condition("{obj.attr}", {"obj": object()})
|
||||
|
||||
# ============= 表达式提取测试 =============
|
||||
|
||||
def test_extract_expression_with_braces(self):
|
||||
"""测试提取带花括号的表达式"""
|
||||
expr = self.evaluator._extract_expression("{count > 0}")
|
||||
assert expr == "count > 0"
|
||||
|
||||
def test_extract_expression_with_whitespace(self):
|
||||
"""测试提取带空白的表达式"""
|
||||
expr = self.evaluator._extract_expression("{ count > 0 }")
|
||||
assert expr == "count > 0"
|
||||
|
||||
def test_extract_expression_without_braces(self):
|
||||
"""测试提取不带花括号的表达式"""
|
||||
expr = self.evaluator._extract_expression("count > 0")
|
||||
assert expr == "count > 0"
|
||||
|
||||
# ============= 向后兼容测试 =============
|
||||
|
||||
def test_backward_compatible_simple_expression(self):
|
||||
"""测试向后兼容的简单表达式"""
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{subtitle != ''}",
|
||||
{"subtitle": "Hello"}
|
||||
) is True
|
||||
assert self.evaluator.evaluate_condition(
|
||||
"{subtitle != ''}",
|
||||
{"subtitle": ""}
|
||||
) is False
|
||||
@@ -90,6 +90,56 @@ class TestValidatePresentationYaml:
|
||||
validate_presentation_yaml(data, "test.yaml")
|
||||
assert "test.yaml" in str(exc_info.value)
|
||||
|
||||
def test_slide_enabled_true(self):
|
||||
"""测试 enabled=true 的幻灯片"""
|
||||
data = {
|
||||
"slides": [
|
||||
{"enabled": True, "elements": []}
|
||||
]
|
||||
}
|
||||
# 不应该引发错误
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_slide_enabled_false(self):
|
||||
"""测试 enabled=false 的幻灯片"""
|
||||
data = {
|
||||
"slides": [
|
||||
{"enabled": False, "elements": []}
|
||||
]
|
||||
}
|
||||
# 不应该引发错误
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_slide_enabled_invalid_string(self):
|
||||
"""测试 enabled 为字符串时拒绝"""
|
||||
data = {
|
||||
"slides": [
|
||||
{"enabled": "false", "elements": []}
|
||||
]
|
||||
}
|
||||
with pytest.raises(YAMLError, match="enabled 必须是布尔值"):
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_slide_enabled_invalid_number(self):
|
||||
"""测试 enabled 为数字时拒绝"""
|
||||
data = {
|
||||
"slides": [
|
||||
{"enabled": 0, "elements": []}
|
||||
]
|
||||
}
|
||||
with pytest.raises(YAMLError, match="enabled 必须是布尔值"):
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_slide_enabled_invalid_expression(self):
|
||||
"""测试 enabled 为条件表达式时拒绝"""
|
||||
data = {
|
||||
"slides": [
|
||||
{"enabled": "{some_var}", "elements": []}
|
||||
]
|
||||
}
|
||||
with pytest.raises(YAMLError, match="enabled 必须是布尔值"):
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
|
||||
class TestValidateTemplateYaml:
|
||||
"""validate_template_yaml 函数测试类"""
|
||||
|
||||
223
tests/unit/test_slide_enabled.py
Normal file
223
tests/unit/test_slide_enabled.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
幻灯片 enabled 参数测试
|
||||
|
||||
测试页面级 enabled 参数的功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from core.presentation import Presentation
|
||||
from renderers.pptx_renderer import PptxGenerator
|
||||
|
||||
|
||||
class TestSlideEnabled:
|
||||
"""幻灯片 enabled 参数测试类"""
|
||||
|
||||
def test_slide_enabled_false(self, temp_dir):
|
||||
"""测试 enabled=false 的幻灯片被跳过"""
|
||||
yaml_content = """
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Slide 1"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- enabled: false
|
||||
elements:
|
||||
- type: text
|
||||
content: "Slide 2 (disabled)"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Slide 3"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path))
|
||||
slides_data = pres.data.get('slides', [])
|
||||
|
||||
# 统计启用的幻灯片
|
||||
enabled_slides = [s for s in slides_data if s.get('enabled', True)]
|
||||
assert len(enabled_slides) == 2 # 只有 2 个启用
|
||||
|
||||
def test_slide_enabled_default_true(self, temp_dir):
|
||||
"""测试未指定 enabled 时默认为 true"""
|
||||
yaml_content = """
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Slide 1"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path))
|
||||
slides_data = pres.data.get('slides', [])
|
||||
|
||||
# 默认应该启用
|
||||
assert slides_data[0].get('enabled', True) is True
|
||||
|
||||
def test_slide_enabled_with_template(self, temp_dir):
|
||||
"""测试 enabled 与模板共存"""
|
||||
# 创建模板
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
template_file = template_dir / "title-slide.yaml"
|
||||
template_content = """
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 2, 8, 1]
|
||||
content: "{title}"
|
||||
font:
|
||||
size: 44
|
||||
"""
|
||||
template_file.write_text(template_content)
|
||||
|
||||
yaml_content = """
|
||||
slides:
|
||||
- template: title-slide
|
||||
enabled: false
|
||||
vars:
|
||||
title: "Disabled Title"
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Enabled Title"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path), str(template_dir))
|
||||
slides_data = pres.data.get('slides', [])
|
||||
|
||||
# 第一个禁用,第二个启用
|
||||
assert slides_data[0].get('enabled', True) is False
|
||||
assert slides_data[1].get('enabled', True) is True
|
||||
|
||||
def test_slide_enabled_with_custom_slide(self, temp_dir):
|
||||
"""测试 enabled 与自定义幻灯片共存"""
|
||||
yaml_content = """
|
||||
slides:
|
||||
- enabled: false
|
||||
elements:
|
||||
- type: text
|
||||
content: "Disabled Custom"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Enabled Custom"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path))
|
||||
slides_data = pres.data.get('slides', [])
|
||||
|
||||
assert slides_data[0].get('enabled', True) is False
|
||||
assert slides_data[1].get('enabled', True) is True
|
||||
|
||||
def test_slide_enabled_with_element_visible(self, temp_dir):
|
||||
"""测试 enabled 和 visible 共存"""
|
||||
# 创建模板
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
template_file = template_dir / "title-slide.yaml"
|
||||
template_content = """
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
- name: subtitle
|
||||
required: false
|
||||
default: ""
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 2, 8, 1]
|
||||
content: "{title}"
|
||||
font:
|
||||
size: 44
|
||||
- type: text
|
||||
box: [1, 3.5, 8, 0.5]
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
template_file.write_text(template_content)
|
||||
|
||||
yaml_content = """
|
||||
slides:
|
||||
- template: title-slide
|
||||
enabled: true
|
||||
vars:
|
||||
title: "Title"
|
||||
subtitle: ""
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path), str(template_dir))
|
||||
slide_data = pres.data['slides'][0]
|
||||
|
||||
# 页面启用
|
||||
assert slide_data.get('enabled', True) is True
|
||||
|
||||
# 渲染幻灯片,元素级 visible 会隐藏空副标题
|
||||
rendered = pres.render_slide(slide_data)
|
||||
# 只有标题元素,副标题被 visible 隐藏
|
||||
assert len(rendered['elements']) == 1
|
||||
|
||||
def test_slide_enabled_count(self, temp_dir):
|
||||
"""测试渲染统计准确"""
|
||||
yaml_content = """
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Slide 1"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- enabled: false
|
||||
elements:
|
||||
- type: text
|
||||
content: "Slide 2"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- enabled: false
|
||||
elements:
|
||||
- type: text
|
||||
content: "Slide 3"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Slide 4"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
- elements:
|
||||
- type: text
|
||||
content: "Slide 5"
|
||||
box: [1, 1, 8, 1]
|
||||
font: {size: 44}
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path))
|
||||
slides_data = pres.data.get('slides', [])
|
||||
|
||||
# 总共 5 个幻灯片
|
||||
assert len(slides_data) == 5
|
||||
|
||||
# 只有 3 个启用
|
||||
enabled_slides = [s for s in slides_data if s.get('enabled', True)]
|
||||
assert len(enabled_slides) == 3
|
||||
@@ -145,15 +145,36 @@ class TestEvaluateCondition:
|
||||
assert result is False
|
||||
|
||||
def test_evaluate_condition_with_missing_variable(self, sample_template):
|
||||
"""测试缺失变量条件为假"""
|
||||
"""测试缺失变量会抛出错误"""
|
||||
template = Template("title-slide", templates_dir=sample_template)
|
||||
result = template.evaluate_condition("{subtitle != ''}", {})
|
||||
assert result is False
|
||||
with pytest.raises(YAMLError, match="条件表达式中的变量未定义"):
|
||||
template.evaluate_condition("{subtitle != ''}", {})
|
||||
|
||||
def test_evaluate_condition_unrecognized_format_returns_true(self, sample_template):
|
||||
"""测试无法识别的条件格式默认返回 True"""
|
||||
def test_evaluate_condition_complex_logic(self, sample_template):
|
||||
"""测试复杂逻辑表达式"""
|
||||
template = Template("title-slide", templates_dir=sample_template)
|
||||
result = template.evaluate_condition("some other format", {})
|
||||
result = template.evaluate_condition(
|
||||
"{count > 0 and status == 'active'}",
|
||||
{"count": 5, "status": "active"}
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_evaluate_condition_member_test(self, sample_template):
|
||||
"""测试成员测试表达式"""
|
||||
template = Template("title-slide", templates_dir=sample_template)
|
||||
result = template.evaluate_condition(
|
||||
"{status in ['draft', 'review']}",
|
||||
{"status": "draft"}
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_evaluate_condition_math_operation(self, sample_template):
|
||||
"""测试数学运算表达式"""
|
||||
template = Template("title-slide", templates_dir=sample_template)
|
||||
result = template.evaluate_condition(
|
||||
"{(price * discount) > 50}",
|
||||
{"price": 100, "discount": 0.8}
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@@ -449,3 +470,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
|
||||
|
||||
28
uv.lock
generated
28
uv.lock
generated
@@ -1418,6 +1418,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simpleeval"
|
||||
version = "0.9.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.9'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/fa/d2d5bbf9a03fe7b0956368ac5420cfcb072146be6e912a50747dc376133a/simpleeval-0.9.13.tar.gz", hash = "sha256:4a30f9cc01825fe4c719c785e3762623e350c4840d5e6855c2a8496baaa65fac", size = 24535, upload-time = "2023-02-17T09:01:54.853Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/51/bedb4af4f3fe4bb32a3cabfd285be388958c6d676f6b0fa65997812a381b/simpleeval-0.9.13-py2.py3-none-any.whl", hash = "sha256:22a2701a5006e4188d125d34accf2405c2c37c93f6b346f2484b6422415ae54a", size = 15277, upload-time = "2023-02-17T09:01:52.953Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simpleeval"
|
||||
version = "1.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
"python_full_version == '3.9.*'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/6f/15be211749430f52f2c8f0c69158a6fc961c03aac93fa28d44d1a6f5ebc7/simpleeval-1.0.3.tar.gz", hash = "sha256:67bbf246040ac3b57c29cf048657b9cf31d4e7b9d6659684daa08ca8f1e45829", size = 24358, upload-time = "2024-11-02T10:29:46.912Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e9/e58082fbb8cecbb6fb4133033c40cc50c248b1a331582be3a0f39138d65b/simpleeval-1.0.3-py3-none-any.whl", hash = "sha256:e3bdbb8c82c26297c9a153902d0fd1858a6c3774bf53ff4f134788c3f2035c38", size = 15762, upload-time = "2024-11-02T10:29:45.706Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.0"
|
||||
@@ -1632,6 +1657,8 @@ dependencies = [
|
||||
{ name = "flask", version = "3.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
||||
{ name = "python-pptx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "simpleeval", version = "0.9.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "simpleeval", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
||||
{ name = "watchdog", version = "4.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "watchdog", version = "6.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
||||
]
|
||||
@@ -1659,6 +1686,7 @@ requires-dist = [
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'" },
|
||||
{ name = "python-pptx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "simpleeval" },
|
||||
{ name = "watchdog" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
@@ -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,6 +158,12 @@ class ResourceValidator:
|
||||
if not template_name:
|
||||
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
|
||||
|
||||
|
||||
@@ -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. 验证每个幻灯片
|
||||
|
||||
12
yaml2pptx.py
12
yaml2pptx.py
@@ -200,8 +200,18 @@ def handle_convert(args):
|
||||
slides_data = pres.data.get('slides', [])
|
||||
total_slides = len(slides_data)
|
||||
|
||||
# 统计实际渲染的幻灯片数量
|
||||
enabled_slides = [s for s in slides_data if s.get('enabled', True)]
|
||||
enabled_count = len(enabled_slides)
|
||||
|
||||
slide_index = 0
|
||||
for i, slide_data in enumerate(slides_data, 1):
|
||||
log_progress(i, total_slides, f"处理幻灯片")
|
||||
# 检查页面级 enabled
|
||||
if not slide_data.get('enabled', True):
|
||||
continue # 跳过禁用的页面
|
||||
|
||||
slide_index += 1
|
||||
log_progress(slide_index, enabled_count, f"处理幻灯片")
|
||||
rendered_slide = pres.render_slide(slide_data)
|
||||
generator.add_slide(rendered_slide, input_path.parent)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user