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

202
utils/font_resolver.py Normal file
View File

@@ -0,0 +1,202 @@
"""
字体解析器模块
提供字体引用解析、继承链处理和预设类别映射功能。
"""
from typing import Optional, Dict, Set, Union
from core.elements import FontConfig
# 预设字体类别映射(跨平台推荐)
PRESET_FONT_MAPPING = {
"sans": "Arial", # 西文无衬线,跨平台通用
"serif": "Times New Roman", # 西文衬线,跨平台通用
"mono": "Courier New", # 等宽字体,跨平台通用
"cjk-sans": "Microsoft YaHei", # 中文无衬线Windows 推荐)
"cjk-serif": "SimSun", # 中文衬线Windows 推荐)
}
class FontResolver:
"""字体解析器,处理字体引用、继承和预设类别映射"""
def __init__(self, fonts: Optional[Dict] = None, fonts_default: Optional[str] = None):
"""
初始化字体解析器
Args:
fonts: 字体配置字典,键为字体名称,值为字体配置
fonts_default: 默认字体引用(格式:@xxx
"""
self.fonts = fonts or {}
self.fonts_default = fonts_default
self._max_depth = 10 # 最大引用深度,防止循环引用
def resolve_font(self, font_config: Union[FontConfig, str, dict, None]) -> FontConfig:
"""
解析字体配置
Args:
font_config: 字体配置FontConfig对象、字符串引用或字典
Returns:
解析后的 FontConfig 对象
Raises:
ValueError: 引用不存在或循环引用
"""
# 如果为 None使用 fonts_default
if font_config is None:
if self.fonts_default:
return self._resolve_reference(self.fonts_default, set())
return FontConfig()
# 如果已经是 FontConfig 对象,直接返回
if isinstance(font_config, FontConfig):
return font_config
# 如果是字符串,处理整体引用
if isinstance(font_config, str):
if font_config.startswith("@"):
return self._resolve_reference(font_config, set())
raise ValueError(f"字体引用必须以 @ 开头: {font_config}")
# 如果是字典,处理继承覆盖或独立定义
if isinstance(font_config, dict):
return self._resolve_font_dict(font_config, set())
raise ValueError(f"不支持的字体配置类型: {type(font_config)}")
def _resolve_reference(self, reference: str, visited: Set[str]) -> FontConfig:
"""
解析字体引用
Args:
reference: 引用字符串(格式:@xxx
visited: 已访问的引用集合,用于检测循环引用
Returns:
解析后的 FontConfig 对象
Raises:
ValueError: 引用不存在或循环引用
"""
if not reference.startswith("@"):
raise ValueError(f"字体引用必须以 @ 开头: {reference}")
font_name = reference[1:] # 移除 @ 前缀
# 检测循环引用
if reference in visited:
path = " -> ".join(visited) + f" -> {reference}"
raise ValueError(f"检测到字体引用循环: {path}")
# 检查引用深度
if len(visited) >= self._max_depth:
raise ValueError(f"字体引用深度超过限制 ({self._max_depth} 层)")
# 检查引用是否存在
if font_name not in self.fonts:
raise ValueError(f"引用的字体配置不存在: {reference}")
# 添加到已访问集合
visited.add(reference)
# 获取引用的字体配置
font_dict = self.fonts[font_name]
# 递归解析
return self._resolve_font_dict(font_dict, visited.copy())
def _resolve_font_dict(self, font_dict: dict, visited: Set[str]) -> FontConfig:
"""
解析字体字典
Args:
font_dict: 字体配置字典
visited: 已访问的引用集合
Returns:
解析后的 FontConfig 对象
"""
# 处理 parent 继承
parent_config = FontConfig()
if "parent" in font_dict and font_dict["parent"]:
parent_config = self._resolve_reference(font_dict["parent"], visited)
# 创建当前配置(从 parent 继承)
config = FontConfig(
parent=None, # parent 已经解析,不需要保留
family=font_dict.get("family", parent_config.family),
size=font_dict.get("size", parent_config.size),
bold=font_dict.get("bold", parent_config.bold),
italic=font_dict.get("italic", parent_config.italic),
underline=font_dict.get("underline", parent_config.underline),
strikethrough=font_dict.get("strikethrough", parent_config.strikethrough),
color=font_dict.get("color", parent_config.color),
align=font_dict.get("align", parent_config.align),
line_spacing=font_dict.get("line_spacing", parent_config.line_spacing),
space_before=font_dict.get("space_before", parent_config.space_before),
space_after=font_dict.get("space_after", parent_config.space_after),
baseline=font_dict.get("baseline", parent_config.baseline),
caps=font_dict.get("caps", parent_config.caps),
)
# 解析 family 字段中的预设类别
if config.family and config.family in PRESET_FONT_MAPPING:
config.family = PRESET_FONT_MAPPING[config.family]
# 如果当前配置的属性仍为 None从 fonts_default 继承
if self.fonts_default:
default_config = self._get_default_config(visited)
config = self._merge_with_default(config, default_config)
return config
def _get_default_config(self, visited: Set[str]) -> FontConfig:
"""
获取默认字体配置
Args:
visited: 已访问的引用集合
Returns:
默认字体配置
"""
if not self.fonts_default:
return FontConfig()
# 避免在获取默认配置时再次访问 fonts_default
if self.fonts_default in visited:
return FontConfig()
return self._resolve_reference(self.fonts_default, visited.copy())
def _merge_with_default(self, config: FontConfig, default: FontConfig) -> FontConfig:
"""
将配置与默认配置合并
Args:
config: 当前配置
default: 默认配置
Returns:
合并后的配置
"""
return FontConfig(
parent=None,
family=config.family if config.family is not None else default.family,
size=config.size if config.size is not None else default.size,
bold=config.bold if config.bold is not None else default.bold,
italic=config.italic if config.italic is not None else default.italic,
underline=config.underline if config.underline is not None else default.underline,
strikethrough=config.strikethrough if config.strikethrough is not None else default.strikethrough,
color=config.color if config.color is not None else default.color,
align=config.align if config.align is not None else default.align,
line_spacing=config.line_spacing if config.line_spacing is not None else default.line_spacing,
space_before=config.space_before if config.space_before is not None else default.space_before,
space_after=config.space_after if config.space_after is not None else default.space_after,
baseline=config.baseline if config.baseline is not None else default.baseline,
caps=config.caps if config.caps is not None else default.caps,
)