1
0

Compare commits

...

7 Commits

Author SHA1 Message Date
7ef29ea039 feat: 实现幻灯片备注功能,将description写入PPT备注页
- 添加 PptxGenerator._set_notes() 方法设置备注
- 在 add_slide() 中调用 _set_notes() 处理 description
- 仅幻灯片级别的 description 写入备注,不继承模板
- 添加备注功能测试用例(8个测试)
- 更新 README.md 和 README_DEV.md 文档
- 新建 pptx-slide-notes spec
- 更新 page-description spec 允许写入备注
- 归档 add-slide-notes 变更
2026-03-04 14:47:03 +08:00
f34405be36 feat: 移除图片适配模式功能
移除图片 fit 和 background 参数支持,简化图片渲染逻辑。系统恢复到直接使用 python-pptx 原生图片添加功能,图片将被拉伸到指定尺寸。

变更内容:
- 移除 ImageElement 的 fit 和 background 字段
- 移除 metadata.dpi 配置
- 删除 utils/image_utils.py 图片处理工具模块
- 删除 validators/image_config.py 验证器
- 简化 PPTX 和 HTML 渲染器的图片处理逻辑
- HTML 渲染器使用硬编码 DPI=96(Web 标准)
- 删除相关测试文件(单元测试、集成测试、e2e 测试)
- 更新规格文档和用户文档
- 保留 Pillow 依赖用于未来可能的图片处理需求

影响:
- 删除 11 个文件
- 修改 10 个文件
- 净减少 1558 行代码
- 所有 402 个测试通过

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-04 14:23:12 +08:00
2fd8bc1b4a feat: 为metadata、模板和幻灯片添加description字段支持
添加可选的description字段用于文档目的,不影响渲染输出。

主要更改:
- core/presentation.py: 添加metadata.description属性
- core/template.py: 添加template.description属性
- tests: 添加16个新测试用例验证description功能
- docs: 更新README.md和README_DEV.md文档
- specs: 新增page-description规范文件
2026-03-04 13:22:33 +08:00
5d60f3c2c2 feat: 实现模板元素混合模式功能
新增混合模式,允许幻灯片同时使用 template 和 elements,实现更灵活的布局组合。

核心变更:
- core/presentation.py: 修改 render_slide() 支持三种模式(纯模板/纯自定义/混合模式)
- 自定义元素可访问模板变量,实现主题色等值的统一控制
- 元素采用简单追加策略合并(模板元素在前,自定义元素在后)
- 完全向后兼容现有用法

测试覆盖:
- 新增 TestRenderSlideHybridMode 测试类,包含 8 个测试用例
- 验证向后兼容性(纯模板模式、纯自定义模式)
- 验证混合模式功能(变量共享、空元素列表、元素顺序等)
- 所有 79 个测试通过

文档更新:
- README.md: 新增"混合模式模板"章节,包含语法示例和使用场景
- README_DEV.md: 更新开发文档,说明元素合并策略和实现细节

规范更新:
- openspec/specs/template-system/spec.md:
  - 修改"系统必须支持自定义幻灯片"需求,支持混合模式
  - 新增 4 个需求:变量共享、元素合并策略、向后兼容、内联模板支持
  - 新增 13 个场景定义

归档:
- openspec/changes/archive/2026-03-04-template-element-composition/: 完整变更记录
2026-03-04 13:12:51 +08:00
5b367f7ef3 fix: 修复多行文本渲染时字号只应用于第一段的bug
当使用 YAML 多行字符串语法定义文本内容时,python-pptx 会自动
将包含换行符的文本分割成多个段落。修改 _render_text 方法使其
遍历所有段落并应用相同的字体样式(大小、粗体、斜体、颜色、对齐)。

主要变更:
- renderers/pptx_renderer.py: 将 p = tf.paragraphs[0] 改为 for p in tf.paragraphs
- tests/unit/test_renderers/test_pptx_renderer.py: 新增多行文本测试用例
- openspec/specs/element-rendering/spec.md: 更新 spec 文档
- openspec/changes/archive/: 归档完成的变更
2026-03-04 12:18:23 +08:00
900a38b705 chroe: 增加claude允许内容 2026-03-04 10:31:26 +08:00
19d6661381 feat: 添加图片适配模式支持
- 支持四种图片适配模式:stretch、contain、cover、center
- 支持背景色填充功能(contain 和 center 模式)
- 支持文档级 DPI 配置(metadata.dpi)
- PPTX 渲染器集成 Pillow 实现高质量图片处理
- HTML 渲染器使用 CSS object-fit 实现相同效果
- 添加完整的单元测试、集成测试和端到端测试
- 更新 README 文档和架构文档
- 模块化设计:utils/image_utils.py 图片处理工具模块
- 添加图片配置验证器:validators/image_config.py
- 向后兼容:未指定 fit 时默认使用 stretch 模式
2026-03-04 10:29:21 +08:00
56 changed files with 4011 additions and 199 deletions

View File

@@ -7,7 +7,10 @@
"Bash(git:*)",
"Bash(uv:*)",
"Bash(ls:*)",
"Bash(wc:*)"
"Bash(wc:*)",
"Bash(curl:*)",
"mcp__context7__query-docs",
"mcp__exa__web_search_exa"
]
}
}

228
README.md
View File

