1
0
Files
PPTX/README_DEV.md
lanyuanxiaoyao 01eacb0b97 feat: 添加内联模板支持
支持在 YAML 源文件中直接定义模板,无需单独的模板文件。
简化单文档编写流程,降低模板系统使用门槛。

核心功能:
- 在 YAML 顶层新增 templates 字段定义内联模板
- 支持变量替换、条件渲染、默认值等完整模板功能
- 内联模板优先于外部模板查找
- 同名冲突检测:禁止内联和外部模板同名
- 相互引用检测:禁止内联模板之间相互引用
- 完整的错误处理和验证机制

代码变更:
- core/template.py: 新增 from_data() 类方法
- core/presentation.py: 支持内联模板查找和冲突检测
- loaders/yaml_loader.py: 新增 validate_templates_yaml() 验证
- validators/: 扩展验证器支持内联模板

测试:
- 新增 9 个内联模板专项测试
- 修复 1 个变量验证测试
- 所有 333 个测试通过

文档:
- README.md: 添加内联模板使用指南和最佳实践
- README_DEV.md: 说明实现细节和设计决策

完全向后兼容,不使用 templates 字段时行为不变。
2026-03-03 15:59:55 +08:00

24 KiB
Raw Blame History

开发文档

本文档说明 yaml2pptx 项目的代码结构、开发规范和技术决策。

项目概述

yaml2pptx 是一个将 YAML 格式的演示文稿源文件转换为 PPTX 文件的工具,支持模板系统和浏览器预览功能。

代码结构

项目采用模块化架构,按功能职责组织代码:

html2pptx/
├── yaml2pptx.py (200 行)          # 入口脚本CLI + main 函数
├── utils.py (74 行)               # 工具函数(日志、颜色转换)
├── core/                          # 核心领域模型
│   ├── elements.py (200 行)       # 元素抽象层dataclass + validate
│   ├── template.py (191 行)       # 模板系统
│   └── 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() - 主流程编排
  • 不包含:业务逻辑、数据处理

2. utils.py工具层

  • 职责:通用工具函数
  • 包含
    • 日志函数:log_info(), log_success(), log_error(), log_progress()
    • 颜色转换:hex_to_rgb(), validate_color()

3. loaders/yaml_loader.py加载层

  • 职责YAML 文件加载和验证
  • 包含
    • YAMLError - 自定义异常
    • load_yaml_file() - 加载 YAML 文件
    • validate_presentation_yaml() - 验证演示文稿结构,调用 validate_templates_yaml() 验证内联模板
    • validate_template_yaml() - 验证外部模板结构
    • validate_templates_yaml() - 验证内联模板结构templates 字段)
  • 特点
    • 内联模板验证包括:结构验证、元素验证、变量定义验证、默认值验证
    • 检测默认值中引用不存在的变量

4. core/elements.py核心层 - 元素抽象)

  • 职责:定义元素数据类和工厂函数
  • 包含
    • _is_valid_color() - 颜色格式验证工具函数
    • TextElement - 文本元素dataclass + validate
    • ImageElement - 图片元素dataclass + validate
    • ShapeElement - 形状元素dataclass + validate
    • TableElement - 表格元素dataclass + validate
    • create_element() - 元素工厂函数
  • 特点
    • 使用 @dataclass 装饰器
    • __post_init__ 中进行创建时验证
    • validate() 方法中进行元素级验证
    • 元素类负责自身属性的验证(颜色格式、字体大小、枚举值等)

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()
    • 模板渲染:render()
  • 特点
    • 支持外部模板(从文件加载)和内联模板(从字典创建)
    • 内联模板通过 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 错误
    • 内联模板不需要缓存(已在内存中)

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 运行脚本

# 正确
uv run yaml2pptx.py input.yaml output.pptx

# 错误 - 严禁直接使用主机环境的 python
python yaml2pptx.py input.yaml output.pptx

依赖管理

  • 所有依赖在 pyproject.toml[project.dependencies] 中声明
  • uv 会自动安装依赖,无需手动 pip install

2. 命令行接口

子命令架构

# 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-validationconvert 专用,跳过自动验证
  • --force/-fconvert 专用,强制覆盖已存在文件
  • --portpreview 专用,指定端口(默认随机 30000-40000
  • --hostpreview 专用,指定主机地址(默认 127.0.0.1
  • --no-browserpreview 专用,不自动打开浏览器

3. 文件组织

代码文件

  • 每个模块文件控制在 150-300 行
  • 入口脚本约 200 行
  • 使用有意义的文件名和目录结构

测试文件

  • 所有测试文件、临时文件必须放在 temp/ 目录下
  • 不污染项目根目录

4. 代码风格

导入顺序

# 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__ 在创建时自动调用
  • 可扩展性:未来可以添加方法

示例

@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 类中

理由

  • 封装性:渲染逻辑与生成器紧密相关
  • 简单性:不需要额外的渲染器接口
  • 性能:避免额外的方法调用开销

示例

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() 方法中)
    • 颜色格式验证
    • 字体大小合理性
    • 枚举值检查(如形状类型)
    • 表格数据一致性

系统级验证职责

  • 几何验证(元素是否在页面范围内,需要知道页面尺寸)
  • 资源验证(文件是否存在,需要知道文件路径)
  • 跨元素验证(如果未来需要)

示例

# 元素级验证(在元素类中)
@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)在视觉上几乎不可见
  • 避免误报,提升用户体验

