1
0
Files
PPTX/core/template.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

282 lines
10 KiB
Python
Raw Permalink 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.
"""
模板系统模块
管理可复用的幻灯片布局和变量解析。
"""
import re
from pathlib import Path
from loaders.yaml_loader import YAMLError, load_yaml_file, validate_template_yaml
from core.condition_evaluator import ConditionEvaluator
class Template:
"""模板类,管理可复用的幻灯片布局"""
def __init__(self, template_file, templates_dir=None):
"""
初始化模板
Args:
template_file: 模板名称(纯文件名,不含路径)
templates_dir: 模板文件目录
"""
# 初始化条件评估器
self._condition_evaluator = ConditionEvaluator()
# 检查是否提供了 templates_dir
if templates_dir is None:
raise YAMLError(
f"未指定模板目录,无法加载模板: {template_file}\n"
f"请使用 --template-dir 参数指定模板目录"
)
# 验证模板名称(不能包含路径分隔符)
if '/' in template_file or '\\' in template_file:
raise YAMLError(
f"模板名称不能包含路径分隔符: {template_file}\n"
f"模板名称应该是纯文件名,如: 'title-slide'"
)
# 构建模板路径
template_path = Path(templates_dir) / f"{template_file}.yaml"
# 检查文件是否存在
if not template_path.exists():
raise YAMLError(
f"模板文件不存在: {template_file}\n"
f"查找位置: {templates_dir}\n"
f"期望文件: {template_path}\n"
f"提示: 请检查模板名称和模板目录是否正确"
)
# 加载并验证模板文件
self.data = load_yaml_file(template_path)
validate_template_yaml(self.data, str(template_path))
# 可选的描述字段
self.description = self.data.get('description')
# 解析变量定义
self.vars_def = {}
for var in self.data.get('vars', []):
self.vars_def[var['name']] = var
# 元素列表
self.elements = self.data.get('elements', [])
@classmethod
def from_data(cls, template_data, template_name, base_dir=None, scope="document", font_resolver=None):
"""从字典创建模板(内联模板或外部模板)
Args:
template_data: 模板数据字典
template_name: 模板名称
base_dir: 资源路径解析的基础目录(外部模板使用模板库文件所在目录,内联模板使用文档目录)
scope: 作用域("document""template"
font_resolver: 字体解析器(用于解析字体引用)
Returns:
Template 对象
"""
obj = cls.__new__(cls)
obj.data = template_data
obj.base_dir = base_dir # 保存 base_dir 用于资源路径解析
obj.scope = scope # 保存作用域
obj.font_resolver = font_resolver # 保存字体解析器
# 初始化条件评估器
obj._condition_evaluator = ConditionEvaluator()
# 可选的描述字段
obj.description = template_data.get('description')
# 解析变量定义
obj.vars_def = {}
for var in template_data.get('vars', []):
obj.vars_def[var['name']] = var
# 元素列表
obj.elements = template_data.get('elements', [])
return obj
def _external_template_exists(self, template_name):
"""检查外部模板文件是否存在"""
if not hasattr(self, 'templates_dir') or not self.templates_dir:
return False
template_path = Path(self.templates_dir) / f"{template_name}.yaml"
return template_path.exists()
def resolve_value(self, value, vars_values):
"""
解析单个值中的变量引用
Args:
value: 要解析的值
vars_values: 用户提供的变量值字典
Returns:
解析后的值
"""
if not isinstance(value, str):
return value
# 匹配 {xxx} 模式
pattern = r'\{([^}]+)\}'
def replacer(match):
expr = match.group(1)
# 模板变量: {title}
if expr in vars_values:
return str(vars_values[expr])
else:
raise YAMLError(f"未定义的变量: {expr}")
result = re.sub(pattern, replacer, value)
# 如果结果是纯数字字符串,转换回数字类型
try:
# 尝试转换为整数
if '.' not in result:
return int(result)
# 尝试转换为浮点数
else:
return float(result)
except ValueError:
# 不是数字,返回字符串
return result
def resolve_element(self, elem, vars_values):
"""
递归解析元素中的所有变量
Args:
elem: 元素dict, list, 或其他类型)
vars_values: 用户提供的变量值字典
Returns:
解析后的元素
"""
if isinstance(elem, dict):
return {k: self.resolve_element(v, vars_values)
for k, v in elem.items() if k != 'visible'}
elif isinstance(elem, list):
return [self.resolve_element(item, vars_values)
for item in elem]
elif isinstance(elem, str):
return self.resolve_value(elem, vars_values)
else:
return elem
def evaluate_condition(self, condition, vars_values):
"""
评估条件表达式
Args:
condition: 条件字符串,如 "{count > 0 and status == 'active'}"
vars_values: 变量值字典
Returns:
bool: 条件是否满足
"""
# 委托给条件评估器
return self._condition_evaluator.evaluate_condition(condition, vars_values)
def render(self, vars_values):
"""
渲染模板,返回实际的元素列表
Args:
vars_values: 用户提供的变量值字典
Returns:
list: 渲染后的元素列表
Raises:
YAMLError: 缺少必需变量,内联模板相互引用
"""
# 检测内联模板相互引用(禁止)
for elem in self.elements:
if isinstance(elem, dict) and 'template' in elem:
raise YAMLError(
f"内联模板不支持相互引用:元素中包含 'template' 字段\n"
f"提示: 内联模板只能包含元素定义,不能引用其他模板"
)
# 填充所有变量的默认值(如果用户未提供)
for var_name, var_def in self.vars_def.items():
if var_name not in vars_values:
# 检查是否是必需变量
if var_def.get('required', False):
# 必需变量必须有默认值或用户提供
if 'default' in var_def:
vars_values[var_name] = self.resolve_value(
var_def['default'], vars_values
)
else:
raise YAMLError(f"缺少必需变量: {var_name}")
else:
# 可选变量使用默认值(如果有)
if 'default' in var_def:
vars_values[var_name] = self.resolve_value(
var_def['default'], vars_values
)
# 渲染所有元素
rendered_elements = []
for elem in self.elements:
# 检查条件渲染
if 'visible' in elem:
if not self.evaluate_condition(elem['visible'], vars_values):
continue
# 深度解析元素中的所有变量引用
rendered_elem = self.resolve_element(elem, vars_values)
# 解析字体引用(如果有 font_resolver
if self.font_resolver and isinstance(rendered_elem, dict):
# 处理普通元素的 font 字段
if 'font' in rendered_elem:
font_config = rendered_elem['font']
# 如果是字符串引用或字典,使用 FontResolver 解析
if isinstance(font_config, (str, dict)):
try:
resolved_font = self.font_resolver.resolve_font(font_config)
rendered_elem['font'] = resolved_font
except ValueError as e:
raise YAMLError(f"字体解析失败: {str(e)}")
elif rendered_elem.get('type') in ['text', 'table']:
# 元素未定义 font使用 fonts_default如果有
if self.font_resolver.fonts_default:
try:
resolved_font = self.font_resolver.resolve_font(None)
rendered_elem['font'] = resolved_font
except ValueError as e:
raise YAMLError(f"字体解析失败: {str(e)}")
# 处理表格元素的 header_font 字段
if rendered_elem.get('type') == 'table' and 'header_font' in rendered_elem:
header_font_config = rendered_elem['header_font']
if isinstance(header_font_config, (str, dict)):
try:
resolved_header_font = self.font_resolver.resolve_font(header_font_config)
rendered_elem['header_font'] = resolved_header_font
except ValueError as e:
raise YAMLError(f"表格 header_font 解析失败: {str(e)}")
# 如果是图片元素且有相对路径,解析为绝对路径
if isinstance(rendered_elem, dict) and rendered_elem.get('type') == 'image':
src = rendered_elem.get('src')
if src and self.base_dir:
src_path = Path(src)
# 只处理相对路径
if not src_path.is_absolute():
resolved_path = Path(self.base_dir) / src
rendered_elem['src'] = str(resolved_path)
rendered_elements.append(rendered_elem)
return rendered_elements