1
0
Files
PPTX/tests/unit/test_font_system.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

650 lines
21 KiB
Python
Raw Permalink 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.
"""
FontConfig 和 FontResolver 单元测试
本测试文件包含字体主题系统的完整单元测试,覆盖以下功能:
1. FontConfig 数据类
- 所有字体属性的创建和访问
- baseline 和 caps 枚举值验证
2. 预设字体类别映射
- 五种预设类别sans、serif、mono、cjk-sans、cjk-serif
- 映射到跨平台通用字体
3. FontResolver 字体引用
- 整体引用(@xxx
- 引用不存在时的错误处理
- None 值处理和 fonts_default 使用
4. FontResolver 继承覆盖
- parent 字段继承
- 独立定义
- 引用错误处理
5. FontResolver 属性继承链
- parent → 当前 → fonts_default → 系统默认
- 多层继承
- 覆盖优先级
6. FontResolver 引用循环检测
- 直接循环引用(自引用)
- 间接循环引用
- 深度限制
7. 预设字体类别解析
- 在 family 字段中识别预设类别
- 与 fonts 配置结合
- 与继承结合
8. 表格字体字段
- font 和 header_font 字段支持
- 数据结构验证
9. 扩展字体属性
- family、underline、strikethrough
- 属性继承
10. 段落属性
- line_spacing、space_before、space_after
- 属性继承
11. baseline 和 caps 属性
- 枚举值验证
- 属性继承
注意:
- 旧语法测试style.font_size、style.header_color已移除
- 新语法使用 font 和 header_font 字段替代
- 所有测试使用 FontConfig 和 FontResolver 进行字体配置
"""
import pytest
from core.elements import FontConfig
from utils.font_resolver import FontResolver, PRESET_FONT_MAPPING
class TestFontConfig:
"""FontConfig 数据类测试"""
def test_create_font_config_with_all_properties(self):
"""测试创建包含所有属性的 FontConfig"""
config = FontConfig(
parent="@base",
family="Arial",
size=24,
bold=True,
italic=False,
underline=True,
strikethrough=False,
color="#333333",
align="center",
line_spacing=1.5,
space_before=12,
space_after=6,
baseline="normal",
caps="allcaps"
)
assert config.parent == "@base"
assert config.family == "Arial"
assert config.size == 24
assert config.bold is True
assert config.italic is False
assert config.underline is True
assert config.strikethrough is False
assert config.color == "#333333"
assert config.align == "center"
assert config.line_spacing == 1.5
assert config.space_before == 12
assert config.space_after == 6
assert config.baseline == "normal"
assert config.caps == "allcaps"
def test_create_font_config_with_minimal_properties(self):
"""测试创建最小属性的 FontConfig"""
config = FontConfig(size=18)
assert config.size == 18
assert config.family is None
assert config.bold is None
assert config.color is None
def test_baseline_validation_normal(self):
"""测试 baseline 为 normal 时验证通过"""
config = FontConfig(baseline="normal")
assert config.baseline == "normal"
def test_baseline_validation_superscript(self):
"""测试 baseline 为 superscript 时验证通过"""
config = FontConfig(baseline="superscript")
assert config.baseline == "superscript"
def test_baseline_validation_subscript(self):
"""测试 baseline 为 subscript 时验证通过"""
config = FontConfig(baseline="subscript")
assert config.baseline == "subscript"
def test_baseline_validation_invalid_raises_error(self):
"""测试 baseline 为无效值时抛出错误"""
with pytest.raises(ValueError, match="baseline 必须是"):
FontConfig(baseline="invalid")
def test_caps_validation_normal(self):
"""测试 caps 为 normal 时验证通过"""
config = FontConfig(caps="normal")
assert config.caps == "normal"
def test_caps_validation_allcaps(self):
"""测试 caps 为 allcaps 时验证通过"""
config = FontConfig(caps="allcaps")
assert config.caps == "allcaps"
def test_caps_validation_smallcaps(self):
"""测试 caps 为 smallcaps 时验证通过"""
config = FontConfig(caps="smallcaps")
assert config.caps == "smallcaps"
def test_caps_validation_invalid_raises_error(self):
"""测试 caps 为无效值时抛出错误"""
with pytest.raises(ValueError, match="caps 必须是"):
FontConfig(caps="invalid")
class TestPresetFontMapping:
"""预设字体类别映射测试"""
def test_preset_mapping_sans(self):
"""测试 sans 映射到 Arial"""
assert PRESET_FONT_MAPPING["sans"] == "Arial"
def test_preset_mapping_serif(self):
"""测试 serif 映射到 Times New Roman"""
assert PRESET_FONT_MAPPING["serif"] == "Times New Roman"
def test_preset_mapping_mono(self):
"""测试 mono 映射到 Courier New"""
assert PRESET_FONT_MAPPING["mono"] == "Courier New"
def test_preset_mapping_cjk_sans(self):
"""测试 cjk-sans 映射到 Microsoft YaHei"""
assert PRESET_FONT_MAPPING["cjk-sans"] == "Microsoft YaHei"
def test_preset_mapping_cjk_serif(self):
"""测试 cjk-serif 映射到 SimSun"""
assert PRESET_FONT_MAPPING["cjk-serif"] == "SimSun"
class TestFontResolverReference:
"""FontResolver 整体引用功能测试"""
def test_resolve_string_reference(self):
"""测试整体引用字符串格式"""
fonts = {
"title": {"family": "Arial", "size": 44, "bold": True}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@title")
assert result.family == "Arial"
assert result.size == 44
assert result.bold is True
def test_resolve_reference_not_found_raises_error(self):
"""测试引用不存在的字体配置时抛出错误"""
fonts = {"title": {"size": 44}}
resolver = FontResolver(fonts=fonts)
with pytest.raises(ValueError, match="引用的字体配置不存在"):
resolver.resolve_font("@nonexistent")
def test_resolve_reference_without_at_raises_error(self):
"""测试引用格式不正确时抛出错误"""
resolver = FontResolver(fonts={})
with pytest.raises(ValueError, match="字体引用必须以 @ 开头"):
resolver.resolve_font("title")
def test_resolve_none_returns_empty_config(self):
"""测试解析 None 返回空配置"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font(None)
assert isinstance(result, FontConfig)
assert result.family is None
assert result.size is None
def test_resolve_none_with_default_uses_default(self):
"""测试解析 None 时使用 fonts_default"""
fonts = {
"body": {"family": "Arial", "size": 18}
}
resolver = FontResolver(fonts=fonts, fonts_default="@body")
result = resolver.resolve_font(None)
assert result.family == "Arial"
assert result.size == 18
class TestFontResolverInheritance:
"""FontResolver 继承覆盖功能测试"""
def test_resolve_dict_with_parent(self):
"""测试字典形式的 parent 继承"""
fonts = {
"title": {"family": "Arial", "size": 44, "bold": True}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font({"parent": "@title", "size": 60})
assert result.family == "Arial" # 继承
assert result.size == 60 # 覆盖
assert result.bold is True # 继承
def test_resolve_dict_without_parent(self):
"""测试字典形式的独立定义"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "SimSun", "size": 24})
assert result.family == "SimSun"
assert result.size == 24
def test_parent_reference_not_found_raises_error(self):
"""测试 parent 引用不存在时抛出错误"""
resolver = FontResolver(fonts={})
with pytest.raises(ValueError, match="引用的字体配置不存在"):
resolver.resolve_font({"parent": "@nonexistent"})
def test_parent_without_at_raises_error(self):
"""测试 parent 格式不正确时抛出错误"""
fonts = {"title": {"size": 44}}
resolver = FontResolver(fonts=fonts)
with pytest.raises(ValueError, match="字体引用必须以 @ 开头"):
resolver.resolve_font({"parent": "title"})
class TestFontResolverInheritanceChain:
"""FontResolver 属性继承链测试"""
def test_inheritance_chain_parent_to_current(self):
"""测试 parent → 当前 的继承链"""
fonts = {
"base": {"family": "Arial", "size": 18, "color": "#333333"},
"title": {"parent": "@base", "size": 44, "bold": True}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@title")
assert result.family == "Arial" # 从 base 继承
assert result.size == 44 # title 覆盖
assert result.color == "#333333" # 从 base 继承
assert result.bold is True # title 定义
def test_inheritance_chain_with_fonts_default(self):
"""测试继承链包含 fonts_default"""
fonts = {
"body": {"family": "Arial", "size": 18, "color": "#666666"}
}
resolver = FontResolver(fonts=fonts, fonts_default="@body")
result = resolver.resolve_font({"bold": True})
assert result.family == "Arial" # 从 fonts_default 继承
assert result.size == 18 # 从 fonts_default 继承
assert result.color == "#666666" # 从 fonts_default 继承
assert result.bold is True # 当前定义
def test_inheritance_chain_current_overrides_default(self):
"""测试当前定义覆盖 fonts_default"""
fonts = {
"body": {"family": "Arial", "size": 18}
}
resolver = FontResolver(fonts=fonts, fonts_default="@body")
result = resolver.resolve_font({"family": "SimSun", "size": 24})
assert result.family == "SimSun" # 当前覆盖
assert result.size == 24 # 当前覆盖
def test_multi_level_parent_inheritance(self):
"""测试多层 parent 继承"""
fonts = {
"base": {"family": "Arial", "size": 18},
"heading": {"parent": "@base", "bold": True},
"title": {"parent": "@heading", "size": 44}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@title")
assert result.family == "Arial" # 从 base 继承
assert result.bold is True # 从 heading 继承
assert result.size == 44 # title 覆盖
class TestFontResolverCircularReference:
"""FontResolver 引用循环检测测试"""
def test_direct_circular_reference_raises_error(self):
"""测试直接循环引用(自引用)"""
fonts = {
"title": {"parent": "@title", "size": 44}
}
resolver = FontResolver(fonts=fonts)
with pytest.raises(ValueError, match="检测到字体引用循环"):
resolver.resolve_font("@title")
def test_indirect_circular_reference_raises_error(self):
"""测试间接循环引用"""
fonts = {
"a": {"parent": "@b", "size": 44},
"b": {"parent": "@a", "size": 18}
}
resolver = FontResolver(fonts=fonts)
with pytest.raises(ValueError, match="检测到字体引用循环"):
resolver.resolve_font("@a")
def test_three_way_circular_reference_raises_error(self):
"""测试三方循环引用"""
fonts = {
"a": {"parent": "@b"},
"b": {"parent": "@c"},
"c": {"parent": "@a"}
}
resolver = FontResolver(fonts=fonts)
with pytest.raises(ValueError, match="检测到字体引用循环"):
resolver.resolve_font("@a")
def test_max_depth_exceeded_raises_error(self):
"""测试引用深度超限"""
# 创建一个很深的继承链
fonts = {}
for i in range(15):
if i == 0:
fonts[f"level{i}"] = {"size": 18}
else:
fonts[f"level{i}"] = {"parent": f"@level{i-1}"}
resolver = FontResolver(fonts=fonts)
with pytest.raises(ValueError, match="引用深度超过限制"):
resolver.resolve_font("@level14")
class TestFontResolverPresetCategories:
"""FontResolver 预设字体类别解析测试"""
def test_resolve_preset_sans(self):
"""测试解析 sans 预设类别"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "sans", "size": 18})
assert result.family == "Arial"
assert result.size == 18
def test_resolve_preset_serif(self):
"""测试解析 serif 预设类别"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "serif", "size": 18})
assert result.family == "Times New Roman"
def test_resolve_preset_mono(self):
"""测试解析 mono 预设类别"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "mono", "size": 18})
assert result.family == "Courier New"
def test_resolve_preset_cjk_sans(self):
"""测试解析 cjk-sans 预设类别"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "cjk-sans", "size": 18})
assert result.family == "Microsoft YaHei"
def test_resolve_preset_cjk_serif(self):
"""测试解析 cjk-serif 预设类别"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "cjk-serif", "size": 18})
assert result.family == "SimSun"
def test_resolve_preset_in_fonts_definition(self):
"""测试在 fonts 配置中使用预设类别"""
fonts = {
"body": {"family": "sans", "size": 18}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@body")
assert result.family == "Arial" # sans 被解析为 Arial
def test_resolve_preset_with_inheritance(self):
"""测试预设类别与继承结合"""
fonts = {
"base": {"family": "cjk-sans", "size": 18},
"title": {"parent": "@base", "size": 44}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@title")
assert result.family == "Microsoft YaHei" # 从 base 继承并解析预设
assert result.size == 44
class TestTableFontFields:
"""表格字体字段测试"""
def test_table_with_font_field(self):
"""测试表格 font 字段"""
from core.elements import TableElement
elem = TableElement(
position=[1, 1],
col_widths=[2, 2],
data=[["A", "B"], ["C", "D"]],
font={"family": "Arial", "size": 14}
)
assert elem.font is not None
assert isinstance(elem.font, dict)
def test_table_with_header_font_field(self):
"""测试表格 header_font 字段"""
from core.elements import TableElement
elem = TableElement(
position=[1, 1],
col_widths=[2, 2],
data=[["A", "B"], ["C", "D"]],
header_font={"bold": True, "color": "#ffffff"}
)
assert elem.header_font is not None
assert isinstance(elem.header_font, dict)
def test_table_with_both_font_fields(self):
"""测试表格同时定义 font 和 header_font"""
from core.elements import TableElement
elem = TableElement(
position=[1, 1],
col_widths=[2, 2],
data=[["A", "B"], ["C", "D"]],
font={"size": 14},
header_font={"bold": True}
)
assert elem.font is not None
assert elem.header_font is not None
def test_table_header_font_inherits_from_font(self):
"""测试表格 header_font 继承 font在渲染器中实现"""
# 这个测试验证数据结构支持,实际继承逻辑在渲染器中
from core.elements import TableElement
elem = TableElement(
position=[1, 1],
col_widths=[2, 2],
data=[["A", "B"], ["C", "D"]],
font={"family": "Arial", "size": 14},
header_font=None # 未定义,应该继承 font
)
assert elem.font is not None
assert elem.header_font is None # 数据结构层面为 None渲染器会处理继承
class TestExtendedFontProperties:
"""扩展字体属性测试"""
def test_font_family_property(self):
"""测试 family 属性"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"family": "SimSun", "size": 18})
assert result.family == "SimSun"
def test_font_underline_property(self):
"""测试 underline 属性"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"underline": True})
assert result.underline is True
def test_font_strikethrough_property(self):
"""测试 strikethrough 属性"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"strikethrough": True})
assert result.strikethrough is True
def test_extended_properties_with_inheritance(self):
"""测试扩展属性继承"""
fonts = {
"base": {"family": "Arial", "underline": True},
"derived": {"parent": "@base", "strikethrough": True}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@derived")
assert result.family == "Arial" # 继承
assert result.underline is True # 继承
assert result.strikethrough is True # 当前定义
class TestParagraphProperties:
"""段落属性测试"""
def test_line_spacing_property(self):
"""测试 line_spacing 属性"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"line_spacing": 1.5})
assert result.line_spacing == 1.5
def test_space_before_property(self):
"""测试 space_before 属性"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"space_before": 12})
assert result.space_before == 12
def test_space_after_property(self):
"""测试 space_after 属性"""
resolver = FontResolver(fonts={})
result = resolver.resolve_font({"space_after": 6})
assert result.space_after == 6
def test_paragraph_properties_with_inheritance(self):
"""测试段落属性继承"""
fonts = {
"base": {"line_spacing": 1.5, "space_before": 12},
"derived": {"parent": "@base", "space_after": 6}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@derived")
assert result.line_spacing == 1.5 # 继承
assert result.space_before == 12 # 继承
assert result.space_after == 6 # 当前定义
class TestBaselineAndCapsProperties:
"""baseline 和 caps 属性测试"""
def test_baseline_normal(self):
"""测试 baseline 为 normal"""
config = FontConfig(baseline="normal")
assert config.baseline == "normal"
def test_baseline_superscript(self):
"""测试 baseline 为 superscript"""
config = FontConfig(baseline="superscript")
assert config.baseline == "superscript"
def test_baseline_subscript(self):
"""测试 baseline 为 subscript"""
config = FontConfig(baseline="subscript")
assert config.baseline == "subscript"
def test_caps_normal(self):
"""测试 caps 为 normal"""
config = FontConfig(caps="normal")
assert config.caps == "normal"
def test_caps_allcaps(self):
"""测试 caps 为 allcaps"""
config = FontConfig(caps="allcaps")
assert config.caps == "allcaps"
def test_caps_smallcaps(self):
"""测试 caps 为 smallcaps"""
config = FontConfig(caps="smallcaps")
assert config.caps == "smallcaps"
def test_baseline_caps_with_inheritance(self):
"""测试 baseline 和 caps 属性继承"""
fonts = {
"base": {"baseline": "superscript", "caps": "allcaps"},
"derived": {"parent": "@base", "size": 18}
}
resolver = FontResolver(fonts=fonts)
result = resolver.resolve_font("@derived")
assert result.baseline == "superscript" # 继承
assert result.caps == "allcaps" # 继承
assert result.size == 18 # 当前定义