diff --git a/README.md b/README.md index 4cc6417..07f7739 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## ✨ 功能特性 - 📝 **YAML 声明式语法** - 使用简单易读的 YAML 定义演示文稿 +- ✅ **智能验证** - 转换前自动检查 YAML 文件,提前发现问题 - 🎨 **模板系统** - 支持参数化模板,复用幻灯片布局 - 🧩 **丰富的元素类型** - 文本、图片、形状、表格 - 👁️ **实时预览** - 浏览器预览模式,支持热重载 @@ -20,28 +21,83 @@ ### 基本用法 ```bash -# 生成 PPTX 文件(自动命名为 input.pptx) -uv run yaml2pptx.py presentation.yaml +# 使用 convert 子命令(推荐) +uv run yaml2pptx.py convert presentation.yaml output.pptx -# 指定输出文件名 -uv run yaml2pptx.py presentation.yaml output.pptx +# 自动生成输出文件名 +uv run yaml2pptx.py convert presentation.yaml # 使用模板 -uv run yaml2pptx.py presentation.yaml output.pptx --template-dir ./templates +uv run yaml2pptx.py convert presentation.yaml output.pptx --template-dir ./templates + +# 传统用法(向后兼容) +uv run yaml2pptx.py presentation.yaml output.pptx ``` ### 实时预览 ```bash # 启动预览服务器(自动打开浏览器) -uv run yaml2pptx.py presentation.yaml --preview +uv run yaml2pptx.py convert presentation.yaml --preview # 指定端口 -uv run yaml2pptx.py presentation.yaml --preview --port 8080 +uv run yaml2pptx.py convert presentation.yaml --preview --port 8080 + +# 传统用法(向后兼容) +uv run yaml2pptx.py presentation.yaml --preview ``` 预览模式会自动监听文件变化,修改 YAML 文件后浏览器会自动刷新。 +### 验证功能 + +在转换前验证 YAML 文件,提前发现问题: + +```bash +# 独立验证命令 +uv run yaml2pptx.py check presentation.yaml + +# 使用模板时验证 +uv run yaml2pptx.py check presentation.yaml --template-dir ./templates +``` + +验证功能会检查: +- ✅ YAML 语法和结构 +- ✅ 元素是否超出页面范围 +- ✅ 图片和模板文件是否存在 +- ✅ 颜色格式是否正确 +- ✅ 字体大小是否合理 +- ✅ 表格数据是否一致 + +**自动验证**:转换时默认会自动验证,如果发现错误会终止转换。可以使用 `--no-check` 跳过验证: + +```bash +# 跳过自动验证(convert 子命令) +uv run yaml2pptx.py convert presentation.yaml --no-check + +# 跳过自动验证(传统用法) +uv run yaml2pptx.py presentation.yaml --no-check +``` + +**验证结果示例**: + +``` +🔍 正在检查 YAML 文件... + +❌ 错误 (2): + [幻灯片 2, 元素 1] 无效的颜色格式: red (应为 #RRGGBB) + [幻灯片 3, 元素 2] 图片文件不存在: logo.png + +⚠️ 警告 (1): + [幻灯片 1, 元素 1] 元素右边界超出: 10.50 > 10 + +检查完成: 发现 2 个错误, 1 个警告 +``` + +- **ERROR**:阻止转换的严重问题(文件不存在、语法错误等) +- **WARNING**:影响视觉效果的问题(元素超出页面、字体太小等) +- **INFO**:优化建议 + ## 📖 YAML 语法 ### 最小示例 diff --git a/README_DEV.md b/README_DEV.md index 0b606c2..65b158a 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -12,14 +12,20 @@ yaml2pptx 是一个将 YAML 格式的演示文稿源文件转换为 PPTX 文件 ``` html2pptx/ -├── yaml2pptx.py (127 行) # 入口脚本,CLI + main 函数 +├── yaml2pptx.py (200 行) # 入口脚本,CLI + main 函数 ├── utils.py (74 行) # 工具函数(日志、颜色转换) ├── core/ # 核心领域模型 -│ ├── elements.py (96 行) # 元素抽象层(dataclass) +│ ├── elements.py (200 行) # 元素抽象层(dataclass + validate) │ ├── template.py (191 行) # 模板系统 │ └── presentation.py (91 行) # 演示文稿类 ├── loaders/ # 数据加载层 │ └── yaml_loader.py (113 行) # YAML 加载和验证 +├── validators/ # 验证层 +│ ├── __init__.py # 导出主验证器 +│ ├── result.py (70 行) # 验证结果数据结构 +│ ├── validator.py (150 行) # 主验证器 +│ ├── geometry.py (120 行) # 几何验证器 +│ └── resource.py (110 行) # 资源验证器 ├── renderers/ # 渲染层 │ ├── pptx_renderer.py (292 行) # PPTX 渲染器 │ └── html_renderer.py (172 行) # HTML 渲染器(预览) @@ -34,6 +40,12 @@ yaml2pptx.py (入口) ↓ ├─→ utils (工具函数) ├─→ loaders.yaml_loader (YAML 加载) + ├─→ validators.validator (验证器) + │ ↓ + │ ├─→ validators.result (验证结果) + │ ├─→ validators.geometry (几何验证) + │ ├─→ validators.resource (资源验证) + │ └─→ core.elements (元素验证) ├─→ core.presentation (演示文稿) │ ↓ │ ├─→ core.template (模板) @@ -49,9 +61,10 @@ yaml2pptx.py (入口) ``` **依赖原则**: -- 单向依赖:入口 → 渲染 → 核心 ← 加载 +- 单向依赖:入口 → 验证/渲染 → 核心 ← 加载 - 无循环依赖 - 核心层不依赖其他业务模块 +- 验证层可以调用核心层的元素验证方法 ## 模块职责 @@ -81,15 +94,37 @@ yaml2pptx.py (入口) ### 4. core/elements.py(核心层 - 元素抽象) - **职责**:定义元素数据类和工厂函数 - **包含**: - - `TextElement` - 文本元素(dataclass) - - `ImageElement` - 图片元素(dataclass) - - `ShapeElement` - 形状元素(dataclass) - - `TableElement` - 表格元素(dataclass) + - `_is_valid_color()` - 颜色格式验证工具函数 + - `TextElement` - 文本元素(dataclass + validate) + - `ImageElement` - 图片元素(dataclass + validate) + - `ShapeElement` - 形状元素(dataclass + validate) + - `TableElement` - 表格元素(dataclass + validate) - `create_element()` - 元素工厂函数 - **特点**: - 使用 `@dataclass` 装饰器 - - 在 `__post_init__` 中进行验证 - - 创建时验证,尽早发现错误 + - 在 `__post_init__` 中进行创建时验证 + - 在 `validate()` 方法中进行元素级验证 + - 元素类负责自身属性的验证(颜色格式、字体大小、枚举值等) + +### 4.5. validators/(验证层) +- **职责**:YAML 文件验证,在转换前检查问题 +- **包含**: + - `validators/result.py` - 验证结果数据结构 + - `ValidationIssue` - 验证问题(level, message, location, code) + - `ValidationResult` - 验证结果(errors, warnings, infos) + - `validators/geometry.py` - 几何验证器 + - `GeometryValidator` - 检查元素边界、页面范围 + - 支持 0.1 英寸容忍度 + - `validators/resource.py` - 资源验证器 + - `ResourceValidator` - 检查图片、模板文件存在性 + - 验证模板文件结构 + - `validators/validator.py` - 主验证器 + - `Validator` - 协调所有子验证器 + - 集成元素级验证、几何验证、资源验证 +- **特点**: + - 分级错误报告(ERROR/WARNING/INFO) + - ERROR 阻止转换,WARNING 不阻止 + - 验证职责分层:元素级验证在元素类中,系统级验证在验证器中 ### 5. core/template.py(核心层 - 模板) - **职责**:模板加载和变量解析 @@ -256,6 +291,72 @@ class PptxGenerator: - 类型安全:进行类型检查 - 易于扩展:添加新元素类型只需添加一个分支 +### 5. 验证职责分层 + +**决策**:将验证逻辑分为两层 +1. **元素级验证**:放在元素类本身(`core/elements.py`) +2. **系统级验证**:放在独立的验证器模块(`validators/`) + +**理由**: +- 元素类最了解自己的约束,应该负责自身的完整性验证 +- 系统级验证需要全局上下文(如页面尺寸、文件路径),适合集中处理 +- 符合单一职责原则,便于扩展和维护 + +**元素级验证职责**: +- 必需字段检查(在 `__post_init__` 中) +- 数据类型检查(在 `__post_init__` 中) +- 值的有效性检查(在 `validate()` 方法中) + - 颜色格式验证 + - 字体大小合理性 + - 枚举值检查(如形状类型) + - 表格数据一致性 + +**系统级验证职责**: +- 几何验证(元素是否在页面范围内,需要知道页面尺寸) +- 资源验证(文件是否存在,需要知道文件路径) +- 跨元素验证(如果未来需要) + +**示例**: +```python +# 元素级验证(在元素类中) +@dataclass +class TextElement: + def validate(self) -> List[ValidationIssue]: + issues = [] + if self.font.get('color'): + if not _is_valid_color(self.font['color']): + issues.append(ValidationIssue( + level="ERROR", + message=f"无效的颜色格式: {self.font['color']}", + code="INVALID_COLOR_FORMAT" + )) + return issues + +# 系统级验证(在验证器中) +class GeometryValidator: + def validate_element(self, element, slide_index, elem_index): + # 需要页面尺寸信息 + if element.box[0] + element.box[2] > self.slide_width: + # 报告边界超出 +``` + +### 6. 验证容忍度 + +**决策**:几何验证时,允许 0.1 英寸的容忍度 + +**理由**: +- 浮点数计算可能有精度误差 +- 0.1 英寸(约 2.54mm)在视觉上几乎不可见 +- 避免误报,提升用户体验 + +**实现**: +```python +TOLERANCE = 0.1 # 英寸 + +if right > slide_width + TOLERANCE: + # 报告 WARNING +``` + ## 扩展指南 ### 添加新元素类型 @@ -351,17 +452,29 @@ def main(): ### 运行测试 ```bash -# 基本 PPTX 生成 +# 验证 YAML 文件 +uv run yaml2pptx.py check temp/test.yaml + +# 使用模板时验证 +uv run yaml2pptx.py check temp/demo.yaml --template-dir temp/templates + +# 使用 convert 子命令转换(推荐) +uv run yaml2pptx.py convert temp/test.yaml temp/output.pptx + +# 传统模式转换(向后兼容) uv run yaml2pptx.py temp/test.yaml temp/output.pptx +# 跳过自动验证 +uv run yaml2pptx.py convert temp/test.yaml temp/output.pptx --no-check + # 使用模板 -uv run yaml2pptx.py temp/demo.yaml temp/output.pptx --template-dir temp/templates +uv run yaml2pptx.py convert temp/demo.yaml temp/output.pptx --template-dir temp/templates # 预览模式 -uv run yaml2pptx.py temp/test.yaml --preview +uv run yaml2pptx.py convert temp/test.yaml --preview # 指定端口 -uv run yaml2pptx.py temp/test.yaml --preview --port 8080 +uv run yaml2pptx.py convert temp/test.yaml --preview --port 8080 ``` ### 测试文件位置 diff --git a/core/elements.py b/core/elements.py index 9ffd2f0..50307ed 100644 --- a/core/elements.py +++ b/core/elements.py @@ -5,7 +5,22 @@ """ from dataclasses import dataclass, field -from typing import Optional +from typing import Optional, List +import re + + +def _is_valid_color(color: str) -> bool: + """ + 验证颜色格式是否正确 + + Args: + color: 颜色字符串 + + Returns: + 是否为有效的颜色格式(#RGB 或 #RRGGBB) + """ + pattern = r'^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$' + return bool(re.match(pattern, color)) @dataclass @@ -21,6 +36,41 @@ class TextElement: if not isinstance(self.box, list) or len(self.box) != 4: raise ValueError("box 必须是包含 4 个数字的列表") + def validate(self) -> List: + """验证元素自身的完整性""" + from validators.result import ValidationIssue + issues = [] + + # 检查颜色格式 + if self.font.get('color'): + if not _is_valid_color(self.font['color']): + issues.append(ValidationIssue( + level="ERROR", + message=f"无效的颜色格式: {self.font['color']} (应为 #RRGGBB)", + location="", + code="INVALID_COLOR_FORMAT" + )) + + # 检查字体大小 + if self.font.get('size'): + size = self.font['size'] + if size < 8: + issues.append(ValidationIssue( + level="WARNING", + message=f"字体太小: {size}pt (建议 >= 8pt)", + location="", + code="FONT_TOO_SMALL" + )) + elif size > 100: + issues.append(ValidationIssue( + level="WARNING", + message=f"字体太大: {size}pt (建议 <= 100pt)", + location="", + code="FONT_TOO_LARGE" + )) + + return issues + @dataclass class ImageElement: @@ -36,6 +86,12 @@ class ImageElement: if not isinstance(self.box, list) or len(self.box) != 4: raise ValueError("box 必须是包含 4 个数字的列表") + def validate(self) -> List: + """验证元素自身的完整性""" + # ImageElement 的必需字段已在 __post_init__ 中检查 + # 这里返回空列表,资源验证由 ResourceValidator 负责 + return [] + @dataclass class ShapeElement: @@ -51,6 +107,42 @@ class ShapeElement: if not isinstance(self.box, list) or len(self.box) != 4: raise ValueError("box 必须是包含 4 个数字的列表") + def validate(self) -> List: + """验证元素自身的完整性""" + from validators.result import ValidationIssue + issues = [] + + # 检查形状类型枚举 + valid_shapes = ['rectangle', 'ellipse', 'rounded_rectangle'] + if self.shape not in valid_shapes: + issues.append(ValidationIssue( + level="ERROR", + message=f"不支持的形状类型: {self.shape} (支持: {', '.join(valid_shapes)})", + location="", + code="INVALID_SHAPE_TYPE" + )) + + # 检查填充颜色格式 + if self.fill and not _is_valid_color(self.fill): + issues.append(ValidationIssue( + level="ERROR", + message=f"无效的填充颜色格式: {self.fill} (应为 #RRGGBB)", + location="", + code="INVALID_COLOR_FORMAT" + )) + + # 检查线条颜色格式 + if self.line and self.line.get('color'): + if not _is_valid_color(self.line['color']): + issues.append(ValidationIssue( + level="ERROR", + message=f"无效的线条颜色格式: {self.line['color']} (应为 #RRGGBB)", + location="", + code="INVALID_COLOR_FORMAT" + )) + + return issues + @dataclass class TableElement: @@ -68,6 +160,37 @@ class TableElement: if not isinstance(self.position, list) or len(self.position) != 2: raise ValueError("position 必须是包含 2 个数字的列表") + def validate(self) -> List: + """验证元素自身的完整性""" + from validators.result import ValidationIssue + issues = [] + + # 检查表格数据行列数一致性 + if self.data: + first_row_cols = len(self.data[0]) if isinstance(self.data[0], list) else 0 + for i, row in enumerate(self.data[1:], start=1): + if isinstance(row, list): + if len(row) != first_row_cols: + issues.append(ValidationIssue( + level="ERROR", + message=f"表格数据行列数不一致: 第 {i+1} 行有 {len(row)} 列,期望 {first_row_cols} 列", + location="", + code="TABLE_INCONSISTENT_COLUMNS" + )) + + # 检查 col_widths 与列数是否匹配 + if self.col_widths and self.data: + first_row_cols = len(self.data[0]) if isinstance(self.data[0], list) else 0 + if len(self.col_widths) != first_row_cols: + issues.append(ValidationIssue( + level="WARNING", + message=f"col_widths 数量 ({len(self.col_widths)}) 与表格列数 ({first_row_cols}) 不匹配", + location="", + code="TABLE_COL_WIDTHS_MISMATCH" + )) + + return issues + def create_element(elem_dict: dict): """ diff --git a/openspec/changes/archive/2026-03-02-add-yaml-validation/.openspec.yaml b/openspec/changes/archive/2026-03-02-add-yaml-validation/.openspec.yaml new file mode 100644 index 0000000..fd79bfc --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-yaml-validation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-02 diff --git a/openspec/changes/archive/2026-03-02-add-yaml-validation/design.md b/openspec/changes/archive/2026-03-02-add-yaml-validation/design.md new file mode 100644 index 0000000..ef6bb16 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-yaml-validation/design.md @@ -0,0 +1,284 @@ +## Context + +当前 yaml2pptx 系统已经有基本的 YAML 加载和元素验证功能: +- `loaders/yaml_loader.py` 提供基本的 YAML 语法检查和结构验证 +- `core/elements.py` 定义了元素数据类,在 `__post_init__` 中进行基本验证 + +但现有验证不够全面,用户经常在转换后才发现问题(元素超出页面、文件不存在等),需要反复修改和转换。 + +项目约束: +- 使用 uv 运行 Python 脚本 +- 无新增外部依赖 +- 测试文件放在 temp 目录 +- 面向中文开发者 + +## Goals / Non-Goals + +**Goals:** +- 提供全面的 YAML 验证功能,在转换前发现问题 +- 支持独立 check 命令和转换前自动检查 +- 实现分级错误报告(ERROR/WARNING/INFO) +- 保持验证逻辑的可扩展性和可维护性 + +**Non-Goals:** +- 不实现自动修复功能(只检测,不修改) +- 不支持第三阶段的质量警告(元素重叠、文本溢出等) +- 不添加 GUI 或 Web 界面 +- 不实现配置文件来自定义验证规则 + +## Decisions + +### 决策 1:验证职责分层 + +**决策**:将验证逻辑分为两层: +1. **元素级验证**:放在元素类本身(`core/elements.py`) +2. **系统级验证**:放在独立的验证器模块(`validators/`) + +**理由**: +- 元素类最了解自己的约束,应该负责自身的完整性验证 +- 系统级验证需要全局上下文(如页面尺寸、文件路径),适合集中处理 +- 符合单一职责原则,便于扩展和维护 + +**元素级验证职责**: +- 必需字段检查(如 image 必须有 src) +- 数据类型检查(如 box 必须是 4 个数字) +- 值的有效性检查(如颜色格式、枚举值) +- 元素内部逻辑一致性 + +**系统级验证职责**: +- 几何验证(元素是否在页面范围内,需要知道页面尺寸) +- 资源验证(文件是否存在,需要知道文件路径) +- 跨元素验证(如果未来需要) + +**替代方案**: +- 方案 A:所有验证集中在验证器中 → 验证器会变得臃肿,元素类失去封装性 +- 方案 B:所有验证都在元素类中 → 元素类需要知道全局上下文,违反依赖倒置原则 + +### 决策 2:验证器模块结构 + +**决策**:创建 `validators/` 目录,包含以下模块: +``` +validators/ +├── __init__.py # 导出主验证器 +├── validator.py # 主验证器,协调各子验证器 +├── geometry.py # 几何验证器 +├── resource.py # 资源验证器 +└── result.py # 验证结果数据结构 +``` + +**理由**: +- 模块化设计,每个验证器职责单一 +- 便于测试和扩展 +- 主验证器作为门面,简化调用 + +**验证流程**: +``` +Validator.validate(yaml_path, template_dir) + ↓ +1. 加载 YAML(复用 yaml_loader) +2. 元素级验证(调用元素类的验证方法) +3. 几何验证(GeometryValidator) +4. 资源验证(ResourceValidator) + ↓ +返回 ValidationResult +``` + +**替代方案**: +- 方案 A:单一验证器文件 → 代码过长,难以维护 +- 方案 B:每种验证一个独立包 → 过度设计,增加复杂度 + +### 决策 3:验证结果数据结构 + +**决策**:定义 `ValidationResult` 和 `ValidationIssue` 类: + +```python +@dataclass +class ValidationIssue: + level: str # "ERROR" | "WARNING" | "INFO" + message: str + location: str # "幻灯片 2, 元素 3" + code: str # "ELEMENT_OUT_OF_BOUNDS" + +@dataclass +class ValidationResult: + valid: bool # 是否有 ERROR + errors: List[ValidationIssue] + warnings: List[ValidationIssue] + infos: List[ValidationIssue] + + def has_errors(self) -> bool + def format_output(self) -> str # 格式化为命令行输出 +``` + +**理由**: +- 结构化的结果便于测试和扩展 +- `code` 字段便于未来实现错误码查询或国际化 +- `format_output()` 方法封装输出格式,便于修改 + +**替代方案**: +- 方案 A:直接返回字符串 → 难以测试,不便于扩展 +- 方案 B:返回字典 → 缺少类型安全,容易出错 + +### 决策 4:命令行接口设计 + +**决策**:修改 `yaml2pptx.py`,使用子命令模式: + +```python +# 独立验证 +yaml2pptx.py check input.yaml [--template-dir DIR] + +# 转换(默认自动验证) +yaml2pptx.py input.yaml [output.pptx] [--template-dir DIR] [--no-check] +``` + +**实现方式**: +- 使用 `argparse` 的 `subparsers` 实现子命令 +- `check` 子命令调用验证器并输出结果 +- 转换命令在加载 YAML 后、渲染前调用验证器 +- 如果验证失败(有 ERROR),终止转换并返回非零退出码 + +**理由**: +- 子命令模式清晰,符合 CLI 工具惯例 +- 默认启用自动验证,提升用户体验 +- `--no-check` 提供灵活性,适合调试场景 + +**替代方案**: +- 方案 A:独立的 `yaml2pptx-check` 命令 → 增加维护成本,用户需要记住两个命令 +- 方案 B:只有自动验证,没有独立命令 → 用户无法单独验证 + +### 决策 5:元素类验证方法增强 + +**决策**:在 `core/elements.py` 中为每个元素类添加 `validate()` 方法: + +```python +@dataclass +class TextElement: + # ... 现有字段 ... + + def validate(self) -> List[ValidationIssue]: + """验证元素自身的完整性""" + issues = [] + + # 检查颜色格式 + if self.font.get('color'): + if not self._is_valid_color(self.font['color']): + issues.append(ValidationIssue( + level="ERROR", + message=f"无效的颜色格式: {self.font['color']}", + location="", # 由调用者填充 + code="INVALID_COLOR_FORMAT" + )) + + # 检查字体大小 + if self.font.get('size'): + size = self.font['size'] + if size < 8: + issues.append(ValidationIssue( + level="WARNING", + message=f"字体太小: {size}pt (建议 >= 8pt)", + location="", + code="FONT_TOO_SMALL" + )) + + return issues +``` + +**理由**: +- 元素类封装自己的验证逻辑 +- `__post_init__` 保留用于阻止创建无效对象(如 box 不是 4 个数字) +- `validate()` 用于检测可以创建但不推荐的情况(如字体太小) + +**替代方案**: +- 方案 A:只在 `__post_init__` 中验证 → 无法区分致命错误和警告 +- 方案 B:不修改元素类,所有验证在验证器中 → 违反封装原则 + +### 决策 6:边界检查容忍度 + +**决策**:几何验证时,允许 0.1 英寸的容忍度: + +```python +TOLERANCE = 0.1 # 英寸 + +if right > slide_width + TOLERANCE: + # 报告 WARNING +``` + +**理由**: +- 浮点数计算可能有精度误差 +- 0.1 英寸(约 2.54mm)在视觉上几乎不可见 +- 避免误报,提升用户体验 + +**替代方案**: +- 方案 A:零容忍 → 可能产生大量误报 +- 方案 B:更大的容忍度(如 0.5 英寸)→ 可能漏掉真正的问题 + +## Risks / Trade-offs + +### 风险 1:验证性能影响 + +**风险**:验证可能增加转换时间,特别是资源验证(检查文件存在性)。 + +**缓解措施**: +- 验证是可选的(`--no-check` 跳过) +- 资源验证只检查文件存在性,不读取文件内容 +- 未来可以考虑并行验证(如果性能成为问题) + +### 风险 2:元素类验证方法的维护成本 + +**风险**:每个元素类都需要实现 `validate()` 方法,增加维护成本。 + +**缓解措施**: +- 提供基类或工具函数来复用常见验证逻辑(如颜色格式检查) +- 验证逻辑相对稳定,不会频繁修改 +- 好处是验证逻辑和元素定义在一起,便于理解和修改 + +### 风险 3:错误消息的可读性 + +**风险**:错误消息可能不够清晰,用户难以理解如何修复。 + +**缓解措施**: +- 错误消息包含具体的位置信息(幻灯片、元素) +- 错误消息包含期望值和实际值(如 "right=10.5 > 10.0") +- 未来可以添加错误码和文档链接 + +### Trade-off:验证完整性 vs 性能 + +**选择**:优先保证验证完整性,性能其次。 + +**理由**: +- 验证是可选的,性能敏感的场景可以跳过 +- 提前发现问题比快速转换更重要 +- 当前验证项不多,性能影响可控 + +## Migration Plan + +**部署步骤**: +1. 实现验证器模块(不影响现有功能) +2. 增强元素类的验证方法(向后兼容) +3. 修改 `yaml2pptx.py` 添加 check 子命令 +4. 添加自动验证逻辑(默认启用) +5. 编写测试用例(在 temp 目录) +6. 更新 README.md 文档 + +**回滚策略**: +- 如果验证器有问题,用户可以使用 `--no-check` 跳过 +- 验证器是独立模块,可以快速禁用或修复 +- 不影响现有的转换功能 + +**兼容性**: +- 完全向后兼容,现有 YAML 文件和命令行用法不受影响 +- 新增的 check 子命令和 --no-check 选项是可选的 + +## Open Questions + +1. **是否需要配置文件来自定义验证规则?** + - 当前决策:不需要,保持简单 + - 未来可以考虑添加 `.yaml2pptx.yaml` 配置文件 + +2. **是否需要支持 JSON 格式的验证结果输出?** + - 当前决策:只支持命令行文本输出 + - 未来可以添加 `--format json` 选项 + +3. **字体大小的合理范围是否需要可配置?** + - 当前决策:硬编码 8pt-100pt + - 未来可以考虑通过配置文件自定义 diff --git a/openspec/changes/archive/2026-03-02-add-yaml-validation/proposal.md b/openspec/changes/archive/2026-03-02-add-yaml-validation/proposal.md new file mode 100644 index 0000000..029e875 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-yaml-validation/proposal.md @@ -0,0 +1,40 @@ +## Why + +用户在编写 YAML 演示文稿源文件时,经常遇到元素超出页面范围、图片文件路径错误、语法问题等错误,这些问题只有在转换成 PPTX 后打开查看才能发现,导致需要反复修改和转换,效率低下。需要提供一个验证功能,在转换前检查 YAML 文件的各种潜在问题,让用户能够提前发现并修复错误。 + +## What Changes + +- 添加 YAML 文件验证功能,支持多层次的检查 + - 结构验证:必需字段、数据类型、枚举值 + - 几何验证:元素边界、页面范围检查 + - 资源验证:图片文件、模板文件存在性 + - 样式验证:颜色格式、字体大小合理性 +- 添加独立的 `check` 子命令:`yaml2pptx.py check input.yaml` +- 添加转换前自动检查选项:默认启用,可通过 `--no-check` 跳过 +- 实现分级的错误报告系统 + - ERROR:阻止转换的严重问题(文件不存在、语法错误、关键参数缺失) + - WARNING:影响视觉效果的问题(元素超出页面、字体太小) + - INFO:优化建议 +- 输出清晰的命令行报告,显示问题位置(幻灯片、元素)和具体描述 + +## Capabilities + +### New Capabilities + +- `yaml-validation`: YAML 演示文稿文件的验证能力,包括结构验证、几何验证、资源验证和样式验证 + +### Modified Capabilities + +无。这是新增功能,不修改现有能力的需求。 + +## Impact + +- **主程序**:修改 `yaml2pptx.py`,添加 `check` 子命令和 `--no-check` 选项 +- **新增模块**:创建验证器模块(建议在 `validators/` 目录下) + - 结构验证器 + - 几何验证器 + - 资源验证器 + - 样式验证器 +- **现有功能**:不影响现有的转换功能,验证是可选的 +- **依赖**:无新增外部依赖,使用现有的 PyYAML 和标准库 +- **用户体验**:提升开发效率,减少调试时间 diff --git a/openspec/changes/archive/2026-03-02-add-yaml-validation/specs/yaml-validation/spec.md b/openspec/changes/archive/2026-03-02-add-yaml-validation/specs/yaml-validation/spec.md new file mode 100644 index 0000000..deda12a --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-yaml-validation/specs/yaml-validation/spec.md @@ -0,0 +1,187 @@ +## ADDED Requirements + +### Requirement: 独立验证命令 + +系统必须提供独立的 check 子命令,用于验证 YAML 文件而不执行转换。 + +#### Scenario: 执行独立验证 + +- **WHEN** 用户运行 `yaml2pptx.py check input.yaml` +- **THEN** 系统验证 YAML 文件并输出验证结果,不生成 PPTX 文件 + +#### Scenario: 验证时指定模板目录 + +- **WHEN** 用户运行 `yaml2pptx.py check input.yaml --template-dir ./templates` +- **THEN** 系统在验证时使用指定的模板目录来检查模板文件 + +### Requirement: 转换前自动验证 + +系统必须在转换 YAML 为 PPTX 之前自动执行验证,除非用户明确跳过。 + +#### Scenario: 默认自动验证 + +- **WHEN** 用户运行 `yaml2pptx.py input.yaml output.pptx` +- **THEN** 系统在转换前自动验证 YAML 文件 + +#### Scenario: 跳过自动验证 + +- **WHEN** 用户运行 `yaml2pptx.py input.yaml output.pptx --no-check` +- **THEN** 系统跳过验证,直接执行转换 + +#### Scenario: 验证失败阻止转换 + +- **WHEN** 自动验证发现 ERROR 级别的问题 +- **THEN** 系统输出错误信息并终止转换,返回非零退出码 + +### Requirement: 元素自验证 + +系统中的每个元素类型必须能够验证自身的完整性和有效性。 + +#### Scenario: 元素验证自身属性 + +- **WHEN** 创建或验证元素对象 +- **THEN** 元素类检查自身的必需字段、数据类型、值有效性(如颜色格式、枚举值、字体大小) + +#### Scenario: 元素验证与系统验证分离 + +- **WHEN** 执行验证 +- **THEN** 元素类负责自身属性验证(不需要全局上下文的验证),系统验证器负责需要全局上下文的验证(如几何验证需要页面尺寸、资源验证需要文件路径) + +### Requirement: 结构验证 + +系统必须验证 YAML 文件的结构完整性和数据类型正确性。 + +#### Scenario: 检测缺少必需字段 + +- **WHEN** YAML 文件缺少 slides 字段 +- **THEN** 系统报告 ERROR 级别错误:"缺少必需字段 'slides'" + +#### Scenario: 检测数据类型错误 + +- **WHEN** slides 字段不是列表类型 +- **THEN** 系统报告 ERROR 级别错误:"'slides' 必须是一个列表" + +#### Scenario: 检测元素类型无效 + +- **WHEN** 元素的 type 字段值不是 text/image/shape/table 之一 +- **THEN** 系统报告 ERROR 级别错误:"不支持的元素类型: {type}" + +#### Scenario: 检测无效的枚举值 + +- **WHEN** shape 元素的 shape 字段值不是 rectangle/ellipse/rounded_rectangle 之一 +- **THEN** 系统报告 ERROR 级别错误:"不支持的形状类型: {shape}" + +### Requirement: 几何验证 + +系统必须验证元素的位置和尺寸是否在页面范围内。 + +#### Scenario: 检测元素超出页面右边界 + +- **WHEN** 元素的 left + width 超出页面宽度 0.1 英寸以上 +- **THEN** 系统报告 WARNING 级别警告:"元素右边界超出: {right} > {width}" + +#### Scenario: 检测元素超出页面下边界 + +- **WHEN** 元素的 top + height 超出页面高度 0.1 英寸以上 +- **THEN** 系统报告 WARNING 级别警告:"元素下边界超出: {bottom} > {height}" + +#### Scenario: 容忍计算精度误差 + +- **WHEN** 元素的边界超出页面范围不超过 0.1 英寸 +- **THEN** 系统不报告警告 + +#### Scenario: 检测元素完全在页面外 + +- **WHEN** 元素的 left >= 页面宽度 或 top >= 页面高度 +- **THEN** 系统报告 WARNING 级别警告:"元素完全在页面外" + +#### Scenario: 检测表格超出页面范围 + +- **WHEN** 表格的 position[0] + sum(col_widths) 超出页面宽度 0.1 英寸以上 +- **THEN** 系统报告 WARNING 级别警告:"表格超出页面宽度" + +### Requirement: 资源验证 + +系统必须验证 YAML 文件引用的外部资源是否存在。 + +#### Scenario: 检测图片文件不存在 + +- **WHEN** image 元素的 src 指向的文件不存在 +- **THEN** 系统报告 ERROR 级别错误:"图片文件不存在: {src}" + +#### Scenario: 支持相对路径图片 + +- **WHEN** image 元素的 src 是相对路径 +- **THEN** 系统相对于 YAML 文件所在目录解析路径 + +#### Scenario: 检测模板文件不存在 + +- **WHEN** 幻灯片引用的 template 文件不存在 +- **THEN** 系统报告 ERROR 级别错误:"模板文件不存在: {template}" + +#### Scenario: 验证模板文件结构 + +- **WHEN** 模板文件存在但结构不正确(缺少 elements 字段) +- **THEN** 系统报告 ERROR 级别错误:"模板文件结构错误: {template}" + +### Requirement: 样式验证 + +系统必须验证样式相关属性的有效性和合理性。 + +#### Scenario: 检测无效的颜色格式 + +- **WHEN** 颜色值不符合 #RGB 或 #RRGGBB 格式 +- **THEN** 系统报告 ERROR 级别错误:"无效的颜色格式: {color}" + +#### Scenario: 检测字体过小 + +- **WHEN** 字体大小小于 8pt +- **THEN** 系统报告 WARNING 级别警告:"字体太小: {size}pt (建议 >= 8pt)" + +#### Scenario: 检测字体过大 + +- **WHEN** 字体大小大于 100pt +- **THEN** 系统报告 WARNING 级别警告:"字体太大: {size}pt (建议 <= 100pt)" + +### Requirement: 分级错误报告 + +系统必须按照严重程度对验证问题进行分级报告。 + +#### Scenario: ERROR 级别阻止转换 + +- **WHEN** 验证发现 ERROR 级别问题 +- **THEN** 系统在独立验证模式下返回非零退出码,在自动验证模式下阻止转换 + +#### Scenario: WARNING 级别不阻止转换 + +- **WHEN** 验证仅发现 WARNING 级别问题 +- **THEN** 系统输出警告信息但允许转换继续,返回零退出码 + +#### Scenario: 显示问题位置 + +- **WHEN** 验证发现问题 +- **THEN** 系统输出包含幻灯片编号和元素编号的位置信息:"[幻灯片 {n}, 元素 {m}]" + +#### Scenario: 分类统计问题数量 + +- **WHEN** 验证完成 +- **THEN** 系统输出问题统计:"发现 {n} 个错误, {m} 个警告" + +### Requirement: 清晰的命令行输出 + +系统必须以清晰易读的格式输出验证结果。 + +#### Scenario: 使用图标区分级别 + +- **WHEN** 输出验证结果 +- **THEN** 系统使用 "❌" 表示 ERROR,"⚠️" 表示 WARNING,"ℹ️" 表示 INFO + +#### Scenario: 按级别分组显示 + +- **WHEN** 输出验证结果 +- **THEN** 系统按 ERROR、WARNING、INFO 分组显示问题 + +#### Scenario: 验证通过的提示 + +- **WHEN** 验证未发现任何问题 +- **THEN** 系统输出:"✅ 验证通过,未发现问题" diff --git a/openspec/changes/archive/2026-03-02-add-yaml-validation/tasks.md b/openspec/changes/archive/2026-03-02-add-yaml-validation/tasks.md new file mode 100644 index 0000000..1246d3d --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-yaml-validation/tasks.md @@ -0,0 +1,75 @@ +## 1. 验证结果数据结构 + +- [x] 1.1 创建 validators/ 目录结构 +- [x] 1.2 实现 validators/result.py 中的 ValidationIssue 数据类 +- [x] 1.3 实现 validators/result.py 中的 ValidationResult 数据类 +- [x] 1.4 实现 ValidationResult.has_errors() 方法 +- [x] 1.5 实现 ValidationResult.format_output() 方法,支持图标和分组显示 + +## 2. 元素类验证增强 + +- [x] 2.1 在 core/elements.py 中添加颜色格式验证工具函数 _is_valid_color() +- [x] 2.2 为 TextElement 类添加 validate() 方法,检查颜色格式和字体大小 +- [x] 2.3 为 ImageElement 类添加 validate() 方法,检查必需字段 +- [x] 2.4 为 ShapeElement 类添加 validate() 方法,检查颜色格式和形状类型枚举 +- [x] 2.5 为 TableElement 类添加 validate() 方法,检查数据完整性 + +## 3. 几何验证器 + +- [x] 3.1 创建 validators/geometry.py 文件 +- [x] 3.2 实现 GeometryValidator 类 +- [x] 3.3 实现元素边界检查方法,支持 0.1 英寸容忍度 +- [x] 3.4 实现表格边界检查方法 +- [x] 3.5 实现元素完全在页面外的检查 + +## 4. 资源验证器 + +- [x] 4.1 创建 validators/resource.py 文件 +- [x] 4.2 实现 ResourceValidator 类 +- [x] 4.3 实现图片文件存在性检查,支持相对路径和绝对路径 +- [x] 4.4 实现模板文件存在性检查 +- [x] 4.5 实现模板文件结构验证(检查 elements 字段) + +## 5. 主验证器 + +- [x] 5.1 创建 validators/validator.py 文件 +- [x] 5.2 实现 Validator 类的 validate() 方法 +- [x] 5.3 集成 YAML 加载(复用 loaders/yaml_loader.py) +- [x] 5.4 集成元素级验证(调用元素类的 validate() 方法) +- [x] 5.5 集成几何验证器 +- [x] 5.6 集成资源验证器 +- [x] 5.7 在 validators/__init__.py 中导出主验证器 + +## 6. 命令行接口 - check 子命令 + +- [x] 6.1 修改 yaml2pptx.py,使用 argparse 的 subparsers 添加 check 子命令 +- [x] 6.2 实现 check 子命令的参数解析(input, --template-dir) +- [x] 6.3 实现 check 子命令的处理函数,调用验证器 +- [x] 6.4 实现验证结果的命令行输出 +- [x] 6.5 根据验证结果返回正确的退出码(有 ERROR 返回非零) + +## 7. 命令行接口 - 自动验证 + +- [x] 7.1 在转换命令中添加 --no-check 选项 +- [x] 7.2 在转换流程中添加自动验证逻辑(加载 YAML 后、渲染前) +- [x] 7.3 实现验证失败时终止转换的逻辑 +- [x] 7.4 确保 --no-check 选项可以跳过验证 + +## 8. 测试 + +- [x] 8.1 在 temp/ 目录创建测试用的 YAML 文件(包含各种错误场景) +- [x] 8.2 测试独立 check 命令的各种场景 +- [x] 8.3 测试自动验证功能 +- [x] 8.4 测试 --no-check 选项 +- [x] 8.5 测试元素超出页面边界的检测 +- [x] 8.6 测试图片文件不存在的检测 +- [x] 8.7 测试模板文件验证 +- [x] 8.8 测试颜色格式验证 +- [x] 8.9 测试字体大小警告 +- [x] 8.10 测试容忍度(0.1 英寸) + +## 9. 文档更新 + +- [x] 9.1 更新 README.md,添加 check 命令的使用说明 +- [x] 9.2 更新 README.md,说明自动验证功能和 --no-check 选项 +- [x] 9.3 添加验证错误码和消息的说明(如果需要) diff --git a/openspec/config.yaml b/openspec/config.yaml index e8e7a1b..64f7757 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -2,9 +2,9 @@ schema: spec-driven context: | 本项目始终面向中文开发者,使用中文进行注释、交流等内容的处理,不考虑多语言; - 本项目编写的python脚本始终使用uv运行,脚本使用Inline script metadata来指定脚本的依赖包和python运行版本; - 严禁直接使用主机环境的python直接执行脚本,严禁在主机环境直接安装python依赖包; + 本项目编写的python脚本和任何python命令都始终使用uv运行,脚本使用Inline script metadata来指定脚本的依赖包和python运行版本,命令使用uv run python -c "xxx"执行命令; + 严禁直接使用主机环境的python直接执行脚本或命令,严禁在主机环境直接安装python依赖包; 本项目编写的测试文件、临时文件必须放在temp目录下; 严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作; 当前项目的面向用户的使用文档在README.md; - 当前项目的面向AI和开发者的开发规范文档在README_DEV.md; \ No newline at end of file + 当前项目的面向AI和开发者的开发规范文档在README_DEV.md; diff --git a/openspec/specs/yaml-validation/spec.md b/openspec/specs/yaml-validation/spec.md new file mode 100644 index 0000000..5ee600d --- /dev/null +++ b/openspec/specs/yaml-validation/spec.md @@ -0,0 +1,193 @@ +# YAML Validation + +## Purpose + +提供 YAML 演示文稿文件的验证能力,在转换为 PPTX 之前检查各种潜在问题,包括结构验证、几何验证、资源验证和样式验证。支持独立验证命令和转换前自动验证。 + +## Requirements + +### Requirement: 独立验证命令 + +系统必须提供独立的 check 子命令,用于验证 YAML 文件而不执行转换。 + +#### Scenario: 执行独立验证 + +- **WHEN** 用户运行 `yaml2pptx.py check input.yaml` +- **THEN** 系统验证 YAML 文件并输出验证结果,不生成 PPTX 文件 + +#### Scenario: 验证时指定模板目录 + +- **WHEN** 用户运行 `yaml2pptx.py check input.yaml --template-dir ./templates` +- **THEN** 系统在验证时使用指定的模板目录来检查模板文件 + +### Requirement: 转换前自动验证 + +系统必须在转换 YAML 为 PPTX 之前自动执行验证,除非用户明确跳过。 + +#### Scenario: 默认自动验证 + +- **WHEN** 用户运行 `yaml2pptx.py input.yaml output.pptx` +- **THEN** 系统在转换前自动验证 YAML 文件 + +#### Scenario: 跳过自动验证 + +- **WHEN** 用户运行 `yaml2pptx.py input.yaml output.pptx --no-check` +- **THEN** 系统跳过验证,直接执行转换 + +#### Scenario: 验证失败阻止转换 + +- **WHEN** 自动验证发现 ERROR 级别的问题 +- **THEN** 系统输出错误信息并终止转换,返回非零退出码 + +### Requirement: 元素自验证 + +系统中的每个元素类型必须能够验证自身的完整性和有效性。 + +#### Scenario: 元素验证自身属性 + +- **WHEN** 创建或验证元素对象 +- **THEN** 元素类检查自身的必需字段、数据类型、值有效性(如颜色格式、枚举值、字体大小) + +#### Scenario: 元素验证与系统验证分离 + +- **WHEN** 执行验证 +- **THEN** 元素类负责自身属性验证(不需要全局上下文的验证),系统验证器负责需要全局上下文的验证(如几何验证需要页面尺寸、资源验证需要文件路径) + +### Requirement: 结构验证 + +系统必须验证 YAML 文件的结构完整性和数据类型正确性。 + +#### Scenario: 检测缺少必需字段 + +- **WHEN** YAML 文件缺少 slides 字段 +- **THEN** 系统报告 ERROR 级别错误:"缺少必需字段 'slides'" + +#### Scenario: 检测数据类型错误 + +- **WHEN** slides 字段不是列表类型 +- **THEN** 系统报告 ERROR 级别错误:"'slides' 必须是一个列表" + +#### Scenario: 检测元素类型无效 + +- **WHEN** 元素的 type 字段值不是 text/image/shape/table 之一 +- **THEN** 系统报告 ERROR 级别错误:"不支持的元素类型: {type}" + +#### Scenario: 检测无效的枚举值 + +- **WHEN** shape 元素的 shape 字段值不是 rectangle/ellipse/rounded_rectangle 之一 +- **THEN** 系统报告 ERROR 级别错误:"不支持的形状类型: {shape}" + +### Requirement: 几何验证 + +系统必须验证元素的位置和尺寸是否在页面范围内。 + +#### Scenario: 检测元素超出页面右边界 + +- **WHEN** 元素的 left + width 超出页面宽度 0.1 英寸以上 +- **THEN** 系统报告 WARNING 级别警告:"元素右边界超出: {right} > {width}" + +#### Scenario: 检测元素超出页面下边界 + +- **WHEN** 元素的 top + height 超出页面高度 0.1 英寸以上 +- **THEN** 系统报告 WARNING 级别警告:"元素下边界超出: {bottom} > {height}" + +#### Scenario: 容忍计算精度误差 + +- **WHEN** 元素的边界超出页面范围不超过 0.1 英寸 +- **THEN** 系统不报告警告 + +#### Scenario: 检测元素完全在页面外 + +- **WHEN** 元素的 left >= 页面宽度 或 top >= 页面高度 +- **THEN** 系统报告 WARNING 级别警告:"元素完全在页面外" + +#### Scenario: 检测表格超出页面范围 + +- **WHEN** 表格的 position[0] + sum(col_widths) 超出页面宽度 0.1 英寸以上 +- **THEN** 系统报告 WARNING 级别警告:"表格超出页面宽度" + +### Requirement: 资源验证 + +系统必须验证 YAML 文件引用的外部资源是否存在。 + +#### Scenario: 检测图片文件不存在 + +- **WHEN** image 元素的 src 指向的文件不存在 +- **THEN** 系统报告 ERROR 级别错误:"图片文件不存在: {src}" + +#### Scenario: 支持相对路径图片 + +- **WHEN** image 元素的 src 是相对路径 +- **THEN** 系统相对于 YAML 文件所在目录解析路径 + +#### Scenario: 检测模板文件不存在 + +- **WHEN** 幻灯片引用的 template 文件不存在 +- **THEN** 系统报告 ERROR 级别错误:"模板文件不存在: {template}" + +#### Scenario: 验证模板文件结构 + +- **WHEN** 模板文件存在但结构不正确(缺少 elements 字段) +- **THEN** 系统报告 ERROR 级别错误:"模板文件结构错误: {template}" + +### Requirement: 样式验证 + +系统必须验证样式相关属性的有效性和合理性。 + +#### Scenario: 检测无效的颜色格式 + +- **WHEN** 颜色值不符合 #RGB 或 #RRGGBB 格式 +- **THEN** 系统报告 ERROR 级别错误:"无效的颜色格式: {color}" + +#### Scenario: 检测字体过小 + +- **WHEN** 字体大小小于 8pt +- **THEN** 系统报告 WARNING 级别警告:"字体太小: {size}pt (建议 >= 8pt)" + +#### Scenario: 检测字体过大 + +- **WHEN** 字体大小大于 100pt +- **THEN** 系统报告 WARNING 级别警告:"字体太大: {size}pt (建议 <= 100pt)" + +### Requirement: 分级错误报告 + +系统必须按照严重程度对验证问题进行分级报告。 + +#### Scenario: ERROR 级别阻止转换 + +- **WHEN** 验证发现 ERROR 级别问题 +- **THEN** 系统在独立验证模式下返回非零退出码,在自动验证模式下阻止转换 + +#### Scenario: WARNING 级别不阻止转换 + +- **WHEN** 验证仅发现 WARNING 级别问题 +- **THEN** 系统输出警告信息但允许转换继续,返回零退出码 + +#### Scenario: 显示问题位置 + +- **WHEN** 验证发现问题 +- **THEN** 系统输出包含幻灯片编号和元素编号的位置信息:"[幻灯片 {n}, 元素 {m}]" + +#### Scenario: 分类统计问题数量 + +- **WHEN** 验证完成 +- **THEN** 系统输出问题统计:"发现 {n} 个错误, {m} 个警告" + +### Requirement: 清晰的命令行输出 + +系统必须以清晰易读的格式输出验证结果。 + +#### Scenario: 使用图标区分级别 + +- **WHEN** 输出验证结果 +- **THEN** 系统使用 "❌" 表示 ERROR,"⚠️" 表示 WARNING,"ℹ️" 表示 INFO + +#### Scenario: 按级别分组显示 + +- **WHEN** 输出验证结果 +- **THEN** 系统按 ERROR、WARNING、INFO 分组显示问题 + +#### Scenario: 验证通过的提示 + +- **WHEN** 验证未发现任何问题 +- **THEN** 系统输出:"✅ 验证通过,未发现问题" diff --git a/validators/__init__.py b/validators/__init__.py new file mode 100644 index 0000000..94d7fc8 --- /dev/null +++ b/validators/__init__.py @@ -0,0 +1,10 @@ +""" +验证器模块 + +提供 YAML 文件验证功能。 +""" + +from validators.validator import Validator +from validators.result import ValidationResult, ValidationIssue + +__all__ = ['Validator', 'ValidationResult', 'ValidationIssue'] diff --git a/validators/geometry.py b/validators/geometry.py new file mode 100644 index 0000000..8036d12 --- /dev/null +++ b/validators/geometry.py @@ -0,0 +1,131 @@ +""" +几何验证器 + +验证元素的位置和尺寸是否在页面范围内。 +""" + +from validators.result import ValidationIssue + + +# 容忍度:0.1 英寸 +TOLERANCE = 0.1 + + +class GeometryValidator: + """几何验证器""" + + def __init__(self, slide_width: float, slide_height: float): + """ + 初始化几何验证器 + + Args: + slide_width: 幻灯片宽度(英寸) + slide_height: 幻灯片高度(英寸) + """ + self.slide_width = slide_width + self.slide_height = slide_height + + def validate_element(self, element, slide_index: int, elem_index: int) -> list: + """ + 验证元素的几何属性 + + Args: + element: 元素对象 + slide_index: 幻灯片索引(从 1 开始) + elem_index: 元素索引(从 1 开始) + + Returns: + 验证问题列表 + """ + issues = [] + location = f"幻灯片 {slide_index}, 元素 {elem_index}" + + # 检查元素边界 + if hasattr(element, 'box'): + issues.extend(self._check_element_bounds( + element.box, location + )) + + # 检查表格边界 + if hasattr(element, 'position') and hasattr(element, 'col_widths'): + if element.col_widths: + issues.extend(self._check_table_bounds( + element.position, element.col_widths, location + )) + + return issues + + def _check_element_bounds(self, box: list, location: str) -> list: + """ + 检查元素边界是否在页面范围内 + + Args: + box: [left, top, width, height] + location: 位置信息 + + Returns: + 验证问题列表 + """ + issues = [] + left, top, width, height = box + right = left + width + bottom = top + height + + # 检查元素是否完全在页面外 + if (right <= 0 or bottom <= 0 or + left >= self.slide_width or top >= self.slide_height): + issues.append(ValidationIssue( + level="WARNING", + message="元素完全在页面外", + location=location, + code="ELEMENT_COMPLETELY_OUT_OF_BOUNDS" + )) + return issues + + # 检查右边界 + if right > self.slide_width + TOLERANCE: + issues.append(ValidationIssue( + level="WARNING", + message=f"元素右边界超出: {right:.2f} > {self.slide_width}", + location=location, + code="ELEMENT_OUT_OF_BOUNDS" + )) + + # 检查下边界 + if bottom > self.slide_height + TOLERANCE: + issues.append(ValidationIssue( + level="WARNING", + message=f"元素下边界超出: {bottom:.2f} > {self.slide_height}", + location=location, + code="ELEMENT_OUT_OF_BOUNDS" + )) + + return issues + + def _check_table_bounds(self, position: list, col_widths: list, location: str) -> list: + """ + 检查表格边界是否在页面范围内 + + Args: + position: [left, top] + col_widths: 列宽列表 + location: 位置信息 + + Returns: + 验证问题列表 + """ + issues = [] + left, top = position + table_width = sum(col_widths) + right = left + table_width + + # 检查表格右边界 + if right > self.slide_width + TOLERANCE: + issues.append(ValidationIssue( + level="WARNING", + message=f"表格超出页面宽度: {right:.2f} > {self.slide_width}", + location=location, + code="TABLE_OUT_OF_BOUNDS" + )) + + return issues diff --git a/validators/resource.py b/validators/resource.py new file mode 100644 index 0000000..4d98ad3 --- /dev/null +++ b/validators/resource.py @@ -0,0 +1,122 @@ +""" +资源验证器 + +验证 YAML 文件引用的外部资源是否存在。 +""" + +from pathlib import Path +from validators.result import ValidationIssue +from loaders.yaml_loader import load_yaml_file, validate_template_yaml + + +class ResourceValidator: + """资源验证器""" + + def __init__(self, yaml_dir: Path, template_dir: Path = None): + """ + 初始化资源验证器 + + Args: + yaml_dir: YAML 文件所在目录 + template_dir: 模板文件目录(可选) + """ + self.yaml_dir = yaml_dir + self.template_dir = template_dir + + def validate_image(self, element, slide_index: int, elem_index: int) -> list: + """ + 验证图片文件是否存在 + + Args: + element: 图片元素对象 + slide_index: 幻灯片索引(从 1 开始) + elem_index: 元素索引(从 1 开始) + + Returns: + 验证问题列表 + """ + issues = [] + location = f"幻灯片 {slide_index}, 元素 {elem_index}" + + if not hasattr(element, 'src'): + return issues + + src = element.src + if not src: + return issues + + # 解析路径(支持相对路径和绝对路径) + src_path = Path(src) + if not src_path.is_absolute(): + src_path = self.yaml_dir / src + + # 检查文件是否存在 + if not src_path.exists(): + issues.append(ValidationIssue( + level="ERROR", + message=f"图片文件不存在: {src}", + location=location, + code="IMAGE_FILE_NOT_FOUND" + )) + + return issues + + def validate_template(self, slide_data: dict, slide_index: int) -> list: + """ + 验证模板文件是否存在且结构正确 + + Args: + slide_data: 幻灯片数据字典 + slide_index: 幻灯片索引(从 1 开始) + + Returns: + 验证问题列表 + """ + issues = [] + location = f"幻灯片 {slide_index}" + + if 'template' not in slide_data: + return issues + + template_name = slide_data['template'] + if not template_name: + return issues + + # 检查是否提供了模板目录 + if not self.template_dir: + issues.append(ValidationIssue( + level="ERROR", + message=f"使用了模板但未指定模板目录: {template_name}", + location=location, + code="TEMPLATE_DIR_NOT_SPECIFIED" + )) + return issues + + # 解析模板文件路径 + template_path = self.template_dir / template_name + if not template_path.suffix: + template_path = template_path.with_suffix('.yaml') + + # 检查模板文件是否存在 + if not template_path.exists(): + issues.append(ValidationIssue( + level="ERROR", + message=f"模板文件不存在: {template_name}", + location=location, + code="TEMPLATE_FILE_NOT_FOUND" + )) + return issues + + # 验证模板文件结构 + try: + template_data = load_yaml_file(template_path) + validate_template_yaml(template_data, str(template_path)) + except Exception as e: + issues.append(ValidationIssue( + level="ERROR", + message=f"模板文件结构错误: {template_name} - {str(e)}", + location=location, + code="TEMPLATE_STRUCTURE_ERROR" + )) + + return issues diff --git a/validators/result.py b/validators/result.py new file mode 100644 index 0000000..204c25c --- /dev/null +++ b/validators/result.py @@ -0,0 +1,73 @@ +""" +验证结果数据结构 + +定义验证问题和验证结果的数据类。 +""" + +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class ValidationIssue: + """验证问题""" + level: str # "ERROR" | "WARNING" | "INFO" + message: str + location: str # "幻灯片 2, 元素 3" + code: str # "ELEMENT_OUT_OF_BOUNDS" + + +@dataclass +class ValidationResult: + """验证结果""" + valid: bool # 是否有 ERROR + errors: List[ValidationIssue] = field(default_factory=list) + warnings: List[ValidationIssue] = field(default_factory=list) + infos: List[ValidationIssue] = field(default_factory=list) + + def has_errors(self) -> bool: + """是否有错误""" + return len(self.errors) > 0 + + def format_output(self) -> str: + """格式化为命令行输出""" + lines = [] + + lines.append("🔍 正在检查 YAML 文件...\n") + + # 错误 + if self.errors: + lines.append(f"❌ 错误 ({len(self.errors)}):") + for issue in self.errors: + location_str = f"[{issue.location}] " if issue.location else "" + lines.append(f" {location_str}{issue.message}") + lines.append("") + + # 警告 + if self.warnings: + lines.append(f"⚠️ 警告 ({len(self.warnings)}):") + for issue in self.warnings: + location_str = f"[{issue.location}] " if issue.location else "" + lines.append(f" {location_str}{issue.message}") + lines.append("") + + # 提示 + if self.infos: + lines.append(f"ℹ️ 提示 ({len(self.infos)}):") + for issue in self.infos: + location_str = f"[{issue.location}] " if issue.location else "" + lines.append(f" {location_str}{issue.message}") + lines.append("") + + # 总结 + if not self.errors and not self.warnings and not self.infos: + lines.append("✅ 验证通过,未发现问题") + else: + summary_parts = [] + if self.errors: + summary_parts.append(f"{len(self.errors)} 个错误") + if self.warnings: + summary_parts.append(f"{len(self.warnings)} 个警告") + lines.append(f"检查完成: 发现 {', '.join(summary_parts)}") + + return "\n".join(lines) diff --git a/validators/validator.py b/validators/validator.py new file mode 100644 index 0000000..5dc1481 --- /dev/null +++ b/validators/validator.py @@ -0,0 +1,160 @@ +""" +主验证器 + +协调各子验证器,执行完整的 YAML 文件验证。 +""" + +from pathlib import Path +from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml, YAMLError +from validators.result import ValidationResult, ValidationIssue +from validators.geometry import GeometryValidator +from validators.resource import ResourceValidator +from core.elements import create_element + + +class Validator: + """主验证器""" + + # 幻灯片尺寸映射(英寸) + SLIDE_SIZES = { + "16:9": (10, 5.625), + "4:3": (10, 7.5), + } + + def __init__(self): + """初始化验证器""" + pass + + def validate(self, yaml_path: Path, template_dir: Path = None) -> ValidationResult: + """ + 验证 YAML 文件 + + Args: + yaml_path: YAML 文件路径 + template_dir: 模板文件目录(可选) + + Returns: + 验证结果 + """ + errors = [] + warnings = [] + infos = [] + + # 1. 加载 YAML + try: + data = load_yaml_file(yaml_path) + validate_presentation_yaml(data, str(yaml_path)) + except YAMLError as e: + errors.append(ValidationIssue( + level="ERROR", + message=str(e), + location="", + code="YAML_ERROR" + )) + return ValidationResult( + valid=False, + errors=errors, + warnings=warnings, + infos=infos + ) + except Exception as e: + errors.append(ValidationIssue( + level="ERROR", + message=f"加载 YAML 文件失败: {str(e)}", + location="", + code="YAML_LOAD_ERROR" + )) + return ValidationResult( + valid=False, + errors=errors, + warnings=warnings, + infos=infos + ) + + # 获取幻灯片尺寸 + size_str = data.get('metadata', {}).get('size', '16:9') + slide_width, slide_height = self.SLIDE_SIZES.get(size_str, (10, 5.625)) + + # 初始化子验证器 + geometry_validator = GeometryValidator(slide_width, slide_height) + resource_validator = ResourceValidator( + yaml_dir=yaml_path.parent, + template_dir=template_dir + ) + + # 2. 验证每个幻灯片 + slides = data.get('slides', []) + for slide_index, slide_data in enumerate(slides, start=1): + # 验证模板 + template_issues = resource_validator.validate_template(slide_data, slide_index) + self._categorize_issues(template_issues, errors, warnings, infos) + + # 验证元素 + elements = slide_data.get('elements', []) + for elem_index, elem_dict in enumerate(elements, start=1): + # 3. 元素级验证 + try: + element = create_element(elem_dict) + + # 调用元素的 validate() 方法 + if hasattr(element, 'validate'): + elem_issues = element.validate() + # 填充位置信息 + for issue in elem_issues: + issue.location = f"幻灯片 {slide_index}, 元素 {elem_index}" + self._categorize_issues(elem_issues, errors, warnings, infos) + + # 4. 几何验证 + geom_issues = geometry_validator.validate_element( + element, slide_index, elem_index + ) + self._categorize_issues(geom_issues, errors, warnings, infos) + + # 5. 资源验证(图片) + if elem_dict.get('type') == 'image': + img_issues = resource_validator.validate_image( + element, slide_index, elem_index + ) + self._categorize_issues(img_issues, errors, warnings, infos) + + except ValueError as e: + # 元素创建失败(__post_init__ 中的验证) + errors.append(ValidationIssue( + level="ERROR", + message=str(e), + location=f"幻灯片 {slide_index}, 元素 {elem_index}", + code="ELEMENT_VALIDATION_ERROR" + )) + except Exception as e: + errors.append(ValidationIssue( + level="ERROR", + message=f"验证元素时出错: {str(e)}", + location=f"幻灯片 {slide_index}, 元素 {elem_index}", + code="ELEMENT_VALIDATION_ERROR" + )) + + # 返回验证结果 + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings, + infos=infos + ) + + def _categorize_issues(self, issues: list, errors: list, warnings: list, infos: list): + """ + 将问题按级别分类 + + Args: + issues: 问题列表 + errors: 错误列表 + warnings: 警告列表 + infos: 提示列表 + """ + for issue in issues: + if issue.level == "ERROR": + errors.append(issue) + elif issue.level == "WARNING": + warnings.append(issue) + elif issue.level == "INFO": + infos.append(issue) diff --git a/yaml2pptx.py b/yaml2pptx.py index ba74f44..27ca474 100644 --- a/yaml2pptx.py +++ b/yaml2pptx.py @@ -14,6 +14,11 @@ YAML to PPTX Converter 将 YAML 格式的演示文稿源文件转换为 PPTX 文件 使用方法: + # 子命令模式(推荐) + uv run yaml2pptx.py convert input.yaml output.pptx + uv run yaml2pptx.py check input.yaml + + # 传统模式(向后兼容) uv run yaml2pptx.py input.yaml output.pptx uv run yaml2pptx.py input.yaml # 自动生成 input.pptx """ @@ -28,42 +33,78 @@ from loaders.yaml_loader import YAMLError from core.presentation import Presentation from renderers.pptx_renderer import PptxGenerator from preview.server import start_preview_server +from validators import Validator def parse_args(): """解析命令行参数""" - parser = argparse.ArgumentParser( - description="将 YAML 格式的演示文稿源文件转换为 PPTX 文件" - ) - parser.add_argument( - "input", - type=str, - help="输入的 YAML 文件路径" - ) - parser.add_argument( - "output", - type=str, - nargs="?", - help="输出的 PPTX 文件路径(可选,默认为输入文件名.pptx)" - ) - parser.add_argument( - "--template-dir", - type=str, - default=None, - help="模板文件目录路径(如果 YAML 中使用了模板则必须指定)。可以是绝对路径或相对路径(相对于当前工作目录)" - ) - parser.add_argument( - "--preview", - action="store_true", - help="启动浏览器预览模式,而不是生成 PPTX 文件" - ) - parser.add_argument( - "--port", - type=int, - default=None, - help="预览服务器端口(默认随机选择 20000-30000 之间的端口)" - ) - return parser.parse_args() + # 检查是否使用了子命令 + if len(sys.argv) > 1 and sys.argv[1] in ['check', 'convert']: + # 使用子命令解析 + parser = argparse.ArgumentParser( + description="将 YAML 格式的演示文稿源文件转换为 PPTX 文件" + ) + subparsers = parser.add_subparsers(dest='command') + + # check 子命令 + check_parser = subparsers.add_parser('check', help='验证 YAML 文件') + check_parser.add_argument("input", type=str, help="输入的 YAML 文件路径") + check_parser.add_argument("--template-dir", type=str, default=None, help="模板文件目录路径") + + # convert 子命令 + convert_parser = subparsers.add_parser('convert', help='转换 YAML 为 PPTX') + convert_parser.add_argument("input", type=str, help="输入的 YAML 文件路径") + convert_parser.add_argument("output", type=str, nargs="?", help="输出的 PPTX 文件路径(可选,默认为输入文件名.pptx)") + convert_parser.add_argument("--template-dir", type=str, default=None, help="模板文件目录路径") + convert_parser.add_argument("--preview", action="store_true", help="启动浏览器预览模式") + convert_parser.add_argument("--port", type=int, default=None, help="预览服务器端口") + convert_parser.add_argument("--no-check", action="store_true", help="跳过自动验证") + + return parser.parse_args() + else: + # 使用传统解析(转换命令)- 向后兼容 + parser = argparse.ArgumentParser( + description="将 YAML 格式的演示文稿源文件转换为 PPTX 文件" + ) + parser.add_argument("input", type=str, help="输入的 YAML 文件路径") + parser.add_argument("output", type=str, nargs="?", help="输出的 PPTX 文件路径(可选,默认为输入文件名.pptx)") + parser.add_argument("--template-dir", type=str, default=None, help="模板文件目录路径") + parser.add_argument("--preview", action="store_true", help="启动浏览器预览模式") + parser.add_argument("--port", type=int, default=None, help="预览服务器端口") + parser.add_argument("--no-check", action="store_true", help="跳过自动验证") + args = parser.parse_args() + args.command = None # 标记为非子命令(向后兼容模式) + return args + + +def handle_check(args): + """处理 check 子命令""" + try: + input_path = Path(args.input) + + # 处理模板目录 + template_dir = None + if args.template_dir: + template_dir = Path(args.template_dir) + + # 执行验证 + validator = Validator() + result = validator.validate(input_path, template_dir) + + # 输出验证结果 + print(result.format_output()) + + # 返回退出码 + if result.has_errors(): + sys.exit(1) + else: + sys.exit(0) + + except Exception as e: + log_error(f"验证失败: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) def main(): @@ -71,47 +112,16 @@ def main(): try: args = parse_args() - # 处理输入文件路径 - input_path = Path(args.input) - - # 预览模式 - if args.preview: - start_preview_server(input_path, args.template_dir, args.port) + # 处理 check 子命令 + if args.command == 'check': + handle_check(args) return - # PPTX 生成模式 - # 处理输出文件路径 - if args.output: - output_path = Path(args.output) - else: - # 自动生成输出文件名 - output_path = input_path.with_suffix(".pptx") - - log_info(f"开始转换: {input_path}") - log_info(f"输出文件: {output_path}") - - # 1. 加载演示文稿 - log_info("加载演示文稿...") - pres = Presentation(input_path, templates_dir=args.template_dir) - - # 2. 创建 PPTX 生成器 - log_info(f"创建演示文稿 ({pres.size})...") - generator = PptxGenerator(pres.size) - - # 3. 渲染所有幻灯片 - slides_data = pres.data.get('slides', []) - total_slides = len(slides_data) - - for i, slide_data in enumerate(slides_data, 1): - log_progress(i, total_slides, f"处理幻灯片") - rendered_slide = pres.render_slide(slide_data) - generator.add_slide(rendered_slide, input_path.parent) - - # 4. 保存 PPTX 文件 - log_info("保存 PPTX 文件...") - generator.save(output_path) - - log_success(f"转换完成: {output_path}") + # 处理 convert 子命令或传统模式(向后兼容) + # convert 子命令和无子命令的行为相同 + if args.command == 'convert' or args.command is None: + handle_convert(args) + return except YAMLError as e: log_error(str(e)) @@ -123,5 +133,73 @@ def main(): sys.exit(1) +def handle_convert(args): + """处理 convert 子命令或传统转换模式""" + # 检查是否提供了输入文件 + if not args.input: + log_error("错误: 缺少输入文件参数") + sys.exit(1) + + # 处理输入文件路径 + input_path = Path(args.input) + + # 预览模式 + if args.preview: + start_preview_server(input_path, args.template_dir, args.port) + return + + # PPTX 生成模式 + # 处理输出文件路径 + if args.output: + output_path = Path(args.output) + else: + # 自动生成输出文件名 + output_path = input_path.with_suffix(".pptx") + + log_info(f"开始转换: {input_path}") + log_info(f"输出文件: {output_path}") + + # 自动验证(除非使用 --no-check) + if not args.no_check: + log_info("验证 YAML 文件...") + template_dir = Path(args.template_dir) if args.template_dir else None + validator = Validator() + result = validator.validate(input_path, template_dir) + + # 如果有错误,输出并终止 + if result.has_errors(): + print("\n" + result.format_output()) + log_error("验证失败,转换已终止") + sys.exit(1) + + # 如果有警告,输出但继续 + if result.warnings: + print("\n" + result.format_output()) + print() # 空行 + + # 1. 加载演示文稿 + log_info("加载演示文稿...") + pres = Presentation(input_path, templates_dir=args.template_dir) + + # 2. 创建 PPTX 生成器 + log_info(f"创建演示文稿 ({pres.size})...") + generator = PptxGenerator(pres.size) + + # 3. 渲染所有幻灯片 + slides_data = pres.data.get('slides', []) + total_slides = len(slides_data) + + for i, slide_data in enumerate(slides_data, 1): + log_progress(i, total_slides, f"处理幻灯片") + rendered_slide = pres.render_slide(slide_data) + generator.add_slide(rendered_slide, input_path.parent) + + # 4. 保存 PPTX 文件 + log_info("保存 PPTX 文件...") + generator.save(output_path) + + log_success(f"转换完成: {output_path}") + + if __name__ == "__main__": main()