""" PPTX 渲染器模块 封装 python-pptx 操作,将元素对象渲染为 PPTX 幻灯片。 """ from pathlib import Path from pptx import Presentation as PptxPresentation from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN from pptx.enum.shapes import MSO_SHAPE from pptx.dml.color import RGBColor from pptx.oxml.xmlchemy import OxmlElement from core.elements import TextElement, ImageElement, ShapeElement, TableElement, FontConfig from loaders.yaml_loader import YAMLError from utils import hex_to_rgb from utils.font_resolver import FontResolver class PptxGenerator: """PPTX 生成器,封装 python-pptx 操作""" def __init__(self, size='16:9', fonts=None, fonts_default=None): """ 初始化 PPTX 生成器 Args: size: 幻灯片尺寸("16:9" 或 "4:3") fonts: 字体配置字典 fonts_default: 默认字体引用 """ 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") # 初始化字体解析器 self.font_resolver = FontResolver(fonts=fonts, fonts_default=fonts_default) def add_slide(self, slide_data, base_path=None): """ 添加幻灯片并渲染所有元素 Args: slide_data: 包含 background 和 elements 的字典 base_path: 基础路径(用于相对路径解析) """ # 使用空白布局(layout[6]) blank_layout = self.prs.slide_layouts[6] slide = self.prs.slides.add_slide(blank_layout) # 设置背景 background = slide_data.get('background') if background: self._render_background(slide, background, base_path) # 按顺序渲染所有元素 elements = slide_data.get('elements', []) for elem in elements: self._render_element(slide, elem, base_path) # 设置备注 description = slide_data.get('description') if description: self._set_notes(slide, description) def _set_font_with_eastasian(self, paragraph, font_name): """ 设置字体,同时支持拉丁字体和东亚字体 Args: paragraph: pptx paragraph 对象 font_name: 字体名称 """ # 设置标准字体(拉丁字符) paragraph.font.name = font_name # 为段落中的每个 run 设置东亚字体 for run in paragraph.runs: rPr = run._r.get_or_add_rPr() # 移除已有的字体设置 for elem in list(rPr): if elem.tag.endswith('}latin') or elem.tag.endswith('}ea') or elem.tag.endswith('}cs'): rPr.remove(elem) # 设置拉丁字体 latin = OxmlElement('a:latin') latin.set('typeface', font_name) rPr.append(latin) # 设置东亚字体(用于中文、日文、韩文等) ea = OxmlElement('a:ea') ea.set('typeface', font_name) rPr.append(ea) # 设置复杂脚本字体 cs = OxmlElement('a:cs') cs.set('typeface', font_name) rPr.append(cs) def _render_element(self, slide, elem, base_path): """ 分发元素到对应的渲染方法 Args: slide: pptx slide 对象 elem: 元素对象 base_path: 基础路径 """ if isinstance(elem, TextElement): self._render_text(slide, elem) elif isinstance(elem, ImageElement): self._render_image(slide, elem, base_path) elif isinstance(elem, ShapeElement): self._render_shape(slide, elem) elif isinstance(elem, TableElement): self._render_table(slide, elem) def _render_text(self, slide, elem: TextElement): """ 渲染文本元素 Args: slide: pptx slide 对象 elem: TextElement 对象 """ # 获取位置和尺寸 x, y, w, h = [Inches(v) for v in elem.box] # 创建文本框 textbox = slide.shapes.add_textbox(x, y, w, h) tf = textbox.text_frame tf.text = elem.content # 默认启用文字自动换行 tf.word_wrap = True # 解析字体配置 font_config = self.font_resolver.resolve_font(elem.font) # 应用字体样式到所有段落 # 当文本包含换行符时,python-pptx 会创建多个段落 # 需要确保所有段落都应用相同的字体样式 for p in tf.paragraphs: # 字体族(同时设置拉丁字体和东亚字体) if font_config.family: self._set_font_with_eastasian(p, font_config.family) # 字体大小 if font_config.size: p.font.size = Pt(font_config.size) # 粗体 if font_config.bold: p.font.bold = True # 斜体 if font_config.italic: p.font.italic = True # 下划线 if font_config.underline: p.font.underline = True # 删除线(注意:python-pptx 可能不支持删除线,需要测试) # 暂时跳过 strikethrough,因为 python-pptx 不直接支持 # 颜色 if font_config.color: rgb = hex_to_rgb(font_config.color) p.font.color.rgb = RGBColor(*rgb) # 对齐方式 align_map = { 'left': PP_ALIGN.LEFT, 'center': PP_ALIGN.CENTER, 'right': PP_ALIGN.RIGHT } align = font_config.align or 'left' p.alignment = align_map.get(align, PP_ALIGN.LEFT) # 行距 if font_config.line_spacing: p.line_spacing = font_config.line_spacing # 段前间距 if font_config.space_before: p.space_before = Pt(font_config.space_before) # 段后间距 if font_config.space_after: p.space_after = Pt(font_config.space_after) def _render_image(self, slide, elem: ImageElement, base_path): """ 渲染图片元素 Args: slide: pptx slide 对象 elem: ImageElement 对象 base_path: 基础路径 """ # 获取图片路径 img_path = Path(elem.src) # 处理相对路径 if not img_path.is_absolute() and base_path: img_path = Path(base_path) / elem.src # 检查文件是否存在 if not img_path.exists(): raise YAMLError(f"图片文件未找到: {img_path}") # 获取位置和尺寸 x, y, w, h = [Inches(v) for v in elem.box] # 直接添加图片到幻灯片 slide.shapes.add_picture(str(img_path), x, y, width=w, height=h) def _render_shape(self, slide, elem: ShapeElement): """ 渲染形状元素 Args: slide: pptx slide 对象 elem: ShapeElement 对象 """ # 获取形状类型 shape_type_map = { 'rectangle': MSO_SHAPE.RECTANGLE, 'ellipse': MSO_SHAPE.OVAL, 'rounded_rectangle': MSO_SHAPE.ROUNDED_RECTANGLE, } mso_shape = shape_type_map.get(elem.shape, MSO_SHAPE.RECTANGLE) # 获取位置和尺寸 x, y, w, h = [Inches(v) for v in elem.box] # 添加形状 shape = slide.shapes.add_shape(mso_shape, x, y, w, h) # 应用填充色 if elem.fill: rgb = hex_to_rgb(elem.fill) shape.fill.solid() shape.fill.fore_color.rgb = RGBColor(*rgb) # 应用边框样式 if elem.line: if 'color' in elem.line: rgb = hex_to_rgb(elem.line['color']) shape.line.color.rgb = RGBColor(*rgb) if 'width' in elem.line: shape.line.width = Pt(elem.line['width']) def _render_table(self, slide, elem: TableElement): """ 渲染表格元素 Args: slide: pptx slide 对象 elem: TableElement 对象 """ # 获取表格数据 rows = len(elem.data) cols = len(elem.data[0]) if elem.data else 0 # 获取列宽 col_widths = elem.col_widths if elem.col_widths else [2] * cols if len(col_widths) != cols: raise YAMLError(f"列宽数量({len(col_widths)})与列数({cols})不匹配") # 获取位置 x, y = [Inches(v) for v in elem.position] # 计算总宽度和高度 total_width = Inches(sum(col_widths)) row_height = Inches(0.5) # 创建表格 table = slide.shapes.add_table(rows, cols, x, y, total_width, row_height * rows).table # 设置列宽 for i, width in enumerate(col_widths): table.columns[i].width = Inches(width) # 填充数据 for i, row_data in enumerate(elem.data): for j, cell_value in enumerate(row_data): cell = table.cell(i, j) cell.text = str(cell_value) # 解析字体配置 font_config = None if elem.font: font_config = self.font_resolver.resolve_font(elem.font) header_font_config = None if elem.header_font: header_font_config = self.font_resolver.resolve_font(elem.header_font) elif font_config: # header_font 未定义时继承 font header_font_config = font_config # 应用字体样式到数据单元格 if font_config: for i in range(1, rows): # 跳过表头行 for cell in table.rows[i].cells: self._apply_font_to_cell(cell, font_config) # 应用字体样式到表头 if header_font_config: for cell in table.rows[0].cells: self._apply_font_to_cell(cell, header_font_config) # 应用非字体样式 # 表头背景色 if 'header_bg' in elem.style: for cell in table.rows[0].cells: rgb = hex_to_rgb(elem.style['header_bg']) cell.fill.solid() cell.fill.fore_color.rgb = RGBColor(*rgb) def _apply_font_to_cell(self, cell, font_config: FontConfig): """ 将字体配置应用到表格单元格 Args: cell: 表格单元格对象 font_config: FontConfig 对象 """ p = cell.text_frame.paragraphs[0] # 字体族(同时设置拉丁字体和东亚字体) if font_config.family: self._set_font_with_eastasian(p, font_config.family) # 字体大小 if font_config.size: p.font.size = Pt(font_config.size) # 粗体 if font_config.bold: p.font.bold = True # 斜体 if font_config.italic: p.font.italic = True # 下划线 if font_config.underline: p.font.underline = True # 颜色 if font_config.color: rgb = hex_to_rgb(font_config.color) p.font.color.rgb = RGBColor(*rgb) # 对齐方式 if font_config.align: align_map = { 'left': PP_ALIGN.LEFT, 'center': PP_ALIGN.CENTER, 'right': PP_ALIGN.RIGHT } p.alignment = align_map.get(font_config.align, PP_ALIGN.LEFT) def _render_background(self, slide, background, base_path=None): """ 设置幻灯片背景 Args: slide: pptx slide 对象 background: 背景字典,包含 color 或 image base_path: 基础路径 """ if not background: return # 纯色背景 if 'color' in background: bg = slide.background fill = bg.fill fill.solid() rgb = hex_to_rgb(background['color']) fill.fore_color.rgb = RGBColor(*rgb) # 图片背景(可选功能,简单实现) elif 'image' in background: # 图片背景需要更复杂的处理,暂时跳过 from utils import log_info log_info(f"图片背景暂未实现: {background['image']}") def _set_notes(self, slide, text): """ 设置幻灯片备注 Args: slide: pptx slide 对象 text: 备注文本内容 """ if text is None: return notes_slide = slide.notes_slide text_frame = notes_slide.notes_text_frame text_frame.text = text 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)}")