1
0

feat: 实现模板库metadata和跨域字体引用系统

实现了统一的metadata结构和字体作用域系统,支持文档和模板库之间的单向字体引用。

主要变更:
- 模板库必须包含metadata字段(包括size、fonts、fonts_default)
- 实现文档和模板库的size一致性校验
- 实现字体作用域系统(文档可引用模板库字体,反之不可)
- 实现跨域循环引用检测
- 实现fonts_default级联规则(模板库→文档→系统默认)
- 添加错误代码常量(SIZE_MISMATCH、FONT_NOT_FOUND等)
- 更新文档和开发者指南

测试覆盖:
- 新增33个测试(单元测试20个,集成测试13个)
- 所有457个测试通过

Breaking Changes:
- 模板库文件必须包含metadata字段
- 模板库metadata.size为必填字段
- 文档和模板库的size必须一致

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 18:12:05 +08:00
parent f1aae96a04
commit 98098dc911
25 changed files with 2794 additions and 141 deletions

View File

@@ -95,7 +95,10 @@ elements:
align: center
"""
TEMPLATE_LIBRARY_YAML = """templates:
TEMPLATE_LIBRARY_YAML = """metadata:
size: "16:9"
templates:
title-slide:
vars:
- name: title

View File

@@ -216,6 +216,9 @@ slides:
# 创建模板库文件
template_content = """
metadata:
size: "16:9"
templates:
test-template:
vars:

View File

@@ -30,6 +30,7 @@ import pytest
from pathlib import Path
from core.presentation import Presentation
from renderers.pptx_renderer import PptxGenerator
from loaders.yaml_loader import YAMLError
class TestMultilineTextExtendedProperties:
@@ -146,9 +147,6 @@ slides:
slide_data = pres.data.get('slides', [])[0]
# 渲染时应该抛出循环引用错误
with pytest.raises(ValueError, match="检测到字体引用循环"):
# 渲染时应该抛出循环引用错误(包装为 YAMLError
with pytest.raises(YAMLError, match="字体解析失败.*检测到字体引用循环"):
rendered = pres.render_slide(slide_data)
# 触发字体解析
for elem in rendered['elements']:
gen.font_resolver.resolve_font(elem.font)

View File

@@ -196,6 +196,9 @@ slides:
# 创建模板库文件
template_content = """
metadata:
size: "16:9"
templates:
test-template:
vars:

View File

@@ -0,0 +1,241 @@
"""
模板库 metadata 和跨域字体引用集成测试
"""
import pytest
from pathlib import Path
from core.presentation import Presentation
from loaders.yaml_loader import YAMLError
class TestCrossDomainFontReference:
"""跨域字体引用集成测试"""
def test_document_element_references_template_font(self, temp_dir):
"""测试文档元素引用模板库字体"""
# 创建模板库
template_content = """
metadata:
size: "16:9"
fonts:
template-title:
family: "SimSun"
size: 36
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- elements:
- type: text
content: "测试"
box: [1, 1, 8, 1]
font: "@template-title"
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 应该正常加载和渲染
pres = Presentation(str(doc_path), str(template_path))
result = pres.render_slide(pres.data['slides'][0])
# 验证字体已解析
assert len(result['elements']) == 1
elem = result['elements'][0]
assert elem.font.family == "SimSun"
assert elem.font.size == 36
def test_template_element_cannot_reference_document_font(self, temp_dir):
"""测试模板元素不能引用文档字体"""
# 创建模板库
template_content = """
metadata:
size: "16:9"
templates:
test:
elements:
- type: text
content: "测试"
box: [1, 1, 8, 1]
font: "@doc-title"
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 创建文档
doc_content = """
metadata:
size: "16:9"
fonts:
doc-title:
family: "Arial"
size: 44
slides:
- template: test
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 应该在渲染时抛出错误
pres = Presentation(str(doc_path), str(template_path))
with pytest.raises(YAMLError, match="引用的字体配置不存在"):
pres.render_slide(pres.data['slides'][0])
class TestSizeConsistencyIntegration:
"""Size 一致性校验集成测试"""
def test_size_mismatch_prevents_loading(self, temp_dir):
"""测试 size 不一致时阻止加载"""
# 创建模板库
template_content = """
metadata:
size: "4:3"
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- elements: []
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 应该在初始化时抛出错误
with pytest.raises(YAMLError, match="文档尺寸.*与模板库尺寸.*不一致"):
Presentation(str(doc_path), str(template_path))
class TestCompleteRenderingFlow:
"""完整渲染流程集成测试"""
def test_full_rendering_with_cross_domain_fonts(self, temp_dir):
"""测试完整渲染流程(包含跨域字体引用)"""
# 创建模板库
template_content = """
metadata:
size: "16:9"
fonts:
template-base:
family: "SimSun"
size: 18
template-title:
parent: "@template-base"
size: 36
bold: true
templates:
title-slide:
elements:
- type: text
content: "标题"
box: [1, 1, 8, 1]
font: "@template-title"
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 创建文档
doc_content = """
metadata:
size: "16:9"
fonts:
doc-body:
parent: "@template-base"
size: 24
slides:
- template: title-slide
- elements:
- type: text
content: "正文"
box: [1, 2, 8, 1]
font: "@doc-body"
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 应该正常加载和渲染
pres = Presentation(str(doc_path), str(template_path))
# 渲染第一张幻灯片(使用模板)
slide1 = pres.render_slide(pres.data['slides'][0])
assert len(slide1['elements']) == 1
elem1 = slide1['elements'][0]
assert elem1.font.family == "SimSun"
assert elem1.font.size == 36
assert elem1.font.bold is True
# 渲染第二张幻灯片(文档元素引用模板库字体)
slide2 = pres.render_slide(pres.data['slides'][1])
assert len(slide2['elements']) == 1
elem2 = slide2['elements'][0]
assert elem2.font.family == "SimSun"
assert elem2.font.size == 24
class TestTableDualFontFields:
"""表格双字体字段集成测试"""
def test_table_font_and_header_font(self, temp_dir):
"""测试表格的 font 和 header_font 字段"""
# 创建模板库
template_content = """
metadata:
size: "16:9"
fonts:
table-body:
family: "Arial"
size: 14
table-header:
family: "Arial"
size: 16
bold: true
templates:
table-slide:
elements:
- type: table
position: [1, 1]
font: "@table-body"
header_font: "@table-header"
data:
- ["列1", "列2"]
- ["数据1", "数据2"]
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- template: table-slide
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 应该正常加载和渲染
pres = Presentation(str(doc_path), str(template_path))
result = pres.render_slide(pres.data['slides'][0])
# 验证表格字体已解析
assert len(result['elements']) == 1
table = result['elements'][0]
assert table.font.family == "Arial"
assert table.font.size == 14
assert table.header_font.family == "Arial"
assert table.header_font.size == 16
assert table.header_font.bold is True

