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:
10
validators/__init__.py
Normal file
10
validators/__init__.py
Normal 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
131
validators/geometry.py
Normal 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
122
validators/resource.py
Normal 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
73
validators/result.py
Normal 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
160
validators/validator.py
Normal 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)
|
||||
Reference in New Issue
Block a user