From ed940f06902549a16345cbe408ce61c3e809b3f0 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 2 Mar 2026 16:43:45 +0800 Subject: [PATCH] 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) --- .gitignore | 1 - README.md | 305 ++--- README_DEV.md | 435 +++++++ core/__init__.py | 0 core/elements.py | 96 ++ core/presentation.py | 91 ++ core/template.py | 191 +++ loaders/__init__.py | 0 loaders/yaml_loader.py | 113 ++ .../.openspec.yaml | 2 + .../design.md | 252 ++++ .../proposal.md | 52 + .../specs/element-abstraction/spec.md | 114 ++ .../specs/element-rendering/spec.md | 60 + .../specs/html-rendering/spec.md | 89 ++ .../specs/modular-architecture/spec.md | 99 ++ .../specs/pptx-generation/spec.md | 84 ++ .../tasks.md | 99 ++ openspec/config.yaml | 4 +- openspec/specs/element-abstraction/spec.md | 120 ++ openspec/specs/element-rendering/spec.md | 57 + openspec/specs/html-rendering/spec.md | 86 ++ openspec/specs/modular-architecture/spec.md | 105 ++ openspec/specs/pptx-generation/spec.md | 81 ++ preview/__init__.py | 0 preview/server.py | 244 ++++ renderers/__init__.py | 0 renderers/html_renderer.py | 172 +++ renderers/pptx_renderer.py | 292 +++++ utils.py | 74 ++ yaml2pptx.py | 1131 +---------------- 31 files changed, 3142 insertions(+), 1307 deletions(-) create mode 100644 README_DEV.md create mode 100644 core/__init__.py create mode 100644 core/elements.py create mode 100644 core/presentation.py create mode 100644 core/template.py create mode 100644 loaders/__init__.py create mode 100644 loaders/yaml_loader.py create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/design.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/proposal.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-abstraction/spec.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-rendering/spec.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/html-rendering/spec.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/modular-architecture/spec.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/pptx-generation/spec.md create mode 100644 openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/tasks.md create mode 100644 openspec/specs/element-abstraction/spec.md create mode 100644 openspec/specs/modular-architecture/spec.md create mode 100644 preview/__init__.py create mode 100644 preview/server.py create mode 100644 renderers/__init__.py create mode 100644 renderers/html_renderer.py create mode 100644 renderers/pptx_renderer.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore index 0bc82e2..4b4bebe 100644 --- a/.gitignore +++ b/.gitignore @@ -263,5 +263,4 @@ pnpm-lock.yaml # !examples/ # !assets/ - temp \ No newline at end of file diff --git a/README.md b/README.md index 2649252..4cc6417 100644 --- a/README.md +++ b/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 diff --git a/README_DEV.md b/README_DEV.md new file mode 100644 index 0000000..0b606c2 --- /dev/null +++ b/README_DEV.md @@ -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'' +``` + +### 添加新渲染器 + +假设要添加 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 高效监听文件变化 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/elements.py b/core/elements.py new file mode 100644 index 0000000..9ffd2f0 --- /dev/null +++ b/core/elements.py @@ -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}") diff --git a/core/presentation.py b/core/presentation.py new file mode 100644 index 0000000..c596421 --- /dev/null +++ b/core/presentation.py @@ -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 + } diff --git a/core/template.py b/core/template.py new file mode 100644 index 0000000..d7897b5 --- /dev/null +++ b/core/template.py @@ -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 diff --git a/loaders/__init__.py b/loaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/loaders/yaml_loader.py b/loaders/yaml_loader.py new file mode 100644 index 0000000..5090e52 --- /dev/null +++ b/loaders/yaml_loader.py @@ -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' 必须是一个列表") diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/.openspec.yaml b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/.openspec.yaml new file mode 100644 index 0000000..fd79bfc --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-02 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/design.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/design.md new file mode 100644 index 0000000..498a2c0 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/design.md @@ -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 中与用户充分讨论并确认。 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/proposal.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/proposal.md new file mode 100644 index 0000000..e1decb3 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/proposal.md @@ -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 预览等完整流程 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-abstraction/spec.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-abstraction/spec.md new file mode 100644 index 0000000..8f33521 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-abstraction/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-rendering/spec.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-rendering/spec.md new file mode 100644 index 0000000..b33b15f --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/element-rendering/spec.md @@ -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` 等属性 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/html-rendering/spec.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/html-rendering/spec.md new file mode 100644 index 0000000..956ff48 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/html-rendering/spec.md @@ -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 中都实现对应的渲染方法 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/modular-architecture/spec.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/modular-architecture/spec.md new file mode 100644 index 0000000..7d1533a --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/modular-architecture/spec.md @@ -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** 系统应正确解析并执行相应功能 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/pptx-generation/spec.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/pptx-generation/spec.md new file mode 100644 index 0000000..10349e2 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/specs/pptx-generation/spec.md @@ -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` 等属性 diff --git a/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/tasks.md b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/tasks.md new file mode 100644 index 0000000..460cafb --- /dev/null +++ b/openspec/changes/archive/2026-03-02-refactor-yaml2pptx-modular/tasks.md @@ -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 备份文件(如果测试通过) diff --git a/openspec/config.yaml b/openspec/config.yaml index 5934219..e8e7a1b 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -5,4 +5,6 @@ context: | 本项目编写的python脚本始终使用uv运行,脚本使用Inline script metadata来指定脚本的依赖包和python运行版本; 严禁直接使用主机环境的python直接执行脚本,严禁在主机环境直接安装python依赖包; 本项目编写的测试文件、临时文件必须放在temp目录下; - 严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作; \ No newline at end of file + 严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作; + 当前项目的面向用户的使用文档在README.md; + 当前项目的面向AI和开发者的开发规范文档在README_DEV.md; \ No newline at end of file diff --git a/openspec/specs/element-abstraction/spec.md b/openspec/specs/element-abstraction/spec.md new file mode 100644 index 0000000..8fccf5f --- /dev/null +++ b/openspec/specs/element-abstraction/spec.md @@ -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 diff --git a/openspec/specs/element-rendering/spec.md b/openspec/specs/element-rendering/spec.md index d3e9125..b896d8d 100644 --- a/openspec/specs/element-rendering/spec.md +++ b/openspec/specs/element-rendering/spec.md @@ -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 字段,仅支持已定义的元素类型。 diff --git a/openspec/specs/html-rendering/spec.md b/openspec/specs/html-rendering/spec.md index f8586f5..d661ddb 100644 --- a/openspec/specs/html-rendering/spec.md +++ b/openspec/specs/html-rendering/spec.md @@ -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` 类和模板渲染逻辑。 diff --git a/openspec/specs/modular-architecture/spec.md b/openspec/specs/modular-architecture/spec.md new file mode 100644 index 0000000..20307f0 --- /dev/null +++ b/openspec/specs/modular-architecture/spec.md @@ -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** 系统应正确解析并执行相应功能 diff --git a/openspec/specs/pptx-generation/spec.md b/openspec/specs/pptx-generation/spec.md index 755a89d..3ce70f9 100644 --- a/openspec/specs/pptx-generation/spec.md +++ b/openspec/specs/pptx-generation/spec.md @@ -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 采用两层架构(模板 + 演示文稿),颜色和样式直接在模板中定义。 diff --git a/preview/__init__.py b/preview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/preview/server.py b/preview/server.py new file mode 100644 index 0000000..a317ef5 --- /dev/null +++ b/preview/server.py @@ -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 = """ + + + + + YAML Preview + + + + {{ slides_html }} + + + + +""" + +ERROR_TEMPLATE = """ + + + + + 预览错误 + + + +
+