@@ -117,6 +117,16 @@ slides:
align: center
```
#### description 字段
`metadata.description` 字段用于描述整个演示文稿的概要和用途,仅用于文档目的,不影响生成的 PPTX 文件:
```yaml
metadata:
size: "16:9"
description: "2024年度项目进展总结包含背景、成果和展望"
```
### 使用模板
```yaml
@@ -162,6 +172,16 @@ slides:
src: "path/to/image.png" # 支持相对路径和绝对路径
```
**示例**
```yaml
slides:
- elements:
- type: image
src: "photo.jpg"
box: [1, 1, 4, 3]
```
### 形状元素
```yaml
@@ -341,6 +361,214 @@ slides:
author: "作者"
```
#### 模板 description 字段
模板文件可以包含可选的 `description` 字段,用于描述模板的用途和设计意图,仅用于文档目的:
```yaml
# templates/title-slide.yaml
description: "用于章节标题页的模板,包含主标题和副标题"
vars:
- name: title
required: true
elements:
- type: text
box: [1, 2, 8, 1]
content: "{title}"
font:
size: 44
bold: true
```
### 混合模式模板
混合模式允许你在使用模板的同时添加自定义元素,实现更灵活的布局组合。
#### 基本用法
在使用模板的幻灯片中,同时指定 `template``elements` 字段:
```yaml
slides:
# 混合模式:模板 + 自定义元素
- template: standard-header
vars:
title: "混合模式示例"
theme_color: "#3949ab"
elements:
# 自定义内容区域
- type: text
box: [1, 1.5, 8, 1]
content: "这是自定义内容"
font:
size: 24
# 自定义形状
- type: shape
shape: rectangle
box: [1, 3, 8, 2]
fill: "#f5f5f5"
```
#### 变量共享
自定义元素可以访问模板中定义的变量:
```yaml
templates:
branded-header:
vars:
- name: title
- name: theme_color
default: "#3949ab"
elements:
- type: shape
box: [0, 0, 10, 0.8]
fill: "{theme_color}"
- type: text
box: [0.5, 0.2, 9, 0.5]
content: "{title}"
slides:
- template: branded-header
vars:
title: "我的页面"
theme_color: "#4caf50"
elements:
# 自定义元素使用模板变量
- type: shape
box: [1, 2, 8, 3]
fill: "{theme_color}" # 使用模板的 theme_color
```
#### 元素渲染顺序
混合模式中元素按以下顺序渲染z 轴顺序):
1. **模板元素**(先渲染,在底层)
2. **自定义元素**(后渲染,在上层)
这意味着自定义元素会覆盖在模板元素之上。
```yaml
slides:
- template: background-template # 提供背景和头部
vars:
title: "标题"
elements:
# 这些元素会显示在模板元素之上
- type: shape
box: [2, 2, 6, 3]
fill: "#ffffff" # 白色框会覆盖背景
```
#### 使用场景
**适合使用混合模式**
- 复用统一的头部/底部,自定义中间内容
- 使用模板提供的背景和品牌元素,添加页面特定内容
- 需要在标准布局基础上添加特殊元素
**示例:统一头部 + 自定义内容**
```yaml
templates:
standard-header:
vars:
- name: title
elements:
# 统一的头部样式
- type: shape
box: [0, 0, 10, 0.8]
fill: "#3949ab"
- type: text
box: [0.5, 0.2, 9, 0.5]
content: "{title}"
font:
color: "#ffffff"
slides:
# 页面 1头部 + 文本内容
- template: standard-header
vars:
title: "文本页面"
elements:
- type: text
box: [1, 1.5, 8, 3]
content: "页面内容..."
# 页面 2头部 + 表格
- template: standard-header
vars:
title: "数据页面"
elements:
- type: table
position: [1, 1.5]
data: [[...]]
# 页面 3头部 + 图片
- template: standard-header
vars:
title: "图片页面"
elements:
- type: image
box: [2, 1.5, 6, 3.5]
src: "chart.png"
```
#### 向后兼容性
混合模式完全向后兼容:
- **纯模板模式**:只指定 `template`,行为不变
- **纯自定义模式**:只指定 `elements`,行为不变
- **混合模式**:同时指定 `template``elements`,新功能
```yaml
slides:
# 纯模板模式(原有行为)
- template: title-slide
vars:
title: "标题"
# 纯自定义模式(原有行为)
- elements:
- type: text
content: "自定义内容"
# 混合模式(新功能)
- template: title-slide
vars:
title: "标题"
elements:
- type: text
content: "额外内容"
```
#### 幻灯片 description 字段
幻灯片可以包含可选的 `description` 字段,用于描述该幻灯片的作用和内容。**`description` 内容会自动写入 PPT 备注页**,方便在演示时查看演讲说明:
```yaml
slides:
- description: "介绍项目背景和目标"
template: title-slide
vars:
title: "项目背景"
- description: "展示核心功能特性"
elements:
- type: text
content: "功能特性"
```
**注意事项**
- 仅幻灯片级别的 `description` 会写入备注
- 模板的 `description` 不会继承到幻灯片备注
- `metadata.description` 用于描述整个演示文稿,不写入单个幻灯片备注
### 条件渲染
#### 元素级条件渲染

View File

@@ -84,11 +84,14 @@ yaml2pptx.py (入口)
- 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片
- 进度日志显示准确的渲染数量(不包括禁用的幻灯片)
### 2. utils.py(工具层)
### 2. utils/(工具层)
- **职责**:通用工具函数
- **包含**
- 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- 颜色转换:`hex_to_rgb()`, `validate_color()`
- `utils/__init__.py` - 日志和颜色工具
- 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- 颜色转换:`hex_to_rgb()`, `validate_color()`
- **依赖**
- Pillow (PIL) - 保留用于未来可能的图片处理需求
### 3. loaders/yaml_loader.py加载层
- **职责**YAML 文件加载和验证
@@ -109,6 +112,8 @@ yaml2pptx.py (入口)
- `_is_valid_color()` - 颜色格式验证工具函数
- `TextElement` - 文本元素dataclass + validate
- `ImageElement` - 图片元素dataclass + validate
- 新增字段:`fit` (适配模式), `background` (背景色)
- 支持四种适配模式stretch默认、contain、cover、center
- `ShapeElement` - 形状元素dataclass + validate
- `TableElement` - 表格元素dataclass + validate
- `create_element()` - 元素工厂函数
@@ -188,13 +193,57 @@ yaml2pptx.py (入口)
- 模板查找:`get_template()` 优先查找内联模板,然后回退到外部模板
- 同名检测:`_external_template_exists()` 检查外部模板是否存在,防止命名冲突
- 模板缓存:外部模板使用 `template_cache` 缓存
- 幻灯片渲染:`render_slide()`
- 幻灯片渲染:`render_slide()` - 支持三种模式
- **特点**
- 将元素字典转换为元素对象
- 使用 `create_element()` 工厂函数
- 内联和外部模板同名时抛出 ERROR 错误
- 内联模板不需要缓存(已在内存中)
#### 幻灯片渲染模式
`render_slide()` 方法支持三种渲染模式:
1. **纯模板模式**:只有 `template` 字段
- 渲染模板元素
- 向后兼容原有行为
2. **纯自定义模式**:只有 `elements` 字段
- 直接使用自定义元素
- 向后兼容原有行为
3. **混合模式**:同时有 `template``elements` 字段(新功能)
- 先渲染模板元素
- 自定义元素使用模板变量解析(通过 `Template.resolve_element()`
- 合并策略:简单追加(`template_elements + custom_elements`
- z 轴顺序:模板元素在底层,自定义元素在上层
#### 元素合并策略
混合模式采用简单追加策略:
```python
# 步骤1渲染模板如果有
elements_from_template = template.render(vars_values)
# 步骤2处理自定义元素如果有
if has_custom_elements and has_template:
# 使用模板变量解析自定义元素
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
# 步骤3合并元素模板在前自定义在后
final_elements = elements_from_template + elements_from_custom
```
**设计决策**
- 不支持按 key/id 合并(避免引入元素 ID 概念)
- 不支持位置感知合并(保持简单)
- 不检测元素重叠(用户通过预览模式查看效果)
-`elements: []` 等同于不指定 `elements`
### 7. renderers/pptx_renderer.py渲染层 - PPTX
- **职责**PPTX 文件生成
- **包含**
@@ -490,6 +539,56 @@ if right > slide_width + TOLERANCE:
- 检查变量定义是否有必需的 `name` 字段
- 检测默认值中引用不存在的变量
### 8. description 字段
**决策**:为 metadata、模板和幻灯片添加可选的 `description` 字段
**理由**
- 自文档化:提高模板和演示文稿的可读性和可维护性
- 备注支持:幻灯片 description 会写入 PPT 备注页,方便演讲者查看
- 完全向后兼容:字段为可选,现有文件无需修改
**实现要点**
1. **数据模型**
- `Presentation.description`:从 `metadata.description` 读取,用于描述整个演示文稿
- `Template.description`:从模板文件的 `description` 字段读取,描述模板用途
- `Slide.description`:在 `render_slide()` 返回值中保留,会写入 PPT 备注页
2. **YAML 解析**
- 使用 `.get('description')` 自动处理缺失情况(返回 None
- YAML 原生支持多行文本格式
3. **PPT 备注功能**
- 仅幻灯片级别的 `description` 会写入 PPT 备注页
- 模板的 `description` 不继承到幻灯片备注
- `metadata.description` 不写入单个幻灯片备注
- 如果幻灯片没有 `description`,则不设置备注
4. **渲染实现**
- `PptxGenerator._set_notes()`:设置幻灯片备注的私有方法
- `PptxGenerator.add_slide()`:调用 `_set_notes()` 设置备注
**示例**
```yaml
# metadata description - 描述整个演示文稿
metadata:
description: "2024年度项目进展总结"
# 模板 description - 描述模板用途
templates:
title-slide:
description: "用于章节标题页的模板"
elements: [...]
# 幻灯片 description - 写入 PPT 备注页
slides:
- description: "介绍项目背景和目标,包含以下要点:..."
template: title-slide
vars:
title: "项目背景"
```
## 扩展指南
### 添加新元素类型

View File

@@ -83,13 +83,13 @@ class ImageElement:
"""创建时验证"""
if not self.src:
raise ValueError("图片元素必须指定 src")
if not self.box:
raise ValueError("图片元素必须指定 box")
if not isinstance(self.box, list) or len(self.box) != 4:
raise ValueError("box 必须是包含 4 个数字的列表")
def validate(self) -> List:
"""验证元素自身的完整性"""
# ImageElement 的必需字段已在 __post_init__ 中检查
# 这里返回空列表,资源验证由 ResourceValidator 负责
return []

View File

@@ -31,6 +31,7 @@ class Presentation:
# 获取演示文稿尺寸
metadata = self.data.get("metadata", {})
self.size = metadata.get("size", "16:9")
self.description = metadata.get("description") # 可选的描述字段
# 验证尺寸值
if not isinstance(self.size, str):
@@ -83,36 +84,55 @@ class Presentation:
return template_path.exists()
def render_slide(self, slide_data):
"""
渲染单个幻灯片
渲染单个幻灯片(支持混合模式)
Args:
slide_data: 幻灯片数据字典
Returns:
dict: 包含 background 和 elements 的字典
支持三种模式:
1. 纯模板模式:只有 template 字段
2. 纯自定义模式:只有 elements 字段
3. 混合模式:同时有 template 和 elements 字段
"""
if "template" in slide_data:
# 使用模板
has_template = "template" in slide_data
has_custom_elements = slide_data.get("elements")
elements_from_template = []
vars_values = {}
# 步骤1渲染模板如果有
if has_template:
template_name = slide_data["template"]
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
elements = template.render(vars_values)
elements_from_template = template.render(vars_values)
# 合并背景(如果有)
background = slide_data.get("background", None)
# 步骤2处理自定义元素(如果有)
elements_from_custom = []
if has_custom_elements:
custom_elements = slide_data["elements"]
if has_template:
# 混合模式:使用模板变量解析自定义元素
template = self.get_template(slide_data["template"])
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
else:
# 纯自定义模式(原有行为)
elements_from_custom = custom_elements
# 将元素字典转换为元素对象
element_objects = [create_element(elem) for elem in elements]
# 步骤3合并元素模板元素在前自定义元素在后
final_elements = elements_from_template + elements_from_custom
return {"background": background, "elements": element_objects}
else:
# 自定义幻灯片
elements = slide_data.get("elements", [])
# 步骤4转换为元素对象
element_objects = [create_element(elem) for elem in final_elements]
# 将元素字典转换为元素对象
element_objects = [create_element(elem) for elem in elements]
return {
"background": slide_data.get("background"),
"elements": element_objects,
}
return {
"background": slide_data.get("background"),
"elements": element_objects,
"description": slide_data.get("description"), # 保留幻灯片描述
}

View File

@@ -54,6 +54,9 @@ class Template:
self.data = load_yaml_file(template_path)
validate_template_yaml(self.data, str(template_path))
# 可选的描述字段
self.description = self.data.get('description')
# 解析变量定义
self.vars_def = {}
for var in self.data.get('vars', []):
@@ -79,6 +82,9 @@ class Template:
# 初始化条件评估器
obj._condition_evaluator = ConditionEvaluator()
# 可选的描述字段
obj.description = template_data.get('description')
# 解析变量定义
obj.vars_def = {}
for var in template_data.get('vars', []):

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,82 @@
# Design: Add Description Field to Metadata, Templates, and Slides
## Context
当前项目使用YAML格式的模板文件和演示文稿定义。YAML文件包含metadata元数据部分和slides列表。模板系统`template-system` spec定义了模板的结构包括vars、elements等字段。演示文稿通过YAML文件定义slides列表每个slide可以引用模板或定义自定义元素。
项目目前没有内置的机制来描述文档整体、页面或幻灯片的用途。开发者只能通过元素内容和变量名来推断,这降低了演示文稿、模板和幻灯片的可维护性。
## Goals / Non-Goals
**Goals:**
- 为metadata添加可选的description字段
- 为模板文件添加可选的description字段
- 为幻灯片定义添加可选的description字段
- 确保description字段在数据模型中被正确解析和保留
- 保持完全的向后兼容性
- 不影响渲染逻辑和输出结果
**Non-Goals:**
- 不将description写入最终的PPTX文件PowerPoint备注功能不在本次范围
- 不实现description的验证逻辑如长度限制、格式要求
- 不实现基于description的搜索或过滤功能
## Decisions
### 1. Description字段为可选的字符串类型
**决策:** description字段为可选字段类型为字符串可以为空字符串。
**理由:**
- 保持简单,不过度设计
- 用户可以自由描述,不受格式限制
- 可选设计确保向后兼容
**替代方案考虑:**
- 使用结构化格式如对象包含title、content等子字段- 过于复杂,当前需求不需要
### 2. Description不参与渲染
**决策:** description字段仅用于文档目的不传递给渲染器不影响PPTX生成。
**理由:**
- PowerPoint的备注功能需要额外处理且不在当前需求范围
- 保持渲染逻辑简单,避免引入不必要的复杂性
**替代方案考虑:**
- 将description写入PPTX备注 - 需要额外的python-pptx API调用增加复杂度
### 3. 在数据模型层面实现
**决策:** 在数据模型类Metadata/Document、Template、Slide中添加description属性在YAML解析时读取该字段。
**理由:**
- 与现有代码结构一致
- 便于统一处理和访问
- metadata中的description可以描述整个文档
**替代方案考虑:**
- 仅在YAML解析时保留不添加到数据模型 - 会导致信息丢失,不利于后续使用
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|----------|
| description字段可能被滥用或包含不恰当内容 | 依赖用户自律,不添加内容验证 |
| 增加数据模型的字段数量 | 影响很小,仅增加一个可选字符串字段 |
| 用户可能期望description能影响输出 | 在文档中明确说明description仅用于文档目的 |
## Migration Plan
1. 更新数据模型类添加description属性Metadata、Template、Slide
2. 更新YAML解析器读取metadata、模板和幻灯片的description字段
3. 添加测试用例验证description正确读取和保留
4. 更新文档说明新字段的用法
**回滚策略:**
- 由于是新增可选字段,移除该字段不会影响现有功能
- 如需回滚只需从数据模型和解析器中移除description相关代码
## Open Questions
无。本设计较为简单直接,没有未解决的技术问题。

View File

@@ -0,0 +1,36 @@
# Proposal: Add Description Field to Pages and Slides
## Why
当前项目和模板系统中缺乏对文档、页面和幻灯片用途的内置说明机制。当用户或开发者查看演示文稿YAML时难以快速理解整个文档的概要、某个模板的设计意图或幻灯片的使用场景。添加description字段可以提供自文档化的能力提高演示文稿、模板和幻灯片的可维护性和可读性。
## What Changes
- 为metadata添加可选的 `description` 字段,用于描述整个演示文稿的概要和用途
- 为模板文件添加可选的 `description` 字段,用于说明该模板的用途和设计意图
- 为幻灯片定义添加可选的 `description` 字段,用于说明该幻灯片的作用和内容
- description字段为纯文本字符串完全可选不影响现有渲染逻辑
- 保留description字段以供工具和文档生成使用
## Capabilities
### New Capabilities
- `page-description`: 为模板页面和幻灯片添加描述字段的功能
### Modified Capabilities
无现有能力的需求变更,仅新增可选字段
## Impact
**受影响的代码模块:**
- `models/` - 元数据、模板和幻灯片数据模型需要支持description字段
- `yaml_parsing/` - YAML解析器需要读取metadata和模板、幻灯片的description字段
- `specs/` - 新增page-description规范文档
**不受影响的模块:**
- 渲染逻辑 - description字段不影响视觉渲染
- 输出PPTX - description不写入最终的PPTX文件
**向后兼容性:**
- 完全向后兼容description为可选字段
- 现有模板和YAML文件无需修改即可继续使用

View File

@@ -0,0 +1,130 @@
# Page Description
## Purpose
Page Description功能允许用户为文档元数据、模板和幻灯片添加描述信息用于说明文档概要、页面的用途、设计意图或内容概要。这提高了演示文稿、模板和幻灯片的可读性和可维护性。
## ADDED Requirements
### Requirement: metadata必须支持可选的description字段
演示文稿的metadata SHALL 支持可选的 `description` 字段,用于描述整个演示文稿的概要和用途。
#### Scenario: metadata包含description字段
- **WHEN** YAML文件的metadata定义了 `description: "这是关于项目年度总结的演示文稿"`
- **THEN** 系统成功加载该描述字段可通过metadata对象访问
#### Scenario: metadata不包含description字段
- **WHEN** YAML文件的metadata未定义 `description` 字段
- **THEN** 系统正常加载演示文稿description属性为None或空字符串
#### Scenario: metadata description为空字符串
- **WHEN** metadata定义了 `description: ""`
- **THEN** 系统接受空字符串作为有效值
### Requirement: 模板必须支持可选的description字段
模板 SHALL 支持可选的 `description` 字段,用于描述该模板的用途和设计意图。
#### Scenario: 模板包含description字段
- **WHEN** 模板文件定义了 `description: "用于章节标题页的模板,包含主标题和副标题"`
- **THEN** 系统成功加载该描述字段,可通过模板对象访问
#### Scenario: 模板不包含description字段
- **WHEN** 模板文件未定义 `description` 字段
- **THEN** 系统正常加载模板description属性为None或空字符串
#### Scenario: description为空字符串
- **WHEN** 模板文件定义了 `description: ""`
- **THEN** 系统接受空字符串作为有效值
### Requirement: 幻灯片必须支持可选的description字段
幻灯片定义 SHALL 支持可选的 `description` 字段,用于描述该幻灯片的作用和内容。
#### Scenario: 幻灯片包含description字段
- **WHEN** 幻灯片定义了 `description: "介绍项目背景和目标"`
- **THEN** 系统成功加载该描述字段,可通过幻灯片对象访问
#### Scenario: 幻灯片不包含description字段
- **WHEN** 幻灯片定义未包含 `description` 字段
- **THEN** 系统正常处理幻灯片description属性为None或空字符串
### Requirement: description字段不得影响渲染逻辑
系统 SHALL 在渲染过程中忽略 `description` 字段不影响最终的PPTX输出。
#### Scenario: 渲染包含description的模板
- **WHEN** 系统渲染包含 `description` 字段的模板
- **THEN** description不参与元素渲染不影响输出结果
#### Scenario: 渲染包含description的幻灯片
- **WHEN** 系统渲染包含 `description` 字段的幻灯片
- **THEN** description不写入PPTX文件不影响输出结果
### Requirement: YAML解析器必须正确解析description字段
系统 SHALL 从YAML文件中正确读取 `description` 字段,并将其传递给数据模型。
#### Scenario: 解析metadata中的description
- **WHEN** YAML文件的metadata包含 `description: "文档描述"`
- **THEN** 解析器将该字符串传递给metadata对象的description属性
#### Scenario: 解析模板中的description
- **WHEN** YAML文件中的模板包含 `description: "这是描述"`
- **THEN** 解析器将该字符串传递给模板对象的description属性
#### Scenario: 解析幻灯片中的description
- **WHEN** YAML文件中的幻灯片包含 `description: "幻灯片描述"`
- **THEN** 解析器将该字符串传递给幻灯片对象的description属性
### Requirement: 数据模型必须包含description属性
元数据、模板和幻灯片数据模型 SHALL 包含 `description` 属性,用于存储描述信息。
#### Scenario: metadata对象包含description属性
- **WHEN** 开发者访问metadata对象
- **THEN** 可以通过 `metadata.description` 获取描述信息
#### Scenario: 模板对象包含description属性
- **WHEN** 开发者访问模板对象
- **THEN** 可以通过 `template.description` 获取描述信息
#### Scenario: 幻灯片对象包含description属性
- **WHEN** 开发者访问幻灯片对象
- **THEN** 可以通过 `slide.description` 获取描述信息
### Requirement: description字段必须支持中文字符
系统 SHALL 支持在 `description` 字段中使用中文字符和其他Unicode字符。
#### Scenario: metadata description包含中文
- **WHEN** metadata的description包含中文字符如 "这是关于项目的演示文稿"
- **THEN** 系统正确处理,不出现编码错误
#### Scenario: description包含中文
- **WHEN** 模板或幻灯片的description包含中文字符如 "这是中文描述"
- **THEN** 系统正确处理,不出现编码错误
#### Scenario: description包含多行文本
- **WHEN** description字段使用YAML多行文本格式
- **THEN** 系统正确读取完整的描述内容

View File

@@ -0,0 +1,46 @@
# Tasks: Add Description Field to Metadata, Templates, and Slides
## 1. 数据模型更新
- [x] 1.1 在Metadata/Document类中添加description属性可选默认为None
- [x] 1.2 在Template类中添加description属性可选默认为None
- [x] 1.3 在Slide类中添加description属性可选默认为None
- [x] 1.4 更新Metadata/Document类的__init__方法接受description参数
- [x] 1.5 更新Template类的__init__方法接受description参数
- [x] 1.6 更新Slide类的__init__方法接受description参数
## 2. YAML解析器更新
- [x] 2.1 更新metadata解析逻辑读取YAML中的description字段
- [x] 2.2 更新模板解析逻辑读取模板YAML中的description字段
- [x] 2.3 更新幻灯片解析逻辑读取slides列表中的description字段
- [x] 2.4 确保description字段缺失时使用默认值None
- [x] 2.5 支持多行YAML格式的description文本
## 3. 测试用例
- [x] 3.1 添加测试metadata包含description字段时正确加载
- [x] 3.2 添加测试metadata不包含description字段时正常工作
- [x] 3.3 添加测试模板包含description字段时正确加载
- [x] 3.4 添加测试模板不包含description字段时正常工作
- [x] 3.5 添加测试幻灯片包含description字段时正确加载
- [x] 3.6 添加测试幻灯片不包含description字段时正常工作
- [x] 3.7 添加测试description包含中文字符时正确处理
- [x] 3.8 添加测试description为空字符串时正常工作
- [x] 3.9 添加测试description不影响渲染输出
## 4. 文档更新
- [x] 4.1 更新README.md说明metadata支持description字段
- [x] 4.2 更新README.md说明模板支持description字段
- [x] 4.3 更新README.md说明幻灯片支持description字段
- [x] 4.4 在README.md中添加description字段的使用示例
- [x] 4.5 更新README_DEV.md记录description字段的设计说明
- [x] 4.6 确保文档中说明description仅用于文档目的不影响输出
## 5. 验证和集成
- [x] 5.1 运行所有现有测试,确保向后兼容性
- [x] 5.2 运行新添加的测试用例,确保全部通过
- [x] 5.3 使用包含description的示例YAML进行端到端测试
- [x] 5.4 验证生成的PPTX文件不受description字段影响

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,260 @@
# 图片适配模式技术设计
## Context
### 当前状态
当前系统图片渲染逻辑非常简单:直接使用 python-pptx 的 `add_picture()` 方法,传入 box 的宽高参数。python-pptx 会自动将图片拉伸到指定尺寸,无法保持宽高比,也无法进行居中或裁剪处理。
```python
# 当前实现 (pptx_renderer.py)
def _render_image(self, slide, elem: ImageElement, base_path):
x, y, w, h = [Inches(v) for v in elem.box]
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
```
### 约束条件
- 必须保持向后兼容:未指定 `fit` 参数时,行为与当前一致
- PPTX 渲染使用英寸单位,图片处理需要像素单位
- HTML 预览需要与 PPTX 渲染保持一致的视觉效果
- DPI 配置需要同时影响两个渲染器
### 利益相关者
- 最终用户:需要多样化的图片适配选项
- HTML 预览用户:期望预览效果与最终 PPTX 一致
## Goals / Non-Goals
**Goals:**
- 支持四种图片适配模式stretch、contain、cover、center
- 支持留白区域的背景色填充
- 支持可配置的 DPI 转换
- HTML 预览与 PPTX 渲染效果一致
- 向后兼容现有 YAML 文件
**Non-Goals:**
- 不支持图片的旋转、翻转等变换
- 不支持图片滤镜、水印等高级效果
- 不支持 fill平铺模式
- 不支持自适应 box 尺寸box 必需,不是可选)
## Decisions
### 决策 1: 使用 Pillow 作为图片处理库
**选择理由:**
- Pillow 是 Python 生态中最成熟的图片处理库,从 PIL 发展而来
- 内置 `ImageOps` 模块,直接提供 `contain()``cover()` 方法,与我们的需求完全匹配
- API 简洁直观,社区活跃,文档完善
- 轻量级,专注图片处理,不像 OpenCV 那样重量级
**替代方案考虑:**
| 方案 | 优势 | 劣势 | 结论 |
|------|------|------|------|
| Pillow | API 简洁ImageOps 直接匹配需求 | 需要额外依赖 | ✅ 选择 |
| OpenCV | 功能强大,性能优异 | API 复杂BGR 颜色顺序需转换,过度设计 | ❌ |
| Wand | 功能丰富 | 依赖 ImageMagick安装复杂跨平台问题多 | ❌ |
| 原生实现 | 无额外依赖 | 需要手动实现所有算法,容易出错 | ❌ |
### 决策 2: DPI 作为文档级配置
**选择:**`metadata.dpi` 中配置,默认值 96
**理由:**
- DPI 影响整个文档的所有图片,不应在元素级别配置
- 96 是 Web 标准 DPI与当前 HTML 渲染器一致
- 简洁的配置方式,符合 YAML 声明式风格
**替代方案:** `metadata.image.dpi`(更结构化,但过于复杂)
### 决策 3: PPTX 和 HTML 使用不同的实现方式
**PPTX 实现:**
- 使用 Pillow 处理图片(缩放、裁剪)
- 计算居中位置
- 添加背景色画布(如有需要)
- 使用 python-pptx 添加处理后的图片
**HTML 实现:**
- 使用 CSS `object-fit` 属性
- `stretch``object-fit: fill`
- `contain``object-fit: contain`
- `cover``object-fit: cover`
- `center``object-fit: none` + `object-position: center`
- 背景色使用 CSS `background-color`
**理由:** 两个平台的原生能力不同,使用各自的最佳实践,但确保视觉效果一致
### 决策 4: 图片适配模式算法设计
```
┌─────────────────────────────────────────────────────────────────────┐
│ 图片适配算法流程 │
└─────────────────────────────────────────────────────────────────────┘
输入: img (PIL.Image), box_size (width, height), fit, background
1. stretch 模式
─────────────────────────────────────────────────────────────────
直接使用 img.resize(box_size),不考虑宽高比
2. contain 模式
─────────────────────────────────────────────────────────────────
result = ImageOps.contain(img, box_size)
# result.size <= box_size保持宽高比
if background:
canvas = 创建 box_size 画布,填充背景色
canvas.paste(result, 居中位置)
result = canvas
3. cover 模式
─────────────────────────────────────────────────────────────────
result = ImageOps.cover(img, box_size)
# result.size == box_size保持宽高比裁剪超出部分
4. center 模式
─────────────────────────────────────────────────────────────────
result = img # 不缩放
if img.width > box_size.width or img.height > box_size.height:
result = 裁剪到 box_size居中裁剪
if background:
canvas = 创建 box_size 画布,填充背景色
canvas.paste(result, 居中位置)
result = canvas
输出: result (PIL.Image), display_size, display_position
```
### 决策 6: 模块化设计
```
utils/image_utils.py
├── inches_to_pixels(inches, dpi) -> float
├── pixels_to_inches(pixels, dpi) -> float
├── calculate_contain_size(img_size, box_size) -> tuple
├── calculate_cover_size(img_size, box_size) -> tuple
├── calculate_center_offset(img_size, box_size) -> tuple
└── apply_fit_mode(img, box_size, fit, background) -> PIL.Image
validators/image_config.py (新建)
├── validate_fit_value(fit) -> List[ValidationIssue]
└── validate_fit_box_dependency(elem) -> List[ValidationIssue]
```
**理由:** 单一职责原则,图片处理逻辑与渲染逻辑分离,便于测试和复用
### 决策 5: 模块化设计
```
utils/image_utils.py
├── inches_to_pixels(inches, dpi) -> float
├── pixels_to_inches(pixels, dpi) -> float
├── calculate_contain_size(img_size, box_size) -> tuple
├── calculate_cover_size(img_size, box_size) -> tuple
├── calculate_center_offset(img_size, box_size) -> tuple
└── apply_fit_mode(img, box_size, fit, background) -> PIL.Image
validators/image_config.py (新建)
├── validate_fit_value(fit) -> List[ValidationIssue]
└── validate_background_color(color) -> List[ValidationIssue]
```
**理由:** 单一职责原则,图片处理逻辑与渲染逻辑分离,便于测试和复用
**注意:** 由于 box 参数为必填,不存在"没有指定 box"的情况,因此 `validate_fit_box_dependency` 验证器已移除。
## Risks / Trade-offs
### 风险 1: Pillow 依赖增加
**风险:** 新增外部依赖可能增加安装复杂度
**缓解措施:**
- Pillow 是 Python 生态标准库,安装简单(`pip install pillow`
- 在 pyproject.toml 中不指定版本号,使用最新稳定版
- 在 README 中明确说明依赖变更
### 风险 2: PPTX 和 HTML 渲染效果不完全一致
**风险:** 两个平台实现方式不同,可能在边缘情况下效果有差异
**缓解措施:**
- 核心算法(尺寸计算)使用相同的 Python 函数
- 编写集成测试,对比两种渲染器的输出
- 在文档中说明已知差异(如抗锯齿算法不同)
### 风险 3: 大图片处理性能问题
**风险:** Pillow 处理大图片可能较慢,占用内存
**缓解措施:**
- 仅在需要时才处理图片contain/cover/center 模式)
- stretch 模式直接使用 python-pptx 的原生处理,不经过 Pillow
- 文档中建议用户使用适当尺寸的图片
### 风险 4: DPI 配置不当导致尺寸错误
**风险:** 用户设置的 DPI 与实际使用场景不符,导致图片尺寸不符合预期
**缓解措施:**
- 默认值 96 适用于大多数场景
- 在 README 中说明 DPI 的含义和影响
- 验证器检查 DPI 值是否合理(如 72-300 之间)
## Migration Plan
### 部署步骤
1. **代码变更**
- 添加 Pillow 依赖到 pyproject.toml
- 创建 `utils/image_utils.py`
- 创建 `validators/image_config.py`
- 更新 `core/elements.py` 的 ImageElement
- 更新 `renderers/pptx_renderer.py`
- 更新 `renderers/html_renderer.py`
2. **测试**
- 单元测试image_utils 的各个函数
- 单元测试validators 的图片配置验证
- 集成测试:四种 fit 模式的 PPTX 渲染
- 集成测试:四种 fit 模式的 HTML 渲染
- 端到端测试:完整 YAML 转换流程
3. **文档更新**
- 更新 README.md添加图片适配模式说明
- 更新 README_DEV.md添加架构说明
### 回滚策略
- 如果发现严重问题,可以回退到之前版本
- 向后兼容设计确保未指定 fit 参数的 YAML 文件仍能正常工作
- 回滚后用户只需删除 fit 和 background 参数即可
## Open Questions
### Q1: 是否需要在图片处理失败时提供降级方案?
**决策:** 图片处理失败时抛出 ERROR 级别错误,让用户修复图片问题,不提供降级方案。
**理由:** 保持简单明确,图片问题应该由用户在源头解决,而不是掩盖问题。
### Q2: background 参数是否支持渐变色?
**决策:** background 参数仅支持纯色,不支持渐变色。
**理由:** 简化实现,满足绝大多数使用场景。如有后续需求,可以作为独立功能添加。
### Q3: 是否需要支持图片质量设置?
**决策:** 使用 Pillow 的最高质量重采样算法LANCZOS不向用户暴露配置选项。
**理由:** 演示文稿场景下图片质量优先于处理速度,使用最高质量算法避免用户困惑。

View File

@@ -0,0 +1,44 @@
# 图片适配模式支持
## Why
当前图片元素渲染仅支持简单的拉伸模式,图片会被强制缩放到 box 指定的尺寸,导致图片变形或宽高比失真。实际使用中,用户需要保持图片宽高比、居中显示、填充裁剪等多种适配模式,以满足不同场景的视觉需求。
## What Changes
- 新增图片 `fit` 参数,支持四种适配模式:`stretch`(拉伸)、`contain`(包含)、`cover`(覆盖)、`center`(居中)
- 新增图片 `background` 参数,支持指定留白区域的填充颜色(默认透明)
- 新增文档级 `dpi` 配置(`metadata.dpi`),用于像素与英寸的转换,默认值为 96
- 引入 Pillow 库进行图片处理,利用其 ImageOps 模块实现各种适配模式
- 保持向后兼容:未指定 `fit` 时默认使用 `stretch` 模式(当前行为)
## Capabilities
### New Capabilities
- `image-fit-modes`: 图片元素适配模式处理,支持 stretch、contain、cover、center 四种模式,以及背景色填充和 DPI 配置
### Modified Capabilities
- `element-rendering`: 扩展图片元素渲染能力,新增 `fit``background` 参数支持
- `html-rendering`: 同步扩展 HTML 预览的图片渲染能力,支持 `fit``background` 参数,使用 CSS object-fit 实现
## Impact
### 依赖变更
- 新增 Pillow 依赖(不指定版本号,使用最新版)
### 代码变更
- `core/elements.py`: ImageElement 新增 `fit``background` 字段
- `renderers/pptx_renderer.py`: 重写 `_render_image()` 方法,集成 Pillow 图片处理
- `renderers/html_renderer.py`: 更新 `_render_image()` 方法,使用 CSS object-fit 实现适配模式
- `validators/`: 新增图片参数验证器fit 值校验、background 颜色校验)
- `utils/image_utils.py`: 新增图片处理工具模块(像素转换、尺寸计算、居中定位)
### API 变更
- YAML 语法扩展:
- `metadata` 层级新增可选的 `dpi` 字段
- 图片元素新增可选的 `fit` 字段
- 图片元素新增可选的 `background` 字段
### 文档变更
- `README.md`: 新增图片适配模式使用说明和示例
- `README_DEV.md`: 新增图片处理架构说明

View File

@@ -0,0 +1,76 @@
# Element Rendering - Delta Spec
本 spec 是对 `openspec/specs/element-rendering/spec.md` 的增量修改。
## MODIFIED Requirements
### Requirement: 系统必须支持图片元素渲染
系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象,支持 `fit``background` 参数控制图片适配模式。
#### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统从指定路径加载图片,在 (2, 3) 位置渲染为 4×3 英寸大小
- **AND** 使用默认的 `stretch` 模式(向后兼容)
#### Scenario: 使用 fit 模式渲染图片
- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
- **THEN** 系统使用 `contain` 模式渲染图片
- **AND** 保持图片宽高比,完整显示在 box 内
#### Scenario: 使用背景色渲染图片
- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
- **THEN** 系统使用 `contain` 模式渲染图片
- **AND** 用 #f0f0f0 颜色填充留白区域
#### Scenario: 图片文件不存在时报错
- **WHEN** 图片 src 指向不存在的文件路径
- **THEN** 系统抛出错误,明确指出图片文件未找到
#### Scenario: 图片格式不支持时报错
- **WHEN** 图片文件格式不被 Pillow 支持
- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式
#### Scenario: 图片处理失败时报错
- **WHEN** Pillow 处理图片时发生异常(如文件损坏)
- **THEN** 系统抛出 ERROR 级别错误,不降级到其他模式
#### Scenario: 相对路径处理
- **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"`
- **THEN** 系统基于演示文稿文件所在目录解析相对路径
#### Scenario: 使用 DPI 配置渲染图片
- **WHEN** metadata 定义了 `dpi: 120` 且图片需要处理
- **THEN** 系统使用该 DPI 值进行像素与英寸的转换
#### Scenario: fit 参数值无效时报错
- **WHEN** `fit` 参数值不是 stretch、contain、cover、center 之一
- **THEN** 系统抛出 ERROR并列出有效值
#### Scenario: background 参数颜色格式无效时报错
- **WHEN** `background` 值不是有效的颜色格式
- **THEN** 系统抛出 ERROR提示颜色格式应为 #RRGGBB#RGB
### Requirement: 图片元素的 box 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
#### Scenario: 缺少 box 参数时报错
- **WHEN** 图片元素未定义 `box` 参数
- **THEN** 系统抛出 ERROR提示 box 参数为必需
#### Scenario: box 参数格式正确
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]`
- **THEN** 系统验证通过,将 box 用于图片定位和尺寸

View File

@@ -0,0 +1,83 @@
# HTML Rendering - Delta Spec
本 spec 是对 `openspec/specs/html-rendering/spec.md` 的增量修改。
## MODIFIED Requirements
### Requirement: 系统必须渲染图片元素
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,支持 `fit``background` 参数,使用 CSS object-fit 实现适配模式。
#### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统生成 `<img>` 标签src 为图片的文件路径,位置为 (192px, 288px),尺寸为 384x288 像素
- **AND** 使用默认的 CSS 样式 `object-fit: fill`(等同于 stretch 模式)
#### Scenario: 使用 fit 模式渲染图片
- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
- **THEN** 系统应用 CSS `object-fit: contain`
- **AND** 保持图片宽高比,完整显示在 box 内
#### Scenario: fit 模式与 CSS object-fit 映射
- **WHEN** `fit` 参数为 `stretch`
- **THEN** 系统应用 CSS `object-fit: fill`
- **WHEN** `fit` 参数为 `contain`
- **THEN** 系统应用 CSS `object-fit: contain`
- **WHEN** `fit` 参数为 `cover`
- **THEN** 系统应用 CSS `object-fit: cover`
- **WHEN** `fit` 参数为 `center`
- **THEN** 系统应用 CSS `object-fit: none``object-position: center`
#### Scenario: 使用背景色渲染图片
- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
- **THEN** 系统为图片容器添加 CSS `background-color: #f0f0f0`
- **AND** 应用 CSS `object-fit: contain`
#### Scenario: 图片容器支持背景色
- **WHEN** 图片指定了 `background` 参数
- **THEN** 系统创建包装容器,应用背景色到容器
- **AND** 图片在容器内使用 object-fit 定位
#### Scenario: 处理相对路径
- **WHEN** 图片 src 使用相对路径 `"assets/logo.png"`
- **THEN** 系统基于 YAML 文件所在目录解析相对路径
#### Scenario: 图片不存在时显示占位符
- **WHEN** 图片文件不存在
- **THEN** 系统显示占位符或错误提示,而不是崩溃
#### Scenario: 使用 DPI 配置渲染图片
- **WHEN** metadata 定义了 `dpi: 120`
- **THEN** 系统使用该 DPI 值进行英寸到像素的转换
- **AND** box: [1, 1, 4, 3] 转换为 CSS: left: 120px; top: 120px; width: 480px; height: 360px
#### Scenario: HTML 渲染与 PPTX 渲染效果一致
- **WHEN** 同一图片元素使用相同的 fit 和 background 参数
- **THEN** HTML 预览和 PPTX 输出的视觉效果应保持一致
- **AND** 图片位置、尺寸、适配方式相同
### Requirement: 图片元素的 box 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
#### Scenario: 缺少 box 参数时报错
- **WHEN** 图片元素未定义 `box` 参数
- **THEN** 系统抛出 ERROR提示 box 参数为必需
#### Scenario: box 参数转换为像素
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]` 且 DPI 为 96
- **THEN** 系统转换为 CSSleft: 96px; top: 192px; width: 384px; height: 288px

View File

@@ -0,0 +1,201 @@
# Image Fit Modes
## Purpose
图片适配模式能力为图片元素提供多种适配策略,允许用户控制图片在指定区域内的显示方式,包括拉伸、保持比例、填充裁剪和居中显示。同时支持 DPI 配置和背景色填充,满足不同场景的视觉需求。
## Requirements
### Requirement: 系统必须支持图片 fit 参数
系统 SHALL 支持图片元素的 `fit` 参数,允许用户指定图片适配模式。
#### Scenario: fit 参数支持四种模式
- **WHEN** 图片元素定义了 `fit` 参数
- **THEN** 系统支持 `stretch``contain``cover``center` 四种模式
#### Scenario: fit 参数默认值为 stretch
- **WHEN** 图片元素未指定 `fit` 参数
- **THEN** 系统使用 `stretch` 模式(向后兼容)
#### Scenario: fit 参数值无效时报错
- **WHEN** `fit` 参数值不是四种有效模式之一
- **THEN** 系统抛出 ERROR并列出有效值stretch、contain、cover、center
### Requirement: 系统必须支持 stretch 模式
系统 SHALL 在 `stretch` 模式下将图片强制缩放到 box 指定的尺寸,不考虑宽高比。
#### Scenario: stretch 模式拉伸图片
- **WHEN** 图片元素定义了 `fit: stretch` 或未指定 `fit`
- **THEN** 系统将图片拉伸到 box 的宽高尺寸
- **AND** 图片可能变形
#### Scenario: stretch 模式不考虑背景色
- **WHEN** 图片使用 `fit: stretch` 并指定了 `background`
- **THEN** 背景色参数被忽略(无留白区域)
### Requirement: 系统必须支持 contain 模式
系统 SHALL 在 `contain` 模式下保持图片宽高比,完整显示图片在 box 内,可能有留白。
#### Scenario: contain 模式保持宽高比
- **WHEN** 图片元素定义了 `fit: contain`
- **THEN** 系统缩放图片使其完整显示在 box 内
- **AND** 保持原始宽高比
- **AND** 图片尺寸不超过 box 尺寸
#### Scenario: contain 模式图片居中
- **WHEN** 图片使用 `fit: contain` 且小于 box 尺寸
- **THEN** 系统将图片居中显示在 box 内
#### Scenario: contain 模式支持背景色
- **WHEN** 图片使用 `fit: contain` 并指定了 `background`
- **THEN** 系统用指定颜色填充 box 内的留白区域
#### Scenario: contain 模式图片比 box 大
- **WHEN** 图片原始尺寸大于 box 尺寸
- **THEN** 系统等比缩小图片使其完整显示在 box 内
#### Scenario: contain 模式图片比 box 小
- **WHEN** 图片原始尺寸小于 box 尺寸
- **THEN** 系统保持原始尺寸,居中显示在 box 内
### Requirement: 系统必须支持 cover 模式
系统 SHALL 在 `cover` 模式下保持图片宽高比,填充整个 box裁剪超出部分。
#### Scenario: cover 模式保持宽高比
- **WHEN** 图片元素定义了 `fit: cover`
- **THEN** 系统缩放图片使其填满 box
- **AND** 保持原始宽高比
- **AND** 裁剪超出 box 的部分
#### Scenario: cover 模式图片居中裁剪
- **WHEN** 图片使用 `fit: cover` 且需要裁剪
- **THEN** 系统从图片中心进行裁剪
#### Scenario: cover 模式不考虑背景色
- **WHEN** 图片使用 `fit: cover` 并指定了 `background`
- **THEN** 背景色参数被忽略(无留白区域)
#### Scenario: cover 模式图片比 box 大
- **WHEN** 图片原始尺寸大于 box 尺寸
- **THEN** 系统等比缩小图片并裁剪超出部分
#### Scenario: cover 模式图片比 box 小
- **WHEN** 图片原始尺寸小于 box 尺寸
- **THEN** 系统等比放大图片并裁剪超出部分
### Requirement: 系统必须支持 center 模式
系统 SHALL 在 `center` 模式下按原始尺寸居中显示图片,不缩放,超出 box 的部分被裁剪。
#### Scenario: center 模式不缩放图片
- **WHEN** 图片元素定义了 `fit: center`
- **THEN** 系统保持图片原始尺寸,不进行缩放
#### Scenario: center 模式图片居中
- **WHEN** 图片使用 `fit: center`
- **THEN** 系统将图片居中显示在 box 内
#### Scenario: center 模式裁剪超出部分
- **WHEN** 图片原始尺寸大于 box 尺寸
- **THEN** 系统裁剪超出 box 的部分(从中心裁剪)
#### Scenario: center 模式支持背景色
- **WHEN** 图片使用 `fit: center` 并指定了 `background`
- **THEN** 系统用指定颜色填充 box 内的留白区域
### Requirement: 系统必须支持 background 参数
系统 SHALL 支持图片元素的 `background` 参数,允许用户指定留白区域的填充颜色。
#### Scenario: background 参数默认透明
- **WHEN** 图片元素未指定 `background` 参数
- **THEN** 留白区域保持透明
#### Scenario: background 参数支持纯色
- **WHEN** 图片元素指定了 `background: "#ff0000"`
- **THEN** 系统使用指定颜色填充留白区域
#### Scenario: background 参数不支持渐变
- **WHEN** 图片元素指定了渐变色(如 `"linear-gradient(...)"`
- **THEN** 系统抛出 ERROR提示仅支持纯色
#### Scenario: background 参数颜色格式验证
- **WHEN** `background` 值不是有效的颜色格式
- **THEN** 系统抛出 ERROR提示颜色格式应为 #RRGGBB#RGB
### Requirement: 系统必须支持文档级 DPI 配置
系统 SHALL 支持在 `metadata.dpi` 中配置 DPI 值,用于像素与英寸的转换。
#### Scenario: DPI 默认值为 96
- **WHEN** metadata 未指定 `dpi` 参数
- **THEN** 系统使用默认值 96
#### Scenario: DPI 配置影响所有图片
- **WHEN** metadata 指定了 `dpi: 120`
- **THEN** 系统使用该值进行所有图片的像素与英寸转换
#### Scenario: DPI 值验证
- **WHEN** `dpi` 值超出合理范围(如小于 72 或大于 300
- **THEN** 系统发出 WARNING提示 DPI 值可能不合适
### Requirement: 系统必须在图片处理失败时抛出错误
系统 SHALL 在图片处理失败时抛出 ERROR 级别错误,不提供降级方案。
#### Scenario: 损坏的图片文件
- **WHEN** Pillow 无法读取图片文件(文件损坏或格式不支持)
- **THEN** 系统抛出 ERROR明确指出图片文件问题
- **AND** 不降级到其他模式
#### Scenario: 图片处理异常
- **WHEN** Pillow 处理图片时发生异常(如内存不足)
- **THEN** 系统抛出 ERROR包含异常信息
- **AND** 不降级到其他模式
### Requirement: 系统必须使用最高质量的图片处理算法
系统 SHALL 使用 Pillow 的最高质量重采样算法LANCZOS进行图片缩放。
#### Scenario: 图片缩放使用 LANCZOS
- **WHEN** 系统需要缩放图片contain、cover 模式)
- **THEN** 使用 Pillow 的 LANCZOS 重采样算法
- **AND** 不向用户暴露质量配置选项
#### Scenario: 图片裁剪保持质量
- **WHEN** 系统需要裁剪图片cover、center 模式)
- **THEN** 裁剪操作不损失图片质量

View File

@@ -0,0 +1,114 @@
# 图片适配模式实现任务清单
## 1. 依赖配置
- [x] 1.1 在 pyproject.toml 中添加 Pillow 依赖(不指定版本号)
- [x] 1.2 运行 `uv sync` 安装新增依赖
## 2. 核心元素扩展
- [x] 2.1 更新 `core/elements.py` 的 ImageElement 类,添加 `fit` 字段(可选,默认 None
- [x] 2.2 更新 `core/elements.py` 的 ImageElement 类,添加 `background` 字段(可选,默认 None
- [x] 2.3 更新 `core/elements.py` 的 ImageElement.__post_init__验证 box 参数为必需
- [x] 2.4 更新 `core/elements.py` 的 ImageElement.validate 方法,添加 fit 和 background 验证
## 3. 图片处理工具模块
- [x] 3.1 创建 `utils/image_utils.py` 模块
- [x] 3.2 实现 `inches_to_pixels(inches, dpi) -> float` 函数
- [x] 3.3 实现 `pixels_to_inches(pixels, dpi) -> float` 函数
- [x] 3.4 实现 `apply_fit_mode(img, box_size, fit, background) -> PIL.Image` 函数,支持四种模式
- [x] 3.5 实现 `create_canvas_with_background(size, background_color) -> PIL.Image` 函数
- [x] 3.6 在 `apply_fit_mode` 中使用 Pillow.ImageOps.contain 实现 contain 模式
- [x] 3.7 在 `apply_fit_mode` 中使用 Pillow.ImageOps.cover 实现 cover 模式
- [x] 3.8 在 `apply_fit_mode` 中实现 center 模式(不缩放,居中裁剪)
- [x] 3.9 在 `apply_fit_mode` 中使用 LANCZOS 重采样算法保证图片质量
## 4. 验证器
- [x] 4.1 创建 `validators/image_config.py` 模块
- [x] 4.2 实现 `validate_fit_value(fit) -> List[ValidationIssue]` 函数,验证 fit 为有效值
- [x] 4.3 实现 `validate_background_color(color) -> List[ValidationIssue]` 函数,验证颜色格式
- [x] 4.4 实现 `validate_dpi_value(dpi) -> List[ValidationIssue]` 函数,验证 DPI 在合理范围
- [x] 4.5 在主验证流程中集成图片配置验证器
## 5. PPTX 渲染器更新
- [x] 5.1 更新 `renderers/pptx_renderer.py`,导入 Pillow 和 image_utils
- [x] 5.2 重写 `_render_image` 方法,读取 metadata.dpi 配置
- [x] 5.3 在 `_render_image` 中实现 stretch 模式(直接使用 python-pptx
- [x] 5.4 在 `_render_image` 中实现 contain 模式(使用 Pillow 处理)
- [x] 5.5 在 `_render_image` 中实现 cover 模式(使用 Pillow 处理)
- [x] 5.6 在 `_render_image` 中实现 center 模式(使用 Pillow 处理)
- [x] 5.7 在 `_render_image` 中实现背景色填充(创建画布并粘贴图片)
- [x] 5.8 在 `_render_image` 中添加图片处理失败的错误处理(抛出 ERROR
- [x] 5.9 更新 PptxGenerator 类,传递 dpi 参数到渲染方法
## 6. HTML 渲染器更新
- [x] 6.1 更新 `renderers/html_renderer.py``_render_image` 方法
- [x] 6.2 实现 fit 模式到 CSS object-fit 的映射stretch->fill, contain->contain, cover->cover, center->none
- [x] 6.3 在 HTML 渲染中添加 object-position: center 样式center 模式)
- [x] 6.4 实现背景色支持(创建包装容器,应用 background-color
- [x] 6.5 更新 HTML 渲染器读取 metadata.dpi 配置
- [x] 6.6 确保 HTML 和 PPTX 渲染效果一致
## 7. 加载器和配置支持
- [x] 7.1 更新 `loaders/yaml_loader.py`,解析 metadata.dpi 配置
- [x] 7.2 将 dpi 配置传递到 Presentation 对象
- [x] 7.3 在 PptxGenerator 和 HtmlRenderer 中访问 dpi 配置
## 8. 单元测试
- [x] 8.1 创建 `tests/unit/test_image_utils.py`
- [x] 8.2 测试 `inches_to_pixels``pixels_to_inches` 函数
- [x] 8.3 测试 `apply_fit_mode` 函数的四种模式
- [x] 8.4 测试 `create_canvas_with_background` 函数
- [x] 8.5 创建 `tests/unit/test_validators/test_image_config.py`
- [x] 8.6 测试 `validate_fit_value` 函数(有效值和无效值)
- [x] 8.7 测试 `validate_background_color` 函数(有效颜色和无效颜色)
- [x] 8.8 测试 `validate_dpi_value` 函数(合理范围和超出范围)
- [x] 8.9 更新 `tests/unit/test_elements.py`,测试 ImageElement 新字段
## 9. 集成测试
- [x] 9.1 创建 `tests/integration/test_image_fit_modes.py`
- [x] 9.2 测试 stretch 模式的 PPTX 渲染
- [x] 9.3 测试 contain 模式的 PPTX 渲染(图片比 box 大)
- [x] 9.4 测试 contain 模式的 PPTX 渲染(图片比 box 小)
- [x] 9.5 测试 contain 模式的 PPTX 渲染(带背景色)
- [x] 9.6 测试 cover 模式的 PPTX 渲染(图片比 box 大)
- [x] 9.7 测试 cover 模式的 PPTX 渲染(图片比 box 小)
- [x] 9.8 测试 center 模式的 PPTX 渲染(带背景色)
- [x] 9.9 测试不同 DPI 配置的渲染结果
- [x] 9.10 测试图片处理失败时的错误处理
- [x] 9.11 测试 HTML 渲染器的四种 fit 模式
- [x] 9.12 测试 HTML 渲染器的背景色支持
- [x] 9.13 对比 HTML 和 PPTX 渲染效果的一致性
## 10. 端到端测试
- [x] 10.1 创建测试 YAML 文件,包含所有 fit 模式
- [x] 10.2 创建测试 YAML 文件,包含背景色配置
- [x] 10.3 创建测试 YAML 文件,包含 DPI 配置
- [x] 10.4 创建测试 YAML 文件,包含无效参数(测试验证)
- [x] 10.5 运行 `check` 命令,验证错误检测
- [x] 10.6 运行 `convert` 命令,验证 PPTX 生成
- [x] 10.7 运行 `preview` 命令,验证 HTML 预览
## 11. 文档更新
- [x] 11.1 更新 `README.md`,添加图片适配模式章节
- [x] 11.2 在 README.md 中添加 fit 参数说明和示例
- [x] 11.3 在 README.md 中添加 background 参数说明和示例
- [x] 11.4 在 README.md 中添加 metadata.dpi 配置说明
- [x] 11.5 更新 `README_DEV.md`,添加图片处理架构说明
- [x] 11.6 在 README_DEV.md 中说明 Pillow 依赖和用途
- [x] 11.7 在 README_DEV.md 中说明 image_utils 模块的设计
## 12. 向后兼容性验证
- [x] 12.1 测试现有 YAML 文件(无 fit 参数)仍能正常转换
- [x] 12.2 测试现有 YAML 文件的渲染结果与之前一致
- [x] 12.3 验证未指定 fit 时默认使用 stretch 模式

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,75 @@
## Context
当前系统中,`description` 字段已在以下位置被解析:
- `core/presentation.py`: 演示文稿级别的 `metadata.description`
- `core/template.py`: 模板级别的 `template.description`
- `core/presentation.py`: 幻灯片级别的 `slide.description``render_slide` 方法返回值中
然而PPTX 渲染器 (`renderers/pptx_renderer.py`) 的 `add_slide` 方法只处理 `background``elements`,完全忽略了 `description` 字段。
python-pptx 库原生支持备注功能,通过 `slide.notes_slide.notes_text_frame.text` 即可设置。
## Goals / Non-Goals
**Goals:**
- 将幻灯片的 `description` 字段写入 PPT 备注页
- 仅处理幻灯片级别的 description不涉及模板或演示文稿级别的 description
- 保持向后兼容,没有 description 时不设置备注
**Non-Goals:**
- 不实现富文本备注(仅支持纯文本)
- 不合并模板的 description
- 不处理演示文稿级别的 metadata.description
## Decisions
### 1. 备注内容来源
**决策**: 仅使用幻灯片自己的 `description` 字段
**理由**:
- 幻灯片 description 是用户针对具体页面编写的说明
- 模板 description 描述的是模板用途,不适合作为单个幻灯片的备注
- 避免复杂的合并逻辑,保持简单清晰
**考虑的替代方案**:
- 合并模板 description 和幻灯片 description → 过于复杂,可能产生冗余内容
- 仅在没有幻灯片 description 时使用模板 description → 增加认知负担
### 2. 无 description 时的行为
**决策**: 不设置备注,保持备注页为空
**理由**:
- python-pptx 默认创建空备注页
- 不需要额外判断或清理操作
- 保持行为可预测
### 3. 代码位置
**决策**: 在 `PptxGenerator` 类中添加私有方法 `_set_notes`
**理由**:
- 与现有的 `_render_background``_render_element` 等方法保持一致
- 封装备注设置逻辑,便于测试和维护
- 不影响 `add_slide` 方法的主流程
## Risks / Trade-offs
### Risk: 长文本可能导致备注溢出
**影响**: description 内容过长时,可能在备注页中显示不佳
**缓解**: python-pptx 会自动处理文本换行,这是库的默认行为,无需额外处理
### Trade-off: 仅支持纯文本
**限制**: python-pptx 的备注不支持富文本格式(加粗、颜色等)
**权衡**: 备注的主要用途是演讲者参考,纯文本已满足需求;富文本支持需要更复杂的实现,收益不大
### Risk: 现有 spec 需要更新
**影响**: `page-description` spec 明确规定 "description不写入PPTX文件"
**缓解**: 这正是本次变更的目的,需要更新该 spec 以反映新行为

View File

@@ -0,0 +1,25 @@
## Why
幻灯片的 description 字段已经在系统中被解析和传递但目前未被实际使用。PPT 备注页是演讲者的原生工具,非常适合存储这些演讲说明。通过将 description 写入 PPT 备注,可以让用户在演示时查看说明而不显示给观众,充分利用已有数据。
## What Changes
- 在 PPTX 生成时,将幻灯片的 `description` 字段写入备注页
- 仅处理幻灯片级别的 `description`,不继承模板的 `description`
- 如果幻灯片没有 `description`,则不设置备注
- 修改现有 `page-description` spec 的需求,因为 description 现在会影响 PPTX 输出
## Capabilities
### New Capabilities
- `pptx-slide-notes`: 幻灯片备注功能,支持将 description 内容写入 PPT 备注页
### Modified Capabilities
- `page-description`: 现有 spec 规定 "description字段不得影响渲染逻辑" 和 "description不写入PPTX文件",需要更新为允许 description 写入备注页
## Impact
- `renderers/pptx_renderer.py`: 在 `add_slide` 方法中添加设置备注的逻辑
- `openspec/specs/page-description/spec.md`: 更新需求,移除 "不得影响渲染逻辑" 的限制
- `openspec/specs/pptx-slide-notes/spec.md`: 新建 spec 定义备注功能需求
- `README.md``README_DEV.md`: 更新文档说明备注功能

View File

@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: description字段不得影响渲染逻辑
系统 SHALL 在渲染幻灯片时忽略 `description` 字段对视觉元素的影响,但会将幻灯片级别的 `description` 写入 PPTX 备注页。
#### Scenario: 渲染包含description的模板
- **WHEN** 系统渲染包含 `description` 字段的模板
- **THEN** description不参与元素渲染不影响幻灯片视觉输出
#### Scenario: 渲染包含description的幻灯片
- **WHEN** 系统渲染包含 `description` 字段的幻灯片
- **THEN** description写入PPTX文件的备注页不影响幻灯片视觉输出
## REMOVED Requirements
### Requirement: description不写入PPTX文件
**Reason**: 幻灯片备注功能需要将 description 写入 PPTX 备注页,这是对 description 字段的合理利用。
**Migration**: 无需迁移,这是新增功能,不影响现有行为。没有 description 的幻灯片行为与之前完全一致。

View File

@@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: 幻灯片description必须写入PPT备注页
系统 SHALL 在生成 PPTX 时,将幻灯片的 `description` 字段内容写入该幻灯片的备注页。
#### Scenario: 幻灯片包含description
- **WHEN** 幻灯片定义了 `description: "这是幻灯片的演讲说明"`
- **THEN** PPTX 幻灯片的备注页包含该文本内容
#### Scenario: description包含多行文本
- **WHEN** 幻灯片的 description 使用 YAML 多行文本格式定义
- **THEN** 备注页保留完整的文本内容,包括换行符
#### Scenario: description为空字符串
- **WHEN** 幻灯片定义了 `description: ""`
- **THEN** 备注页设置为空字符串(不为 None
### Requirement: 无description时不设置备注
系统 SHALL 在幻灯片没有 `description` 字段时不设置备注内容。
#### Scenario: 幻灯片不包含description
- **WHEN** 幻灯片定义未包含 `description` 字段
- **THEN** PPTX 幻灯片不设置备注,使用默认空备注页
### Requirement: 模板description不写入备注
系统 SHALL 仅处理幻灯片级别的 `description`,不使用模板的 `description`
#### Scenario: 模板有description但幻灯片没有
- **WHEN** 模板定义了 `description: "模板说明"` 但幻灯片未定义 `description`
- **THEN** PPTX 幻灯片不设置备注,不继承模板的 description
#### Scenario: 模板和幻灯片都有description
- **WHEN** 模板定义了 `description` 且幻灯片也定义了 `description`
- **THEN** PPTX 幻灯片备注仅包含幻灯片的 description忽略模板的 description
### Requirement: description必须支持中文字符
系统 SHALL 支持在 `description` 字段中使用中文字符,并正确写入 PPTX 备注。
#### Scenario: description包含中文
- **WHEN** 幻灯片的 description 包含中文字符,如 "这是演讲备注内容"
- **THEN** 系统正确处理PPTX 备注页显示正确的中文内容

View File

@@ -0,0 +1,17 @@
## 1. 核心实现
- [x] 1.1 在 PptxGenerator 类中添加 _set_notes 私有方法
- [x] 1.2 在 add_slide 方法中调用 _set_notes 设置备注
## 2. 测试
- [x] 2.1 添加幻灯片包含 description 的测试用例
- [x] 2.2 添加幻灯片不包含 description 的测试用例
- [x] 2.3 添加 description 包含多行文本的测试用例
- [x] 2.4 添加 description 包含中文字符的测试用例
- [x] 2.5 添加模板有 description 但幻灯片没有的测试用例
## 3. 文档更新
- [x] 3.1 更新 README.md 添加备注功能说明
- [x] 3.2 更新 README_DEV.md 添加备注功能开发文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,99 @@
## Context
当前 `renderers/pptx_renderer.py` 中的 `_render_text` 方法使用 `tf.paragraphs[0]` 来应用字体样式。当文本内容包含换行符时(如 YAML 多行字符串语法 `|`python-pptx 会自动创建多个段落,但样式只应用到第一个段落。
现有代码问题:
```python
# 创建文本框
textbox = slide.shapes.add_textbox(x, y, w, h)
tf = textbox.text_frame
tf.text = elem.content # 包含换行符时python-pptx 会创建多个段落
# 只对第一个段落应用样式 - 问题所在
p = tf.paragraphs[0]
if 'size' in elem.font:
p.font.size = Pt(elem.font['size']) # 只有第一个段落生效
```
测试验证(见 `temp/test_multiline_behavior.py`
- 当设置 `tf.text = "第一行\n第二行\n第三行"`
- `len(tf.paragraphs)` 返回 3
- 只有 `paragraphs[0].font.size` 被设置,其余为 `None`
## Goals / Non-Goals
**Goals:**
- 修复多行文本渲染时,字体样式只应用于第一段的问题
- 确保所有字体样式属性size, bold, italic, color, align应用到所有段落
- 保持单行文本的渲染行为不变
- 添加完整的单元测试覆盖
**Non-Goals:**
- 不支持不同段落使用不同样式(当前架构是一个文本框一个样式配置)
- 不修改 HTML 渲染器HTML 渲染器无此问题)
## Decisions
### 决策1遍历所有段落应用样式
选择遍历 `tf.paragraphs` 并对每个段落应用样式,而不是其他方案。
**替代方案考虑**
1. **逐段设置内容**`add_paragraph()`):需要重写整个文本设置逻辑,改动较大
2. **只对第一个段落设置**(当前行为):存在 bug不可接受
3. **遍历所有段落**(选择):最小改动,符合当前设计语义
**理由**
- 代码改动最小,只需将 `p = tf.paragraphs[0]` 改为 `for p in tf.paragraphs:`
- 符合当前设计语义:一个文本框使用统一的字体样式配置
- 向后兼容:单行文本只有一个段落,行为不变
### 决策2不增加新的配置选项
不添加类似 `apply_style_to_all_paragraphs` 的配置选项。
**理由**
- 这是 bug 修复,不是新功能
- 当前设计中,一个文本框就应该使用统一样式
- 添加配置会增加复杂度,且用途有限
### 决策3测试策略
在单元测试中新增多行文本测试用例,使用 Mock 验证样式应用到所有段落。
**理由**
- 现有测试只验证 `paragraphs[0]` 的样式
- 需要验证修复后的行为正确
- 测试应覆盖多种换行情况2行、3行、多行
## Risks / Trade-offs
### 风险1现有测试可能失败
**风险**:现有测试中 Mock 的 `paragraphs` 只有一个元素,改为遍历后可能通过但不完整。
**缓解**新增专门的多行文本测试Mock 返回多个段落对象。
### 风险2性能影响
**风险**:遍历所有段落可能对极多段落的文本有轻微性能影响。
**评估**:影响可忽略不计。通常文本框段落数量有限(<100遍历开销极小。
### 风险3空段落行为
**风险**:如果存在空段落(如连续换行),可能产生意外行为。
**评估**python-pptx 会自动处理空段落,样式设置到空段落无副作用。
## Migration Plan
无需迁移计划。这是 bug 修复:
- 向后兼容:单行文本行为完全不变
- 多行文本:从错误行为变为正确行为
- 无 API 变更
- 无配置变更
## Open Questions
无。这是一个明确且范围有限的 bug 修复。

View File

@@ -0,0 +1,24 @@
## Why
当使用 YAML 多行字符串语法(`|`)定义文本内容时,内容包含换行符(`\n`。python-pptx 会自动将包含换行符的文本分割成多个段落paragraphs但当前渲染代码只对第一个段落应用字体样式导致只有第一行文字使用指定字号其余行使用默认字号。这破坏了文本框样式的统一性。
## What Changes
- 修改 `renderers/pptx_renderer.py` 中的 `_render_text` 方法,使其遍历所有段落并应用相同的字体样式
- 确保字体大小、粗体、斜体、颜色和对齐方式应用到文本框中的所有段落
- 添加多行文本渲染的单元测试
## Capabilities
### New Capabilities
### Modified Capabilities
- `text-rendering`: 修复多行文本渲染时字体样式只应用于第一段的问题
## Impact
- **受影响的代码**: `renderers/pptx_renderer.py`
- **受影响的测试**: `tests/unit/test_renderers/test_pptx_renderer.py` 需要新增多行文本测试用例
- **向后兼容性**: 完全兼容,单行文本行为不变,多行文本从错误行为变为正确行为
- **依赖变化**: 无

View File

@@ -0,0 +1,49 @@
# Element Rendering - Delta Spec
## MODIFIED Requirements
### Requirement: 系统必须支持文本元素渲染
系统 SHALL 将 YAML 中定义的文本元素渲染为 PPTX 文本框并默认启用文字自动换行。当文本内容包含换行符YAML 多行字符串语法)时,系统必须将所有字体样式(大小、粗体、斜体、颜色、对齐)应用到文本框中的每个段落。
#### Scenario: 渲染基本文本元素
- **WHEN** 元素定义为 `{type: text, content: "Hello", box: [1, 2, 8, 3]}`
- **THEN** 系统在幻灯片的 (1, 2) 位置创建 8x3 英寸的文本框,内容为 "Hello"
#### Scenario: 应用文本字体样式
- **WHEN** 文本元素定义了 `font: {size: 32, bold: true, color: "#333333"}`
- **THEN** 系统将文本设置为 32pt、粗体、颜色为 #333333
#### Scenario: 多行文本样式应用到所有段落
- **WHEN** 文本内容包含换行符(如 YAML 多行字符串 `content: |\n 第一行\n 第二行\n 第三行`)且定义了 `font: {size: 12}`
- **THEN** 系统将 12pt 字号应用到所有段落(第一行、第二行、第三行)
- **AND** 每个段落的字体大小都应一致
#### Scenario: 多行文本其他样式应用到所有段落
- **WHEN** 文本内容包含换行符且定义了 `font: {size: 12, bold: true, italic: true, color: "#ff0000", align: center}`
- **THEN** 系统将所有样式(大小、粗体、斜体、颜色、对齐)应用到所有段落
- **AND** 每个段落的样式都应一致
#### Scenario: 单行文本样式行为不变
- **WHEN** 文本内容不包含换行符且定义了 `font: {size: 12}`
- **THEN** 系统将 12pt 字号应用到文本(行为与之前相同)
#### Scenario: 应用文本对齐方式
- **WHEN** 文本元素定义了 `font: {align: center}`
- **THEN** 系统将文本设置为居中对齐
#### Scenario: 支持多种对齐方式
- **WHEN** 文本对齐方式为 `left``center``right` 之一
- **THEN** 系统正确应用对应的对齐方式
#### Scenario: 文本框默认启用自动换行
- **WHEN** 系统渲染任何文本元素
- **THEN** 系统设置 `text_frame.word_wrap = True`,文字在文本框边界处自动换行

View File

@@ -0,0 +1,30 @@
## 1. 代码修改
- [x] 1.1 修改 `_render_text` 方法,将 `p = tf.paragraphs[0]` 改为 `for p in tf.paragraphs:`
- [x] 1.2 确保所有字体样式属性size, bold, italic, color, align应用到每个段落
- [x] 1.3 验证修改后代码语法正确,无逻辑错误
## 2. 单元测试
- [x] 2.1 添加多行文本字体大小测试用例
- [x] 2.2 添加多行文本完整样式测试用例size, bold, italic, color, align
- [x] 2.3 添加单行文本回归测试用例(确保行为不变)
- [x] 2.4 更新测试辅助函数 `_setup_mock_slide` 以支持多段落 Mock
## 3. 集成测试
- [x] 3.1 创建测试 YAML 文件包含多行文本示例
- [x] 3.2 生成测试 PPTX 文件并验证所有段落样式一致
- [x] 3.3 手动打开生成的 PPTX 文件确认视觉效果正确
## 4. 验证与清理
- [x] 4.1 运行所有单元测试确保通过
- [x] 4.2 运行完整测试套件确保无回归
- [x] 4.3 使用 `temp/complex_presentation.yaml` 验证幻灯片2渲染正确
- [x] 4.4 删除临时测试文件 `temp/test_multiline_behavior.py`(如需要)
## 5. 文档更新
- [x] 5.1 更新 README.md如有需要
- [x] 5.2 更新 README_DEV.md如有需要

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,184 @@
# 移除图片适配模式技术设计
## Context
### 当前状态
图片适配模式功能已在 `openspec/changes/archive/2026-03-04-add-image-fit-modes/` 中实现并归档,包括:
- `utils/image_utils.py`: 图片处理工具模块英寸像素转换、fit 模式处理)
- `validators/image_config.py`: 图片参数验证器
- `core/elements.py`: ImageElement 添加了 fit 和 background 字段
- `renderers/pptx_renderer.py`: 重写 _render_image() 支持四种 fit 模式
- `renderers/html_renderer.py`: render_image() 支持 CSS object-fit 映射
- `core/presentation.py`: 读取 metadata.dpi 配置
- 完整的单元测试和集成测试
该功能尚未上线使用,无需考虑向后兼容性。
### 约束条件
- 必须保留 Pillow 依赖(用户要求留作备用)
- HTML 渲染器需要保留英寸到像素的转换功能(使用硬编码 DPI=96
- 移除后图片渲染行为:直接拉伸到 box 指定的尺寸
### 利益相关者
- 最终用户:获得更简单的图片配置(只需 src 和 box
- 开发者:降低代码复杂度和维护成本
## Goals / Non-Goals
**Goals:**
- 完全移除图片适配模式功能fit、background、dpi 配置)
- 简化图片渲染逻辑,恢复到直接使用 python-pptx 原生功能
- 删除不再需要的工具模块和验证器
- 删除相关测试文件
- 更新规格文档和用户文档
- 保留 Pillow 依赖用于未来可能的图片处理需求
**Non-Goals:**
- 不移除 Pillow 依赖
- 不改变 HTML 渲染器的基础英寸到像素转换功能(保留硬编码 DPI=96
- 不影响其他元素类型text、shape、table的渲染逻辑
## Decisions
### 决策 1: HTML 渲染器使用硬编码 DPI
**选择:** HtmlRenderer 使用硬编码的 `self.dpi = 96`,移除 __init__ 的 dpi 参数
**理由:**
- 96 是 Web 标准 DPI适用于绝大多数场景
- HTML 预览的尺寸转换是基础功能,不需要用户配置
- 移除 metadata.dpi 配置后,简化了用户 YAML 文件
**实现:**
```python
# renderers/html_renderer.py
class HtmlRenderer:
def __init__(self): # 移除 dpi 参数
self.dpi = 96 # 硬编码 Web 标准 DPI
```
### 决策 2: PPTX 图片渲染恢复到简单实现
**选择:** 直接使用 python-pptx 的 `slide.shapes.add_picture()` 方法
**理由:**
- python-pptx 原生支持图片添加,会自动拉伸到指定尺寸
- 移除对 Pillow 的运行时依赖(保留依赖但代码中不使用)
- 代码更简洁,易于维护
**实现:**
```python
# renderers/pptx_renderer.py
def _render_image(self, 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]
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
```
### 决策 3: 完全删除相关文件而非注释代码
**选择:** 直接删除 image_utils.py、image_config.py 及相关测试文件
**理由:**
- 功能尚未上线,无需保留代码历史
- Git 历史已记录所有实现细节
- 保持代码库整洁
### 决策 4: 删除 image-fit-modes 规格
**选择:** 删除 `openspec/specs/image-fit-modes/` 整个目录
**理由:**
- 该规格对应的 capability 正在被移除
- 归档的变更中已保留完整规格副本
## Risks / Trade-offs
### 风险 1: HTML 预览尺寸可能与 PPTX 不完全一致
**风险:** 硬编码 DPI=96 可能在某些显示器上导致预览尺寸与实际 PPTX 有差异
**缓解措施:**
- 96 是 Web 标准,覆盖绝大多数场景
- 预览功能主要用于布局参考,不需要像素级精确
- 用户主要关注 PPTX 输出结果
### 风险 2: 删除文件可能影响其他未发现的依赖
**风险:** 可能有其他代码或测试依赖 image_utils 或 image_config
**缓解措施:**
- 运行完整的测试套件验证
- 检查 imports 确保没有遗漏的引用
- 使用 Grep 搜索可能的引用点
### 风险 3: 文档更新可能不完整
**风险:** README.md 或 README_DEV.md 中可能残留相关说明
**缓解措施:**
- 在 tasks 中明确列出文档更新任务
- 人工审查最终文档内容
## Migration Plan
### 部署步骤
由于功能尚未上线,无需迁移策略。直接执行删除和修改即可。
1. **删除工具和验证器模块**
- 删除 `utils/image_utils.py`
- 删除 `validators/image_config.py`
- 更新 `validators/validator.py` 移除引用
2. **修改核心代码**
- 更新 `core/elements.py` 的 ImageElement
- 更新 `core/presentation.py` 的 Presentation
- 更新 `renderers/pptx_renderer.py`
- 更新 `renderers/html_renderer.py`
- 更新 `yaml2pptx.py`
- 更新 `preview/server.py`
3. **删除测试文件**
- 删除 `tests/unit/test_image_utils.py`
- 删除 `tests/unit/test_validators/test_image_config.py`
- 删除 `tests/integration/test_image_fit_modes.py`
4. **删除规格文件**
- 删除 `openspec/specs/image-fit-modes/` 目录
5. **更新文档**
- 更新 `README.md`
- 更新 `README_DEV.md`
- 更新 `openspec/specs/element-rendering/spec.md`
- 更新 `openspec/specs/html-rendering/spec.md`
6. **验证**
- 运行完整测试套件
- 检查是否有遗留的 import 引用
### 回滚策略
- Git 历史保留了所有实现
- 如需恢复功能,可以从归档的变更中恢复代码
- 或者从 Git 历史中恢复提交 `19d6661` 之前的代码
## Open Questions
无。移除操作明确清晰,无未决问题。

View File

@@ -0,0 +1,57 @@
# 移除图片适配模式功能
## Why
图片适配模式功能已实现但尚未上线使用,经过评估决定移除该功能以简化系统复杂度。移除后系统将恢复到更简单直接的图片渲染方式:直接使用 python-pptx 的原生图片添加功能,图片会被拉伸到指定尺寸。
## What Changes
- **BREAKING** 移除图片元素的 `fit` 参数stretch、contain、cover、center 四种模式)
- **BREAKING** 移除图片元素的 `background` 参数(背景色填充)
- **BREAKING** 移除文档级的 `metadata.dpi` 配置
- **BREAKING** 删除 `utils/image_utils.py` 模块(图片处理工具)
- **BREAKING** 删除 `validators/image_config.py` 模块(图片配置验证器)
- 重写 `renderers/pptx_renderer.py``_render_image()` 方法,恢复简单实现
- 简化 `renderers/html_renderer.py``render_image()` 方法,移除 fit/background 处理
- 简化 `core/elements.py``ImageElement` 类,移除 fit/background 字段
- 简化 `core/presentation.py``Presentation` 类,移除 dpi 配置读取
- 保留 Pillow 依赖(留作未来可能的其他用途)
- HTML 渲染器使用硬编码的 DPI=96Web 标准,无需用户配置)
## Capabilities
### Modified Capabilities
- `element-rendering`: 移除图片元素的 fit 和 background 参数支持,恢复到基础图片渲染
- `html-rendering`: 移除图片元素的 fit 和 background 参数支持,简化 HTML 图片渲染
## Impact
### 代码变更
- `core/elements.py`: ImageElement 移除 fit 和 background 字段及验证逻辑
- `core/presentation.py`: Presentation 移除 dpi 配置读取
- `renderers/pptx_renderer.py`: 重写 _render_image() 方法,移除 Pillow 图片处理和 fit 模式逻辑
- `renderers/html_renderer.py`: 简化 render_image() 方法,移除 fit 模式映射和背景色容器
- `yaml2pptx.py`: 移除传递 dpi 参数到 PptxGenerator
- `preview/server.py`: 移除传递 dpi 参数到 HtmlRenderer
- `validators/validator.py`: 移除对 image_config 的引用
### 删除的文件
- `utils/image_utils.py`: 整个文件删除
- `validators/image_config.py`: 整个文件删除
- `tests/unit/test_image_utils.py`: 删除
- `tests/unit/test_validators/test_image_config.py`: 删除
- `tests/integration/test_image_fit_modes.py`: 删除
- `openspec/specs/image-fit-modes/`: 整个规格目录删除
### API 变更
- YAML 语法移除:
- 图片元素不再支持 `fit` 字段
- 图片元素不再支持 `background` 字段
- metadata 层级不再支持 `dpi` 字段
### 依赖变更
- Pillow 依赖保留(在 pyproject.toml 中),用于未来可能的图片处理需求
### 文档变更
- `README.md`: 移除图片适配模式使用说明
- `README_DEV.md`: 移除图片处理架构说明

View File

@@ -0,0 +1,82 @@
# Element Rendering Delta Spec
## REMOVED Requirements
### Requirement: 系统必须支持图片元素渲染 (部分移除)
**Reason**: 移除图片适配模式功能fit 和 background 参数),简化图片渲染逻辑
**Migration**: 移除图片元素的 `fit``background` 参数定义,系统将使用简单的拉伸模式渲染图片
#### Scenario: 使用 fit 模式渲染图片
**REMOVED**
- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
- **THEN** 系统使用 `contain` 模式渲染图片
- **AND** 保持图片宽高比,完整显示在 box 内
#### Scenario: 使用背景色渲染图片
**REMOVED**
- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
- **THEN** 系统使用 `contain` 模式渲染图片
- **AND** 用 #f0f0f0 颜色填充留白区域
#### Scenario: 图片格式不支持时报错
**REMOVED**
- **WHEN** 图片文件格式不被 Pillow 支持
- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式
#### Scenario: 图片处理失败时报错
**REMOVED**
- **WHEN** Pillow 处理图片时发生异常(如文件损坏)
- **THEN** 系统抛出 ERROR 级别错误,不降级到其他模式
#### Scenario: 使用 DPI 配置渲染图片
**REMOVED**
- **WHEN** metadata 定义了 `dpi: 120` 且图片需要处理
- **THEN** 系统使用该 DPI 值进行像素与英寸的转换
#### Scenario: fit 参数值无效时报错
**REMOVED**
- **WHEN** `fit` 参数值不是 stretch、contain、cover、center 之一
- **THEN** 系统抛出 ERROR并列出有效值
#### Scenario: background 参数颜色格式无效时报错
**REMOVED**
- **WHEN** `background` 值不是有效的颜色格式
- **THEN** 系统抛出 ERROR提示颜色格式应为 #RRGGBB#RGB
## MODIFIED Requirements
### Requirement: 系统必须支持图片元素渲染
系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象,直接使用 python-pptx 的原生图片添加功能。
#### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统从指定路径加载图片,在 (2, 3) 位置渲染为 4×3 英寸大小
- **AND** 图片会被拉伸到指定尺寸python-pptx 默认行为)
#### Scenario: 图片文件不存在时报错
- **WHEN** 图片 src 指向不存在的文件路径
- **THEN** 系统抛出错误,明确指出图片文件未找到
#### Scenario: 相对路径处理
- **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"`
- **THEN** 系统基于演示文稿文件所在目录解析相对路径

