1
0

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:
2026-03-02 16:43:45 +08:00
parent b2132dc06b
commit ed940f0690
31 changed files with 3142 additions and 1307 deletions

0
renderers/__init__.py Normal file
View File

172
renderers/html_renderer.py Normal file
View File

@@ -0,0 +1,172 @@
"""
HTML 渲染器模块
将元素对象渲染为 HTML 代码,用于浏览器预览。
"""
from pathlib import Path
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
# 固定 DPI 用于单位转换
DPI = 96
class HtmlRenderer:
"""HTML 渲染器,将元素渲染为 HTML"""
def render_slide(self, slide_data, index, base_path):
"""
渲染单个幻灯片为 HTML
Args:
slide_data: 包含 background 和 elements 的字典
index: 幻灯片索引
base_path: 基础路径
Returns:
str: 幻灯片的 HTML 代码
"""
elements_html = ""
bg_style = ""
if slide_data.get('background'):
bg = slide_data['background']
if 'color' in bg:
bg_style = f"background: {bg['color']};"
for elem in slide_data.get('elements', []):
try:
if isinstance(elem, TextElement):
elements_html += self.render_text(elem)
elif isinstance(elem, ShapeElement):
elements_html += self.render_shape(elem)
elif isinstance(elem, TableElement):
elements_html += self.render_table(elem)
elif isinstance(elem, ImageElement):
elements_html += self.render_image(elem, base_path)
except Exception as e:
elements_html += f'<div class="element" style="color: red;">渲染错误: {str(e)}</div>'
return f'''
<div class="slide" style="{bg_style}">
<div class="slide-number">幻灯片 {index + 1}</div>
{elements_html}
</div>
'''
def render_text(self, elem: TextElement):
"""
将文本元素转换为 HTML
Args:
elem: TextElement 对象
Returns:
str: HTML 代码
"""
style = f"""
left: {elem.box[0] * DPI}px;
top: {elem.box[1] * DPI}px;
width: {elem.box[2] * DPI}px;
height: {elem.box[3] * DPI}px;
font-size: {elem.font.get('size', 16)}pt;
color: {elem.font.get('color', '#000000')};
text-align: {elem.font.get('align', 'left')};
{'font-weight: bold;' if elem.font.get('bold') else ''}
{'font-style: italic;' if elem.font.get('italic') else ''}
display: flex;
align-items: center;
white-space: normal;
overflow-wrap: break-word;
"""
content = elem.content.replace('<', '&lt;').replace('>', '&gt;')
return f'<div class="element text-element" style="{style}">{content}</div>'
def render_shape(self, elem: ShapeElement):
"""
将形状元素转换为 HTML
Args:
elem: ShapeElement 对象
Returns:
str: HTML 代码
"""
border_radius = {
'rectangle': '0',
'ellipse': '50%',
'rounded_rectangle': '8px'
}.get(elem.shape, '0')
style = f"""
left: {elem.box[0] * DPI}px;
top: {elem.box[1] * DPI}px;
width: {elem.box[2] * DPI}px;
height: {elem.box[3] * DPI}px;
background: {elem.fill if elem.fill else 'transparent'};
border-radius: {border_radius};
"""
if elem.line:
style += f"""
border: {elem.line.get('width', 1)}pt solid {elem.line.get('color', '#000000')};
"""
return f'<div class="element shape-element" style="{style}"></div>'
def render_table(self, elem: TableElement):
"""
将表格元素转换为 HTML
Args:
elem: TableElement 对象
Returns:
str: HTML 代码
"""
table_style = f"""
left: {elem.position[0] * DPI}px;
top: {elem.position[1] * DPI}px;
"""
rows_html = ""
for i, row in enumerate(elem.data):
cells_html = ""
for cell in row:
cell_style = f"font-size: {elem.style.get('font_size', 14)}pt;"
if i == 0:
if 'header_bg' in elem.style:
cell_style += f"background: {elem.style['header_bg']};"
if 'header_color' in elem.style:
cell_style += f"color: {elem.style['header_color']};"
cell_content = str(cell).replace('<', '&lt;').replace('>', '&gt;')
cells_html += f'<td style="{cell_style}">{cell_content}</td>'
rows_html += f'<tr>{cells_html}</tr>'
return f'<table class="element table-element" style="{table_style}">{rows_html}</table>'
def render_image(self, elem: ImageElement, base_path):
"""
将图片元素转换为 HTML
Args:
elem: ImageElement 对象
base_path: 基础路径
Returns:
str: HTML 代码
"""
img_path = Path(base_path) / elem.src if base_path else Path(elem.src)
style = f"""
left: {elem.box[0] * DPI}px;
top: {elem.box[1] * DPI}px;
width: {elem.box[2] * DPI}px;
height: {elem.box[3] * DPI}px;
"""
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'

