1
0

feat: 移除图片适配模式功能

移除图片 fit 和 background 参数支持,简化图片渲染逻辑。系统恢复到直接使用 python-pptx 原生图片添加功能,图片将被拉伸到指定尺寸。

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 14:23:12 +08:00
parent 2fd8bc1b4a
commit f34405be36
31 changed files with 494 additions and 1556 deletions

View File

@@ -102,7 +102,6 @@ uv run yaml2pptx.py convert presentation.yaml --skip-validation
```yaml ```yaml
metadata: metadata:
size: "16:9" # 或 "4:3" size: "16:9" # 或 "4:3"
dpi: 96 # 可选DPI 配置,默认 96
slides: slides:
- background: - background:
@@ -118,24 +117,6 @@ slides:
align: center align: center
``` ```
#### DPI 配置
`metadata.dpi` 参数控制图片处理的分辨率,影响图片质量和文件大小:
- **默认值**96 DPI标准屏幕分辨率
- **建议范围**72-300 DPI
- **常用值**
- 72 DPI网页显示
- 96 DPI标准屏幕默认
- 150 DPI高质量打印
- 300 DPI专业印刷
```yaml
metadata:
size: "16:9"
dpi: 150 # 高质量输出
```
#### description 字段 #### description 字段
`metadata.description` 字段用于描述整个演示文稿的概要和用途,仅用于文档目的,不影响生成的 PPTX 文件: `metadata.description` 字段用于描述整个演示文稿的概要和用途,仅用于文档目的,不影响生成的 PPTX 文件:
@@ -189,64 +170,16 @@ slides:
- type: image - type: image
box: [x, y, width, height] box: [x, y, width, height]
src: "path/to/image.png" # 支持相对路径和绝对路径 src: "path/to/image.png" # 支持相对路径和绝对路径
fit: contain # 可选:图片适配模式
background: "#f0f0f0" # 可选:背景色(仅对 contain 和 center 模式有效)
``` ```
#### 图片适配模式
`fit` 参数控制图片如何适配到指定的 box 区域,支持以下模式:
- **stretch**(默认):拉伸图片以填满整个 box可能改变宽高比
- **contain**:保持宽高比,完整显示图片在 box 内,可能留白
- **cover**:保持宽高比,填满整个 box可能裁剪图片
- **center**:不缩放,居中显示,超出部分裁剪
**示例** **示例**
```yaml ```yaml
slides: slides:
- elements: - elements:
# 默认 stretch 模式
- type: image - type: image
src: "photo.jpg" src: "photo.jpg"
box: [1, 1, 4, 3] box: [1, 1, 4, 3]
# contain 模式,带灰色背景
- type: image
src: "photo.jpg"
box: [5, 1, 4, 3]
fit: contain
background: "#f0f0f0"
# cover 模式,填满区域
- type: image
src: "photo.jpg"
box: [1, 4, 4, 3]
fit: cover
# center 模式,不缩放
- type: image
src: "icon.png"
box: [5, 4, 2, 2]
fit: center
background: "#ffffff"
```
#### 背景色
`background` 参数为图片添加背景色,支持 `#RRGGBB``#RGB` 格式:
- 仅对 `contain``center` 模式有效
- 当图片小于 box 或保持宽高比时,背景色填充留白区域
- 默认为透明(无背景色)
```yaml
- type: image
src: "logo.png"
box: [1, 1, 3, 2]
fit: contain
background: "#ffffff" # 白色背景
``` ```
### 形状元素 ### 形状元素

View File

@@ -67,49 +67,6 @@ yaml2pptx.py (入口)
- 核心层不依赖其他业务模块 - 核心层不依赖其他业务模块
- 验证层可以调用核心层的元素验证方法 - 验证层可以调用核心层的元素验证方法
### 图片处理架构
图片适配模式功能采用分层设计,将图片处理逻辑与渲染逻辑分离:
```
ImageElement (核心层)
↓ 定义 fit/background 字段
utils/image_utils.py (工具层)
↓ 提供图片处理函数
├─→ apply_fit_mode() - 应用适配模式
├─→ create_canvas_with_background() - 创建背景画布
└─→ inches_to_pixels() / pixels_to_inches() - 单位转换
↓ 使用 Pillow (PIL)
renderers/pptx_renderer.py (渲染层)
↓ 调用 image_utils 处理图片
└─→ 将处理后的图片添加到 PPTX
renderers/html_renderer.py (渲染层)
└─→ 使用 CSS object-fit 实现相同效果
```
**设计要点**
1. **Pillow 依赖**
- 用于高质量图片处理(缩放、裁剪、画布创建)
- 使用 LANCZOS 重采样算法确保最佳质量
- 支持 RGB 模式和透明度处理
2. **适配模式实现**
- **stretch**:直接使用 `Image.resize()`
- **contain**:使用 `ImageOps.contain()` 保持宽高比
- **cover**:使用 `ImageOps.cover()` + 中心裁剪
- **center**:不缩放,使用 `Image.crop()` 裁剪超出部分
3. **DPI 配置**
-`metadata.dpi` 配置,默认 96
- 通过 Presentation → Renderer 传递
- 用于像素与英寸的转换计算
4. **向后兼容性**
- `fit``background` 参数可选
- 未指定 `fit` 时默认使用 `stretch` 模式
- 现有 YAML 文件无需修改即可正常工作
## 模块职责 ## 模块职责
### 1. yaml2pptx.py入口层 ### 1. yaml2pptx.py入口层
@@ -128,22 +85,13 @@ renderers/html_renderer.py (渲染层)
- 进度日志显示准确的渲染数量(不包括禁用的幻灯片) - 进度日志显示准确的渲染数量(不包括禁用的幻灯片)
### 2. utils/(工具层) ### 2. utils/(工具层)
- **职责**:通用工具函数和图片处理 - **职责**:通用工具函数
- **包含** - **包含**
- `utils/__init__.py` - 日志和颜色工具 - `utils/__init__.py` - 日志和颜色工具
- 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()` - 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- 颜色转换:`hex_to_rgb()`, `validate_color()` - 颜色转换:`hex_to_rgb()`, `validate_color()`
- `utils/image_utils.py` - 图片处理工具
- 单位转换:`inches_to_pixels()`, `pixels_to_inches()`
- 图片适配:`apply_fit_mode()` - 支持 stretch/contain/cover/center 四种模式
- 画布创建:`create_canvas_with_background()` - 创建带背景色的画布
- **依赖** - **依赖**
- Pillow (PIL) - 用于高质量图片处理 - Pillow (PIL) - 保留用于未来可能的图片处理需求
- 使用 LANCZOS 重采样算法确保最佳质量
- **设计原则**
- 图片处理与渲染逻辑分离
- 支持 DPI 配置,灵活控制图片分辨率
- 所有图片操作返回 PIL Image 对象,便于后续处理
### 3. loaders/yaml_loader.py加载层 ### 3. loaders/yaml_loader.py加载层
- **职责**YAML 文件加载和验证 - **职责**YAML 文件加载和验证
@@ -187,10 +135,6 @@ renderers/html_renderer.py (渲染层)
- `validators/resource.py` - 资源验证器 - `validators/resource.py` - 资源验证器
- `ResourceValidator` - 检查图片、模板文件存在性 - `ResourceValidator` - 检查图片、模板文件存在性
- 验证模板文件结构 - 验证模板文件结构
- `validators/image_config.py` - 图片配置验证器
- `validate_fit_value()` - 验证 fit 参数值
- `validate_background_color()` - 验证背景色格式
- `validate_dpi_value()` - 验证 DPI 值范围
- `validators/validator.py` - 主验证器 - `validators/validator.py` - 主验证器
- `Validator` - 协调所有子验证器 - `Validator` - 协调所有子验证器
- 集成元素级验证、几何验证、资源验证 - 集成元素级验证、几何验证、资源验证