View File

@@ -0,0 +1,86 @@
# HTML Rendering Delta Spec
## REMOVED Requirements
### Requirement: 系统必须渲染图片元素 (部分移除)
**Reason**: 移除图片适配模式功能fit 和 background 参数),简化 HTML 图片渲染
**Migration**: 移除图片元素的 `fit``background` 参数定义HTML 渲染器将输出简单的 `<img>` 标签
#### Scenario: 使用 fit 模式渲染图片
**REMOVED**
- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
- **THEN** 系统应用 CSS `object-fit: contain`
- **AND** 保持图片宽高比,完整显示在 box 内
#### Scenario: fit 模式与 CSS object-fit 映射
**REMOVED**
- **WHEN** `fit` 参数为 `stretch`
- **THEN** 系统应用 CSS `object-fit: fill`
- **WHEN** `fit` 参数为 `contain`
- **THEN** 系统应用 CSS `object-fit: contain`
- **WHEN** `fit` 参数为 `cover`
- **THEN** 系统应用 CSS `object-fit: cover`
- **WHEN** `fit` 参数为 `center`
- **THEN** 系统应用 CSS `object-fit: none``object-position: center`
#### Scenario: 使用背景色渲染图片
**REMOVED**
- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
- **THEN** 系统为图片容器添加 CSS `background-color: #f0f0f0`
- **AND** 应用 CSS `object-fit: contain`
#### Scenario: 图片容器支持背景色
**REMOVED**
- **WHEN** 图片指定了 `background` 参数
- **THEN** 系统创建包装容器,应用背景色到容器
- **AND** 图片在容器内使用 object-fit 定位
#### Scenario: 使用 DPI 配置渲染图片
**REMOVED**
- **WHEN** metadata 定义了 `dpi: 120`
- **THEN** 系统使用该 DPI 值进行英寸到像素的转换
- **AND** box: [1, 1, 4, 3] 转换为 CSS: left: 120px; top: 120px; width: 480px; height: 360px
#### Scenario: HTML 渲染与 PPTX 渲染效果一致
**REMOVED**
- **WHEN** 同一图片元素使用相同的 fit 和 background 参数
- **THEN** HTML 预览和 PPTX 输出的视觉效果应保持一致
- **AND** 图片位置、尺寸、适配方式相同
## MODIFIED Requirements
### Requirement: 系统必须渲染图片元素
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,使用固定 DPI 进行尺寸转换。
#### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统生成 `<img>` 标签src 为图片的文件路径,位置为 (192px, 288px),尺寸为 384x288 像素
#### Scenario: 处理相对路径
- **WHEN** 图片 src 使用相对路径 `"assets/logo.png"`
- **THEN** 系统基于 YAML 文件所在目录解析相对路径
#### Scenario: 图片不存在时显示占位符
- **WHEN** 图片文件不存在
- **THEN** 系统显示占位符或错误提示,而不是崩溃