View File

@@ -0,0 +1,146 @@
"""
FontResolver 跨域引用测试
"""
import pytest
from utils.font_resolver import FontResolver
from core.elements import FontConfig
class TestFontResolverCrossDomain:
"""FontResolver 跨域引用测试类"""
def test_document_scope_can_reference_template_fonts(self):
"""测试文档作用域可以引用模板库字体"""
doc_fonts = {
"title": {"family": "Arial", "size": 44}
}
template_fonts = {
"base": {"family": "SimSun", "size": 18}
}
resolver = FontResolver(
fonts=doc_fonts,
scope="document",
template_fonts=template_fonts
)
# 引用模板库字体
result = resolver.resolve_font("@base")
assert result.family == "SimSun"
assert result.size == 18
def test_document_scope_prioritizes_document_fonts(self):
"""测试文档作用域优先使用文档字体"""
doc_fonts = {
"title": {"family": "Arial", "size": 44}
}
template_fonts = {
"title": {"family": "SimSun", "size": 36}
}
resolver = FontResolver(
fonts=doc_fonts,
scope="document",
template_fonts=template_fonts
)
# 同名字体优先使用文档的
result = resolver.resolve_font("@title")
assert result.family == "Arial"
assert result.size == 44
def test_template_scope_cannot_reference_document_fonts(self):
"""测试模板库作用域不能引用文档字体"""
doc_fonts = {
"title": {"family": "Arial", "size": 44}
}
template_fonts = {
"base": {"family": "SimSun", "size": 18}
}
resolver = FontResolver(
fonts=template_fonts,
scope="template",
template_fonts=template_fonts
)
# 尝试引用文档字体应该失败
with pytest.raises(ValueError, match="引用的字体配置不存在"):
resolver.resolve_font("@title")
def test_template_scope_can_reference_template_fonts(self):
"""测试模板库作用域可以引用模板库字体"""
template_fonts = {
"base": {"family": "SimSun", "size": 18},
"title": {"parent": "@base", "size": 36}
}
resolver = FontResolver(
fonts=template_fonts,
scope="template",
template_fonts=template_fonts
)
# 引用模板库字体
result = resolver.resolve_font("@title")
assert result.family == "SimSun"
assert result.size == 36
def test_cross_domain_circular_reference_detection(self):
"""测试跨域循环引用检测"""
doc_fonts = {
"a": {"parent": "@b"}
}
template_fonts = {
"b": {"parent": "@a"}
}
resolver = FontResolver(
fonts=doc_fonts,
scope="document",
template_fonts=template_fonts
)
# 应该检测到跨域循环引用
with pytest.raises(ValueError, match="检测到.*字体引用循环"):
resolver.resolve_font("@a")
def test_document_parent_can_reference_template_fonts(self):
"""测试文档字体的 parent 可以引用模板库字体"""
doc_fonts = {
"custom": {"parent": "@base", "bold": True}
}
template_fonts = {
"base": {"family": "SimSun", "size": 18}
}
resolver = FontResolver(
fonts=doc_fonts,
scope="document",
template_fonts=template_fonts
)
result = resolver.resolve_font("@custom")
assert result.family == "SimSun"
assert result.size == 18
assert result.bold is True
def test_template_parent_cannot_reference_document_fonts(self):
"""测试模板库字体的 parent 不能引用文档字体"""
doc_fonts = {
"doc-base": {"family": "Arial", "size": 18}
}
template_fonts = {
"custom": {"parent": "@doc-base", "bold": True}
}
resolver = FontResolver(
fonts=template_fonts,
scope="template",
template_fonts=template_fonts
)
# 应该失败
with pytest.raises(ValueError, match="模板元素不能引用文档的字体配置"):
resolver.resolve_font("@custom")

