实现了统一的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>
13 KiB
13 KiB
Design: 模板库元数据与跨域字体引用
Context
当前状态
当前系统中:
- 文档支持 metadata 结构,包含 size、description、fonts、fonts_default 字段
- 模板库仅支持 templates 字段和可选的简单元数据(description、version、author)
- 字体引用系统(FontResolver)只处理单一作用域的字体配置
- 模板中的元素无法引用字体配置,只能直接定义字体属性
约束条件
- 模板库必须包含 metadata 字段且 size 必填(不向后兼容)
- 模板库和文档的 metadata 使用相同的验证规则
- 字体引用规则是单向的:文档可引用模板库,模板库不能引用文档
- 变量解析逻辑保持现状,不扩展到 font 字段
涉及模块
loaders/yaml_loader.py- YAML 加载和验证validators/validator.py- 主验证器core/presentation.py- 演示文稿管理core/template.py- 模板渲染utils/font_resolver.py- 字体解析器
Goals / Non-Goals
Goals:
- 统一文档和模板库的 metadata 结构,支持相同的字段定义
- 模板库必须包含 metadata 且 size 必填(破坏性变更)
- 建立清晰的跨域字体引用规则,支持文档引用模板库的字体配置
- 实现 size 一致性校验,确保文档和模板库尺寸匹配
- 扩展 FontResolver 支持跨域引用和循环检测
Non-Goals:
- 不支持模板库引用文档的字体配置(严格单向)
- 不修改现有的变量解析逻辑
- 不引入新的作用域前缀语法(如 @template:xxx)
- 不改变 font 字段的变量替换行为(font 字段不被变量替换)
Decisions
Decision 1: 统一的 Metadata 验证函数
决策: 创建统一的 metadata 验证函数,同时用于文档和模板库。
理由:
- 减少代码重复
- 确保文档和模板库的 metadata 使用完全相同的验证规则
- 未来扩展 metadata 字段时只需修改一处
实现:
# loaders/yaml_loader.py
def validate_metadata(metadata, file_path, context="文档"):
"""验证 metadata 结构
Args:
metadata: metadata 字典
file_path: 文件路径(用于错误消息)
context: 上下文描述("文档" 或 "模板库")
"""
if not isinstance(metadata, dict):
raise YAMLError(f"{file_path}: metadata 必须是字典对象")
# size 必填
if "size" not in metadata:
raise YAMLError(f"{file_path}: metadata 缺少必填字段 'size'")
size = metadata["size"]
if size not in ["16:9", "4:3"]:
raise YAMLError(f"{file_path}: metadata.size 必须是 '16:9' 或 '4:3',当前值: {size}")
# 其他字段可选
# version, author, description: 不验证格式
# fonts, fonts_default: 使用字体主题系统的验证逻辑
Decision 2: FontResolver 作用域感知
决策: 扩展 FontResolver 支持作用域参数,区分文档作用域和模板库作用域。
理由:
- 保持现有 FontResolver API 的简洁性
- 通过作用域参数控制引用行为
- 支持跨域引用检测
实现:
# utils/font_resolver.py
class FontResolver:
def __init__(self, fonts=None, fonts_default=None, scope="document", template_fonts=None):
self.fonts = fonts or {}
self.fonts_default = fonts_default
self.scope = scope # "document" 或 "template"
self.template_fonts = template_fonts or {}
self._max_depth = 10
def _resolve_reference(self, reference, visited, allow_cross_domain=False):
# 解析引用时考虑作用域规则
if self.scope == "template" and not allow_cross_domain:
# 模板库作用域:只能引用 template_fonts
font_dict = self.template_fonts.get(font_name)
if font_dict is None:
raise ValueError(
f"模板元素不能引用文档的字体配置: {reference},"
f"只能引用模板库中定义的字体"
)
else:
# 文档作用域或允许跨域:优先 fonts,fallback template_fonts
if font_name in self.fonts:
font_dict = self.fonts[font_name]
elif font_name in self.template_fonts:
font_dict = self.template_fonts[font_name]
else:
raise ValueError(f"引用的字体配置不存在: {reference}")
Decision 3: 跨域循环引用检测
决策: 在循环引用检测中增加作用域追踪,检测跨域循环。
理由:
- 防止文档和模板库之间形成循环引用
- 提供清晰的错误消息,帮助用户定位问题
实现:
def _resolve_reference(self, reference, visited, allow_cross_domain=False):
# 检测循环时,记录引用的作用域
scope_tag = "doc" if self.scope == "document" else "template"
tagged_ref = f"{scope_tag}.{reference}"
if tagged_ref in visited:
path = " -> ".join(visited) + f" -> {tagged_ref}"
if any("doc." in v and "template." in v for v in visited):
raise ValueError(f"检测到跨域字体引用循环: {path}")
else:
raise ValueError(f"检测到字体引用循环: {path}")
visited.add(tagged_ref)
# ... 继续解析
Decision 4: Size 一致性校验时机
决策: 在 Presentation 初始化时,加载模板库后立即进行 size 一致性校验。
理由:
- 早期失败,避免后续处理才发现问题
- 错误消息更清晰,用户可以立即修正
实现:
# core/presentation.py
def __init__(self, pres_file, template_file=None):
# ... 加载文档
# 加载并验证模板库文件(如果提供)
if self.template_file:
self.template_library = load_yaml_file(self.template_file)
validate_template_library_yaml(self.template_library, str(self.template_file))
# 验证模板库 metadata(必须存在)
if "metadata" not in self.template_library:
raise YAMLError(
f"{self.template_file}: 模板库必须包含 metadata 字段"
)
validate_metadata(
self.template_library["metadata"],
str(self.template_file),
context="模板库"
)
# size 一致性校验
doc_size = self.data.get("metadata", {}).get("size")
template_size = self.template_library["metadata"].get("size")
if doc_size != template_size:
raise YAMLError(
f"文档尺寸 '{doc_size}' 与模板库尺寸 '{template_size}' 不一致"
)
Decision 5: fonts_default 级联处理
决策: 在模板渲染时,如果元素未定义 font,级联查找 fonts_default:先文档后模板库。
理由:
- 文档优先级更高,允许文档覆盖模板库的默认值
- 保持灵活性,模板库提供基础配置
实现:
# core/template.py 或渲染器中
def resolve_element_font(self, font_config, doc_fonts_default, template_fonts_default):
if font_config is None:
# 级联:文档 fonts_default → 模板库 fonts_default → 系统默认
if doc_fonts_default:
return self.font_resolver_doc.resolve_font(doc_fonts_default)
elif template_fonts_default:
return self.font_resolver_template.resolve_font(template_fonts_default)
else:
return FontConfig()
else:
return self.font_resolver.resolve_font(font_config)
Decision 6: 错误代码标准化
决策: 定义统一的错误代码和错误消息模板。
理由:
- 便于测试和文档维护
- 用户提供一致的错误体验
错误代码:
SIZE_MISMATCH - 文档和模板库尺寸不一致
TEMPLATE_FONT_REF_DOC_FORBIDDEN - 模板元素引用文档字体
TEMPLATE_PARENT_REF_DOC_FORBIDDEN - 模板库 parent 引用文档字体
FONT_NOT_FOUND - 字体配置不存在
CIRCULAR_REFERENCE - 循环引用(包括跨域)
FONT_DEFAULT_INVALID - fonts_default 引用无效
TEMPLATE_LIBRARY_MISSING_METADATA - 模板库缺少 metadata 字段
TEMPLATE_LIBRARY_METADATA_MISSING_SIZE - 模板库 metadata 缺少 size
TEMPLATE_LIBRARY_METADATA_INVALID_SIZE - 模板库 metadata.size 无效
Risks / Trade-offs
Risk 1: 旧测试和代码清理
风险: 破坏性变更可能遗留旧的测试代码和逻辑,导致测试失败或行为不一致。
缓解措施:
- 系统性识别所有假设模板库没有 metadata 的测试
- 移除向后兼容性相关的代码路径
- 确保所有错误代码和错误消息一致
Risk 2: 跨域引用的复杂性
风险: 跨域引用规则增加理解和使用复杂度。
缓解措施:
- 提供清晰的错误消息,明确指出违反了哪条规则
- 在文档中详细说明引用规则
- 添加验证测试覆盖所有场景
Risk 3: 性能影响
风险: 跨域引用和循环检测可能增加解析时间。
缓解措施:
- 最大深度限制(10 层)防止无限循环
- 早期失败机制避免无效配置的深度解析
- 缓存已解析的字体配置(如果需要)
Risk 4: 测试覆盖不足
风险: 跨域场景复杂,可能遗漏边界情况。
缓解措施:
- 编写全面的单元测试和集成测试
- 使用属性测试(property-based testing)覆盖边界情况
- 添加循环引用的压力测试
实施步骤
阶段 1: 清理旧测试
- 识别并移除与旧模板库逻辑相关的测试
- 移除假设模板库没有 metadata 的测试场景
阶段 2: 实现核心功能
- 添加统一 metadata 验证函数
- 扩展 FontResolver 支持作用域
- 实现跨域引用和循环检测
- 添加 size 一致性校验
阶段 3: 添加新测试
- 添加 metadata 验证相关测试
- 添加跨域字体引用测试
- 添加 size 一致性校验测试
- 添加 fonts_default 级联测试
阶段 4: 更新文档
- 更新 README.md 和 README_DEV.md
- 添加示例配置文件
- 同步更新相关规范文档
Open Questions
Q1: 是否需要支持模板库的 metadata 继承?
问题: 如果文档和模板库都有 metadata,是否允许模板库的某些字段被文档覆盖?
当前决策: 不支持继承,size 必须严格一致,其他字段各自独立。
Q2: fonts_default 的级联是否应该在验证阶段检测?
问题: 是否需要在验证阶段检查 fonts_default 引用的有效性?
当前决策: 在渲染时动态解析,验证阶段只检查格式。
Q3: 是否需要提供诊断命令?
问题: 是否需要提供一个命令来显示字体引用链,帮助用户调试?
当前决策: 暂不实现,通过错误消息提供足够信息。
Implementation Notes
代码组织
loaders/yaml_loader.py
- validate_metadata() # 新增:统一 metadata 验证
- validate_template_library_yaml() # 修改:调用 validate_metadata
validators/validator.py
- Validator # 修改:添加 size 一致性校验
core/presentation.py
- Presentation.__init__() # 修改:解析模板库 metadata
- Presentation.get_template() # 修改:传递字体配置
core/template.py
- Template.from_data() # 修改:接收作用域参数
- Template.render() # 修改:应用正确的字体解析器
utils/font_resolver.py
- FontResolver.__init__() # 修改:支持作用域和跨域
- FontResolver._resolve_reference() # 修改:跨域引用检测
- FontResolver._check_circular() # 新增:跨域循环检测
测试策略
清理旧测试
tests/unit/test_template.py
- 移除假设模板库没有 metadata 的测试场景
- 移除向后兼容性相关的测试
tests/integration/test_template_library.py
- 移除假设模板库可选 metadata 的测试
- 重新设计基于新 metadata 结构的测试
添加新测试
tests/unit/test_yaml_loader.py
- 添加 validate_metadata() 测试
- 添加模板库 metadata 必须存在的测试
tests/unit/test_font_resolver.py
- 添加跨域引用测试
- 添加跨域循环检测测试
- 添加作用域限制测试
tests/integration/test_font_cross_domain.py # 新增
- 测试文档元素引用模板库字体
- 测试模板元素只能引用模板库字体
- 测试 parent 跨域引用规则
- 测试 fonts_default 级联
tests/integration/test_size_validation.py # 新增
- 测试 size 一致性校验
- 测试模板库缺少 metadata
- 测试模板库缺少 size
- 测试模板库 size 无效
数据流
文档加载
↓
验证文档 metadata
↓
加载模板库(如果有)
↓
验证模板库 metadata
↓
size 一致性校验
↓
创建 FontResolver(文档作用域)
创建 FontResolver(模板库作用域)
↓
渲染幻灯片
- 文档元素:使用文档 FontResolver(可引用模板库)
- 内联模板元素:使用文档 FontResolver
- 外部模板元素:使用模板库 FontResolver(不能引用文档)