1
0

feat: 添加图片适配模式支持

- 支持四种图片适配模式:stretch、contain、cover、center
- 支持背景色填充功能(contain 和 center 模式)
- 支持文档级 DPI 配置(metadata.dpi)
- PPTX 渲染器集成 Pillow 实现高质量图片处理
- HTML 渲染器使用 CSS object-fit 实现相同效果
- 添加完整的单元测试、集成测试和端到端测试
- 更新 README 文档和架构文档
- 模块化设计:utils/image_utils.py 图片处理工具模块
- 添加图片配置验证器:validators/image_config.py
- 向后兼容:未指定 fit 时默认使用 stretch 模式
This commit is contained in:
2026-03-04 10:29:21 +08:00
parent 16ca9d77cd
commit 19d6661381
32 changed files with 2310 additions and 57 deletions

View File

@@ -102,6 +102,7 @@ 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:
@@ -117,6 +118,24 @@ 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 # 高质量输出
```
### 使用模板 ### 使用模板
```yaml ```yaml
@@ -160,6 +179,64 @@ 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
slides:
- elements:
# 默认 stretch 模式
- type: image
src: "photo.jpg"
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,6 +67,49 @@ 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入口层
@@ -84,11 +127,23 @@ yaml2pptx.py (入口)
- 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片 - 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片
- 进度日志显示准确的渲染数量(不包括禁用的幻灯片) - 进度日志显示准确的渲染数量(不包括禁用的幻灯片)
### 2. utils.py(工具层) ### 2. utils/(工具层)
- **职责**:通用工具函数 - **职责**:通用工具函数和图片处理
- **包含** - **包含**
- 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()` - `utils/__init__.py` - 日志和颜色工具
- 颜色转换:`hex_to_rgb()`, `validate_color()` - 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- 颜色转换:`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) - 用于高质量图片处理
- 使用 LANCZOS 重采样算法确保最佳质量
- **设计原则**
- 图片处理与渲染逻辑分离
- 支持 DPI 配置,灵活控制图片分辨率
- 所有图片操作返回 PIL Image 对象,便于后续处理
### 3. loaders/yaml_loader.py加载层 ### 3. loaders/yaml_loader.py加载层
- **职责**YAML 文件加载和验证 - **职责**YAML 文件加载和验证
@@ -109,6 +164,8 @@ yaml2pptx.py (入口)
- `_is_valid_color()` - 颜色格式验证工具函数 - `_is_valid_color()` - 颜色格式验证工具函数
- `TextElement` - 文本元素dataclass + validate - `TextElement` - 文本元素dataclass + validate
- `ImageElement` - 图片元素dataclass + validate - `ImageElement` - 图片元素dataclass + validate
- 新增字段:`fit` (适配模式), `background` (背景色)
- 支持四种适配模式stretch默认、contain、cover、center
- `ShapeElement` - 形状元素dataclass + validate - `ShapeElement` - 形状元素dataclass + validate
- `TableElement` - 表格元素dataclass + validate - `TableElement` - 表格元素dataclass + validate
- `create_element()` - 元素工厂函数 - `create_element()` - 元素工厂函数
@@ -130,6 +187,10 @@ yaml2pptx.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,19 +78,45 @@ 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):
"""创建时验证""" """创建时验证"""
if not self.src: if not self.src:
raise ValueError("图片元素必须指定 src") raise ValueError("图片元素必须指定 src")
if not self.box:
raise ValueError("图片元素必须指定 box")
if not isinstance(self.box, list) or len(self.box) != 4: if not isinstance(self.box, list) or len(self.box) != 4:
raise ValueError("box 必须是包含 4 个数字的列表") raise ValueError("box 必须是包含 4 个数字的列表")
def validate(self) -> List: def validate(self) -> List:
"""验证元素自身的完整性""" """验证元素自身的完整性"""
# ImageElement 的必需字段已在 __post_init__ 中检查 from validators.result import ValidationIssue
# 这里返回空列表,资源验证由 ResourceValidator 负责 issues = []
return []
# 验证 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,6 +31,7 @@ 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)
# 验证尺寸值 # 验证尺寸值
if not isinstance(self.size, str): if not isinstance(self.size, str):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,12 +37,25 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须支持图片元素渲染 ### Requirement: 系统必须支持图片元素渲染
系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象。 系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象,支持 `fit``background` 参数控制图片适配模式
#### 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: 图片文件不存在时报错
@@ -51,14 +64,48 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
#### Scenario: 图片格式不支持时报错 #### Scenario: 图片格式不支持时报错
- **WHEN** 图片文件格式不被 python-pptx 支持 - **WHEN** 图片文件格式不被 Pillow 支持
- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式 - **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 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
#### Scenario: 缺少 box 参数时报错
- **WHEN** 图片元素未定义 `box` 参数
- **THEN** 系统抛出 ERROR提示 box 参数为必需
#### Scenario: box 参数格式正确
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]`
- **THEN** 系统验证通过,将 box 用于图片定位和尺寸
### Requirement: 系统必须支持形状元素渲染 ### Requirement: 系统必须支持形状元素渲染
系统 SHALL 将 YAML 中定义的形状元素渲染为 PPTX 形状对象。 系统 SHALL 将 YAML 中定义的形状元素渲染为 PPTX 形状对象。

View File

