#!/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 = """ YAML Preview {{ slides_html }} """ ERROR_TEMPLATE = """ 预览错误

⚠️ YAML 解析错误

{{ error }}
""" 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'
{content}
' 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'
' 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'{cell_content}' rows_html += f'{cells_html}' return f'{rows_html}
' 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'' 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'
渲染错误: {str(e)}
' return f'''
幻灯片 {index + 1}
{elements_html}
''' 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()