1
0
Files
PPTX/yaml2pptx.py
lanyuanxiaoyao cd7988cbd5 feat: initial implementation of html2pptx with OpenSpec documentation
Add core Python script (yaml2pptx.py) for converting YAML to PowerPoint:
- Element rendering: text, image, shape, table, chart
- Template system with placeholders
- PPTX generation with python-pptx

OpenSpec workflow setup:
- 3 archived changes: browser-preview, template-dir-cli, yaml-to-pptx
- 7 main specifications covering all core modules
- Config and documentation structure

30 files changed, 4984 insertions(+)
2026-03-02 14:28:25 +08:00

1242 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.8"
# dependencies = [
# "python-pptx",
# "pyyaml",
# "flask",
# "watchdog",
# ]
# ///
"""
YAML to PPTX Converter
将 YAML 格式的演示文稿源文件转换为 PPTX 文件
使用方法:
uv run yaml2pptx.py input.yaml output.pptx
uv run yaml2pptx.py input.yaml # 自动生成 input.pptx
"""
import sys
import argparse
import re
from pathlib import Path
import yaml
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
# ============= 日志输出函数 =============
def log_info(message):
"""输出信息日志"""
print(f"[INFO] {message}")
def log_success(message):
"""输出成功日志"""
print(f"[SUCCESS] ✓ {message}")
def log_error(message):
"""输出错误日志"""
print(f"[ERROR] ✗ {message}", file=sys.stderr)
def log_progress(current, total, message=""):
"""输出进度日志"""
print(f"[PROGRESS] {current}/{total} {message}")
# ============= YAML 解析和验证 =============
class YAMLError(Exception):
"""YAML 相关错误"""
pass
def load_yaml_file(file_path):
"""
加载 YAML 文件UTF-8 编码,错误处理)
Args:
file_path: 文件路径(字符串或 Path 对象)
Returns:
解析后的 Python 字典
Raises:
YAMLError: 文件不存在、权限不足、YAML 语法错误等
"""
file_path = Path(file_path)
# 检查文件是否存在
if not file_path.exists():
raise YAMLError(f"文件不存在: {file_path}")
# 检查是否有读取权限
if not file_path.is_file():
raise YAMLError(f"不是有效的文件: {file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data
except PermissionError:
raise YAMLError(f"权限不足,无法读取文件: {file_path}")
except yaml.YAMLError as e:
# 提取行号信息
if hasattr(e, 'problem_mark'):
mark = e.problem_mark
raise YAMLError(
f"YAML 语法错误: {file_path}, 第 {mark.line + 1} 行: {e.problem}"
)
else:
raise YAMLError(f"YAML 解析错误: {file_path}: {str(e)}")
except Exception as e:
raise YAMLError(f"读取文件失败: {file_path}: {str(e)}")
def validate_color(color_value):
"""
验证颜色值格式(十六进制 #RRGGBB 或 #RGB
Args:
color_value: 颜色字符串
Returns:
bool: 是否有效
"""
if not isinstance(color_value, str):
return False
# 匹配 #RRGGBB 或 #RGB 格式
pattern = r'^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$'
return re.match(pattern, color_value) is not None
def validate_presentation_yaml(data, file_path=""):
"""
验证演示文稿 YAML 结构必需字段slides
Args:
data: 解析后的 YAML 数据
file_path: 文件路径(用于错误消息)
Raises:
YAMLError: 结构验证失败
"""
if not isinstance(data, dict):
raise YAMLError(f"{file_path}: 演示文稿必须是一个字典对象")
# 验证 slides 字段
if 'slides' not in data:
raise YAMLError(f"{file_path}: 缺少必需字段 'slides'")
if not isinstance(data['slides'], list):
raise YAMLError(f"{file_path}: 'slides' 必须是一个列表")
def validate_template_yaml(data, file_path=""):
"""
验证模板 YAML 结构vars, elements
Args:
data: 解析后的 YAML 数据
file_path: 文件路径(用于错误消息)
Raises:
YAMLError: 结构验证失败
"""
if not isinstance(data, dict):
raise YAMLError(f"{file_path}: 模板必须是一个字典对象")
# 验证 vars 字段
if 'vars' in data:
if not isinstance(data['vars'], list):
raise YAMLError(f"{file_path}: 'vars' 必须是一个列表")
# 验证每个变量定义
for i, var_def in enumerate(data['vars']):
if not isinstance(var_def, dict):
raise YAMLError(f"{file_path}: vars[{i}] 必须是一个字典对象")
if 'name' not in var_def:
raise YAMLError(f"{file_path}: vars[{i}] 缺少必需字段 'name'")
# 验证 elements 字段
if 'elements' not in data:
raise YAMLError(f"{file_path}: 缺少必需字段 'elements'")
if not isinstance(data['elements'], list):
raise YAMLError(f"{file_path}: 'elements' 必须是一个列表")
# ============= 模板系统 =============
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
# ============= 元素渲染函数 =============
def hex_to_rgb(hex_color):
"""
将十六进制颜色转换为 RGB 元组
Args:
hex_color: 十六进制颜色字符串,如 "#4a90e2""#fff"
Returns:
tuple: (R, G, B) 元组
"""
hex_color = hex_color.lstrip('#')
# 处理短格式 #RGB -> #RRGGBB
if len(hex_color) == 3:
hex_color = ''.join([c*2 for c in hex_color])
if len(hex_color) != 6:
raise YAMLError(f"无效的颜色格式: #{hex_color}")
try:
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except ValueError:
raise YAMLError(f"无效的颜色格式: #{hex_color}")
def add_text_element(slide, elem, base_path=None):
"""
添加文本元素到幻灯片
Args:
slide: pptx slide 对象
elem: 文本元素字典
base_path: 基础路径(用于相对路径解析)
"""
# 获取位置和尺寸
box = elem.get('box', [1, 1, 8, 1])
x, y, w, h = [Inches(v) for v in box]
# 创建文本框
textbox = slide.shapes.add_textbox(x, y, w, h)
tf = textbox.text_frame
tf.text = elem.get('content', '')
# 应用字体样式
font_style = elem.get('font', {})
p = tf.paragraphs[0]
# 字体大小
if 'size' in font_style:
p.font.size = Pt(font_style['size'])
# 粗体
if font_style.get('bold'):
p.font.bold = True
# 斜体
if font_style.get('italic'):
p.font.italic = True
# 颜色
if 'color' in font_style:
rgb = hex_to_rgb(font_style['color'])
p.font.color.rgb = RGBColor(*rgb)
# 对齐方式
align_map = {
'left': PP_ALIGN.LEFT,
'center': PP_ALIGN.CENTER,
'right': PP_ALIGN.RIGHT
}
align = font_style.get('align', 'left')
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
def add_image_element(slide, elem, base_path=None):
"""
添加图片元素到幻灯片
Args:
slide: pptx slide 对象
elem: 图片元素字典
base_path: 基础路径(用于相对路径解析)
"""
# 获取图片路径
src = elem.get('src', '')
img_path = Path(src)
# 处理相对路径
if not img_path.is_absolute() and base_path:
img_path = Path(base_path) / src
# 检查文件是否存在
if not img_path.exists():
raise YAMLError(f"图片文件未找到: {img_path}")
# 获取位置和尺寸
box = elem.get('box', [1, 1, 4, 3])
x, y, w, h = [Inches(v) for v in 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 add_shape_element(slide, elem, base_path=None):
"""
添加形状元素到幻灯片
Args:
slide: pptx slide 对象
elem: 形状元素字典
base_path: 基础路径(用于相对路径解析)
"""
# 获取形状类型
shape_type_map = {
'rectangle': MSO_SHAPE.RECTANGLE,
'ellipse': MSO_SHAPE.OVAL,
'rounded_rectangle': MSO_SHAPE.ROUNDED_RECTANGLE,
}
shape_type = elem.get('shape', 'rectangle')
mso_shape = shape_type_map.get(shape_type, MSO_SHAPE.RECTANGLE)
# 获取位置和尺寸
box = elem.get('box', [1, 1, 2, 1])
x, y, w, h = [Inches(v) for v in box]
# 添加形状
shape = slide.shapes.add_shape(mso_shape, x, y, w, h)
# 应用填充色
if 'fill' in elem:
rgb = hex_to_rgb(elem['fill'])
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(*rgb)
# 应用边框样式
if 'line' in elem:
line_style = elem['line']
if 'color' in line_style:
rgb = hex_to_rgb(line_style['color'])
shape.line.color.rgb = RGBColor(*rgb)
if 'width' in line_style:
shape.line.width = Pt(line_style['width'])
def add_table_element(slide, elem, base_path=None):
"""
添加表格元素到幻灯片
Args:
slide: pptx slide 对象
elem: 表格元素字典
base_path: 基础路径(用于相对路径解析)
"""
# 获取表格数据
data = elem.get('data', [])
if not data:
raise YAMLError("表格数据不能为空")
rows = len(data)
cols = len(data[0]) if data else 0
# 获取列宽
col_widths = elem.get('col_widths', [2] * cols)
if len(col_widths) != cols:
raise YAMLError(f"列宽数量({len(col_widths)})与列数({cols})不匹配")
# 获取位置
position = elem.get('position', [1, 1])
x, y = [Inches(v) for v in 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(data):
for j, cell_value in enumerate(row_data):
cell = table.cell(i, j)
cell.text = str(cell_value)
# 应用样式
style = elem.get('style', {})
# 字体大小
if 'font_size' in style:
for row in table.rows:
for cell in row.cells:
cell.text_frame.paragraphs[0].font.size = Pt(style['font_size'])
# 表头样式
if 'header_bg' in style or 'header_color' in style:
for i, cell in enumerate(table.rows[0].cells):
if 'header_bg' in style:
rgb = hex_to_rgb(style['header_bg'])
cell.fill.solid()
cell.fill.fore_color.rgb = RGBColor(*rgb)
if 'header_color' in style:
rgb = hex_to_rgb(style['header_color'])
cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*rgb)
def set_slide_background(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:
# 图片背景需要更复杂的处理,暂时跳过
log_info(f"图片背景暂未实现: {background['image']}")
def validate_element_type(elem):
"""
验证元素类型
Args:
elem: 元素字典
Raises:
YAMLError: 元素类型无效或缺失
"""
if 'type' not in elem:
raise YAMLError("元素缺少 'type' 字段")
elem_type = elem['type']
supported_types = ['text', 'image', 'shape', 'table']
if elem_type not in supported_types:
raise YAMLError(
f"不支持的元素类型: '{elem_type}'"
f"支持的类型: {', '.join(supported_types)}"
)
# ============= 演示文稿和 PPTX 生成 =============
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)
return {
'background': background,
'elements': elements
}
else:
# 自定义幻灯片
return {
'background': slide_data.get('background'),
'elements': slide_data.get('elements', [])
}
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:
set_slide_background(slide, background, base_path)
# 按顺序渲染所有元素
elements = slide_data.get('elements', [])
for elem in elements:
# 验证元素类型
validate_element_type(elem)
elem_type = elem['type']
if elem_type == 'text':
add_text_element(slide, elem, base_path)
elif elem_type == 'image':
add_image_element(slide, elem, base_path)
elif elem_type == 'shape':
add_shape_element(slide, elem, base_path)
elif elem_type == 'table':
add_table_element(slide, elem, base_path)
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)}")
# ============= 浏览器预览功能 =============
# 固定 DPI 用于单位转换
DPI = 96
# HTML 模板
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>YAML Preview</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 20px;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.slide {
width: 960px;
height: 540px;
position: relative;
background: white;
margin: 20px auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.slide-number {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 12px;
color: #999;
z-index: 1000;
}
.element {
position: absolute;
}
table.element {
border-collapse: collapse;
}
table.element td {
padding: 8px;
border: 1px solid #ddd;
}
</style>
</head>
<body>
{{ slides_html }}
<script>
const eventSource = new EventSource('/events');
eventSource.onmessage = (e) => {
if (e.data === 'reload') {
console.log('[Preview] 重新加载...');
location.reload();
}
};
eventSource.onerror = () => {
console.error('[Preview] 连接断开');
};
</script>
</body>
</html>
"""
ERROR_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>预览错误</title>
<style>
body {
margin: 0;
padding: 40px;
background: #f5f5f5;
font-family: monospace;
}
.error {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 4px;
color: #856404;
}
h1 { margin-top: 0; }
</style>
</head>
<body>
<div class="error">
<h1>⚠️ YAML 解析错误</h1>
<pre>{{ error }}</pre>
</div>
<script>
const eventSource = new EventSource('/events');
eventSource.onmessage = () => location.reload();
</script>
</body>
</html>
"""
def render_text_element_to_html(elem):
"""将文本元素转换为 HTML"""
box = elem.get('box', [0, 0, 1, 1])
font = elem.get('font', {})
style = f"""
left: {box[0] * DPI}px;
top: {box[1] * DPI}px;
width: {box[2] * DPI}px;
height: {box[3] * DPI}px;
font-size: {font.get('size', 16)}pt;
color: {font.get('color', '#000000')};
text-align: {font.get('align', 'left')};
{'font-weight: bold;' if font.get('bold') else ''}
{'font-style: italic;' if font.get('italic') else ''}
display: flex;
align-items: center;
white-space: pre-wrap;
"""
content = elem.get('content', '').replace('<', '&lt;').replace('>', '&gt;')
return f'<div class="element text-element" style="{style}">{content}</div>'
def render_shape_element_to_html(elem):
"""将形状元素转换为 HTML"""
box = elem.get('box', [0, 0, 1, 1])
border_radius = {
'rectangle': '0',
'ellipse': '50%',
'rounded_rectangle': '8px'
}.get(elem.get('shape', 'rectangle'), '0')
style = f"""
left: {box[0] * DPI}px;
top: {box[1] * DPI}px;
width: {box[2] * DPI}px;
height: {box[3] * DPI}px;
background: {elem.get('fill', 'transparent')};
border-radius: {border_radius};
"""
if 'line' in elem:
line = elem['line']
style += f"""
border: {line.get('width', 1)}pt solid {line.get('color', '#000000')};
"""
return f'<div class="element shape-element" style="{style}"></div>'
def render_table_element_to_html(elem):
"""将表格元素转换为 HTML"""
position = elem.get('position', [0, 0])
data = elem.get('data', [])
style_config = elem.get('style', {})
table_style = f"""
left: {position[0] * DPI}px;
top: {position[1] * DPI}px;
"""
rows_html = ""
for i, row in enumerate(data):
cells_html = ""
for cell in row:
cell_style = f"font-size: {style_config.get('font_size', 14)}pt;"
if i == 0:
if 'header_bg' in style_config:
cell_style += f"background: {style_config['header_bg']};"
if 'header_color' in style_config:
cell_style += f"color: {style_config['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_element_to_html(elem, base_path):
"""将图片元素转换为 HTML"""
box = elem.get('box', [0, 0, 1, 1])
src = elem.get('src', '')
img_path = Path(base_path) / src if base_path else Path(src)
style = f"""
left: {box[0] * DPI}px;
top: {box[1] * DPI}px;
width: {box[2] * DPI}px;
height: {box[3] * DPI}px;
"""
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'
def render_slide_to_html(slide_data, index, base_path):
"""渲染单个幻灯片为 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', []):
elem_type = elem.get('type')
try:
if elem_type == 'text':
elements_html += render_text_element_to_html(elem)
elif elem_type == 'shape':
elements_html += render_shape_element_to_html(elem)
elif elem_type == 'table':
elements_html += render_table_element_to_html(elem)
elif elem_type == 'image':
elements_html += render_image_element_to_html(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 generate_preview_html(yaml_file, template_dir):
"""生成完整的预览 HTML 页面"""
try:
pres = Presentation(yaml_file, template_dir)
slides_html = ""
for i, slide_data in enumerate(pres.data.get('slides', [])):
rendered = pres.render_slide(slide_data)
slides_html += render_slide_to_html(rendered, i, Path(yaml_file).parent)
return HTML_TEMPLATE.replace('{{ slides_html }}', slides_html)
except YAMLError as e:
return ERROR_TEMPLATE.replace('{{ error }}', str(e))
# Flask 应用和文件监听
import queue
import webbrowser
import random
from threading import Thread
try:
from flask import Flask, Response
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
except ImportError:
# 如果没有安装预览依赖,在使用时会报错
Flask = None
Observer = None
FileSystemEventHandler = None
# 全局变量
app = None
change_queue = None
current_yaml_file = None
current_template_dir = None
class YAMLChangeHandler:
"""文件变化处理器"""
def on_modified(self, event):
if event.src_path.endswith('.yaml'):
log_info(f"检测到文件变化: {event.src_path}")
if change_queue:
change_queue.put('reload')
def create_flask_app():
"""创建 Flask 应用"""
flask_app = Flask(__name__)
@flask_app.route('/')
def index():
"""主页面"""
try:
return generate_preview_html(current_yaml_file, current_template_dir)
except Exception as e:
return ERROR_TEMPLATE.replace('{{ error }}', f"生成预览失败: {str(e)}")
@flask_app.route('/events')
def events():
"""SSE 事件流"""
def event_stream():
while True:
change_queue.get()
yield 'data: reload\n\n'
return Response(event_stream(), mimetype='text/event-stream')
return flask_app
def start_preview_server(yaml_file, template_dir, port):
"""启动预览服务器"""
global app, change_queue, current_yaml_file, current_template_dir
if Flask is None:
log_error("预览功能需要 flask 和 watchdog 依赖")
log_error("请确保使用 uv 运行脚本,依赖会自动安装")
sys.exit(1)
# 如果没有指定端口,随机选择 20000-30000 之间的端口
if port is None:
port = random.randint(20000, 30000)
current_yaml_file = yaml_file
current_template_dir = template_dir
change_queue = queue.Queue()
# 创建 Flask 应用
app = create_flask_app()
# 启动文件监听
if FileSystemEventHandler:
handler = YAMLChangeHandler()
if hasattr(handler, 'on_modified'):
# 创建一个简单的事件处理器
class SimpleHandler(FileSystemEventHandler):
def on_modified(self, event):
handler.on_modified(event)
observer = Observer()
observer.schedule(SimpleHandler(), str(Path(yaml_file).parent), recursive=False)
observer.start()
# 输出日志
log_info(f"正在监听: {yaml_file}")
log_info(f"预览地址: http://localhost:{port}")
log_info("按 Ctrl+C 停止")
# 自动打开浏览器
Thread(target=lambda: webbrowser.open(f'http://localhost:{port}')).start()
# 启动 Flask
try:
app.run(host='0.0.0.0', port=port, debug=False, threaded=True)
except OSError as e:
if 'Address already in use' in str(e):
log_error(f"端口 {port} 已被占用")
log_error(f"请使用 --port 参数指定其他端口,例如: --port {port + 1}")
else:
log_error(f"启动服务器失败: {str(e)}")
sys.exit(1)
except KeyboardInterrupt:
if 'observer' in locals():
observer.stop()
observer.join()
log_info("已停止")
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()
def main():
"""主函数:加载 YAML → 渲染幻灯片 → 生成 PPTX"""
try:
args = parse_args()
# 处理输入文件路径
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}")
# 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}")
except YAMLError as e:
log_error(str(e))
sys.exit(1)
except Exception as e:
log_error(f"未知错误: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()