View File

@@ -78,8 +78,6 @@ class ImageElement:
type: str = 'image' type: str = 'image'
src: str = '' src: str = ''
box: list = field(default_factory=lambda: [1, 1, 4, 3]) box: list = field(default_factory=lambda: [1, 1, 4, 3])
fit: Optional[str] = None
background: Optional[str] = None
def __post_init__(self): def __post_init__(self):
"""创建时验证""" """创建时验证"""
@@ -92,31 +90,7 @@ class ImageElement:
def validate(self) -> List: def validate(self) -> List:
"""验证元素自身的完整性""" """验证元素自身的完整性"""
from validators.result import ValidationIssue return []
issues = []
# 验证 fit 参数
if self.fit is not None:
valid_fits = ['stretch', 'contain', 'cover', 'center']
if self.fit not in valid_fits:
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的 fit 值: {self.fit} (支持: {', '.join(valid_fits)})",
location="",
code="INVALID_FIT_VALUE"
))
# 验证 background 参数
if self.background is not None:
if not _is_valid_color(self.background):
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的背景颜色格式: {self.background} (应为 #RRGGBB 或 #RGB)",
location="",
code="INVALID_COLOR_FORMAT"
))
return issues
@dataclass @dataclass

View File

@@ -31,7 +31,6 @@ class Presentation:
# 获取演示文稿尺寸 # 获取演示文稿尺寸
metadata = self.data.get("metadata", {}) metadata = self.data.get("metadata", {})
self.size = metadata.get("size", "16:9") self.size = metadata.get("size", "16:9")
self.dpi = metadata.get("dpi", 96)
self.description = metadata.get("description") # 可选的描述字段 self.description = metadata.get("description") # 可选的描述字段
# 验证尺寸值 # 验证尺寸值

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,61 +54,23 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须支持图片元素渲染 ### Requirement: 系统必须支持图片元素渲染
系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象,支持 `fit``background` 参数控制图片适配模式 系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象。
#### Scenario: 渲染本地图片 #### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}` - **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统从指定路径加载图片,在 (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: 图片文件不存在时报错 #### Scenario: 图片文件不存在时报错
- **WHEN** 图片 src 指向不存在的文件路径 - **WHEN** 图片 src 指向不存在的文件路径
- **THEN** 系统抛出错误,明确指出图片文件未找到 - **THEN** 系统抛出错误,明确指出图片文件未找到
#### Scenario: 图片格式不支持时报错
- **WHEN** 图片文件格式不被 Pillow 支持
- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式
#### Scenario: 图片处理失败时报错
- **WHEN** Pillow 处理图片时发生异常(如文件损坏)
- **THEN** 系统抛出 ERROR 级别错误,不降级到其他模式
#### Scenario: 相对路径处理 #### Scenario: 相对路径处理
- **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"` - **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"`
- **THEN** 系统基于演示文稿文件所在目录解析相对路径 - **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 参数必须存在 ### Requirement: 图片元素的 box 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。 系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。

View File

@@ -133,45 +133,12 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须渲染图片元素 ### Requirement: 系统必须渲染图片元素
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,支持 `fit``background` 参数,使用 CSS object-fit 实现适配模式 系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,使用固定 DPI 进行尺寸转换
#### Scenario: 渲染本地图片 #### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}` - **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统生成 `<img>` 标签src 为图片的文件路径,位置为 (192px, 288px),尺寸为 384x288 像素 - **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: 处理相对路径 #### Scenario: 处理相对路径
@@ -183,18 +150,6 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
- **WHEN** 图片文件不存在 - **WHEN** 图片文件不存在
- **THEN** 系统显示占位符或错误提示,而不是崩溃 - **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 参数必须存在 ### Requirement: 图片元素的 box 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。 系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。

