当使用 YAML 多行字符串语法定义文本内容时,python-pptx 会自动 将包含换行符的文本分割成多个段落。修改 _render_text 方法使其 遍历所有段落并应用相同的字体样式(大小、粗体、斜体、颜色、对齐)。 主要变更: - renderers/pptx_renderer.py: 将 p = tf.paragraphs[0] 改为 for p in tf.paragraphs - tests/unit/test_renderers/test_pptx_renderer.py: 新增多行文本测试用例 - openspec/specs/element-rendering/spec.md: 更新 spec 文档 - openspec/changes/archive/: 归档完成的变更
329 lines
11 KiB
Python
329 lines
11 KiB
Python
"""
|
||
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 PIL import Image
|
||
import tempfile
|
||
|
||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||
from loaders.yaml_loader import YAMLError
|
||
from utils import hex_to_rgb
|
||
from utils.image_utils import inches_to_pixels, apply_fit_mode
|
||
|
||
|
||
class PptxGenerator:
|
||
"""PPTX 生成器,封装 python-pptx 操作"""
|
||
|
||
def __init__(self, size='16:9', dpi=96):
|
||
"""
|
||
初始化 PPTX 生成器
|
||
|
||
Args:
|
||
size: 幻灯片尺寸("16:9" 或 "4:3")
|
||
dpi: DPI 配置,默认 96
|
||
"""
|
||
self.prs = PptxPresentation()
|
||
self.dpi = dpi
|
||
|
||
# 设置幻灯片尺寸
|
||
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:
|
||
self._render_background(slide, background, base_path)
|
||
|
||
# 按顺序渲染所有元素
|
||
elements = slide_data.get('elements', [])
|
||
for elem in elements:
|
||
self._render_element(slide, elem, base_path)
|
||
|
||
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
|
||
|
||
# 应用字体样式到所有段落
|
||
# 当文本包含换行符时,python-pptx 会创建多个段落
|
||
# 需要确保所有段落都应用相同的字体样式
|
||
for p in tf.paragraphs:
|
||
# 字体大小
|
||
if 'size' in elem.font:
|
||
p.font.size = Pt(elem.font['size'])
|
||
|
||
# 粗体
|
||
if elem.font.get('bold'):
|
||
p.font.bold = True
|
||
|
||
# 斜体
|
||
if elem.font.get('italic'):
|
||
p.font.italic = True
|
||
|
||
# 颜色
|
||
if 'color' in elem.font:
|
||
rgb = hex_to_rgb(elem.font['color'])
|
||
p.font.color.rgb = RGBColor(*rgb)
|
||
|
||
# 对齐方式
|
||
align_map = {
|
||
'left': PP_ALIGN.LEFT,
|
||
'center': PP_ALIGN.CENTER,
|
||
'right': PP_ALIGN.RIGHT
|
||
}
|
||
align = elem.font.get('align', 'left')
|
||
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
|
||
|
||
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]
|
||
|
||
# 获取 fit 模式,默认为 stretch
|
||
fit = elem.fit if elem.fit else 'stretch'
|
||
|
||
try:
|
||
# stretch 模式:直接使用 python-pptx 的原生处理
|
||
if fit == 'stretch':
|
||
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
|
||
else:
|
||
# 其他模式:使用 Pillow 处理图片
|
||
# 打开图片
|
||
with Image.open(img_path) as img:
|
||
# 转换 box 尺寸为像素
|
||
box_width_px = int(inches_to_pixels(elem.box[2], self.dpi))
|
||
box_height_px = int(inches_to_pixels(elem.box[3], self.dpi))
|
||
box_size = (box_width_px, box_height_px)
|
||
|
||
# 应用 fit 模式
|
||
processed_img = apply_fit_mode(
|
||
img, box_size, fit, elem.background
|
||
)
|
||
|
||
# 保存处理后的图片到临时文件
|
||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||
processed_img.save(tmp.name, 'PNG')
|
||
tmp_path = tmp.name
|
||
|
||
try:
|
||
# 添加处理后的图片到幻灯片
|
||
slide.shapes.add_picture(tmp_path, x, y, width=w, height=h)
|
||
finally:
|
||
# 清理临时文件
|
||
Path(tmp_path).unlink(missing_ok=True)
|
||
|
||
except Exception as e:
|
||
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")
|
||
|
||
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)
|
||
|
||
# 应用样式
|
||
# 字体大小
|
||
if 'font_size' in elem.style:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
cell.text_frame.paragraphs[0].font.size = Pt(elem.style['font_size'])
|
||
|
||
# 表头样式
|
||
if 'header_bg' in elem.style or 'header_color' in elem.style:
|
||
for i, cell in enumerate(table.rows[0].cells):
|
||
if 'header_bg' in elem.style:
|
||
rgb = hex_to_rgb(elem.style['header_bg'])
|
||
cell.fill.solid()
|
||
cell.fill.fore_color.rgb = RGBColor(*rgb)
|
||
if 'header_color' in elem.style:
|
||
rgb = hex_to_rgb(elem.style['header_color'])
|
||
cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*rgb)
|
||
|
||
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 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)}")
|