1
0

feat: 添加内联模板支持

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

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

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

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

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

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

View File

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

View File

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