View File

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

View File

@@ -148,7 +148,7 @@ def generate_preview_html(yaml_file, template_dir):
"""生成完整的预览 HTML 页面""" """生成完整的预览 HTML 页面"""
try: try:
pres = Presentation(yaml_file, template_dir) pres = Presentation(yaml_file, template_dir)
renderer = HtmlRenderer(pres.dpi) renderer = HtmlRenderer()
slides_html = "" slides_html = ""
for i, slide_data in enumerate(pres.data.get('slides', [])): for i, slide_data in enumerate(pres.data.get('slides', [])):

View File

@@ -11,14 +11,11 @@ from core.elements import TextElement, ImageElement, ShapeElement, TableElement
class HtmlRenderer: class HtmlRenderer:
"""HTML 渲染器,将元素渲染为 HTML""" """HTML 渲染器,将元素渲染为 HTML"""
def __init__(self, dpi=96): def __init__(self):
""" """
初始化 HTML 渲染器 初始化 HTML 渲染器
Args:
dpi: DPI 配置,默认 96
""" """
self.dpi = dpi self.dpi = 96 # 硬编码 Web 标准 DPI
def render_slide(self, slide_data, index, base_path): def render_slide(self, slide_data, index, base_path):
""" """
@@ -171,46 +168,12 @@ class HtmlRenderer:
""" """
img_path = Path(base_path) / elem.src if base_path else Path(elem.src) img_path = Path(base_path) / elem.src if base_path else Path(elem.src)
# 获取 fit 模式,默认为 stretch
fit = elem.fit if elem.fit else 'stretch'
# fit 模式到 CSS object-fit 的映射
object_fit_map = {
'stretch': 'fill',
'contain': 'contain',
'cover': 'cover',
'center': 'none'
}
object_fit = object_fit_map.get(fit, 'fill')
# 基础样式 # 基础样式
style = f""" style = f"""
left: {elem.box[0] * self.dpi}px; left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * self.dpi}px; top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * self.dpi}px; width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * self.dpi}px; height: {elem.box[3] * self.dpi}px;
object-fit: {object_fit};
object-position: center;
""" """
# 如果有背景色,需要创建包装容器 return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'
if elem.background:
container_style = f"""
position: absolute;
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-color: {elem.background};
"""
img_style = f"""
width: 100%;
height: 100%;
object-fit: {object_fit};
object-position: center;
"""
return f'''<div class="element image-container" style="{container_style}">
<img class="image-element" src="file://{img_path.absolute()}" style="{img_style}">
</div>'''
else:
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'

View File

