1
0
Files
PPTX/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/design.md
lanyuanxiaoyao 98098dc911 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>
2026-03-05 18:12:05 +08:00

13 KiB
Raw Blame History

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:
            # 文档作用域或允许跨域:优先 fontsfallback 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: 清理旧测试

  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不能引用文档