1
0

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>
This commit is contained in:
2026-03-05 10:38:59 +08:00
parent 7ef29ea039
commit bd12fce14b
22 changed files with 3142 additions and 103 deletions

View File

@@ -0,0 +1,649 @@
"""
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 # 当前定义

View File

@@ -382,47 +382,6 @@ class TestRenderShape:
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):
"""测试列宽不匹配"""
@@ -436,7 +395,6 @@ class TestRenderTable:
position=[1, 1],
col_widths=[2, 3, 4], # 3 列
data=[["A", "B"]], # 2 列数据
style={},
)
with pytest.raises(YAMLError, match="列宽数量"):