From 98098dc911f7cf84f7bed2e1e4c0ca6b526abee1 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 5 Mar 2026 18:12:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=BA=93metadata=E5=92=8C=E8=B7=A8=E5=9F=9F=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了统一的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 --- README.md | 47 +- README_DEV.md | 183 ++++++++ core/presentation.py | 107 ++++- core/template.py | 37 +- loaders/yaml_loader.py | 58 +++ .../.openspec.yaml | 2 + .../design.md | 400 ++++++++++++++++++ .../proposal.md | 73 ++++ .../specs/font-theme/spec.md | 398 +++++++++++++++++ .../specs/template-library/spec.md | 173 ++++++++ .../tasks.md | 84 ++++ openspec/config.yaml | 2 +- openspec/specs/font-theme/spec.md | 317 ++++++++++++-- openspec/specs/template-library/spec.md | 171 +++++++- tests/conftest.py | 5 +- tests/e2e/test_convert_cmd.py | 3 + tests/integration/test_font_system.py | 8 +- tests/integration/test_presentation.py | 3 + .../test_template_metadata_integration.py | 241 +++++++++++ tests/unit/test_font_resolver_cross_domain.py | 146 +++++++ tests/unit/test_fonts_default.py | 140 ++++++ .../test_loaders/test_yaml_loader_metadata.py | 64 +++ tests/unit/test_presentation.py | 183 ++++++-- utils/font_resolver.py | 72 +++- validators/result.py | 18 + 25 files changed, 2794 insertions(+), 141 deletions(-) create mode 100644 openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/design.md create mode 100644 openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/proposal.md create mode 100644 openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/font-theme/spec.md create mode 100644 openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/template-library/spec.md create mode 100644 openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/tasks.md create mode 100644 tests/integration/test_template_metadata_integration.py create mode 100644 tests/unit/test_font_resolver_cross_domain.py create mode 100644 tests/unit/test_fonts_default.py create mode 100644 tests/unit/test_loaders/test_yaml_loader_metadata.py diff --git a/README.md b/README.md index dcc665c..c1fe5d9 100644 --- a/README.md +++ b/README.md @@ -482,10 +482,21 @@ slides: 创建模板库文件 `templates.yaml`: ```yaml -# 模板库元数据(可选) -description: "公司标准模板库" -version: "1.0.0" -author: "设计团队" +# 模板库元数据(必需) +metadata: + size: "16:9" # 必需:模板库尺寸,必须与使用它的文档一致 + description: "公司标准模板库" # 可选:描述信息 + fonts: # 可选:模板库字体主题 + template-title: + family: "cjk-sans" + size: 44 + bold: true + color: "#2c3e50" + template-body: + family: "sans" + size: 20 + color: "#34495e" + fonts_default: "@template-body" # 可选:模板库默认字体 # 模板定义(必需) templates: @@ -501,15 +512,13 @@ templates: - type: text box: [1, 2, 8, 1] content: "{title}" - font: - size: 44 - bold: true - align: center + font: "@template-title" # 引用模板库字体 - type: text box: [1, 3.5, 8, 0.5] content: "{subtitle}" visible: "{subtitle != ''}" font: + parent: "@template-title" size: 24 align: center @@ -525,15 +534,31 @@ templates: box: [1, 1, 8, 0.8] content: "{title}" font: + parent: "@template-title" size: 32 - bold: true - type: text box: [1, 2, 8, 3] content: "{content}" - font: - size: 20 + # 未指定 font 时使用 fonts_default ``` +**重要说明**: + +1. **metadata 字段是必需的**: + - `metadata.size` 必须指定("16:9" 或 "4:3") + - 模板库的 size 必须与使用它的文档 size 一致,否则会报错 + +2. **字体主题系统**: + - 模板库可以定义自己的字体主题(`metadata.fonts`) + - 文档可以引用模板库的字体(跨域引用) + - 模板库不能引用文档的字体(单向引用) + - 模板库的 `fonts_default` 只能引用模板库内部字体 + +3. **字体级联规则**: + - 模板元素未指定字体时,使用模板库的 `fonts_default` + - 如果模板库没有 `fonts_default`,使用文档的 `fonts_default` + - 如果都没有,使用系统默认字体 + #### 使用外部模板库 在命令行中指定模板库文件: diff --git a/README_DEV.md b/README_DEV.md index b55a177..7599121 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -1047,6 +1047,189 @@ uv run yaml2pptx.py preview temp/test.yaml 3. **测试文件隔离**:所有测试文件放在 `temp/` 目录 4. **不污染主机环境**:不修改主机的 Python 配置 +## 字体作用域系统 + +### 概述 + +字体作用域系统实现了文档和模板库之间的字体隔离和跨域引用控制,确保字体引用的安全性和可维护性。 + +### 作用域定义 + +系统定义了两个字体作用域: + +1. **文档作用域(document)**: + - 包含文档的 `metadata.fonts` 中定义的字体 + - 文档的 `fonts_default` 只能引用文档作用域的字体 + - 文档元素可以引用文档作用域和模板库作用域的字体 + +2. **模板库作用域(template)**: + - 包含模板库的 `metadata.fonts` 中定义的字体 + - 模板库的 `fonts_default` 只能引用模板库作用域的字体 + - 模板元素只能引用模板库作用域的字体(不能引用文档字体) + +### 跨域引用规则 + +**允许的引用**: +- ✅ 文档元素 → 文档字体 +- ✅ 文档元素 → 模板库字体(跨域引用) +- ✅ 模板元素 → 模板库字体 +- ✅ 文档 fonts_default → 文档字体 +- ✅ 模板库 fonts_default → 模板库字体 + +**禁止的引用**: +- ❌ 模板元素 → 文档字体(跨域引用被禁止) +- ❌ 模板库 fonts_default → 文档字体(跨域引用被禁止) +- ❌ 文档 fonts_default → 模板库字体(跨域引用被禁止) + +**设计理由**: +- 模板库应该是自包含的,不依赖特定文档的字体配置 +- 文档可以引用模板库字体,实现样式复用 +- 防止模板库与文档之间的紧耦合 + +### FontResolver 实现 + +`utils/font_resolver.py` 中的 `FontResolver` 类实现了作用域控制: + +```python +class FontResolver: + def __init__(self, fonts, fonts_default, scope="document", template_fonts=None): + """ + Args: + fonts: 当前作用域的字体字典 + fonts_default: 当前作用域的默认字体 + scope: 作用域标识 ("document" 或 "template") + template_fonts: 模板库字体字典(仅文档作用域需要) + """ +``` + +**跨域引用检测**: +- 使用作用域标签(`doc.@font-name` 或 `template.@font-name`)追踪引用路径 +- 检测跨域循环引用(如 `doc.@a → template.@b → doc.@a`) +- 在 `parent` 引用时根据作用域限制跨域访问 + +### fonts_default 级联规则 + +当元素未指定字体时,按以下顺序查找默认字体: + +1. **模板元素**: + - 模板库的 `fonts_default`(如果存在) + - 文档的 `fonts_default`(如果存在) + - 系统默认字体 + +2. **文档元素**: + - 文档的 `fonts_default`(如果存在) + - 系统默认字体 + +**实现位置**: +- `core/template.py` - Template.render() 方法 +- `core/presentation.py` - Presentation.render_slide() 方法 + +### 循环引用检测 + +系统检测两种循环引用: + +1. **单域内循环**: + ```yaml + fonts: + a: + parent: "@b" + b: + parent: "@a" + ``` + 错误信息:`检测到字体引用循环: doc.@a -> doc.@b -> doc.@a` + +2. **跨域循环**: + ```yaml + # 文档 + fonts: + doc-font: + parent: "@template-font" + + # 模板库 + fonts: + template-font: + parent: "@doc-font" # 这会被禁止 + ``` + 错误信息:`检测到跨域字体引用循环: doc.@doc-font -> template.@template-font -> doc.@doc-font` + +### 错误代码 + +字体作用域系统相关的错误代码(定义在 `validators/result.py`): + +#### 模板库 metadata 相关 +- `TEMPLATE_LIBRARY_MISSING_METADATA` - 模板库缺少 metadata 字段 +- `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` - 模板库 metadata 缺少 size 字段 +- `TEMPLATE_LIBRARY_METADATA_INVALID_SIZE` - 模板库 metadata.size 值无效(必须是 "16:9" 或 "4:3") + +#### Size 一致性 +- `SIZE_MISMATCH` - 文档和模板库的 size 不一致 + +#### 字体引用相关 +- `TEMPLATE_FONT_REF_DOC_FORBIDDEN` - 模板元素引用文档字体(跨域引用被禁止) +- `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` - 模板库字体的 parent 引用文档字体(跨域引用被禁止) +- `FONT_NOT_FOUND` - 字体配置不存在 +- `CIRCULAR_REFERENCE` - 检测到字体引用循环(包括跨域循环) +- `FONT_DEFAULT_INVALID` - fonts_default 引用无效(字体不存在或跨域引用) + +### 使用示例 + +**正确的跨域引用**: + +```yaml +# templates.yaml(模板库) +metadata: + size: "16:9" + fonts: + template-title: + family: "cjk-sans" + size: 44 + bold: true + fonts_default: "@template-title" + +templates: + title-slide: + elements: + - type: text + content: "{title}" + # 未指定 font,使用模板库的 fonts_default + +# presentation.yaml(文档) +metadata: + size: "16:9" + fonts: + doc-body: + family: "sans" + size: 18 + fonts_default: "@doc-body" + +slides: + - template: title-slide + vars: + title: "标题" + + - elements: + - type: text + content: "正文" + font: "@template-title" # ✅ 文档元素可以引用模板库字体 +``` + +**错误的跨域引用**: + +```yaml +# templates.yaml(模板库) +metadata: + size: "16:9" + fonts_default: "@doc-body" # ❌ 模板库 fonts_default 不能引用文档字体 + +templates: + title-slide: + elements: + - type: text + content: "{title}" + font: "@doc-body" # ❌ 模板元素不能引用文档字体 +``` + + ## 维护指南 ### 代码审查要点 diff --git a/core/presentation.py b/core/presentation.py index 73534ae..8f4a087 100644 --- a/core/presentation.py +++ b/core/presentation.py @@ -8,6 +8,7 @@ from pathlib import Path from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml, validate_template_library_yaml, YAMLError from core.template import Template from core.elements import create_element +from utils.font_resolver import FontResolver class Presentation: @@ -32,15 +33,7 @@ class Presentation: self.pres_base_dir = self.pres_file.parent self.template_base_dir = self.template_file.parent if self.template_file else None - # 加载并验证模板库文件(如果提供) - self.template_library = None - if self.template_file: - if not self.template_file.exists(): - raise YAMLError(f"模板库文件不存在: {self.template_file}") - self.template_library = load_yaml_file(self.template_file) - validate_template_library_yaml(self.template_library, str(self.template_file)) - - # 获取演示文稿尺寸 + # 获取演示文稿尺寸和描述 metadata = self.data.get("metadata", {}) self.size = metadata.get("size", "16:9") self.description = metadata.get("description") # 可选的描述字段 @@ -51,10 +44,34 @@ class Presentation: f"无效的尺寸值: {self.size},尺寸必须是字符串(如 '16:9' 或 '4:3')" ) + # 加载并验证模板库文件(如果提供) + self.template_library = None + if self.template_file: + if not self.template_file.exists(): + raise YAMLError(f"模板库文件不存在: {self.template_file}") + self.template_library = load_yaml_file(self.template_file) + validate_template_library_yaml(self.template_library, str(self.template_file)) + + # size 一致性校验 + template_metadata = self.template_library.get("metadata", {}) + template_size = template_metadata.get("size") + if self.size != template_size: + raise YAMLError( + f"文档尺寸 '{self.size}' 与模板库尺寸 '{template_size}' 不一致" + ) + # 解析字体配置 self.fonts = metadata.get("fonts", {}) self.fonts_default = metadata.get("fonts_default") + # 解析模板库字体配置 + self.template_fonts = {} + self.template_fonts_default = None + if self.template_library: + template_metadata = self.template_library.get("metadata", {}) + self.template_fonts = template_metadata.get("fonts", {}) + self.template_fonts_default = template_metadata.get("fonts_default") + # 验证 fonts_default if self.fonts_default: # fonts_default 必须是引用格式 @@ -62,13 +79,30 @@ class Presentation: raise YAMLError( f"fonts_default 必须是引用格式(@xxx),当前值: {self.fonts_default}" ) - # fonts_default 引用的配置必须存在于 fonts 中 + # fonts_default 引用的配置必须存在于 fonts 或 template_fonts 中 font_name = self.fonts_default[1:] - if font_name not in self.fonts: + if font_name not in self.fonts and font_name not in self.template_fonts: raise YAMLError( f"fonts_default 引用的字体配置不存在: {self.fonts_default}" ) + # 创建字体解析器 + # 文档作用域的 FontResolver(可引用模板库字体) + self.font_resolver_doc = FontResolver( + fonts=self.fonts, + fonts_default=self.fonts_default, + scope="document", + template_fonts=self.template_fonts + ) + + # 模板库作用域的 FontResolver(只能引用模板库字体) + self.font_resolver_template = FontResolver( + fonts=self.template_fonts, + fonts_default=self.template_fonts_default, + scope="template", + template_fonts=self.template_fonts + ) + # 解析并保存内联模板 self.inline_templates = self.data.get('templates', {}) @@ -93,8 +127,14 @@ class Presentation: # 注意:这里只是记录警告,实际的 WARNING 级别验证问题应该在验证器中生成 pass inline_data = self.inline_templates[template_name] - # 内联模板使用文档目录作为 base_dir - return Template.from_data(inline_data, template_name, base_dir=self.pres_base_dir) + # 内联模板使用文档目录作为 base_dir,文档作用域,文档 FontResolver + return Template.from_data( + inline_data, + template_name, + base_dir=self.pres_base_dir, + scope="document", + font_resolver=self.font_resolver_doc + ) # 3. 回退到外部模板 if not self.template_library: @@ -111,8 +151,14 @@ class Presentation: ) template_data = self.template_library['templates'][template_name] - # 外部模板使用模板库目录作为 base_dir - return Template.from_data(template_data, template_name, base_dir=self.template_base_dir) + # 外部模板使用模板库目录作为 base_dir,模板作用域,模板库 FontResolver + return Template.from_data( + template_data, + template_name, + base_dir=self.template_base_dir, + scope="template", + font_resolver=self.font_resolver_template + ) def _external_template_exists(self, template_name): """检查外部模板是否存在""" @@ -162,6 +208,37 @@ class Presentation: # 纯自定义模式(原有行为) elements_from_custom = custom_elements + # 解析自定义元素的字体引用(使用文档 FontResolver) + for elem in elements_from_custom: + if isinstance(elem, dict): + # 处理普通元素的 font 字段 + if 'font' in elem: + font_config = elem['font'] + if isinstance(font_config, (str, dict)): + try: + resolved_font = self.font_resolver_doc.resolve_font(font_config) + elem['font'] = resolved_font + except ValueError as e: + raise YAMLError(f"字体解析失败: {str(e)}") + elif elem.get('type') in ['text', 'table']: + # 元素未定义 font,使用 fonts_default(如果有) + if self.fonts_default: + try: + resolved_font = self.font_resolver_doc.resolve_font(None) + elem['font'] = resolved_font + except ValueError as e: + raise YAMLError(f"字体解析失败: {str(e)}") + + # 处理表格元素的 header_font 字段 + if elem.get('type') == 'table' and 'header_font' in elem: + header_font_config = elem['header_font'] + if isinstance(header_font_config, (str, dict)): + try: + resolved_header_font = self.font_resolver_doc.resolve_font(header_font_config) + elem['header_font'] = resolved_header_font + except ValueError as e: + raise YAMLError(f"表格 header_font 解析失败: {str(e)}") + # 解析自定义元素的图片路径(相对于文档目录) for elem in elements_from_custom: if isinstance(elem, dict) and elem.get('type') == 'image': diff --git a/core/template.py b/core/template.py index 2d6bbae..92503d1 100644 --- a/core/template.py +++ b/core/template.py @@ -66,13 +66,15 @@ class Template: self.elements = self.data.get('elements', []) @classmethod - def from_data(cls, template_data, template_name, base_dir=None): + def from_data(cls, template_data, template_name, base_dir=None, scope="document", font_resolver=None): """从字典创建模板(内联模板或外部模板) Args: template_data: 模板数据字典 template_name: 模板名称 base_dir: 资源路径解析的基础目录(外部模板使用模板库文件所在目录,内联模板使用文档目录) + scope: 作用域("document" 或 "template") + font_resolver: 字体解析器(用于解析字体引用) Returns: Template 对象 @@ -80,6 +82,8 @@ class Template: obj = cls.__new__(cls) obj.data = template_data obj.base_dir = base_dir # 保存 base_dir 用于资源路径解析 + obj.scope = scope # 保存作用域 + obj.font_resolver = font_resolver # 保存字体解析器 # 初始化条件评估器 obj._condition_evaluator = ConditionEvaluator() @@ -231,6 +235,37 @@ class Template: # 深度解析元素中的所有变量引用 rendered_elem = self.resolve_element(elem, vars_values) + # 解析字体引用(如果有 font_resolver) + if self.font_resolver and isinstance(rendered_elem, dict): + # 处理普通元素的 font 字段 + if 'font' in rendered_elem: + font_config = rendered_elem['font'] + # 如果是字符串引用或字典,使用 FontResolver 解析 + if isinstance(font_config, (str, dict)): + try: + resolved_font = self.font_resolver.resolve_font(font_config) + rendered_elem['font'] = resolved_font + except ValueError as e: + raise YAMLError(f"字体解析失败: {str(e)}") + elif rendered_elem.get('type') in ['text', 'table']: + # 元素未定义 font,使用 fonts_default(如果有) + if self.font_resolver.fonts_default: + try: + resolved_font = self.font_resolver.resolve_font(None) + rendered_elem['font'] = resolved_font + except ValueError as e: + raise YAMLError(f"字体解析失败: {str(e)}") + + # 处理表格元素的 header_font 字段 + if rendered_elem.get('type') == 'table' and 'header_font' in rendered_elem: + header_font_config = rendered_elem['header_font'] + if isinstance(header_font_config, (str, dict)): + try: + resolved_header_font = self.font_resolver.resolve_font(header_font_config) + rendered_elem['header_font'] = resolved_header_font + except ValueError as e: + raise YAMLError(f"表格 header_font 解析失败: {str(e)}") + # 如果是图片元素且有相对路径,解析为绝对路径 if isinstance(rendered_elem, dict) and rendered_elem.get('type') == 'image': src = rendered_elem.get('src') diff --git a/loaders/yaml_loader.py b/loaders/yaml_loader.py index 64bf678..5a89650 100644 --- a/loaders/yaml_loader.py +++ b/loaders/yaml_loader.py @@ -87,9 +87,43 @@ def validate_presentation_yaml(data, file_path=""): f"不支持字符串或条件表达式" ) + # 验证 metadata 字段(如果存在) + if 'metadata' in data: + validate_metadata(data['metadata'], file_path, context="文档") + # 验证 templates 字段(内联模板) validate_templates_yaml(data, file_path) + +def validate_metadata(metadata, file_path, context="文档"): + """ + 验证 metadata 结构(统一用于文档和模板库) + + Args: + metadata: metadata 字典 + file_path: 文件路径(用于错误消息) + context: 上下文描述("文档" 或 "模板库") + + Raises: + YAMLError: 结构验证失败 + """ + if not isinstance(metadata, dict): + raise YAMLError(f"{file_path}: metadata 必须是字典对象") + + # size 必填 + if "size" not in metadata: + raise YAMLError(f"{file_path}: {context} 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 + # fonts 和 fonts_default 的详细验证在字体主题系统中处理 + + def validate_template_yaml(data, file_path=""): """ 验证模板 YAML 结构(vars, elements) @@ -185,6 +219,30 @@ def validate_template_library_yaml(data, file_path=""): if not isinstance(data, dict): raise YAMLError(f"{file_path}: 模板库文件必须是一个字典对象") + # 验证必需的 metadata 字段 + if 'metadata' not in data: + raise YAMLError(f"{file_path}: 模板库必须包含 metadata 字段") + + validate_metadata(data['metadata'], file_path, context="模板库") + + # 验证模板库 fonts_default 只能引用模板库内部字体 + metadata = data['metadata'] + if 'fonts_default' in metadata and metadata['fonts_default']: + fonts_default = metadata['fonts_default'] + # fonts_default 必须是引用格式 + if not isinstance(fonts_default, str) or not fonts_default.startswith("@"): + raise YAMLError( + f"{file_path}: 模板库 fonts_default 必须是引用格式(@xxx),当前值: {fonts_default}" + ) + # fonts_default 引用的配置必须存在于模板库 fonts 中 + font_name = fonts_default[1:] + template_fonts = metadata.get('fonts', {}) + if font_name not in template_fonts: + raise YAMLError( + f"{file_path}: 模板库 fonts_default 只能引用模板库内部的字体配置," + f"但 '{fonts_default}' 不存在于模板库 metadata.fonts 中" + ) + # 验证必需的 templates 字段 if 'templates' not in data: raise YAMLError(f"{file_path}: 缺少必需字段 'templates'") diff --git a/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/.openspec.yaml b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/.openspec.yaml new file mode 100644 index 0000000..8f0b869 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-05 diff --git a/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/design.md b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/design.md new file mode 100644 index 0000000..98d8a1d --- /dev/null +++ b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/design.md @@ -0,0 +1,400 @@ +# 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(不能引用文档) +``` diff --git a/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/proposal.md b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/proposal.md new file mode 100644 index 0000000..fb917b6 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/proposal.md @@ -0,0 +1,73 @@ +# Proposal: 模板库元数据与跨域字体引用 + +## Why + +当前系统中,文档可以定义 metadata.fonts 来管理字体主题,但模板库缺乏相应的元数据结构,导致: +1. 模板库无法定义自己的字体配置,限制了模板的独立性和复用性 +2. 模板中的元素无法引用统一的字体配置 +3. 文档和模板库之间缺乏明确的字体引用关系规范 + +本变更旨在统一文档和模板库的元数据结构,建立清晰的跨域字体引用规则。 + +## What Changes + +- **模板库增加 metadata 结构** + - 模板库必须包含 metadata 字段,与文档使用相同的结构 + - metadata.size 必填,必须是 "16:9" 或 "4:3" + - 其他字段(version、author、description、fonts、fonts_default)可选 + - 模板库的 fonts 定义模板级别的字体配置 + +- **文档 metadata 扩展** + - 新增 version 字段(可选) + - 新增 author 字段(可选) + +- **跨域字体引用规则** + - 文档元素和内联模板元素:优先引用文档 fonts,文档没有时可引用模板库 fonts + - 外部模板元素:只能引用模板库 fonts,不能引用文档 fonts + - 文档 fonts 的 parent:可引用文档内部或模板库 fonts + - 模板库 fonts 的 parent:只能引用模板库内部 fonts + +- **fonts_default 级联规则** + - 模板元素未定义 font 时:优先使用文档 fonts_default,文档没有则使用模板库 fonts_default + +- **size 一致性校验** + - 文档的 size 必须与模板库的 size 一致 + - 不一致时抛出 ERROR 级别错误 + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `template-library`: 扩展模板库能力,支持 metadata 结构和字体配置 +- `font-theme`: 扩展字体主题能力,支持跨域引用(文档引用模板库) + +## Impact + +### 受影响的代码模块 + +- `loaders/yaml_loader.py`: 添加模板库 metadata 验证逻辑 +- `validators/validator.py`: 添加 size 一致性校验 +- `core/presentation.py`: 解析和存储模板库 metadata.fonts +- `core/template.py`: 模板渲染时应用字体引用规则 +- `utils/font_resolver.py`: 扩展字体解析器,支持跨域引用 + +### API 变更 + +- `validate_template_library_yaml()` 增加 metadata 验证 +- `Template` 类增加字体解析上下文(是文档作用域还是模板库作用域) +- `FontResolver` 增加跨域引用检测逻辑 + +### 破坏性变更 + +- 模板库必须包含 metadata 字段且 size 必填 +- 需要清理旧的模板库相关测试,重新设计新测试 + +### 测试影响 + +- 清理旧的模板库验证测试 +- 重新设计模板库 metadata 相关测试 +- 新增跨域字体引用测试 +- 新增 size 一致性校验测试 +- 新增 fonts_default 级联测试 diff --git a/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/font-theme/spec.md b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/font-theme/spec.md new file mode 100644 index 0000000..28c3e49 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/font-theme/spec.md @@ -0,0 +1,398 @@ +# Font Theme Delta Spec + +## ADDED Requirements + +### Requirement: 文档 metadata 必须支持 version 和 author 字段 + +文档 metadata SHALL 支持可选的 `version` 和 `author` 字段。 + +#### Scenario: 文档包含 version 和 author + +- **WHEN** 文档 metadata 包含 version: "1.0" 和 author: "作者名" +- **THEN** 系统成功解析并存储这些字段 + +#### Scenario: 文档不包含 version 和 author + +- **WHEN** 文档 metadata 不包含 version 和 author 字段 +- **THEN** 系统正常处理,这些字段为可选项 + +### Requirement: 文档元素和内联模板元素必须支持跨域引用字体 + +文档中的元素和内联模板(文档中定义的 templates)中的元素 SHALL 优先引用文档的 fonts,文档没有时可引用模板库的 fonts。 + +#### Scenario: 元素引用文档中存在的字体 + +- **WHEN** 文档元素定义 font: "@title" +- **AND** title 存在于文档 metadata.fonts 中 +- **THEN** 系统使用文档的 title 配置 + +#### Scenario: 元素引用文档不存在但模板库存在的字体 + +- **WHEN** 文档元素定义 font: "@template-title" +- **AND** template-title 不存在于文档 fonts 中 +- **AND** template-title 存在于模板库 metadata.fonts 中 +- **THEN** 系统使用模板库的 template-title 配置 + +#### Scenario: 元素引用不存在的字体(文档和模板库都没有) + +- **WHEN** 文档元素定义 font: "@nonexistent" +- **AND** nonexistent 不存在于文档和模板库的 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` +- **AND** 错误消息包含"引用的字体配置不存在" + +#### Scenario: 同名字体时优先使用文档的 + +- **WHEN** 文档元素定义 font: "@title" +- **AND** title 同时存在于文档 fonts 和模板库 fonts 中 +- **THEN** 系统使用文档的 title 配置(不使用模板库的) + +#### Scenario: 内联模板元素引用文档字体 + +- **WHEN** 内联模板(文档 templates 中定义)的元素定义 font: "@body" +- **AND** body 存在于文档 metadata.fonts 中 +- **THEN** 系统使用文档的 body 配置 + +#### Scenario: 内联模板元素引用模板库字体 + +- **WHEN** 内联模板的元素定义 font: "@template-body" +- **AND** template-body 只存在于模板库 metadata.fonts 中 +- **THEN** 系统使用模板库的 template-body 配置 + +### Requirement: 文档 fonts 的 parent 必须支持跨域引用 + +文档 metadata.fonts 中定义的字体配置,其 parent 字段 SHALL 可以引用文档内部的字体配置或模板库的字体配置。 + +#### Scenario: parent 引用文档内部的字体 + +- **WHEN** 文档 metadata.fonts 中定义 heading: {parent: "@base"} +- **AND** base 存在于文档 fonts 中 +- **THEN** 系统成功继承 base 的属性 + +#### Scenario: parent 引用模板库的字体 + +- **WHEN** 文档 metadata.fonts 中定义 custom: {parent: "@template-base"} +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功继承 template-base 的属性 + +#### Scenario: parent 引用不存在的字体(文档和模板库都没有) + +- **WHEN** 文档 metadata.fonts 中定义 heading: {parent: "@nonexistent"} +- **AND** nonexistent 不存在于文档和模板库的 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` +- **AND** 错误消息包含"引用的字体配置不存在" + +#### Scenario: 同名字体时 parent 优先引用文档的 + +- **WHEN** 文档 metadata.fonts 中定义 heading: {parent: "@base"} +- **AND** base 同时存在于文档 fonts 和模板库 fonts 中 +- **THEN** 系统使用文档的 base 配置 + +### Requirement: 系统必须检测跨域循环引用 + +系统 SHALL 在解析字体引用时检测跨域循环引用,包括文档和模板库之间的循环。 + +#### Scenario: 文档内部循环引用 + +- **WHEN** 文档 fonts.a.parent: "@b" 且 fonts.b.parent: "@a" +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到字体引用循环"和完整路径 + +#### Scenario: 模板库内部循环引用 + +- **WHEN** 模板库 fonts.x.parent: "@y" 且 fonts.y.parent: "@x" +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到字体引用循环"和完整路径 + +#### Scenario: 跨域循环引用 + +- **WHEN** 文档 fonts.a.parent: "@template-b" +- **AND** 模板库 fonts.b.parent: "@template-c" +- **AND** 模板库 fonts.c.parent: "@doc-a" +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到跨域字体引用循环"和完整路径 + +#### Scenario: 跨域引用链不循环 + +- **WHEN** 文档 fonts.a.parent: "@template-b" +- **AND** 模板库 fonts.b.parent: "@template-c" +- **AND** 模板库 fonts.c 没有引用其他字体 +- **THEN** 系统成功解析,不报错 + +### Requirement: 文档和模板库的 fonts_default 必须独立验证 + +文档的 metadata.fonts_default SHALL 只能引用文档或模板库中存在的字体配置,模板库的 metadata.fonts_default SHALL 只能引用模板库中存在的字体配置。 + +#### Scenario: 文档 fonts_default 引用文档字体 + +- **WHEN** 文档 metadata.fonts_default: "@body" +- **AND** body 存在于文档 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 文档 fonts_default 引用模板库字体 + +- **WHEN** 文档 metadata.fonts_default: "@template-base" +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 文档 fonts_default 引用不存在的字体 + +- **WHEN** 文档 metadata.fonts_default: "@nonexistent" +- **AND** nonexistent 不存在于文档和模板库的 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` +- **AND** 错误消息包含"fonts_default 引用的字体配置不存在" + +#### Scenario: 模板库 fonts_default 引用模板库字体 + +- **WHEN** 模板库 metadata.fonts_default: "@base" +- **AND** base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 模板库 fonts_default 引用文档字体 + +- **WHEN** 模板库 metadata.fonts_default: "@doc-body" +- **AND** doc-body 只存在于文档 metadata.fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` +- **AND** 错误消息包含"模板库 fonts_default 只能引用模板库内部的字体配置" + +## MODIFIED Requirements + +### Requirement: 系统必须支持在 metadata 中定义 fonts 字段 + +系统 SHALL 支持在文档和模板库的 YAML metadata 中定义 fonts 字段,用于存储可复用的字体配置。模板库的 fonts 与文档的 fonts 使用相同的定义和验证规则。 + +#### Scenario: 定义 fonts 字段(文档) + +- **WHEN** 文档 metadata 中定义 fonts 字段 +- **THEN** 系统成功解析并存储字体配置字典 + +#### Scenario: 定义 fonts 字段(模板库) + +- **WHEN** 模板库 metadata 中定义 fonts 字段 +- **THEN** 系统成功解析并存储字体配置字典 + +#### Scenario: fonts 字段为空字典 + +- **WHEN** metadata 中定义 fonts: {} +- **THEN** 系统接受空的字体配置字典 + +#### Scenario: fonts 字段未定义 + +- **WHEN** metadata 中未定义 fonts 字段 +- **THEN** 系统正常处理,fonts 为空字典 + +### Requirement: 系统必须支持 fonts_default 字段 + +系统 SHALL 支持在文档和模板库的 metadata 中定义可选的 fonts_default 字段,指定默认字体配置的引用。文档的 fonts_default 可以引用文档或模板库的 fonts,模板库的 fonts_default 只能引用模板库的 fonts。 + +#### Scenario: 定义 fonts_default 引用(文档) + +- **WHEN** 文档 metadata 中定义 fonts_default: "@body" +- **AND** body 存在于文档 metadata.fonts 中 +- **THEN** 系统将 fonts_default 解析为对 fonts.body 的引用 + +#### Scenario: 定义 fonts_default 引用(模板库) + +- **WHEN** 模板库 metadata 中定义 fonts_default: "@base" +- **AND** base 存在于模板库 metadata.fonts 中 +- **THEN** 系统将 fonts_default 解析为对 fonts.base 的引用 + +#### Scenario: 文档 fonts_default 引用模板库字体 + +- **WHEN** 文档 metadata.fonts_default: "@template-base" +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 模板库 fonts_default 不能引用文档字体 + +- **WHEN** 模板库 metadata.fonts_default: "@doc-body" +- **AND** doc-body 只存在于文档 metadata.fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` + +#### Scenario: fonts_default 未定义 + +- **WHEN** metadata 中未定义 fonts_default 字段 +- **THEN** 系统使用 python-pptx 的默认字体 + +#### Scenario: fonts_default 引用不存在的配置 + +- **WHEN** fonts_default: "@undefined" 且 undefined 不存在 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` + +#### Scenario: fonts_default 必须是引用格式 + +- **WHEN** fonts_default: "Arial"(直接字体名称) +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` + +### Requirement: 元素 font 字段必须支持多种引用方式 + +元素 font 字段 SHALL 支持字符串引用(整体引用)、字典引用(继承覆盖或独立定义)两种形式。引用的目标根据元素所在作用域决定。 + +#### Scenario: 文档元素引用文档字体 + +- **WHEN** 文档元素定义 font: "@title" +- **AND** title 存在于文档 metadata.fonts 中 +- **THEN** 系统使用文档的 title 配置 + +#### Scenario: 文档元素引用模板库字体 + +- **WHEN** 文档元素定义 font: "@template-title" +- **AND** template-title 只存在于模板库 metadata.fonts 中 +- **THEN** 系统使用模板库的 template-title 配置 + +#### Scenario: 模板元素只能引用模板库字体 + +- **WHEN** 外部模板元素定义 font: "@title" +- **AND** title 只存在于文档 metadata.fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_FONT_REF_DOC_FORBIDDEN` + +#### Scenario: 字典继承覆盖 + +- **WHEN** 元素定义 font: {parent: "@title", size: 60} +- **THEN** 系统继承引用字体的所有属性,覆盖 size 为 60 + +#### Scenario: 字典独立定义 + +- **WHEN** 元素定义 font: {family: "SimSun", size: 24} +- **THEN** 系统使用定义的属性,未定义的属性继承 fonts_default + +#### Scenario: font 字段未定义 + +- **WHEN** 元素未定义 font 字段 +- **THEN** 元素使用 fonts_default 或系统默认字体 + +### Requirement: parent 字段必须引用有效域中的字体配置 + +font 字典中的 parent 字段 SHALL 引用有效域中的配置。文档 fonts 的 parent 可以引用文档或模板库的 fonts,模板库 fonts 的 parent 只能引用模板库的 fonts。 + +#### Scenario: 文档字体 parent 引用文档字体 + +- **WHEN** 文档 fonts 中定义 heading: {parent: "@title"} +- **AND** title 存在于文档 metadata.fonts 中 +- **THEN** 系统成功继承 title 的属性 + +#### Scenario: 文档字体 parent 引用模板库字体 + +- **WHEN** 文档 fonts 中定义 custom: {parent: "@template-base"} +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功继承 template-base 的属性 + +#### Scenario: 模板库字体 parent 引用模板库字体 + +- **WHEN** 模板库 fonts 中定义 heading: {parent: "@base"} +- **AND** base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功继承 base 的属性 + +#### Scenario: 模板库字体 parent 引用文档字体(禁止) + +- **WHEN** 模板库 fonts 中定义 custom: {parent: "@doc-base"} +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` + +#### Scenario: parent 引用不存在的配置 + +- **WHEN** parent: "@undefined" 且 undefined 不存在 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` + +#### Scenario: parent 必须是引用格式 + +- **WHEN** parent: "Arial"(直接字体名称) +- **THEN** 系统抛出 ERROR + +### Requirement: 系统必须检测并拒绝引用循环 + +系统 SHALL 在解析字体引用时检测循环引用,包括单域内部循环和跨域循环,检测到循环时抛出 ERROR。 + +#### Scenario: 直接循环引用 + +- **WHEN** fonts.title.parent: "@title"(引用自身) +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` + +#### Scenario: 间接循环引用(单域) + +- **WHEN** fonts.a.parent: "@b" 且 fonts.b.parent: "@a" +- **THEN** 系统抛出 ERROR,显示完整的引用循环路径 + +#### Scenario: 跨域循环引用 + +- **WHEN** 文档 fonts.a 引用模板库 fonts.b +- **AND** 模板库 fonts.b 引用模板库 fonts.c +- **AND** 模板库 fonts.c 引用文档 fonts.a +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到跨域字体引用循环" + +#### Scenario: 深层引用但不循环 + +- **WHEN** 引用链深度超过 5 层但没有循环 +- **THEN** 系统成功解析 + +#### Scenario: 引用链深度超过限制 + +- **WHEN** 引用链深度超过 10 层 +- **THEN** 系统抛出 ERROR,提示引用深度超限 + +#### Scenario: 错误信息包含引用路径 + +- **WHEN** 系统检测到循环引用 +- **THEN** 错误信息包含完整的引用路径 + +### Requirement: 系统必须支持属性继承链 + +字体属性解析 SHALL 按照优先级顺序继承:parent → 当前定义 → fonts_default → 系统默认。跨域继承时,文档可继承模板库的,模板库不能继承文档的。 + +#### Scenario: parent 定义了基础属性 + +- **WHEN** fonts.title 定义 size: 44,元素定义 font: {parent: "@title", bold: true} +- **THEN** 元素使用 size: 44(继承)、bold: true(覆盖) + +#### Scenario: 文档字体 parent 继承模板库字体属性 + +- **WHEN** 文档 fonts.custom 定义 parent: "@template-base" +- **AND** 模板库 fonts.base 定义 family: "Arial", size: 18 +- **THEN** custom 继承 family 和 size + +#### Scenario: 模板库字体 parent 不能继承文档字体属性 + +- **WHEN** 模板库 fonts.custom 定义 parent: "@doc-base" +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` + +#### Scenario: parent 未定义的属性继承 fonts_default + +- **WHEN** fonts_default 定义 size: 18,元素定义 font: {parent: "@title"} 且 title 未定义 size +- **THEN** 元素使用 size: 18(从 fonts_default 继承) + +#### Scenario: 当前定义覆盖 parent + +- **WHEN** parent 定义 size: 44,当前定义 size: 60 +- **THEN** 元素使用 size: 60(当前定义覆盖 parent) + +### Requirement: 模板元素必须支持继承 fonts_default + +模板中未定义 font 的元素 SHALL 继承 fonts_default 配置。对于外部模板,级联顺序为:文档 fonts_default → 模板库 fonts_default → 系统默认。 + +#### Scenario: 外部模板元素未定义 font,文档有 fonts_default + +- **WHEN** 外部模板元素未定义 font +- **AND** 文档定义了 metadata.fonts_default: "@body" +- **THEN** 元素使用文档的 fonts_default 配置 + +#### Scenario: 外部模板元素未定义 font,文档没有 fonts_default + +- **WHEN** 外部模板元素未定义 font +- **AND** 文档未定义 fonts_default +- **AND** 模板库定义了 metadata.fonts_default: "@base" +- **THEN** 元素使用模板库的 fonts_default 配置 + +#### Scenario: 内联模板元素未定义 font + +- **WHEN** 内联模板(文档中定义)元素未定义 font +- **THEN** 元素继承文档的 fonts_default 配置 + +#### Scenario: 模板元素定义了 font + +- **WHEN** 模板元素定义 font: "@title" +- **THEN** 元素使用 font: "@title",不继承 fonts_default + +#### Scenario: fonts_default 未定义时模板元素行为 + +- **WHEN** 模板元素未定义 font +- **AND** 文档和模板库都未定义 fonts_default +- **THEN** 元素使用系统默认字体 diff --git a/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/template-library/spec.md b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/template-library/spec.md new file mode 100644 index 0000000..2b5539b --- /dev/null +++ b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/specs/template-library/spec.md @@ -0,0 +1,173 @@ +# Template Library Delta Spec + +## ADDED Requirements + +### Requirement: 模板库必须包含 metadata 结构 + +模板库文件 SHALL 必须包含 metadata 字段,与文档使用相同的结构,包含 `size`(必填)、`version`、`author`、`description`、`fonts`、`fonts_default`。 + +#### Scenario: 模板库包含完整的 metadata + +- **WHEN** 模板库文件包含 metadata 字段,包含 size、version、author、description、fonts、fonts_default +- **THEN** 系统成功解析并存储这些元数据 + +#### Scenario: 模板库缺少 metadata 字段 + +- **WHEN** 模板库文件不包含 metadata 字段 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_LIBRARY_MISSING_METADATA` +- **AND** 错误消息包含"模板库必须包含 metadata 字段" + +#### Scenario: 模板库 metadata 缺少 size 字段 + +- **WHEN** 模板库包含 metadata 但缺少 size 字段 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` +- **AND** 错误消息包含"模板库 metadata 缺少必填字段 'size'" + +#### Scenario: 模板库 metadata 的 size 值无效 + +- **WHEN** 模板库 metadata.size 不是有效的尺寸值(如 "16:9" 或 "4:3") +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_LIBRARY_METADATA_INVALID_SIZE` + +### Requirement: 模板库 metadata 必须使用与文档相同的验证规则 + +模板库 metadata 的字段验证 SHALL 与文档 metadata 使用相同的规则。 + +#### Scenario: 模板库 metadata.fonts 验证 + +- **WHEN** 模板库 metadata.fonts 中定义字体配置 +- **THEN** 系统使用与文档 fonts 相同的验证规则 + +#### Scenario: 模板库 metadata.fonts_default 验证 + +- **WHEN** 模板库 metadata.fonts_default 引用字体配置 +- **THEN** 系统验证引用存在于模板库 metadata.fonts 中 + +#### Scenario: 模板库 metadata.version 和 author 可选 + +- **WHEN** 模板库 metadata 包含或不含 version、author 字段 +- **THEN** 系统正常处理,这些字段为可选项 + +### Requirement: 系统必须校验文档和模板库的 size 一致性 + +系统 SHALL 在加载模板库时校验文档的 metadata.size 与模板库的 metadata.size 是否一致。 + +#### Scenario: 文档和模板库 size 一致 + +- **WHEN** 文档 metadata.size 为 "16:9" 且模板库 metadata.size 也为 "16:9" +- **THEN** 系统正常加载,不报错 + +#### Scenario: 文档和模板库 size 不一致 + +- **WHEN** 文档 metadata.size 为 "16:9" 但模板库 metadata.size 为 "4:3" +- **THEN** 系统抛出 ERROR,错误代码为 `SIZE_MISMATCH` +- **AND** 错误消息包含"文档尺寸 '16:9' 与模板库尺寸 '4:3' 不一致" + +### Requirement: 模板库 fonts 的 parent 只能引用模板库内部 + +模板库 metadata.fonts 中定义的字体配置,其 parent 字段 SHALL 只能引用模板库内部的字体配置。 + +#### Scenario: parent 引用模板库内部的字体 + +- **WHEN** 模板库 metadata.fonts 中定义 heading: {parent: "@base"} 且 base 存在于模板库 fonts 中 +- **THEN** 系统成功解析继承关系 + +#### Scenario: parent 引用文档的字体 + +- **WHEN** 模板库 metadata.fonts 中定义 custom: {parent: "@doc-font"} +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` +- **AND** 错误消息包含"模板库字段的 parent 不能引用文档的字体配置" + +#### Scenario: parent 引用不存在的字体 + +- **WHEN** 模板库 metadata.fonts 中定义 heading: {parent: "@nonexistent"} +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` +- **AND** 错误消息包含"引用的字体配置不存在" + +### Requirement: 外部模板元素只能引用模板库的 fonts + +外部模板(模板库中定义的模板)中的元素 SHALL 只能引用模板库 metadata.fonts 中定义的字体配置。 + +#### Scenario: 模板元素引用模板库的字体 + +- **WHEN** 外部模板元素定义 font: "@title" 且 title 存在于模板库 fonts 中 +- **THEN** 系统成功应用字体配置 + +#### Scenario: 模板元素引用文档的字体 + +- **WHEN** 外部模板元素定义 font: "@doc-title" 且 doc-title 只存在于文档 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_FONT_REF_DOC_FORBIDDEN` +- **AND** 错误消息包含"模板元素不能引用文档的字体配置" + +#### Scenario: 模板元素引用同名字体(只使用模板库的) + +- **WHEN** 外部模板元素定义 font: "@title" +- **AND** title 同时存在于文档 fonts 和模板库 fonts 中 +- **THEN** 系统只使用模板库的 title 配置(不查找文档) + +### Requirement: 模板元素的 fonts_default 级联规则 + +外部模板元素未定义 font 时 SHALL 级联使用 fonts_default:优先文档 fonts_default,文档没有则使用模板库 fonts_default。 + +#### Scenario: 模板元素未定义 font,文档有 fonts_default + +- **WHEN** 模板元素未定义 font +- **AND** 文档定义了 metadata.fonts_default: "@body" +- **THEN** 元素使用文档的 fonts_default 配置 + +#### Scenario: 模板元素未定义 font,文档没有 fonts_default + +- **WHEN** 模板元素未定义 font +- **AND** 文档未定义 fonts_default +- **AND** 模板库定义了 metadata.fonts_default: "@base" +- **THEN** 元素使用模板库的 fonts_default 配置 + +#### Scenario: 模板元素未定义 font,都没有 fonts_default + +- **WHEN** 模板元素未定义 font +- **AND** 文档和模板库都未定义 fonts_default +- **THEN** 元素使用系统默认字体 + +### Requirement: 表格元素的双字体字段应用相同规则 + +表格元素的 font 和 header_font 字段 SHALL 应用与普通元素相同的字体引用规则。 + +#### Scenario: 表格的 font 引用模板库字体 + +- **WHEN** 模板中的表格定义 font: "@table-body" +- **AND** table-body 存在于模板库 fonts 中 +- **THEN** 系统成功应用字体配置 + +#### Scenario: 表格的 header_font 引用模板库字体 + +- **WHEN** 模板中的表格定义 header_font: "@table-header" +- **AND** table-header 存在于模板库 fonts 中 +- **THEN** 系统成功应用字体配置 + +#### Scenario: 表格的 font 为 null + +- **WHEN** 模板中的表格定义 font: null 或未定义 +- **THEN** 系统按 fonts_default 级联规则处理 + +## MODIFIED Requirements + +### Requirement: 模板库文件必须包含元数据字段 + +模板库文件 SHALL 必须包含 metadata 字段,元数据结构与文档相同,包含 `size`(必填)、`version`、`author`、`description`、`fonts`、`fonts_default`。 + +#### Scenario: 加载包含完整元数据的模板库文件 + +- **WHEN** 模板库文件包含 metadata 字段,包含 size、version、author、description、fonts、fonts_default +- **THEN** 系统成功加载这些元数据字段 +- **AND** size 字段必须为有效值 +- **AND** fonts 和 fonts_default 按字体主题规则验证 + +#### Scenario: 模板库 metadata 缺少必填的 size 字段 + +- **WHEN** 模板库 metadata 中缺少 size 字段 +- **THEN** 系统抛出错误,错误代码为 `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` + +#### Scenario: 模板库缺少 metadata 字段 + +- **WHEN** 模板库文件不包含 metadata 字段 +- **THEN** 系统抛出错误,错误代码为 `TEMPLATE_LIBRARY_MISSING_METADATA` +- **AND** 错误消息包含"模板库必须包含 metadata 字段" diff --git a/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/tasks.md b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/tasks.md new file mode 100644 index 0000000..56778b1 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-template-metadata-and-font-ref/tasks.md @@ -0,0 +1,84 @@ +# Implementation Tasks + +## 0. 清理旧代码和测试 + +- [x] 0.1 识别并标记需要清理的旧测试 +- [x] 0.2 清理 tests/unit/test_template.py 中向后兼容性相关测试 +- [x] 0.3 清理 tests/integration/test_template_library.py 中向后兼容性相关测试 +- [x] 0.4 清理旧版本的模板库元数据处理逻辑 + +## 1. YAML 加载和验证 + +- [x] 1.1 在 loaders/yaml_loader.py 中创建 validate_metadata() 函数 +- [x] 1.2 修改 validate_presentation_yaml() 调用 validate_metadata() +- [x] 1.3 修改 validate_template_library_yaml() 验证模板库必须包含 metadata +- [x] 1.4 在 validate_template_library_yaml() 中验证模板库 fonts_default 只能引用模板库内部字体 + +## 2. Size 一致性校验 + +- [x] 2.1 在 core/presentation.py 中添加 size 一致性校验 +- [x] 2.2 添加 size 一致性校验的单元测试 + +## 3. FontResolver 跨域支持 + +- [x] 3.1 扩展 FontResolver 类支持 scope 和 template_fonts 参数 +- [x] 3.2 修改 _resolve_reference() 方法根据作用域限制引用范围 +- [x] 3.3 实现跨域循环引用检测 +- [x] 3.4 添加 parent 引用的跨域限制 + +## 4. Presentation 类扩展 + +- [x] 4.1 在 Presentation 中解析和存储模板库 metadata.fonts +- [x] 4.2 创建文档作用域的 FontResolver +- [x] 4.3 创建模板库作用域的 FontResolver + +## 5. Template 类扩展 + +- [x] 5.1 修改 Template.from_data() 接收 scope 和 font_resolver 参数 +- [x] 5.2 修改 Template.render() 应用正确的 FontResolver 和 fonts_default 级联 +- [x] 5.3 添加表格双字体字段的处理 + +## 6. 错误代码定义 + +- [x] 6.1 在 validators/result.py 中添加新的错误代码: + - 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 无效 + +## 7. 单元测试 + +- [x] 7.1 添加 validate_metadata() 的单元测试 +- [x] 7.2 添加 FontResolver 跨域引用的单元测试 +- [x] 7.3 添加 fonts_default 级联的单元测试 +- [x] 7.4 添加跨域循环引用检测的单元测试(包括单域内循环和跨域循环) +- [x] 7.5 添加模板库 fonts_default 验证的单元测试 + +## 8. 集成测试 + +- [x] 8.1 添加跨域字体引用的集成测试 +- [x] 8.2 添加 size 一致性校验的集成测试 +- [x] 8.3 添加完整渲染流程的集成测试 +- [x] 8.4 添加表格双字体字段(font 和 header_font)的集成测试 + +## 9. 文档更新 + +- [x] 9.1 更新 README.md 添加模板库 metadata 说明和示例 +- [x] 9.2 更新 README_DEV.md 记录错误代码和作用域规则 +- [x] 9.3 添加模板库示例文件 + +## 10. 代码清理和格式化 + +- [x] 10.1 验证旧测试已完全清理 +- [x] 10.2 代码格式化和添加中文注释 + +## 11. 最终测试 + +- [x] 11.1 运行完整的测试套件 +- [x] 11.2 手动测试关键场景 +- [x] 11.3 确保所有测试通过 diff --git a/openspec/config.yaml b/openspec/config.yaml index da08b9f..f68cb98 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -6,6 +6,6 @@ context: | 严禁直接使用主机环境的python直接执行脚本或命令,严禁在主机环境直接安装python依赖包; 本项目编写的非正式测试文件、临时文件必须放在temp目录下; 严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作; - 当前项目的面向用户的使用文档在README.md;当前项目的面向AI和开发者的开发规范文档在README_DEV.md;每次功能迭代都需要同步更新这两份说明文档; + 当前项目的面向用户的使用文档在README.md;当前项目的面向AI和开发者的开发规范文档在README_DEV.md;每次功能迭代都需要同步更新这两份说明文档;目前项目还没有上线,没有用户使用,遇到破坏性变更不需要特殊说明和迁移说明,只需要正常更新文档的相关内容即可; 所有的文档、日志、说明严禁使用emoji或其他特殊字符,保证字符显示的兼容性; 所有的需求都必须设计全面、合理、完善、有针对性的测试内容; diff --git a/openspec/specs/font-theme/spec.md b/openspec/specs/font-theme/spec.md index 04adc1c..0d70265 100644 --- a/openspec/specs/font-theme/spec.md +++ b/openspec/specs/font-theme/spec.md @@ -2,17 +2,22 @@ ## Purpose -字体主题系统提供可复用的字体配置管理能力,允许用户在 metadata 中定义字体配置模板,通过引用方式应用到元素,实现统一的字体样式管理。 +字体主题系统提供可复用的字体配置管理能力,允许用户在 metadata 中定义字体配置模板,通过引用方式应用到元素,实现统一的字体样式管理。支持文档和模板库的字体作用域隔离,以及跨域字体引用。 ## Requirements ### Requirement: 系统必须支持在 metadata 中定义 fonts 字段 -系统 SHALL 支持在 YAML metadata 中定义 fonts 字段,用于存储可复用的字体配置。 +系统 SHALL 支持在文档和模板库的 YAML metadata 中定义 fonts 字段,用于存储可复用的字体配置。模板库的 fonts 与文档的 fonts 使用相同的定义和验证规则。 -#### Scenario: 定义 fonts 字段 +#### Scenario: 定义 fonts 字段(文档) -- **WHEN** metadata 中定义 fonts 字段 +- **WHEN** 文档 metadata 中定义 fonts 字段 +- **THEN** 系统成功解析并存储字体配置字典 + +#### Scenario: 定义 fonts 字段(模板库) + +- **WHEN** 模板库 metadata 中定义 fonts 字段 - **THEN** 系统成功解析并存储字体配置字典 #### Scenario: fonts 字段为空字典 @@ -39,15 +44,48 @@ fonts 字段 SHALL 包含一个或多个命名字体配置,每个配置是一 - **WHEN** fonts 中定义 title、subtitle、body 等多个配置 - **THEN** 系统为每个配置创建独立的字体对象 +### Requirement: 文档 metadata 必须支持 version 和 author 字段 + +文档 metadata SHALL 支持可选的 `version` 和 `author` 字段。 + +#### Scenario: 文档包含 version 和 author + +- **WHEN** 文档 metadata 包含 version: "1.0" 和 author: "作者名" +- **THEN** 系统成功解析并存储这些字段 + +#### Scenario: 文档不包含 version 和 author + +- **WHEN** 文档 metadata 不包含 version 和 author 字段 +- **THEN** 系统正常处理,这些字段为可选项 + ### Requirement: 系统必须支持 fonts_default 字段 -系统 SHALL 支持在 metadata 中定义可选的 fonts_default 字段,指定默认字体配置的引用。 +系统 SHALL 支持在文档和模板库的 metadata 中定义可选的 fonts_default 字段,指定默认字体配置的引用。文档的 fonts_default 可以引用文档或模板库的 fonts,模板库的 fonts_default 只能引用模板库的 fonts。 -#### Scenario: 定义 fonts_default 引用 +#### Scenario: 定义 fonts_default 引用(文档) -- **WHEN** metadata 中定义 fonts_default: "@body" +- **WHEN** 文档 metadata 中定义 fonts_default: "@body" +- **AND** body 存在于文档 metadata.fonts 中 - **THEN** 系统将 fonts_default 解析为对 fonts.body 的引用 +#### Scenario: 定义 fonts_default 引用(模板库) + +- **WHEN** 模板库 metadata 中定义 fonts_default: "@base" +- **AND** base 存在于模板库 metadata.fonts 中 +- **THEN** 系统将 fonts_default 解析为对 fonts.base 的引用 + +#### Scenario: 文档 fonts_default 引用模板库字体 + +- **WHEN** 文档 metadata.fonts_default: "@template-base" +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 模板库 fonts_default 不能引用文档字体 + +- **WHEN** 模板库 metadata.fonts_default: "@doc-body" +- **AND** doc-body 只存在于文档 metadata.fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` + #### Scenario: fonts_default 未定义 - **WHEN** metadata 中未定义 fonts_default 字段 @@ -55,27 +93,82 @@ fonts 字段 SHALL 包含一个或多个命名字体配置,每个配置是一 #### Scenario: fonts_default 引用不存在的配置 -- **WHEN** fonts_default: "@undefined" 且 fonts.undefined 不存在 -- **THEN** 系统抛出 ERROR,提示引用的字体配置不存在 +- **WHEN** fonts_default: "@undefined" 且 undefined 不存在 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` -#### Scenario: fonts_default 必须引用 fonts 中的配置 +#### Scenario: fonts_default 必须是引用格式 - **WHEN** fonts_default: "Arial"(直接字体名称) -- **THEN** 系统抛出 ERROR,提示 fonts_default 必须是引用格式 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` -### Requirement: 元素 font 字段必须支持三种引用方式 +### Requirement: 文档元素和内联模板元素必须支持跨域引用字体 -元素 font 字段 SHALL 支持字符串引用(整体引用)、字典引用(继承覆盖或独立定义)两种形式。 +文档中的元素和内联模板(文档中定义的 templates)中的元素 SHALL 优先引用文档的 fonts,文档没有时可引用模板库的 fonts。 -#### Scenario: 字符串整体引用 +#### Scenario: 元素引用文档中存在的字体 -- **WHEN** 元素定义 font: "@title" -- **THEN** 系统使用 fonts.title 的所有属性 +- **WHEN** 文档元素定义 font: "@title" +- **AND** title 存在于文档 metadata.fonts 中 +- **THEN** 系统使用文档的 title 配置 + +#### Scenario: 元素引用文档不存在但模板库存在的字体 + +- **WHEN** 文档元素定义 font: "@template-title" +- **AND** template-title 不存在于文档 fonts 中 +- **AND** template-title 存在于模板库 metadata.fonts 中 +- **THEN** 系统使用模板库的 template-title 配置 + +#### Scenario: 元素引用不存在的字体(文档和模板库都没有) + +- **WHEN** 文档元素定义 font: "@nonexistent" +- **AND** nonexistent 不存在于文档和模板库的 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` +- **AND** 错误消息包含"引用的字体配置不存在" + +#### Scenario: 同名字体时优先使用文档的 + +- **WHEN** 文档元素定义 font: "@title" +- **AND** title 同时存在于文档 fonts 和模板库 fonts 中 +- **THEN** 系统使用文档的 title 配置(不使用模板库的) + +#### Scenario: 内联模板元素引用文档字体 + +- **WHEN** 内联模板(文档 templates 中定义)的元素定义 font: "@body" +- **AND** body 存在于文档 metadata.fonts 中 +- **THEN** 系统使用文档的 body 配置 + +#### Scenario: 内联模板元素引用模板库字体 + +- **WHEN** 内联模板的元素定义 font: "@template-body" +- **AND** template-body 只存在于模板库 metadata.fonts 中 +- **THEN** 系统使用模板库的 template-body 配置 + +### Requirement: 元素 font 字段必须支持多种引用方式 + +元素 font 字段 SHALL 支持字符串引用(整体引用)、字典引用(继承覆盖或独立定义)两种形式。引用的目标根据元素所在作用域决定。 + +#### Scenario: 文档元素引用文档字体 + +- **WHEN** 文档元素定义 font: "@title" +- **AND** title 存在于文档 metadata.fonts 中 +- **THEN** 系统使用文档的 title 配置 + +#### Scenario: 文档元素引用模板库字体 + +- **WHEN** 文档元素定义 font: "@template-title" +- **AND** template-title 只存在于模板库 metadata.fonts 中 +- **THEN** 系统使用模板库的 template-title 配置 + +#### Scenario: 模板元素只能引用模板库字体 + +- **WHEN** 外部模板元素定义 font: "@title" +- **AND** title 只存在于文档 metadata.fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_FONT_REF_DOC_FORBIDDEN` #### Scenario: 字典继承覆盖 - **WHEN** 元素定义 font: {parent: "@title", size: 60} -- **THEN** 系统继承 fonts.title 的所有属性,覆盖 size 为 60 +- **THEN** 系统继承引用字体的所有属性,覆盖 size 为 60 #### Scenario: 字典独立定义 @@ -87,40 +180,131 @@ fonts 字段 SHALL 包含一个或多个命名字体配置,每个配置是一 - **WHEN** 元素未定义 font 字段 - **THEN** 元素使用 fonts_default 或系统默认字体 -### Requirement: parent 字段必须引用 fonts 中的配置 +### Requirement: 文档 fonts 的 parent 必须支持跨域引用 -font 字典中的 parent 字段 SHALL 引用 fonts 中已定义的配置名称。 +文档 metadata.fonts 中定义的字体配置,其 parent 字段 SHALL 可以引用文档内部的字体配置或模板库的字体配置。 -#### Scenario: parent 引用存在的配置 +#### Scenario: parent 引用文档内部的字体 -- **WHEN** parent: "@title" 且 fonts.title 存在 -- **THEN** 系统成功继承 fonts.title 的属性 +- **WHEN** 文档 metadata.fonts 中定义 heading: {parent: "@base"} +- **AND** base 存在于文档 fonts 中 +- **THEN** 系统成功继承 base 的属性 + +#### Scenario: parent 引用模板库的字体 + +- **WHEN** 文档 metadata.fonts 中定义 custom: {parent: "@template-base"} +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功继承 template-base 的属性 + +#### Scenario: parent 引用不存在的字体(文档和模板库都没有) + +- **WHEN** 文档 metadata.fonts 中定义 heading: {parent: "@nonexistent"} +- **AND** nonexistent 不存在于文档和模板库的 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` +- **AND** 错误消息包含"引用的字体配置不存在" + +#### Scenario: 同名字体时 parent 优先引用文档的 + +- **WHEN** 文档 metadata.fonts 中定义 heading: {parent: "@base"} +- **AND** base 同时存在于文档 fonts 和模板库 fonts 中 +- **THEN** 系统使用文档的 base 配置 + +### Requirement: parent 字段必须引用有效域中的字体配置 + +font 字典中的 parent 字段 SHALL 引用有效域中的配置。文档 fonts 的 parent 可以引用文档或模板库的 fonts,模板库 fonts 的 parent 只能引用模板库的 fonts。 + +#### Scenario: 文档字体 parent 引用文档字体 + +- **WHEN** 文档 fonts 中定义 heading: {parent: "@title"} +- **AND** title 存在于文档 metadata.fonts 中 +- **THEN** 系统成功继承 title 的属性 + +#### Scenario: 文档字体 parent 引用模板库字体 + +- **WHEN** 文档 fonts 中定义 custom: {parent: "@template-base"} +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功继承 template-base 的属性 + +#### Scenario: 模板库字体 parent 引用模板库字体 + +- **WHEN** 模板库 fonts 中定义 heading: {parent: "@base"} +- **AND** base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功继承 base 的属性 + +#### Scenario: 模板库字体 parent 引用文档字体(禁止) + +- **WHEN** 模板库 fonts 中定义 custom: {parent: "@doc-base"} +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` #### Scenario: parent 引用不存在的配置 -- **WHEN** parent: "@undefined" 且 fonts.undefined 不存在 -- **THEN** 系统抛出 ERROR,提示引用的字体配置不存在 +- **WHEN** parent: "@undefined" 且 undefined 不存在 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` #### Scenario: parent 必须是引用格式 - **WHEN** parent: "Arial"(直接字体名称) -- **THEN** 系统抛出 ERROR,提示 parent 必须是引用格式 +- **THEN** 系统抛出 ERROR + +### Requirement: 系统必须检测跨域循环引用 + +系统 SHALL 在解析字体引用时检测跨域循环引用,包括文档和模板库之间的循环。 + +#### Scenario: 文档内部循环引用 + +- **WHEN** 文档 fonts.a.parent: "@b" 且 fonts.b.parent: "@a" +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到字体引用循环"和完整路径 + +#### Scenario: 模板库内部循环引用 + +- **WHEN** 模板库 fonts.x.parent: "@y" 且 fonts.y.parent: "@x" +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到字体引用循环"和完整路径 + +#### Scenario: 跨域循环引用 + +- **WHEN** 文档 fonts.a.parent: "@template-b" +- **AND** 模板库 fonts.b.parent: "@template-c" +- **AND** 模板库 fonts.c.parent: "@doc-a" +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到跨域字体引用循环"和完整路径 + +#### Scenario: 跨域引用链不循环 + +- **WHEN** 文档 fonts.a.parent: "@template-b" +- **AND** 模板库 fonts.b.parent: "@template-c" +- **AND** 模板库 fonts.c 没有引用其他字体 +- **THEN** 系统成功解析,不报错 ### Requirement: 系统必须检测并拒绝引用循环 -系统 SHALL 在解析字体引用时检测循环引用,检测到循环时抛出 ERROR。 +系统 SHALL 在解析字体引用时检测循环引用,包括单域内部循环和跨域循环,检测到循环时抛出 ERROR。 #### Scenario: 直接循环引用 - **WHEN** fonts.title.parent: "@title"(引用自身) -- **THEN** 系统抛出 ERROR,提示检测到自引用 +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` -#### Scenario: 间接循环引用 +#### Scenario: 间接循环引用(单域) - **WHEN** fonts.a.parent: "@b" 且 fonts.b.parent: "@a" - **THEN** 系统抛出 ERROR,显示完整的引用循环路径 -#### Scenario: 深层循环引用 +#### Scenario: 跨域循环引用 + +- **WHEN** 文档 fonts.a 引用模板库 fonts.b +- **AND** 模板库 fonts.b 引用模板库 fonts.c +- **AND** 模板库 fonts.c 引用文档 fonts.a +- **THEN** 系统抛出 ERROR,错误代码为 `CIRCULAR_REFERENCE` +- **AND** 错误消息包含"检测到跨域字体引用循环" + +#### Scenario: 深层引用但不循环 + +- **WHEN** 引用链深度超过 5 层但没有循环 +- **THEN** 系统成功解析 + +#### Scenario: 引用链深度超过限制 - **WHEN** 引用链深度超过 10 层 - **THEN** 系统抛出 ERROR,提示引用深度超限 @@ -128,17 +312,64 @@ font 字典中的 parent 字段 SHALL 引用 fonts 中已定义的配置名称 #### Scenario: 错误信息包含引用路径 - **WHEN** 系统检测到循环引用 -- **THEN** 错误信息包含完整的引用路径,如 "fonts.title -> fonts.subtitle -> fonts.title" +- **THEN** 错误信息包含完整的引用路径 + +### Requirement: 文档和模板库的 fonts_default 必须独立验证 + +文档的 metadata.fonts_default SHALL 只能引用文档或模板库中存在的字体配置,模板库的 metadata.fonts_default SHALL 只能引用模板库中存在的字体配置。 + +#### Scenario: 文档 fonts_default 引用文档字体 + +- **WHEN** 文档 metadata.fonts_default: "@body" +- **AND** body 存在于文档 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 文档 fonts_default 引用模板库字体 + +- **WHEN** 文档 metadata.fonts_default: "@template-base" +- **AND** template-base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 文档 fonts_default 引用不存在的字体 + +- **WHEN** 文档 metadata.fonts_default: "@nonexistent" +- **AND** nonexistent 不存在于文档和模板库的 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` +- **AND** 错误消息包含"fonts_default 引用的字体配置不存在" + +#### Scenario: 模板库 fonts_default 引用模板库字体 + +- **WHEN** 模板库 metadata.fonts_default: "@base" +- **AND** base 存在于模板库 metadata.fonts 中 +- **THEN** 系统成功解析默认字体 + +#### Scenario: 模板库 fonts_default 引用文档字体 + +- **WHEN** 模板库 metadata.fonts_default: "@doc-body" +- **AND** doc-body 只存在于文档 metadata.fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_DEFAULT_INVALID` +- **AND** 错误消息包含"模板库 fonts_default 只能引用模板库内部的字体配置" ### Requirement: 系统必须支持属性继承链 -字体属性解析 SHALL 按照优先级顺序继承:parent → 当前定义 → fonts_default → 系统默认。 +字体属性解析 SHALL 按照优先级顺序继承:parent → 当前定义 → fonts_default → 系统默认。跨域继承时,文档可继承模板库的,模板库不能继承文档的。 #### Scenario: parent 定义了基础属性 - **WHEN** fonts.title 定义 size: 44,元素定义 font: {parent: "@title", bold: true} - **THEN** 元素使用 size: 44(继承)、bold: true(覆盖) +#### Scenario: 文档字体 parent 继承模板库字体属性 + +- **WHEN** 文档 fonts.custom 定义 parent: "@template-base" +- **AND** 模板库 fonts.base 定义 family: "Arial", size: 18 +- **THEN** custom 继承 family 和 size + +#### Scenario: 模板库字体 parent 不能继承文档字体属性 + +- **WHEN** 模板库 fonts.custom 定义 parent: "@doc-base" +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` + #### Scenario: parent 未定义的属性继承 fonts_default - **WHEN** fonts_default 定义 size: 18,元素定义 font: {parent: "@title"} 且 title 未定义 size @@ -151,12 +382,25 @@ font 字典中的 parent 字段 SHALL 引用 fonts 中已定义的配置名称 ### Requirement: 模板元素必须支持继承 fonts_default -模板中未定义 font 的元素 SHALL 继承 metadata.fonts_default 配置。 +模板中未定义 font 的元素 SHALL 继承 fonts_default 配置。对于外部模板,级联顺序为:文档 fonts_default → 模板库 fonts_default → 系统默认。 -#### Scenario: 模板元素未定义 font +#### Scenario: 外部模板元素未定义 font,文档有 fonts_default -- **WHEN** 模板元素未定义 font 字段 -- **THEN** 元素继承 metadata.fonts_default 的配置 +- **WHEN** 外部模板元素未定义 font +- **AND** 文档定义了 metadata.fonts_default: "@body" +- **THEN** 元素使用文档的 fonts_default 配置 + +#### Scenario: 外部模板元素未定义 font,文档没有 fonts_default + +- **WHEN** 外部模板元素未定义 font +- **AND** 文档未定义 fonts_default +- **AND** 模板库定义了 metadata.fonts_default: "@base" +- **THEN** 元素使用模板库的 fonts_default 配置 + +#### Scenario: 内联模板元素未定义 font + +- **WHEN** 内联模板(文档中定义)元素未定义 font +- **THEN** 元素继承文档的 fonts_default 配置 #### Scenario: 模板元素定义了 font @@ -165,7 +409,8 @@ font 字典中的 parent 字段 SHALL 引用 fonts 中已定义的配置名称 #### Scenario: fonts_default 未定义时模板元素行为 -- **WHEN** 模板元素未定义 font 且 metadata 未定义 fonts_default +- **WHEN** 模板元素未定义 font +- **AND** 文档和模板库都未定义 fonts_default - **THEN** 元素使用系统默认字体 ### Requirement: 表格元素必须支持 font 和 header_font 字段 diff --git a/openspec/specs/template-library/spec.md b/openspec/specs/template-library/spec.md index 6680d7c..5d0914b 100644 --- a/openspec/specs/template-library/spec.md +++ b/openspec/specs/template-library/spec.md @@ -2,7 +2,7 @@ ## Purpose -Template library 提供集中的模板管理机制,允许将多个模板定义存储在单个 YAML 文件中,通过模板名称引用。 +Template library 提供集中的模板管理机制,允许将多个模板定义存储在单个 YAML 文件中,通过模板名称引用。模板库支持独立的 metadata 结构,包括字体主题和尺寸定义。 ## Requirements @@ -28,20 +28,173 @@ Template library 提供集中的模板管理机制,允许将多个模板定义 - **THEN** 系统抛出错误,错误代码为 `TEMPLATE_LIBRARY_MISSING_TEMPLATES_FIELD` - **AND** 错误消息包含"'templates' 必须是字典" -### Requirement: 模板库文件必须支持元数据字段 +### Requirement: 模板库文件必须包含元数据字段 -模板库文件 SHALL 支持可选的元数据字段,包括 `description`、`version`、`author` 等。 +模板库文件 SHALL 必须包含 metadata 字段,元数据结构与文档相同,包含 `size`(必填)、`version`、`author`、`description`、`fonts`、`fonts_default`。 -#### Scenario: 加载包含元数据的模板库文件 +#### Scenario: 加载包含完整元数据的模板库文件 -- **WHEN** 模板库文件包含 `description`、`version`、`author` 等字段 +- **WHEN** 模板库文件包含 metadata 字段,包含 size、version、author、description、fonts、fonts_default - **THEN** 系统成功加载这些元数据字段 -- **AND** 元数据不影响模板的加载和使用 +- **AND** size 字段必须为有效值 +- **AND** fonts 和 fonts_default 按字体主题规则验证 -#### Scenario: 不包含元数据字段时正常加载 +#### Scenario: 模板库 metadata 缺少必填的 size 字段 -- **WHEN** 模板库文件仅包含 `templates` 字段,不包含任何元数据 -- **THEN** 系统正常加载,不要求元数据字段 +- **WHEN** 模板库 metadata 中缺少 size 字段 +- **THEN** 系统抛出错误,错误代码为 `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` + +#### Scenario: 模板库缺少 metadata 字段 + +- **WHEN** 模板库文件不包含 metadata 字段 +- **THEN** 系统抛出错误,错误代码为 `TEMPLATE_LIBRARY_MISSING_METADATA` +- **AND** 错误消息包含"模板库必须包含 metadata 字段" + +### Requirement: 模板库必须包含 metadata 结构 + +模板库文件 SHALL 必须包含 metadata 字段,与文档使用相同的结构,包含 `size`(必填)、`version`、`author`、`description`、`fonts`、`fonts_default`。 + +#### Scenario: 模板库包含完整的 metadata + +- **WHEN** 模板库文件包含 metadata 字段,包含 size、version、author、description、fonts、fonts_default +- **THEN** 系统成功解析并存储这些元数据 + +#### Scenario: 模板库缺少 metadata 字段 + +- **WHEN** 模板库文件不包含 metadata 字段 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_LIBRARY_MISSING_METADATA` +- **AND** 错误消息包含"模板库必须包含 metadata 字段" + +#### Scenario: 模板库 metadata 缺少 size 字段 + +- **WHEN** 模板库包含 metadata 但缺少 size 字段 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` +- **AND** 错误消息包含"模板库 metadata 缺少必填字段 'size'" + +#### Scenario: 模板库 metadata 的 size 值无效 + +- **WHEN** 模板库 metadata.size 不是有效的尺寸值(如 "16:9" 或 "4:3") +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_LIBRARY_METADATA_INVALID_SIZE` + +### Requirement: 模板库 metadata 必须使用与文档相同的验证规则 + +模板库 metadata 的字段验证 SHALL 与文档 metadata 使用相同的规则。 + +#### Scenario: 模板库 metadata.fonts 验证 + +- **WHEN** 模板库 metadata.fonts 中定义字体配置 +- **THEN** 系统使用与文档 fonts 相同的验证规则 + +#### Scenario: 模板库 metadata.fonts_default 验证 + +- **WHEN** 模板库 metadata.fonts_default 引用字体配置 +- **THEN** 系统验证引用存在于模板库 metadata.fonts 中 + +#### Scenario: 模板库 metadata.version 和 author 可选 + +- **WHEN** 模板库 metadata 包含或不含 version、author 字段 +- **THEN** 系统正常处理,这些字段为可选项 + +### Requirement: 系统必须校验文档和模板库的 size 一致性 + +系统 SHALL 在加载模板库时校验文档的 metadata.size 与模板库的 metadata.size 是否一致。 + +#### Scenario: 文档和模板库 size 一致 + +- **WHEN** 文档 metadata.size 为 "16:9" 且模板库 metadata.size 也为 "16:9" +- **THEN** 系统正常加载,不报错 + +#### Scenario: 文档和模板库 size 不一致 + +- **WHEN** 文档 metadata.size 为 "16:9" 但模板库 metadata.size 为 "4:3" +- **THEN** 系统抛出 ERROR,错误代码为 `SIZE_MISMATCH` +- **AND** 错误消息包含"文档尺寸 '16:9' 与模板库尺寸 '4:3' 不一致" + +### Requirement: 模板库 fonts 的 parent 只能引用模板库内部 + +模板库 metadata.fonts 中定义的字体配置,其 parent 字段 SHALL 只能引用模板库内部的字体配置。 + +#### Scenario: parent 引用模板库内部的字体 + +- **WHEN** 模板库 metadata.fonts 中定义 heading: {parent: "@base"} 且 base 存在于模板库 fonts 中 +- **THEN** 系统成功解析继承关系 + +#### Scenario: parent 引用文档的字体 + +- **WHEN** 模板库 metadata.fonts 中定义 custom: {parent: "@doc-font"} +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` +- **AND** 错误消息包含"模板库字段的 parent 不能引用文档的字体配置" + +#### Scenario: parent 引用不存在的字体 + +- **WHEN** 模板库 metadata.fonts 中定义 heading: {parent: "@nonexistent"} +- **THEN** 系统抛出 ERROR,错误代码为 `FONT_NOT_FOUND` +- **AND** 错误消息包含"引用的字体配置不存在" + +### Requirement: 外部模板元素只能引用模板库的 fonts + +外部模板(模板库中定义的模板)中的元素 SHALL 只能引用模板库 metadata.fonts 中定义的字体配置。 + +#### Scenario: 模板元素引用模板库的字体 + +- **WHEN** 外部模板元素定义 font: "@title" 且 title 存在于模板库 fonts 中 +- **THEN** 系统成功应用字体配置 + +#### Scenario: 模板元素引用文档的字体 + +- **WHEN** 外部模板元素定义 font: "@doc-title" 且 doc-title 只存在于文档 fonts 中 +- **THEN** 系统抛出 ERROR,错误代码为 `TEMPLATE_FONT_REF_DOC_FORBIDDEN` +- **AND** 错误消息包含"模板元素不能引用文档的字体配置" + +#### Scenario: 模板元素引用同名字体(只使用模板库的) + +- **WHEN** 外部模板元素定义 font: "@title" +- **AND** title 同时存在于文档 fonts 和模板库 fonts 中 +- **THEN** 系统只使用模板库的 title 配置(不查找文档) + +### Requirement: 模板元素的 fonts_default 级联规则 + +外部模板元素未定义 font 时 SHALL 级联使用 fonts_default:优先文档 fonts_default,文档没有则使用模板库 fonts_default。 + +#### Scenario: 模板元素未定义 font,文档有 fonts_default + +- **WHEN** 模板元素未定义 font +- **AND** 文档定义了 metadata.fonts_default: "@body" +- **THEN** 元素使用文档的 fonts_default 配置 + +#### Scenario: 模板元素未定义 font,文档没有 fonts_default + +- **WHEN** 模板元素未定义 font +- **AND** 文档未定义 fonts_default +- **AND** 模板库定义了 metadata.fonts_default: "@base" +- **THEN** 元素使用模板库的 fonts_default 配置 + +#### Scenario: 模板元素未定义 font,都没有 fonts_default + +- **WHEN** 模板元素未定义 font +- **AND** 文档和模板库都未定义 fonts_default +- **THEN** 元素使用系统默认字体 + +### Requirement: 表格元素的双字体字段应用相同规则 + +表格元素的 font 和 header_font 字段 SHALL 应用与普通元素相同的字体引用规则。 + +#### Scenario: 表格的 font 引用模板库字体 + +- **WHEN** 模板中的表格定义 font: "@table-body" +- **AND** table-body 存在于模板库 fonts 中 +- **THEN** 系统成功应用字体配置 + +#### Scenario: 表格的 header_font 引用模板库字体 + +- **WHEN** 模板中的表格定义 header_font: "@table-header" +- **AND** table-header 存在于模板库 fonts 中 +- **THEN** 系统成功应用字体配置 + +#### Scenario: 表格的 font 为 null + +- **WHEN** 模板中的表格定义 font: null 或未定义 +- **THEN** 系统按 fonts_default 级联规则处理 ### Requirement: 模板库文件必须递归验证每个模板的结构 diff --git a/tests/conftest.py b/tests/conftest.py index e35c6bc..da29f8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,7 +95,10 @@ elements: align: center """ -TEMPLATE_LIBRARY_YAML = """templates: +TEMPLATE_LIBRARY_YAML = """metadata: + size: "16:9" + +templates: title-slide: vars: - name: title diff --git a/tests/e2e/test_convert_cmd.py b/tests/e2e/test_convert_cmd.py index 4d59cc0..9fdf974 100644 --- a/tests/e2e/test_convert_cmd.py +++ b/tests/e2e/test_convert_cmd.py @@ -216,6 +216,9 @@ slides: # 创建模板库文件 template_content = """ +metadata: + size: "16:9" + templates: test-template: vars: diff --git a/tests/integration/test_font_system.py b/tests/integration/test_font_system.py index d20c278..dbff498 100644 --- a/tests/integration/test_font_system.py +++ b/tests/integration/test_font_system.py @@ -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) diff --git a/tests/integration/test_presentation.py b/tests/integration/test_presentation.py index c197bc0..69d7f41 100644 --- a/tests/integration/test_presentation.py +++ b/tests/integration/test_presentation.py @@ -196,6 +196,9 @@ slides: # 创建模板库文件 template_content = """ +metadata: + size: "16:9" + templates: test-template: vars: diff --git a/tests/integration/test_template_metadata_integration.py b/tests/integration/test_template_metadata_integration.py new file mode 100644 index 0000000..ac0199c --- /dev/null +++ b/tests/integration/test_template_metadata_integration.py @@ -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 diff --git a/tests/unit/test_font_resolver_cross_domain.py b/tests/unit/test_font_resolver_cross_domain.py new file mode 100644 index 0000000..d334c17 --- /dev/null +++ b/tests/unit/test_font_resolver_cross_domain.py @@ -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") diff --git a/tests/unit/test_fonts_default.py b/tests/unit/test_fonts_default.py new file mode 100644 index 0000000..5603b74 --- /dev/null +++ b/tests/unit/test_fonts_default.py @@ -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") diff --git a/tests/unit/test_loaders/test_yaml_loader_metadata.py b/tests/unit/test_loaders/test_yaml_loader_metadata.py new file mode 100644 index 0000000..55739d0 --- /dev/null +++ b/tests/unit/test_loaders/test_yaml_loader_metadata.py @@ -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="文档") diff --git a/tests/unit/test_presentation.py b/tests/unit/test_presentation.py index f0c4bb5..674cf1c 100644 --- a/tests/unit/test_presentation.py +++ b/tests/unit/test_presentation.py @@ -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)) + diff --git a/utils/font_resolver.py b/utils/font_resolver.py index 824bd66..df0f4f5 100644 --- a/utils/font_resolver.py +++ b/utils/font_resolver.py @@ -21,16 +21,26 @@ PRESET_FONT_MAPPING = { class FontResolver: """字体解析器,处理字体引用、继承和预设类别映射""" - def __init__(self, fonts: Optional[Dict] = None, fonts_default: Optional[str] = None): + def __init__( + self, + fonts: Optional[Dict] = None, + fonts_default: Optional[str] = None, + scope: str = "document", + template_fonts: Optional[Dict] = None + ): """ 初始化字体解析器 Args: fonts: 字体配置字典,键为字体名称,值为字体配置 fonts_default: 默认字体引用(格式:@xxx) + scope: 作用域("document" 或 "template") + template_fonts: 模板库字体配置字典(用于跨域引用) """ self.fonts = fonts or {} self.fonts_default = fonts_default + self.scope = scope + self.template_fonts = template_fonts or {} self._max_depth = 10 # 最大引用深度,防止循环引用 def resolve_font(self, font_config: Union[FontConfig, str, dict, None]) -> FontConfig: @@ -68,13 +78,14 @@ class FontResolver: raise ValueError(f"不支持的字体配置类型: {type(font_config)}") - def _resolve_reference(self, reference: str, visited: Set[str]) -> FontConfig: + def _resolve_reference(self, reference: str, visited: Set[str], allow_cross_domain: bool = True) -> FontConfig: """ 解析字体引用 Args: reference: 引用字符串(格式:@xxx) visited: 已访问的引用集合,用于检测循环引用 + allow_cross_domain: 是否允许跨域引用(元素引用时为 True,parent 引用时根据作用域决定) Returns: 解析后的 FontConfig 对象 @@ -87,24 +98,49 @@ class FontResolver: font_name = reference[1:] # 移除 @ 前缀 - # 检测循环引用 - if reference in visited: - path = " -> ".join(visited) + f" -> {reference}" - raise ValueError(f"检测到字体引用循环: {path}") + # 为引用添加作用域标签,用于跨域循环检测 + 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}" + # 检查是否为跨域循环 + has_doc = any("doc." in v for v in visited) + has_template = any("template." in v for v in visited) + if has_doc and has_template: + raise ValueError(f"检测到跨域字体引用循环: {path}") + else: + raise ValueError(f"检测到字体引用循环: {path}") # 检查引用深度 if len(visited) >= self._max_depth: raise ValueError(f"字体引用深度超过限制 ({self._max_depth} 层)") - # 检查引用是否存在 - if font_name not in self.fonts: - raise ValueError(f"引用的字体配置不存在: {reference}") - # 添加到已访问集合 - visited.add(reference) + visited.add(tagged_ref) - # 获取引用的字体配置 - font_dict = self.fonts[font_name] + # 根据作用域查找字体配置 + font_dict = None + if self.scope == "template": + # 模板库作用域:只能引用 template_fonts + if font_name in self.template_fonts: + font_dict = self.template_fonts[font_name] + elif not allow_cross_domain: + 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] + + # 检查引用是否存在 + if font_dict is None: + raise ValueError(f"引用的字体配置不存在: {reference}") # 递归解析 return self._resolve_font_dict(font_dict, visited.copy()) @@ -123,7 +159,9 @@ class FontResolver: # 处理 parent 继承 parent_config = FontConfig() if "parent" in font_dict and font_dict["parent"]: - parent_config = self._resolve_reference(font_dict["parent"], visited) + # parent 引用的跨域限制:模板库 fonts 的 parent 不能引用文档 fonts + allow_cross_domain = self.scope == "document" + parent_config = self._resolve_reference(font_dict["parent"], visited, allow_cross_domain) # 创建当前配置(从 parent 继承) config = FontConfig( @@ -168,10 +206,12 @@ class FontResolver: return FontConfig() # 避免在获取默认配置时再次访问 fonts_default - if self.fonts_default in visited: + scope_tag = "doc" if self.scope == "document" else "template" + tagged_default = f"{scope_tag}.{self.fonts_default}" + if tagged_default in visited: return FontConfig() - return self._resolve_reference(self.fonts_default, visited.copy()) + return self._resolve_reference(self.fonts_default, visited.copy(), allow_cross_domain=True) def _merge_with_default(self, config: FontConfig, default: FontConfig) -> FontConfig: """ diff --git a/validators/result.py b/validators/result.py index 7a25a7f..f692142 100644 --- a/validators/result.py +++ b/validators/result.py @@ -75,3 +75,21 @@ class ValidationResult: lines.append(f"检查完成: 发现 {', '.join(summary_parts)}") return "\n".join(lines) + + +# ============= 错误代码常量 ============= + +# 模板库 metadata 相关错误 +ERROR_TEMPLATE_LIBRARY_MISSING_METADATA = "TEMPLATE_LIBRARY_MISSING_METADATA" +ERROR_TEMPLATE_LIBRARY_METADATA_MISSING_SIZE = "TEMPLATE_LIBRARY_METADATA_MISSING_SIZE" +ERROR_TEMPLATE_LIBRARY_METADATA_INVALID_SIZE = "TEMPLATE_LIBRARY_METADATA_INVALID_SIZE" + +# Size 一致性错误 +ERROR_SIZE_MISMATCH = "SIZE_MISMATCH" + +# 字体引用相关错误 +ERROR_TEMPLATE_FONT_REF_DOC_FORBIDDEN = "TEMPLATE_FONT_REF_DOC_FORBIDDEN" +ERROR_TEMPLATE_PARENT_REF_DOC_FORBIDDEN = "TEMPLATE_PARENT_REF_DOC_FORBIDDEN" +ERROR_FONT_NOT_FOUND = "FONT_NOT_FOUND" +ERROR_CIRCULAR_REFERENCE = "CIRCULAR_REFERENCE" +ERROR_FONT_DEFAULT_INVALID = "FONT_DEFAULT_INVALID"