- Set text_frame.word_wrap = True in add_text_element() for PPTX - Change CSS from white-space: pre-wrap to normal in HTML preview - Add overflow-wrap: break-word for better word breaking - Update README.md with auto-wrap documentation - Update element-rendering and html-rendering specs - Archive change: 2026-03-02-add-text-auto-wrap
1245 lines
35 KiB
Python
1245 lines
35 KiB
Python
#!/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', '')
|
||
# 默认启用文字自动换行
|
||
tf.word_wrap = True
|
||
|
||
# 应用字体样式
|
||
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: normal;
|
||
overflow-wrap: break-word;
|
||
"""
|
||
|
||
content = elem.get('content', '').replace('<', '<').replace('>', '>')
|
||
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('<', '<').replace('>', '>')
|
||
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()
|