1
0
Files
PPTX/README_DEV.md
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

1250 lines
41 KiB
Markdown
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.
# 开发文档
本文档说明 yaml2pptx 项目的代码结构、开发规范和技术决策。
## 项目概述
yaml2pptx 是一个将 YAML 格式的演示文稿源文件转换为 PPTX 文件的工具,支持模板系统和浏览器预览功能。
## 代码结构
项目采用模块化架构,按功能职责组织代码:
```
html2pptx/
├── yaml2pptx.py (200 行) # 入口脚本CLI + main 函数
├── utils.py (74 行) # 工具函数(日志、颜色转换)
├── core/ # 核心领域模型
│ ├── elements.py (200 行) # 元素抽象层dataclass + validate
│ ├── template.py (191 行) # 模板系统
│ ├── condition_evaluator.py # 条件表达式评估器(新增)
│ └── presentation.py (91 行) # 演示文稿类
├── loaders/ # 数据加载层
│ └── yaml_loader.py (113 行) # YAML 加载和验证
├── validators/ # 验证层
│ ├── __init__.py # 导出主验证器
│ ├── result.py (70 行) # 验证结果数据结构
│ ├── validator.py (150 行) # 主验证器
│ ├── geometry.py (120 行) # 几何验证器
│ └── resource.py (110 行) # 资源验证器
├── renderers/ # 渲染层
│ ├── pptx_renderer.py (292 行) # PPTX 渲染器
│ └── html_renderer.py (172 行) # HTML 渲染器(预览)
└── preview/ # 预览功能
└── server.py (244 行) # Flask 服务器 + 文件监听
```
### 依赖关系
```
yaml2pptx.py (入口)
├─→ utils (工具函数)
├─→ loaders.yaml_loader (YAML 加载)
├─→ validators.validator (验证器)
│ ↓
│ ├─→ validators.result (验证结果)
│ ├─→ validators.geometry (几何验证)
│ ├─→ validators.resource (资源验证)
│ └─→ core.elements (元素验证)
├─→ core.presentation (演示文稿)
│ ↓
│ ├─→ core.template (模板)
│ └─→ core.elements (元素)
├─→ renderers.pptx_renderer (PPTX 生成)
│ ↓
│ └─→ core.elements
└─→ preview.server (预览服务)
└─→ renderers.html_renderer
└─→ core.elements
```
**依赖原则**
- 单向依赖:入口 → 验证/渲染 → 核心 ← 加载
- 无循环依赖
- 核心层不依赖其他业务模块
- 验证层可以调用核心层的元素验证方法
## 模块职责
### 1. yaml2pptx.py入口层
- **职责**CLI 参数解析、流程编排
- **行数**:约 100-150 行
- **包含**
- `/// script` 依赖声明
- `parse_args()` - 命令行参数解析
- `main()` - 主流程编排
- `handle_convert()` - 转换流程,包含页面级 `enabled` 检查
- **不包含**:业务逻辑、数据处理
- **enabled 实现细节**
- 在主渲染循环中检查 `slide_data.get('enabled', True)`
- 跳过 `enabled=false` 的幻灯片,不调用 `render_slide()`
- 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片
- 进度日志显示准确的渲染数量(不包括禁用的幻灯片)
### 2. utils/(工具层)
- **职责**:通用工具函数
- **包含**
- `utils/__init__.py` - 日志和颜色工具
- 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- 颜色转换:`hex_to_rgb()`, `validate_color()`
- **依赖**
- Pillow (PIL) - 保留用于未来可能的图片处理需求
### 3. loaders/yaml_loader.py加载层
- **职责**YAML 文件加载和验证
- **包含**
- `YAMLError` - 自定义异常
- `load_yaml_file()` - 加载 YAML 文件
- `validate_presentation_yaml()` - 验证演示文稿结构,调用 `validate_templates_yaml()` 验证内联模板,验证幻灯片 `enabled` 字段
- `validate_template_yaml()` - 验证外部模板结构
- `validate_templates_yaml()` - 验证内联模板结构templates 字段)
- **特点**
- 内联模板验证包括:结构验证、元素验证、变量定义验证、默认值验证
- 检测默认值中引用不存在的变量
- 验证 `enabled` 字段必须是布尔值,拒绝字符串或条件表达式
### 4. core/elements.py核心层 - 元素抽象)
- **职责**:定义元素数据类和工厂函数
- **包含**
- `_is_valid_color()` - 颜色格式验证工具函数
- `FontConfig` - 字体配置数据类(新增)
- 支持所有字体属性parent、family、size、bold、italic、underline、strikethrough、color、align、line_spacing、space_before、space_after、baseline、caps
-`__post_init__` 中验证 baseline 和 caps 枚举值
- `TextElement` - 文本元素dataclass + validate
- `font` 字段类型:`FontConfig | str | dict`
- `ImageElement` - 图片元素dataclass + validate
- 新增字段:`fit` (适配模式), `background` (背景色)
- 支持四种适配模式stretch默认、contain、cover、center
- `ShapeElement` - 形状元素dataclass + validate
- `TableElement` - 表格元素dataclass + validate
- 新增字段:`font`(表格数据单元格字体)、`header_font`(表头字体)
- `create_element()` - 元素工厂函数
- 自动将字典形式的 font 转换为 FontConfig 对象
- **特点**
- 使用 `@dataclass` 装饰器
-`__post_init__` 中进行创建时验证
-`validate()` 方法中进行元素级验证
- 元素类负责自身属性的验证(颜色格式、字体大小、枚举值等)
### 4.3. utils/font_resolver.py工具层 - 字体解析)
- **职责**:字体引用解析、继承链处理和预设类别映射
- **包含**
- `PRESET_FONT_MAPPING` - 预设字体类别映射常量
- sans → Arial
- serif → Times New Roman
- mono → Courier New
- cjk-sans → Microsoft YaHei
- cjk-serif → SimSun
- `FontResolver`
- `__init__(fonts, fonts_default)` - 初始化解析器
- `resolve_font(font_config)` - 解析字体配置(主入口)
- `_resolve_reference(reference, visited)` - 解析字体引用
- `_resolve_font_dict(font_dict, visited)` - 解析字体字典
- `_get_default_config(visited)` - 获取默认字体配置
- `_merge_with_default(config, default)` - 合并配置与默认值
- **特点**
- 支持三种引用方式:整体引用(`"@xxx"`)、继承覆盖(`{parent: "@xxx"}`)、独立定义
- 实现属性继承链parent → 当前 → fonts_default → 系统默认
- 引用循环检测:维护已访问集合,检测重复引用
- 最大引用深度限制10 层
- 预设类别自动映射:在 family 字段中识别预设类别名称
- **字体引用解析逻辑**
1. 如果 font_config 为 None使用 fonts_default
2. 如果是字符串(`"@xxx"`),解析为整体引用
3. 如果是字典且包含 parent先解析 parent 再覆盖当前属性
4. 如果是字典且不包含 parent直接使用字典属性
5. 未定义的属性从 fonts_default 继承
6. 如果 family 是预设类别,映射到具体字体名称
7. 返回完整的 FontConfig 对象
### 4.5. validators/(验证层)
- **职责**YAML 文件验证,在转换前检查问题
- **包含**
- `validators/result.py` - 验证结果数据结构
- `ValidationIssue` - 验证问题level, message, location, code
- `ValidationResult` - 验证结果errors, warnings, infos
- `validators/geometry.py` - 几何验证器
- `GeometryValidator` - 检查元素边界、页面范围
- 支持 0.1 英寸容忍度
- `validators/resource.py` - 资源验证器
- `ResourceValidator` - 检查图片、模板文件存在性
- 验证模板文件结构
- `validators/validator.py` - 主验证器
- `Validator` - 协调所有子验证器
- 集成元素级验证、几何验证、资源验证
- **特点**
- 分级错误报告ERROR/WARNING/INFO
- ERROR 阻止转换WARNING 不阻止
- 验证职责分层:元素级验证在元素类中,系统级验证在验证器中
### 5. core/template.py核心层 - 模板)
- **职责**:模板加载和变量解析
- **包含**
- `Template`
- `from_data()` 类方法:从字典创建模板实例(用于内联模板)
- 变量解析:`resolve_value()`, `resolve_element()`
- 条件渲染:`evaluate_condition()` - 委托给 ConditionEvaluator
- 模板渲染:`render()`
- **特点**
- 支持外部模板(从文件加载)和内联模板(从字典创建)
### 5.5. core/condition_evaluator.py核心层 - 条件评估)
- **职责**:安全地评估条件表达式
- **包含**
- `ConditionEvaluator`
- `evaluate_condition()` - 主评估方法
- `_get_evaluator()` - 配置 simpleeval 实例
- `_extract_expression()` - 提取表达式内容
- **技术实现**
- 使用 simpleeval 库的 `EvalWithCompoundTypes` 进行安全评估
- 支持比较运算符(==, !=, >, <, >=, <=
- 支持逻辑运算符and, or, not
- 支持成员测试in, not in
- 支持列表/元组字面量
- 支持数学运算(+, -, *, /, %, **
- 支持内置函数int, float, str, len, bool, abs, min, max
- **安全策略**
- 表达式最大长度限制500 字符
- 禁止属性访问obj.attr
- 禁止函数定义lambda, def
- 禁止模块导入import
- 白名单函数限制
- 详细的错误信息映射
- **错误处理**
- `NameNotDefined` → "条件表达式中的变量未定义"
- `FunctionNotDefined` → "条件表达式中使用了不支持的函数"
- `FeatureNotAvailable` → "条件表达式使用了不支持的语法特性"
- `AttributeDoesNotExist` → "不支持属性访问"
- `SyntaxError` → "条件表达式语法错误"
- 内联模板通过 `from_data()` 类方法创建,避免修改现有 `__init__`
- 禁止内联模板相互引用,在渲染时检测并报错
### 6. core/presentation.py核心层 - 演示文稿)
- **职责**:演示文稿管理和幻灯片渲染
- **包含**
- `Presentation`
- 内联模板存储:`__init__` 中解析并保存 `templates` 字段到 `self.inline_templates`
- 模板查找:`get_template()` 优先查找内联模板,然后回退到外部模板
- 同名检测:`_external_template_exists()` 检查外部模板是否存在,防止命名冲突
- 模板缓存:外部模板使用 `template_cache` 缓存
- 幻灯片渲染:`render_slide()` - 支持三种模式
- **特点**
- 将元素字典转换为元素对象
- 使用 `create_element()` 工厂函数
- 内联和外部模板同名时抛出 ERROR 错误
- 内联模板不需要缓存(已在内存中)
#### 幻灯片渲染模式
`render_slide()` 方法支持三种渲染模式:
1. **纯模板模式**:只有 `template` 字段
- 渲染模板元素
- 向后兼容原有行为
2. **纯自定义模式**:只有 `elements` 字段
- 直接使用自定义元素
- 向后兼容原有行为
3. **混合模式**:同时有 `template``elements` 字段(新功能)
- 先渲染模板元素
- 自定义元素使用模板变量解析(通过 `Template.resolve_element()`
- 合并策略:简单追加(`template_elements + custom_elements`
- z 轴顺序:模板元素在底层,自定义元素在上层
#### 元素合并策略
混合模式采用简单追加策略:
```python
# 步骤1渲染模板如果有
elements_from_template = template.render(vars_values)
# 步骤2处理自定义元素如果有
if has_custom_elements and has_template:
# 使用模板变量解析自定义元素
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
# 步骤3合并元素模板在前自定义在后
final_elements = elements_from_template + elements_from_custom
```
**设计决策**
- 不支持按 key/id 合并(避免引入元素 ID 概念)
- 不支持位置感知合并(保持简单)
- 不检测元素重叠(用户通过预览模式查看效果)
-`elements: []` 等同于不指定 `elements`
### 7. renderers/pptx_renderer.py渲染层 - PPTX
- **职责**PPTX 文件生成
- **包含**
- `PptxGenerator`
- 渲染方法:`_render_text()`, `_render_image()`, `_render_shape()`, `_render_table()`
- 元素分发:`_render_element()`
- **特点**
- 渲染器内置在生成器中
- 使用 `isinstance()` 检查元素类型
- 通过元素对象的属性访问数据
### 8. renderers/html_renderer.py渲染层 - HTML
- **职责**HTML 预览渲染
- **包含**
- `HtmlRenderer`
- 渲染方法:`render_text()`, `render_image()`, `render_shape()`, `render_table()`
- **特点**
- 与 PptxRenderer 共享元素抽象层
- 使用固定 DPI (96) 进行单位转换
### 9. preview/server.py预览层
- **职责**:浏览器预览和热重载
- **包含**
- Flask 应用:`create_flask_app()`
- 文件监听:`YAMLChangeHandler`
- 预览服务器:`start_preview_server()`
- HTML 模板:`HTML_TEMPLATE`, `ERROR_TEMPLATE`
## 开发规范
### 1. Python 环境
**必须使用 uv 运行脚本**
```bash
# 正确
uv run yaml2pptx.py input.yaml output.pptx
# 错误 - 严禁直接使用主机环境的 python
python yaml2pptx.py input.yaml output.pptx
```
**依赖管理**
- 所有依赖在 `pyproject.toml``[project.dependencies]` 中声明
- uv 会自动安装依赖,无需手动 `pip install`
### 2. 命令行接口
**子命令架构**
```bash
# check - 验证 YAML 文件
uv run yaml2pptx.py check <input> [--template-dir <dir>]
# convert - 转换为 PPTX
uv run yaml2pptx.py convert <input> [output] [--template-dir <dir>] [--skip-validation] [--force]
# preview - 启动预览服务器
uv run yaml2pptx.py preview <input> [--template-dir <dir>] [--port <port>] [--host <host>] [--no-browser]
```
**参数说明**
- `--template-dir`:所有命令通用,指定模板目录
- `--skip-validation`convert 专用,跳过自动验证
- `--force/-f`convert 专用,强制覆盖已存在文件
- `--port`preview 专用,指定端口(默认随机 30000-40000
- `--host`preview 专用,指定主机地址(默认 127.0.0.1
- `--no-browser`preview 专用,不自动打开浏览器
### 3. 文件组织
**代码文件**
- 每个模块文件控制在 150-300 行
- 入口脚本约 200 行
- 使用有意义的文件名和目录结构
**测试文件**
- 所有测试文件、临时文件必须放在 `temp/` 目录下
- 不污染项目根目录
### 4. 代码风格
**导入顺序**
```python
# 1. 标准库
import sys
from pathlib import Path
# 2. 第三方库
import yaml
from pptx import Presentation
# 3. 本地模块
from utils import log_info
from core.elements import TextElement
```
**文档字符串**
- 每个模块必须有模块级文档字符串
- 每个类和函数必须有文档字符串
- 使用中文编写注释和文档
**命名规范**
- 模块名:小写 + 下划线(如 `yaml_loader.py`
- 类名:大驼峰(如 `TextElement`
- 函数名:小写 + 下划线(如 `load_yaml_file()`
- 常量:大写 + 下划线(如 `HTML_TEMPLATE`
## 技术决策
### 1. 元素抽象层使用 dataclass
**决策**:使用 Python dataclass 定义元素数据类
**理由**
- 简洁性:自动生成 `__init__``__repr__` 等方法
- 类型提示支持类型注解IDE 友好
- 验证时机:`__post_init__` 在创建时自动调用
- 可扩展性:未来可以添加方法
**示例**
```python
@dataclass
class TextElement:
type: str = 'text'
content: str = ''
box: list = field(default_factory=lambda: [1, 1, 8, 1])
font: dict = field(default_factory=dict)
def __post_init__(self):
if len(self.box) != 4:
raise ValueError("box 必须包含 4 个数字")
```
### 2. 渲染器内置在生成器中
**决策**:将渲染逻辑内置在 PptxGenerator 和 HtmlRenderer 类中
**理由**
- 封装性:渲染逻辑与生成器紧密相关
- 简单性:不需要额外的渲染器接口
- 性能:避免额外的方法调用开销
**示例**
```python
class PptxGenerator:
def _render_element(self, slide, elem, base_path):
if isinstance(elem, TextElement):
self._render_text(slide, elem)
elif isinstance(elem, ImageElement):
self._render_image(slide, elem, base_path)
```
### 3. 创建时验证
**决策**:在元素对象创建时进行验证(`__post_init__` 方法)
**理由**
- 尽早失败:在数据进入系统时就发现错误
- 清晰的错误位置:堆栈指向元素创建处
- 避免无效状态:确保元素对象始终有效
### 4. 元素工厂函数
**决策**:提供 `create_element(elem_dict)` 工厂函数
**理由**
- 统一入口:所有元素创建都通过工厂函数
- 类型安全:进行类型检查
- 易于扩展:添加新元素类型只需添加一个分支
### 5. 验证职责分层
**决策**:将验证逻辑分为两层
1. **元素级验证**:放在元素类本身(`core/elements.py`
2. **系统级验证**:放在独立的验证器模块(`validators/`
**理由**
- 元素类最了解自己的约束,应该负责自身的完整性验证
- 系统级验证需要全局上下文(如页面尺寸、文件路径),适合集中处理
- 符合单一职责原则,便于扩展和维护
**元素级验证职责**
- 必需字段检查(在 `__post_init__` 中)
- 数据类型检查(在 `__post_init__` 中)
- 值的有效性检查(在 `validate()` 方法中)
- 颜色格式验证
- 字体大小合理性
- 枚举值检查(如形状类型)
- 表格数据一致性
**系统级验证职责**
- 几何验证(元素是否在页面范围内,需要知道页面尺寸)
- 资源验证(文件是否存在,需要知道文件路径)
- 跨元素验证(如果未来需要)
**示例**
```python
# 元素级验证(在元素类中)
@dataclass
class TextElement:
def validate(self) -> List[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']}",
code="INVALID_COLOR_FORMAT"
))
return issues
# 系统级验证(在验证器中)
class GeometryValidator:
def validate_element(self, element, slide_index, elem_index):
# 需要页面尺寸信息
if element.box[0] + element.box[2] > self.slide_width:
# 报告边界超出
```
### 6. 验证容忍度
**决策**:几何验证时,允许 0.1 英寸的容忍度
**理由**
- 浮点数计算可能有精度误差
- 0.1 英寸(约 2.54mm)在视觉上几乎不可见
- 避免误报,提升用户体验
**实现**
```python
TOLERANCE = 0.1 # 英寸
if right > slide_width + TOLERANCE:
# 报告 WARNING
```
### 7. 模板系统架构
yaml2pptx 支持两种模板方式:内联模板和外部模板库。
#### 7.1 内联模板系统
**决策**:支持在 YAML 源文件中定义内联模板,与外部模板系统共存
**理由**
- 降低使用门槛:简单场景无需创建单独的模板文件
- 保持灵活性:复杂场景仍可使用外部模板
- 向后兼容:不影响现有外部模板功能
**实现要点**
1. **模板定义**:在 YAML 顶层添加 `templates` 字段
```yaml
templates:
my-template:
vars: [...]
elements: [...]
```
2. **模板创建**:使用 `Template.from_data()` 类方法
```python
@classmethod
def from_data(cls, template_data, template_name, base_dir=None):
"""从字典创建模板(内联模板或外部模板)"""
obj = cls.__new__(cls)
obj.data = template_data
obj.base_dir = base_dir # 用于资源路径解析
obj.vars_def = {var['name']: var for var in template_data.get('vars', [])}
obj.elements = template_data.get('elements', [])
return obj
```
3. **模板查找**`Presentation.get_template()` 优先查找内联模板
```python
def get_template(self, template_name):
# 1. 检查内联模板
if template_name in self.inline_templates:
# 2. 检查同名冲突
if self._external_template_exists(template_name):
# 发出 WARNING优先使用内联模板
self.validation_issues.append(ValidationIssue(
level="WARNING",
code="TEMPLATE_NAME_CONFLICT",
message=f"模板名称冲突: '{template_name}'"
))
return Template.from_data(
self.inline_templates[template_name],
template_name,
base_dir=self.pres_base_dir
)
# 3. 回退到外部模板
return self._get_external_template(template_name)
```
4. **同名处理**:内联和外部模板同名时发出警告,优先使用内联模板
- 内联模板更贴近文档,优先级更高
- 发出 WARNING 提醒用户注意冲突
- 不阻止转换,保持灵活性
5. **限制**:禁止内联模板相互引用
- 降低实现复杂度
- 内联模板适合简单场景
- 复杂引用应使用外部模板
6. **验证**`validate_templates_yaml()` 验证内联模板结构
- 检查 `templates` 是否为字典
- 检查每个模板是否有必需的 `elements` 字段
- 检查变量定义是否有必需的 `name` 字段
- 检测默认值中引用不存在的变量
#### 7.2 外部模板库系统
**决策**:使用单个 YAML 文件作为模板库,包含多个模板定义
**理由**
- 统一数据结构:外部模板和内联模板使用相同的字典结构
- 简化管理:单个文件包含所有模板,便于版本控制和分发
- 支持元数据:模板库可以包含 description、version、author 等元数据
- 统一查询:外部模板和内联模板使用相同的查询接口
**模板库文件格式**
```yaml
# 模板库元数据(可选)
description: "公司标准模板库"
version: "1.0.0"
author: "设计团队"
# 模板定义(必需)
templates:
template-name-1:
description: "模板描述(可选)"
vars: [...]
elements: [...]
template-name-2:
description: "模板描述(可选)"
vars: [...]
elements: [...]
```
**实现要点**
1. **命令行参数**`--template` 指定模板库文件路径
```bash
yaml2pptx.py convert input.yaml --template ./templates.yaml
```
2. **模板库加载**:在 `Presentation.__init__` 中加载
```python
def __init__(self, yaml_path, template_file=None):
self.template_file = template_file
self.template_library = None
self.template_base_dir = None
if template_file:
self.template_base_dir = template_file.parent
self.template_library = load_yaml_file(template_file)
validate_template_library_yaml(self.template_library, str(template_file))
```
3. **模板查询**:从模板库字典查找
```python
def _external_template_exists(self, template_name):
if not self.template_library:
return False
return template_name in self.template_library.get('templates', {})
def _get_external_template(self, template_name):
if not self.template_library:
raise YAMLError(f"未指定模板库文件")
templates = self.template_library.get('templates', {})
if template_name not in templates:
raise YAMLError(f"模板库中找不到模板: {template_name}")
return Template.from_data(
templates[template_name],
template_name,
base_dir=self.template_base_dir
)
```
4. **资源路径解析**:统一在渲染时解析
- 外部模板元素:相对于模板库文件所在目录(`template_base_dir`
- 内联模板元素:相对于文档 YAML 所在目录(`pres_base_dir`
- 自定义元素:相对于文档 YAML 所在目录(`pres_base_dir`
- 在 `Template.render()` 和 `Presentation.render_slide()` 中实现
5. **验证**`validate_template_library_yaml()` 验证模板库结构
```python
def validate_template_library_yaml(data, file_path):
# 检查必需的 templates 字段
if 'templates' not in data:
raise YAMLError(f"模板库文件缺少 'templates' 字段: {file_path}")
if not isinstance(data['templates'], dict):
raise YAMLError(f"'templates' 必须是字典类型: {file_path}")
# 递归验证每个模板
for name, template_data in data['templates'].items():
validate_template_yaml(template_data, f"{file_path}::{name}")
```
6. **错误类型区分**
- `TEMPLATE_FILE_NOT_SPECIFIED`: 使用模板但未指定 `--template`
- `TEMPLATE_LIBRARY_FILE_NOT_FOUND`: 模板库文件不存在
- `TEMPLATE_NOT_FOUND_IN_LIBRARY`: 模板名称在模板库中不存在
- `TEMPLATE_NAME_CONFLICT`: 内联和外部模板同名WARNING
**架构优势**
- ✅ 统一数据结构:内联和外部模板使用相同的字典格式
- ✅ 统一查询接口:`get_template()` 方法处理两种模板
- ✅ 统一资源解析:通过 `base_dir` 参数统一处理路径
- ✅ 简化分发:单个文件包含所有模板
- ✅ 支持元数据:模板库和模板都可以有 description
- ✅ 移除缓存:简化代码,模板库加载开销小
### 8. description 字段
**决策**:为 metadata、模板和幻灯片添加可选的 `description` 字段
**理由**
- 自文档化:提高模板和演示文稿的可读性和可维护性
- 备注支持:幻灯片 description 会写入 PPT 备注页,方便演讲者查看
- 完全向后兼容:字段为可选,现有文件无需修改
**实现要点**
1. **数据模型**
- `Presentation.description`:从 `metadata.description` 读取,用于描述整个演示文稿
- `Template.description`:从模板文件的 `description` 字段读取,描述模板用途
- `Slide.description`:在 `render_slide()` 返回值中保留,会写入 PPT 备注页
2. **YAML 解析**
- 使用 `.get('description')` 自动处理缺失情况(返回 None
- YAML 原生支持多行文本格式
3. **PPT 备注功能**
- 仅幻灯片级别的 `description` 会写入 PPT 备注页
- 模板的 `description` 不继承到幻灯片备注
- `metadata.description` 不写入单个幻灯片备注
- 如果幻灯片没有 `description`,则不设置备注
4. **渲染实现**
- `PptxGenerator._set_notes()`:设置幻灯片备注的私有方法
- `PptxGenerator.add_slide()`:调用 `_set_notes()` 设置备注
**示例**
```yaml
# metadata description - 描述整个演示文稿
metadata:
description: "2024年度项目进展总结"
# 模板 description - 描述模板用途
templates:
title-slide:
description: "用于章节标题页的模板"
elements: [...]
# 幻灯片 description - 写入 PPT 备注页
slides:
- description: "介绍项目背景和目标,包含以下要点:..."
template: title-slide
vars:
title: "项目背景"
```
## 扩展指南
### 添加新元素类型
假设要添加 `VideoElement`
**1. 在 core/elements.py 中定义数据类**
```python
@dataclass
class VideoElement:
type: str = 'video'
src: str = ''
box: list = field(default_factory=lambda: [1, 1, 4, 3])
autoplay: bool = False
def __post_init__(self):
if not self.src:
raise ValueError("视频元素必须指定 src")
if len(self.box) != 4:
raise ValueError("box 必须包含 4 个数字")
```
**2. 在工厂函数中添加分支**
```python
def create_element(elem_dict: dict):
elem_type = elem_dict.get('type')
# ... 其他类型 ...
elif elem_type == 'video':
return VideoElement(**elem_dict)
```
**3. 在 PptxGenerator 中实现渲染方法**
```python
def _render_element(self, slide, elem, base_path):
# ... 其他类型 ...
elif isinstance(elem, VideoElement):
self._render_video(slide, elem, base_path)
def _render_video(self, slide, elem: VideoElement, base_path):
# 实现视频渲染逻辑
pass
```
**4. 在 HtmlRenderer 中实现渲染方法**
```python
def render_slide(self, slide_data, index, base_path):
# ... 其他类型 ...
elif isinstance(elem, VideoElement):
elements_html += self.render_video(elem, base_path)
def render_video(self, elem: VideoElement, base_path):
# 实现 HTML 视频渲染
return f'<video src="{elem.src}" ...></video>'
```
### 添加新渲染器
假设要添加 PDF 渲染器:
**1. 创建 renderers/pdf_renderer.py**
```python
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
class PdfRenderer:
def __init__(self):
# 初始化 PDF 库
pass
def add_slide(self, slide_data, base_path=None):
# 添加页面
pass
def _render_element(self, page, elem, base_path):
if isinstance(elem, TextElement):
self._render_text(page, elem)
# ... 其他元素类型
```
**2. 在 yaml2pptx.py 中添加 PDF 模式**
```python
from renderers.pdf_renderer import PdfRenderer
def main():
# ... 解析参数 ...
if args.pdf:
# PDF 生成模式
generator = PdfRenderer()
# ... 渲染逻辑
```
## 测试规范
### 测试框架
项目使用 pytest 作为测试框架,测试代码位于 `tests/` 目录。
### 测试结构
```
tests/
├── conftest.py # pytest 配置和共享 fixtures
├── conftest_pptx.py # PPTX 文件验证工具
├── unit/ # 单元测试
│ ├── test_elements.py # 元素类测试
│ ├── test_template.py # 模板系统测试
│ ├── test_utils.py # 工具函数测试
│ ├── test_validators/ # 验证器测试
│ │ ├── test_geometry.py
│ │ ├── test_resource.py
│ │ ├── test_result.py
│ │ └── test_validator.py
│ └── test_loaders/ # 加载器测试
│ └── test_yaml_loader.py
├── integration/ # 集成测试
│ ├── test_presentation.py
│ ├── test_rendering_flow.py
│ └── test_validation_flow.py
├── e2e/ # 端到端测试
│ ├── test_convert_cmd.py
│ ├── test_check_cmd.py
│ └── test_preview_cmd.py
└── fixtures/ # 测试数据
├── yaml_samples/ # YAML 样本
├── templates/ # 测试模板
└── images/ # 测试图片
```
### 运行测试
```bash
# 安装测试依赖
uv pip install -e ".[dev]"
# 运行所有测试
uv run pytest
# 运行特定类型的测试
uv run pytest tests/unit/ # 单元测试
uv run pytest tests/integration/ # 集成测试
uv run pytest tests/e2e/ # 端到端测试
# 运行特定测试文件
uv run pytest tests/unit/test_elements.py
# 显示详细输出
uv run pytest -v
# 显示测试覆盖率
uv run pytest --cov=. --cov-report=html
```
### 编写测试
**测试类命名**:使用 `Test<ClassName>` 格式
**测试方法命名**:使用 `test_<what_is_being_tested>` 格式
```python
class TestTextElement:
"""TextElement 测试类"""
def test_create_with_defaults(self):
"""测试使用默认值创建 TextElement"""
elem = TextElement()
assert elem.type == 'text'
def test_invalid_color_raises_error(self):
"""测试无效颜色会引发错误"""
with pytest.raises(ValueError, match="无效颜色"):
TextElement(font={"color": "red"})
```
### Fixtures
共享 fixtures 定义在 `tests/conftest.py` 中:
- `temp_dir`: 临时目录
- `sample_yaml`: 最小测试 YAML 文件
- `sample_image`: 测试图片
- `sample_template`: 测试模板
- `pptx_validator`: PPTX 验证器
```python
def test_with_fixture(sample_yaml):
"""使用 fixture 的测试"""
assert sample_yaml.exists()
```
### PPTX 验证
使用 `PptxFileValidator` 验证生成的 PPTX 文件:
```python
def test_pptx_generation(temp_dir, pptx_validator):
"""测试 PPTX 生成"""
# ... 生成 PPTX ...
output_path = temp_dir / "output.pptx"
# 验证文件
assert pptx_validator.validate_file(output_path) is True
# 验证内容
prs = Presentation(str(output_path))
assert pptx_validator.validate_text_element(
prs.slides[0],
index=0,
expected_content="Test"
) is True
```
### 手动测试
```bash
# 验证 YAML 文件
uv run yaml2pptx.py check temp/test.yaml
# 使用模板时验证
uv run yaml2pptx.py check temp/demo.yaml --template-dir temp/templates
# 转换 YAML 为 PPTX
uv run yaml2pptx.py convert temp/test.yaml temp/output.pptx
# 自动生成输出文件名
uv run yaml2pptx.py convert temp/test.yaml
# 跳过自动验证
uv run yaml2pptx.py convert temp/test.yaml temp/output.pptx --skip-validation
# 强制覆盖已存在文件
uv run yaml2pptx.py convert temp/test.yaml temp/output.pptx --force
# 使用模板
uv run yaml2pptx.py convert temp/demo.yaml temp/output.pptx --template-dir temp/templates
# 启动预览服务器
uv run yaml2pptx.py preview temp/test.yaml
# 指定端口
uv run yaml2pptx.py preview temp/test.yaml --port 8080
# 允许局域网访问
uv run yaml2pptx.py preview temp/test.yaml --host 0.0.0.0
# 不自动打开浏览器
uv run yaml2pptx.py preview temp/test.yaml --no-browser
```
### 测试文件位置
- **自动化测试**`tests/` 目录
- **手动测试文件**`temp/` 目录
- `temp/*.yaml` - 手动测试用的 YAML 文件
- `temp/*.pptx` - 生成的 PPTX 文件
- `temp/templates/` - 手动测试用的模板文件
## 常见问题
### Q: 为什么不能直接使用 python 运行脚本?
A: 项目使用 uv 和 pyproject.toml 来管理依赖。直接使用 python 会导致依赖缺失。必须使用 `uv run yaml2pptx.py`。
### Q: 如何添加新的依赖?
A: 在 `pyproject.toml` 的 `[project.dependencies]` 中添加:
```toml
[project]
dependencies = [
"python-pptx",
"pyyaml",
"flask",
"watchdog",
"new-dependency", # 添加新依赖
]
```
### Q: 为什么元素使用 dataclass 而不是普通字典?
A: dataclass 提供:
1. 类型安全和 IDE 支持
2. 自动生成的方法(`__init__`, `__repr__`
3. 创建时验证(`__post_init__`
4. 更好的可维护性和可扩展性
### Q: 如何调试渲染问题?
A: 使用预览模式:
```bash
uv run yaml2pptx.py preview temp/test.yaml
```
在浏览器中查看渲染结果,支持热重载。
## 项目约束
1. **面向中文开发者**:注释、文档、错误消息使用中文
2. **使用 uv 运行**:严禁直接使用主机环境的 python
3. **测试文件隔离**:所有测试文件放在 `temp/` 目录
4. **不污染主机环境**:不修改主机的 Python 配置
## 字体作用域系统
### 概述
字体作用域系统实现了文档和模板库之间的字体隔离和跨域引用控制,确保字体引用的安全性和可维护性。
### 作用域定义
系统定义了两个字体作用域:
1. **文档作用域document**
- 包含文档的 `metadata.fonts` 中定义的字体
- 文档的 `fonts_default` 只能引用文档作用域的字体
- 文档元素可以引用文档作用域和模板库作用域的字体
2. **模板库作用域template**
- 包含模板库的 `metadata.fonts` 中定义的字体
- 模板库的 `fonts_default` 只能引用模板库作用域的字体
- 模板元素只能引用模板库作用域的字体(不能引用文档字体)
### 跨域引用规则
**允许的引用**
- ✅ 文档元素 → 文档字体
- ✅ 文档元素 → 模板库字体(跨域引用)
- ✅ 模板元素 → 模板库字体
- ✅ 文档 fonts_default → 文档字体
- ✅ 模板库 fonts_default → 模板库字体
**禁止的引用**
- ❌ 模板元素 → 文档字体(跨域引用被禁止)
- ❌ 模板库 fonts_default → 文档字体(跨域引用被禁止)
- ❌ 文档 fonts_default → 模板库字体(跨域引用被禁止)
**设计理由**
- 模板库应该是自包含的,不依赖特定文档的字体配置
- 文档可以引用模板库字体,实现样式复用
- 防止模板库与文档之间的紧耦合
### FontResolver 实现
`utils/font_resolver.py` 中的 `FontResolver` 类实现了作用域控制:
```python
class FontResolver:
def __init__(self, fonts, fonts_default, scope="document", template_fonts=None):
"""
Args:
fonts: 当前作用域的字体字典
fonts_default: 当前作用域的默认字体
scope: 作用域标识 ("document" 或 "template")
template_fonts: 模板库字体字典(仅文档作用域需要)
"""
```
**跨域引用检测**
- 使用作用域标签(`doc.@font-name` 或 `template.@font-name`)追踪引用路径
- 检测跨域循环引用(如 `doc.@a → template.@b → doc.@a`
- 在 `parent` 引用时根据作用域限制跨域访问
### fonts_default 级联规则
当元素未指定字体时,按以下顺序查找默认字体:
1. **模板元素**
- 模板库的 `fonts_default`(如果存在)
- 文档的 `fonts_default`(如果存在)
- 系统默认字体
2. **文档元素**
- 文档的 `fonts_default`(如果存在)
- 系统默认字体
**实现位置**
- `core/template.py` - Template.render() 方法
- `core/presentation.py` - Presentation.render_slide() 方法
### 循环引用检测
系统检测两种循环引用:
1. **单域内循环**
```yaml
fonts:
a:
parent: "@b"
b:
parent: "@a"
```
错误信息:`检测到字体引用循环: doc.@a -> doc.@b -> doc.@a`
2. **跨域循环**
```yaml
# 文档
fonts:
doc-font:
parent: "@template-font"
# 模板库
fonts:
template-font:
parent: "@doc-font" # 这会被禁止
```
错误信息:`检测到跨域字体引用循环: doc.@doc-font -> template.@template-font -> doc.@doc-font`
### 错误代码
字体作用域系统相关的错误代码(定义在 `validators/result.py`
#### 模板库 metadata 相关
- `TEMPLATE_LIBRARY_MISSING_METADATA` - 模板库缺少 metadata 字段
- `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` - 模板库 metadata 缺少 size 字段
- `TEMPLATE_LIBRARY_METADATA_INVALID_SIZE` - 模板库 metadata.size 值无效(必须是 "16:9" 或 "4:3"
#### Size 一致性
- `SIZE_MISMATCH` - 文档和模板库的 size 不一致
#### 字体引用相关
- `TEMPLATE_FONT_REF_DOC_FORBIDDEN` - 模板元素引用文档字体(跨域引用被禁止)
- `TEMPLATE_PARENT_REF_DOC_FORBIDDEN` - 模板库字体的 parent 引用文档字体(跨域引用被禁止)
- `FONT_NOT_FOUND` - 字体配置不存在
- `CIRCULAR_REFERENCE` - 检测到字体引用循环(包括跨域循环)
- `FONT_DEFAULT_INVALID` - fonts_default 引用无效(字体不存在或跨域引用)
### 使用示例
**正确的跨域引用**
```yaml
# templates.yaml模板库
metadata:
size: "16:9"
fonts:
template-title:
family: "cjk-sans"
size: 44
bold: true
fonts_default: "@template-title"
templates:
title-slide:
elements:
- type: text
content: "{title}"
# 未指定 font使用模板库的 fonts_default
# presentation.yaml文档
metadata:
size: "16:9"
fonts:
doc-body:
family: "sans"
size: 18
fonts_default: "@doc-body"
slides:
- template: title-slide
vars:
title: "标题"
- elements:
- type: text
content: "正文"
font: "@template-title" # ✅ 文档元素可以引用模板库字体
```
**错误的跨域引用**
```yaml
# templates.yaml模板库
metadata:
size: "16:9"
fonts_default: "@doc-body" # ❌ 模板库 fonts_default 不能引用文档字体
templates:
title-slide:
elements:
- type: text
content: "{title}"
font: "@doc-body" # ❌ 模板元素不能引用文档字体
```
## 维护指南
### 代码审查要点
- [ ] 模块文件大小合理150-300 行)
- [ ] 无循环依赖
- [ ] 所有类和函数有文档字符串
- [ ] 使用中文注释
- [ ] 元素验证在 `__post_init__` 中完成
- [ ] 导入语句按标准库、第三方库、本地模块排序
- [ ] 测试文件在 `temp/` 目录下
### 性能优化建议
1. **模板缓存**Presentation 类已实现模板缓存
2. **元素验证**:只在创建时验证一次,渲染时不再验证
3. **文件监听**:预览模式使用 watchdog 高效监听文件变化