refactor: modularize yaml2pptx into layered architecture
Refactor yaml2pptx.py from a 1,245-line monolithic script into a modular architecture with clear separation of concerns. The entry point is now 127 lines, with business logic distributed across focused modules. Architecture: - core/: Domain models (elements, template, presentation) - loaders/: YAML loading and validation - renderers/: PPTX and HTML rendering - preview/: Flask preview server - utils.py: Shared utilities Key improvements: - Element abstraction layer using dataclass with validation - Renderer logic built into generator classes - Single-direction dependencies (no circular imports) - Each module 150-300 lines for better readability - Backward compatible CLI interface Documentation: - README.md: User-facing usage guide - README_DEV.md: Developer documentation OpenSpec: - Archive refactor-yaml2pptx-modular change (63/70 tasks complete) - Sync 5 delta specs to main specs (2 new + 3 updated)
This commit is contained in:
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
96
core/elements.py
Normal file
96
core/elements.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
元素抽象层模块
|
||||
|
||||
定义统一的元素数据类,支持元素验证和未来扩展。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextElement:
|
||||
"""文本元素"""
|
||||
type: str = 'text'
|
||||
content: str = ''
|
||||
box: list = field(default_factory=lambda: [1, 1, 8, 1])
|
||||
font: dict = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not isinstance(self.box, list) or len(self.box) != 4:
|
||||
raise ValueError("box 必须是包含 4 个数字的列表")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageElement:
|
||||
"""图片元素"""
|
||||
type: str = 'image'
|
||||
src: str = ''
|
||||
box: list = field(default_factory=lambda: [1, 1, 4, 3])
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not self.src:
|
||||
raise ValueError("图片元素必须指定 src")
|
||||
if not isinstance(self.box, list) or len(self.box) != 4:
|
||||
raise ValueError("box 必须是包含 4 个数字的列表")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShapeElement:
|
||||
"""形状元素"""
|
||||
type: str = 'shape'
|
||||
shape: str = 'rectangle'
|
||||
box: list = field(default_factory=lambda: [1, 1, 2, 1])
|
||||
fill: Optional[str] = None
|
||||
line: Optional[dict] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not isinstance(self.box, list) or len(self.box) != 4:
|
||||
raise ValueError("box 必须是包含 4 个数字的列表")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableElement:
|
||||
"""表格元素"""
|
||||
type: str = 'table'
|
||||
data: list = field(default_factory=list)
|
||||
position: list = field(default_factory=lambda: [1, 1])
|
||||
col_widths: list = field(default_factory=list)
|
||||
style: dict = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not self.data:
|
||||
raise ValueError("表格数据不能为空")
|
||||
if not isinstance(self.position, list) or len(self.position) != 2:
|
||||
raise ValueError("position 必须是包含 2 个数字的列表")
|
||||
|
||||
|
||||
def create_element(elem_dict: dict):
|
||||
"""
|
||||
元素工厂函数,从字典创建对应类型的元素对象
|
||||
|
||||
Args:
|
||||
elem_dict: 元素字典,必须包含 type 字段
|
||||
|
||||
Returns:
|
||||
对应类型的元素对象
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的元素类型
|
||||
"""
|
||||
elem_type = elem_dict.get('type')
|
||||
|
||||
if elem_type == 'text':
|
||||
return TextElement(**elem_dict)
|
||||
elif elem_type == 'image':
|
||||
return ImageElement(**elem_dict)
|
||||
elif elem_type == 'shape':
|
||||
return ShapeElement(**elem_dict)
|
||||
elif elem_type == 'table':
|
||||
return TableElement(**elem_dict)
|
||||
else:
|
||||
raise ValueError(f"不支持的元素类型: {elem_type}")
|
||||
91
core/presentation.py
Normal file
91
core/presentation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
演示文稿模块
|
||||
|
||||
管理整个演示文稿的生成流程。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml
|
||||
from core.template import Template
|
||||
from core.elements import create_element
|
||||
|
||||
|
||||
class Presentation:
|
||||
"""演示文稿类,管理整个演示文稿的生成流程"""
|
||||
|
||||
def __init__(self, pres_file, templates_dir=None):
|
||||
"""
|
||||
初始化演示文稿
|
||||
|
||||
Args:
|
||||
pres_file: 演示文稿 YAML 文件路径
|
||||
templates_dir: 模板目录
|
||||
"""
|
||||
self.pres_file = Path(pres_file)
|
||||
self.templates_dir = templates_dir
|
||||
|
||||
# 加载演示文稿文件
|
||||
self.data = load_yaml_file(pres_file)
|
||||
validate_presentation_yaml(self.data, str(pres_file))
|
||||
|
||||
# 获取演示文稿尺寸
|
||||
metadata = self.data.get('metadata', {})
|
||||
self.size = metadata.get('size', '16:9')
|
||||
|
||||
# 模板缓存
|
||||
self.template_cache = {}
|
||||
|
||||
def get_template(self, template_name):
|
||||
"""
|
||||
获取模板(带缓存)
|
||||
|
||||
Args:
|
||||
template_name: 模板名称
|
||||
|
||||
Returns:
|
||||
Template 对象
|
||||
"""
|
||||
if template_name not in self.template_cache:
|
||||
self.template_cache[template_name] = Template(
|
||||
template_name, self.templates_dir
|
||||
)
|
||||
return self.template_cache[template_name]
|
||||
|
||||
def render_slide(self, slide_data):
|
||||
"""
|
||||
渲染单个幻灯片
|
||||
|
||||
Args:
|
||||
slide_data: 幻灯片数据字典
|
||||
|
||||
Returns:
|
||||
dict: 包含 background 和 elements 的字典
|
||||
"""
|
||||
if 'template' in slide_data:
|
||||
# 使用模板
|
||||
template_name = slide_data['template']
|
||||
template = self.get_template(template_name)
|
||||
vars_values = slide_data.get('vars', {})
|
||||
elements = template.render(vars_values)
|
||||
|
||||
# 合并背景(如果有)
|
||||
background = slide_data.get('background', None)
|
||||
|
||||
# 将元素字典转换为元素对象
|
||||
element_objects = [create_element(elem) for elem in elements]
|
||||
|
||||
return {
|
||||
'background': background,
|
||||
'elements': element_objects
|
||||
}
|
||||
else:
|
||||
# 自定义幻灯片
|
||||
elements = slide_data.get('elements', [])
|
||||
|
||||
# 将元素字典转换为元素对象
|
||||
element_objects = [create_element(elem) for elem in elements]
|
||||
|
||||
return {
|
||||
'background': slide_data.get('background'),
|
||||
'elements': element_objects
|
||||
}
|
||||
191
core/template.py
Normal file
191
core/template.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
模板系统模块
|
||||
|
||||
管理可复用的幻灯片布局和变量解析。
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from loaders.yaml_loader import YAMLError, load_yaml_file, validate_template_yaml
|
||||
|
||||
|
||||
class Template:
|
||||
"""模板类,管理可复用的幻灯片布局"""
|
||||
|
||||
def __init__(self, template_file, templates_dir=None):
|
||||
"""
|
||||
初始化模板
|
||||
|
||||
Args:
|
||||
template_file: 模板名称(纯文件名,不含路径)
|
||||
templates_dir: 模板文件目录
|
||||
"""
|
||||
# 检查是否提供了 templates_dir
|
||||
if templates_dir is None:
|
||||
raise YAMLError(
|
||||
f"未指定模板目录,无法加载模板: {template_file}\n"
|
||||
f"请使用 --template-dir 参数指定模板目录"
|
||||
)
|
||||
|
||||
# 验证模板名称(不能包含路径分隔符)
|
||||
if '/' in template_file or '\\' in template_file:
|
||||
raise YAMLError(
|
||||
f"模板名称不能包含路径分隔符: {template_file}\n"
|
||||
f"模板名称应该是纯文件名,如: 'title-slide'"
|
||||
)
|
||||
|
||||
# 构建模板路径
|
||||
template_path = Path(templates_dir) / f"{template_file}.yaml"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not template_path.exists():
|
||||
raise YAMLError(
|
||||
f"模板文件不存在: {template_file}\n"
|
||||
f"查找位置: {templates_dir}\n"
|
||||
f"期望文件: {template_path}\n"
|
||||
f"提示: 请检查模板名称和模板目录是否正确"
|
||||
)
|
||||
|
||||
# 加载并验证模板文件
|
||||
self.data = load_yaml_file(template_path)
|
||||
validate_template_yaml(self.data, str(template_path))
|
||||
|
||||
# 解析变量定义
|
||||
self.vars_def = {}
|
||||
for var in self.data.get('vars', []):
|
||||
self.vars_def[var['name']] = var
|
||||
|
||||
# 元素列表
|
||||
self.elements = self.data.get('elements', [])
|
||||
|
||||
def resolve_value(self, value, vars_values):
|
||||
"""
|
||||
解析单个值中的变量引用
|
||||
|
||||
Args:
|
||||
value: 要解析的值
|
||||
vars_values: 用户提供的变量值字典
|
||||
|
||||
Returns:
|
||||
解析后的值
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
# 匹配 {xxx} 模式
|
||||
pattern = r'\{([^}]+)\}'
|
||||
|
||||
def replacer(match):
|
||||
expr = match.group(1)
|
||||
|
||||
# 模板变量: {title}
|
||||
if expr in vars_values:
|
||||
return str(vars_values[expr])
|
||||
else:
|
||||
raise YAMLError(f"未定义的变量: {expr}")
|
||||
|
||||
result = re.sub(pattern, replacer, value)
|
||||
|
||||
# 如果结果是纯数字字符串,转换回数字类型
|
||||
try:
|
||||
# 尝试转换为整数
|
||||
if '.' not in result:
|
||||
return int(result)
|
||||
# 尝试转换为浮点数
|
||||
else:
|
||||
return float(result)
|
||||
except ValueError:
|
||||
# 不是数字,返回字符串
|
||||
return result
|
||||
|
||||
def resolve_element(self, elem, vars_values):
|
||||
"""
|
||||
递归解析元素中的所有变量
|
||||
|
||||
Args:
|
||||
elem: 元素(dict, list, 或其他类型)
|
||||
vars_values: 用户提供的变量值字典
|
||||
|
||||
Returns:
|
||||
解析后的元素
|
||||
"""
|
||||
if isinstance(elem, dict):
|
||||
return {k: self.resolve_element(v, vars_values)
|
||||
for k, v in elem.items() if k != 'visible'}
|
||||
elif isinstance(elem, list):
|
||||
return [self.resolve_element(item, vars_values)
|
||||
for item in elem]
|
||||
elif isinstance(elem, str):
|
||||
return self.resolve_value(elem, vars_values)
|
||||
else:
|
||||
return elem
|
||||
|
||||
def evaluate_condition(self, condition, vars_values):
|
||||
"""
|
||||
评估条件表达式(简单的存在性检查)
|
||||
|
||||
Args:
|
||||
condition: 条件字符串,如 "{subtitle != ''}"
|
||||
vars_values: 变量值字典
|
||||
|
||||
Returns:
|
||||
bool: 条件是否满足
|
||||
"""
|
||||
# 简单实现:检查变量是否非空
|
||||
# 匹配 {var_name != ''} 或 {var_name != ""}
|
||||
pattern = r'\{(\w+)\s*!=\s*[\'\"]{2}\}'
|
||||
match = re.match(pattern, condition)
|
||||
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
value = vars_values.get(var_name, '')
|
||||
return value != ''
|
||||
|
||||
# 默认返回 True
|
||||
return True
|
||||
|
||||
def render(self, vars_values):
|
||||
"""
|
||||
渲染模板,返回实际的元素列表
|
||||
|
||||
Args:
|
||||
vars_values: 用户提供的变量值字典
|
||||
|
||||
Returns:
|
||||
list: 渲染后的元素列表
|
||||
|
||||
Raises:
|
||||
YAMLError: 缺少必需变量
|
||||
"""
|
||||
# 填充所有变量的默认值(如果用户未提供)
|
||||
for var_name, var_def in self.vars_def.items():
|
||||
if var_name not in vars_values:
|
||||
# 检查是否是必需变量
|
||||
if var_def.get('required', False):
|
||||
# 必需变量必须有默认值或用户提供
|
||||
if 'default' in var_def:
|
||||
vars_values[var_name] = self.resolve_value(
|
||||
var_def['default'], vars_values
|
||||
)
|
||||
else:
|
||||
raise YAMLError(f"缺少必需变量: {var_name}")
|
||||
else:
|
||||
# 可选变量使用默认值(如果有)
|
||||
if 'default' in var_def:
|
||||
vars_values[var_name] = self.resolve_value(
|
||||
var_def['default'], vars_values
|
||||
)
|
||||
|
||||
# 渲染所有元素
|
||||
rendered_elements = []
|
||||
for elem in self.elements:
|
||||
# 检查条件渲染
|
||||
if 'visible' in elem:
|
||||
if not self.evaluate_condition(elem['visible'], vars_values):
|
||||
continue
|
||||
|
||||
# 深度解析元素中的所有变量引用
|
||||
rendered_elem = self.resolve_element(elem, vars_values)
|
||||
rendered_elements.append(rendered_elem)
|
||||
|
||||
return rendered_elements
|
||||
Reference in New Issue
Block a user