@@ -133,12 +133,45 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须渲染图片元素 ### Requirement: 系统必须渲染图片元素
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签。 系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,支持 `fit``background` 参数,使用 CSS object-fit 实现适配模式
#### 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: 处理相对路径
@@ -150,6 +183,32 @@ 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 参数必须存在
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
#### Scenario: 缺少 box 参数时报错
- **WHEN** 图片元素未定义 `box` 参数
- **THEN** 系统抛出 ERROR提示 box 参数为必需
#### Scenario: box 参数转换为像素
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]` 且 DPI 为 96
- **THEN** 系统转换为 CSSleft: 96px; top: 192px; width: 384px; height: 288px
### Requirement: 系统必须渲染幻灯片背景 ### Requirement: 系统必须渲染幻灯片背景
系统 SHALL 支持为幻灯片设置纯色背景。 系统 SHALL 支持为幻灯片设置纯色背景。

View File

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

View File

@@ -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() renderer = HtmlRenderer(pres.dpi)
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

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

View File

@@ -8,13 +8,18 @@ from pathlib import Path
from core.elements import TextElement, ImageElement, ShapeElement, TableElement from core.elements import TextElement, ImageElement, ShapeElement, TableElement
# 固定 DPI 用于单位转换
DPI = 96
class HtmlRenderer: class HtmlRenderer:
"""HTML 渲染器,将元素渲染为 HTML""" """HTML 渲染器,将元素渲染为 HTML"""
def __init__(self, dpi=96):
"""
初始化 HTML 渲染器
Args:
dpi: DPI 配置,默认 96
"""
self.dpi = dpi
def render_slide(self, slide_data, index, base_path): def render_slide(self, slide_data, index, base_path):
""" """
渲染单个幻灯片为 HTML 渲染单个幻灯片为 HTML
@@ -68,10 +73,10 @@ class HtmlRenderer:
str: HTML 代码 str: HTML 代码
""" """
style = f""" style = f"""
left: {elem.box[0] * DPI}px; left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * DPI}px; top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * DPI}px; width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * DPI}px; height: {elem.box[3] * self.dpi}px;
font-size: {elem.font.get("size", 16)}pt; font-size: {elem.font.get("size", 16)}pt;
color: {elem.font.get("color", "#000000")}; color: {elem.font.get("color", "#000000")};
text-align: {elem.font.get("align", "left")}; text-align: {elem.font.get("align", "left")};
@@ -105,10 +110,10 @@ class HtmlRenderer:
}.get(elem.shape, "0") }.get(elem.shape, "0")
style = f""" style = f"""
left: {elem.box[0] * DPI}px; left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * DPI}px; top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * DPI}px; width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * DPI}px; height: {elem.box[3] * self.dpi}px;
background: {elem.fill if elem.fill else "transparent"}; background: {elem.fill if elem.fill else "transparent"};
border-radius: {border_radius}; border-radius: {border_radius};
""" """
@@ -131,8 +136,8 @@ class HtmlRenderer:
str: HTML 代码 str: HTML 代码
""" """
table_style = f""" table_style = f"""
left: {elem.position[0] * DPI}px; left: {elem.position[0] * self.dpi}px;
top: {elem.position[1] * DPI}px; top: {elem.position[1] * self.dpi}px;
""" """
rows_html = "" rows_html = ""
@@ -166,11 +171,46 @@ 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] * DPI}px; left: {elem.box[0] * self.dpi}px;
top: {elem.box[1] * DPI}px; top: {elem.box[1] * self.dpi}px;
width: {elem.box[2] * DPI}px; width: {elem.box[2] * self.dpi}px;
height: {elem.box[3] * 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,23 +10,28 @@ 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'): def __init__(self, size='16:9', dpi=96):
""" """
初始化 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':
@@ -148,9 +153,39 @@ 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'
try: try:
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h) # 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: except Exception as e:
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}") raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,304 @@
"""
图片适配模式集成测试
测试图片 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,6 +89,19 @@ 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

@@ -0,0 +1,120 @@
"""
单元测试:图片处理工具函数
测试 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

@@ -0,0 +1,136 @@
"""
单元测试:图片配置验证器
测试 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

@@ -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)) return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except ValueError: except ValueError:
raise ValueError(f"无效的颜色格式: #{hex_color}") 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

149
utils/image_utils.py Normal file
View File

@@ -0,0 +1,149 @@
"""
图片处理工具模块
提供图片适配模式处理、像素与英寸转换等功能。
"""
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}")

8
uv.lock generated
View File

@@ -1655,6 +1655,9 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "flask", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { 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 = "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 = "python-pptx" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "simpleeval", version = "0.9.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { 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] [package.optional-dependencies]
dev = [ 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.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 = "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'" }, { 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] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "flask" }, { name = "flask" },
{ name = "pillow", marker = "extra == 'dev'" }, { name = "pillow" },
{ name = "pytest", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" },
{ name = "pytest-mock", marker = "extra == 'dev'" }, { name = "pytest-mock", marker = "extra == 'dev'" },

101
validators/image_config.py Normal file
View File

@@ -0,0 +1,101 @@
"""
图片配置验证器
验证图片元素的 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,6 +9,7 @@ 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
@@ -70,6 +71,12 @@ 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) generator = PptxGenerator(pres.size, pres.dpi)
# 3. 渲染所有幻灯片 # 3. 渲染所有幻灯片
slides_data = pres.data.get('slides', []) slides_data = pres.data.get('slides', [])