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

264 lines
11 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 pathlib import Path
from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml, validate_template_library_yaml, YAMLError
from core.template import Template
from core.elements import create_element
from utils.font_resolver import FontResolver
class Presentation:
"""演示文稿类,管理整个演示文稿的生成流程"""
def __init__(self, pres_file, template_file=None):
"""
初始化演示文稿
Args:
pres_file: 演示文稿 YAML 文件路径
template_file: 模板库文件路径(可选)
"""
self.pres_file = Path(pres_file).resolve()
self.template_file = Path(template_file).resolve() if template_file else None
# 加载演示文稿文件
self.data = load_yaml_file(pres_file)
validate_presentation_yaml(self.data, str(pres_file))
# 保存文档目录和模板库目录(使用绝对路径)
self.pres_base_dir = self.pres_file.parent
self.template_base_dir = self.template_file.parent if self.template_file else None
# 获取演示文稿尺寸和描述
metadata = self.data.get("metadata", {})
self.size = metadata.get("size", "16:9")
self.description = metadata.get("description") # 可选的描述字段
# 验证尺寸值
if not isinstance(self.size, str):
raise ValueError(
f"无效的尺寸值: {self.size},尺寸必须是字符串(如 '16:9''4:3'"
)
# 加载并验证模板库文件(如果提供)
self.template_library = None
if self.template_file:
if not self.template_file.exists():
raise YAMLError(f"模板库文件不存在: {self.template_file}")
self.template_library = load_yaml_file(self.template_file)
validate_template_library_yaml(self.template_library, str(self.template_file))
# size 一致性校验
template_metadata = self.template_library.get("metadata", {})
template_size = template_metadata.get("size")
if self.size != template_size:
raise YAMLError(
f"文档尺寸 '{self.size}' 与模板库尺寸 '{template_size}' 不一致"
)
# 解析字体配置
self.fonts = metadata.get("fonts", {})
self.fonts_default = metadata.get("fonts_default")
# 解析模板库字体配置
self.template_fonts = {}
self.template_fonts_default = None
if self.template_library:
template_metadata = self.template_library.get("metadata", {})
self.template_fonts = template_metadata.get("fonts", {})
self.template_fonts_default = template_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 或 template_fonts 中
font_name = self.fonts_default[1:]
if font_name not in self.fonts and font_name not in self.template_fonts:
raise YAMLError(
f"fonts_default 引用的字体配置不存在: {self.fonts_default}"
)
# 创建字体解析器
# 文档作用域的 FontResolver可引用模板库字体
self.font_resolver_doc = FontResolver(
fonts=self.fonts,
fonts_default=self.fonts_default,
scope="document",
template_fonts=self.template_fonts
)
# 模板库作用域的 FontResolver只能引用模板库字体
self.font_resolver_template = FontResolver(
fonts=self.template_fonts,
fonts_default=self.template_fonts_default,
scope="template",
template_fonts=self.template_fonts
)
# 解析并保存内联模板
self.inline_templates = self.data.get('templates', {})
def get_template(self, template_name):
"""
获取模板(优先检查内联模板,检测同名冲突)
Args:
template_name: 模板名称
Returns:
Template 对象
Raises:
YAMLError: 模板不存在
"""
# 1. 先检查内联模板
if template_name in self.inline_templates:
# 2. 检查外部模板是否也存在同名WARNING
if self._external_template_exists(template_name):
# 同名冲突:发出警告但继续使用内联模板
# 注意:这里只是记录警告,实际的 WARNING 级别验证问题应该在验证器中生成
pass
inline_data = self.inline_templates[template_name]
# 内联模板使用文档目录作为 base_dir文档作用域文档 FontResolver
return Template.from_data(
inline_data,
template_name,
base_dir=self.pres_base_dir,
scope="document",
font_resolver=self.font_resolver_doc
)
# 3. 回退到外部模板
if not self.template_library:
raise YAMLError(
f"模板不存在: {template_name}\n"
f"提示: 该模板既不在内联模板中,也未提供外部模板库文件"
)
# 从模板库字典查找
if template_name not in self.template_library['templates']:
raise YAMLError(
f"模板库中找不到指定模板名称: {template_name}\n"
f"模板库文件: {self.template_file}"
)
template_data = self.template_library['templates'][template_name]
# 外部模板使用模板库目录作为 base_dir模板作用域模板库 FontResolver
return Template.from_data(
template_data,
template_name,
base_dir=self.template_base_dir,
scope="template",
font_resolver=self.font_resolver_template
)
def _external_template_exists(self, template_name):
"""检查外部模板是否存在"""
if not self.template_library:
return False
return template_name in self.template_library['templates']
def render_slide(self, slide_data):
"""
渲染单个幻灯片(支持混合模式)
Args:
slide_data: 幻灯片数据字典
Returns:
dict: 包含 background 和 elements 的字典
支持三种模式:
1. 纯模板模式:只有 template 字段
2. 纯自定义模式:只有 elements 字段
3. 混合模式:同时有 template 和 elements 字段
"""
has_template = "template" in slide_data
has_custom_elements = slide_data.get("elements")
elements_from_template = []
vars_values = {}
# 步骤1渲染模板如果有
if has_template:
template_name = slide_data["template"]
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
elements_from_template = template.render(vars_values)
# 步骤2处理自定义元素如果有
elements_from_custom = []
if has_custom_elements:
custom_elements = slide_data["elements"]
if has_template:
# 混合模式:使用模板变量解析自定义元素
template = self.get_template(slide_data["template"])
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
else:
# 纯自定义模式(原有行为)
elements_from_custom = custom_elements
# 解析自定义元素的字体引用(使用文档 FontResolver
for elem in elements_from_custom:
if isinstance(elem, dict):
# 处理普通元素的 font 字段
if 'font' in elem:
font_config = elem['font']
if isinstance(font_config, (str, dict)):
try:
resolved_font = self.font_resolver_doc.resolve_font(font_config)
elem['font'] = resolved_font
except ValueError as e:
raise YAMLError(f"字体解析失败: {str(e)}")
elif elem.get('type') in ['text', 'table']:
# 元素未定义 font使用 fonts_default如果有
if self.fonts_default:
try:
resolved_font = self.font_resolver_doc.resolve_font(None)
elem['font'] = resolved_font
except ValueError as e:
raise YAMLError(f"字体解析失败: {str(e)}")
# 处理表格元素的 header_font 字段
if elem.get('type') == 'table' and 'header_font' in elem:
header_font_config = elem['header_font']
if isinstance(header_font_config, (str, dict)):
try:
resolved_header_font = self.font_resolver_doc.resolve_font(header_font_config)
elem['header_font'] = resolved_header_font
except ValueError as e:
raise YAMLError(f"表格 header_font 解析失败: {str(e)}")
# 解析自定义元素的图片路径(相对于文档目录)
for elem in elements_from_custom:
if isinstance(elem, dict) and elem.get('type') == 'image':
src = elem.get('src')
if src:
src_path = Path(src)
# 只处理相对路径
if not src_path.is_absolute():
resolved_path = self.pres_base_dir / src
elem['src'] = str(resolved_path)
# 步骤3合并元素模板元素在前自定义元素在后
final_elements = elements_from_template + elements_from_custom
# 步骤4转换为元素对象
element_objects = [create_element(elem) for elem in final_elements]
return {
"background": slide_data.get("background"),
"elements": element_objects,
"description": slide_data.get("description"), # 保留幻灯片描述
}