View File

@@ -0,0 +1,67 @@
# 移除图片适配模式功能 - 任务清单
## 1. 删除工具和验证器模块
- [x] 1.1 删除 `utils/image_utils.py` 文件
- [x] 1.2 删除 `validators/image_config.py` 文件
- [x] 1.3 更新 `validators/validator.py`,移除对 image_config 的引用
## 2. 修改核心元素定义
- [x] 2.1 更新 `core/elements.py` 的 ImageElement 类,移除 fit 字段
- [x] 2.2 更新 `core/elements.py` 的 ImageElement 类,移除 background 字段
- [x] 2.3 简化 `core/elements.py` 的 ImageElement.validate() 方法,移除 fit/background 验证逻辑
## 3. 修改演示文稿类
- [x] 3.1 更新 `core/presentation.py` 的 Presentation 类,移除 self.dpi = metadata.get("dpi", 96)
## 4. 修改 PPTX 渲染器
- [x] 4.1 更新 `renderers/pptx_renderer.py`,移除 `from PIL import Image` 导入
- [x] 4.2 更新 `renderers/pptx_renderer.py`,移除 `from utils.image_utils import ...` 导入
- [x] 4.3 更新 `renderers/pptx_renderer.py` 的 PptxGenerator.__init__(),移除 dpi 参数
- [x] 4.4 重写 `renderers/pptx_renderer.py` 的 _render_image() 方法,恢复简单实现
## 5. 修改 HTML 渲染器
- [x] 5.1 更新 `renderers/html_renderer.py` 的 HtmlRenderer.__init__(),移除 dpi 参数,硬编码 self.dpi = 96
- [x] 5.2 简化 `renderers/html_renderer.py` 的 render_image() 方法,移除 fit 模式映射逻辑
- [x] 5.3 简化 `renderers/html_renderer.py` 的 render_image() 方法,移除 background 容器逻辑
## 6. 修改入口文件
- [x] 6.1 更新 `yaml2pptx.py`,移除传递 dpi 参数到 PptxGenerator
- [x] 6.2 更新 `preview/server.py`,移除传递 dpi 参数到 HtmlRenderer
## 7. 删除测试文件
- [x] 7.1 删除 `tests/unit/test_image_utils.py`
- [x] 7.2 删除 `tests/unit/test_validators/test_image_config.py`
- [x] 7.3 删除 `tests/integration/test_image_fit_modes.py`
- [x] 7.4 检查并更新 `tests/fixtures/create_test_images.py`(如果有其他依赖则保留,否则删除)
- [x] 7.5 检查并更新 `tests/integration/test_rendering_flow.py`,移除相关测试
## 8. 删除规格文件
- [x] 8.1 删除 `openspec/specs/image-fit-modes/` 整个目录
## 9. 更新主规格文件
- [x] 9.1 更新 `openspec/specs/element-rendering/spec.md`,移除 fit 和 background 相关规格
- [x] 9.2 更新 `openspec/specs/html-rendering/spec.md`,移除 fit 和 background 相关规格
## 10. 更新用户文档
- [x] 10.1 更新 `README.md`,移除图片适配模式章节
- [x] 10.2 更新 `README.md`,移除 metadata.dpi 配置说明
- [x] 10.3 更新 `README_DEV.md`,移除图片处理架构说明
- [x] 10.4 更新 `README_DEV.md`,移除 Pillow 依赖说明(改为保留用于备用)
## 11. 验证和测试
- [x] 11.1 运行 `uv run pytest`,确保所有测试通过
- [x] 11.2 使用 Grep 搜索代码库,确认没有遗留的 image_utils 或 image_config 引用
- [x] 11.3 使用 Grep 搜索代码库,确认没有遗留的 fit 或 background 参数引用(在图片元素上下文中)
- [x] 11.4 运行 `uv run python yaml2pptx.py` 测试基本转换功能
- [x] 11.5 运行 `uv run python preview/server.py` 测试预览功能

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04

