1
0

refactor: 重构文档结构,采用渐进式信息披露模式

将 README.md 拆分为多个专题文档,减少认知负荷:
- 用户文档迁移到 docs/ (用户指南、元素、模板、参考等)
- 开发文档迁移到 docs/development/ (架构、模块、规范)
- README.md 精简至 ~290 行,仅保留概览和导航
- 删除 README_DEV.md,内容已迁移
- 归档 OpenSpec 变更 refactor-docs-progressive-disclosure
This commit is contained in:
2026-03-06 15:11:36 +08:00
parent 98098dc911
commit 124ef0e5ce
41 changed files with 5238 additions and 2228 deletions

View File

@@ -0,0 +1,193 @@
# 架构设计
本文档说明 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
```
### 依赖原则
- **单向依赖**:入口 → 验证/渲染 → 核心 ← 加载
- **无循环依赖**
- **核心层不依赖**其他业务模块
- **验证层可以调用**核心层的元素验证方法
## 模块概览
### 入口层 (yaml2pptx.py)
- **职责**CLI 参数解析、流程编排
- **包含**
- `/// script` 依赖声明
- `parse_args()` - 命令行参数解析
- `main()` - 主流程编排
- `handle_convert()` - 转换流程
- **不包含**:业务逻辑、数据处理
### 工具层 (utils/)
- **职责**:通用工具函数
- **包含**
- 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- 颜色转换:`hex_to_rgb()`, `validate_color()`
### 加载层 (loaders/)
- **职责**YAML 文件加载和验证
- **包含**
- `YAMLError` - 自定义异常
- `load_yaml_file()` - 加载 YAML 文件
- `validate_presentation_yaml()` - 验证演示文稿结构
### 核心层 (core/)
#### elements.py
- **职责**:定义元素数据类和工厂函数
- **包含**
- `FontConfig` - 字体配置数据类
- `TextElement`, `ImageElement`, `ShapeElement`, `TableElement`
- `create_element()` - 元素工厂函数
#### template.py
- **职责**:模板加载和变量解析
- **包含**
- `Template`
- 变量解析:`resolve_value()`, `resolve_element()`
- 条件渲染:`evaluate_condition()`
- 模板渲染:`render()`
#### condition_evaluator.py
- **职责**:安全地评估条件表达式
- **包含**
- `ConditionEvaluator`
- 使用 simpleeval 库进行安全评估
#### presentation.py
- **职责**:演示文稿管理和幻灯片渲染
- **包含**
- `Presentation`
- 内联模板存储和查找
- 幻灯片渲染:`render_slide()`
### 验证层 (validators/)
- **职责**YAML 文件验证
- **包含**
- `ValidationIssue`, `ValidationResult` - 验证结果结构
- `GeometryValidator` - 几何验证器
- `ResourceValidator` - 资源验证器
- `Validator` - 主验证器
### 渲染层 (renderers/)
#### pptx_renderer.py
- **职责**PPTX 文件生成
- **包含**
- `PptxGenerator`
- 渲染方法:`_render_text()`, `_render_image()`, `_render_shape()`, `_render_table()`
#### html_renderer.py
- **职责**HTML 预览渲染
- **包含**
- `HtmlRenderer`
- 渲染方法:`render_text()`, `render_image()`, `render_shape()`, `render_table()`
### 预览层 (preview/)
- **职责**:浏览器预览和热重载
- **包含**
- Flask 应用:`create_flask_app()`
- 文件监听:`YAMLChangeHandler`
- 预览服务器:`start_preview_server()`
## 技术决策概要
### 1. 元素抽象层使用 dataclass
- **理由**:简洁性、类型提示、创建时验证
- **实现**`@dataclass` 装饰器 + `__post_init__` 验证
### 2. 创建时验证
- **理由**:尽早失败、清晰错误位置
- **实现**:在 `__post_init__` 中进行验证
### 3. 验证职责分层
- **元素级验证**:放在元素类本身
- **系统级验证**:放在独立的验证器模块
### 4. 模板系统架构
- **内联模板**:在源文件中定义,适合简单场景
- **外部模板库**:独立文件,适合复用和共享
## 相关文档
- [模块详解](modules/) - 各模块详细说明
- [开发规范](development-guide.md) - 编码规范和约定
- [扩展指南](extending.md) - 添加新功能
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,132 @@
# 开发规范
本文档说明 yaml2pptx 项目的开发规范和约定。
## 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`
## 命令行接口
### 子命令架构
```bash
# check - 验证 YAML 文件
uv run yaml2pptx.py check <input> [--template <path>]
# convert - 转换为 PPTX
uv run yaml2pptx.py convert <input> [output] [--template <path>] [--skip-validation] [--force]
# preview - 启动预览服务器
uv run yaml2pptx.py preview <input> [--template <path>] [--port <port>] [--host <host>] [--no-browser]
```
### 参数说明
- `--template`:指定模板库文件路径
- `--skip-validation`convert 专用,跳过自动验证
- `--force/-f`convert 专用,强制覆盖已存在文件
- `--port`preview 专用,指定端口
- `--host`preview 专用,指定主机地址
- `--no-browser`preview 专用,不自动打开浏览器
## 文件组织
### 代码文件
- 每个模块文件控制在 150-300 行
- 入口脚本约 200 行
- 使用有意义的文件名和目录结构
### 测试文件
- 所有测试文件、临时文件必须放在 `temp/` 目录下
- 不污染项目根目录
## 代码风格
### 导入顺序
```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. **面向中文开发者**:注释、文档、错误消息使用中文
2. **使用 uv 运行**:严禁直接使用主机环境的 python
3. **测试文件隔离**:所有测试文件放在 `temp/` 目录
4. **不污染主机环境**:不修改主机的 Python 配置
## 验证容忍度
几何验证时,允许 0.1 英寸的容忍度:
```python
TOLERANCE = 0.1 # 英寸
if right > slide_width + TOLERANCE:
# 报告 WARNING
```
## 最佳实践
### 模块职责
- 每个模块职责明确,单一功能
- 模块间通过清晰接口通信
- 避免循环依赖
### 错误处理
- 使用自定义异常类(如 `YAMLError`
- 提供清晰的错误信息
- 包含错误位置(文件、行号、元素)
### 日志
- 使用统一的日志函数(`log_info()`, `log_success()`, `log_error()`
- 显示友好的中文错误信息
## 相关文档
- [架构设计](architecture.md) - 代码结构
- [扩展指南](extending.md) - 添加新功能
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,280 @@
# 扩展指南
本文档说明如何扩展 yaml2pptx 的功能。
## 添加新元素类型
假设要添加 `VideoElement`
### 1. 在 core/elements.py 中定义数据类
```python
from dataclasses import dataclass, field
@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')
if elem_type == 'text':
return TextElement(**elem_dict)
elif elem_type == 'image':
return ImageElement(**elem_dict)
# ... 其他类型 ...
elif elem_type == 'video':
return VideoElement(**elem_dict)
else:
raise ValueError(f"Unknown element type: {elem_type}")
```
### 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):
"""实现视频渲染逻辑"""
movie = slide.shapes.add_movie(
str(Path(base_path) / elem.src),
left=Inches(elem.box[0]),
top=Inches(elem.box[1]),
width=Inches(elem.box[2]),
height=Inches(elem.box[3])
)
if elem.autoplay:
movie.click.action = pp.action.Action(pyppote.xmlns.namespace('p').MSO_ANIMATION_VIDEO_CLICK)
```
### 4. 在 HtmlRenderer 中实现渲染方法
```python
def render_slide(self, slide_data, index, base_path=None):
elements_html = ""
for elem in slide_data:
# ... 其他类型 ...
elif isinstance(elem, VideoElement):
elements_html += self.render_video(elem, base_path)
return self.SLIDE_TEMPLATE.format(content=elements_html)
def render_video(self, elem: VideoElement, base_path):
"""实现 HTML 视频渲染"""
src_path = str(Path(base_path) / elem.src) if base_path else elem.src
autoplay_attr = "autoplay" if elem.autoplay else ""
return f'''
<video src="{src_path}" {autoplay_attr}
style="position:absolute; left:{elem.box[0]*96}px; top:{elem.box[1]*96}px;
width:{elem.box[2]*96}px; height:{elem.box[3]*96}px;">
</video>
'''
```
### 5. 更新验证器
如果需要验证视频文件:
```python
# validators/resource.py
def validate_video(self, src, slide_index, elem_index):
"""检查视频文件是否存在"""
video_path = self.base_dir / src
if not video_path.exists():
return ValidationIssue(
level="ERROR",
message=f"视频文件不存在: {src}",
location=f"[幻灯片 {slide_index + 1}, 元素 {elem_index + 1}]",
code="VIDEO_FILE_NOT_FOUND"
)
return None
```
## 添加新渲染器
假设要添加 PDF 渲染器:
### 1. 创建 renderers/pdf_renderer.py
```python
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
class PdfRenderer:
def __init__(self, size="16:9"):
# 初始化 PDF 库
self.size = size
# ...
def add_slide(self, slide_data, base_path=None):
"""添加页面"""
# 实现...
pass
def _render_element(self, page, elem, base_path):
"""渲染元素到 PDF 页面"""
if isinstance(elem, TextElement):
self._render_text(page, elem)
elif isinstance(elem, ImageElement):
self._render_image(page, elem, base_path)
elif isinstance(elem, ShapeElement):
self._render_shape(page, elem)
elif isinstance(elem, TableElement):
self._render_table(page, elem)
def _render_text(self, page, elem):
"""渲染文本到 PDF"""
# 实现...
pass
def _render_image(self, page, elem, base_path):
"""渲染图片到 PDF"""
# 实现...
pass
def _render_shape(self, page, elem):
"""渲染形状到 PDF"""
# 实现...
pass
def _render_table(self, page, elem):
"""渲染表格到 PDF"""
# 实现...
pass
def save(self, output_path):
"""保存 PDF 文件"""
# 实现...
pass
```
### 2. 在 yaml2pptx.py 中添加 PDF 模式
```python
from renderers.pdf_renderer import PdfRenderer
def main():
# ... 解析参数 ...
if args.pdf:
# PDF 生成模式
generator = PdfRenderer(size=args.size)
# ... 渲染逻辑
```
### 3. 添加命令行参数
```python
def parse_args():
parser = argparse.ArgumentParser(description='YAML to PPTX converter')
subparsers = parser.add_subparsers(dest='command', help='子命令')
# ... 其他命令 ...
# PDF 命令
pdf_parser = subparsers.add_parser('pdf', help='生成 PDF')
pdf_parser.add_argument('input', help='输入的 YAML 文件')
pdf_parser.add_argument('output', help='输出的 PDF 文件', nargs='?')
pdf_parser.add_argument('--template', help='模板库文件路径')
pdf_parser.add_argument('--size', default='16:9', choices=['16:9', '4:3'])
return parser.parse_args()
```
## 添加新的验证规则
### 1. 在 validators/ 中创建新的验证器
```python
# validators/custom.py
class CustomValidator:
def __init__(self):
pass
def validate(self, presentation):
"""执行自定义验证"""
issues = []
# 验证逻辑
return issues
```
### 2. 在主验证器中集成
```python
# validators/validator.py
class Validator:
def __init__(self, ...):
# ...
self.custom_validator = CustomValidator()
def validate_presentation(self, presentation):
# ...
# 调用自定义验证器
custom_issues = self.custom_validator.validate(presentation)
result.infos.extend(custom_issues)
```
## 测试新功能
### 1. 创建测试文件
```python
# tests/unit/test_video_element.py
import pytest
from core.elements import VideoElement, create_element
def test_create_video_element():
elem_dict = {
'type': 'video',
'src': 'test.mp4',
'box': [1, 1, 4, 3],
'autoplay': True
}
elem = create_element(elem_dict)
assert isinstance(elem, VideoElement)
assert elem.autoplay is True
def test_video_element_without_src():
with pytest.raises(ValueError, match="必须指定 src"):
VideoElement(src='', box=[1, 1, 4, 3])
```
### 2. 运行测试
```bash
uv run pytest tests/unit/test_video_element.py -v
```
## 提交变更
1. 更新相关文档
2. 添加测试
3. 运行完整测试套件
4. 提交 Pull Request
## 相关文档
- [架构设计](architecture.md) - 代码结构
- [Elements 模块](modules/elements.md) - 元素抽象层
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,144 @@
# 字体系统实现
字体解析和继承链处理的实现。
## 职责
- 字体引用解析
- 继承链处理
- 预设类别映射
- 循环引用检测
## PRESET_FONT_MAPPING
预设字体类别映射常量:
```python
PRESET_FONT_MAPPING = {
'sans': 'Arial',
'serif': 'Times New Roman',
'mono': 'Courier New',
'cjk-sans': 'Microsoft YaHei',
'cjk-serif': 'SimSun',
}
```
## FontResolver 类
### 初始化
```python
class FontResolver:
def __init__(self, fonts, fonts_default, scope="document", template_fonts=None):
"""
Args:
fonts: 当前作用域的字体字典
fonts_default: 当前作用域的默认字体
scope: 作用域标识 ("document""template")
template_fonts: 模板库字体字典(仅文档作用域需要)
"""
```
### resolve_font()
解析字体配置的主入口:
```python
def resolve_font(self, font_config):
"""解析字体配置(主入口)"""
if font_config is None:
return self._get_default_config(set())
if isinstance(font_config, str):
# "@xxx" 格式
return self._resolve_reference(font_config.strip('@'), set())
if isinstance(font_config, dict):
if 'parent' in font_config:
# {parent: "@xxx", ...} 格式
return self._resolve_font_dict(font_config, set())
else:
# 独立定义
return FontConfig(**font_config)
```
### _resolve_reference()
解析字体引用:
```python
def _resolve_reference(self, reference, visited):
"""解析字体引用"""
if reference in visited:
raise ValueError(f"检测到字体引用循环: {' -> '.join(visited + [reference])}")
visited.add(reference)
# 检查作用域
if reference in self.fonts:
font_data = self.fonts[reference]
elif self.template_fonts and reference in self.template_fonts:
font_data = self.template_fonts[reference]
else:
raise ValueError(f"字体配置不存在: @{reference}")
return self._build_font_config(font_data, visited)
```
### _resolve_font_dict()
解析字体字典:
```python
def _resolve_font_dict(self, font_dict, visited):
"""解析字体字典"""
parent_ref = font_dict.get('parent', '').strip('@')
if parent_ref:
# 先解析父级
parent_config = self._resolve_reference(parent_ref, visited.copy())
# 合并当前属性
return self._merge_with_parent(font_dict, parent_config)
# 独立定义
return FontConfig(**font_dict)
```
## 字体引用解析逻辑
1. 如果 `font_config` 为 None使用 `fonts_default`
2. 如果是字符串(`"@xxx"`),解析为整体引用
3. 如果是字典且包含 `parent`,先解析 `parent` 再覆盖当前属性
4. 如果是字典且不包含 `parent`,直接使用字典属性
5. 未定义的属性从 `fonts_default` 继承
6. 如果 `family` 是预设类别,映射到具体字体名称
7. 返回完整的 `FontConfig` 对象
## 继承链
属性继承顺序:
```
parent → 当前属性 → fonts_default → 系统默认
```
## 循环引用检测
- 维护已访问集合 `visited`
- 检测重复引用
- 最大引用深度限制10 层
## 预设类别映射
`family` 字段中识别预设类别名称:
```python
if family in PRESET_FONT_MAPPING:
family = PRESET_FONT_MAPPING[family]
```
## 相关文档
- [作用域系统](scope-system.md) - 字体作用域规则
- [字体主题系统](../../fonts.md) - 用户指南
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,172 @@
# 维护指南
本文档提供 yaml2pptx 项目的维护指南。
## 代码审查要点
在审查代码时,检查以下要点:
### 结构和设计
- [ ] 模块文件大小合理150-300 行)
- [ ] 无循环依赖
- [ ] 单一职责原则
- [ ] 清晰的接口和抽象
### 代码质量
- [ ] 所有类和函数有文档字符串
- [ ] 使用中文注释
- [ ] 元素验证在 `__post_init__` 中完成
- [ ] 导入语句按标准库、第三方库、本地模块排序
### 测试
- [ ] 有相应的测试用例
- [ ] 测试覆盖率足够
- [ ] 测试文件在 `tests/` 目录下
### 约束检查
- [ ] 使用 `uv run` 运行脚本
- [ ] 测试文件在 `temp/` 目录
- [ ] 面向中文开发者
- [ ] 不污染主机环境
## 性能优化建议
### 1. 模板缓存
Presentation 类已实现模板缓存,避免重复加载:
```python
def get_template(self, template_name):
if template_name not in self.template_cache:
self.template_cache[template_name] = self._load_template(template_name)
return self.template_cache[template_name]
```
### 2. 元素验证
只在创建时验证一次,渲染时不再验证:
```python
# 创建时验证
elem = TextElement(**elem_dict) # __post_init__ 验证
# 渲染时直接使用,不重复验证
self._render_text(slide, elem)
```
### 3. 文件监听
预览模式使用 watchdog 高效监听文件变化:
```python
class YAMLChangeHandler(FileSystemEventHandler):
def on_modified(self, event):
if event.src_path.endswith('.yaml'):
# 重新加载和渲染
```
## 代码质量工具
### 格式化
使用 Black 格式化 Python 代码:
```bash
uv run black .
```
### Linting
使用 Pylint 检查代码质量:
```bash
uv run pylint core/ loaders/ renderers/ validators/
```
### 类型检查
使用 mypy 进行类型检查:
```bash
uv run mypy core/
```
## 常见维护任务
### 添加新的依赖
1.`pyproject.toml` 中添加依赖
2. 更新 `README.md` 的依赖项列表
3. 运行测试确保兼容性
```toml
[project]
dependencies = [
"python-pptx>=0.6.21",
"pyyaml>=6.0",
"flask>=3.0",
"watchdog>=3.0",
"new-dependency>=1.0",
]
```
### 更新文档
功能变更后,同步更新以下文档:
- `README.md` - 用户文档
- `README_DEV.md` - 开发文档
- 相关的 `docs/` 子文档
### 版本发布
1. 更新版本号(在 pyproject.toml 中)
2. 更新 CHANGELOG
3. 创建 git tag
4. 发布到 PyPI如果需要
## 调试技巧
### 使用预览模式
```bash
uv run yaml2pptx.py preview temp/test.yaml
```
### 查看详细错误
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
### 使用 Python 调试器
```bash
uv run python -m pdb yaml2pptx.py convert test.yaml
```
## 问题排查
### 常见问题
1. **导入错误**:检查是否使用 `uv run`
2. **依赖缺失**:运行 `uv pip install -e .[dev]`
3. **测试失败**:检查 `temp/` 目录权限
### 获取帮助
- 查看项目 Issues
- 查看 `README.md``README_DEV.md`
- 联系维护者
## 相关文档
- [开发规范](development-guide.md) - 编码规范
- [测试规范](testing.md) - 测试指南
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,18 @@
# 模块详解
本目录包含各代码模块的详细说明。
## 核心模块
- [Elements](elements.md) - 元素抽象层
- [Template](template.md) - 模板系统
- [Validators](validators.md) - 验证器
- [Renderers](renderers.md) - 渲染器
- [Loaders](loaders.md) - 加载器
## 相关文档
- [架构设计](../architecture.md) - 整体代码结构
- [开发规范](../development-guide.md) - 编码规范
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,137 @@
# Elements 模块
`core/elements.py` 定义了所有元素类型的数据类和工厂函数。
## 职责
- 定义元素数据类(使用 `@dataclass`
- 在创建时进行元素级验证
- 提供元素工厂函数
## 包含的内容
### FontConfig 类
字体配置数据类,支持所有字体属性:
```python
@dataclass
class FontConfig:
parent: Optional[str] = None
family: Optional[str] = None
size: Optional[int] = None
bold: Optional[bool] = None
italic: Optional[bool] = None
underline: Optional[bool] = None
strikethrough: Optional[bool] = None
color: Optional[str] = None
align: Optional[str] = None
line_spacing: Optional[float] = None
space_before: Optional[float] = None
space_after: Optional[float] = None
baseline: Optional[str] = None
caps: Optional[str] = None
```
`__post_init__` 中验证 baseline 和 caps 枚举值。
### 元素类
#### TextElement
```python
@dataclass
class TextElement:
type: str = 'text'
content: str = ''
box: list = field(default_factory=lambda: [1, 1, 8, 1])
font: FontConfig | str | dict = field(default_factory=dict)
```
#### ImageElement
```python
@dataclass
class ImageElement:
type: str = 'image'
src: str = ''
box: list = field(default_factory=lambda: [1, 1, 4, 3])
```
#### ShapeElement
```python
@dataclass
class ShapeElement:
type: str = 'shape'
box: list = field(default_factory=lambda: [1, 1, 4, 2])
shape: str = 'rectangle'
fill: str = '#000000'
line: dict = field(default_factory=dict)
```
#### TableElement
```python
@dataclass
class TableElement:
type: str = 'table'
position: list = field(default_factory=lambda: [0, 0])
col_widths: list = field(default_factory=list)
data: list = field(default_factory=list)
font: FontConfig | str | dict = field(default_factory=dict)
header_font: FontConfig | str | dict = field(default_factory=dict)
style: dict = field(default_factory=dict)
```
### create_element() 工厂函数
```python
def create_element(elem_dict: dict):
elem_type = elem_dict.get('type')
if elem_type == 'text':
return TextElement(**elem_dict)
elif elem_type == 'image':
return ImageElement(**elem_dict)
elif elem_type == 'shape':
return ShapeElement(**elem_dict)
elif elem_type == 'table':
return TableElement(**elem_dict)
else:
raise ValueError(f"Unknown element type: {elem_type}")
```
自动将字典形式的 font 转换为 FontConfig 对象。
## 创建时验证
每个元素类在 `__post_init__` 中进行验证:
```python
def __post_init__(self):
# 检查必需字段
if len(self.box) != 4:
raise ValueError("box 必须包含 4 个数字")
# 验证颜色格式
if self.fill and not _is_valid_color(self.fill):
raise ValueError(f"无效的颜色格式: {self.fill}")
```
## 元素级验证职责
- 必需字段检查
- 数据类型检查
- 值的有效性检查:
- 颜色格式验证
- 字体大小合理性
- 枚举值检查(如形状类型)
- 表格数据一致性
## 相关文档
- [Template 模块](template.md) - 模板系统
- [Validators 模块](validators.md) - 验证器详解
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,135 @@
# Loaders 模块
`loaders/yaml_loader.py` 负责 YAML 文件加载和验证。
## 职责
- YAML 文件加载
- 演示文稿结构验证
- 内联模板验证
- 外部模板验证
## 包含的内容
### YAMLError
自定义异常类:
```python
class YAMLError(Exception):
"""YAML 相关错误"""
pass
```
### load_yaml_file()
加载 YAML 文件:
```python
def load_yaml_file(file_path):
"""加载 YAML 文件并返回解析后的数据"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data
except yaml.YAMLError as e:
raise YAMLError(f"YAML 语法错误: {e}")
except FileNotFoundError:
raise YAMLError(f"文件不存在: {file_path}")
```
### validate_presentation_yaml()
验证演示文稿结构:
```python
def validate_presentation_yaml(data, file_path):
"""验证演示文稿 YAML 结构"""
# 检查必需字段
if 'metadata' not in data:
raise YAMLError(f"缺少 metadata 字段: {file_path}")
if 'slides' not in data:
raise YAMLError(f"缺少 slides 字段: {file_path}")
# 验证 metadata
metadata = data['metadata']
if 'size' not in metadata:
raise YAMLError(f"metadata 缺少 size 字段: {file_path}")
if metadata['size'] not in ['16:9', '4:3']:
raise YAMLError(f"无效的 size 值: {metadata['size']}")
# 验证内联模板(如果有)
if 'templates' in data:
validate_templates_yaml(data['templates'])
# 验证幻灯片
for slide in data['slides']:
# 验证 enabled 字段
if 'enabled' in slide:
if not isinstance(slide['enabled'], bool):
raise YAMLError(f"enabled 必须是布尔值: {file_path}")
```
### validate_template_yaml()
验证外部模板结构:
```python
def validate_template_yaml(template_data, template_name):
"""验证外部模板结构"""
if 'elements' not in template_data:
raise YAMLError(f"模板缺少 elements 字段: {template_name}")
if 'vars' in template_data:
for var in template_data['vars']:
if 'name' not in var:
raise YAMLError(f"变量缺少 name 字段: {template_name}")
```
### validate_templates_yaml()
验证内联模板结构:
```python
def validate_templates_yaml(templates):
"""验证内联模板结构"""
if not isinstance(templates, dict):
raise YAMLError("templates 必须是字典类型")
for name, template_data in templates.items():
validate_template_yaml(template_data, f"templates.{name}")
# 检测默认值中引用不存在的变量
vars_def = {v['name']: v for v in template_data.get('vars', [])}
for var in template_data.get('vars', []):
if 'default' in var:
# 检查默认值是否引用了不存在的变量
# 实现省略...
```
## 内联模板验证
内联模板验证包括:
- 结构验证(是否为字典)
- 元素验证(是否有 elements 字段)
- 变量定义验证(是否有 name 字段)
- 默认值验证(检测循环引用)
## enabled 字段验证
验证 `enabled` 字段必须是布尔值:
```python
if 'enabled' in slide:
if not isinstance(slide['enabled'], bool):
raise YAMLError(f"enabled 必须是布尔值,不能是字符串或条件表达式")
```
## 相关文档
- [架构设计](../architecture.md) - 代码结构
- [验证器模块](validators.md) - 验证详解
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,153 @@
# Renderers 模块
`renderers/` 目录包含 PPTX 和 HTML 渲染器。
## 模块组成
### pptx_renderer.py
PPTX 文件生成器:
```python
class PptxGenerator:
def __init__(self, size="16:9"):
self.presentation = Presentation()
self.slide_width = 10 # 英寸
self.slide_height = 5.625 if size == "16:9" else 7.5
def add_slide(self):
"""添加新幻灯片"""
slide = self.presentation.slides.add_slide(self.blank_layout)
return slide
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)
# ...
def _render_text(self, slide, elem):
"""渲染文本元素"""
# 实现...
def _render_image(self, slide, elem, base_path):
"""渲染图片元素"""
# 实现...
def _render_shape(self, slide, elem):
"""渲染形状元素"""
# 实现...
def _render_table(self, slide, elem):
"""渲染表格元素"""
# 实现...
def save(self, output_path):
"""保存 PPTX 文件"""
self.presentation.save(output_path)
```
### html_renderer.py
HTML 预览渲染器:
```python
class HtmlRenderer:
def __init__(self, size="16:9"):
self.slide_width = 960 # 96 DPI * 10"
self.slide_height = 540 if size == "16:9" else 720
def render_slide(self, slide_data, index, base_path=None):
"""渲染幻灯片为 HTML"""
elements_html = ""
for elem in slide_data:
if isinstance(elem, TextElement):
elements_html += self.render_text(elem)
elif isinstance(elem, ImageElement):
elements_html += self.render_image(elem, base_path)
# ...
return self.SLIDE_TEMPLATE.format(
width=self.slide_width,
height=self.slide_height,
content=elements_html
)
def render_text(self, elem):
"""渲染文本为 HTML"""
# 实现...
def render_image(self, elem, base_path):
"""渲染图片为 HTML"""
# 实现...
def render_shape(self, elem):
"""渲染形状为 HTML"""
# 实现...
def render_table(self, elem):
"""渲染表格为 HTML"""
# 实现...
```
## 设计特点
### 渲染器内置在生成器中
- **封装性**:渲染逻辑与生成器紧密相关
- **简单性**:不需要额外的渲染器接口
- **性能**:避免额外的方法调用开销
### 使用 isinstance() 检查类型
```python
if isinstance(elem, TextElement):
self._render_text(slide, elem)
```
### 通过元素对象属性访问数据
```python
def _render_text(self, slide, elem):
text_box = elem.box
content = elem.content
font_config = elem.font
```
## 共享元素抽象层
两个渲染器共享相同的元素数据类:
- `TextElement`
- `ImageElement`
- `ShapeElement`
- `TableElement`
## 单位转换
### HTML 渲染器
使用固定 DPI (96) 进行单位转换:
```python
DPI = 96
pixels = inches * DPI
```
### PPTX 渲染器
python-pptx 使用 Inches 单位:
```python
from pptx.util import Inches
left = Inches(box[0])
```
## 相关文档
- [Elements 模块](elements.md) - 元素数据类
- [预览功能](../../user-guide.md) - 用户指南
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,148 @@
# Template 模块
`core/template.py` 实现了模板系统,支持变量解析、条件渲染和模板渲染。
## 职责
- 模板加载和变量解析
- 条件表达式评估
- 模板渲染
## 包含的内容
### 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
```
#### 变量解析
**resolve_value()** - 解析变量值:
```python
def resolve_value(self, value, vars_values):
"""解析变量值,支持 {varname} 语法"""
if isinstance(value, str) and '{' in value:
# 替换变量
result = value
for var_name, var_value in vars_values.items():
result = result.replace(f'{{{var_name}}}', str(var_value))
return result
return value
```
**resolve_element()** - 解析元素中的变量:
```python
def resolve_element(self, element, vars_values):
"""递归解析元素中的所有变量"""
resolved = {}
for key, value in element.items():
if isinstance(value, dict):
resolved[key] = self.resolve_element(value, vars_values)
elif isinstance(value, str):
resolved[key] = self.resolve_value(value, vars_values)
else:
resolved[key] = value
return resolved
```
#### 条件渲染
**evaluate_condition()** - 委托给 ConditionEvaluator
```python
def evaluate_condition(self, condition, vars_values):
"""评估条件表达式"""
from core.condition_evaluator import ConditionEvaluator
evaluator = ConditionEvaluator()
return evaluator.evaluate(condition, vars_values)
```
#### 模板渲染
**render()** - 渲染模板:
```python
def render(self, vars_values):
"""渲染模板,返回解析后的元素列表"""
elements = []
for elem in self.elements:
# 检查条件渲染
if 'visible' in elem:
if not self.evaluate_condition(elem['visible'], vars_values):
continue
# 解析变量
resolved_elem = self.resolve_element(elem, vars_values)
elements.append(resolved_elem)
return elements
```
## 特点
### 支持两种模板方式
- **外部模板**:从文件加载
- **内联模板**:从字典创建(通过 `from_data()`
### 变量替换
支持 `{varname}` 语法的变量替换:
```yaml
templates:
title-slide:
vars:
- name: title
elements:
- type: text
content: "{title}" # 变量替换
```
### 条件渲染
支持 `visible` 属性的条件表达式:
```yaml
elements:
- type: text
content: "有数据"
visible: "{count > 0}"
```
## 内联模板支持
`from_data()` 类方法使模板可以从字典创建:
```python
# 从内联模板定义创建
template = Template.from_data(
template_data=inline_template_dict,
template_name="title-slide",
base_dir=document_base_dir
)
```
## 相关文档
- [条件评估器](../condition-rendering.md) - 条件表达式语法
- [Elements 模块](elements.md) - 元素数据类
- [内联模板](../../templates/inline.md) - 用户指南
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,112 @@
# Validators 模块
`validators/` 目录包含所有验证相关的代码。
## 模块组成
### validators/result.py
验证结果数据结构:
```python
@dataclass
class ValidationIssue:
level: str # ERROR/WARNING/INFO
message: str
location: str = ""
code: str = ""
@dataclass
class ValidationResult:
errors: List[ValidationIssue]
warnings: List[ValidationIssue]
infos: List[ValidationIssue]
```
### validators/geometry.py
几何验证器,检查元素边界:
```python
class GeometryValidator:
def __init__(self, slide_width, slide_height):
self.slide_width = slide_width
self.slide_height = slide_height
self.tolerance = 0.1 # 英寸
def validate_element(self, element, slide_index, elem_index):
"""检查元素是否在页面范围内"""
# 检查边界
# 支持容忍度
```
### validators/resource.py
资源验证器,检查文件存在性:
```python
class ResourceValidator:
def __init__(self, base_dir):
self.base_dir = Path(base_dir)
def validate_image(self, src, slide_index, elem_index):
"""检查图片文件是否存在"""
# 检查文件路径
# 验证文件存在
```
### validators/validator.py
主验证器,协调所有子验证器:
```python
class Validator:
def __init__(self, slide_width, slide_height, base_dir):
self.geometry_validator = GeometryValidator(slide_width, slide_height)
self.resource_validator = ResourceValidator(base_dir)
def validate_presentation(self, presentation):
"""验证整个演示文稿"""
# 遍历所有幻灯片和元素
# 调用子验证器
```
## 验证职责分层
### 元素级验证
在元素类中完成:
- 必需字段检查
- 数据类型检查
- 值的有效性检查
### 系统级验证
在验证器中完成:
- 几何验证(需要页面尺寸)
- 资源验证(需要文件路径)
- 跨元素验证
## 验证容忍度
几何验证时,允许 0.1 英寸的容忍度:
```python
TOLERANCE = 0.1 # 英寸
if right > slide_width + TOLERANCE:
# 报告 WARNING
```
## 分级错误报告
- **ERROR**:阻止转换的严重问题
- **WARNING**:影响视觉效果的问题
- **INFO**:优化建议
## 相关文档
- [Elements 模块](elements.md) - 元素级验证
- [开发规范](../development-guide.md) - 验证职责
[返回开发文档索引](../README.md)

View File

@@ -0,0 +1,174 @@
# 作用域系统
字体作用域系统实现了文档和模板库之间的字体隔离和跨域引用控制。
## 作用域定义
系统定义了两个字体作用域:
### 文档作用域document
- 包含文档的 `metadata.fonts` 中定义的字体
- 文档的 `fonts_default` 只能引用文档作用域的字体
- 文档元素可以引用文档作用域和模板库作用域的字体
### 模板库作用域template
- 包含模板库的 `metadata.fonts` 中定义的字体
- 模板库的 `fonts_default` 只能引用模板库作用域的字体
- 模板元素只能引用模板库作用域的字体(不能引用文档字体)
## 跨域引用规则
### 允许的引用
- 文档元素 → 文档字体
- 文档元素 → 模板库字体(跨域引用)
- 模板元素 → 模板库字体
- 文档 fonts_default → 文档字体
- 模板库 fonts_default → 模板库字体
### 禁止的引用
- 模板元素 → 文档字体(跨域引用被禁止)
- 模板库 fonts_default → 文档字体(跨域引用被禁止)
- 文档 fonts_default → 模板库字体(跨域引用被禁止)
### 设计理由
- 模板库应该是自包含的,不依赖特定文档的字体配置
- 文档可以引用模板库字体,实现样式复用
- 防止模板库与文档之间的紧耦合
## FontResolver 实现
### 初始化
```python
class FontResolver:
def __init__(self, fonts, fonts_default, scope="document", template_fonts=None):
"""
Args:
scope: 作用域标识 ("document""template")
template_fonts: 模板库字体字典(仅文档作用域需要)
"""
```
### 跨域引用检测
- 使用作用域标签(`doc.@font-name``template.@font-name`)追踪引用路径
- 检测跨域循环引用(如 `doc.@a → template.@b → doc.@a`
-`parent` 引用时根据作用域限制跨域访问
## fonts_default 级联规则
当元素未指定字体时,按以下顺序查找默认字体:
### 模板元素
1. 模板库的 `fonts_default`(如果存在)
2. 文档的 `fonts_default`(如果存在)
3. 系统默认字体
### 文档元素
1. 文档的 `fonts_default`(如果存在)
2. 系统默认字体
## 循环引用检测
### 单域内循环
```yaml
fonts:
a:
parent: "@b"
b:
parent: "@a"
```
错误信息:`检测到字体引用循环: doc.@a -> doc.@b -> doc.@a`
### 跨域循环
```yaml
# 文档
fonts:
doc-font:
parent: "@template-font"
# 模板库
fonts:
template-font:
parent: "@doc-font" # 这会被禁止
```
错误信息:`检测到跨域字体引用循环: doc.@doc-font -> template.@template-font -> doc.@doc-font`
## 错误代码
### 模板库 metadata 相关
- `TEMPLATE_LIBRARY_MISSING_METADATA` - 模板库缺少 metadata 字段
- `TEMPLATE_LIBRARY_METADATA_MISSING_SIZE` - 模板库 metadata 缺少 size 字段
- `TEMPLATE_LIBRARY_METADATA_INVALID_SIZE` - 模板库 metadata.size 值无效
### 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
# presentation.yaml文档
metadata:
size: "16:9"
slides:
- 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" # 模板元素不能引用文档字体
```
## 相关文档
- [字体系统实现](font-system.md) - FontResolver 实现
- [字体主题系统](../../fonts.md) - 用户指南
[返回开发文档索引](../README.md)

267
docs/development/testing.md Normal file
View File

@@ -0,0 +1,267 @@
# 测试规范
本文档说明 yaml2pptx 项目的测试框架和规范。
## 测试框架
项目使用 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
```
### 测试文件位置
- **自动化测试**`tests/` 目录
- **手动测试文件**`temp/` 目录
## 编写测试
### 测试类命名
使用 `Test<ClassName>` 格式:
```python
class TestTextElement:
"""TextElement 测试类"""
pass
```
### 测试方法命名
使用 `test_<what_is_being_tested>` 格式:
```python
def test_create_with_defaults(self):
"""测试使用默认值创建 TextElement"""
pass
def test_invalid_color_raises_error(self):
"""测试无效颜色会引发错误"""
pass
```
### 测试示例
```python
import pytest
from core.elements import TextElement, FontConfig
class TestTextElement:
"""TextElement 测试类"""
def test_create_with_defaults(self):
"""测试使用默认值创建 TextElement"""
elem = TextElement()
assert elem.type == 'text'
assert elem.content == ''
assert elem.box == [1, 1, 8, 1]
def test_create_with_custom_values(self):
"""测试使用自定义值创建 TextElement"""
elem = TextElement(
content="Test",
box=[2, 2, 6, 1],
font={"size": 24}
)
assert elem.content == "Test"
assert elem.box == [2, 2, 6, 1]
def test_invalid_color_raises_error(self):
"""测试无效颜色会引发错误"""
with pytest.raises(ValueError, match="无效颜色"):
TextElement(font={"color": "red"})
```
## Fixtures
共享 fixtures 定义在 `tests/conftest.py` 中:
```python
import pytest
from pathlib import Path
@pytest.fixture
def temp_dir(tmp_path):
"""临时目录 fixture"""
return tmp_path
@pytest.fixture
def sample_yaml(temp_dir):
"""最小测试 YAML 文件"""
yaml_file = temp_dir / "test.yaml"
yaml_file.write_text("""
metadata:
size: "16:9"
slides:
- elements:
- type: text
box: [1, 1, 8, 1]
content: "Test"
""")
return yaml_file
@pytest.fixture
def sample_image(temp_dir):
"""测试图片 fixture"""
import PIL.Image
img_path = temp_dir / "test.png"
img = PIL.Image.new('RGB', (100, 100), color='red')
img.save(img_path)
return img_path
@pytest.fixture
def pptx_validator():
"""PPTX 验证器 fixture"""
from tests.conftest_pptx import PptxFileValidator
return PptxFileValidator()
```
### 使用 Fixtures
```python
def test_with_fixture(sample_yaml):
"""使用 fixture 的测试"""
assert sample_yaml.exists()
assert sample_yaml.stat().st_size > 0
```
## PPTX 验证
使用 `PptxFileValidator` 验证生成的 PPTX 文件:
```python
def test_pptx_generation(temp_dir, pptx_validator):
"""测试 PPTX 生成"""
from core.presentation import Presentation
from renderers.pptx_renderer import PptxGenerator
# 生成 PPTX
yaml_path = temp_dir / "test.yaml"
output_path = temp_dir / "output.pptx"
# ... 创建演示文稿 ...
# 验证文件
assert pptx_validator.validate_file(output_path) is True
# 验证内容
from pptx import Presentation as PPTX
prs = PPTX(str(output_path))
assert len(prs.slides) == 1
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 ./templates.yaml
# 转换 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 ./templates.yaml
# 启动预览服务器
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
```
## 测试覆盖率
目标测试覆盖率:>80%
```bash
# 生成覆盖率报告
uv run pytest --cov=. --cov-report=html
# 查看报告
open htmlcov/index.html
```
## 相关文档
- [开发规范](development-guide.md) - 编码规范
- [扩展指南](extending.md) - 添加新功能
[返回开发文档索引](../README.md)