1
0
Files
PPTX/tests/unit/test_renderers/test_pptx_renderer.py
lanyuanxiaoyao bd12fce14b feat: 实现字体主题系统和东亚字体支持
实现完整的字体主题系统,支持可复用字体配置、预设类别和扩展属性。
同时修复中文字体渲染问题,确保 Source Han Sans 等东亚字体正确显示。

核心功能:
- 字体主题配置:metadata.fonts 和 fonts_default
- 三种引用方式:整体引用、继承覆盖、独立定义
- 预设字体类别:sans、serif、mono、cjk-sans、cjk-serif
- 扩展字体属性:family、underline、strikethrough、line_spacing、
  space_before、space_after、baseline、caps
- 表格字体字段:font 和 header_font 替代旧的 style.font_size
- 引用循环检测和属性继承链
- 模板字体继承支持

东亚字体修复:
- 添加 _set_font_with_eastasian() 方法
- 同时设置拉丁字体、东亚字体和复杂脚本字体
- 修复中文字符使用默认字体的问题

测试:
- 58 个单元测试覆盖所有字体系统功能
- 3 个集成测试验证端到端场景
- 移除旧语法相关测试

文档:
- 更新 README.md 添加字体主题系统使用说明
- 更新 README_DEV.md 添加技术文档
- 创建 4 个示例 YAML 文件
- 同步 delta specs 到主 specs

归档:
- 归档 font-theme-system 变更到 openspec/changes/archive/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 10:38:59 +08:00

656 lines
24 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_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 列数据
)
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)
class TestSetNotes:
"""_set_notes 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_text(self, mock_prs_class):
"""测试设置备注文本"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_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()
gen._set_notes(mock_slide, "这是演讲备注内容")
# 验证备注被设置
mock_text_frame.text = "这是演讲备注内容"
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_none(self, mock_prs_class):
"""测试设置 None 不设置备注"""
mock_slide = Mock(spec=[]) # 使用 spec=[] 禁止自动创建属性
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()
# 不应该崩溃,不应该访问 notes_slide
gen._set_notes(mock_slide, None)
# 使用 spec=[] 后,访问不存在的属性会抛出 AttributeError
# 如果没有抛出异常,说明 notes_slide 没有被访问
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_empty_string(self, mock_prs_class):
"""测试设置空字符串"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_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()
gen._set_notes(mock_slide, "")
# 验证空字符串也被设置
mock_text_frame.text = ""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_multiline_text(self, mock_prs_class):
"""测试设置多行文本"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_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()
multiline_text = "第一行备注\n第二行备注\n第三行备注"
gen._set_notes(mock_slide, multiline_text)
# 验证多行文本被设置
mock_text_frame.text = multiline_text
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_chinese_text(self, mock_prs_class):
"""测试设置中文备注"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_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()
chinese_text = "这是中文演讲备注内容,用于演示时参考"
gen._set_notes(mock_slide, chinese_text)
# 验证中文文本被设置
mock_text_frame.text = chinese_text
class TestAddSlideWithNotes:
"""add_slide 方法备注功能测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_with_description_sets_notes(self, mock_prs_class):
"""测试幻灯片包含 description 时设置备注"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_slide
mock_prs = Mock()
mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
slide_data = {"background": None, "elements": [], "description": "这是幻灯片的演讲说明"}
gen.add_slide(slide_data)
# 验证备注被设置
mock_text_frame.text = "这是幻灯片的演讲说明"
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_without_description_no_notes(self, mock_prs_class):
"""测试幻灯片不包含 description 时不设置备注"""
mock_slide = Mock(spec=[]) # 禁止自动创建属性
mock_prs = Mock()
mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None]
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)
# 不应该访问 notes_slide使用 spec=[] 会抛出异常如果被访问)
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_with_template_description_ignored(self, mock_prs_class):
"""测试模板的 description 不被用作备注(仅幻灯片自己的 description 有效)"""
mock_slide = Mock(spec=[]) # 禁止自动创建属性
mock_prs = Mock()
mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
# 幻灯片没有 description则不设置备注即使模板可能有 description
slide_data = {"background": None, "elements": []}
gen.add_slide(slide_data)
# 不应该访问 notes_slide使用 spec=[] 会抛出异常如果被访问)