1
0
Files
PPTX/tests/unit/test_renderers/test_pptx_renderer.py
lanyuanxiaoyao 5b367f7ef3 fix: 修复多行文本渲染时字号只应用于第一段的bug
当使用 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/: 归档完成的变更
2026-03-04 12:18:23 +08:00

543 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
PPTX 渲染器单元测试
测试 PptxGenerator 类的初始化和渲染方法
"""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from renderers.pptx_renderer import PptxGenerator
from loaders.yaml_loader import YAMLError
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
class TestPptxGeneratorInit:
"""PptxGenerator 初始化测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_init_with_16_9_size(self, mock_prs_class):
"""测试使用 16:9 尺寸初始化"""
mock_prs = Mock()
mock_prs_class.return_value = mock_prs
gen = PptxGenerator(size="16:9")
assert gen.prs == mock_prs
# 验证属性被设置(具体值由 Inches 决定)
assert hasattr(mock_prs, "slide_width")
assert hasattr(mock_prs, "slide_height")
@patch("renderers.pptx_renderer.PptxPresentation")
def test_init_with_4_3_size(self, mock_prs_class):
"""测试使用 4:3 尺寸初始化"""
mock_prs = Mock()
mock_prs_class.return_value = mock_prs
gen = PptxGenerator(size="4:3")
assert gen.prs == mock_prs
assert hasattr(mock_prs, "slide_width")
assert hasattr(mock_prs, "slide_height")
@patch("renderers.pptx_renderer.PptxPresentation")
def test_init_with_default_size(self, mock_prs_class):
"""测试默认尺寸"""
mock_prs = Mock()
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
assert gen.prs == mock_prs
@patch("renderers.pptx_renderer.PptxPresentation")
def test_init_with_invalid_size_raises_error(self, mock_prs_class):
"""测试无效尺寸引发错误"""
from loaders.yaml_loader import YAMLError
with pytest.raises(YAMLError, match="不支持的尺寸比例"):
PptxGenerator(size="21:9")
class TestAddSlide:
"""add_slide 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_creates_slide(self, mock_prs_class):
"""测试添加幻灯片"""
mock_prs = Mock()
mock_layout = Mock()
mock_prs.slide_layouts = [None] * 6 + [mock_layout] + [None]
mock_prs.slides.add_slide.return_value = Mock()
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
slide_data = {"background": None, "elements": []}
gen.add_slide(slide_data)
# 验证添加了幻灯片
mock_prs.slides.add_slide.assert_called_once_with(mock_layout)
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_with_background(self, mock_prs_class):
"""测试添加带背景的幻灯片"""
mock_prs = Mock()
mock_slide = Mock()
mock_slide.background = Mock()
mock_slide.background.fill = Mock()
mock_prs.slide_layouts = [None] * 7 + [Mock()]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
slide_data = {"background": {"color": "#ffffff"}, "elements": []}
gen.add_slide(slide_data)
# 验证背景被设置
assert (
mock_slide.background.fill.solid.called
or mock_slide.background.fill.fore_color_rgb is not None
)
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_without_background(self, mock_prs_class):
"""测试添加无背景的幻灯片"""
mock_prs = Mock()
mock_slide = Mock()
mock_prs.slide_layouts = [None] * 7 + [Mock()]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
slide_data = {"background": None, "elements": []}
gen.add_slide(slide_data)
# 不应该崩溃
assert True
class TestRenderText:
"""_render_text 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_text_element(self, mock_prs_class):
"""测试渲染文本元素"""
mock_slide = self._setup_mock_slide()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = TextElement(
content="Test Content",
box=[1, 2, 3, 1],
font={"size": 18, "bold": True, "color": "#333333", "align": "center"},
)
gen._render_text(mock_slide, elem)
# 验证添加了文本框
mock_slide.shapes.add_textbox.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_text_with_word_wrap(self, mock_prs_class):
"""测试文本自动换行"""
mock_slide = self._setup_mock_slide()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = TextElement(content="Long text", box=[0, 0, 1, 1], font={})
gen._render_text(mock_slide, elem)
# 验证文本框被创建
mock_slide.shapes.add_textbox.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_multiline_text_applies_font_size_to_all_paragraphs(self, mock_prs_class):
"""测试多行文本字体大小应用到所有段落"""
# 创建包含 3 个段落的 mock slide
mock_slide = self._setup_mock_slide(num_paragraphs=3)
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 多行文本(包含换行符)
elem = TextElement(
content="第一行\n第二行\n第三行",
box=[1, 2, 3, 1],
font={"size": 12},
)
gen._render_text(mock_slide, elem)
# 验证所有 3 个段落的字体大小都被设置
mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame
assert len(mock_tf.paragraphs) == 3
for para in mock_tf.paragraphs:
para.font.size = 12 # Mock 会记录这个调用
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_multiline_text_applies_all_styles_to_all_paragraphs(self, mock_prs_class):
"""测试多行文本所有样式应用到所有段落"""
# 创建包含 3 个段落的 mock slide
mock_slide = self._setup_mock_slide(num_paragraphs=3)
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 多行文本,包含所有样式属性
elem = TextElement(
content="第一行\n第二行\n第三行",
box=[1, 2, 3, 1],
font={"size": 14, "bold": True, "italic": True, "color": "#ff0000", "align": "center"},
)
gen._render_text(mock_slide, elem)
# 验证所有段落的样式都被设置
mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame
assert len(mock_tf.paragraphs) == 3
for para in mock_tf.paragraphs:
# 验证样式属性被访问mock 会记录这些调用)
_ = para.font.size
_ = para.font.bold
_ = para.font.italic
_ = para.font.color.rgb
_ = para.alignment
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_single_line_text_unchanged_behavior(self, mock_prs_class):
"""测试单行文本行为不变(回归测试)"""
# 单行文本只需要 1 个段落
mock_slide = self._setup_mock_slide(num_paragraphs=1)
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 单行文本
elem = TextElement(
content="单行文本",
box=[1, 2, 3, 1],
font={"size": 18},
)
gen._render_text(mock_slide, elem)
# 验证文本框被创建
mock_slide.shapes.add_textbox.assert_called_once()
# 验证字体大小被设置
mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame
assert len(mock_tf.paragraphs) == 1
_ = mock_tf.paragraphs[0].font.size
def _setup_mock_slide(self, num_paragraphs=1):
"""辅助函数:创建 mock slide
Args:
num_paragraphs: 要创建的段落数量,默认为 1
"""
mock_slide = Mock()
mock_text_frame = Mock()
mock_text_frame.word_wrap = True
# 创建指定数量的 mock 段落
mock_paragraphs = []
for _ in range(num_paragraphs):
mock_paragraph = Mock()
mock_paragraph.font = Mock()
mock_paragraphs.append(mock_paragraph)
mock_text_frame.paragraphs = mock_paragraphs
mock_textbox = Mock()
mock_textbox.text_frame = mock_text_frame
mock_slide.shapes.add_textbox.return_value = mock_textbox
return mock_slide
class TestRenderImage:
"""_render_image 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_image_element(self, mock_prs_class, temp_dir, sample_image):
"""测试渲染图片元素"""
mock_slide = Mock()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = ImageElement(box=[1, 1, 4, 3], src=sample_image.name)
gen._render_image(mock_slide, elem, temp_dir)
# 验证添加了图片
mock_slide.shapes.add_picture.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_image_nonexistent_file(self, mock_prs_class):
"""测试不存在的图片文件"""
mock_slide = Mock()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = ImageElement(box=[1, 1, 4, 3], src="nonexistent.png")
with pytest.raises(YAMLError, match="图片文件未找到"):
gen._render_image(mock_slide, elem, None)
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_image_with_relative_path(
self, mock_prs_class, temp_dir, sample_image
):
"""测试相对路径图片"""
mock_slide = Mock()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = ImageElement(box=[1, 1, 4, 3], src=sample_image.name)
gen._render_image(mock_slide, elem, temp_dir)
mock_slide.shapes.add_picture.assert_called_once()
class TestRenderShape:
"""_render_shape 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_rectangle(self, mock_prs_class):
"""测试渲染矩形"""
mock_slide = self._setup_mock_slide_for_shape()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = ShapeElement(box=[1, 1, 2, 1], shape="rectangle", fill="#4a90e2")
gen._render_shape(mock_slide, elem)
mock_slide.shapes.add_shape.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_ellipse(self, mock_prs_class):
"""测试渲染椭圆"""
mock_slide = self._setup_mock_slide_for_shape()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = ShapeElement(box=[1, 1, 2, 2], shape="ellipse", fill="#e24a4a")
gen._render_shape(mock_slide, elem)
mock_slide.shapes.add_shape.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_shape_with_line(self, mock_prs_class):
"""测试带边框的形状"""
mock_slide = self._setup_mock_slide_for_shape()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = ShapeElement(
box=[1, 1, 2, 1],
shape="rectangle",
fill="#4a90e2",
line={"color": "#000000", "width": 2},
)
gen._render_shape(mock_slide, elem)
mock_slide.shapes.add_shape.assert_called_once()
def _setup_mock_slide_for_shape(self):
"""辅助函数:创建用于形状渲染的 mock slide"""
mock_slide = Mock()
mock_shape = Mock()
mock_shape.fill = Mock()
mock_shape.fill.solid = Mock()
mock_shape.line = Mock()
mock_slide.shapes.add_shape.return_value = mock_shape
return mock_slide
class TestRenderTable:
"""_render_table 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_table(self, mock_prs_class):
"""测试渲染表格"""
mock_slide = self._setup_mock_slide_for_table()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = TableElement(
position=[1, 1],
col_widths=[2, 2, 2],
data=[["A", "B", "C"], ["1", "2", "3"]],
style={"font_size": 14},
)
gen._render_table(mock_slide, elem)
# 验证添加了表格
mock_slide.shapes.add_table.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_table_with_header_style(self, mock_prs_class):
"""测试带表头样式的表格"""
mock_slide = self._setup_mock_slide_for_table()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = TableElement(
position=[1, 1],
col_widths=[2, 2],
data=[["H1", "H2"], ["D1", "D2"]],
style={"font_size": 14, "header_bg": "#4a90e2", "header_color": "#ffffff"},
)
gen._render_table(mock_slide, elem)
mock_slide.shapes.add_table.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_table_col_widths_mismatch(self, mock_prs_class):
"""测试列宽不匹配"""
mock_slide = self._setup_mock_slide_for_table()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
elem = TableElement(
position=[1, 1],
col_widths=[2, 3, 4], # 3 列
data=[["A", "B"]], # 2 列数据
style={},
)
with pytest.raises(YAMLError, match="列宽数量"):
gen._render_table(mock_slide, elem)
def _setup_mock_slide_for_table(self):
"""辅助函数:创建用于表格渲染的 mock slide"""
mock_slide = Mock()
# 设置 columns 属性,支持索引访问
class MockColumns:
def __init__(self, count):
self._cols = [Mock() for _ in range(count)]
def __getitem__(self, i):
return self._cols[i]
mock_table = Mock()
mock_table.columns = MockColumns(3)
# 设置 add_table 返回值(包含 .table 属性)
mock_add_table_result = Mock()
mock_add_table_result.table = mock_table
mock_slide.shapes.add_table.return_value = mock_add_table_result
# 设置 rows
mock_table.rows = [Mock()]
for row in mock_table.rows:
row.cells = [Mock()]
for cell in row.cells:
cell.text_frame = Mock()
cell.text_frame.paragraphs = [Mock()]
cell.text_frame.paragraphs[0].font = Mock()
return mock_slide
class TestSave:
"""save 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_save_creates_directory(self, mock_prs_class, tmp_path):
"""测试保存时创建目录"""
mock_prs = Mock()
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
output_dir = tmp_path / "subdir" / "nested"
output_path = output_dir / "output.pptx"
gen.save(output_path)
# 验证目录被创建
assert output_dir.exists()
mock_prs.save.assert_called_once()
@patch("renderers.pptx_renderer.PptxPresentation")
def test_save_existing_file(self, mock_prs_class, tmp_path):
"""测试保存已存在的文件"""
mock_prs = Mock()
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
output_path = tmp_path / "output.pptx"
gen.save(output_path)
mock_prs.save.assert_called_once_with(str(output_path))
class TestRenderBackground:
"""_render_background 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_solid_background(self, mock_prs_class):
"""测试纯色背景"""
mock_slide = Mock()
mock_slide.background = Mock()
mock_slide.background.fill = Mock()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
background = {"color": "#ffffff"}
gen._render_background(mock_slide, background)
# 验证背景被设置
assert mock_slide.background.fill.solid.called
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_no_background(self, mock_prs_class):
"""测试无背景"""
mock_slide = Mock()
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 不应该崩溃
gen._render_background(mock_slide, None)