# 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 字段时只需修改一处 **实现**: ```python # 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 的简洁性 - 通过作用域参数控制引用行为 - 支持跨域引用检测 **实现**: ```python # 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: 跨域循环引用检测 **决策**: 在循环引用检测中增加作用域追踪,检测跨域循环。 **理由**: - 防止文档和模板库之间形成循环引用 - 提供清晰的错误消息,帮助用户定位问题 **实现**: ```python 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 一致性校验。 **理由**: - 早期失败,避免后续处理才发现问题 - 错误消息更清晰,用户可以立即修正 **实现**: ```python # 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:先文档后模板库。 **理由**: - 文档优先级更高,允许文档覆盖模板库的默认值 - 保持灵活性,模板库提供基础配置 **实现**: ```python # 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: 清理旧测试 1. 识别并移除与旧模板库逻辑相关的测试 2. 移除假设模板库没有 metadata 的测试场景 ### 阶段 2: 实现核心功能 1. 添加统一 metadata 验证函数 2. 扩展 FontResolver 支持作用域 3. 实现跨域引用和循环检测 4. 添加 size 一致性校验 ### 阶段 3: 添加新测试 1. 添加 metadata 验证相关测试 2. 添加跨域字体引用测试 3. 添加 size 一致性校验测试 4. 添加 fonts_default 级联测试 ### 阶段 4: 更新文档 1. 更新 README.md 和 README_DEV.md 2. 添加示例配置文件 3. 同步更新相关规范文档 ## 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(不能引用文档) ```