Compare commits
7 Commits
16ca9d77cd
...
7ef29ea039
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef29ea039 | |||
| f34405be36 | |||
| 2fd8bc1b4a | |||
| 5d60f3c2c2 | |||
| 5b367f7ef3 | |||
| 900a38b705 | |||
| 19d6661381 |
@@ -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
228
README.md
@@ -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` 用于描述整个演示文稿,不写入单个幻灯片备注
|
||||
|
||||
### 条件渲染
|
||||
|
||||
#### 元素级条件渲染
|
||||
|
||||
107
README_DEV.md
107
README_DEV.md
@@ -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: "项目背景"
|
||||
```
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新元素类型
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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"), # 保留幻灯片描述
|
||||
}
|
||||
|
||||
@@ -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', []):
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -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
|
||||
|
||||
无。本设计较为简单直接,没有未解决的技术问题。
|
||||
@@ -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文件无需修改即可继续使用
|
||||
@@ -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** 系统正确读取完整的描述内容
|
||||
@@ -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字段影响
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -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),不向用户暴露配置选项。
|
||||
|
||||
**理由:** 演示文稿场景下图片质量优先于处理速度,使用最高质量算法避免用户困惑。
|
||||
@@ -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`: 新增图片处理架构说明
|
||||
@@ -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 用于图片定位和尺寸
|
||||
@@ -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** 系统转换为 CSS:left: 96px; top: 192px; width: 384px; height: 288px
|
||||
@@ -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** 裁剪操作不损失图片质量
|
||||
114
openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md
Normal file
114
openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md
Normal 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 模式
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -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 以反映新行为
|
||||
@@ -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`: 更新文档说明备注功能
|
||||
@@ -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 的幻灯片行为与之前完全一致。
|
||||
@@ -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 备注页显示正确的中文内容
|
||||
17
openspec/changes/archive/2026-03-04-add-slide-notes/tasks.md
Normal file
17
openspec/changes/archive/2026-03-04-add-slide-notes/tasks.md
Normal 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 添加备注功能开发文档
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -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 修复。
|
||||
@@ -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` 需要新增多行文本测试用例
|
||||
- **向后兼容性**: 完全兼容,单行文本行为不变,多行文本从错误行为变为正确行为
|
||||
- **依赖变化**: 无
|
||||
@@ -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`,文字在文本框边界处自动换行
|
||||
@@ -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(如有需要)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -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
|
||||
|
||||
无。移除操作明确清晰,无未决问题。
|
||||
@@ -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=96(Web 标准,无需用户配置)
|
||||
|
||||
## 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`: 移除图片处理架构说明
|
||||
@@ -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** 系统基于演示文稿文件所在目录解析相对路径
|
||||
@@ -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** 系统显示占位符或错误提示,而不是崩溃
|
||||
@@ -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` 测试预览功能
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -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
|
||||
# 处理自定义元素
|
||||
```
|
||||
|
||||
### 决策5:YAML 验证不变
|
||||
|
||||
**选择**:不修改 `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
|
||||
|
||||
无。所有设计决策已在探索阶段明确。
|
||||
@@ -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、两者都不用的现有用法保持不变
|
||||
- 新功能为增量增强,不破坏任何现有用法
|
||||
@@ -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** 系统正确处理所有组合情况
|
||||
@@ -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
|
||||
@@ -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 形状对象。
|
||||
|
||||
@@ -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** 系统转换为 CSS:left: 96px; top: 192px; width: 384px; height: 288px
|
||||
|
||||
### Requirement: 系统必须渲染幻灯片背景
|
||||
|
||||
系统 SHALL 支持为幻灯片设置纯色背景。
|
||||
|
||||
130
openspec/specs/page-description/spec.md
Normal file
130
openspec/specs/page-description/spec.md
Normal 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** 系统正确读取完整的描述内容
|
||||
58
openspec/specs/pptx-slide-notes/spec.md
Normal file
58
openspec/specs/pptx-slide-notes/spec.md
Normal 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 备注页显示正确的中文内容
|
||||
@@ -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** 系统正确处理所有组合情况
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}">'
|
||||
|
||||
@@ -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 文件
|
||||
|
||||
34
tests/fixtures/create_test_images.py
vendored
34
tests/fixtures/create_test_images.py
vendored
@@ -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}")
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=[] 会抛出异常如果被访问)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
8
uv.lock
generated
@@ -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'" },
|
||||
|
||||
Reference in New Issue
Block a user