⚠️ YAML 解析错误

+
{{ error }}
+
+ + + +""" + + +# 全局变量 +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("已停止") diff --git a/renderers/__init__.py b/renderers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/renderers/html_renderer.py b/renderers/html_renderer.py new file mode 100644 index 0000000..2935aec --- /dev/null +++ b/renderers/html_renderer.py @@ -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'
渲染错误: {str(e)}
' + + return f''' +
+
幻灯片 {index + 1}
+ {elements_html} +
+ ''' + + 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'
{content}
' + + 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'
' + + 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'{cell_content}' + rows_html += f'{cells_html}' + + return f'{rows_html}
' + + 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'' diff --git a/renderers/pptx_renderer.py b/renderers/pptx_renderer.py new file mode 100644 index 0000000..f7e2246 --- /dev/null +++ b/renderers/pptx_renderer.py @@ -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)}") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..12d5227 --- /dev/null +++ b/utils.py @@ -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 diff --git a/yaml2pptx.py b/yaml2pptx.py index 1b60f8d..ba74f44 100644 --- a/yaml2pptx.py +++ b/yaml2pptx.py @@ -20,1131 +20,14 @@ YAML to PPTX Converter import sys import argparse -import re from pathlib import Path -import yaml -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 - -# ============= 日志输出函数 ============= - -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}") - - -# ============= 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_color(color_value): - """ - 验证颜色值格式(十六进制 #RRGGBB 或 #RGB) - - Args: - color_value: 颜色字符串 - - Returns: - bool: 是否有效 - """ - 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 - - -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' 必须是一个列表") - - - - -# ============= 模板系统 ============= - -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 - - -# ============= 元素渲染函数 ============= - -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 YAMLError(f"无效的颜色格式: #{hex_color}") - - try: - return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - except ValueError: - raise YAMLError(f"无效的颜色格式: #{hex_color}") - - -def add_text_element(slide, elem, base_path=None): - """ - 添加文本元素到幻灯片 - - Args: - slide: pptx slide 对象 - elem: 文本元素字典 - base_path: 基础路径(用于相对路径解析) - """ - # 获取位置和尺寸 - box = elem.get('box', [1, 1, 8, 1]) - x, y, w, h = [Inches(v) for v in box] - - # 创建文本框 - textbox = slide.shapes.add_textbox(x, y, w, h) - tf = textbox.text_frame - tf.text = elem.get('content', '') - # 默认启用文字自动换行 - tf.word_wrap = True - - # 应用字体样式 - font_style = elem.get('font', {}) - p = tf.paragraphs[0] - - # 字体大小 - if 'size' in font_style: - p.font.size = Pt(font_style['size']) - - # 粗体 - if font_style.get('bold'): - p.font.bold = True - - # 斜体 - if font_style.get('italic'): - p.font.italic = True - - # 颜色 - if 'color' in font_style: - rgb = hex_to_rgb(font_style['color']) - p.font.color.rgb = RGBColor(*rgb) - - # 对齐方式 - align_map = { - 'left': PP_ALIGN.LEFT, - 'center': PP_ALIGN.CENTER, - 'right': PP_ALIGN.RIGHT - } - align = font_style.get('align', 'left') - p.alignment = align_map.get(align, PP_ALIGN.LEFT) - - -def add_image_element(slide, elem, base_path=None): - """ - 添加图片元素到幻灯片 - - Args: - slide: pptx slide 对象 - elem: 图片元素字典 - base_path: 基础路径(用于相对路径解析) - """ - # 获取图片路径 - src = elem.get('src', '') - img_path = Path(src) - - # 处理相对路径 - if not img_path.is_absolute() and base_path: - img_path = Path(base_path) / src - - # 检查文件是否存在 - if not img_path.exists(): - raise YAMLError(f"图片文件未找到: {img_path}") - - # 获取位置和尺寸 - box = elem.get('box', [1, 1, 4, 3]) - x, y, w, h = [Inches(v) for v in 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 add_shape_element(slide, elem, base_path=None): - """ - 添加形状元素到幻灯片 - - Args: - slide: pptx slide 对象 - elem: 形状元素字典 - base_path: 基础路径(用于相对路径解析) - """ - # 获取形状类型 - shape_type_map = { - 'rectangle': MSO_SHAPE.RECTANGLE, - 'ellipse': MSO_SHAPE.OVAL, - 'rounded_rectangle': MSO_SHAPE.ROUNDED_RECTANGLE, - } - shape_type = elem.get('shape', 'rectangle') - mso_shape = shape_type_map.get(shape_type, MSO_SHAPE.RECTANGLE) - - # 获取位置和尺寸 - box = elem.get('box', [1, 1, 2, 1]) - x, y, w, h = [Inches(v) for v in box] - - # 添加形状 - shape = slide.shapes.add_shape(mso_shape, x, y, w, h) - - # 应用填充色 - if 'fill' in elem: - rgb = hex_to_rgb(elem['fill']) - shape.fill.solid() - shape.fill.fore_color.rgb = RGBColor(*rgb) - - # 应用边框样式 - if 'line' in elem: - line_style = elem['line'] - if 'color' in line_style: - rgb = hex_to_rgb(line_style['color']) - shape.line.color.rgb = RGBColor(*rgb) - if 'width' in line_style: - shape.line.width = Pt(line_style['width']) - - -def add_table_element(slide, elem, base_path=None): - """ - 添加表格元素到幻灯片 - - Args: - slide: pptx slide 对象 - elem: 表格元素字典 - base_path: 基础路径(用于相对路径解析) - """ - # 获取表格数据 - data = elem.get('data', []) - if not data: - raise YAMLError("表格数据不能为空") - - rows = len(data) - cols = len(data[0]) if data else 0 - - # 获取列宽 - col_widths = elem.get('col_widths', [2] * cols) - if len(col_widths) != cols: - raise YAMLError(f"列宽数量({len(col_widths)})与列数({cols})不匹配") - - # 获取位置 - position = elem.get('position', [1, 1]) - x, y = [Inches(v) for v in 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(data): - for j, cell_value in enumerate(row_data): - cell = table.cell(i, j) - cell.text = str(cell_value) - - # 应用样式 - style = elem.get('style', {}) - - # 字体大小 - if 'font_size' in style: - for row in table.rows: - for cell in row.cells: - cell.text_frame.paragraphs[0].font.size = Pt(style['font_size']) - - # 表头样式 - if 'header_bg' in style or 'header_color' in style: - for i, cell in enumerate(table.rows[0].cells): - if 'header_bg' in style: - rgb = hex_to_rgb(style['header_bg']) - cell.fill.solid() - cell.fill.fore_color.rgb = RGBColor(*rgb) - if 'header_color' in style: - rgb = hex_to_rgb(style['header_color']) - cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*rgb) - - -def set_slide_background(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: - # 图片背景需要更复杂的处理,暂时跳过 - log_info(f"图片背景暂未实现: {background['image']}") - - -def validate_element_type(elem): - """ - 验证元素类型 - - Args: - elem: 元素字典 - - Raises: - YAMLError: 元素类型无效或缺失 - """ - if 'type' not in elem: - raise YAMLError("元素缺少 'type' 字段") - - elem_type = elem['type'] - supported_types = ['text', 'image', 'shape', 'table'] - - if elem_type not in supported_types: - raise YAMLError( - f"不支持的元素类型: '{elem_type}'," - f"支持的类型: {', '.join(supported_types)}" - ) - - -# ============= 演示文稿和 PPTX 生成 ============= - -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) - - return { - 'background': background, - 'elements': elements - } - else: - # 自定义幻灯片 - return { - 'background': slide_data.get('background'), - 'elements': slide_data.get('elements', []) - } - - -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: - set_slide_background(slide, background, base_path) - - # 按顺序渲染所有元素 - elements = slide_data.get('elements', []) - for elem in elements: - # 验证元素类型 - validate_element_type(elem) - - elem_type = elem['type'] - if elem_type == 'text': - add_text_element(slide, elem, base_path) - elif elem_type == 'image': - add_image_element(slide, elem, base_path) - elif elem_type == 'shape': - add_shape_element(slide, elem, base_path) - elif elem_type == 'table': - add_table_element(slide, elem, base_path) - - 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)}") - - -# ============= 浏览器预览功能 ============= - -# 固定 DPI 用于单位转换 -DPI = 96 - -# HTML 模板 -HTML_TEMPLATE = """ - - - - - YAML Preview - - - - {{ slides_html }} - - - - -""" - -ERROR_TEMPLATE = """ - - - - - 预览错误 - - - -
-

⚠️ YAML 解析错误

-
{{ error }}
-
- - - -""" - - -def render_text_element_to_html(elem): - """将文本元素转换为 HTML""" - box = elem.get('box', [0, 0, 1, 1]) - font = elem.get('font', {}) - - style = f""" - left: {box[0] * DPI}px; - top: {box[1] * DPI}px; - width: {box[2] * DPI}px; - height: {box[3] * DPI}px; - font-size: {font.get('size', 16)}pt; - color: {font.get('color', '#000000')}; - text-align: {font.get('align', 'left')}; - {'font-weight: bold;' if font.get('bold') else ''} - {'font-style: italic;' if font.get('italic') else ''} - display: flex; - align-items: center; - white-space: normal; - overflow-wrap: break-word; - """ - - content = elem.get('content', '').replace('<', '<').replace('>', '>') - return f'
{content}
' - - -def render_shape_element_to_html(elem): - """将形状元素转换为 HTML""" - box = elem.get('box', [0, 0, 1, 1]) - - border_radius = { - 'rectangle': '0', - 'ellipse': '50%', - 'rounded_rectangle': '8px' - }.get(elem.get('shape', 'rectangle'), '0') - - style = f""" - left: {box[0] * DPI}px; - top: {box[1] * DPI}px; - width: {box[2] * DPI}px; - height: {box[3] * DPI}px; - background: {elem.get('fill', 'transparent')}; - border-radius: {border_radius}; - """ - - if 'line' in elem: - line = elem['line'] - style += f""" - border: {line.get('width', 1)}pt solid {line.get('color', '#000000')}; - """ - - return f'
' - - -def render_table_element_to_html(elem): - """将表格元素转换为 HTML""" - position = elem.get('position', [0, 0]) - data = elem.get('data', []) - style_config = elem.get('style', {}) - - table_style = f""" - left: {position[0] * DPI}px; - top: {position[1] * DPI}px; - """ - - rows_html = "" - for i, row in enumerate(data): - cells_html = "" - for cell in row: - cell_style = f"font-size: {style_config.get('font_size', 14)}pt;" - - if i == 0: - if 'header_bg' in style_config: - cell_style += f"background: {style_config['header_bg']};" - if 'header_color' in style_config: - cell_style += f"color: {style_config['header_color']};" - - cell_content = str(cell).replace('<', '<').replace('>', '>') - cells_html += f'{cell_content}' - rows_html += f'{cells_html}' - - return f'{rows_html}
' - - -def render_image_element_to_html(elem, base_path): - """将图片元素转换为 HTML""" - box = elem.get('box', [0, 0, 1, 1]) - src = elem.get('src', '') - - img_path = Path(base_path) / src if base_path else Path(src) - - style = f""" - left: {box[0] * DPI}px; - top: {box[1] * DPI}px; - width: {box[2] * DPI}px; - height: {box[3] * DPI}px; - """ - - return f'' - - -def render_slide_to_html(slide_data, index, base_path): - """渲染单个幻灯片为 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', []): - elem_type = elem.get('type') - try: - if elem_type == 'text': - elements_html += render_text_element_to_html(elem) - elif elem_type == 'shape': - elements_html += render_shape_element_to_html(elem) - elif elem_type == 'table': - elements_html += render_table_element_to_html(elem) - elif elem_type == 'image': - elements_html += render_image_element_to_html(elem, base_path) - except Exception as e: - elements_html += f'
渲染错误: {str(e)}
' - - return f''' -
-
幻灯片 {index + 1}
- {elements_html} -
- ''' - - -def generate_preview_html(yaml_file, template_dir): - """生成完整的预览 HTML 页面""" - try: - pres = Presentation(yaml_file, template_dir) - - slides_html = "" - for i, slide_data in enumerate(pres.data.get('slides', [])): - rendered = pres.render_slide(slide_data) - slides_html += render_slide_to_html(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)) - - -# Flask 应用和文件监听 -import queue -import webbrowser -import random -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 - -# 全局变量 -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 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("已停止") +# 导入模块 +from utils import log_info, log_success, log_error, log_progress +from loaders.yaml_loader import YAMLError +from core.presentation import Presentation +from renderers.pptx_renderer import PptxGenerator +from preview.server import start_preview_server def parse_args(): @@ -1196,7 +79,7 @@ def main(): start_preview_server(input_path, args.template_dir, args.port) return - # PPTX 生成模式(原有逻辑) + # PPTX 生成模式 # 处理输出文件路径 if args.output: output_path = Path(args.output)