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

@@ -5,10 +5,36 @@
"""
from dataclasses import dataclass, field
from typing import Optional, List
from typing import Optional, List, Union
import re
@dataclass
class FontConfig:
"""字体配置数据类"""
parent: Optional[str] = None
family: Optional[str] = None
size: Optional[int] = None
bold: Optional[bool] = None
italic: Optional[bool] = None
underline: Optional[bool] = None
strikethrough: Optional[bool] = None
color: Optional[str] = None
align: Optional[str] = None
line_spacing: Optional[float] = None
space_before: Optional[int] = None
space_after: Optional[int] = None
baseline: Optional[str] = None
caps: Optional[str] = None
def __post_init__(self):
"""验证枚举值"""
if self.baseline is not None and self.baseline not in ['normal', 'superscript', 'subscript']:
raise ValueError(f"baseline 必须是 normal、superscript 或 subscript当前值: {self.baseline}")
if self.caps is not None and self.caps not in ['normal', 'allcaps', 'smallcaps']:
raise ValueError(f"caps 必须是 normal、allcaps 或 smallcaps当前值: {self.caps}")
def _is_valid_color(color: str) -> bool:
"""
验证颜色格式是否正确
@@ -29,7 +55,7 @@ class TextElement:
type: str = 'text'
content: str = ''
box: list = field(default_factory=lambda: [1, 1, 8, 1])
font: dict = field(default_factory=dict)
font: Union[FontConfig, str, dict] = field(default_factory=dict)
def __post_init__(self):
"""创建时验证"""
@@ -41,33 +67,36 @@ class TextElement:
from validators.result import ValidationIssue
issues = []
# 检查颜色格式
if self.font.get('color'):
if not _is_valid_color(self.font['color']):
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的颜色格式: {self.font['color']} (应为 #RRGGBB)",
location="",
code="INVALID_COLOR_FORMAT"
))
# 只在 font 是字典类型时进行验证
# 字符串引用和 FontConfig 对象会在渲染时由 FontResolver 处理
if isinstance(self.font, dict):
# 检查颜色格式
if self.font.get('color'):
if not _is_valid_color(self.font['color']):
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的颜色格式: {self.font['color']} (应为 #RRGGBB)",
location="",
code="INVALID_COLOR_FORMAT"
))
# 检查字体大小
if self.font.get('size'):
size = self.font['size']
if size < 8:
issues.append(ValidationIssue(
level="WARNING",
message=f"字体太小: {size}pt (建议 >= 8pt)",
location="",
code="FONT_TOO_SMALL"
))
elif size > 100:
issues.append(ValidationIssue(
level="WARNING",
message=f"字体太大: {size}pt (建议 <= 100pt)",
location="",
code="FONT_TOO_LARGE"
))
# 检查字体大小
if self.font.get('size'):
size = self.font['size']
if size < 8:
issues.append(ValidationIssue(
level="WARNING",
message=f"字体太小: {size}pt (建议 >= 8pt)",
location="",
code="FONT_TOO_SMALL"
))
elif size > 100:
issues.append(ValidationIssue(
level="WARNING",
message=f"字体太大: {size}pt (建议 <= 100pt)",
location="",
code="FONT_TOO_LARGE"
))
return issues
@@ -152,6 +181,8 @@ class TableElement:
position: list = field(default_factory=lambda: [1, 1])
col_widths: list = field(default_factory=list)
style: dict = field(default_factory=dict)
font: Union[FontConfig, str, None] = None
header_font: Union[FontConfig, str, None] = None
def __post_init__(self):
"""创建时验证"""
@@ -207,6 +238,19 @@ def create_element(elem_dict: dict):
"""
elem_type = elem_dict.get('type')
# 转换 font 字段为 FontConfig 对象(如果是字典)
if 'font' in elem_dict and isinstance(elem_dict['font'], dict):
elem_dict = elem_dict.copy() # 避免修改原字典
elem_dict['font'] = FontConfig(**elem_dict['font'])
# 转换表格的 font 和 header_font 字段
if elem_type == 'table':
elem_dict = elem_dict.copy()
if 'font' in elem_dict and isinstance(elem_dict['font'], dict):
elem_dict['font'] = FontConfig(**elem_dict['font'])
if 'header_font' in elem_dict and isinstance(elem_dict['header_font'], dict):
elem_dict['header_font'] = FontConfig(**elem_dict['header_font'])
if elem_type == 'text':
return TextElement(**elem_dict)
elif elem_type == 'image':

View File

@@ -39,9 +39,27 @@ class Presentation:
f"无效的尺寸值: {self.size},尺寸必须是字符串(如 '16:9''4:3'"
)
# 解析字体配置
self.fonts = metadata.get("fonts", {})
self.fonts_default = metadata.get("fonts_default")
# 验证 fonts_default
if self.fonts_default:
# fonts_default 必须是引用格式
if not isinstance(self.fonts_default, str) or not self.fonts_default.startswith("@"):
raise YAMLError(
f"fonts_default 必须是引用格式(@xxx当前值: {self.fonts_default}"
)
# fonts_default 引用的配置必须存在于 fonts 中
font_name = self.fonts_default[1:]
if font_name not in self.fonts:
raise YAMLError(
f"fonts_default 引用的字体配置不存在: {self.fonts_default}"
)
# 模板缓存
self.template_cache = {}
# 解析并保存内联模板
self.inline_templates = self.data.get('templates', {})