View File

@@ -0,0 +1,140 @@
"""
fonts_default 级联和验证测试
"""
import pytest
from utils.font_resolver import FontResolver
from loaders.yaml_loader import validate_template_library_yaml, YAMLError
class TestFontsDefaultCascade:
"""fonts_default 级联测试类"""
def test_fonts_default_basic_resolution(self):
"""测试基本的 fonts_default 解析"""
fonts = {
"body": {"family": "Arial", "size": 18}
}
resolver = FontResolver(
fonts=fonts,
fonts_default="@body",
scope="document"
)
# 元素未定义 font 时使用 fonts_default
result = resolver.resolve_font(None)
assert result.family == "Arial"
assert result.size == 18
def test_document_fonts_default_can_reference_template_fonts(self):
"""测试文档 fonts_default 可以引用模板库字体"""
template_fonts = {
"base": {"family": "SimSun", "size": 18}
}
resolver = FontResolver(
fonts={},
fonts_default="@base",
scope="document",
template_fonts=template_fonts
)
result = resolver.resolve_font(None)
assert result.family == "SimSun"
assert result.size == 18
def test_fonts_default_with_parent_inheritance(self):
"""测试 fonts_default 的 parent 继承"""
fonts = {
"base": {"family": "Arial", "size": 18},
"body": {"parent": "@base", "color": "#000000"}
}
resolver = FontResolver(
fonts=fonts,
fonts_default="@body",
scope="document"
)
result = resolver.resolve_font(None)
assert result.family == "Arial"
assert result.size == 18
assert result.color == "#000000"
class TestTemplatLibraryFontsDefaultValidation:
"""模板库 fonts_default 验证测试类"""
def test_template_library_fonts_default_valid(self):
"""测试模板库 fonts_default 引用有效字体"""
data = {
"metadata": {
"size": "16:9",
"fonts": {
"base": {"family": "SimSun", "size": 18}
},
"fonts_default": "@base"
},
"templates": {
"test": {"elements": []}
}
}
# 应该不抛出异常
validate_template_library_yaml(data, "test.yaml")
def test_template_library_fonts_default_invalid_reference(self):
"""测试模板库 fonts_default 引用不存在的字体"""
data = {
"metadata": {
"size": "16:9",
"fonts": {
"base": {"family": "SimSun", "size": 18}
},
"fonts_default": "@nonexistent"
},
"templates": {
"test": {"elements": []}
}
}
# 应该抛出错误
with pytest.raises(YAMLError, match="fonts_default.*不存在"):
validate_template_library_yaml(data, "test.yaml")
def test_template_library_fonts_default_not_reference_format(self):
"""测试模板库 fonts_default 不是引用格式"""
data = {
"metadata": {
"size": "16:9",
"fonts": {
"base": {"family": "SimSun", "size": 18}
},
"fonts_default": "Arial"
},
"templates": {
"test": {"elements": []}
}
}
# 应该抛出错误
with pytest.raises(YAMLError, match="fonts_default 必须是引用格式"):
validate_template_library_yaml(data, "test.yaml")
def test_template_library_without_fonts_default(self):
"""测试模板库没有 fonts_default 时正常工作"""
data = {
"metadata": {
"size": "16:9",
"fonts": {
"base": {"family": "SimSun", "size": 18}
}
},
"templates": {
"test": {"elements": []}
}
}
# 应该不抛出异常
validate_template_library_yaml(data, "test.yaml")

