#!/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 = """
{{ error }}