1
0

feat: add YAML validation with check command and auto-validation

Implements comprehensive validation before PPTX conversion to catch errors early. Includes element-level validation (colors, fonts, table consistency) and system-level validation (geometry, resources). Supports standalone check command and automatic validation during conversion.
This commit is contained in:
2026-03-02 18:14:45 +08:00
parent d598de27b3
commit 83ff827ad1
16 changed files with 1742 additions and 95 deletions

10
validators/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
验证器模块
提供 YAML 文件验证功能。
"""
from validators.validator import Validator
from validators.result import ValidationResult, ValidationIssue
__all__ = ['Validator', 'ValidationResult', 'ValidationIssue']

131
validators/geometry.py Normal file
View File

@@ -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

122
validators/resource.py Normal file
View File

@@ -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

73
validators/result.py Normal file
View File

@@ -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)

160
validators/validator.py Normal file
View File

@@ -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)