View File

@@ -0,0 +1,64 @@
"""
YAML Loader metadata 验证测试
"""
import pytest
from loaders.yaml_loader import validate_metadata, YAMLError
class TestValidateMetadata:
"""validate_metadata 函数测试类"""
def test_valid_metadata_16_9(self):
"""测试有效的 16:9 metadata"""
metadata = {
"size": "16:9",
"description": "测试文档"
}
# 应该不抛出异常
validate_metadata(metadata, "test.yaml", context="文档")
def test_valid_metadata_4_3(self):
"""测试有效的 4:3 metadata"""
metadata = {
"size": "4:3",
"version": "1.0",
"author": "测试作者"
}
# 应该不抛出异常
validate_metadata(metadata, "test.yaml", context="模板库")
def test_metadata_missing_size(self):
"""测试 metadata 缺少 size 字段"""
metadata = {
"description": "测试文档"
}
with pytest.raises(YAMLError, match="metadata 缺少必填字段 'size'"):
validate_metadata(metadata, "test.yaml", context="文档")
def test_metadata_invalid_size(self):
"""测试 metadata size 值无效"""
metadata = {
"size": "21:9"
}
with pytest.raises(YAMLError, match="metadata.size 必须是 '16:9''4:3'"):
validate_metadata(metadata, "test.yaml", context="文档")
def test_metadata_not_dict(self):
"""测试 metadata 不是字典"""
metadata = "invalid"
with pytest.raises(YAMLError, match="metadata 必须是字典对象"):
validate_metadata(metadata, "test.yaml", context="文档")
def test_metadata_with_optional_fields(self):
"""测试 metadata 包含可选字段"""
metadata = {
"size": "16:9",
"version": "1.0",
"author": "作者",
"description": "描述",
"fonts": {},
"fonts_default": "@body"
}
# 应该不抛出异常
validate_metadata(metadata, "test.yaml", context="文档")

View File

@@ -252,52 +252,6 @@ templates:
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_template_only(self, temp_dir, sample_template):
"""测试向后兼容:纯模板模式"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"}
}
result = pres.render_slide(slide_data)
# 应该只有模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_custom_only(self, sample_yaml):
"""测试向后兼容:纯自定义元素模式"""
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Custom", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该只有自定义元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Custom"
def test_hybrid_mode_with_inline_template(self, temp_dir):
"""测试内联模板与自定义元素混合使用"""
@@ -485,3 +439,140 @@ class TestSlideDescription:
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test Content"
class TestSizeConsistency:
"""Size 一致性校验测试类"""
def test_size_consistency_16_9(self, temp_dir):
"""测试文档和模板库 size 都是 16:9 时正常加载"""
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- elements: []
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 创建模板库
template_content = """
metadata:
size: "16:9"
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 应该正常加载
pres = Presentation(str(doc_path), str(template_path))
assert pres.size == "16:9"
def test_size_consistency_4_3(self, temp_dir):
"""测试文档和模板库 size 都是 4:3 时正常加载"""
# 创建文档
doc_content = """
metadata:
size: "4:3"
slides:
- elements: []
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 创建模板库
template_content = """
metadata:
size: "4:3"
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 应该正常加载
pres = Presentation(str(doc_path), str(template_path))
assert pres.size == "4:3"
def test_size_mismatch_error(self, temp_dir):
"""测试文档和模板库 size 不一致时抛出错误"""
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- elements: []
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 创建模板库
template_content = """
metadata:
size: "4:3"
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 应该抛出错误
with pytest.raises(YAMLError, match="文档尺寸.*与模板库尺寸.*不一致"):
Presentation(str(doc_path), str(template_path))
def test_template_library_missing_metadata(self, temp_dir):
"""测试模板库缺少 metadata 时抛出错误"""
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- elements: []
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 创建模板库(缺少 metadata
template_content = """
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 应该抛出错误
with pytest.raises(YAMLError, match="模板库必须包含 metadata 字段"):
Presentation(str(doc_path), str(template_path))
def test_template_library_missing_size(self, temp_dir):
"""测试模板库 metadata 缺少 size 时抛出错误"""
# 创建文档
doc_content = """
metadata:
size: "16:9"
slides:
- elements: []
"""
doc_path = temp_dir / "doc.yaml"
doc_path.write_text(doc_content)
# 创建模板库metadata 缺少 size
template_content = """
metadata:
description: "测试模板库"
templates:
test:
elements: []
"""
template_path = temp_dir / "templates.yaml"
template_path.write_text(template_content)
# 应该抛出错误
with pytest.raises(YAMLError, match="metadata 缺少必填字段 'size'"):
Presentation(str(doc_path), str(template_path))