1
0
Files
PPTX/utils/font_resolver.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

203 lines
7.6 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.
"""
字体解析器模块
提供字体引用解析、继承链处理和预设类别映射功能。
"""
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,
)