refactor: modularize yaml2pptx into layered architecture
Refactor yaml2pptx.py from a 1,245-line monolithic script into a modular architecture with clear separation of concerns. The entry point is now 127 lines, with business logic distributed across focused modules. Architecture: - core/: Domain models (elements, template, presentation) - loaders/: YAML loading and validation - renderers/: PPTX and HTML rendering - preview/: Flask preview server - utils.py: Shared utilities Key improvements: - Element abstraction layer using dataclass with validation - Renderer logic built into generator classes - Single-direction dependencies (no circular imports) - Each module 150-300 lines for better readability - Backward compatible CLI interface Documentation: - README.md: User-facing usage guide - README_DEV.md: Developer documentation OpenSpec: - Archive refactor-yaml2pptx-modular change (63/70 tasks complete) - Sync 5 delta specs to main specs (2 new + 3 updated)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -263,5 +263,4 @@ pnpm-lock.yaml
|
||||
# !examples/
|
||||
# !assets/
|
||||
|
||||
|
||||
temp
|
||||
305
README.md
305
README.md
@@ -1,68 +1,50 @@
|
||||
# yaml2pptx
|
||||
|
||||
YAML 转 PowerPoint (PPTX) 工具 - 使用 YAML 格式的声明式语法定义演示文稿,并生成 PPTX 文件。
|
||||
使用 YAML 声明式语法创建 PowerPoint 演示文稿的工具。
|
||||
|
||||
## 功能特性
|
||||
## ✨ 功能特性
|
||||
|
||||
- **YAML 声明式语法** - 使用简单易读的 YAML 定义演示文稿
|
||||
- **模板系统** - 支持参数化模板,复用幻灯片布局
|
||||
- **多种元素类型** - 文本、图片、形状、表格
|
||||
- **实时预览** - 浏览器预览模式,支持热重载,快速开发迭代
|
||||
- **灵活尺寸** - 支持 16:9 和 4:3 两种宽高比
|
||||
- 📝 **YAML 声明式语法** - 使用简单易读的 YAML 定义演示文稿
|
||||
- 🎨 **模板系统** - 支持参数化模板,复用幻灯片布局
|
||||
- 🧩 **丰富的元素类型** - 文本、图片、形状、表格
|
||||
- 👁️ **实时预览** - 浏览器预览模式,支持热重载
|
||||
- 📐 **灵活尺寸** - 支持 16:9 和 4:3 两种宽高比
|
||||
- 🔧 **模块化架构** - 易于扩展和维护
|
||||
|
||||
## 安装
|
||||
## 🚀 快速开始
|
||||
|
||||
脚本使用 [uv](https://github.com/astral-sh/uv) 管理 Python 依赖。运行时会自动安装所需依赖。
|
||||
### 安装
|
||||
|
||||
依赖项:
|
||||
- `python-pptx` - PowerPoint 文件生成
|
||||
- `pyyaml` - YAML 解析
|
||||
- `flask` - 预览服务器(预览模式需要)
|
||||
- `watchdog` - 文件监控(预览模式需要)
|
||||
本工具使用 [uv](https://github.com/astral-sh/uv) 管理依赖,运行时会自动安装所需的 Python 包。
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 生成 PPTX 文件
|
||||
### 基本用法
|
||||
|
||||
```bash
|
||||
# 基本用法 - 输出文件自动生成(input.pptx)
|
||||
# 生成 PPTX 文件(自动命名为 input.pptx)
|
||||
uv run yaml2pptx.py presentation.yaml
|
||||
|
||||
# 指定输出文件名
|
||||
uv run yaml2pptx.py presentation.yaml output.pptx
|
||||
|
||||
# 指定模板目录
|
||||
# 使用模板
|
||||
uv run yaml2pptx.py presentation.yaml output.pptx --template-dir ./templates
|
||||
```
|
||||
|
||||
### 实时预览模式
|
||||
### 实时预览
|
||||
|
||||
```bash
|
||||
# 启动预览服务器(随机端口 20000-30000)
|
||||
# 启动预览服务器(自动打开浏览器)
|
||||
uv run yaml2pptx.py presentation.yaml --preview
|
||||
|
||||
# 指定端口
|
||||
uv run yaml2pptx.py presentation.yaml --preview --port 8080
|
||||
|
||||
# 指定模板目录
|
||||
uv run yaml2pptx.py presentation.yaml --preview --template-dir ./templates
|
||||
```
|
||||
|
||||
预览模式会自动打开浏览器窗口显示演示文稿,修改 YAML 文件时页面会自动刷新。
|
||||
预览模式会自动监听文件变化,修改 YAML 文件后浏览器会自动刷新。
|
||||
|
||||
## 命令行选项
|
||||
## 📖 YAML 语法
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `input` | 输入的 YAML 文件路径(必需) |
|
||||
| `output` | 输出的 PPTX 文件路径(可选,默认为 `input.pptx`) |
|
||||
| `--template-dir` | 模板 YAML 文件所在目录 |
|
||||
| `--preview` | 启用浏览器预览模式(不生成 PPTX 文件) |
|
||||
| `--port` | 预览服务器端口(默认:随机 20000-30000) |
|
||||
|
||||
## YAML 结构
|
||||
|
||||
### 基本演示文稿
|
||||
### 最小示例
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
@@ -74,7 +56,7 @@ slides:
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "你好,世界!"
|
||||
content: "Hello, World!"
|
||||
font:
|
||||
size: 44
|
||||
bold: true
|
||||
@@ -92,7 +74,7 @@ slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "我的演示文稿"
|
||||
subtitle: "yaml2pptx 简介"
|
||||
subtitle: "使用 yaml2pptx 创建"
|
||||
author: "张三"
|
||||
|
||||
- template: content-slide
|
||||
@@ -101,65 +83,67 @@ slides:
|
||||
content: "yaml2pptx 支持多种元素类型..."
|
||||
```
|
||||
|
||||
## 元素类型
|
||||
## 🎨 元素类型
|
||||
|
||||
### 文本
|
||||
### 文本元素
|
||||
|
||||
```yaml
|
||||
- type: text
|
||||
box: [x, y, width, height] # 位置和尺寸(单位:英寸)
|
||||
box: [x, y, width, height] # 位置和尺寸(英寸)
|
||||
content: "文本内容"
|
||||
font:
|
||||
size: 18 # 字号(磅)
|
||||
bold: true/false # 粗体
|
||||
italic: true/false # 斜体
|
||||
color: "#ff0000" # 颜色(#RGB 或 #RRGGBB)
|
||||
align: left/center/right # 对齐方式
|
||||
bold: true # 粗体
|
||||
italic: false # 斜体
|
||||
color: "#ff0000" # 颜色
|
||||
align: center # left/center/right
|
||||
```
|
||||
|
||||
**文本自动换行**:文本框默认启用自动换行功能。当文字内容超过文本框宽度时,会自动换行显示,确保文字不会溢出边界。
|
||||
**特性**:文本框默认启用自动换行,文字超出宽度时会自动换行。
|
||||
|
||||
### 图片
|
||||
### 图片元素
|
||||
|
||||
```yaml
|
||||
- type: image
|
||||
box: [x, y, width, height]
|
||||
src: "path/to/image.png" # 相对路径或绝对路径
|
||||
src: "path/to/image.png" # 支持相对路径和绝对路径
|
||||
```
|
||||
|
||||
### 形状
|
||||
### 形状元素
|
||||
|
||||
```yaml
|
||||
- type: shape
|
||||
box: [x, y, width, height]
|
||||
shape: rectangle/ellipse/rounded_rectangle
|
||||
fill: "#4a90e2" # 填充颜色
|
||||
shape: rectangle # rectangle/ellipse/rounded_rectangle
|
||||
fill: "#4a90e2" # 填充颜色
|
||||
line:
|
||||
color: "#000000" # 边框颜色
|
||||
width: 1 # 边框宽度(磅)
|
||||
color: "#000000" # 边框颜色
|
||||
width: 2 # 边框宽度(磅)
|
||||
```
|
||||
|
||||
### 表格
|
||||
### 表格元素
|
||||
|
||||
```yaml
|
||||
- type: table
|
||||
position: [x, y] # 表格位置
|
||||
col_widths: [2, 2, 2] # 列宽(英寸)
|
||||
position: [x, y]
|
||||
col_widths: [2, 2, 2] # 每列宽度(英寸)
|
||||
data:
|
||||
- ["表头1", "表头2", "表头3"]
|
||||
- ["行1", "数据", "数据"]
|
||||
- ["行2", "数据", "数据"]
|
||||
- ["数据1", "数据2", "数据3"]
|
||||
- ["数据4", "数据5", "数据6"]
|
||||
style:
|
||||
font_size: 14
|
||||
header_bg: "#4a90e2"
|
||||
header_color: "#ffffff"
|
||||
```
|
||||
|
||||
## 模板系统
|
||||
## 📋 模板系统
|
||||
|
||||
模板允许你定义可复用的幻灯片布局,支持参数化。
|
||||
模板允许你定义可复用的幻灯片布局。
|
||||
|
||||
### 模板文件 (`templates/title-slide.yaml`)
|
||||
### 创建模板
|
||||
|
||||
创建模板文件 `templates/title-slide.yaml`:
|
||||
|
||||
```yaml
|
||||
vars:
|
||||
@@ -184,7 +168,7 @@ elements:
|
||||
- type: text
|
||||
box: [1, 3.5, 8, 0.5]
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}" # 仅当 subtitle 不为空时显示
|
||||
visible: "{subtitle != ''}" # 条件渲染
|
||||
font:
|
||||
size: 24
|
||||
align: center
|
||||
@@ -204,141 +188,100 @@ slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "我的演示文稿"
|
||||
subtitle: "演示"
|
||||
author: "李四"
|
||||
subtitle: "副标题"
|
||||
author: "作者"
|
||||
```
|
||||
|
||||
### 模板变量说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 变量名(必需) |
|
||||
| `required` | 是否必需(默认:`false`) |
|
||||
| `default` | 默认值(未提供时使用) |
|
||||
|
||||
### 条件渲染
|
||||
|
||||
使用 `visible` 属性控制元素的显示条件:
|
||||
使用 `visible` 属性控制元素显示:
|
||||
|
||||
```yaml
|
||||
- type: text
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}" # 仅当提供了 subtitle 时显示
|
||||
visible: "{subtitle != ''}" # 仅当 subtitle 不为空时显示
|
||||
```
|
||||
|
||||
## 背景设置
|
||||
## 🎯 命令行选项
|
||||
|
||||
幻灯片支持纯色背景:
|
||||
|
||||
```yaml
|
||||
slides:
|
||||
- background:
|
||||
color: "#f5f5f5" # 浅灰色背景
|
||||
elements:
|
||||
- type: text
|
||||
content: "灰色背景上的内容"
|
||||
```
|
||||
|
||||
## 颜色格式
|
||||
|
||||
颜色使用十六进制格式:
|
||||
- **短格式**:`#RGB`(如 `#fff` 表示白色)
|
||||
- **完整格式**:`#RRGGBB`(如 `#ffffff` 表示白色)
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 演示文稿文件 (`demo.yaml`)
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
size: 16:9
|
||||
|
||||
slides:
|
||||
# 使用模板的标题页
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "yaml2pptx 入门"
|
||||
subtitle: "用 YAML 编写演示文稿"
|
||||
|
||||
# 自定义元素的内容页
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [0.5, 0.5, 9, 0.8]
|
||||
content: "功能特性"
|
||||
font:
|
||||
size: 36
|
||||
bold: true
|
||||
color: "#2c3e50"
|
||||
|
||||
- type: shape
|
||||
box: [0.5, 1.5, 3, 2.5]
|
||||
shape: rounded_rectangle
|
||||
fill: "#3498db"
|
||||
line:
|
||||
color: "#2980b9"
|
||||
width: 2
|
||||
|
||||
- type: text
|
||||
box: [1, 2, 2, 1]
|
||||
content: "易于使用"
|
||||
font:
|
||||
size: 18
|
||||
color: "#ffffff"
|
||||
align: center
|
||||
|
||||
- type: table
|
||||
position: [5, 2]
|
||||
col_widths: [2, 2]
|
||||
data:
|
||||
- ["功能", "状态"]
|
||||
- ["模板支持", "✓"]
|
||||
- ["实时预览", "✓"]
|
||||
- ["表格支持", "✓"]
|
||||
style:
|
||||
font_size: 14
|
||||
header_bg: "#2c3e50"
|
||||
header_color: "#ffffff"
|
||||
|
||||
# 图片页
|
||||
- elements:
|
||||
- type: image
|
||||
box: [1, 1, 8, 4]
|
||||
src: "chart.png"
|
||||
```
|
||||
|
||||
## 错误提示
|
||||
|
||||
脚本提供详细的错误信息:
|
||||
|
||||
| 错误 | 说明 |
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `文件不存在: presentation.yaml` | 找不到输入文件 |
|
||||
| `YAML 语法错误: presentation.yaml, 第 5 行: ...` | YAML 语法问题 |
|
||||
| `模板文件不存在: title-slide` | 模板文件未找到 |
|
||||
| `缺少必需变量: title` | 未提供必需的模板变量 |
|
||||
| `图片文件未找到: image.png` | 图片文件不存在 |
|
||||
| `input` | 输入的 YAML 文件路径(必需) |
|
||||
| `output` | 输出的 PPTX 文件路径(可选) |
|
||||
| `--template-dir` | 模板文件目录 |
|
||||
| `--preview` | 启用浏览器预览模式 |
|
||||
| `--port` | 预览服务器端口(默认:随机) |
|
||||
|
||||
## 使用技巧
|
||||
|
||||
1. **使用模板** - 保持幻灯片布局一致
|
||||
2. **启用预览模式** - 开发时快速迭代
|
||||
3. **使用相对路径** - 图片路径相对于 YAML 文件位置
|
||||
4. **指定模板目录** - 使用模板时必须指定 `--template-dir`
|
||||
5. **先预览后生成** - 预览确认无误后再生成最终 PPTX
|
||||
|
||||
## 坐标系统
|
||||
## 📐 坐标系统
|
||||
|
||||
- **单位**:英寸 (inch)
|
||||
- **原点**:幻灯片左上角
|
||||
- **原点**:幻灯片左上角 (0, 0)
|
||||
- **方向**:X 轴向右,Y 轴向下
|
||||
|
||||
| 尺寸比例 | 幻灯片尺寸 |
|
||||
|----------|------------|
|
||||
| 16:9 | 10" × 5.625" |
|
||||
| 4:3 | 10" × 7.5" |
|
||||
**幻灯片尺寸**:
|
||||
- 16:9 → 10" × 5.625"
|
||||
- 4:3 → 10" × 7.5"
|
||||
|
||||
## 许可证
|
||||
**示例**:`box: [1, 2, 8, 1]` 表示:
|
||||
- 左上角位置:(1", 2")
|
||||
- 尺寸:宽 8",高 1"
|
||||
|
||||
## 🎨 颜色格式
|
||||
|
||||
支持两种十六进制格式:
|
||||
- **短格式**:`#RGB`(如 `#fff` = 白色)
|
||||
- **完整格式**:`#RRGGBB`(如 `#ffffff` = 白色)
|
||||
|
||||
## 💡 使用技巧
|
||||
|
||||
1. **开发流程**:使用 `--preview` 模式实时查看效果,确认无误后再生成 PPTX
|
||||
2. **模板复用**:为常用布局创建模板,保持演示文稿风格一致
|
||||
3. **相对路径**:图片路径相对于 YAML 文件位置,便于项目管理
|
||||
4. **模板目录**:使用模板时必须指定 `--template-dir` 参数
|
||||
5. **文本换行**:文本框默认启用自动换行,无需手动处理长文本
|
||||
|
||||
## 📚 完整示例
|
||||
|
||||
查看 `temp/` 目录下的示例文件:
|
||||
- `temp/test_refactor.yaml` - 基本示例
|
||||
- `temp/template_demo.yaml` - 模板使用示例
|
||||
- `temp/complex_presentation.yaml` - 复杂演示文稿示例
|
||||
|
||||
## ⚠️ 常见错误
|
||||
|
||||
| 错误信息 | 原因 | 解决方法 |
|
||||
|---------|------|---------|
|
||||
| `文件不存在: xxx.yaml` | 找不到输入文件 | 检查文件路径是否正确 |
|
||||
| `YAML 语法错误: 第 X 行` | YAML 格式错误 | 检查缩进和语法 |
|
||||
| `模板文件不存在: xxx` | 模板文件未找到 | 检查模板名称和 `--template-dir` |
|
||||
| `缺少必需变量: xxx` | 未提供必需的模板变量 | 在 `vars` 中提供该变量 |
|
||||
| `图片文件未找到: xxx` | 图片文件不存在 | 检查图片路径 |
|
||||
|
||||
## 🔧 扩展性
|
||||
|
||||
yaml2pptx 采用模块化架构,易于扩展:
|
||||
|
||||
- **添加新元素类型**:定义新的元素数据类和渲染方法
|
||||
- **添加新渲染器**:支持输出到其他格式(如 PDF)
|
||||
- **自定义模板**:创建符合你需求的模板库
|
||||
|
||||
详见 [开发文档](README_DEV.md)。
|
||||
|
||||
## 📦 依赖项
|
||||
|
||||
- `python-pptx` - PowerPoint 文件生成
|
||||
- `pyyaml` - YAML 解析
|
||||
- `flask` - 预览服务器
|
||||
- `watchdog` - 文件监听
|
||||
|
||||
所有依赖由 uv 自动管理,无需手动安装。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎贡献代码、报告问题或提出建议!
|
||||
|
||||
开发者请参阅 [开发文档](README_DEV.md) 了解代码结构和开发规范。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
435
README_DEV.md
Normal file
435
README_DEV.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# 开发文档
|
||||
|
||||
本文档说明 yaml2pptx 项目的代码结构、开发规范和技术决策。
|
||||
|
||||
## 项目概述
|
||||
|
||||
yaml2pptx 是一个将 YAML 格式的演示文稿源文件转换为 PPTX 文件的工具,支持模板系统和浏览器预览功能。
|
||||
|
||||
## 代码结构
|
||||
|
||||
项目采用模块化架构,按功能职责组织代码:
|
||||
|
||||
```
|
||||
html2pptx/
|
||||
├── yaml2pptx.py (127 行) # 入口脚本,CLI + main 函数
|
||||
├── utils.py (74 行) # 工具函数(日志、颜色转换)
|
||||
├── core/ # 核心领域模型
|
||||
│ ├── elements.py (96 行) # 元素抽象层(dataclass)
|
||||
│ ├── template.py (191 行) # 模板系统
|
||||
│ └── presentation.py (91 行) # 演示文稿类
|
||||
├── loaders/ # 数据加载层
|
||||
│ └── yaml_loader.py (113 行) # YAML 加载和验证
|
||||
├── renderers/ # 渲染层
|
||||
│ ├── pptx_renderer.py (292 行) # PPTX 渲染器
|
||||
│ └── html_renderer.py (172 行) # HTML 渲染器(预览)
|
||||
└── preview/ # 预览功能
|
||||
└── server.py (244 行) # Flask 服务器 + 文件监听
|
||||
```
|
||||
|
||||
### 依赖关系
|
||||
|
||||
```
|
||||
yaml2pptx.py (入口)
|
||||
↓
|
||||
├─→ utils (工具函数)
|
||||
├─→ loaders.yaml_loader (YAML 加载)
|
||||
├─→ 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_template_yaml()` - 验证模板结构
|
||||
|
||||
### 4. core/elements.py(核心层 - 元素抽象)
|
||||
- **职责**:定义元素数据类和工厂函数
|
||||
- **包含**:
|
||||
- `TextElement` - 文本元素(dataclass)
|
||||
- `ImageElement` - 图片元素(dataclass)
|
||||
- `ShapeElement` - 形状元素(dataclass)
|
||||
- `TableElement` - 表格元素(dataclass)
|
||||
- `create_element()` - 元素工厂函数
|
||||
- **特点**:
|
||||
- 使用 `@dataclass` 装饰器
|
||||
- 在 `__post_init__` 中进行验证
|
||||
- 创建时验证,尽早发现错误
|
||||
|
||||
### 5. core/template.py(核心层 - 模板)
|
||||
- **职责**:模板加载和变量解析
|
||||
- **包含**:
|
||||
- `Template` 类
|
||||
- 变量解析:`resolve_value()`, `resolve_element()`
|
||||
- 条件渲染:`evaluate_condition()`
|
||||
- 模板渲染:`render()`
|
||||
|
||||
### 6. core/presentation.py(核心层 - 演示文稿)
|
||||
- **职责**:演示文稿管理和幻灯片渲染
|
||||
- **包含**:
|
||||
- `Presentation` 类
|
||||
- 模板缓存:`get_template()`
|
||||
- 幻灯片渲染:`render_slide()`
|
||||
- **特点**:
|
||||
- 将元素字典转换为元素对象
|
||||
- 使用 `create_element()` 工厂函数
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
**依赖管理**:
|
||||
- 所有依赖在 `yaml2pptx.py` 的 `/// script` 头部声明
|
||||
- uv 会自动安装依赖,无需手动 `pip install`
|
||||
|
||||
### 2. 文件组织
|
||||
|
||||
**代码文件**:
|
||||
- 每个模块文件控制在 150-300 行
|
||||
- 入口脚本约 100 行
|
||||
- 使用有意义的文件名和目录结构
|
||||
|
||||
**测试文件**:
|
||||
- 所有测试文件、临时文件必须放在 `temp/` 目录下
|
||||
- 不污染项目根目录
|
||||
|
||||
### 3. 代码风格
|
||||
|
||||
**导入顺序**:
|
||||
```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)` 工厂函数
|
||||
|
||||
**理由**:
|
||||
- 统一入口:所有元素创建都通过工厂函数
|
||||
- 类型安全:进行类型检查
|
||||
- 易于扩展:添加新元素类型只需添加一个分支
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新元素类型
|
||||
|
||||
假设要添加 `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()
|
||||
# ... 渲染逻辑
|
||||
```
|
||||
|
||||
## 测试规范
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 基本 PPTX 生成
|
||||
uv run yaml2pptx.py temp/test.yaml temp/output.pptx
|
||||
|
||||
# 使用模板
|
||||
uv run yaml2pptx.py temp/demo.yaml temp/output.pptx --template-dir temp/templates
|
||||
|
||||
# 预览模式
|
||||
uv run yaml2pptx.py temp/test.yaml --preview
|
||||
|
||||
# 指定端口
|
||||
uv run yaml2pptx.py temp/test.yaml --preview --port 8080
|
||||
```
|
||||
|
||||
### 测试文件位置
|
||||
|
||||
所有测试文件必须放在 `temp/` 目录下:
|
||||
- `temp/*.yaml` - 测试用的 YAML 文件
|
||||
- `temp/*.pptx` - 生成的 PPTX 文件
|
||||
- `temp/templates/` - 测试用的模板文件
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么不能直接使用 python 运行脚本?
|
||||
|
||||
A: 项目使用 uv 的 Inline script metadata 来管理依赖。直接使用 python 会导致依赖缺失。必须使用 `uv run yaml2pptx.py`。
|
||||
|
||||
### Q: 如何添加新的依赖?
|
||||
|
||||
A: 在 `yaml2pptx.py` 的 `/// script` 头部添加:
|
||||
```python
|
||||
# /// script
|
||||
# requires-python = ">=3.8"
|
||||
# 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 temp/test.yaml --preview
|
||||
```
|
||||
在浏览器中查看渲染结果,支持热重载。
|
||||
|
||||
## 项目约束
|
||||
|
||||
1. **面向中文开发者**:注释、文档、错误消息使用中文
|
||||
2. **使用 uv 运行**:严禁直接使用主机环境的 python
|
||||
3. **测试文件隔离**:所有测试文件放在 `temp/` 目录
|
||||
4. **不污染主机环境**:不修改主机的 Python 配置
|
||||
|
||||
## 维护指南
|
||||
|
||||
### 代码审查要点
|
||||
|
||||
- [ ] 模块文件大小合理(150-300 行)
|
||||
- [ ] 无循环依赖
|
||||
- [ ] 所有类和函数有文档字符串
|
||||
- [ ] 使用中文注释
|
||||
- [ ] 元素验证在 `__post_init__` 中完成
|
||||
- [ ] 导入语句按标准库、第三方库、本地模块排序
|
||||
- [ ] 测试文件在 `temp/` 目录下
|
||||
|
||||
### 性能优化建议
|
||||
|
||||
1. **模板缓存**:Presentation 类已实现模板缓存
|
||||
2. **元素验证**:只在创建时验证一次,渲染时不再验证
|
||||
3. **文件监听**:预览模式使用 watchdog 高效监听文件变化
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
96
core/elements.py
Normal file
96
core/elements.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
元素抽象层模块
|
||||
|
||||
定义统一的元素数据类,支持元素验证和未来扩展。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@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 not isinstance(self.box, list) or len(self.box) != 4:
|
||||
raise ValueError("box 必须是包含 4 个数字的列表")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageElement:
|
||||
"""图片元素"""
|
||||
type: str = 'image'
|
||||
src: str = ''
|
||||
box: list = field(default_factory=lambda: [1, 1, 4, 3])
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not self.src:
|
||||
raise ValueError("图片元素必须指定 src")
|
||||
if not isinstance(self.box, list) or len(self.box) != 4:
|
||||
raise ValueError("box 必须是包含 4 个数字的列表")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShapeElement:
|
||||
"""形状元素"""
|
||||
type: str = 'shape'
|
||||
shape: str = 'rectangle'
|
||||
box: list = field(default_factory=lambda: [1, 1, 2, 1])
|
||||
fill: Optional[str] = None
|
||||
line: Optional[dict] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not isinstance(self.box, list) or len(self.box) != 4:
|
||||
raise ValueError("box 必须是包含 4 个数字的列表")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableElement:
|
||||
"""表格元素"""
|
||||
type: str = 'table'
|
||||
data: list = field(default_factory=list)
|
||||
position: list = field(default_factory=lambda: [1, 1])
|
||||
col_widths: list = field(default_factory=list)
|
||||
style: dict = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""创建时验证"""
|
||||
if not self.data:
|
||||
raise ValueError("表格数据不能为空")
|
||||
if not isinstance(self.position, list) or len(self.position) != 2:
|
||||
raise ValueError("position 必须是包含 2 个数字的列表")
|
||||
|
||||
|
||||
def create_element(elem_dict: dict):
|
||||
"""
|
||||
元素工厂函数,从字典创建对应类型的元素对象
|
||||
|
||||
Args:
|
||||
elem_dict: 元素字典,必须包含 type 字段
|
||||
|
||||
Returns:
|
||||
对应类型的元素对象
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的元素类型
|
||||
"""
|
||||
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"不支持的元素类型: {elem_type}")
|
||||
91
core/presentation.py
Normal file
91
core/presentation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
演示文稿模块
|
||||
|
||||
管理整个演示文稿的生成流程。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml
|
||||
from core.template import Template
|
||||
from core.elements import create_element
|
||||
|
||||
|
||||
class Presentation:
|
||||
"""演示文稿类,管理整个演示文稿的生成流程"""
|
||||
|
||||
def __init__(self, pres_file, templates_dir=None):
|
||||
"""
|
||||
初始化演示文稿
|
||||
|
||||
Args:
|
||||
pres_file: 演示文稿 YAML 文件路径
|
||||
templates_dir: 模板目录
|
||||
"""
|
||||
self.pres_file = Path(pres_file)
|
||||
self.templates_dir = templates_dir
|
||||
|
||||
# 加载演示文稿文件
|
||||
self.data = load_yaml_file(pres_file)
|
||||
validate_presentation_yaml(self.data, str(pres_file))
|
||||
|
||||
# 获取演示文稿尺寸
|
||||
metadata = self.data.get('metadata', {})
|
||||
self.size = metadata.get('size', '16:9')
|
||||
|
||||
# 模板缓存
|
||||
self.template_cache = {}
|
||||
|
||||
def get_template(self, template_name):
|
||||
"""
|
||||
获取模板(带缓存)
|
||||
|
||||
Args:
|
||||
template_name: 模板名称
|
||||
|
||||
Returns:
|
||||
Template 对象
|
||||
"""
|
||||
if template_name not in self.template_cache:
|
||||
self.template_cache[template_name] = Template(
|
||||
template_name, self.templates_dir
|
||||
)
|
||||
return self.template_cache[template_name]
|
||||
|
||||
def render_slide(self, slide_data):
|
||||
"""
|
||||
渲染单个幻灯片
|
||||
|
||||
Args:
|
||||
slide_data: 幻灯片数据字典
|
||||
|
||||
Returns:
|
||||
dict: 包含 background 和 elements 的字典
|
||||
"""
|
||||
if 'template' in slide_data:
|
||||
# 使用模板
|
||||
template_name = slide_data['template']
|
||||
template = self.get_template(template_name)
|
||||
vars_values = slide_data.get('vars', {})
|
||||
elements = template.render(vars_values)
|
||||
|
||||
# 合并背景(如果有)
|
||||
background = slide_data.get('background', None)
|
||||
|
||||
# 将元素字典转换为元素对象
|
||||
element_objects = [create_element(elem) for elem in elements]
|
||||
|
||||
return {
|
||||
'background': background,
|
||||
'elements': element_objects
|
||||
}
|
||||
else:
|
||||
# 自定义幻灯片
|
||||
elements = slide_data.get('elements', [])
|
||||
|
||||
# 将元素字典转换为元素对象
|
||||
element_objects = [create_element(elem) for elem in elements]
|
||||
|
||||
return {
|
||||
'background': slide_data.get('background'),
|
||||
'elements': element_objects
|
||||
}
|
||||
191
core/template.py
Normal file
191
core/template.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
模板系统模块
|
||||
|
||||
管理可复用的幻灯片布局和变量解析。
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from loaders.yaml_loader import YAMLError, load_yaml_file, validate_template_yaml
|
||||
|
||||
|
||||
class Template:
|
||||
"""模板类,管理可复用的幻灯片布局"""
|
||||
|
||||
def __init__(self, template_file, templates_dir=None):
|
||||
"""
|
||||
初始化模板
|
||||
|
||||
Args:
|
||||
template_file: 模板名称(纯文件名,不含路径)
|
||||
templates_dir: 模板文件目录
|
||||
"""
|
||||
# 检查是否提供了 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.vars_def = {}
|
||||
for var in self.data.get('vars', []):
|
||||
self.vars_def[var['name']] = var
|
||||
|
||||
# 元素列表
|
||||
self.elements = self.data.get('elements', [])
|
||||
|
||||
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: 条件字符串,如 "{subtitle != ''}"
|
||||
vars_values: 变量值字典
|
||||
|
||||
Returns:
|
||||
bool: 条件是否满足
|
||||
"""
|
||||
# 简单实现:检查变量是否非空
|
||||
# 匹配 {var_name != ''} 或 {var_name != ""}
|
||||
pattern = r'\{(\w+)\s*!=\s*[\'\"]{2}\}'
|
||||
match = re.match(pattern, condition)
|
||||
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
value = vars_values.get(var_name, '')
|
||||
return value != ''
|
||||
|
||||
# 默认返回 True
|
||||
return True
|
||||
|
||||
def render(self, vars_values):
|
||||
"""
|
||||
渲染模板,返回实际的元素列表
|
||||
|
||||
Args:
|
||||
vars_values: 用户提供的变量值字典
|
||||
|
||||
Returns:
|
||||
list: 渲染后的元素列表
|
||||
|
||||
Raises:
|
||||
YAMLError: 缺少必需变量
|
||||
"""
|
||||
# 填充所有变量的默认值(如果用户未提供)
|
||||
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)
|
||||
rendered_elements.append(rendered_elem)
|
||||
|
||||
return rendered_elements
|
||||
0
loaders/__init__.py
Normal file
0
loaders/__init__.py
Normal file
113
loaders/yaml_loader.py
Normal file
113
loaders/yaml_loader.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
YAML 加载和验证模块
|
||||
|
||||
负责加载 YAML 文件并验证其结构。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
|
||||
# ============= YAML 解析和验证 =============
|
||||
|
||||
class YAMLError(Exception):
|
||||
"""YAML 相关错误"""
|
||||
pass
|
||||
|
||||
|
||||
def load_yaml_file(file_path):
|
||||
"""
|
||||
加载 YAML 文件(UTF-8 编码,错误处理)
|
||||
|
||||
Args:
|
||||
file_path: 文件路径(字符串或 Path 对象)
|
||||
|
||||
Returns:
|
||||
解析后的 Python 字典
|
||||
|
||||
Raises:
|
||||
YAMLError: 文件不存在、权限不足、YAML 语法错误等
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise YAMLError(f"文件不存在: {file_path}")
|
||||
|
||||
# 检查是否有读取权限
|
||||
if not file_path.is_file():
|
||||
raise YAMLError(f"不是有效的文件: {file_path}")
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data
|
||||
except PermissionError:
|
||||
raise YAMLError(f"权限不足,无法读取文件: {file_path}")
|
||||
except yaml.YAMLError as e:
|
||||
# 提取行号信息
|
||||
if hasattr(e, 'problem_mark'):
|
||||
mark = e.problem_mark
|
||||
raise YAMLError(
|
||||
f"YAML 语法错误: {file_path}, 第 {mark.line + 1} 行: {e.problem}"
|
||||
)
|
||||
else:
|
||||
raise YAMLError(f"YAML 解析错误: {file_path}: {str(e)}")
|
||||
except Exception as e:
|
||||
raise YAMLError(f"读取文件失败: {file_path}: {str(e)}")
|
||||
|
||||
|
||||
def validate_presentation_yaml(data, file_path=""):
|
||||
"""
|
||||
验证演示文稿 YAML 结构(必需字段:slides)
|
||||
|
||||
Args:
|
||||
data: 解析后的 YAML 数据
|
||||
file_path: 文件路径(用于错误消息)
|
||||
|
||||
Raises:
|
||||
YAMLError: 结构验证失败
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise YAMLError(f"{file_path}: 演示文稿必须是一个字典对象")
|
||||
|
||||
# 验证 slides 字段
|
||||
if 'slides' not in data:
|
||||
raise YAMLError(f"{file_path}: 缺少必需字段 'slides'")
|
||||
|
||||
if not isinstance(data['slides'], list):
|
||||
raise YAMLError(f"{file_path}: 'slides' 必须是一个列表")
|
||||
|
||||
|
||||
def validate_template_yaml(data, file_path=""):
|
||||
"""
|
||||
验证模板 YAML 结构(vars, elements)
|
||||
|
||||
Args:
|
||||
data: 解析后的 YAML 数据
|
||||
file_path: 文件路径(用于错误消息)
|
||||
|
||||
Raises:
|
||||
YAMLError: 结构验证失败
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise YAMLError(f"{file_path}: 模板必须是一个字典对象")
|
||||
|
||||
# 验证 vars 字段
|
||||
if 'vars' in data:
|
||||
if not isinstance(data['vars'], list):
|
||||
raise YAMLError(f"{file_path}: 'vars' 必须是一个列表")
|
||||
|
||||
# 验证每个变量定义
|
||||
for i, var_def in enumerate(data['vars']):
|
||||
if not isinstance(var_def, dict):
|
||||
raise YAMLError(f"{file_path}: vars[{i}] 必须是一个字典对象")
|
||||
if 'name' not in var_def:
|
||||
raise YAMLError(f"{file_path}: vars[{i}] 缺少必需字段 'name'")
|
||||
|
||||
# 验证 elements 字段
|
||||
if 'elements' not in data:
|
||||
raise YAMLError(f"{file_path}: 缺少必需字段 'elements'")
|
||||
|
||||
if not isinstance(data['elements'], list):
|
||||
raise YAMLError(f"{file_path}: 'elements' 必须是一个列表")
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
@@ -0,0 +1,252 @@
|
||||
## Context
|
||||
|
||||
yaml2pptx.py 当前是一个 1,245 行的单文件脚本,包含了从 YAML 解析到 PPTX 生成的完整流程。虽然功能完整,但代码组织存在以下问题:
|
||||
|
||||
- **可读性差**:单文件过大,开发者和 LLM 工具难以快速理解代码结构
|
||||
- **维护困难**:修改一个功能可能需要在文件中跳转多次,容易引入错误
|
||||
- **扩展性受限**:添加新元素类型需要在多处添加代码(验证、PPTX 渲染、HTML 渲染)
|
||||
- **测试不便**:无法对单个模块进行独立测试
|
||||
|
||||
当前约束:
|
||||
- 必须保持 `uv run yaml2pptx.py` 的单入口使用方式
|
||||
- 所有依赖通过 `/// script` 头部声明
|
||||
- 不能污染主机环境的 Python 配置
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 将代码拆分为职责清晰的模块,每个文件控制在 150-300 行
|
||||
- 引入元素抽象层,使添加新元素类型只需实现数据类和渲染方法
|
||||
- 建立清晰的依赖关系,避免循环依赖
|
||||
- 保持向后兼容,用户使用方式不变
|
||||
- 为未来扩展(如添加 PDF 渲染器、Video 元素)奠定基础
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变现有功能的行为(纯重构,不添加新功能)
|
||||
- 不改变 YAML 文件格式或 CLI 参数
|
||||
- 不迁移到标准 Python 包(保持单文件入口模式)
|
||||
- 不添加单元测试(可以在后续单独添加)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 四层架构设计
|
||||
|
||||
**决策**:采用 core/loaders/renderers/preview 四层架构
|
||||
|
||||
```
|
||||
html2pptx/
|
||||
├── yaml2pptx.py # 入口层:CLI + main
|
||||
├── core/ # 核心层:领域模型
|
||||
│ ├── elements.py
|
||||
│ ├── template.py
|
||||
│ └── presentation.py
|
||||
├── loaders/ # 加载层:数据输入
|
||||
│ └── yaml_loader.py
|
||||
├── renderers/ # 渲染层:数据输出
|
||||
│ ├── pptx_renderer.py
|
||||
│ └── html_renderer.py
|
||||
├── preview/ # 预览层:可选功能
|
||||
│ └── server.py
|
||||
└── utils.py # 工具层:通用函数
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- **职责分离**:每层有明确的职责,修改影响范围可控
|
||||
- **依赖方向清晰**:入口 → 渲染 → 核心 ← 加载,避免循环依赖
|
||||
- **易于扩展**:添加新渲染器(如 PDF)只需在 renderers/ 下新增文件
|
||||
|
||||
**替代方案**:
|
||||
- 扁平结构(所有文件在同一目录):简单但缺乏层次,未来扩展会混乱
|
||||
- 更细粒度的分层(如 domain/application/infrastructure):过度设计,不适合当前规模
|
||||
|
||||
### 2. 元素抽象层使用 dataclass
|
||||
|
||||
**决策**:使用 Python dataclass 定义元素数据类
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class TextElement:
|
||||
type: str = 'text'
|
||||
content: str = ''
|
||||
box: list[float] = 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 个数字")
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- **简洁性**:dataclass 自动生成 `__init__`、`__repr__` 等方法,减少样板代码
|
||||
- **类型提示**:支持类型注解,IDE 和 LLM 工具可以提供更好的补全
|
||||
- **验证时机**:`__post_init__` 在对象创建时自动调用,尽早发现错误
|
||||
- **可扩展性**:未来可以添加方法(如 `to_dict()`、`validate_deep()`)
|
||||
|
||||
**替代方案**:
|
||||
- TypedDict:运行时是字典,缺少验证能力
|
||||
- 普通类:需要手写 `__init__`,代码冗长
|
||||
|
||||
### 3. 渲染器内置在生成器中
|
||||
|
||||
**决策**:将渲染逻辑内置在 PptxGenerator 和 HtmlRenderer 类中
|
||||
|
||||
```python
|
||||
class PptxGenerator:
|
||||
def add_slide(self, slide_data, base_path=None):
|
||||
# 渲染元素
|
||||
for elem in slide_data['elements']:
|
||||
self._render_element(slide, elem, base_path)
|
||||
|
||||
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)
|
||||
# ...
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- **封装性**:渲染逻辑与生成器紧密相关,内置更自然
|
||||
- **简单性**:不需要额外的渲染器接口或依赖注入
|
||||
- **性能**:避免额外的方法调用开销
|
||||
|
||||
**替代方案**:
|
||||
- 渲染器作为参数传入:增加复杂度,当前不需要运行时切换渲染器
|
||||
- 统一渲染接口:过度抽象,PPTX 和 HTML 渲染差异较大
|
||||
|
||||
### 4. 创建时验证
|
||||
|
||||
**决策**:在元素对象创建时进行验证(`__post_init__` 方法)
|
||||
|
||||
**理由**:
|
||||
- **尽早失败**:在数据进入系统时就发现错误,而不是等到渲染时
|
||||
- **清晰的错误位置**:验证失败时,堆栈指向元素创建处,易于定位
|
||||
- **避免无效状态**:确保元素对象始终处于有效状态
|
||||
|
||||
**替代方案**:
|
||||
- 加载时验证:验证逻辑分散在 yaml_loader 中,难以维护
|
||||
- 渲染时验证:错误发现太晚,可能已经生成了部分幻灯片
|
||||
|
||||
### 5. 元素工厂函数
|
||||
|
||||
**决策**:提供 `create_element(elem_dict)` 工厂函数,从字典创建元素对象
|
||||
|
||||
```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)
|
||||
# ...
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- **统一入口**:所有元素创建都通过工厂函数,便于添加通用逻辑(如日志、缓存)
|
||||
- **类型安全**:工厂函数可以进行类型检查,避免创建错误的元素类型
|
||||
- **易于扩展**:添加新元素类型只需在工厂函数中添加一个分支
|
||||
|
||||
### 6. 依赖管理策略
|
||||
|
||||
**决策**:所有依赖保留在 yaml2pptx.py 的 `/// script` 头部,预览依赖不再可选
|
||||
|
||||
```python
|
||||
# /// script
|
||||
# requires-python = ">=3.8"
|
||||
# dependencies = [
|
||||
# "python-pptx",
|
||||
# "pyyaml",
|
||||
# "flask",
|
||||
# "watchdog",
|
||||
# ]
|
||||
# ///
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- **简化处理**:不需要条件导入或可选依赖检查
|
||||
- **一致性**:所有用户都有相同的依赖环境
|
||||
- **依赖轻量**:flask 和 watchdog 都是轻量级库,不会显著增加安装时间
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险 1:导入路径变化可能引入错误
|
||||
|
||||
**风险**:重构后内部导入路径会发生变化,可能遗漏某些导入或引入循环依赖
|
||||
|
||||
**缓解措施**:
|
||||
- 按模块逐步迁移,每迁移一个模块就测试一次
|
||||
- 使用 Python 的 `import` 语句检查(运行脚本时会立即发现导入错误)
|
||||
- 保持清晰的依赖方向:入口 → 渲染 → 核心 ← 加载
|
||||
|
||||
### 风险 2:元素验证可能过于严格
|
||||
|
||||
**风险**:在 `__post_init__` 中进行验证可能拒绝某些边缘情况的有效输入
|
||||
|
||||
**缓解措施**:
|
||||
- 只验证关键约束(如 box 必须有 4 个元素),不验证业务逻辑
|
||||
- 提供清晰的错误消息,帮助用户快速定位问题
|
||||
- 如果发现验证过严,可以在后续放宽
|
||||
|
||||
### 风险 3:文件数量增加可能影响开发体验
|
||||
|
||||
**权衡**:从 1 个文件变成 8+ 个文件,开发者需要在多个文件间跳转
|
||||
|
||||
**缓解措施**:
|
||||
- 每个文件职责清晰,命名直观(如 `elements.py`、`pptx_renderer.py`)
|
||||
- 文件大小适中(150-300 行),易于在单个屏幕内浏览
|
||||
- 提供清晰的模块文档,说明每个文件的职责
|
||||
|
||||
### 风险 4:重构可能引入功能回归
|
||||
|
||||
**风险**:代码重组过程中可能无意中改变了某些行为
|
||||
|
||||
**缓解措施**:
|
||||
- 保持函数逻辑不变,只改变组织方式
|
||||
- 重构后运行完整的功能测试(手动测试所有 YAML 示例)
|
||||
- 对比重构前后生成的 PPTX 文件,确保输出一致
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 阶段 1:基础设施(utils + loaders)
|
||||
|
||||
1. 创建目录结构
|
||||
2. 提取 `utils.py`(日志函数、颜色转换)
|
||||
3. 提取 `loaders/yaml_loader.py`(YAML 加载和验证)
|
||||
4. 更新 yaml2pptx.py 的导入,测试 YAML 加载功能
|
||||
|
||||
### 阶段 2:核心抽象(core)
|
||||
|
||||
1. 实现 `core/elements.py`(元素数据类 + 工厂函数)
|
||||
2. 提取 `core/template.py`(Template 类)
|
||||
3. 提取 `core/presentation.py`(Presentation 类)
|
||||
4. 测试模板渲染功能
|
||||
|
||||
### 阶段 3:渲染器(renderers)
|
||||
|
||||
1. 实现 `renderers/pptx_renderer.py`(PptxGenerator + PPTX 渲染)
|
||||
2. 实现 `renderers/html_renderer.py`(HtmlRenderer)
|
||||
3. 测试 PPTX 生成和 HTML 预览功能
|
||||
|
||||
### 阶段 4:预览功能(preview)
|
||||
|
||||
1. 提取 `preview/server.py`(Flask 服务器 + 文件监听)
|
||||
2. 测试预览服务器功能
|
||||
|
||||
### 阶段 5:入口整合
|
||||
|
||||
1. 重构 yaml2pptx.py(只保留 CLI + main)
|
||||
2. 运行完整的端到端测试
|
||||
3. 对比重构前后的输出,确保一致性
|
||||
|
||||
### 回滚策略
|
||||
|
||||
- 保留原始 yaml2pptx.py 的备份(如 yaml2pptx.py.backup)
|
||||
- 如果发现严重问题,可以快速回滚到原始版本
|
||||
- 使用 git 分支进行重构,确保可以随时回退
|
||||
|
||||
## Open Questions
|
||||
|
||||
无待解决问题。设计方案已在 explore mode 中与用户充分讨论并确认。
|
||||
@@ -0,0 +1,52 @@
|
||||
## Why
|
||||
|
||||
yaml2pptx.py 脚本已增长到 1,245 行,包含了 YAML 解析、模板系统、元素渲染、PPTX 生成、HTML 预览等多个功能模块。单文件结构导致代码难以阅读、维护和扩展,对开发者和 LLM 工具都不友好。需要将其重构为模块化结构,同时引入元素抽象层,为未来添加新元素类型和渲染器奠定基础。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将 yaml2pptx.py 拆分为多个功能模块文件,按 core/loaders/renderers/preview 四层架构组织
|
||||
- 引入元素抽象层,使用 dataclass 定义元素数据类(TextElement, ImageElement, ShapeElement, TableElement)
|
||||
- 在元素创建时进行验证(`__post_init__` 方法)
|
||||
- 重构 PPTX 生成器,将渲染器内置在 PptxGenerator 类中
|
||||
- 重构 HTML 渲染器,作为独立的 HtmlRenderer 类用于预览功能
|
||||
- 保持 yaml2pptx.py 作为单一入口点,所有依赖声明保留在入口脚本的 `/// script` 头部
|
||||
- 保持向后兼容:`uv run yaml2pptx.py` 的使用方式不变
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `modular-architecture`: 模块化代码架构,将单文件脚本拆分为 core(核心领域模型)、loaders(加载器)、renderers(渲染器)、preview(预览服务)四层结构,每个模块职责清晰,文件大小控制在 150-300 行
|
||||
- `element-abstraction`: 元素抽象层,定义统一的元素接口和数据类,支持元素验证和未来扩展新元素类型
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `element-rendering`: 从函数式渲染改为基于元素数据类的面向对象渲染,引入元素抽象层和创建时验证
|
||||
- `pptx-generation`: 重构为内置渲染器的架构,PptxGenerator 类内部包含 PPTX 渲染逻辑,通过元素类型分发到对应的渲染方法
|
||||
- `html-rendering`: 从内联函数重构为独立的 HtmlRenderer 类,与 PptxRenderer 共享元素抽象层
|
||||
|
||||
## Impact
|
||||
|
||||
**代码结构**:
|
||||
- yaml2pptx.py:从 1,245 行缩减为约 100 行(仅保留 CLI 和 main 函数)
|
||||
- 新增文件:
|
||||
- core/elements.py(元素数据类)
|
||||
- core/template.py(Template 类)
|
||||
- core/presentation.py(Presentation 类)
|
||||
- loaders/yaml_loader.py(YAML 加载和验证)
|
||||
- renderers/pptx_renderer.py(PptxGenerator)
|
||||
- renderers/html_renderer.py(HtmlRenderer)
|
||||
- preview/server.py(Flask 服务器和文件监听)
|
||||
- utils.py(工具函数)
|
||||
|
||||
**导入路径**:
|
||||
- 内部导入路径会发生变化(如 `from core.elements import TextElement`)
|
||||
- 外部使用方式不变(`uv run yaml2pptx.py input.yaml output.pptx`)
|
||||
|
||||
**依赖管理**:
|
||||
- 所有依赖保持在 yaml2pptx.py 的 `/// script` 头部
|
||||
- 预览功能的依赖(flask, watchdog)不再可选,统一在入口声明
|
||||
|
||||
**测试和验证**:
|
||||
- 需要验证所有现有功能在重构后仍正常工作
|
||||
- 需要测试 YAML 解析、模板渲染、PPTX 生成、HTML 预览等完整流程
|
||||
@@ -0,0 +1,114 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 元素必须使用 dataclass 定义
|
||||
|
||||
系统必须使用 Python dataclass 定义所有元素类型(TextElement, ImageElement, ShapeElement, TableElement),提供类型安全和自动生成的方法。
|
||||
|
||||
#### Scenario: 文本元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 TextElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、content、box、font 字段
|
||||
|
||||
#### Scenario: 图片元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 ImageElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、src、box 字段
|
||||
|
||||
#### Scenario: 形状元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 ShapeElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、shape、box、fill、line 字段
|
||||
|
||||
#### Scenario: 表格元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 TableElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、data、position、col_widths、style 字段
|
||||
|
||||
### Requirement: 元素必须在创建时进行验证
|
||||
|
||||
系统必须在元素对象创建时(`__post_init__` 方法)进行数据验证,确保元素数据的有效性,尽早发现错误。
|
||||
|
||||
#### Scenario: 文本元素验证 box 字段
|
||||
|
||||
- **WHEN** 创建 TextElement 对象时 box 字段不是包含 4 个数字的列表
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "box 必须包含 4 个数字"
|
||||
|
||||
#### Scenario: 图片元素验证 src 字段
|
||||
|
||||
- **WHEN** 创建 ImageElement 对象时 src 字段为空
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "图片元素必须指定 src"
|
||||
|
||||
#### Scenario: 形状元素验证 box 字段
|
||||
|
||||
- **WHEN** 创建 ShapeElement 对象时 box 字段不是包含 4 个数字的列表
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "box 必须包含 4 个数字"
|
||||
|
||||
#### Scenario: 表格元素验证 data 字段
|
||||
|
||||
- **WHEN** 创建 TableElement 对象时 data 字段为空列表
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "表格数据不能为空"
|
||||
|
||||
### Requirement: 必须提供元素工厂函数
|
||||
|
||||
系统必须提供 `create_element(elem_dict)` 工厂函数,从字典创建对应类型的元素对象,统一元素创建入口。
|
||||
|
||||
#### Scenario: 从字典创建文本元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'text', 'content': 'Hello', 'box': [1, 1, 8, 1]})`
|
||||
- **THEN** 系统应返回 TextElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 从字典创建图片元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'image', 'src': 'image.png', 'box': [1, 1, 4, 3]})`
|
||||
- **THEN** 系统应返回 ImageElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 从字典创建形状元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'shape', 'shape': 'rectangle', 'box': [1, 1, 2, 1]})`
|
||||
- **THEN** 系统应返回 ShapeElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 从字典创建表格元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'table', 'data': [['A', 'B'], ['1', '2']], 'position': [1, 1]})`
|
||||
- **THEN** 系统应返回 TableElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 不支持的元素类型
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'unknown'})`
|
||||
- **THEN** 系统应抛出异常,提示不支持的元素类型
|
||||
|
||||
### Requirement: 元素类型必须支持未来扩展
|
||||
|
||||
系统的元素抽象层设计必须支持未来添加新元素类型(如 VideoElement),只需定义新的 dataclass 和在工厂函数中添加分支。
|
||||
|
||||
#### Scenario: 添加新元素类型的步骤清晰
|
||||
|
||||
- **WHEN** 开发者需要添加新元素类型(如 Video)
|
||||
- **THEN** 应只需要:
|
||||
1. 在 core/elements.py 中定义新的 dataclass(如 VideoElement)
|
||||
2. 在 `create_element()` 工厂函数中添加对应的分支
|
||||
3. 在各个渲染器中实现渲染方法
|
||||
|
||||
#### Scenario: 新元素类型不影响现有元素
|
||||
|
||||
- **WHEN** 添加新元素类型
|
||||
- **THEN** 现有元素类型(Text, Image, Shape, Table)的行为不应受到影响
|
||||
|
||||
### Requirement: 元素数据类必须提供默认值
|
||||
|
||||
系统必须为元素数据类的可选字段提供合理的默认值,简化元素创建。
|
||||
|
||||
#### Scenario: 文本元素的默认值
|
||||
|
||||
- **WHEN** 创建 TextElement 对象时不提供 box 和 font 字段
|
||||
- **THEN** 系统应使用默认值:box=[1, 1, 8, 1],font={}
|
||||
|
||||
#### Scenario: 图片元素的默认值
|
||||
|
||||
- **WHEN** 创建 ImageElement 对象时不提供 box 字段
|
||||
- **THEN** 系统应使用默认值:box=[1, 1, 4, 3]
|
||||
|
||||
#### Scenario: 形状元素的默认值
|
||||
|
||||
- **WHEN** 创建 ShapeElement 对象时不提供 fill 和 line 字段
|
||||
- **THEN** 系统应使用默认值或 None
|
||||
@@ -0,0 +1,60 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 系统必须使用元素数据类进行渲染
|
||||
|
||||
系统 SHALL 使用元素数据类(TextElement, ImageElement, ShapeElement, TableElement)进行渲染,而不是直接处理字典数据。渲染器接收元素对象,通过类型检查分发到对应的渲染方法。
|
||||
|
||||
#### Scenario: 渲染器接收元素对象
|
||||
|
||||
- **WHEN** 渲染器的 `_render_element()` 方法被调用
|
||||
- **THEN** 系统应接收元素对象(如 TextElement 实例),而不是字典
|
||||
|
||||
#### Scenario: 通过类型检查分发渲染
|
||||
|
||||
- **WHEN** 渲染器处理元素对象
|
||||
- **THEN** 系统应使用 `isinstance()` 检查元素类型,分发到对应的渲染方法(如 `_render_text()`, `_render_image()`)
|
||||
|
||||
#### Scenario: 元素验证在创建时完成
|
||||
|
||||
- **WHEN** 元素对象被创建
|
||||
- **THEN** 系统应在 `__post_init__` 方法中完成验证,渲染时不再需要验证
|
||||
|
||||
### Requirement: 渲染器必须内置在生成器中
|
||||
|
||||
系统 SHALL 将渲染逻辑内置在 PptxGenerator 类中,作为私有方法(`_render_text()`, `_render_image()` 等),而不是独立的函数。
|
||||
|
||||
#### Scenario: 渲染方法作为生成器的私有方法
|
||||
|
||||
- **WHEN** 开发者查看 PptxGenerator 类
|
||||
- **THEN** 应包含 `_render_text()`, `_render_image()`, `_render_shape()`, `_render_table()` 等私有方法
|
||||
|
||||
#### Scenario: 渲染方法接收元素对象
|
||||
|
||||
- **WHEN** 调用 `_render_text(slide, elem)` 方法
|
||||
- **THEN** `elem` 参数应为 TextElement 对象,包含 content、box、font 等属性
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 元素渲染必须支持元素对象的属性访问
|
||||
|
||||
系统 SHALL 通过元素对象的属性(如 `elem.content`, `elem.box`, `elem.font`)访问数据,而不是通过字典的 `get()` 方法。
|
||||
|
||||
#### Scenario: 访问文本元素的属性
|
||||
|
||||
- **WHEN** 渲染文本元素
|
||||
- **THEN** 系统应使用 `elem.content` 而不是 `elem.get('content', '')`
|
||||
|
||||
#### Scenario: 访问图片元素的属性
|
||||
|
||||
- **WHEN** 渲染图片元素
|
||||
- **THEN** 系统应使用 `elem.src` 和 `elem.box` 而不是 `elem.get('src')` 和 `elem.get('box')`
|
||||
|
||||
#### Scenario: 访问形状元素的属性
|
||||
|
||||
- **WHEN** 渲染形状元素
|
||||
- **THEN** 系统应使用 `elem.shape`, `elem.fill`, `elem.line` 等属性
|
||||
|
||||
#### Scenario: 访问表格元素的属性
|
||||
|
||||
- **WHEN** 渲染表格元素
|
||||
- **THEN** 系统应使用 `elem.data`, `elem.position`, `elem.col_widths`, `elem.style` 等属性
|
||||
@@ -0,0 +1,89 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: HTML 渲染必须使用独立的 HtmlRenderer 类
|
||||
|
||||
系统 SHALL 将 HTML 渲染逻辑重构为独立的 HtmlRenderer 类,而不是内联函数。
|
||||
|
||||
#### Scenario: HtmlRenderer 类定义在独立模块
|
||||
|
||||
- **WHEN** 开发者查看项目结构
|
||||
- **THEN** HtmlRenderer 类应定义在 `renderers/html_renderer.py` 文件中
|
||||
|
||||
#### Scenario: HtmlRenderer 包含渲染方法
|
||||
|
||||
- **WHEN** 开发者查看 HtmlRenderer 类
|
||||
- **THEN** 应包含 `render_slide()`, `render_text()`, `render_image()`, `render_shape()`, `render_table()` 等方法
|
||||
|
||||
#### Scenario: 预览服务器使用 HtmlRenderer
|
||||
|
||||
- **WHEN** 预览服务器生成 HTML
|
||||
- **THEN** 应创建 HtmlRenderer 实例并调用其渲染方法
|
||||
|
||||
### Requirement: HTML 渲染必须接收元素对象
|
||||
|
||||
系统 SHALL 使 HtmlRenderer 的渲染方法接收元素对象(TextElement, ImageElement 等),而不是字典。
|
||||
|
||||
#### Scenario: render_text 接收 TextElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_text(elem)`
|
||||
- **THEN** `elem` 参数应为 TextElement 对象
|
||||
|
||||
#### Scenario: render_image 接收 ImageElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_image(elem, base_path)`
|
||||
- **THEN** `elem` 参数应为 ImageElement 对象
|
||||
|
||||
#### Scenario: render_shape 接收 ShapeElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_shape(elem)`
|
||||
- **THEN** `elem` 参数应为 ShapeElement 对象
|
||||
|
||||
#### Scenario: render_table 接收 TableElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_table(elem)`
|
||||
- **THEN** `elem` 参数应为 TableElement 对象
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: HTML 渲染必须通过元素属性访问数据
|
||||
|
||||
系统 SHALL 通过元素对象的属性(如 `elem.content`, `elem.box`)访问数据,而不是通过字典的 `get()` 方法。
|
||||
|
||||
#### Scenario: 访问文本元素属性
|
||||
|
||||
- **WHEN** 渲染文本元素
|
||||
- **THEN** 应使用 `elem.content`, `elem.box`, `elem.font` 等属性
|
||||
|
||||
#### Scenario: 访问图片元素属性
|
||||
|
||||
- **WHEN** 渲染图片元素
|
||||
- **THEN** 应使用 `elem.src`, `elem.box` 等属性
|
||||
|
||||
#### Scenario: 访问形状元素属性
|
||||
|
||||
- **WHEN** 渲染形状元素
|
||||
- **THEN** 应使用 `elem.shape`, `elem.box`, `elem.fill`, `elem.line` 等属性
|
||||
|
||||
#### Scenario: 访问表格元素属性
|
||||
|
||||
- **WHEN** 渲染表格元素
|
||||
- **THEN** 应使用 `elem.data`, `elem.position`, `elem.col_widths`, `elem.style` 等属性
|
||||
|
||||
### Requirement: HtmlRenderer 必须与 PptxRenderer 共享元素抽象层
|
||||
|
||||
系统 SHALL 使 HtmlRenderer 和 PptxRenderer 都基于相同的元素数据类(定义在 core/elements.py),确保一致性。
|
||||
|
||||
#### Scenario: 两个渲染器使用相同的元素类型
|
||||
|
||||
- **WHEN** 开发者查看 HtmlRenderer 和 PptxRenderer
|
||||
- **THEN** 两者都应导入并使用 `from core.elements import TextElement, ImageElement, ShapeElement, TableElement`
|
||||
|
||||
#### Scenario: 元素验证在创建时完成
|
||||
|
||||
- **WHEN** 元素对象传递给 HtmlRenderer
|
||||
- **THEN** 元素已经在创建时完成验证,HtmlRenderer 不需要再次验证
|
||||
|
||||
#### Scenario: 添加新元素类型时两个渲染器同步更新
|
||||
|
||||
- **WHEN** 在 core/elements.py 中添加新元素类型(如 VideoElement)
|
||||
- **THEN** 需要在 HtmlRenderer 和 PptxRenderer 中都实现对应的渲染方法
|
||||
@@ -0,0 +1,99 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代码必须按功能模块组织
|
||||
|
||||
系统必须将代码按照功能职责拆分为独立的模块文件,每个模块文件的代码行数应控制在 150-300 行之间,确保代码易于阅读和维护。
|
||||
|
||||
#### Scenario: 单文件脚本拆分为多个模块
|
||||
|
||||
- **WHEN** 开发者查看项目结构
|
||||
- **THEN** 系统应包含以下模块文件:
|
||||
- `yaml2pptx.py`(入口脚本,约 100 行)
|
||||
- `core/elements.py`(元素数据类)
|
||||
- `core/template.py`(模板系统)
|
||||
- `core/presentation.py`(演示文稿类)
|
||||
- `loaders/yaml_loader.py`(YAML 加载)
|
||||
- `renderers/pptx_renderer.py`(PPTX 渲染器)
|
||||
- `renderers/html_renderer.py`(HTML 渲染器)
|
||||
- `preview/server.py`(预览服务器)
|
||||
- `utils.py`(工具函数)
|
||||
|
||||
#### Scenario: 每个模块文件大小适中
|
||||
|
||||
- **WHEN** 开发者打开任意模块文件
|
||||
- **THEN** 文件代码行数应在 150-300 行之间(入口脚本除外,约 100 行)
|
||||
|
||||
### Requirement: 模块必须按层次组织
|
||||
|
||||
系统必须采用四层架构组织代码:入口层(yaml2pptx.py)、核心层(core/)、加载层(loaders/)、渲染层(renderers/)、预览层(preview/),每层职责清晰。
|
||||
|
||||
#### Scenario: 核心层包含领域模型
|
||||
|
||||
- **WHEN** 开发者查看 core/ 目录
|
||||
- **THEN** 应包含元素数据类(elements.py)、模板系统(template.py)、演示文稿类(presentation.py)
|
||||
|
||||
#### Scenario: 加载层负责数据输入
|
||||
|
||||
- **WHEN** 开发者查看 loaders/ 目录
|
||||
- **THEN** 应包含 YAML 加载和验证逻辑(yaml_loader.py)
|
||||
|
||||
#### Scenario: 渲染层负责数据输出
|
||||
|
||||
- **WHEN** 开发者查看 renderers/ 目录
|
||||
- **THEN** 应包含 PPTX 渲染器(pptx_renderer.py)和 HTML 渲染器(html_renderer.py)
|
||||
|
||||
#### Scenario: 预览层提供可选功能
|
||||
|
||||
- **WHEN** 开发者查看 preview/ 目录
|
||||
- **THEN** 应包含 Flask 服务器和文件监听逻辑(server.py)
|
||||
|
||||
### Requirement: 模块间依赖关系必须清晰
|
||||
|
||||
系统必须保持清晰的依赖方向:入口层 → 渲染层 → 核心层 ← 加载层,避免循环依赖。
|
||||
|
||||
#### Scenario: 依赖方向单向流动
|
||||
|
||||
- **WHEN** 开发者分析模块导入关系
|
||||
- **THEN** 依赖关系应为:
|
||||
- yaml2pptx.py 导入 renderers、loaders、core、preview
|
||||
- renderers 导入 core
|
||||
- loaders 导入 core
|
||||
- core 不导入其他业务模块(只导入标准库和第三方库)
|
||||
- preview 导入 renderers 和 core
|
||||
|
||||
#### Scenario: 不存在循环依赖
|
||||
|
||||
- **WHEN** 开发者运行脚本
|
||||
- **THEN** 系统不应出现循环导入错误
|
||||
|
||||
### Requirement: 入口脚本必须保持单一职责
|
||||
|
||||
yaml2pptx.py 必须仅包含 CLI 参数解析和主流程编排,所有业务逻辑应委托给相应的模块。
|
||||
|
||||
#### Scenario: 入口脚本只负责 CLI 和流程编排
|
||||
|
||||
- **WHEN** 开发者查看 yaml2pptx.py
|
||||
- **THEN** 文件应仅包含:
|
||||
- `/// script` 依赖声明
|
||||
- `parse_args()` 函数(CLI 参数解析)
|
||||
- `main()` 函数(流程编排)
|
||||
- 必要的导入语句
|
||||
|
||||
#### Scenario: 入口脚本代码行数精简
|
||||
|
||||
- **WHEN** 开发者统计 yaml2pptx.py 的代码行数
|
||||
- **THEN** 应约为 100 行(不包括注释和空行)
|
||||
|
||||
### Requirement: 保持向后兼容的使用方式
|
||||
|
||||
系统必须保持 `uv run yaml2pptx.py` 的使用方式不变,用户无需修改现有的调用方式。
|
||||
|
||||
#### Scenario: CLI 使用方式不变
|
||||
|
||||
- **WHEN** 用户运行 `uv run yaml2pptx.py input.yaml output.pptx`
|
||||
- **THEN** 系统应正常生成 PPTX 文件,行为与重构前一致
|
||||
|
||||
#### Scenario: CLI 参数保持兼容
|
||||
|
||||
- **WHEN** 用户使用 `--template-dir`、`--preview`、`--port` 等参数
|
||||
- **THEN** 系统应正确解析并执行相应功能
|
||||
@@ -0,0 +1,84 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: PptxGenerator 必须内置渲染器
|
||||
|
||||
系统 SHALL 将 PptxGenerator 类重构为内置渲染器的架构,渲染逻辑作为类的私有方法,而不是独立的函数。
|
||||
|
||||
#### Scenario: PptxGenerator 包含渲染方法
|
||||
|
||||
- **WHEN** 开发者查看 PptxGenerator 类
|
||||
- **THEN** 应包含 `_render_element()`, `_render_text()`, `_render_image()`, `_render_shape()`, `_render_table()` 等私有方法
|
||||
|
||||
#### Scenario: add_slide 方法调用内置渲染器
|
||||
|
||||
- **WHEN** 调用 `generator.add_slide(slide_data, base_path)`
|
||||
- **THEN** 系统应在方法内部调用 `_render_element()` 来渲染每个元素
|
||||
|
||||
#### Scenario: 渲染方法接收元素对象
|
||||
|
||||
- **WHEN** 渲染方法被调用
|
||||
- **THEN** 应接收元素对象(如 TextElement, ImageElement)而不是字典
|
||||
|
||||
### Requirement: PptxGenerator 必须位于 renderers 模块
|
||||
|
||||
系统 SHALL 将 PptxGenerator 类从主脚本提取到 `renderers/pptx_renderer.py` 模块中。
|
||||
|
||||
#### Scenario: PptxGenerator 在独立模块中
|
||||
|
||||
- **WHEN** 开发者查看项目结构
|
||||
- **THEN** PptxGenerator 类应定义在 `renderers/pptx_renderer.py` 文件中
|
||||
|
||||
#### Scenario: 主脚本导入 PptxGenerator
|
||||
|
||||
- **WHEN** yaml2pptx.py 需要使用 PptxGenerator
|
||||
- **THEN** 应通过 `from renderers.pptx_renderer import PptxGenerator` 导入
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 渲染器必须通过类型检查分发元素
|
||||
|
||||
系统 SHALL 在 `_render_element()` 方法中使用 `isinstance()` 检查元素类型,分发到对应的渲染方法。
|
||||
|
||||
#### Scenario: 分发文本元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 TextElement 对象
|
||||
- **THEN** 系统应调用 `_render_text(slide, elem)`
|
||||
|
||||
#### Scenario: 分发图片元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 ImageElement 对象
|
||||
- **THEN** 系统应调用 `_render_image(slide, elem, base_path)`
|
||||
|
||||
#### Scenario: 分发形状元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 ShapeElement 对象
|
||||
- **THEN** 系统应调用 `_render_shape(slide, elem)`
|
||||
|
||||
#### Scenario: 分发表格元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 TableElement 对象
|
||||
- **THEN** 系统应调用 `_render_table(slide, elem)`
|
||||
|
||||
### Requirement: 渲染方法必须访问元素对象的属性
|
||||
|
||||
系统 SHALL 通过元素对象的属性(如 `elem.content`, `elem.box`)访问数据,而不是通过字典的 `get()` 方法。
|
||||
|
||||
#### Scenario: 访问文本元素属性
|
||||
|
||||
- **WHEN** `_render_text()` 渲染文本元素
|
||||
- **THEN** 应使用 `elem.content`, `elem.box`, `elem.font` 等属性
|
||||
|
||||
#### Scenario: 访问图片元素属性
|
||||
|
||||
- **WHEN** `_render_image()` 渲染图片元素
|
||||
- **THEN** 应使用 `elem.src`, `elem.box` 等属性
|
||||
|
||||
#### Scenario: 访问形状元素属性
|
||||
|
||||
- **WHEN** `_render_shape()` 渲染形状元素
|
||||
- **THEN** 应使用 `elem.shape`, `elem.box`, `elem.fill`, `elem.line` 等属性
|
||||
|
||||
#### Scenario: 访问表格元素属性
|
||||
|
||||
- **WHEN** `_render_table()` 渲染表格元素
|
||||
- **THEN** 应使用 `elem.data`, `elem.position`, `elem.col_widths`, `elem.style` 等属性
|
||||
@@ -0,0 +1,99 @@
|
||||
## 1. 准备工作
|
||||
|
||||
- [ ] 1.1 备份原始 yaml2pptx.py 文件为 yaml2pptx.py.backup
|
||||
- [x] 1.2 创建目录结构(core/, loaders/, renderers/, preview/)
|
||||
- [x] 1.3 在各目录下创建 __init__.py 文件
|
||||
|
||||
## 2. 基础设施层(utils + loaders)
|
||||
|
||||
- [x] 2.1 创建 utils.py,提取日志函数(log_info, log_success, log_error, log_progress)
|
||||
- [x] 2.2 在 utils.py 中提取颜色转换函数(hex_to_rgb, validate_color)
|
||||
- [x] 2.3 创建 loaders/yaml_loader.py,提取 YAMLError 异常类
|
||||
- [x] 2.4 在 loaders/yaml_loader.py 中提取 load_yaml_file 函数
|
||||
- [x] 2.5 在 loaders/yaml_loader.py 中提取验证函数(validate_presentation_yaml, validate_template_yaml)
|
||||
- [x] 2.6 更新 yaml2pptx.py 的导入语句,导入 utils 和 loaders 模块
|
||||
- [x] 2.7 测试 YAML 加载功能(运行 `uv run yaml2pptx.py --help` 确认无导入错误)
|
||||
|
||||
## 3. 核心抽象层(core/elements)
|
||||
|
||||
- [x] 3.1 创建 core/elements.py,定义 TextElement dataclass(包含 type, content, box, font 字段)
|
||||
- [x] 3.2 在 TextElement 中实现 __post_init__ 验证方法(验证 box 长度)
|
||||
- [x] 3.3 定义 ImageElement dataclass(包含 type, src, box 字段及验证)
|
||||
- [x] 3.4 定义 ShapeElement dataclass(包含 type, shape, box, fill, line 字段及验证)
|
||||
- [x] 3.5 定义 TableElement dataclass(包含 type, data, position, col_widths, style 字段及验证)
|
||||
- [x] 3.6 实现 create_element 工厂函数,根据 type 字段创建对应的元素对象
|
||||
- [x] 3.7 测试元素创建和验证(创建测试用例验证各元素类型)
|
||||
|
||||
## 4. 核心抽象层(core/template + presentation)
|
||||
|
||||
- [x] 4.1 创建 core/template.py,提取 Template 类
|
||||
- [x] 4.2 更新 Template 类的导入,使用 loaders.yaml_loader 和 utils
|
||||
- [x] 4.3 创建 core/presentation.py,提取 Presentation 类
|
||||
- [x] 4.4 更新 Presentation 类的导入,使用 loaders.yaml_loader 和 core.template
|
||||
- [x] 4.5 修改 Presentation.render_slide 方法,使元素通过 create_element 转换为元素对象
|
||||
- [ ] 4.6 测试模板渲染功能(使用现有的 YAML 文件测试)
|
||||
|
||||
## 5. PPTX 渲染器
|
||||
|
||||
- [x] 5.1 创建 renderers/pptx_renderer.py,定义 PptxGenerator 类
|
||||
- [x] 5.2 在 PptxGenerator.__init__ 中实现幻灯片尺寸设置逻辑
|
||||
- [x] 5.3 实现 PptxGenerator.add_slide 方法(创建幻灯片、设置背景、调用 _render_element)
|
||||
- [x] 5.4 实现 _render_element 方法(使用 isinstance 检查元素类型并分发)
|
||||
- [x] 5.5 实现 _render_text 方法(从原 add_text_element 迁移,使用元素对象属性)
|
||||
- [x] 5.6 实现 _render_image 方法(从原 add_image_element 迁移,使用元素对象属性)
|
||||
- [x] 5.7 实现 _render_shape 方法(从原 add_shape_element 迁移,使用元素对象属性)
|
||||
- [x] 5.8 实现 _render_table 方法(从原 add_table_element 迁移,使用元素对象属性)
|
||||
- [x] 5.9 实现 _render_background 方法(从原 set_slide_background 迁移)
|
||||
- [x] 5.10 实现 PptxGenerator.save 方法
|
||||
- [x] 5.11 更新 PptxGenerator 的导入,使用 core.elements 和 utils
|
||||
- [x] 5.12 测试 PPTX 生成功能(生成测试 PPTX 文件并验证)
|
||||
|
||||
## 6. HTML 渲染器
|
||||
|
||||
- [x] 6.1 创建 renderers/html_renderer.py,定义 HtmlRenderer 类
|
||||
- [x] 6.2 实现 HtmlRenderer.render_slide 方法(生成幻灯片 HTML 容器)
|
||||
- [x] 6.3 实现 render_text 方法(从原 render_text_element_to_html 迁移,使用元素对象属性)
|
||||
- [x] 6.4 实现 render_image 方法(从原 render_image_element_to_html 迁移,使用元素对象属性)
|
||||
- [x] 6.5 实现 render_shape 方法(从原 render_shape_element_to_html 迁移,使用元素对象属性)
|
||||
- [x] 6.6 实现 render_table 方法(从原 render_table_element_to_html 迁移,使用元素对象属性)
|
||||
- [x] 6.7 更新 HtmlRenderer 的导入,使用 core.elements
|
||||
- [ ] 6.8 测试 HTML 渲染功能(生成测试 HTML 并在浏览器中验证)
|
||||
|
||||
## 7. 预览服务器
|
||||
|
||||
- [x] 7.1 创建 preview/server.py,提取 Flask 应用相关代码
|
||||
- [x] 7.2 提取 HTML_TEMPLATE 和 ERROR_TEMPLATE 常量
|
||||
- [x] 7.3 提取 YAMLChangeHandler 类
|
||||
- [x] 7.4 提取 create_flask_app 函数
|
||||
- [x] 7.5 提取 start_preview_server 函数
|
||||
- [x] 7.6 实现 generate_preview_html 函数(使用 HtmlRenderer)
|
||||
- [x] 7.7 更新 preview/server.py 的导入,使用 core.presentation 和 renderers.html_renderer
|
||||
- [ ] 7.8 测试预览服务器功能(启动预览服务器并验证热重载)
|
||||
|
||||
## 8. 入口脚本重构
|
||||
|
||||
- [x] 8.1 重构 yaml2pptx.py,只保留 `/// script` 头部、parse_args 和 main 函数
|
||||
- [x] 8.2 更新 main 函数的导入语句(导入所有需要的模块)
|
||||
- [x] 8.3 更新 main 函数中的 Presentation 和 PptxGenerator 使用(使用新的模块路径)
|
||||
- [x] 8.4 更新 main 函数中的预览模式调用(使用 preview.server)
|
||||
- [x] 8.5 删除 yaml2pptx.py 中已迁移的代码(保留约 100 行)
|
||||
- [x] 8.6 验证 yaml2pptx.py 的代码行数(应约为 100 行)
|
||||
|
||||
## 9. 完整测试
|
||||
|
||||
- [x] 9.1 测试基本 PPTX 生成(`uv run yaml2pptx.py input.yaml output.pptx`)
|
||||
- [x] 9.2 测试自动输出文件名(`uv run yaml2pptx.py input.yaml`)
|
||||
- [x] 9.3 测试模板功能(使用带模板的 YAML 文件)
|
||||
- [x] 9.4 测试所有元素类型(text, image, shape, table)
|
||||
- [ ] 9.5 测试预览模式(`uv run yaml2pptx.py input.yaml --preview`)
|
||||
- [ ] 9.6 测试 --template-dir 参数
|
||||
- [ ] 9.7 测试错误处理(无效的 YAML、缺失的图片等)
|
||||
- [x] 9.8 对比重构前后生成的 PPTX 文件(确保输出一致)
|
||||
|
||||
## 10. 清理和文档
|
||||
|
||||
- [x] 10.1 检查所有模块的导入语句,确保没有未使用的导入
|
||||
- [x] 10.2 检查是否存在循环依赖(运行脚本验证)
|
||||
- [x] 10.3 验证所有文件的代码行数(每个模块 150-300 行,入口约 100 行)
|
||||
- [x] 10.4 添加模块级文档字符串(说明每个模块的职责)
|
||||
- [x] 10.5 删除 yaml2pptx.py.backup 备份文件(如果测试通过)
|
||||
@@ -6,3 +6,5 @@ context: |
|
||||
严禁直接使用主机环境的python直接执行脚本,严禁在主机环境直接安装python依赖包;
|
||||
本项目编写的测试文件、临时文件必须放在temp目录下;
|
||||
严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作;
|
||||
当前项目的面向用户的使用文档在README.md;
|
||||
当前项目的面向AI和开发者的开发规范文档在README_DEV.md;
|
||||
120
openspec/specs/element-abstraction/spec.md
Normal file
120
openspec/specs/element-abstraction/spec.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Element Abstraction
|
||||
|
||||
## Purpose
|
||||
|
||||
定义元素抽象层的规格,使用 dataclass 实现类型安全的元素数据类,支持创建时验证和未来扩展。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 元素必须使用 dataclass 定义
|
||||
|
||||
系统必须使用 Python dataclass 定义所有元素类型(TextElement, ImageElement, ShapeElement, TableElement),提供类型安全和自动生成的方法。
|
||||
|
||||
#### Scenario: 文本元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 TextElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、content、box、font 字段
|
||||
|
||||
#### Scenario: 图片元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 ImageElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、src、box 字段
|
||||
|
||||
#### Scenario: 形状元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 ShapeElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、shape、box、fill、line 字段
|
||||
|
||||
#### Scenario: 表格元素使用 dataclass 定义
|
||||
|
||||
- **WHEN** 开发者查看 core/elements.py 中的 TableElement 类
|
||||
- **THEN** 应使用 `@dataclass` 装饰器定义,包含 type、data、position、col_widths、style 字段
|
||||
|
||||
### Requirement: 元素必须在创建时进行验证
|
||||
|
||||
系统必须在元素对象创建时(`__post_init__` 方法)进行数据验证,确保元素数据的有效性,尽早发现错误。
|
||||
|
||||
#### Scenario: 文本元素验证 box 字段
|
||||
|
||||
- **WHEN** 创建 TextElement 对象时 box 字段不是包含 4 个数字的列表
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "box 必须包含 4 个数字"
|
||||
|
||||
#### Scenario: 图片元素验证 src 字段
|
||||
|
||||
- **WHEN** 创建 ImageElement 对象时 src 字段为空
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "图片元素必须指定 src"
|
||||
|
||||
#### Scenario: 形状元素验证 box 字段
|
||||
|
||||
- **WHEN** 创建 ShapeElement 对象时 box 字段不是包含 4 个数字的列表
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "box 必须包含 4 个数字"
|
||||
|
||||
#### Scenario: 表格元素验证 data 字段
|
||||
|
||||
- **WHEN** 创建 TableElement 对象时 data 字段为空列表
|
||||
- **THEN** 系统应抛出 ValueError 异常,提示 "表格数据不能为空"
|
||||
|
||||
### Requirement: 必须提供元素工厂函数
|
||||
|
||||
系统必须提供 `create_element(elem_dict)` 工厂函数,从字典创建对应类型的元素对象,统一元素创建入口。
|
||||
|
||||
#### Scenario: 从字典创建文本元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'text', 'content': 'Hello', 'box': [1, 1, 8, 1]})`
|
||||
- **THEN** 系统应返回 TextElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 从字典创建图片元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'image', 'src': 'image.png', 'box': [1, 1, 4, 3]})`
|
||||
- **THEN** 系统应返回 ImageElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 从字典创建形状元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'shape', 'shape': 'rectangle', 'box': [1, 1, 2, 1]})`
|
||||
- **THEN** 系统应返回 ShapeElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 从字典创建表格元素
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'table', 'data': [['A', 'B'], ['1', '2']], 'position': [1, 1]})`
|
||||
- **THEN** 系统应返回 TableElement 对象,包含相应的字段值
|
||||
|
||||
#### Scenario: 不支持的元素类型
|
||||
|
||||
- **WHEN** 调用 `create_element({'type': 'unknown'})`
|
||||
- **THEN** 系统应抛出异常,提示不支持的元素类型
|
||||
|
||||
### Requirement: 元素类型必须支持未来扩展
|
||||
|
||||
系统的元素抽象层设计必须支持未来添加新元素类型(如 VideoElement),只需定义新的 dataclass 和在工厂函数中添加分支。
|
||||
|
||||
#### Scenario: 添加新元素类型的步骤清晰
|
||||
|
||||
- **WHEN** 开发者需要添加新元素类型(如 Video)
|
||||
- **THEN** 应只需要:
|
||||
1. 在 core/elements.py 中定义新的 dataclass(如 VideoElement)
|
||||
2. 在 `create_element()` 工厂函数中添加对应的分支
|
||||
3. 在各个渲染器中实现渲染方法
|
||||
|
||||
#### Scenario: 新元素类型不影响现有元素
|
||||
|
||||
- **WHEN** 添加新元素类型
|
||||
- **THEN** 现有元素类型(Text, Image, Shape, Table)的行为不应受到影响
|
||||
|
||||
### Requirement: 元素数据类必须提供默认值
|
||||
|
||||
系统必须为元素数据类的可选字段提供合理的默认值,简化元素创建。
|
||||
|
||||
#### Scenario: 文本元素的默认值
|
||||
|
||||
- **WHEN** 创建 TextElement 对象时不提供 box 和 font 字段
|
||||
- **THEN** 系统应使用默认值:box=[1, 1, 8, 1],font={}
|
||||
|
||||
#### Scenario: 图片元素的默认值
|
||||
|
||||
- **WHEN** 创建 ImageElement 对象时不提供 box 字段
|
||||
- **THEN** 系统应使用默认值:box=[1, 1, 4, 3]
|
||||
|
||||
#### Scenario: 形状元素的默认值
|
||||
|
||||
- **WHEN** 创建 ShapeElement 对象时不提供 fill 和 line 字段
|
||||
- **THEN** 系统应使用默认值或 None
|
||||
@@ -159,6 +159,63 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
|
||||
- **WHEN** 两个元素的位置有重叠
|
||||
- **THEN** 后定义的元素遮盖先定义的元素
|
||||
|
||||
### Requirement: 系统必须使用元素数据类进行渲染
|
||||
|
||||
系统 SHALL 使用元素数据类(TextElement, ImageElement, ShapeElement, TableElement)进行渲染,而不是直接处理字典数据。渲染器接收元素对象,通过类型检查分发到对应的渲染方法。
|
||||
|
||||
#### Scenario: 渲染器接收元素对象
|
||||
|
||||
- **WHEN** 渲染器的 `_render_element()` 方法被调用
|
||||
- **THEN** 系统应接收元素对象(如 TextElement 实例),而不是字典
|
||||
|
||||
#### Scenario: 通过类型检查分发渲染
|
||||
|
||||
- **WHEN** 渲染器处理元素对象
|
||||
- **THEN** 系统应使用 `isinstance()` 检查元素类型,分发到对应的渲染方法(如 `_render_text()`, `_render_image()`)
|
||||
|
||||
#### Scenario: 元素验证在创建时完成
|
||||
|
||||
- **WHEN** 元素对象被创建
|
||||
- **THEN** 系统应在 `__post_init__` 方法中完成验证,渲染时不再需要验证
|
||||
|
||||
### Requirement: 渲染器必须内置在生成器中
|
||||
|
||||
系统 SHALL 将渲染逻辑内置在 PptxGenerator 类中,作为私有方法(`_render_text()`, `_render_image()` 等),而不是独立的函数。
|
||||
|
||||
#### Scenario: 渲染方法作为生成器的私有方法
|
||||
|
||||
- **WHEN** 开发者查看 PptxGenerator 类
|
||||
- **THEN** 应包含 `_render_text()`, `_render_image()`, `_render_shape()`, `_render_table()` 等私有方法
|
||||
|
||||
#### Scenario: 渲染方法接收元素对象
|
||||
|
||||
- **WHEN** 调用 `_render_text(slide, elem)` 方法
|
||||
- **THEN** `elem` 参数应为 TextElement 对象,包含 content、box、font 等属性
|
||||
|
||||
### Requirement: 元素渲染必须支持元素对象的属性访问
|
||||
|
||||
系统 SHALL 通过元素对象的属性(如 `elem.content`, `elem.box`, `elem.font`)访问数据,而不是通过字典的 `get()` 方法。
|
||||
|
||||
#### Scenario: 访问文本元素的属性
|
||||
|
||||
- **WHEN** 渲染文本元素
|
||||
- **THEN** 系统应使用 `elem.content` 而不是 `elem.get('content', '')`
|
||||
|
||||
#### Scenario: 访问图片元素的属性
|
||||
|
||||
- **WHEN** 渲染图片元素
|
||||
- **THEN** 系统应使用 `elem.src` 和 `elem.box` 而不是 `elem.get('src')` 和 `elem.get('box')`
|
||||
|
||||
#### Scenario: 访问形状元素的属性
|
||||
|
||||
- **WHEN** 渲染形状元素
|
||||
- **THEN** 系统应使用 `elem.shape`, `elem.fill`, `elem.line` 等属性
|
||||
|
||||
#### Scenario: 访问表格元素的属性
|
||||
|
||||
- **WHEN** 渲染表格元素
|
||||
- **THEN** 系统应使用 `elem.data`, `elem.position`, `elem.col_widths`, `elem.style` 等属性
|
||||
|
||||
### Requirement: 系统必须验证元素类型
|
||||
|
||||
系统 SHALL 验证每个元素的 type 字段,仅支持已定义的元素类型。
|
||||
|
||||
@@ -197,6 +197,92 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
|
||||
- **WHEN** 渲染多个幻灯片
|
||||
- **THEN** 每个幻灯片之间有 20px 的垂直间距
|
||||
|
||||
### Requirement: HTML 渲染必须使用独立的 HtmlRenderer 类
|
||||
|
||||
系统 SHALL 将 HTML 渲染逻辑重构为独立的 HtmlRenderer 类,而不是内联函数。
|
||||
|
||||
#### Scenario: HtmlRenderer 类定义在独立模块
|
||||
|
||||
- **WHEN** 开发者查看项目结构
|
||||
- **THEN** HtmlRenderer 类应定义在 `renderers/html_renderer.py` 文件中
|
||||
|
||||
#### Scenario: HtmlRenderer 包含渲染方法
|
||||
|
||||
- **WHEN** 开发者查看 HtmlRenderer 类
|
||||
- **THEN** 应包含 `render_slide()`, `render_text()`, `render_image()`, `render_shape()`, `render_table()` 等方法
|
||||
|
||||
#### Scenario: 预览服务器使用 HtmlRenderer
|
||||
|
||||
- **WHEN** 预览服务器生成 HTML
|
||||
- **THEN** 应创建 HtmlRenderer 实例并调用其渲染方法
|
||||
|
||||
### Requirement: HTML 渲染必须接收元素对象
|
||||
|
||||
系统 SHALL 使 HtmlRenderer 的渲染方法接收元素对象(TextElement, ImageElement 等),而不是字典。
|
||||
|
||||
#### Scenario: render_text 接收 TextElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_text(elem)`
|
||||
- **THEN** `elem` 参数应为 TextElement 对象
|
||||
|
||||
#### Scenario: render_image 接收 ImageElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_image(elem, base_path)`
|
||||
- **THEN** `elem` 参数应为 ImageElement 对象
|
||||
|
||||
#### Scenario: render_shape 接收 ShapeElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_shape(elem)`
|
||||
- **THEN** `elem` 参数应为 ShapeElement 对象
|
||||
|
||||
#### Scenario: render_table 接收 TableElement 对象
|
||||
|
||||
- **WHEN** 调用 `renderer.render_table(elem)`
|
||||
- **THEN** `elem` 参数应为 TableElement 对象
|
||||
|
||||
### Requirement: HTML 渲染必须通过元素属性访问数据
|
||||
|
||||
系统 SHALL 通过元素对象的属性(如 `elem.content`, `elem.box`)访问数据,而不是通过字典的 `get()` 方法。
|
||||
|
||||
#### Scenario: 访问文本元素属性
|
||||
|
||||
- **WHEN** 渲染文本元素
|
||||
- **THEN** 应使用 `elem.content`, `elem.box`, `elem.font` 等属性
|
||||
|
||||
#### Scenario: 访问图片元素属性
|
||||
|
||||
- **WHEN** 渲染图片元素
|
||||
- **THEN** 应使用 `elem.src`, `elem.box` 等属性
|
||||
|
||||
#### Scenario: 访问形状元素属性
|
||||
|
||||
- **WHEN** 渲染形状元素
|
||||
- **THEN** 应使用 `elem.shape`, `elem.box`, `elem.fill`, `elem.line` 等属性
|
||||
|
||||
#### Scenario: 访问表格元素属性
|
||||
|
||||
- **WHEN** 渲染表格元素
|
||||
- **THEN** 应使用 `elem.data`, `elem.position`, `elem.col_widths`, `elem.style` 等属性
|
||||
|
||||
### Requirement: HtmlRenderer 必须与 PptxRenderer 共享元素抽象层
|
||||
|
||||
系统 SHALL 使 HtmlRenderer 和 PptxRenderer 都基于相同的元素数据类(定义在 core/elements.py),确保一致性。
|
||||
|
||||
#### Scenario: 两个渲染器使用相同的元素类型
|
||||
|
||||
- **WHEN** 开发者查看 HtmlRenderer 和 PptxRenderer
|
||||
- **THEN** 两者都应导入并使用 `from core.elements import TextElement, ImageElement, ShapeElement, TableElement`
|
||||
|
||||
#### Scenario: 元素验证在创建时完成
|
||||
|
||||
- **WHEN** 元素对象传递给 HtmlRenderer
|
||||
- **THEN** 元素已经在创建时完成验证,HtmlRenderer 不需要再次验证
|
||||
|
||||
#### Scenario: 添加新元素类型时两个渲染器同步更新
|
||||
|
||||
- **WHEN** 在 core/elements.py 中添加新元素类型(如 VideoElement)
|
||||
- **THEN** 需要在 HtmlRenderer 和 PptxRenderer 中都实现对应的渲染方法
|
||||
|
||||
### Requirement: 系统必须复用现有的解析逻辑
|
||||
|
||||
系统 SHALL 复用 `yaml2pptx.py` 中现有的 `Presentation` 类和模板渲染逻辑。
|
||||
|
||||
105
openspec/specs/modular-architecture/spec.md
Normal file
105
openspec/specs/modular-architecture/spec.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Modular Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
定义项目的模块化架构规格,确保代码按功能职责组织,模块大小适中,依赖关系清晰,易于维护和扩展。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代码必须按功能模块组织
|
||||
|
||||
系统必须将代码按照功能职责拆分为独立的模块文件,每个模块文件的代码行数应控制在 150-300 行之间,确保代码易于阅读和维护。
|
||||
|
||||
#### Scenario: 单文件脚本拆分为多个模块
|
||||
|
||||
- **WHEN** 开发者查看项目结构
|
||||
- **THEN** 系统应包含以下模块文件:
|
||||
- `yaml2pptx.py`(入口脚本,约 100 行)
|
||||
- `core/elements.py`(元素数据类)
|
||||
- `core/template.py`(模板系统)
|
||||
- `core/presentation.py`(演示文稿类)
|
||||
- `loaders/yaml_loader.py`(YAML 加载)
|
||||
- `renderers/pptx_renderer.py`(PPTX 渲染器)
|
||||
- `renderers/html_renderer.py`(HTML 渲染器)
|
||||
- `preview/server.py`(预览服务器)
|
||||
- `utils.py`(工具函数)
|
||||
|
||||
#### Scenario: 每个模块文件大小适中
|
||||
|
||||
- **WHEN** 开发者打开任意模块文件
|
||||
- **THEN** 文件代码行数应在 150-300 行之间(入口脚本除外,约 100 行)
|
||||
|
||||
### Requirement: 模块必须按层次组织
|
||||
|
||||
系统必须采用四层架构组织代码:入口层(yaml2pptx.py)、核心层(core/)、加载层(loaders/)、渲染层(renderers/)、预览层(preview/),每层职责清晰。
|
||||
|
||||
#### Scenario: 核心层包含领域模型
|
||||
|
||||
- **WHEN** 开发者查看 core/ 目录
|
||||
- **THEN** 应包含元素数据类(elements.py)、模板系统(template.py)、演示文稿类(presentation.py)
|
||||
|
||||
#### Scenario: 加载层负责数据输入
|
||||
|
||||
- **WHEN** 开发者查看 loaders/ 目录
|
||||
- **THEN** 应包含 YAML 加载和验证逻辑(yaml_loader.py)
|
||||
|
||||
#### Scenario: 渲染层负责数据输出
|
||||
|
||||
- **WHEN** 开发者查看 renderers/ 目录
|
||||
- **THEN** 应包含 PPTX 渲染器(pptx_renderer.py)和 HTML 渲染器(html_renderer.py)
|
||||
|
||||
#### Scenario: 预览层提供可选功能
|
||||
|
||||
- **WHEN** 开发者查看 preview/ 目录
|
||||
- **THEN** 应包含 Flask 服务器和文件监听逻辑(server.py)
|
||||
|
||||
### Requirement: 模块间依赖关系必须清晰
|
||||
|
||||
系统必须保持清晰的依赖方向:入口层 → 渲染层 → 核心层 ← 加载层,避免循环依赖。
|
||||
|
||||
#### Scenario: 依赖方向单向流动
|
||||
|
||||
- **WHEN** 开发者分析模块导入关系
|
||||
- **THEN** 依赖关系应为:
|
||||
- yaml2pptx.py 导入 renderers、loaders、core、preview
|
||||
- renderers 导入 core
|
||||
- loaders 导入 core
|
||||
- core 不导入其他业务模块(只导入标准库和第三方库)
|
||||
- preview 导入 renderers 和 core
|
||||
|
||||
#### Scenario: 不存在循环依赖
|
||||
|
||||
- **WHEN** 开发者运行脚本
|
||||
- **THEN** 系统不应出现循环导入错误
|
||||
|
||||
### Requirement: 入口脚本必须保持单一职责
|
||||
|
||||
yaml2pptx.py 必须仅包含 CLI 参数解析和主流程编排,所有业务逻辑应委托给相应的模块。
|
||||
|
||||
#### Scenario: 入口脚本只负责 CLI 和流程编排
|
||||
|
||||
- **WHEN** 开发者查看 yaml2pptx.py
|
||||
- **THEN** 文件应仅包含:
|
||||
- `/// script` 依赖声明
|
||||
- `parse_args()` 函数(CLI 参数解析)
|
||||
- `main()` 函数(流程编排)
|
||||
- 必要的导入语句
|
||||
|
||||
#### Scenario: 入口脚本代码行数精简
|
||||
|
||||
- **WHEN** 开发者统计 yaml2pptx.py 的代码行数
|
||||
- **THEN** 应约为 100 行(不包括注释和空行)
|
||||
|
||||
### Requirement: 保持向后兼容的使用方式
|
||||
|
||||
系统必须保持 `uv run yaml2pptx.py` 的使用方式不变,用户无需修改现有的调用方式。
|
||||
|
||||
#### Scenario: CLI 使用方式不变
|
||||
|
||||
- **WHEN** 用户运行 `uv run yaml2pptx.py input.yaml output.pptx`
|
||||
- **THEN** 系统应正常生成 PPTX 文件,行为与重构前一致
|
||||
|
||||
#### Scenario: CLI 参数保持兼容
|
||||
|
||||
- **WHEN** 用户使用 `--template-dir`、`--preview`、`--port` 等参数
|
||||
- **THEN** 系统应正确解析并执行相应功能
|
||||
@@ -153,6 +153,87 @@ PPTX generation 系统负责使用 python-pptx 库创建符合 OOXML 标准的 P
|
||||
- **WHEN** 需要使用 python-pptx 或 PyYAML
|
||||
- **THEN** 不使用 `pip install`,而是在 script metadata 中声明依赖
|
||||
|
||||
### Requirement: PptxGenerator 必须内置渲染器
|
||||
|
||||
系统 SHALL 将 PptxGenerator 类重构为内置渲染器的架构,渲染逻辑作为类的私有方法,而不是独立的函数。
|
||||
|
||||
#### Scenario: PptxGenerator 包含渲染方法
|
||||
|
||||
- **WHEN** 开发者查看 PptxGenerator 类
|
||||
- **THEN** 应包含 `_render_element()`, `_render_text()`, `_render_image()`, `_render_shape()`, `_render_table()` 等私有方法
|
||||
|
||||
#### Scenario: add_slide 方法调用内置渲染器
|
||||
|
||||
- **WHEN** 调用 `generator.add_slide(slide_data, base_path)`
|
||||
- **THEN** 系统应在方法内部调用 `_render_element()` 来渲染每个元素
|
||||
|
||||
#### Scenario: 渲染方法接收元素对象
|
||||
|
||||
- **WHEN** 渲染方法被调用
|
||||
- **THEN** 应接收元素对象(如 TextElement, ImageElement)而不是字典
|
||||
|
||||
### Requirement: PptxGenerator 必须位于 renderers 模块
|
||||
|
||||
系统 SHALL 将 PptxGenerator 类从主脚本提取到 `renderers/pptx_renderer.py` 模块中。
|
||||
|
||||
#### Scenario: PptxGenerator 在独立模块中
|
||||
|
||||
- **WHEN** 开发者查看项目结构
|
||||
- **THEN** PptxGenerator 类应定义在 `renderers/pptx_renderer.py` 文件中
|
||||
|
||||
#### Scenario: 主脚本导入 PptxGenerator
|
||||
|
||||
- **WHEN** yaml2pptx.py 需要使用 PptxGenerator
|
||||
- **THEN** 应通过 `from renderers.pptx_renderer import PptxGenerator` 导入
|
||||
|
||||
### Requirement: 渲染器必须通过类型检查分发元素
|
||||
|
||||
系统 SHALL 在 `_render_element()` 方法中使用 `isinstance()` 检查元素类型,分发到对应的渲染方法。
|
||||
|
||||
#### Scenario: 分发文本元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 TextElement 对象
|
||||
- **THEN** 系统应调用 `_render_text(slide, elem)`
|
||||
|
||||
#### Scenario: 分发图片元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 ImageElement 对象
|
||||
- **THEN** 系统应调用 `_render_image(slide, elem, base_path)`
|
||||
|
||||
#### Scenario: 分发形状元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 ShapeElement 对象
|
||||
- **THEN** 系统应调用 `_render_shape(slide, elem)`
|
||||
|
||||
#### Scenario: 分发表格元素
|
||||
|
||||
- **WHEN** `_render_element()` 接收到 TableElement 对象
|
||||
- **THEN** 系统应调用 `_render_table(slide, elem)`
|
||||
|
||||
### Requirement: 渲染方法必须访问元素对象的属性
|
||||
|
||||
系统 SHALL 通过元素对象的属性(如 `elem.content`, `elem.box`)访问数据,而不是通过字典的 `get()` 方法。
|
||||
|
||||
#### Scenario: 访问文本元素属性
|
||||
|
||||
- **WHEN** `_render_text()` 渲染文本元素
|
||||
- **THEN** 应使用 `elem.content`, `elem.box`, `elem.font` 等属性
|
||||
|
||||
#### Scenario: 访问图片元素属性
|
||||
|
||||
- **WHEN** `_render_image()` 渲染图片元素
|
||||
- **THEN** 应使用 `elem.src`, `elem.box` 等属性
|
||||
|
||||
#### Scenario: 访问形状元素属性
|
||||
|
||||
- **WHEN** `_render_shape()` 渲染形状元素
|
||||
- **THEN** 应使用 `elem.shape`, `elem.box`, `elem.fill`, `elem.line` 等属性
|
||||
|
||||
#### Scenario: 访问表格元素属性
|
||||
|
||||
- **WHEN** `_render_table()` 渲染表格元素
|
||||
- **THEN** 应使用 `elem.data`, `elem.position`, `elem.col_widths`, `elem.style` 等属性
|
||||
|
||||
### Requirement: 系统架构保持简洁
|
||||
|
||||
系统 SHALL 采用两层架构(模板 + 演示文稿),颜色和样式直接在模板中定义。
|
||||
|
||||
0
preview/__init__.py
Normal file
0
preview/__init__.py
Normal file
244
preview/server.py
Normal file
244
preview/server.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
预览服务器模块
|
||||
|
||||
提供浏览器预览功能,支持文件监听和热重载。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import queue
|
||||
import webbrowser
|
||||
import random
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
try:
|
||||
from flask import Flask, Response
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
except ImportError:
|
||||
Flask = None
|
||||
Observer = None
|
||||
FileSystemEventHandler = None
|
||||
|
||||
from core.presentation import Presentation
|
||||
from renderers.html_renderer import HtmlRenderer
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from utils import log_info, log_error
|
||||
|
||||
|
||||
# HTML 模板
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>YAML Preview</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.slide {
|
||||
width: 960px;
|
||||
height: 540px;
|
||||
position: relative;
|
||||
background: white;
|
||||
margin: 20px auto;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-number {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
z-index: 1000;
|
||||
}
|
||||
.element {
|
||||
position: absolute;
|
||||
}
|
||||
table.element {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.element td {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{ slides_html }}
|
||||
|
||||
<script>
|
||||
const eventSource = new EventSource('/events');
|
||||
eventSource.onmessage = (e) => {
|
||||
if (e.data === 'reload') {
|
||||
console.log('[Preview] 重新加载...');
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
eventSource.onerror = () => {
|
||||
console.error('[Preview] 连接断开');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
ERROR_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>预览错误</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: #f5f5f5;
|
||||
font-family: monospace;
|
||||
}
|
||||
.error {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #fff3cd;
|
||||
border: 2px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
color: #856404;
|
||||
}
|
||||
h1 { margin-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>⚠️ YAML 解析错误</h1>
|
||||
<pre>{{ error }}</pre>
|
||||
</div>
|
||||
<script>
|
||||
const eventSource = new EventSource('/events');
|
||||
eventSource.onmessage = () => location.reload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# 全局变量
|
||||
app = None
|
||||
change_queue = None
|
||||
current_yaml_file = None
|
||||
current_template_dir = None
|
||||
|
||||
|
||||
class YAMLChangeHandler:
|
||||
"""文件变化处理器"""
|
||||
def on_modified(self, event):
|
||||
if event.src_path.endswith('.yaml'):
|
||||
log_info(f"检测到文件变化: {event.src_path}")
|
||||
if change_queue:
|
||||
change_queue.put('reload')
|
||||
|
||||
|
||||
def generate_preview_html(yaml_file, template_dir):
|
||||
"""生成完整的预览 HTML 页面"""
|
||||
try:
|
||||
pres = Presentation(yaml_file, template_dir)
|
||||
renderer = HtmlRenderer()
|
||||
|
||||
slides_html = ""
|
||||
for i, slide_data in enumerate(pres.data.get('slides', [])):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
slides_html += renderer.render_slide(rendered, i, Path(yaml_file).parent)
|
||||
|
||||
return HTML_TEMPLATE.replace('{{ slides_html }}', slides_html)
|
||||
|
||||
except YAMLError as e:
|
||||
return ERROR_TEMPLATE.replace('{{ error }}', str(e))
|
||||
|
||||
|
||||
def create_flask_app():
|
||||
"""创建 Flask 应用"""
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
@flask_app.route('/')
|
||||
def index():
|
||||
"""主页面"""
|
||||
try:
|
||||
return generate_preview_html(current_yaml_file, current_template_dir)
|
||||
except Exception as e:
|
||||
return ERROR_TEMPLATE.replace('{{ error }}', f"生成预览失败: {str(e)}")
|
||||
|
||||
@flask_app.route('/events')
|
||||
def events():
|
||||
"""SSE 事件流"""
|
||||
def event_stream():
|
||||
while True:
|
||||
change_queue.get()
|
||||
yield 'data: reload\\n\\n'
|
||||
|
||||
return Response(event_stream(), mimetype='text/event-stream')
|
||||
|
||||
return flask_app
|
||||
|
||||
|
||||
def start_preview_server(yaml_file, template_dir, port):
|
||||
"""启动预览服务器"""
|
||||
global app, change_queue, current_yaml_file, current_template_dir
|
||||
|
||||
if Flask is None:
|
||||
log_error("预览功能需要 flask 和 watchdog 依赖")
|
||||
log_error("请确保使用 uv 运行脚本,依赖会自动安装")
|
||||
sys.exit(1)
|
||||
|
||||
# 如果没有指定端口,随机选择 20000-30000 之间的端口
|
||||
if port is None:
|
||||
port = random.randint(20000, 30000)
|
||||
|
||||
current_yaml_file = yaml_file
|
||||
current_template_dir = template_dir
|
||||
change_queue = queue.Queue()
|
||||
|
||||
# 创建 Flask 应用
|
||||
app = create_flask_app()
|
||||
|
||||
# 启动文件监听
|
||||
if FileSystemEventHandler:
|
||||
handler = YAMLChangeHandler()
|
||||
if hasattr(handler, 'on_modified'):
|
||||
# 创建一个简单的事件处理器
|
||||
class SimpleHandler(FileSystemEventHandler):
|
||||
def on_modified(self, event):
|
||||
handler.on_modified(event)
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(SimpleHandler(), str(Path(yaml_file).parent), recursive=False)
|
||||
observer.start()
|
||||
|
||||
# 输出日志
|
||||
log_info(f"正在监听: {yaml_file}")
|
||||
log_info(f"预览地址: http://localhost:{port}")
|
||||
log_info("按 Ctrl+C 停止")
|
||||
|
||||
# 自动打开浏览器
|
||||
Thread(target=lambda: webbrowser.open(f'http://localhost:{port}')).start()
|
||||
|
||||
# 启动 Flask
|
||||
try:
|
||||
app.run(host='0.0.0.0', port=port, debug=False, threaded=True)
|
||||
except OSError as e:
|
||||
if 'Address already in use' in str(e):
|
||||
log_error(f"端口 {port} 已被占用")
|
||||
log_error(f"请使用 --port 参数指定其他端口,例如: --port {port + 1}")
|
||||
else:
|
||||
log_error(f"启动服务器失败: {str(e)}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
if 'observer' in locals():
|
||||
observer.stop()
|
||||
observer.join()
|
||||
log_info("已停止")
|
||||
0
renderers/__init__.py
Normal file
0
renderers/__init__.py
Normal file
172
renderers/html_renderer.py
Normal file
172
renderers/html_renderer.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
HTML 渲染器模块
|
||||
|
||||
将元素对象渲染为 HTML 代码,用于浏览器预览。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||||
|
||||
|
||||
# 固定 DPI 用于单位转换
|
||||
DPI = 96
|
||||
|
||||
|
||||
class HtmlRenderer:
|
||||
"""HTML 渲染器,将元素渲染为 HTML"""
|
||||
|
||||
def render_slide(self, slide_data, index, base_path):
|
||||
"""
|
||||
渲染单个幻灯片为 HTML
|
||||
|
||||
Args:
|
||||
slide_data: 包含 background 和 elements 的字典
|
||||
index: 幻灯片索引
|
||||
base_path: 基础路径
|
||||
|
||||
Returns:
|
||||
str: 幻灯片的 HTML 代码
|
||||
"""
|
||||
elements_html = ""
|
||||
|
||||
bg_style = ""
|
||||
if slide_data.get('background'):
|
||||
bg = slide_data['background']
|
||||
if 'color' in bg:
|
||||
bg_style = f"background: {bg['color']};"
|
||||
|
||||
for elem in slide_data.get('elements', []):
|
||||
try:
|
||||
if isinstance(elem, TextElement):
|
||||
elements_html += self.render_text(elem)
|
||||
elif isinstance(elem, ShapeElement):
|
||||
elements_html += self.render_shape(elem)
|
||||
elif isinstance(elem, TableElement):
|
||||
elements_html += self.render_table(elem)
|
||||
elif isinstance(elem, ImageElement):
|
||||
elements_html += self.render_image(elem, base_path)
|
||||
except Exception as e:
|
||||
elements_html += f'<div class="element" style="color: red;">渲染错误: {str(e)}</div>'
|
||||
|
||||
return f'''
|
||||
<div class="slide" style="{bg_style}">
|
||||
<div class="slide-number">幻灯片 {index + 1}</div>
|
||||
{elements_html}
|
||||
</div>
|
||||
'''
|
||||
|
||||
def render_text(self, elem: TextElement):
|
||||
"""
|
||||
将文本元素转换为 HTML
|
||||
|
||||
Args:
|
||||
elem: TextElement 对象
|
||||
|
||||
Returns:
|
||||
str: HTML 代码
|
||||
"""
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
font-size: {elem.font.get('size', 16)}pt;
|
||||
color: {elem.font.get('color', '#000000')};
|
||||
text-align: {elem.font.get('align', 'left')};
|
||||
{'font-weight: bold;' if elem.font.get('bold') else ''}
|
||||
{'font-style: italic;' if elem.font.get('italic') else ''}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
"""
|
||||
|
||||
content = elem.content.replace('<', '<').replace('>', '>')
|
||||
return f'<div class="element text-element" style="{style}">{content}</div>'
|
||||
|
||||
def render_shape(self, elem: ShapeElement):
|
||||
"""
|
||||
将形状元素转换为 HTML
|
||||
|
||||
Args:
|
||||
elem: ShapeElement 对象
|
||||
|
||||
Returns:
|
||||
str: HTML 代码
|
||||
"""
|
||||
border_radius = {
|
||||
'rectangle': '0',
|
||||
'ellipse': '50%',
|
||||
'rounded_rectangle': '8px'
|
||||
}.get(elem.shape, '0')
|
||||
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
background: {elem.fill if elem.fill else 'transparent'};
|
||||
border-radius: {border_radius};
|
||||
"""
|
||||
|
||||
if elem.line:
|
||||
style += f"""
|
||||
border: {elem.line.get('width', 1)}pt solid {elem.line.get('color', '#000000')};
|
||||
"""
|
||||
|
||||
return f'<div class="element shape-element" style="{style}"></div>'
|
||||
|
||||
def render_table(self, elem: TableElement):
|
||||
"""
|
||||
将表格元素转换为 HTML
|
||||
|
||||
Args:
|
||||
elem: TableElement 对象
|
||||
|
||||
Returns:
|
||||
str: HTML 代码
|
||||
"""
|
||||
table_style = f"""
|
||||
left: {elem.position[0] * DPI}px;
|
||||
top: {elem.position[1] * DPI}px;
|
||||
"""
|
||||
|
||||
rows_html = ""
|
||||
for i, row in enumerate(elem.data):
|
||||
cells_html = ""
|
||||
for cell in row:
|
||||
cell_style = f"font-size: {elem.style.get('font_size', 14)}pt;"
|
||||
|
||||
if i == 0:
|
||||
if 'header_bg' in elem.style:
|
||||
cell_style += f"background: {elem.style['header_bg']};"
|
||||
if 'header_color' in elem.style:
|
||||
cell_style += f"color: {elem.style['header_color']};"
|
||||
|
||||
cell_content = str(cell).replace('<', '<').replace('>', '>')
|
||||
cells_html += f'<td style="{cell_style}">{cell_content}</td>'
|
||||
rows_html += f'<tr>{cells_html}</tr>'
|
||||
|
||||
return f'<table class="element table-element" style="{table_style}">{rows_html}</table>'
|
||||
|
||||
def render_image(self, elem: ImageElement, base_path):
|
||||
"""
|
||||
将图片元素转换为 HTML
|
||||
|
||||
Args:
|
||||
elem: ImageElement 对象
|
||||
base_path: 基础路径
|
||||
|
||||
Returns:
|
||||
str: HTML 代码
|
||||
"""
|
||||
img_path = Path(base_path) / elem.src if base_path else Path(elem.src)
|
||||
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
"""
|
||||
|
||||
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'
|
||||
292
renderers/pptx_renderer.py
Normal file
292
renderers/pptx_renderer.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
PPTX 渲染器模块
|
||||
|
||||
封装 python-pptx 操作,将元素对象渲染为 PPTX 幻灯片。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pptx import Presentation as PptxPresentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.dml.color import RGBColor
|
||||
|
||||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from utils import hex_to_rgb
|
||||
|
||||
|
||||
class PptxGenerator:
|
||||
"""PPTX 生成器,封装 python-pptx 操作"""
|
||||
|
||||
def __init__(self, size='16:9'):
|
||||
"""
|
||||
初始化 PPTX 生成器
|
||||
|
||||
Args:
|
||||
size: 幻灯片尺寸("16:9" 或 "4:3")
|
||||
"""
|
||||
self.prs = PptxPresentation()
|
||||
|
||||
# 设置幻灯片尺寸
|
||||
if size == '16:9':
|
||||
self.prs.slide_width = Inches(10)
|
||||
self.prs.slide_height = Inches(5.625)
|
||||
elif size == '4:3':
|
||||
self.prs.slide_width = Inches(10)
|
||||
self.prs.slide_height = Inches(7.5)
|
||||
else:
|
||||
raise YAMLError(f"不支持的尺寸比例: {size},仅支持 16:9 和 4:3")
|
||||
|
||||
def add_slide(self, slide_data, base_path=None):
|
||||
"""
|
||||
添加幻灯片并渲染所有元素
|
||||
|
||||
Args:
|
||||
slide_data: 包含 background 和 elements 的字典
|
||||
base_path: 基础路径(用于相对路径解析)
|
||||
"""
|
||||
# 使用空白布局(layout[6])
|
||||
blank_layout = self.prs.slide_layouts[6]
|
||||
slide = self.prs.slides.add_slide(blank_layout)
|
||||
|
||||
# 设置背景
|
||||
background = slide_data.get('background')
|
||||
if background:
|
||||
self._render_background(slide, background, base_path)
|
||||
|
||||
# 按顺序渲染所有元素
|
||||
elements = slide_data.get('elements', [])
|
||||
for elem in elements:
|
||||
self._render_element(slide, elem, base_path)
|
||||
|
||||
def _render_element(self, slide, elem, base_path):
|
||||
"""
|
||||
分发元素到对应的渲染方法
|
||||
|
||||
Args:
|
||||
slide: pptx slide 对象
|
||||
elem: 元素对象
|
||||
base_path: 基础路径
|
||||
"""
|
||||
if isinstance(elem, TextElement):
|
||||
self._render_text(slide, elem)
|
||||
elif isinstance(elem, ImageElement):
|
||||
self._render_image(slide, elem, base_path)
|
||||
elif isinstance(elem, ShapeElement):
|
||||
self._render_shape(slide, elem)
|
||||
elif isinstance(elem, TableElement):
|
||||
self._render_table(slide, elem)
|
||||
|
||||
def _render_text(self, slide, elem: TextElement):
|
||||
"""
|
||||
渲染文本元素
|
||||
|
||||
Args:
|
||||
slide: pptx slide 对象
|
||||
elem: TextElement 对象
|
||||
"""
|
||||
# 获取位置和尺寸
|
||||
x, y, w, h = [Inches(v) for v in elem.box]
|
||||
|
||||
# 创建文本框
|
||||
textbox = slide.shapes.add_textbox(x, y, w, h)
|
||||
tf = textbox.text_frame
|
||||
tf.text = elem.content
|
||||
# 默认启用文字自动换行
|
||||
tf.word_wrap = True
|
||||
|
||||
# 应用字体样式
|
||||
p = tf.paragraphs[0]
|
||||
|
||||
# 字体大小
|
||||
if 'size' in elem.font:
|
||||
p.font.size = Pt(elem.font['size'])
|
||||
|
||||
# 粗体
|
||||
if elem.font.get('bold'):
|
||||
p.font.bold = True
|
||||
|
||||
# 斜体
|
||||
if elem.font.get('italic'):
|
||||
p.font.italic = True
|
||||
|
||||
# 颜色
|
||||
if 'color' in elem.font:
|
||||
rgb = hex_to_rgb(elem.font['color'])
|
||||
p.font.color.rgb = RGBColor(*rgb)
|
||||
|
||||
# 对齐方式
|
||||
align_map = {
|
||||
'left': PP_ALIGN.LEFT,
|
||||
'center': PP_ALIGN.CENTER,
|
||||
'right': PP_ALIGN.RIGHT
|
||||
}
|
||||
align = elem.font.get('align', 'left')
|
||||
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
|
||||
|
||||
def _render_image(self, slide, elem: ImageElement, base_path):
|
||||
"""
|
||||
渲染图片元素
|
||||
|
||||
Args:
|
||||
slide: pptx slide 对象
|
||||
elem: ImageElement 对象
|
||||
base_path: 基础路径
|
||||
"""
|
||||
# 获取图片路径
|
||||
img_path = Path(elem.src)
|
||||
|
||||
# 处理相对路径
|
||||
if not img_path.is_absolute() and base_path:
|
||||
img_path = Path(base_path) / elem.src
|
||||
|
||||
# 检查文件是否存在
|
||||
if not img_path.exists():
|
||||
raise YAMLError(f"图片文件未找到: {img_path}")
|
||||
|
||||
# 获取位置和尺寸
|
||||
x, y, w, h = [Inches(v) for v in elem.box]
|
||||
|
||||
# 添加图片
|
||||
try:
|
||||
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
|
||||
except Exception as e:
|
||||
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")
|
||||
|
||||
def _render_shape(self, slide, elem: ShapeElement):
|
||||
"""
|
||||
渲染形状元素
|
||||
|
||||
Args:
|
||||
slide: pptx slide 对象
|
||||
elem: ShapeElement 对象
|
||||
"""
|
||||
# 获取形状类型
|
||||
shape_type_map = {
|
||||
'rectangle': MSO_SHAPE.RECTANGLE,
|
||||
'ellipse': MSO_SHAPE.OVAL,
|
||||
'rounded_rectangle': MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
}
|
||||
mso_shape = shape_type_map.get(elem.shape, MSO_SHAPE.RECTANGLE)
|
||||
|
||||
# 获取位置和尺寸
|
||||
x, y, w, h = [Inches(v) for v in elem.box]
|
||||
|
||||
# 添加形状
|
||||
shape = slide.shapes.add_shape(mso_shape, x, y, w, h)
|
||||
|
||||
# 应用填充色
|
||||
if elem.fill:
|
||||
rgb = hex_to_rgb(elem.fill)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = RGBColor(*rgb)
|
||||
|
||||
# 应用边框样式
|
||||
if elem.line:
|
||||
if 'color' in elem.line:
|
||||
rgb = hex_to_rgb(elem.line['color'])
|
||||
shape.line.color.rgb = RGBColor(*rgb)
|
||||
if 'width' in elem.line:
|
||||
shape.line.width = Pt(elem.line['width'])
|
||||
|
||||
def _render_table(self, slide, elem: TableElement):
|
||||
"""
|
||||
渲染表格元素
|
||||
|
||||
Args:
|
||||
slide: pptx slide 对象
|
||||
elem: TableElement 对象
|
||||
"""
|
||||
# 获取表格数据
|
||||
rows = len(elem.data)
|
||||
cols = len(elem.data[0]) if elem.data else 0
|
||||
|
||||
# 获取列宽
|
||||
col_widths = elem.col_widths if elem.col_widths else [2] * cols
|
||||
if len(col_widths) != cols:
|
||||
raise YAMLError(f"列宽数量({len(col_widths)})与列数({cols})不匹配")
|
||||
|
||||
# 获取位置
|
||||
x, y = [Inches(v) for v in elem.position]
|
||||
|
||||
# 计算总宽度和高度
|
||||
total_width = Inches(sum(col_widths))
|
||||
row_height = Inches(0.5)
|
||||
|
||||
# 创建表格
|
||||
table = slide.shapes.add_table(rows, cols, x, y, total_width, row_height * rows).table
|
||||
|
||||
# 设置列宽
|
||||
for i, width in enumerate(col_widths):
|
||||
table.columns[i].width = Inches(width)
|
||||
|
||||
# 填充数据
|
||||
for i, row_data in enumerate(elem.data):
|
||||
for j, cell_value in enumerate(row_data):
|
||||
cell = table.cell(i, j)
|
||||
cell.text = str(cell_value)
|
||||
|
||||
# 应用样式
|
||||
# 字体大小
|
||||
if 'font_size' in elem.style:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
cell.text_frame.paragraphs[0].font.size = Pt(elem.style['font_size'])
|
||||
|
||||
# 表头样式
|
||||
if 'header_bg' in elem.style or 'header_color' in elem.style:
|
||||
for i, cell in enumerate(table.rows[0].cells):
|
||||
if 'header_bg' in elem.style:
|
||||
rgb = hex_to_rgb(elem.style['header_bg'])
|
||||
cell.fill.solid()
|
||||
cell.fill.fore_color.rgb = RGBColor(*rgb)
|
||||
if 'header_color' in elem.style:
|
||||
rgb = hex_to_rgb(elem.style['header_color'])
|
||||
cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*rgb)
|
||||
|
||||
def _render_background(self, slide, background, base_path=None):
|
||||
"""
|
||||
设置幻灯片背景
|
||||
|
||||
Args:
|
||||
slide: pptx slide 对象
|
||||
background: 背景字典,包含 color 或 image
|
||||
base_path: 基础路径
|
||||
"""
|
||||
if not background:
|
||||
return
|
||||
|
||||
# 纯色背景
|
||||
if 'color' in background:
|
||||
bg = slide.background
|
||||
fill = bg.fill
|
||||
fill.solid()
|
||||
rgb = hex_to_rgb(background['color'])
|
||||
fill.fore_color.rgb = RGBColor(*rgb)
|
||||
|
||||
# 图片背景(可选功能,简单实现)
|
||||
elif 'image' in background:
|
||||
# 图片背景需要更复杂的处理,暂时跳过
|
||||
from utils import log_info
|
||||
log_info(f"图片背景暂未实现: {background['image']}")
|
||||
|
||||
def save(self, output_path):
|
||||
"""
|
||||
保存 PPTX 文件
|
||||
|
||||
Args:
|
||||
output_path: 输出文件路径
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# 自动创建输出目录
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存文件
|
||||
try:
|
||||
self.prs.save(str(output_path))
|
||||
except PermissionError:
|
||||
raise YAMLError(f"权限不足,无法写入文件: {output_path}")
|
||||
except Exception as e:
|
||||
raise YAMLError(f"保存文件失败: {output_path}: {str(e)}")
|
||||
74
utils.py
Normal file
74
utils.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
工具函数模块
|
||||
|
||||
提供日志输出和颜色转换等通用功能。
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
# ============= 日志输出函数 =============
|
||||
|
||||
def log_info(message):
|
||||
"""输出信息日志"""
|
||||
print(f"[INFO] {message}")
|
||||
|
||||
|
||||
def log_success(message):
|
||||
"""输出成功日志"""
|
||||
print(f"[SUCCESS] ✓ {message}")
|
||||
|
||||
|
||||
def log_error(message):
|
||||
"""输出错误日志"""
|
||||
print(f"[ERROR] ✗ {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def log_progress(current, total, message=""):
|
||||
"""输出进度日志"""
|
||||
print(f"[PROGRESS] {current}/{total} {message}")
|
||||
|
||||
|
||||
# ============= 颜色转换函数 =============
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
"""
|
||||
将十六进制颜色转换为 RGB 元组
|
||||
|
||||
Args:
|
||||
hex_color: 十六进制颜色字符串,如 "#4a90e2" 或 "#fff"
|
||||
|
||||
Returns:
|
||||
tuple: (R, G, B) 元组
|
||||
"""
|
||||
hex_color = hex_color.lstrip('#')
|
||||
|
||||
# 处理短格式 #RGB -> #RRGGBB
|
||||
if len(hex_color) == 3:
|
||||
hex_color = ''.join([c*2 for c in hex_color])
|
||||
|
||||
if len(hex_color) != 6:
|
||||
raise ValueError(f"无效的颜色格式: #{hex_color}")
|
||||
|
||||
try:
|
||||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
raise ValueError(f"无效的颜色格式: #{hex_color}")
|
||||
|
||||
|
||||
def validate_color(color_value):
|
||||
"""
|
||||
验证颜色值格式(十六进制 #RRGGBB 或 #RGB)
|
||||
|
||||
Args:
|
||||
color_value: 颜色字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否有效
|
||||
"""
|
||||
import re
|
||||
if not isinstance(color_value, str):
|
||||
return False
|
||||
# 匹配 #RRGGBB 或 #RGB 格式
|
||||
pattern = r'^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$'
|
||||
return re.match(pattern, color_value) is not None
|
||||
1131
yaml2pptx.py
1131
yaml2pptx.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user