实现了统一的metadata结构和字体作用域系统,支持文档和模板库之间的单向字体引用。 主要变更: - 模板库必须包含metadata字段(包括size、fonts、fonts_default) - 实现文档和模板库的size一致性校验 - 实现字体作用域系统(文档可引用模板库字体,反之不可) - 实现跨域循环引用检测 - 实现fonts_default级联规则(模板库→文档→系统默认) - 添加错误代码常量(SIZE_MISMATCH、FONT_NOT_FOUND等) - 更新文档和开发者指南 测试覆盖: - 新增33个测试(单元测试20个,集成测试13个) - 所有457个测试通过 Breaking Changes: - 模板库文件必须包含metadata字段 - 模板库metadata.size为必填字段 - 文档和模板库的size必须一致 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
243 lines
9.6 KiB
Python
243 lines
9.6 KiB
Python
"""
|
||
字体解析器模块
|
||
|
||
提供字体引用解析、继承链处理和预设类别映射功能。
|
||
"""
|
||
|
||
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,
|
||
scope: str = "document",
|
||
template_fonts: Optional[Dict] = None
|
||
):
|
||
"""
|
||
初始化字体解析器
|
||
|
||
Args:
|
||
fonts: 字体配置字典,键为字体名称,值为字体配置
|
||
fonts_default: 默认字体引用(格式:@xxx)
|
||
scope: 作用域("document" 或 "template")
|
||
template_fonts: 模板库字体配置字典(用于跨域引用)
|
||
"""
|
||
self.fonts = fonts or {}
|
||
self.fonts_default = fonts_default
|
||
self.scope = scope
|
||
self.template_fonts = template_fonts or {}
|
||
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], allow_cross_domain: bool = True) -> FontConfig:
|
||
"""
|
||
解析字体引用
|
||
|
||
Args:
|
||
reference: 引用字符串(格式:@xxx)
|
||
visited: 已访问的引用集合,用于检测循环引用
|
||
allow_cross_domain: 是否允许跨域引用(元素引用时为 True,parent 引用时根据作用域决定)
|
||
|
||
Returns:
|
||
解析后的 FontConfig 对象
|
||
|
||
Raises:
|
||
ValueError: 引用不存在或循环引用
|
||
"""
|
||
if not reference.startswith("@"):
|
||
raise ValueError(f"字体引用必须以 @ 开头: {reference}")
|
||
|
||
font_name = reference[1:] # 移除 @ 前缀
|
||
|
||
# 为引用添加作用域标签,用于跨域循环检测
|
||
scope_tag = "doc" if self.scope == "document" else "template"
|
||
tagged_ref = f"{scope_tag}.{reference}"
|
||
|
||
# 检测循环引用(包括跨域循环)
|
||
if tagged_ref in visited:
|
||
path = " -> ".join(visited) + f" -> {tagged_ref}"
|
||
# 检查是否为跨域循环
|
||
has_doc = any("doc." in v for v in visited)
|
||
has_template = any("template." in v for v in visited)
|
||
if has_doc and has_template:
|
||
raise ValueError(f"检测到跨域字体引用循环: {path}")
|
||
else:
|
||
raise ValueError(f"检测到字体引用循环: {path}")
|
||
|
||
# 检查引用深度
|
||
if len(visited) >= self._max_depth:
|
||
raise ValueError(f"字体引用深度超过限制 ({self._max_depth} 层)")
|
||
|
||
# 添加到已访问集合
|
||
visited.add(tagged_ref)
|
||
|
||
# 根据作用域查找字体配置
|
||
font_dict = None
|
||
if self.scope == "template":
|
||
# 模板库作用域:只能引用 template_fonts
|
||
if font_name in self.template_fonts:
|
||
font_dict = self.template_fonts[font_name]
|
||
elif not allow_cross_domain:
|
||
raise ValueError(
|
||
f"模板元素不能引用文档的字体配置: {reference},"
|
||
f"只能引用模板库中定义的字体"
|
||
)
|
||
else:
|
||
# 文档作用域:优先 fonts,fallback template_fonts
|
||
if font_name in self.fonts:
|
||
font_dict = self.fonts[font_name]
|
||
elif font_name in self.template_fonts:
|
||
font_dict = self.template_fonts[font_name]
|
||
|
||
# 检查引用是否存在
|
||
if font_dict is None:
|
||
raise ValueError(f"引用的字体配置不存在: {reference}")
|
||
|
||
# 递归解析
|
||
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 引用的跨域限制:模板库 fonts 的 parent 不能引用文档 fonts
|
||
allow_cross_domain = self.scope == "document"
|
||
parent_config = self._resolve_reference(font_dict["parent"], visited, allow_cross_domain)
|
||
|
||
# 创建当前配置(从 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
|
||
scope_tag = "doc" if self.scope == "document" else "template"
|
||
tagged_default = f"{scope_tag}.{self.fonts_default}"
|
||
if tagged_default in visited:
|
||
return FontConfig()
|
||
|
||
return self._resolve_reference(self.fonts_default, visited.copy(), allow_cross_domain=True)
|
||
|
||
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,
|
||
)
|