292
renderers/pptx_renderer.py Normal file
View File

@@ -0,0 +1,292 @@
"""
PPTX 渲染器模块
封装 python-pptx 操作,将元素对象渲染为 PPTX 幻灯片。
"""
from pathlib import Path
from pptx import Presentation as PptxPresentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from pptx.dml.color import RGBColor
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
from loaders.yaml_loader import YAMLError
from utils import hex_to_rgb
class PptxGenerator:
"""PPTX 生成器,封装 python-pptx 操作"""
def __init__(self, size='16:9'):
"""
初始化 PPTX 生成器
Args:
size: 幻灯片尺寸("16:9""4:3"
"""
self.prs = PptxPresentation()
# 设置幻灯片尺寸
if size == '16:9':
self.prs.slide_width = Inches(10)
self.prs.slide_height = Inches(5.625)
elif size == '4:3':
self.prs.slide_width = Inches(10)
self.prs.slide_height = Inches(7.5)
else:
raise YAMLError(f"不支持的尺寸比例: {size},仅支持 16:9 和 4:3")
def add_slide(self, slide_data, base_path=None):
"""
添加幻灯片并渲染所有元素
Args:
slide_data: 包含 background 和 elements 的字典
base_path: 基础路径(用于相对路径解析)
"""
# 使用空白布局layout[6]
blank_layout = self.prs.slide_layouts[6]
slide = self.prs.slides.add_slide(blank_layout)
# 设置背景
background = slide_data.get('background')
if background:
self._render_background(slide, background, base_path)
# 按顺序渲染所有元素
elements = slide_data.get('elements', [])
for elem in elements:
self._render_element(slide, elem, base_path)
def _render_element(self, slide, elem, base_path):
"""
分发元素到对应的渲染方法
Args:
slide: pptx slide 对象
elem: 元素对象
base_path: 基础路径
"""
if isinstance(elem, TextElement):
self._render_text(slide, elem)
elif isinstance(elem, ImageElement):
self._render_image(slide, elem, base_path)
elif isinstance(elem, ShapeElement):
self._render_shape(slide, elem)
elif isinstance(elem, TableElement):
self._render_table(slide, elem)
def _render_text(self, slide, elem: TextElement):
"""
渲染文本元素
Args:
slide: pptx slide 对象
elem: TextElement 对象
"""
# 获取位置和尺寸
x, y, w, h = [Inches(v) for v in elem.box]
# 创建文本框
textbox = slide.shapes.add_textbox(x, y, w, h)
tf = textbox.text_frame
tf.text = elem.content
# 默认启用文字自动换行
tf.word_wrap = True
# 应用字体样式
p = tf.paragraphs[0]
# 字体大小
if 'size' in elem.font:
p.font.size = Pt(elem.font['size'])
# 粗体
if elem.font.get('bold'):
p.font.bold = True
# 斜体
if elem.font.get('italic'):
p.font.italic = True
# 颜色
if 'color' in elem.font:
rgb = hex_to_rgb(elem.font['color'])
p.font.color.rgb = RGBColor(*rgb)
# 对齐方式
align_map = {
'left': PP_ALIGN.LEFT,
'center': PP_ALIGN.CENTER,
'right': PP_ALIGN.RIGHT
}
align = elem.font.get('align', 'left')
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
def _render_image(self, slide, elem: ImageElement, base_path):
"""
渲染图片元素
Args:
slide: pptx slide 对象
elem: ImageElement 对象
base_path: 基础路径
"""
# 获取图片路径
img_path = Path(elem.src)
# 处理相对路径
if not img_path.is_absolute() and base_path:
img_path = Path(base_path) / elem.src
# 检查文件是否存在
if not img_path.exists():
raise YAMLError(f"图片文件未找到: {img_path}")
# 获取位置和尺寸
x, y, w, h = [Inches(v) for v in elem.box]
# 添加图片
try:
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
except Exception as e:
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")
def _render_shape(self, slide, elem: ShapeElement):
"""
渲染形状元素
Args:
slide: pptx slide 对象
elem: ShapeElement 对象
"""
# 获取形状类型
shape_type_map = {
'rectangle': MSO_SHAPE.RECTANGLE,
'ellipse': MSO_SHAPE.OVAL,
'rounded_rectangle': MSO_SHAPE.ROUNDED_RECTANGLE,
}
mso_shape = shape_type_map.get(elem.shape, MSO_SHAPE.RECTANGLE)
# 获取位置和尺寸
x, y, w, h = [Inches(v) for v in elem.box]
# 添加形状
shape = slide.shapes.add_shape(mso_shape, x, y, w, h)
# 应用填充色
if elem.fill:
rgb = hex_to_rgb(elem.fill)
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(*rgb)
# 应用边框样式
if elem.line:
if 'color' in elem.line:
rgb = hex_to_rgb(elem.line['color'])
shape.line.color.rgb = RGBColor(*rgb)
if 'width' in elem.line:
shape.line.width = Pt(elem.line['width'])
def _render_table(self, slide, elem: TableElement):
"""
渲染表格元素
Args:
slide: pptx slide 对象
elem: TableElement 对象
"""
# 获取表格数据
rows = len(elem.data)
cols = len(elem.data[0]) if elem.data else 0
# 获取列宽
col_widths = elem.col_widths if elem.col_widths else [2] * cols
if len(col_widths) != cols:
raise YAMLError(f"列宽数量({len(col_widths)})与列数({cols})不匹配")
# 获取位置
x, y = [Inches(v) for v in elem.position]
# 计算总宽度和高度
total_width = Inches(sum(col_widths))
row_height = Inches(0.5)
# 创建表格
table = slide.shapes.add_table(rows, cols, x, y, total_width, row_height * rows).table
# 设置列宽
for i, width in enumerate(col_widths):
table.columns[i].width = Inches(width)
# 填充数据
for i, row_data in enumerate(elem.data):
for j, cell_value in enumerate(row_data):
cell = table.cell(i, j)
cell.text = str(cell_value)
# 应用样式
# 字体大小
if 'font_size' in elem.style:
for row in table.rows:
for cell in row.cells:
cell.text_frame.paragraphs[0].font.size = Pt(elem.style['font_size'])
# 表头样式
if 'header_bg' in elem.style or 'header_color' in elem.style:
for i, cell in enumerate(table.rows[0].cells):
if 'header_bg' in elem.style:
rgb = hex_to_rgb(elem.style['header_bg'])
cell.fill.solid()
cell.fill.fore_color.rgb = RGBColor(*rgb)
if 'header_color' in elem.style:
rgb = hex_to_rgb(elem.style['header_color'])
cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*rgb)
def _render_background(self, slide, background, base_path=None):
"""
设置幻灯片背景
Args:
slide: pptx slide 对象
background: 背景字典,包含 color 或 image
base_path: 基础路径
"""
if not background:
return
# 纯色背景
if 'color' in background:
bg = slide.background
fill = bg.fill
fill.solid()
rgb = hex_to_rgb(background['color'])
fill.fore_color.rgb = RGBColor(*rgb)
# 图片背景(可选功能,简单实现)
elif 'image' in background:
# 图片背景需要更复杂的处理,暂时跳过
from utils import log_info
log_info(f"图片背景暂未实现: {background['image']}")
def save(self, output_path):
"""
保存 PPTX 文件
Args:
output_path: 输出文件路径
"""
output_path = Path(output_path)
# 自动创建输出目录
output_path.parent.mkdir(parents=True, exist_ok=True)
# 保存文件
try:
self.prs.save(str(output_path))
except PermissionError:
raise YAMLError(f"权限不足,无法写入文件: {output_path}")
except Exception as e:
raise YAMLError(f"保存文件失败: {output_path}: {str(e)}")