@@ -10,28 +10,23 @@ from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE from pptx.enum.shapes import MSO_SHAPE
from pptx.dml.color import RGBColor from pptx.dml.color import RGBColor
from PIL import Image
import tempfile
from core.elements import TextElement, ImageElement, ShapeElement, TableElement from core.elements import TextElement, ImageElement, ShapeElement, TableElement
from loaders.yaml_loader import YAMLError from loaders.yaml_loader import YAMLError
from utils import hex_to_rgb from utils import hex_to_rgb
from utils.image_utils import inches_to_pixels, apply_fit_mode
class PptxGenerator: class PptxGenerator:
"""PPTX 生成器,封装 python-pptx 操作""" """PPTX 生成器,封装 python-pptx 操作"""
def __init__(self, size='16:9', dpi=96): def __init__(self, size='16:9'):
""" """
初始化 PPTX 生成器 初始化 PPTX 生成器
Args: Args:
size: 幻灯片尺寸("16:9""4:3" size: 幻灯片尺寸("16:9""4:3"
dpi: DPI 配置,默认 96
""" """
self.prs = PptxPresentation() self.prs = PptxPresentation()
self.dpi = dpi
# 设置幻灯片尺寸 # 设置幻灯片尺寸
if size == '16:9': if size == '16:9':
@@ -154,41 +149,8 @@ class PptxGenerator:
# 获取位置和尺寸 # 获取位置和尺寸
x, y, w, h = [Inches(v) for v in elem.box] x, y, w, h = [Inches(v) for v in elem.box]
# 获取 fit 模式,默认为 stretch # 直接添加图片到幻灯片
fit = elem.fit if elem.fit else 'stretch' slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
try:
# stretch 模式:直接使用 python-pptx 的原生处理
if fit == 'stretch':
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
else:
# 其他模式:使用 Pillow 处理图片
# 打开图片
with Image.open(img_path) as img:
# 转换 box 尺寸为像素
box_width_px = int(inches_to_pixels(elem.box[2], self.dpi))
box_height_px = int(inches_to_pixels(elem.box[3], self.dpi))
box_size = (box_width_px, box_height_px)
# 应用 fit 模式
processed_img = apply_fit_mode(
img, box_size, fit, elem.background
)
# 保存处理后的图片到临时文件
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
processed_img.save(tmp.name, 'PNG')
tmp_path = tmp.name
try:
# 添加处理后的图片到幻灯片
slide.shapes.add_picture(tmp_path, x, y, width=w, height=h)
finally:
# 清理临时文件
Path(tmp_path).unlink(missing_ok=True)
except Exception as e:
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")
def _render_shape(self, slide, elem: ShapeElement): def _render_shape(self, slide, elem: ShapeElement):
""" """

View File

@@ -1,35 +0,0 @@
metadata:
title: "背景色测试"
size: "16:9"
dpi: 96
slides:
- elements:
- type: text
content: "背景色支持测试"
box: [1, 0.5, 8, 0.8]
font:
size: 28
bold: true
align: center
# 白色背景
- type: image
src: "../fixtures/images/test_image.png"
box: [1, 1.5, 2.5, 2]
fit: contain
background: "#ffffff"
# 灰色背景
- type: image
src: "../fixtures/images/test_image.png"
box: [4, 1.5, 2.5, 2]
fit: contain
background: "#cccccc"
# 彩色背景
- type: image
src: "../fixtures/images/test_image.png"
box: [7, 1.5, 2.5, 2]
fit: center
background: "#e3f2fd"

View File

@@ -1,20 +0,0 @@
metadata:
title: "DPI 配置测试"
size: "16:9"
dpi: 150
slides:
- elements:
- type: text
content: "DPI: 150"
box: [1, 0.5, 8, 0.8]
font:
size: 28
bold: true
align: center
- type: image
src: "../fixtures/images/test_image.png"
box: [2, 1.5, 6, 3]
fit: contain
background: "#f5f5f5"

View File

@@ -1,40 +0,0 @@
metadata:
title: "图片适配模式端到端测试"
size: "16:9"
dpi: 96
slides:
# 测试所有 fit 模式
- elements:
- type: text
content: "图片适配模式测试"
box: [1, 0.5, 8, 0.8]
font:
size: 32
bold: true
align: center
# stretch 模式(默认)
- type: image
src: "../fixtures/images/test_image.png"
box: [0.5, 1.5, 2, 1.5]
# contain 模式
- type: image
src: "../fixtures/images/test_image.png"
box: [3, 1.5, 2, 1.5]
fit: contain
background: "#f0f0f0"
# cover 模式
- type: image
src: "../fixtures/images/test_image.png"
box: [5.5, 1.5, 2, 1.5]
fit: cover
# center 模式
- type: image
src: "../fixtures/images/test_image.png"
box: [8, 1.5, 2, 1.5]
fit: center
background: "#ffffff"

View File

@@ -1,19 +0,0 @@
metadata:
title: "无效参数测试"
size: "16:9"
dpi: 96
slides:
- elements:
# 无效的 fit 值
- type: image
src: "../fixtures/images/test_image.png"
box: [1, 1, 3, 2]
fit: invalid_mode
# 无效的背景色
- type: image
src: "../fixtures/images/test_image.png"
box: [5, 1, 3, 2]
fit: contain
background: "not-a-color"

View File

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

View File

@@ -1,304 +0,0 @@
"""
图片适配模式集成测试
测试图片 fit 模式在 PPTX 和 HTML 渲染中的完整流程。
"""
import pytest
from pathlib import Path
from PIL import Image
from pptx import Presentation
from core.presentation import Presentation as CorePresentation
from renderers.pptx_renderer import PptxGenerator
from renderers.html_renderer import HtmlRenderer
@pytest.fixture
def test_image_path(tmp_path):
"""创建测试图片"""
img_path = tmp_path / "test_image.png"
# 创建 200x100 的测试图片
img = Image.new("RGB", (200, 100), color="red")
img.save(img_path)
return img_path
@pytest.fixture
def yaml_dir(tmp_path):
"""创建临时 YAML 目录"""
return tmp_path
class TestPptxImageFitModes:
"""测试 PPTX 渲染器的图片适配模式"""
def test_stretch_mode(self, yaml_dir, test_image_path, tmp_path):
"""测试 stretch 模式的 PPTX 渲染"""
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
# 加载并渲染
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
# 保存并验证
output_path = tmp_path / "stretch.pptx"
gen.save(output_path)
assert output_path.exists()
# 验证 PPTX 可以打开
pptx = Presentation(str(output_path))
assert len(pptx.slides) == 1
def test_contain_mode_larger_image(self, yaml_dir, tmp_path):
"""测试 contain 模式(图片比 box 大)"""
# 创建 400x300 的大图片
img_path = yaml_dir / "large_image.png"
img = Image.new("RGB", (400, 300), color="blue")
img.save(img_path)
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{img_path.name}"
box: [1, 1, 3, 2]
fit: contain
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
output_path = tmp_path / "contain_large.pptx"
gen.save(output_path)
assert output_path.exists()
def test_contain_mode_smaller_image(self, yaml_dir, tmp_path):
"""测试 contain 模式(图片比 box 小)"""
# 创建 50x50 的小图片
img_path = yaml_dir / "small_image.png"
img = Image.new("RGB", (50, 50), color="green")
img.save(img_path)
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{img_path.name}"
box: [1, 1, 4, 3]
fit: contain
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
output_path = tmp_path / "contain_small.pptx"
gen.save(output_path)
assert output_path.exists()
def test_contain_mode_with_background(self, yaml_dir, test_image_path, tmp_path):
"""测试 contain 模式(带背景色)"""
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
fit: contain
background: "#f0f0f0"
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
output_path = tmp_path / "contain_bg.pptx"
gen.save(output_path)
assert output_path.exists()
def test_cover_mode(self, yaml_dir, test_image_path, tmp_path):
"""测试 cover 模式"""
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
fit: cover
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
output_path = tmp_path / "cover.pptx"
gen.save(output_path)
assert output_path.exists()
def test_center_mode_with_background(self, yaml_dir, test_image_path, tmp_path):
"""测试 center 模式(带背景色)"""
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
fit: center
background: "#ffffff"
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
output_path = tmp_path / "center_bg.pptx"
gen.save(output_path)
assert output_path.exists()
def test_different_dpi_values(self, yaml_dir, test_image_path, tmp_path):
"""测试不同 DPI 配置"""
for dpi in [72, 96, 150, 300]:
yaml_content = f"""
metadata:
size: "16:9"
dpi: {dpi}
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
fit: contain
"""
yaml_path = yaml_dir / f"test_dpi_{dpi}.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
gen = PptxGenerator(size='16:9', dpi=dpi)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
gen.add_slide(rendered, base_path=yaml_dir)
output_path = tmp_path / f"dpi_{dpi}.pptx"
gen.save(output_path)
assert output_path.exists()
class TestHtmlImageFitModes:
"""测试 HTML 渲染器的图片适配模式"""
def test_html_fit_modes(self, yaml_dir, test_image_path):
"""测试 HTML 渲染器的四种 fit 模式"""
for fit_mode in ['stretch', 'contain', 'cover', 'center']:
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
fit: {fit_mode}
"""
yaml_path = yaml_dir / f"test_{fit_mode}.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
renderer = HtmlRenderer(dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
html = renderer.render_slide(rendered, 0, base_path=yaml_dir)
# 验证 HTML 包含图片元素
assert '<img' in html or 'background-image' in html
def test_html_background_color(self, yaml_dir, test_image_path):
"""测试 HTML 渲染器的背景色支持"""
yaml_content = f"""
metadata:
size: "16:9"
dpi: 96
slides:
- elements:
- type: image
src: "{test_image_path.name}"
box: [1, 1, 4, 3]
fit: contain
background: "#f0f0f0"
"""
yaml_path = yaml_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = CorePresentation(str(yaml_path))
renderer = HtmlRenderer(dpi=96)
for slide_data in pres.data.get('slides', []):
rendered = pres.render_slide(slide_data)
html = renderer.render_slide(rendered, 0, base_path=yaml_dir)
# 验证 HTML 包含背景色
assert '#f0f0f0' in html or 'background-color' in html

View File

@@ -89,19 +89,6 @@ class TestImageElement:
assert elem.type == 'image' assert elem.type == 'image'
assert elem.src == "test.png" assert elem.src == "test.png"
assert elem.box == [1, 1, 4, 3] assert elem.box == [1, 1, 4, 3]
assert elem.fit is None
assert elem.background is None
def test_create_with_fit_and_background(self):
"""测试创建带 fit 和 background 的 ImageElement"""
elem = ImageElement(
src="test.png",
box=[1, 1, 4, 3],
fit="contain",
background="#ffffff"
)
assert elem.fit == "contain"
assert elem.background == "#ffffff"
def test_empty_src_raises_error(self): def test_empty_src_raises_error(self):
"""测试空 src 会引发错误""" """测试空 src 会引发错误"""

View File

@@ -1,120 +0,0 @@
"""
单元测试:图片处理工具函数
测试 utils/image_utils.py 中的图片处理函数。
"""
import pytest
from PIL import Image
from utils.image_utils import (
inches_to_pixels,
pixels_to_inches,
apply_fit_mode,
create_canvas_with_background,
)
class TestUnitConversion:
"""测试单位转换函数"""
def test_inches_to_pixels_default_dpi(self):
"""测试英寸转像素(默认 96 DPI"""
assert inches_to_pixels(1) == 96
assert inches_to_pixels(2) == 192
assert inches_to_pixels(0.5) == 48
def test_inches_to_pixels_custom_dpi(self):
"""测试英寸转像素(自定义 DPI"""
assert inches_to_pixels(1, dpi=72) == 72
assert inches_to_pixels(2, dpi=150) == 300
assert inches_to_pixels(0.5, dpi=300) == 150
def test_pixels_to_inches_default_dpi(self):
"""测试像素转英寸(默认 96 DPI"""
assert pixels_to_inches(96) == 1.0
assert pixels_to_inches(192) == 2.0
assert pixels_to_inches(48) == 0.5
def test_pixels_to_inches_custom_dpi(self):
"""测试像素转英寸(自定义 DPI"""
assert pixels_to_inches(72, dpi=72) == 1.0
assert pixels_to_inches(300, dpi=150) == 2.0
class TestApplyFitMode:
"""测试图片适配模式函数"""
@pytest.fixture
def test_image(self):
"""创建测试图片100x100"""
return Image.new("RGB", (100, 100), color="red")
def test_stretch_mode(self, test_image):
"""测试 stretch 模式"""
result = apply_fit_mode(test_image, (200, 150), "stretch")
assert result.size == (200, 150)
def test_contain_mode_wider_box(self, test_image):
"""测试 contain 模式box 更宽)"""
result = apply_fit_mode(test_image, (200, 100), "contain")
# 图片应该适配到高度,宽度保持比例
assert result.size == (100, 100)
def test_contain_mode_taller_box(self, test_image):
"""测试 contain 模式box 更高)"""
result = apply_fit_mode(test_image, (100, 200), "contain")
# 图片应该适配到宽度,高度保持比例
assert result.size == (100, 100)
def test_cover_mode_wider_box(self, test_image):
"""测试 cover 模式box 更宽)"""
result = apply_fit_mode(test_image, (200, 100), "cover")
# 图片应该覆盖整个 box
assert result.size == (200, 100)
def test_cover_mode_taller_box(self, test_image):
"""测试 cover 模式box 更高)"""
result = apply_fit_mode(test_image, (100, 200), "cover")
# 图片应该覆盖整个 box
assert result.size == (100, 200)
def test_center_mode(self, test_image):
"""测试 center 模式"""
result = apply_fit_mode(test_image, (200, 150), "center")
# center 模式保持原始尺寸
assert result.size == (100, 100)
def test_invalid_mode(self, test_image):
"""测试无效的 fit 模式"""
with pytest.raises(ValueError, match="不支持的 fit 模式"):
apply_fit_mode(test_image, (200, 150), "invalid")
class TestCreateCanvas:
"""测试画布创建函数"""
def test_create_canvas_with_white_background(self):
"""测试创建白色背景画布"""
canvas = create_canvas_with_background((200, 150), "#ffffff")
assert canvas.size == (200, 150)
assert canvas.mode == "RGB"
# 检查中心像素是白色
assert canvas.getpixel((100, 75)) == (255, 255, 255)
def test_create_canvas_with_colored_background(self):
"""测试创建彩色背景画布"""
canvas = create_canvas_with_background((200, 150), "#ff0000")
assert canvas.size == (200, 150)
# 检查中心像素是红色
assert canvas.getpixel((100, 75)) == (255, 0, 0)
def test_create_canvas_with_short_hex(self):
"""测试创建画布(短格式十六进制颜色)"""
canvas = create_canvas_with_background((100, 100), "#f00")
# 检查中心像素是红色
assert canvas.getpixel((50, 50)) == (255, 0, 0)
def test_create_canvas_with_invalid_color(self):
"""测试无效颜色格式"""
with pytest.raises(ValueError, match="无效的颜色格式"):
create_canvas_with_background((100, 100), "invalid")

View File

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

View File

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

View File

@@ -1,136 +0,0 @@
"""
单元测试:图片配置验证器
测试 validators/image_config.py 中的验证函数。
"""
import pytest
from validators.image_config import (
validate_fit_value,
validate_background_color,
validate_dpi_value,
)
class TestValidateFitValue:
"""测试 fit 值验证"""
def test_valid_fit_values(self):
"""测试有效的 fit 值"""
assert validate_fit_value("stretch") == []
assert validate_fit_value("contain") == []
assert validate_fit_value("cover") == []
assert validate_fit_value("center") == []
def test_none_fit_value(self):
"""测试 None 值(默认)"""
# None 是无效的,应该返回错误
issues = validate_fit_value(None)
assert len(issues) > 0
def test_invalid_fit_value(self):
"""测试无效的 fit 值"""
issues = validate_fit_value("invalid")
assert len(issues) > 0
assert issues[0].level == "ERROR"
issues = validate_fit_value("fill")
assert len(issues) > 0
issues = validate_fit_value("scale")
assert len(issues) > 0
def test_case_sensitive(self):
"""测试大小写敏感"""
issues = validate_fit_value("STRETCH")
assert len(issues) > 0
issues = validate_fit_value("Contain")
assert len(issues) > 0
class TestValidateBackgroundColor:
"""测试背景色验证"""
def test_valid_hex_colors(self):
"""测试有效的十六进制颜色"""
assert validate_background_color("#ffffff") == []
assert validate_background_color("#000000") == []
assert validate_background_color("#ff0000") == []
assert validate_background_color("#4a90e2") == []
def test_valid_short_hex_colors(self):
"""测试有效的短格式十六进制颜色"""
assert validate_background_color("#fff") == []
assert validate_background_color("#000") == []
assert validate_background_color("#f00") == []
def test_none_background(self):
"""测试 None 值(透明)"""
# None 会导致 TypeError应该返回错误
issues = validate_background_color(None)
assert len(issues) > 0
def test_invalid_colors(self):
"""测试无效的颜色格式"""
issues = validate_background_color("white")
assert len(issues) > 0
issues = validate_background_color("rgb(255,255,255)")
assert len(issues) > 0
issues = validate_background_color("#gggggg")
assert len(issues) > 0
issues = validate_background_color("#ff")
assert len(issues) > 0
issues = validate_background_color("ffffff")
assert len(issues) > 0
class TestValidateDpiValue:
"""测试 DPI 值验证"""
def test_valid_dpi_values(self):
"""测试有效的 DPI 值"""
assert validate_dpi_value(72) == []
assert validate_dpi_value(96) == []
assert validate_dpi_value(150) == []
assert validate_dpi_value(300) == []
def test_boundary_dpi_values(self):
"""测试边界 DPI 值"""
# 1 和 1200 超出建议范围,会返回 WARNING
issues = validate_dpi_value(1)
assert len(issues) > 0
assert issues[0].level == "WARNING"
issues = validate_dpi_value(1200)
assert len(issues) > 0
assert issues[0].level == "WARNING"
def test_invalid_dpi_values(self):
"""测试无效的 DPI 值"""
# 0 和负数会返回 WARNING
issues = validate_dpi_value(0)
assert len(issues) > 0
issues = validate_dpi_value(-1)
assert len(issues) > 0
issues = validate_dpi_value(1201)
assert len(issues) > 0
issues = validate_dpi_value(2000)
assert len(issues) > 0
def test_non_integer_dpi(self):
"""测试非整数 DPI 值"""
# 浮点数 DPI 可能被接受(取决于实现)
# 字符串和 None 应该返回错误
issues = validate_dpi_value("96")
assert len(issues) > 0
issues = validate_dpi_value(None)
assert len(issues) > 0

View File

@@ -1,149 +0,0 @@
"""
图片处理工具模块
提供图片适配模式处理、像素与英寸转换等功能。
"""
from PIL import Image, ImageOps
from typing import Tuple, Optional
def inches_to_pixels(inches: float, dpi: int = 96) -> float:
"""
将英寸转换为像素
Args:
inches: 英寸值
dpi: DPI每英寸像素数默认 96
Returns:
像素值
"""
return inches * dpi
def pixels_to_inches(pixels: float, dpi: int = 96) -> float:
"""
将像素转换为英寸
Args:
pixels: 像素值
dpi: DPI每英寸像素数默认 96
Returns:
英寸值
"""
return pixels / dpi
def create_canvas_with_background(size: Tuple[int, int], background_color: str) -> Image.Image:
"""
创建带背景色的画布
Args:
size: 画布尺寸 (width, height)
background_color: 背景颜色(#RRGGBB 或 #RGB 格式)
Returns:
PIL Image 对象
"""
# 验证颜色格式
import re
if not isinstance(background_color, str):
raise ValueError(f"无效的颜色格式: {background_color}")
pattern = r'^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$'
if not re.match(pattern, background_color):
raise ValueError(f"无效的颜色格式: {background_color}")
# 创建 RGB 模式的画布
canvas = Image.new('RGB', size, background_color)
return canvas
def apply_fit_mode(
img: Image.Image,
box_size: Tuple[int, int],
fit: Optional[str] = None,
background: Optional[str] = None
) -> Image.Image:
"""
应用图片适配模式
Args:
img: PIL Image 对象
box_size: 目标尺寸 (width, height) 像素
fit: 适配模式stretch, contain, cover, center默认 stretch
background: 背景颜色(#RRGGBB 或 #RGB 格式),仅对 contain 和 center 模式有效
Returns:
处理后的 PIL Image 对象
"""
if fit is None or fit == 'stretch':
# stretch 模式:直接拉伸到目标尺寸
return img.resize(box_size, Image.Resampling.LANCZOS)
elif fit == 'contain':
# contain 模式:保持宽高比,完整显示在 box 内
# 如果图片比 box 小,保持原始尺寸;如果比 box 大,等比缩小
img_width, img_height = img.size
box_width, box_height = box_size
# 检查图片是否需要缩小
if img_width <= box_width and img_height <= box_height:
# 图片比 box 小,保持原始尺寸
result = img
else:
# 图片比 box 大,使用 contain 缩小
result = ImageOps.contain(img, box_size, Image.Resampling.LANCZOS)
# 如果指定了背景色,创建画布并居中粘贴
if background:
canvas = create_canvas_with_background(box_size, background)
# 计算居中位置
offset_x = (box_size[0] - result.width) // 2
offset_y = (box_size[1] - result.height) // 2
canvas.paste(result, (offset_x, offset_y))
return canvas
return result
elif fit == 'cover':
# cover 模式:保持宽高比,填满 box裁剪超出部分
result = ImageOps.cover(img, box_size, Image.Resampling.LANCZOS)
# ImageOps.cover 可能返回比 box_size 大的图片,需要裁剪到精确尺寸
if result.size != box_size:
# 从中心裁剪到目标尺寸
left = (result.width - box_size[0]) // 2
top = (result.height - box_size[1]) // 2
result = result.crop((left, top, left + box_size[0], top + box_size[1]))
return result
elif fit == 'center':
# center 模式:不缩放,居中显示,超出部分裁剪
img_width, img_height = img.size
box_width, box_height = box_size
# 如果图片比 box 大,需要裁剪
if img_width > box_width or img_height > box_height:
# 计算裁剪区域(从中心裁剪)
left = max(0, (img_width - box_width) // 2)
top = max(0, (img_height - box_height) // 2)
right = min(img_width, left + box_width)
bottom = min(img_height, top + box_height)
result = img.crop((left, top, right, bottom))
else:
result = img
# 如果指定了背景色,创建画布并居中粘贴
if background:
canvas = create_canvas_with_background(box_size, background)
# 计算居中位置
offset_x = (box_size[0] - result.width) // 2
offset_y = (box_size[1] - result.height) // 2
canvas.paste(result, (offset_x, offset_y))
return canvas
return result
else:
raise ValueError(f"不支持的 fit 模式: {fit}")

View File

@@ -1,101 +0,0 @@
"""
图片配置验证器
验证图片元素的 fit、background、dpi 等参数。
"""
from typing import List
from validators.result import ValidationIssue
import re
def validate_fit_value(fit: str) -> List[ValidationIssue]:
"""
验证 fit 参数值
Args:
fit: fit 参数值
Returns:
验证问题列表
"""
issues = []
valid_fits = ['stretch', 'contain', 'cover', 'center']
if fit not in valid_fits:
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的 fit 值: {fit} (支持: {', '.join(valid_fits)})",
location="",
code="INVALID_FIT_VALUE"
))
return issues
def validate_background_color(color: str) -> List[ValidationIssue]:
"""
验证背景颜色格式
Args:
color: 颜色字符串
Returns:
验证问题列表
"""
issues = []
# 检查类型
if not isinstance(color, str):
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的背景颜色格式: {color} (应为 #RRGGBB 或 #RGB)",
location="",
code="INVALID_COLOR_FORMAT"
))
return issues
pattern = r'^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$'
if not re.match(pattern, color):
issues.append(ValidationIssue(
level="ERROR",
message=f"无效的背景颜色格式: {color} (应为 #RRGGBB 或 #RGB)",
location="",
code="INVALID_COLOR_FORMAT"
))
return issues
def validate_dpi_value(dpi: int) -> List[ValidationIssue]:
"""
验证 DPI 值
Args:
dpi: DPI 值
Returns:
验证问题列表
"""
issues = []
# 检查类型
if not isinstance(dpi, int):
issues.append(ValidationIssue(
level="ERROR",
message=f"DPI 值必须是整数: {dpi}",
location="",
code="INVALID_DPI_TYPE"
))
return issues
if dpi < 72 or dpi > 300:
issues.append(ValidationIssue(
level="WARNING",
message=f"DPI 值可能不合适: {dpi} (建议范围: 72-300)",
location="",
code="DPI_OUT_OF_RANGE"
))
return issues

View File

@@ -9,7 +9,6 @@ from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml, YAML
from validators.result import ValidationResult, ValidationIssue from validators.result import ValidationResult, ValidationIssue
from validators.geometry import GeometryValidator from validators.geometry import GeometryValidator
from validators.resource import ResourceValidator from validators.resource import ResourceValidator
from validators.image_config import validate_dpi_value
from core.elements import create_element from core.elements import create_element
@@ -71,12 +70,6 @@ class Validator:
size_str = data.get("metadata", {}).get("size", "16:9") size_str = data.get("metadata", {}).get("size", "16:9")
slide_width, slide_height = self.SLIDE_SIZES.get(size_str, (10, 5.625)) slide_width, slide_height = self.SLIDE_SIZES.get(size_str, (10, 5.625))
# 验证 DPI 配置
dpi = data.get("metadata", {}).get("dpi")
if dpi is not None:
dpi_issues = validate_dpi_value(dpi)
self._categorize_issues(dpi_issues, errors, warnings, infos)
# 初始化子验证器 # 初始化子验证器
geometry_validator = GeometryValidator(slide_width, slide_height) geometry_validator = GeometryValidator(slide_width, slide_height)
resource_validator = ResourceValidator( resource_validator = ResourceValidator(

View File

@@ -194,7 +194,7 @@ def handle_convert(args):
# 2. 创建 PPTX 生成器 # 2. 创建 PPTX 生成器
log_info(f"创建演示文稿 ({pres.size})...") log_info(f"创建演示文稿 ({pres.size})...")
generator = PptxGenerator(pres.size, pres.dpi) generator = PptxGenerator(pres.size)
# 3. 渲染所有幻灯片 # 3. 渲染所有幻灯片
slides_data = pres.data.get('slides', []) slides_data = pres.data.get('slides', [])