1
0
Files
PPTX/utils/font_resolver.py
lanyuanxiaoyao 98098dc911 feat: 实现模板库metadata和跨域字体引用系统
实现了统一的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>
2026-03-05 18:12:05 +08:00

243 lines
9.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,
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: 是否允许跨域引用(元素引用时为 Trueparent 引用时根据作用域决定)
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:
# 文档作用域:优先 fontsfallback 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,
)