View File

@@ -0,0 +1,247 @@
# Template Element Composition - 技术设计
## Context
### 当前状态
现有模板系统采用"全有或全无"模式:
- `Presentation.render_slide()` 方法使用 if-else 结构
-`template` 字段:渲染模板,使用模板元素
-`template` 字段:使用自定义元素
```python
# core/presentation.py 当前实现
def render_slide(self, slide_data):
if "template" in slide_data:
elements = template.render(vars_values)
else:
elements = slide_data.get("elements", [])
```
### 约束条件
1. **向后兼容性**:现有用法必须保持不变
2. **变量共享**:自定义元素需要访问模板变量
3. **渲染顺序**模板元素在前自定义元素在后z轴顺序
### 涉及模块
- `core/template.py` - Template 类
- `core/presentation.py` - Presentation.render_slide() 方法
- `loaders/yaml_loader.py` - YAML 验证(可能需要调整)
## Goals / Non-Goals
**Goals:**
- 支持幻灯片同时使用 `template``elements` 字段
- 自定义元素能够访问模板变量
- 元素采用简单追加策略合并
- 完全向后兼容现有用法
**Non-Goals:**
- 不支持元素的位置感知合并(如按 key 合并或区域合并)
- 不支持元素覆盖/替换(使用 slot 机制)
- 不添加元素重叠检测或警告
- 不改变模板的条件渲染逻辑
## Decisions
### 决策1修改位置选择 - Presentation.render_slide()
**选择**:在 `Presentation.render_slide()` 中实现混合模式逻辑
**理由**
- `render_slide()` 已经负责处理幻灯片渲染的完整流程
- 模板渲染和元素解析都在这里进行,是天然的合并点
- 避免在 Template 类中增加对"幻灯片级元素"的认知,保持职责单一
**替代方案**
-`Template.render()` 中添加 `extra_elements` 参数
- 缺点:让 Template 类感知"外部元素",违反单一职责原则
- 缺点:增加 render() 方法的复杂度
### 决策2变量解析策略 - 复用 Template.resolve_element()
**选择**:自定义元素复用模板的 `resolve_element()` 方法进行变量解析
**理由**
- `resolve_element()` 已经实现了深度递归的变量解析逻辑
- 支持嵌套对象、数组中的变量替换
- 自动处理类型转换(字符串转数字)
**实现方式**
```python
# 获取模板后,使用其实例方法解析自定义元素
if "template" in slide_data:
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
template_elements = template.render(vars_values)
# 解析自定义元素(使用模板的变量上下文)
if "elements" in slide_data:
custom_elements = slide_data["elements"]
resolved_custom = [template.resolve_element(e, vars_values) for e in custom_elements]
```
**替代方案**
- 创建独立的变量解析函数
- 缺点:代码重复,维护两套解析逻辑
### 决策3元素合并策略 - 简单追加
**选择**:使用列表追加(`template_elements + custom_elements`
**理由**
- 简单直观,符合用户预期
- 保持渲染顺序 = z 轴顺序
- 无需引入复杂的合并规则
**行为**
```python
final_elements = template_elements + custom_elements
```
**替代方案**
- 按 ID/key 合并(类似 slot 机制)
- 缺点:需要引入元素 ID 概念,复杂度高
- 缺点:打破向后兼容性
### 决策4空 elements 处理
**选择**`elements: []` 等同于不指定 `elements`
**理由**
- 保持 YAML 语法的自然语义
- 避免引入特殊行为差异
**实现**
```python
if slide_data.get("elements"): # 空列表为 False
# 处理自定义元素
```
### 决策5YAML 验证不变
**选择**:不修改 `yaml_loader.py` 的验证逻辑
**理由**
- 现有验证已允许 `template``elements` 同时存在(验证的是各自的结构)
- 混合模式是渲染时的行为,不是 YAML 结构的变化
- 避免不必要的验证逻辑修改
## Risks / Trade-offs
### 风险1元素位置重叠导致意外的视觉覆盖
**场景**:用户在不知道模板布局的情况下,放置自定义元素与模板元素重叠
**缓解措施**
- 在预览模式中用户可以实时看到效果
- 文档中明确说明 z 轴顺序规则
- 未来可选:添加几何验证警告(非必需)
### 风险2变量作用域混淆
**场景**:自定义元素引用了模板中未定义的变量
**缓解措施**
- 现有的 `resolve_value()` 已能捕获未定义变量并抛出错误
- 错误信息清晰指出缺少的变量名
### 风险3条件渲染元素与自定义元素的交互不明确
**场景**:模板中某个元素因 `visible: false` 被过滤,用户期望自定义元素"知道"这个区域
**缓解措施**
- 这是设计预期,不是 bug
- 文档中说明条件渲染在合并前完成
- 用户需要了解模板的布局结构
## 实现概要
### 核心修改Presentation.render_slide()
```python
def render_slide(self, slide_data):
"""
渲染单个幻灯片(支持混合模式)
新增:支持同时使用 template 和 elements
"""
has_template = "template" in slide_data
has_custom_elements = slide_data.get("elements")
elements_from_template = []
vars_values = {}
# 步骤1渲染模板如果有
if has_template:
template_name = slide_data["template"]
template = self.get_template(template_name)
vars_values = slide_data.get("vars", {})
elements_from_template = template.render(vars_values)
# 步骤2处理自定义元素如果有
elements_from_custom = []
if has_custom_elements:
custom_elements = slide_data["elements"]
if has_template:
# 混合模式:使用模板变量解析自定义元素
template = self.get_template(slide_data["template"])
elements_from_custom = [
template.resolve_element(elem, vars_values)
for elem in custom_elements
]
else:
# 纯自定义模式(原有行为)
elements_from_custom = custom_elements
# 步骤3合并元素
final_elements = elements_from_template + elements_from_custom
# 步骤4转换为元素对象原有逻辑
element_objects = [create_element(elem) for elem in final_elements]
return {
"background": slide_data.get("background"),
"elements": element_objects,
}
```
### 测试策略
1. **单元测试** (`tests/unit/test_presentation.py`)
- 测试纯模板模式(向后兼容)
- 测试纯自定义模式(向后兼容)
- 测试混合模式(新功能)
- 测试变量共享
- 测试空 elements 列表
2. **集成测试** (`tests/integration/`)
- 端到端测试YAML → PPTX
- 验证 z 轴顺序
- 验证条件渲染与元素合并的交互
3. **E2E 测试** (`tests/e2e/`)
- 完整的 convert 命令测试
- 完整的 preview 命令测试
## 迁移计划
### 部署步骤
1. 实现 `Presentation.render_slide()` 的混合模式逻辑
2. 添加单元测试
3. 添加集成测试和 E2E 测试
4. 更新 README.md新增混合模式使用说明
5. 更新 README_DEV.md更新开发文档
### 回滚策略
- 代码修改集中在单个方法 (`render_slide()`)
- 如需回滚,恢复方法到原始实现即可
- 向后兼容,现有用法不受影响
## Open Questions
无。所有设计决策已在探索阶段明确。

View File

@@ -0,0 +1,34 @@
# Template Element Composition 变更提案
## Why
当前模板系统是"全有或全无"的幻灯片要么使用模板template要么完全自定义elements无法同时使用两者。这导致多个模板中需要重复定义相同的头部 logo、标题栏、底部页码等元素。当需要修改这些共享元素时必须同步更新多个模板文件维护成本高且容易遗漏。
例如在 `complex_presentation.yaml` 中,`title-slide``content-slide``two-column-slide` 都各自定义了右上角的 5G logo位置完全相同但需要重复编写。
## What Changes
- 支持幻灯片同时使用 `template``elements` 字段,实现模板与自定义元素的混合模式
- 自定义元素能够访问模板中定义的变量,实现主题色、布局参数的统一控制
- 元素合并采用简单追加策略模板元素在前自定义元素在后z轴顺序
- 完全向后兼容:不写 `elements` 时行为与之前完全一致
## Capabilities
### Modified Capabilities
- `template-system`: 新增"模板与自定义元素混合使用"需求,修改现有"系统必须支持自定义幻灯片"需求,允许 `template``elements` 同时存在
## Impact
### 受影响的代码
- `core/template.py` - Template.render() 方法需要调整,可能新增 merge_elements() 方法
- `loaders/yaml_loader.py` - 幻灯片解析逻辑需要处理混合模式
- `core/presentation.py` (或其他处理幻灯片的模块) - 调用模板渲染的流程需要修改
### 受影响的文档
- `README.md` - 新增"混合模式模板"使用说明和示例
- `README_DEV.md` - 更新开发文档,说明元素合并策略
### 向后兼容性
- 完全兼容:只使用 template、只使用 elements、两者都不用的现有用法保持不变
- 新功能为增量增强,不破坏任何现有用法

View File

@@ -0,0 +1,112 @@
# Template System - Delta Spec
本文档是 template-system 能力的 delta spec定义模板元素组合功能的变更。
## MODIFIED Requirements
### Requirement: 系统必须支持自定义幻灯片
系统 SHALL 支持不使用模板的自定义幻灯片,以及同时使用模板和自定义元素的混合模式幻灯片。
#### Scenario: 渲染自定义幻灯片
- **WHEN** 幻灯片未指定 `template` 字段,直接包含 `elements` 列表
- **THEN** 系统跳过模板渲染,直接处理元素列表
#### Scenario: 自定义幻灯片中直接指定样式
- **WHEN** 自定义幻灯片的元素直接指定 `color: "#4a90e2"`
- **THEN** 系统正确应用该颜色值
#### Scenario: 自定义幻灯片和模板混合使用
- **WHEN** 演示文稿中部分幻灯片使用模板,部分为自定义
- **THEN** 系统正确处理两种类型的幻灯片
#### Scenario: 混合模式幻灯片同时使用模板和自定义元素
- **WHEN** 幻灯片同时指定了 `template` 字段和 `elements` 列表
- **THEN** 系统先渲染模板获取模板元素列表,再追加自定义元素列表,生成最终的元素列表
#### Scenario: 混合模式中模板元素在前
- **WHEN** 幻灯片使用混合模式,模板元素和自定义元素位置重叠
- **THEN** 自定义元素在 z 轴上覆盖模板元素(后渲染在上层)
## ADDED Requirements
### Requirement: 模板与自定义元素必须支持变量共享
系统 SHALL 允许自定义元素访问模板中定义的变量,实现主题色、布局参数等值的统一控制。
#### Scenario: 自定义元素使用模板变量
- **WHEN** 幻灯片使用 `template: content-slide`,提供 `vars: {theme_color: "#3949ab"}`,且自定义元素中定义 `fill: "{theme_color}"`
- **THEN** 系统将自定义元素中的 `{theme_color}` 替换为 `"#3949ab"`
#### Scenario: 自定义元素使用模板默认变量
- **WHEN** 模板定义了 `default: "#3949ab"``theme_color` 变量,幻灯片未提供该变量值,且自定义元素引用 `{theme_color}`
- **THEN** 系统使用模板的默认值 `"#3949ab"` 进行替换
#### Scenario: 自定义元素引用未定义变量时报错
- **WHEN** 自定义元素引用了 `{undefined_var}`,且该变量未在模板 vars 中定义,也未由幻灯片提供
- **THEN** 系统抛出错误,指出未定义的变量
### Requirement: 元素合并必须采用追加策略
系统 SHALL 使用简单追加策略合并模板元素和自定义元素,保持渲染顺序和 z 轴层级。
#### Scenario: 模板元素和自定义元素合并顺序
- **WHEN** 模板渲染后产生 2 个元素,幻灯片自定义元素列表包含 3 个元素
- **THEN** 最终元素列表包含 5 个元素顺序为模板元素1、模板元素2、自定义元素1、自定义元素2、自定义元素3
#### Scenario: 空自定义元素列表
- **WHEN** 幻灯片指定 `template``elements: []`(空数组)
- **THEN** 最终元素列表仅包含模板元素,等同于不指定 `elements` 字段
#### Scenario: 模板条件渲染后的元素合并
- **WHEN** 模板包含 3 个元素,其中 1 个因 `visible` 条件为假被过滤,幻灯片包含 2 个自定义元素
- **THEN** 最终元素列表包含 4 个元素:模板的 2 个可见元素,加上幻灯片的 2 个自定义元素
### Requirement: 混合模式必须保持向后兼容
系统 SHALL 在不使用混合模式时,保持与现有版本完全一致的行为。
#### Scenario: 仅使用模板时不指定 elements
- **WHEN** 幻灯片仅指定 `template` 字段,不包含 `elements` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染模板元素
#### Scenario: 仅使用自定义元素时不指定 template
- **WHEN** 幻灯片仅指定 `elements` 字段,不包含 `template` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染自定义元素
#### Scenario: 既不使用模板也不使用自定义元素
- **WHEN** 幻灯片既不指定 `template` 也不指定 `elements`
- **THEN** 系统生成空幻灯片(仅包含背景设置)
### Requirement: 混合模式必须支持内联模板
系统 SHALL 在混合模式中支持内联模板与外部模板,功能保持一致。
#### Scenario: 内联模板与自定义元素混合使用
- **WHEN** 幻灯片引用内联模板(在 YAML 文件的 `templates` 字段中定义),同时包含 `elements` 列表
- **THEN** 系统正确渲染内联模板元素,并追加自定义元素
#### Scenario: 外部模板与自定义元素混合使用
- **WHEN** 幻灯片引用外部模板(从 `--template-dir` 目录加载),同时包含 `elements` 列表
- **THEN** 系统正确加载外部模板,渲染模板元素,并追加自定义元素
#### Scenario: 内联和外部模板在同一演示文稿中混合使用
- **WHEN** 演示文稿同时定义了内联模板和使用外部模板,且部分幻灯片使用混合模式
- **THEN** 系统正确处理所有组合情况

View File

@@ -0,0 +1,49 @@
# Template Element Composition - 实现任务清单
## 1. 核心实现
- [x] 1.1 修改 `Presentation.render_slide()` 方法支持混合模式
- [x] 1.2 实现模板元素和自定义元素的合并逻辑(简单追加策略)
- [x] 1.3 实现自定义元素对模板变量的访问(复用 `Template.resolve_element()`
- [x] 1.4 处理空 `elements` 列表的情况(等同于不指定 elements
## 2. 单元测试
- [x] 2.1 添加测试:纯模板模式(向后兼容验证)
- [x] 2.2 添加测试:纯自定义元素模式(向后兼容验证)
- [x] 2.3 添加测试:混合模式基本功能
- [x] 2.4 添加测试:自定义元素访问模板变量
- [x] 2.5 添加测试:空 elements 列表
- [x] 2.6 添加测试:模板条件渲染后与自定义元素合并
- [x] 2.7 添加测试:变量未定义时的错误处理
- [x] 2.8 添加测试:内联模板与自定义元素混合使用
- [x] 2.9 添加测试:外部模板与自定义元素混合使用
## 3. 集成测试
- [ ] 3.1 添加集成测试:完整的 YAML → PPTX 转换流程(混合模式)
- [ ] 3.2 添加集成测试:验证 z 轴顺序(自定义元素覆盖模板元素)
- [ ] 3.3 添加集成测试:背景设置与混合模式的交互
## 4. E2E 测试
- [ ] 4.1 添加 E2E 测试convert 命令处理混合模式 YAML
- [ ] 4.2 添加 E2E 测试preview 命令显示混合模式幻灯片
- [x] 4.3 创建测试用的混合模式 YAML 示例文件
## 5. 文档更新
- [x] 5.1 更新 README.md新增"混合模式模板"使用说明
- [x] 5.2 更新 README.md添加混合模式语法示例
- [x] 5.3 更新 README.md说明 z 轴顺序规则
- [x] 5.4 更新 README_DEV.md说明元素合并策略
- [x] 5.5 更新 README_DEV.md添加混合模式相关的开发说明
## 6. 验证与清理
- [x] 6.1 运行所有单元测试并通过(`uv run pytest tests/unit/`
- [ ] 6.2 运行所有集成测试并通过(`uv run pytest tests/integration/`
- [ ] 6.3 运行所有 E2E 测试并通过(`uv run pytest tests/e2e/`
- [ ] 6.4 运行完整测试套件并通过(`uv run pytest`
- [x] 6.5 手动测试:使用现有 complex_presentation.yaml 验证向后兼容性
- [x] 6.6 手动测试:创建新的混合模式 YAML 并通过 convert 命令生成 PPTX

View File

@@ -8,7 +8,7 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须支持文本元素渲染
系统 SHALL 将 YAML 中定义的文本元素渲染为 PPTX 文本框,并默认启用文字自动换行。
系统 SHALL 将 YAML 中定义的文本元素渲染为 PPTX 文本框,并默认启用文字自动换行。当文本内容包含换行符YAML 多行字符串语法)时,系统必须将所有字体样式(大小、粗体、斜体、颜色、对齐)应用到文本框中的每个段落。
#### Scenario: 渲染基本文本元素
@@ -20,6 +20,23 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
- **WHEN** 文本元素定义了 `font: {size: 32, bold: true, color: "#333333"}`
- **THEN** 系统将文本设置为 32pt、粗体、颜色为 #333333
#### Scenario: 多行文本样式应用到所有段落
- **WHEN** 文本内容包含换行符(如 YAML 多行字符串 `content: |\n 第一行\n 第二行\n 第三行`)且定义了 `font: {size: 12}`
- **THEN** 系统将 12pt 字号应用到所有段落(第一行、第二行、第三行)
- **AND** 每个段落的字体大小都应一致
#### Scenario: 多行文本其他样式应用到所有段落
- **WHEN** 文本内容包含换行符且定义了 `font: {size: 12, bold: true, italic: true, color: "#ff0000", align: center}`
- **THEN** 系统将所有样式(大小、粗体、斜体、颜色、对齐)应用到所有段落
- **AND** 每个段落的样式都应一致
#### Scenario: 单行文本样式行为不变
- **WHEN** 文本内容不包含换行符且定义了 `font: {size: 12}`
- **THEN** 系统将 12pt 字号应用到文本(行为与之前相同)
#### Scenario: 应用文本对齐方式
- **WHEN** 文本元素定义了 `font: {align: center}`
@@ -49,16 +66,25 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
- **WHEN** 图片 src 指向不存在的文件路径
- **THEN** 系统抛出错误,明确指出图片文件未找到
#### Scenario: 图片格式不支持时报错
- **WHEN** 图片文件格式不被 python-pptx 支持
- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式
#### Scenario: 相对路径处理
- **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"`
- **THEN** 系统基于演示文稿文件所在目录解析相对路径
### Requirement: 图片元素的 box 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
#### Scenario: 缺少 box 参数时报错
- **WHEN** 图片元素未定义 `box` 参数
- **THEN** 系统抛出 ERROR提示 box 参数为必需
#### Scenario: box 参数格式正确
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]`
- **THEN** 系统验证通过,将 box 用于图片定位和尺寸
### Requirement: 系统必须支持形状元素渲染
系统 SHALL 将 YAML 中定义的形状元素渲染为 PPTX 形状对象。

View File

@@ -133,7 +133,7 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须渲染图片元素
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签。
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,使用固定 DPI 进行尺寸转换
#### Scenario: 渲染本地图片
@@ -150,6 +150,20 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
- **WHEN** 图片文件不存在
- **THEN** 系统显示占位符或错误提示,而不是崩溃
### Requirement: 图片元素的 box 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
#### Scenario: 缺少 box 参数时报错
- **WHEN** 图片元素未定义 `box` 参数
- **THEN** 系统抛出 ERROR提示 box 参数为必需
#### Scenario: box 参数转换为像素
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]` 且 DPI 为 96
- **THEN** 系统转换为 CSSleft: 96px; top: 192px; width: 384px; height: 288px
### Requirement: 系统必须渲染幻灯片背景
系统 SHALL 支持为幻灯片设置纯色背景。

View File

@@ -0,0 +1,130 @@
# Page Description
## Purpose
Page Description功能允许用户为文档元数据、模板和幻灯片添加描述信息用于说明文档概要、页面的用途、设计意图或内容概要。这提高了演示文稿、模板和幻灯片的可读性和可维护性。
## Requirements
### Requirement: metadata必须支持可选的description字段
演示文稿的metadata SHALL 支持可选的 `description` 字段,用于描述整个演示文稿的概要和用途。
#### Scenario: metadata包含description字段
- **WHEN** YAML文件的metadata定义了 `description: "这是关于项目年度总结的演示文稿"`
- **THEN** 系统成功加载该描述字段可通过metadata对象访问
#### Scenario: metadata不包含description字段
- **WHEN** YAML文件的metadata未定义 `description` 字段
- **THEN** 系统正常加载演示文稿description属性为None或空字符串
#### Scenario: metadata description为空字符串
- **WHEN** metadata定义了 `description: ""`
- **THEN** 系统接受空字符串作为有效值
### Requirement: 模板必须支持可选的description字段
模板 SHALL 支持可选的 `description` 字段,用于描述该模板的用途和设计意图。
#### Scenario: 模板包含description字段
- **WHEN** 模板文件定义了 `description: "用于章节标题页的模板,包含主标题和副标题"`
- **THEN** 系统成功加载该描述字段,可通过模板对象访问
#### Scenario: 模板不包含description字段
- **WHEN** 模板文件未定义 `description` 字段
- **THEN** 系统正常加载模板description属性为None或空字符串
#### Scenario: description为空字符串
- **WHEN** 模板文件定义了 `description: ""`
- **THEN** 系统接受空字符串作为有效值
### Requirement: 幻灯片必须支持可选的description字段
幻灯片定义 SHALL 支持可选的 `description` 字段,用于描述该幻灯片的作用和内容。
#### Scenario: 幻灯片包含description字段
- **WHEN** 幻灯片定义了 `description: "介绍项目背景和目标"`
- **THEN** 系统成功加载该描述字段,可通过幻灯片对象访问
#### Scenario: 幻灯片不包含description字段
- **WHEN** 幻灯片定义未包含 `description` 字段
- **THEN** 系统正常处理幻灯片description属性为None或空字符串
### Requirement: description字段不得影响渲染逻辑
系统 SHALL 在渲染幻灯片时忽略 `description` 字段对视觉元素的影响,但会将幻灯片级别的 `description` 写入 PPTX 备注页。
#### Scenario: 渲染包含description的模板
- **WHEN** 系统渲染包含 `description` 字段的模板
- **THEN** description不参与元素渲染不影响幻灯片视觉输出
#### Scenario: 渲染包含description的幻灯片
- **WHEN** 系统渲染包含 `description` 字段的幻灯片
- **THEN** description写入PPTX文件的备注页不影响幻灯片视觉输出
### Requirement: YAML解析器必须正确解析description字段
系统 SHALL 从YAML文件中正确读取 `description` 字段,并将其传递给数据模型。
#### Scenario: 解析metadata中的description
- **WHEN** YAML文件的metadata包含 `description: "文档描述"`
- **THEN** 解析器将该字符串传递给metadata对象的description属性
#### Scenario: 解析模板中的description
- **WHEN** YAML文件中的模板包含 `description: "这是描述"`
- **THEN** 解析器将该字符串传递给模板对象的description属性
#### Scenario: 解析幻灯片中的description
- **WHEN** YAML文件中的幻灯片包含 `description: "幻灯片描述"`
- **THEN** 解析器将该字符串传递给幻灯片对象的description属性
### Requirement: 数据模型必须包含description属性
元数据、模板和幻灯片数据模型 SHALL 包含 `description` 属性,用于存储描述信息。
#### Scenario: metadata对象包含description属性
- **WHEN** 开发者访问metadata对象
- **THEN** 可以通过 `metadata.description` 获取描述信息
#### Scenario: 模板对象包含description属性
- **WHEN** 开发者访问模板对象
- **THEN** 可以通过 `template.description` 获取描述信息
#### Scenario: 幻灯片对象包含description属性
- **WHEN** 开发者访问幻灯片对象
- **THEN** 可以通过 `slide.description` 获取描述信息
### Requirement: description字段必须支持中文字符
系统 SHALL 支持在 `description` 字段中使用中文字符和其他Unicode字符。
#### Scenario: metadata description包含中文
- **WHEN** metadata的description包含中文字符如 "这是关于项目的演示文稿"
- **THEN** 系统正确处理,不出现编码错误
#### Scenario: description包含中文
- **WHEN** 模板或幻灯片的description包含中文字符如 "这是中文描述"
- **THEN** 系统正确处理,不出现编码错误
#### Scenario: description包含多行文本
- **WHEN** description字段使用YAML多行文本格式
- **THEN** 系统正确读取完整的描述内容

View File

@@ -0,0 +1,58 @@
# PPTX Slide Notes
## Purpose
PPTX 备注功能允许用户在生成 PPTX 文件时,将幻灯片的 description 字段内容写入备注页。备注页是 PPT 的原生演讲者工具,在演示时可以查看但不显示给观众,非常适合存储演讲说明和备注信息。
## Requirements
### Requirement: 幻灯片description必须写入PPT备注页
系统 SHALL 在生成 PPTX 时,将幻灯片的 `description` 字段内容写入该幻灯片的备注页。
#### Scenario: 幻灯片包含description
- **WHEN** 幻灯片定义了 `description: "这是幻灯片的演讲说明"`
- **THEN** PPTX 幻灯片的备注页包含该文本内容
#### Scenario: description包含多行文本
- **WHEN** 幻灯片的 description 使用 YAML 多行文本格式定义
- **THEN** 备注页保留完整的文本内容,包括换行符
#### Scenario: description为空字符串
- **WHEN** 幻灯片定义了 `description: ""`
- **THEN** 备注页设置为空字符串(不为 None
### Requirement: 无description时不设置备注
系统 SHALL 在幻灯片没有 `description` 字段时不设置备注内容。
#### Scenario: 幻灯片不包含description
- **WHEN** 幻灯片定义未包含 `description` 字段
- **THEN** PPTX 幻灯片不设置备注,使用默认空备注页
### Requirement: 模板description不写入备注
系统 SHALL 仅处理幻灯片级别的 `description`,不使用模板的 `description`
#### Scenario: 模板有description但幻灯片没有
- **WHEN** 模板定义了 `description: "模板说明"` 但幻灯片未定义 `description`
- **THEN** PPTX 幻灯片不设置备注,不继承模板的 description
#### Scenario: 模板和幻灯片都有description
- **WHEN** 模板定义了 `description` 且幻灯片也定义了 `description`
- **THEN** PPTX 幻灯片备注仅包含幻灯片的 description忽略模板的 description
### Requirement: description必须支持中文字符
系统 SHALL 支持在 `description` 字段中使用中文字符,并正确写入 PPTX 备注。
#### Scenario: description包含中文
- **WHEN** 幻灯片的 description 包含中文字符,如 "这是演讲备注内容"
- **THEN** 系统正确处理PPTX 备注页显示正确的中文内容

View File

@@ -139,7 +139,7 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
### Requirement: 系统必须支持自定义幻灯片
系统 SHALL 支持不使用模板,直接定义元素的自定义幻灯片。
系统 SHALL 支持不使用模板的自定义幻灯片,以及同时使用模板和自定义元素的混合模式幻灯片。
#### Scenario: 渲染自定义幻灯片
@@ -156,6 +156,16 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
- **WHEN** 演示文稿中部分幻灯片使用模板,部分为自定义
- **THEN** 系统正确处理两种类型的幻灯片
#### Scenario: 混合模式幻灯片同时使用模板和自定义元素
- **WHEN** 幻灯片同时指定了 `template` 字段和 `elements` 列表
- **THEN** 系统先渲染模板获取模板元素列表,再追加自定义元素列表,生成最终的元素列表
#### Scenario: 混合模式中模板元素在前
- **WHEN** 幻灯片使用混合模式,模板元素和自定义元素位置重叠
- **THEN** 自定义元素在 z 轴上覆盖模板元素(后渲染在上层)
### Requirement: 模板变量解析必须深度递归
系统 SHALL 递归解析模板元素的所有嵌套字段中的变量引用。
@@ -241,3 +251,79 @@ Template system 提供可复用的幻灯片布局和样式定义。模板包含
- **WHEN** 自定义幻灯片(不使用模板)定义了 `enabled: false`
- **THEN** 系统跳过该幻灯片,不渲染元素列表
### Requirement: 模板与自定义元素必须支持变量共享
系统 SHALL 允许自定义元素访问模板中定义的变量,实现主题色、布局参数等值的统一控制。
#### Scenario: 自定义元素使用模板变量
- **WHEN** 幻灯片使用 `template: content-slide`,提供 `vars: {theme_color: "#3949ab"}`,且自定义元素中定义 `fill: "{theme_color}"`
- **THEN** 系统将自定义元素中的 `{theme_color}` 替换为 `"#3949ab"`
#### Scenario: 自定义元素使用模板默认变量
- **WHEN** 模板定义了 `default: "#3949ab"``theme_color` 变量,幻灯片未提供该变量值,且自定义元素引用 `{theme_color}`
- **THEN** 系统使用模板的默认值 `"#3949ab"` 进行替换
#### Scenario: 自定义元素引用未定义变量时报错
- **WHEN** 自定义元素引用了 `{undefined_var}`,且该变量未在模板 vars 中定义,也未由幻灯片提供
- **THEN** 系统抛出错误,指出未定义的变量
### Requirement: 元素合并必须采用追加策略
系统 SHALL 使用简单追加策略合并模板元素和自定义元素,保持渲染顺序和 z 轴层级。
#### Scenario: 模板元素和自定义元素合并顺序
- **WHEN** 模板渲染后产生 2 个元素,幻灯片自定义元素列表包含 3 个元素
- **THEN** 最终元素列表包含 5 个元素顺序为模板元素1、模板元素2、自定义元素1、自定义元素2、自定义元素3
#### Scenario: 空自定义元素列表
- **WHEN** 幻灯片指定 `template``elements: []`(空数组)
- **THEN** 最终元素列表仅包含模板元素,等同于不指定 `elements` 字段
#### Scenario: 模板条件渲染后的元素合并
- **WHEN** 模板包含 3 个元素,其中 1 个因 `visible` 条件为假被过滤,幻灯片包含 2 个自定义元素
- **THEN** 最终元素列表包含 4 个元素:模板的 2 个可见元素,加上幻灯片的 2 个自定义元素
### Requirement: 混合模式必须保持向后兼容
系统 SHALL 在不使用混合模式时,保持与现有版本完全一致的行为。
#### Scenario: 仅使用模板时不指定 elements
- **WHEN** 幻灯片仅指定 `template` 字段,不包含 `elements` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染模板元素
#### Scenario: 仅使用自定义元素时不指定 template
- **WHEN** 幻灯片仅指定 `elements` 字段,不包含 `template` 字段
- **THEN** 系统表现与现有版本完全一致,仅渲染自定义元素
#### Scenario: 既不使用模板也不使用自定义元素
- **WHEN** 幻灯片既不指定 `template` 也不指定 `elements`
- **THEN** 系统生成空幻灯片(仅包含背景设置)
### Requirement: 混合模式必须支持内联模板
系统 SHALL 在混合模式中支持内联模板与外部模板,功能保持一致。
#### Scenario: 内联模板与自定义元素混合使用
- **WHEN** 幻灯片引用内联模板(在 YAML 文件的 `templates` 字段中定义),同时包含 `elements` 列表
- **THEN** 系统正确渲染内联模板元素,并追加自定义元素
#### Scenario: 外部模板与自定义元素混合使用
- **WHEN** 幻灯片引用外部模板(从 `--template-dir` 目录加载),同时包含 `elements` 列表
- **THEN** 系统正确加载外部模板,渲染模板元素,并追加自定义元素
#### Scenario: 内联和外部模板在同一演示文稿中混合使用
- **WHEN** 演示文稿同时定义了内联模板和使用外部模板,且部分幻灯片使用混合模式
- **THEN** 系统正确处理所有组合情况

View File

@@ -8,6 +8,7 @@ dependencies = [
"flask",
"watchdog",
"simpleeval",
"pillow",
]
[project.optional-dependencies]
@@ -15,7 +16,6 @@ dev = [
"pytest",
"pytest-cov",
"pytest-mock",
"pillow",
]
[tool.setuptools]

View File

@@ -8,13 +8,15 @@ from pathlib import Path
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
# 固定 DPI 用于单位转换
DPI = 96
class HtmlRenderer:
"""HTML 渲染器,将元素渲染为 HTML"""
def __init__(self):
"""
初始化 HTML 渲染器
"""
self.dpi = 96 # 硬编码 Web 标准 DPI
def render_slide(self, slide_data, index, base_path):
"""
渲染单个幻灯片为 HTML
@@ -68,10 +70,10 @@ class HtmlRenderer:
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;
left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * self.dpi}px;
font-size: {elem.font.get("size", 16)}pt;
color: {elem.font.get("color", "#000000")};
text-align: {elem.font.get("align", "left")};
@@ -105,10 +107,10 @@ class HtmlRenderer:
}.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;
left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * self.dpi}px;
background: {elem.fill if elem.fill else "transparent"};
border-radius: {border_radius};
"""
@@ -131,8 +133,8 @@ class HtmlRenderer:
str: HTML 代码
"""
table_style = f"""
left: {elem.position[0] * DPI}px;
top: {elem.position[1] * DPI}px;
left: {elem.position[0] * self.dpi}px;
top: {elem.position[1] * self.dpi}px;
"""
rows_html = ""
@@ -166,11 +168,12 @@ class HtmlRenderer:
"""
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;
left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * self.dpi}px;
"""
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'

View File

@@ -60,6 +60,11 @@ class PptxGenerator:
for elem in elements:
self._render_element(slide, elem, base_path)
# 设置备注
description = slide_data.get('description')
if description:
self._set_notes(slide, description)
def _render_element(self, slide, elem, base_path):
"""
分发元素到对应的渲染方法
@@ -96,34 +101,35 @@ class PptxGenerator:
# 默认启用文字自动换行
tf.word_wrap = True
# 应用字体样式
p = tf.paragraphs[0]
# 应用字体样式到所有段落
# 当文本包含换行符时python-pptx 会创建多个段落
# 需要确保所有段落都应用相同的字体样式
for p in tf.paragraphs:
# 字体大小
if 'size' in elem.font:
p.font.size = Pt(elem.font['size'])
# 字体大小
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('bold'):
p.font.bold = True
#
if elem.font.get('italic'):
p.font.italic = 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)
# 颜色
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)
# 对齐方式
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):
"""
@@ -148,11 +154,8 @@ class PptxGenerator:
# 获取位置和尺寸
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)}")
# 直接添加图片到幻灯片
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
def _render_shape(self, slide, elem: ShapeElement):
"""
@@ -271,6 +274,20 @@ class PptxGenerator:
from utils import log_info
log_info(f"图片背景暂未实现: {background['image']}")
def _set_notes(self, slide, text):
"""
设置幻灯片备注
Args:
slide: pptx slide 对象
text: 备注文本内容
"""
if text is None:
return
notes_slide = slide.notes_slide
text_frame = notes_slide.notes_text_frame
text_frame.text = text
def save(self, output_path):
"""
保存 PPTX 文件

View File

@@ -1,34 +0,0 @@
"""
创建测试图片的辅助脚本
"""
from PIL import Image, ImageDraw
from pathlib import Path
# 确保目录存在
images_dir = Path(__file__).parent.parent / "fixtures" / "images"
images_dir.mkdir(exist_ok=True)
# 创建一个简单的红色图片
img = Image.new('RGB', (100, 100), color='red')
img.save(images_dir / "test_image.png")
# 创建一个较大的图片
large_img = Image.new('RGB', (800, 600), color='blue')
large_img.save(images_dir / "large_image.png")
# 创建一个带有透明度的图片PNG
transparent_img = Image.new('RGBA', (100, 100), (255, 0, 0, 128))
transparent_img.save(images_dir / "transparent_image.png")
# 创建一个小图片
small_img = Image.new('RGB', (50, 50), color='green')
small_img.save(images_dir / "small_image.png")
# 创建一个带文字的图片
text_img = Image.new('RGB', (200, 100), color='white')
draw = ImageDraw.Draw(text_img)
draw.text((10, 30), "Test Image", fill='black')
text_img.save(images_dir / "text_image.png")
print(f"Created test images in {images_dir}")

View File

@@ -336,3 +336,425 @@ slides:
result = pres.render_slide(slide_data)
assert len(result["elements"]) == 2
class TestRenderSlideHybridMode:
"""render_slide 混合模式测试类"""
@patch("core.presentation.Template")
def test_hybrid_mode_basic(self, mock_template_class, temp_dir, sample_template):
"""测试混合模式基本功能template + elements"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "From Template", "box": [0, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"},
"elements": [
{"type": "text", "content": "Custom Element", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素1 个来自模板1 个自定义
assert len(result["elements"]) == 2
assert result["elements"][0].content == "From Template"
assert result["elements"][1].content == "Custom Element"
@patch("core.presentation.Template")
def test_hybrid_mode_variable_sharing(self, mock_template_class, temp_dir, sample_template):
"""测试自定义元素访问模板变量"""
mock_template = Mock()
mock_template.render.return_value = []
# 模拟 resolve_element 解析变量
def resolve_element_mock(elem, vars):
resolved = elem.copy()
if "content" in resolved and "{" in resolved["content"]:
resolved["content"] = resolved["content"].replace("{theme_color}", vars.get("theme_color", ""))
if "fill" in resolved and "{" in resolved["fill"]:
resolved["fill"] = resolved["fill"].replace("{theme_color}", vars.get("theme_color", ""))
return resolved
mock_template.resolve_element.side_effect = resolve_element_mock
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {"theme_color": "#3949ab"},
"elements": [
{"type": "shape", "fill": "{theme_color}", "box": [0, 0, 1, 1]}
]
}
result = pres.render_slide(slide_data)
# 自定义元素应该使用模板变量
assert result["elements"][0].fill == "#3949ab"
def test_hybrid_mode_empty_elements(self, temp_dir, sample_template):
"""测试空 elements 列表"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"},
"elements": []
}
result = pres.render_slide(slide_data)
# 空 elements 列表应该只渲染模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_template_only(self, temp_dir, sample_template):
"""测试向后兼容:纯模板模式"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"}
}
result = pres.render_slide(slide_data)
# 应该只有模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_custom_only(self, sample_yaml):
"""测试向后兼容:纯自定义元素模式"""
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Custom", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该只有自定义元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Custom"
def test_hybrid_mode_with_inline_template(self, temp_dir):
"""测试内联模板与自定义元素混合使用"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Inline Template"},
"elements": [
{"type": "text", "content": "Custom", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素
assert len(result["elements"]) == 2
assert result["elements"][0].content == "Inline Template"
assert result["elements"][1].content == "Custom"
@patch("core.presentation.Template")
def test_hybrid_mode_with_external_template(self, mock_template_class, temp_dir, sample_template):
"""测试外部模板与自定义元素混合使用"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "External", "box": [0, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "external-template",
"vars": {},
"elements": [
{"type": "text", "content": "Custom", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素
assert len(result["elements"]) == 2
assert result["elements"][0].content == "External"
assert result["elements"][1].content == "Custom"
@patch("core.presentation.Template")
def test_hybrid_mode_element_order(self, mock_template_class, temp_dir, sample_template):
"""测试元素顺序:模板元素在前,自定义元素在后"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "Template1", "box": [0, 0, 1, 1], "font": {}},
{"type": "text", "content": "Template2", "box": [1, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {},
"elements": [
{"type": "text", "content": "Custom1", "box": [2, 0, 1, 1], "font": {}},
{"type": "text", "content": "Custom2", "box": [3, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 验证顺序:模板元素在前
assert len(result["elements"]) == 4
assert result["elements"][0].content == "Template1"
assert result["elements"][1].content == "Template2"
assert result["elements"][2].content == "Custom1"
assert result["elements"][3].content == "Custom2"
# ============= Description 字段测试 =============
class TestPresentationDescription:
"""Presentation description 字段测试类"""
def test_metadata_with_description(self, temp_dir):
"""测试 metadata 包含 description 字段时正确加载"""
yaml_content = """
metadata:
title: "测试演示文稿"
description: "这是关于项目年度总结的演示文稿"
size: "16:9"
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.description == "这是关于项目年度总结的演示文稿"
def test_metadata_without_description(self, temp_dir):
"""测试 metadata 不包含 description 字段时正常工作"""
yaml_content = """
metadata:
title: "测试演示文稿"
size: "16:9"
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.description is None
def test_metadata_description_empty_string(self, temp_dir):
"""测试 metadata description 为空字符串时正常工作"""
yaml_content = """
metadata:
title: "测试演示文稿"
description: ""
size: "16:9"
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.description == ""
def test_metadata_description_chinese_characters(self, temp_dir):
"""测试 metadata description 包含中文字符时正确处理"""
yaml_content = """
metadata:
title: "测试"
description: "这是关于项目的演示文稿,包含中文和特殊字符:测试、验证、确认"
size: "16:9"
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert "这是关于项目的演示文稿" in pres.description
assert "中文" in pres.description
class TestSlideDescription:
"""Slide description 字段测试类"""
def test_slide_with_description(self, sample_yaml):
"""测试幻灯片包含 description 字段时正确加载"""
pres = Presentation(str(sample_yaml))
slide_data = {
"description": "介绍项目背景和目标",
"elements": [
{"type": "text", "content": "Test", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
assert result["description"] == "介绍项目背景和目标"
def test_slide_without_description(self, sample_yaml):
"""测试幻灯片不包含 description 字段时正常工作"""
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Test", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
assert result["description"] is None
def test_slide_description_empty_string(self, sample_yaml):
"""测试幻灯片 description 为空字符串时正常工作"""
pres = Presentation(str(sample_yaml))
slide_data = {
"description": "",
"elements": [
{"type": "text", "content": "Test", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
assert result["description"] == ""
def test_slide_description_chinese_characters(self, sample_yaml):
"""测试幻灯片 description 包含中文字符时正确处理"""
pres = Presentation(str(sample_yaml))
slide_data = {
"description": "这是幻灯片描述,包含中文内容",
"elements": [
{"type": "text", "content": "Test", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
assert "这是幻灯片描述" in result["description"]
assert "中文" in result["description"]
def test_slide_description_does_not_affect_rendering(self, sample_yaml):
"""测试 description 不影响渲染输出"""
pres = Presentation(str(sample_yaml))
slide_data = {
"description": "这段描述不应该影响渲染",
"elements": [
{"type": "text", "content": "Test Content", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# description 字段存在但不影响元素渲染
assert result["description"] == "这段描述不应该影响渲染"
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test Content"

View File

@@ -6,21 +6,18 @@ HTML 渲染器单元测试
import pytest
from pathlib import Path
from renderers.html_renderer import HtmlRenderer, DPI
from renderers.html_renderer import HtmlRenderer
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
class TestHtmlRenderer:
"""HtmlRenderer 测试类"""
def test_renderer_has_dpi_constant(self):
"""测试渲染器有 DPI 常量"""
assert DPI == 96
def test_init_renderer(self):
"""测试创建渲染器"""
renderer = HtmlRenderer()
assert renderer is not None
assert renderer.dpi == 96 # 硬编码的 DPI
class TestRenderText:

View File

@@ -158,14 +158,105 @@ class TestRenderText:
# 验证文本框被创建
mock_slide.shapes.add_textbox.assert_called_once()
def _setup_mock_slide(self):
"""辅助函数:创建 mock slide"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_multiline_text_applies_font_size_to_all_paragraphs(self, mock_prs_class):
"""测试多行文本字体大小应用到所有段落"""
# 创建包含 3 个段落的 mock slide
mock_slide = self._setup_mock_slide(num_paragraphs=3)
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 多行文本(包含换行符)
elem = TextElement(
content="第一行\n第二行\n第三行",
box=[1, 2, 3, 1],
font={"size": 12},
)
gen._render_text(mock_slide, elem)
# 验证所有 3 个段落的字体大小都被设置
mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame
assert len(mock_tf.paragraphs) == 3
for para in mock_tf.paragraphs:
para.font.size = 12 # Mock 会记录这个调用
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_multiline_text_applies_all_styles_to_all_paragraphs(self, mock_prs_class):
"""测试多行文本所有样式应用到所有段落"""
# 创建包含 3 个段落的 mock slide
mock_slide = self._setup_mock_slide(num_paragraphs=3)
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 多行文本,包含所有样式属性
elem = TextElement(
content="第一行\n第二行\n第三行",
box=[1, 2, 3, 1],
font={"size": 14, "bold": True, "italic": True, "color": "#ff0000", "align": "center"},
)
gen._render_text(mock_slide, elem)
# 验证所有段落的样式都被设置
mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame
assert len(mock_tf.paragraphs) == 3
for para in mock_tf.paragraphs:
# 验证样式属性被访问mock 会记录这些调用)
_ = para.font.size
_ = para.font.bold
_ = para.font.italic
_ = para.font.color.rgb
_ = para.alignment
@patch("renderers.pptx_renderer.PptxPresentation")
def test_render_single_line_text_unchanged_behavior(self, mock_prs_class):
"""测试单行文本行为不变(回归测试)"""
# 单行文本只需要 1 个段落
mock_slide = self._setup_mock_slide(num_paragraphs=1)
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 单行文本
elem = TextElement(
content="单行文本",
box=[1, 2, 3, 1],
font={"size": 18},
)
gen._render_text(mock_slide, elem)
# 验证文本框被创建
mock_slide.shapes.add_textbox.assert_called_once()
# 验证字体大小被设置
mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame
assert len(mock_tf.paragraphs) == 1
_ = mock_tf.paragraphs[0].font.size
def _setup_mock_slide(self, num_paragraphs=1):
"""辅助函数:创建 mock slide
Args:
num_paragraphs: 要创建的段落数量,默认为 1
"""
mock_slide = Mock()
mock_text_frame = Mock()
mock_text_frame.word_wrap = True
mock_paragraph = Mock()
mock_paragraph.font = Mock()
mock_text_frame.paragraphs = [mock_paragraph]
# 创建指定数量的 mock 段落
mock_paragraphs = []
for _ in range(num_paragraphs):
mock_paragraph = Mock()
mock_paragraph.font = Mock()
mock_paragraphs.append(mock_paragraph)
mock_text_frame.paragraphs = mock_paragraphs
mock_textbox = Mock()
mock_textbox.text_frame = mock_text_frame
@@ -449,3 +540,158 @@ class TestRenderBackground:
# 不应该崩溃
gen._render_background(mock_slide, None)
class TestSetNotes:
"""_set_notes 方法测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_text(self, mock_prs_class):
"""测试设置备注文本"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_slide
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
gen._set_notes(mock_slide, "这是演讲备注内容")
# 验证备注被设置
mock_text_frame.text = "这是演讲备注内容"
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_none(self, mock_prs_class):
"""测试设置 None 不设置备注"""
mock_slide = Mock(spec=[]) # 使用 spec=[] 禁止自动创建属性
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
# 不应该崩溃,不应该访问 notes_slide
gen._set_notes(mock_slide, None)
# 使用 spec=[] 后,访问不存在的属性会抛出 AttributeError
# 如果没有抛出异常,说明 notes_slide 没有被访问
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_empty_string(self, mock_prs_class):
"""测试设置空字符串"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_slide
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
gen._set_notes(mock_slide, "")
# 验证空字符串也被设置
mock_text_frame.text = ""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_multiline_text(self, mock_prs_class):
"""测试设置多行文本"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_slide
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
multiline_text = "第一行备注\n第二行备注\n第三行备注"
gen._set_notes(mock_slide, multiline_text)
# 验证多行文本被设置
mock_text_frame.text = multiline_text
@patch("renderers.pptx_renderer.PptxPresentation")
def test_set_notes_with_chinese_text(self, mock_prs_class):
"""测试设置中文备注"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_slide
mock_prs_class.return_value = Mock()
mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()]
mock_prs_class.return_value.slides.add_slide.return_value = mock_slide
gen = PptxGenerator()
chinese_text = "这是中文演讲备注内容,用于演示时参考"
gen._set_notes(mock_slide, chinese_text)
# 验证中文文本被设置
mock_text_frame.text = chinese_text
class TestAddSlideWithNotes:
"""add_slide 方法备注功能测试类"""
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_with_description_sets_notes(self, mock_prs_class):
"""测试幻灯片包含 description 时设置备注"""
mock_slide = Mock()
mock_notes_slide = Mock()
mock_text_frame = Mock()
mock_notes_slide.notes_text_frame = mock_text_frame
mock_slide.notes_slide = mock_notes_slide
mock_prs = Mock()
mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
slide_data = {"background": None, "elements": [], "description": "这是幻灯片的演讲说明"}
gen.add_slide(slide_data)
# 验证备注被设置
mock_text_frame.text = "这是幻灯片的演讲说明"
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_without_description_no_notes(self, mock_prs_class):
"""测试幻灯片不包含 description 时不设置备注"""
mock_slide = Mock(spec=[]) # 禁止自动创建属性
mock_prs = Mock()
mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
slide_data = {"background": None, "elements": []}
gen.add_slide(slide_data)
# 不应该访问 notes_slide使用 spec=[] 会抛出异常如果被访问)
@patch("renderers.pptx_renderer.PptxPresentation")
def test_add_slide_with_template_description_ignored(self, mock_prs_class):
"""测试模板的 description 不被用作备注(仅幻灯片自己的 description 有效)"""
mock_slide = Mock(spec=[]) # 禁止自动创建属性
mock_prs = Mock()
mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None]
mock_prs.slides.add_slide.return_value = mock_slide
mock_prs_class.return_value = mock_prs
gen = PptxGenerator()
# 幻灯片没有 description则不设置备注即使模板可能有 description
slide_data = {"background": None, "elements": []}
gen.add_slide(slide_data)
# 不应该访问 notes_slide使用 spec=[] 会抛出异常如果被访问)

View File

@@ -8,6 +8,7 @@ import pytest
from pathlib import Path
from loaders.yaml_loader import YAMLError
from core.template import Template
from core.presentation import Presentation
# ============= 模板初始化测试 =============
@@ -892,3 +893,165 @@ slides:
slide_data = pres.data['slides'][0]
rendered = pres.render_slide(slide_data)
assert len(rendered['elements']) == 1
# ============= Description 字段测试 =============
class TestTemplateDescription:
"""Template description 字段测试类"""
def test_template_with_description(self, temp_dir):
"""测试模板包含 description 字段时正确加载"""
template_content = """
description: "用于章节标题页的模板,包含主标题和副标题"
vars:
- name: title
required: true
- name: subtitle
required: false
default: ""
elements:
- type: text
content: "{title}"
box: [1, 1, 8, 1]
font:
size: 44
bold: true
- type: text
content: "{subtitle}"
box: [1, 2, 8, 1]
font:
size: 24
"""
template_path = temp_dir / "test-template.yaml"
template_path.write_text(template_content)
template = Template("test-template", templates_dir=temp_dir)
assert template.description == "用于章节标题页的模板,包含主标题和副标题"
def test_template_without_description(self, sample_template):
"""测试模板不包含 description 字段时正常工作"""
template = Template("title-slide", templates_dir=sample_template)
assert template.description is None
def test_template_description_empty_string(self, temp_dir):
"""测试模板 description 为空字符串时正常工作"""
template_content = """
description: ""
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [1, 1, 8, 1]
font: {}
"""
template_path = temp_dir / "test-template.yaml"
template_path.write_text(template_content)
template = Template("test-template", templates_dir=temp_dir)
assert template.description == ""
def test_template_description_chinese_characters(self, temp_dir):
"""测试模板 description 包含中文字符时正确处理"""
template_content = """
description: "这是中文模板描述,用于标题页面"
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [1, 1, 8, 1]
font: {}
"""
template_path = temp_dir / "test-template.yaml"
template_path.write_text(template_content)
template = Template("test-template", templates_dir=temp_dir)
assert "这是中文模板描述" in template.description
assert "标题页面" in template.description
def test_template_description_multiline(self, temp_dir):
"""测试模板 description 支持多行文本"""
template_content = """
description: |
这是一个多行描述。
第一行说明模板的用途。
第二行说明使用场景。
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [1, 1, 8, 1]
font: {}
"""
template_path = temp_dir / "test-template.yaml"
template_path.write_text(template_content)
template = Template("test-template", templates_dir=temp_dir)
# 多行文本应该被正确读取
assert "这是一个多行描述" in template.description
assert "第一行说明模板的用途" in template.description
assert "第二行说明使用场景" in template.description
def test_inline_template_with_description(self, temp_dir):
"""测试内联模板包含 description 字段"""
yaml_content = """
metadata:
size: "16:9"
templates:
test-template:
description: "内联模板描述"
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [1, 1, 8, 1]
font: {}
slides:
- template: test-template
vars:
title: "Test"
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
template = pres.get_template("test-template")
assert template.description == "内联模板描述"
def test_template_description_does_not_affect_rendering(self, temp_dir):
"""测试 description 不影响模板渲染"""
template_content = """
description: "这段描述不应该影响渲染"
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [1, 1, 8, 1]
font:
size: 44
"""
template_path = temp_dir / "test-template.yaml"
template_path.write_text(template_content)
template = Template("test-template", templates_dir=temp_dir)
# 渲染应该正常工作description 不影响结果
result = template.render({"title": "Test Title"})
assert len(result) == 1
assert result[0]["content"] == "Test Title"

View File

@@ -5,7 +5,7 @@
"""
import pytest
from utils import hex_to_rgb, validate_color
from utils import hex_to_rgb
class TestHexToRgb:
@@ -52,53 +52,3 @@ class TestHexToRgb:
"""测试大小写混合"""
assert hex_to_rgb("#AbCdEf") == (171, 205, 239)
assert hex_to_rgb("#aBcDeF") == (171, 205, 239)
class TestValidateColor:
"""validate_color 函数测试类"""
def test_valid_full_hex_colors(self):
"""测试有效的完整十六进制颜色"""
assert validate_color("#ffffff") is True
assert validate_color("#000000") is True
assert validate_color("#4a90e2") is True
assert validate_color("#FF0000") is True
assert validate_color("#ABCDEF") is True
def test_valid_short_hex_colors(self):
"""测试有效的短格式十六进制颜色"""
assert validate_color("#fff") is True
assert validate_color("#000") is True
assert validate_color("#abc") is True
assert validate_color("#ABC") is True
def test_without_hash_sign(self):
"""测试没有 # 号"""
assert validate_color("ffffff") is False
assert validate_color("fff") is False
def test_invalid_length(self):
"""测试无效长度"""
assert validate_color("#ff") is False
assert validate_color("#ffff") is False
assert validate_color("#fffff") is False
assert validate_color("#fffffff") is False
def test_invalid_characters(self):
"""测试无效字符"""
assert validate_color("#gggggg") is False
assert validate_color("#xyz") is False
assert validate_color("red") is False
assert validate_color("rgb(255,0,0)") is False
def test_non_string_input(self):
"""测试非字符串输入"""
assert validate_color(123) is False
assert validate_color(None) is False
assert validate_color([]) is False
assert validate_color({"color": "#fff"}) is False
def test_empty_string(self):
"""测试空字符串"""
assert validate_color("") is False
assert validate_color("#") is False

View File

@@ -54,21 +54,3 @@ def hex_to_rgb(hex_color):
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

8
uv.lock generated
View File

@@ -1655,6 +1655,9 @@ source = { virtual = "." }
dependencies = [
{ name = "flask", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "flask", version = "3.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pillow", version = "12.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "python-pptx" },
{ name = "pyyaml" },
{ name = "simpleeval", version = "0.9.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -1665,9 +1668,6 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pillow", version = "12.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
@@ -1680,7 +1680,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "flask" },
{ name = "pillow", marker = "extra == 'dev'" },
{ name = "pillow" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-cov", marker = "extra == 'dev'" },
{ name = "pytest-mock", marker = "extra == 'dev'" },