实现

TOLERANCE = 0.1  # 英寸

if right > slide_width + TOLERANCE:
    # 报告 WARNING

7. 内联模板系统

决策:支持在 YAML 源文件中定义内联模板,与外部模板系统共存

理由

  • 降低使用门槛:简单场景无需创建单独的模板文件
  • 保持灵活性:复杂场景仍可使用外部模板
  • 向后兼容:不影响现有外部模板功能

实现要点

  1. 模板定义:在 YAML 顶层添加 templates 字段

    templates:
      my-template:
        vars: [...]
        elements: [...]
    
  2. 模板创建:使用 Template.from_data() 类方法

    @classmethod
    def from_data(cls, template_data, template_name):
        """从字典创建模板(内联模板)"""
        obj = cls.__new__(cls)
        obj.data = template_data
        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() 优先查找内联模板

    def get_template(self, template_name):
        # 1. 检查内联模板
        if hasattr(self, 'inline_templates') and template_name in self.inline_templates:
            # 2. 检查同名冲突
            if self._external_template_exists(template_name):
                raise YAMLError(f"模板名称冲突: '{template_name}'")
            return Template.from_data(self.inline_templates[template_name], template_name)
        # 3. 回退到外部模板
        return self._get_external_template(template_name)
    
  4. 同名检测:禁止内联和外部模板同名

    • 显式报错比隐式选择更安全
    • 强制用户明确模板来源
    • 降低调试难度
  5. 限制:禁止内联模板相互引用

    • 降低实现复杂度
    • 内联模板适合简单场景
    • 复杂引用应使用外部模板
  6. 验证validate_templates_yaml() 验证内联模板结构

    • 检查 templates 是否为字典
    • 检查每个模板是否有必需的 elements 字段
    • 检查变量定义是否有必需的 name 字段
    • 检测默认值中引用不存在的变量

扩展指南

添加新元素类型

假设要添加 VideoElement

1. 在 core/elements.py 中定义数据类

@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. 在工厂函数中添加分支

def create_element(elem_dict: dict):
    elem_type = elem_dict.get('type')
    # ... 其他类型 ...
    elif elem_type == 'video':
        return VideoElement(**elem_dict)

3. 在 PptxGenerator 中实现渲染方法

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 中实现渲染方法

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

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 模式

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/              # 测试图片

运行测试

# 安装测试依赖
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> 格式

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 验证器
def test_with_fixture(sample_yaml):
    """使用 fixture 的测试"""
    assert sample_yaml.exists()

PPTX 验证

使用 PptxFileValidator 验证生成的 PPTX 文件:

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

手动测试

# 验证 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] 中添加:

[project]
dependencies = [
    "python-pptx",
    "pyyaml",
    "flask",
    "watchdog",
    "new-dependency",  # 添加新依赖
]

Q: 为什么元素使用 dataclass 而不是普通字典?

A: dataclass 提供:

  1. 类型安全和 IDE 支持
  2. 自动生成的方法(__init__, __repr__
  3. 创建时验证(__post_init__
  4. 更好的可维护性和可扩展性

Q: 如何调试渲染问题?

A: 使用预览模式:

uv run yaml2pptx.py preview temp/test.yaml

在浏览器中查看渲染结果,支持热重载。

项目约束

  1. 面向中文开发者:注释、文档、错误消息使用中文
  2. 使用 uv 运行:严禁直接使用主机环境的 python
  3. 测试文件隔离:所有测试文件放在 temp/ 目录
  4. 不污染主机环境:不修改主机的 Python 配置

维护指南

代码审查要点

  • 模块文件大小合理150-300 行)
  • 无循环依赖
  • 所有类和函数有文档字符串
  • 使用中文注释
  • 元素验证在 __post_init__ 中完成
  • 导入语句按标准库、第三方库、本地模块排序
  • 测试文件在 temp/ 目录下

性能优化建议

  1. 模板缓存Presentation 类已实现模板缓存
  2. 元素验证:只在创建时验证一次,渲染时不再验证
  3. 文件监听:预览模式使用 watchdog 高效监听文件变化