diff --git a/README.md b/README.md
index 051d7d5..af5189c 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ uv run yaml2pptx.py convert presentation.yaml --skip-validation
```yaml
metadata:
size: "16:9" # 或 "4:3"
+ dpi: 96 # 可选:DPI 配置,默认 96
slides:
- background:
@@ -117,6 +118,24 @@ slides:
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
@@ -160,6 +179,64 @@ slides:
- type: image
box: [x, y, width, height]
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" # 白色背景
```
### 形状元素
diff --git a/README_DEV.md b/README_DEV.md
index 09b93fa..0198408 100644
--- a/README_DEV.md
+++ b/README_DEV.md
@@ -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(入口层)
@@ -84,11 +127,23 @@ yaml2pptx.py (入口)
- 维护独立的 `slide_index` 计数器,只统计实际渲染的幻灯片
- 进度日志显示准确的渲染数量(不包括禁用的幻灯片)
-### 2. utils.py(工具层)
-- **职责**:通用工具函数
+### 2. utils/(工具层)
+- **职责**:通用工具函数和图片处理
- **包含**:
- - 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
- - 颜色转换:`hex_to_rgb()`, `validate_color()`
+ - `utils/__init__.py` - 日志和颜色工具
+ - 日志函数:`log_info()`, `log_success()`, `log_error()`, `log_progress()`
+ - 颜色转换:`hex_to_rgb()`, `validate_color()`
+ - `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(加载层)
- **职责**:YAML 文件加载和验证
@@ -109,6 +164,8 @@ yaml2pptx.py (入口)
- `_is_valid_color()` - 颜色格式验证工具函数
- `TextElement` - 文本元素(dataclass + validate)
- `ImageElement` - 图片元素(dataclass + validate)
+ - 新增字段:`fit` (适配模式), `background` (背景色)
+ - 支持四种适配模式:stretch(默认)、contain、cover、center
- `ShapeElement` - 形状元素(dataclass + validate)
- `TableElement` - 表格元素(dataclass + validate)
- `create_element()` - 元素工厂函数
@@ -130,6 +187,10 @@ yaml2pptx.py (入口)
- `validators/resource.py` - 资源验证器
- `ResourceValidator` - 检查图片、模板文件存在性
- 验证模板文件结构
+ - `validators/image_config.py` - 图片配置验证器
+ - `validate_fit_value()` - 验证 fit 参数值
+ - `validate_background_color()` - 验证背景色格式
+ - `validate_dpi_value()` - 验证 DPI 值范围
- `validators/validator.py` - 主验证器
- `Validator` - 协调所有子验证器
- 集成元素级验证、几何验证、资源验证
diff --git a/core/elements.py b/core/elements.py
index 50307ed..1c2f52d 100644
--- a/core/elements.py
+++ b/core/elements.py
@@ -78,19 +78,45 @@ class ImageElement:
type: str = 'image'
src: str = ''
box: list = field(default_factory=lambda: [1, 1, 4, 3])
+ fit: Optional[str] = None
+ background: Optional[str] = None
def __post_init__(self):
"""创建时验证"""
if not self.src:
raise ValueError("图片元素必须指定 src")
+ if not self.box:
+ raise ValueError("图片元素必须指定 box")
if not isinstance(self.box, list) or len(self.box) != 4:
raise ValueError("box 必须是包含 4 个数字的列表")
def validate(self) -> List:
"""验证元素自身的完整性"""
- # ImageElement 的必需字段已在 __post_init__ 中检查
- # 这里返回空列表,资源验证由 ResourceValidator 负责
- return []
+ from validators.result import ValidationIssue
+ 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
diff --git a/core/presentation.py b/core/presentation.py
index 973fec1..68cab56 100644
--- a/core/presentation.py
+++ b/core/presentation.py
@@ -31,6 +31,7 @@ class Presentation:
# 获取演示文稿尺寸
metadata = self.data.get("metadata", {})
self.size = metadata.get("size", "16:9")
+ self.dpi = metadata.get("dpi", 96)
# 验证尺寸值
if not isinstance(self.size, str):
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/.openspec.yaml b/openspec/changes/archive/2026-03-04-add-image-fit-modes/.openspec.yaml
new file mode 100644
index 0000000..5aae5cf
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-04
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/design.md b/openspec/changes/archive/2026-03-04-add-image-fit-modes/design.md
new file mode 100644
index 0000000..03c6683
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/design.md
@@ -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),不向用户暴露配置选项。
+
+**理由:** 演示文稿场景下图片质量优先于处理速度,使用最高质量算法避免用户困惑。
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/proposal.md b/openspec/changes/archive/2026-03-04-add-image-fit-modes/proposal.md
new file mode 100644
index 0000000..62f8b5f
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/proposal.md
@@ -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`: 新增图片处理架构说明
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/element-rendering/spec.md b/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/element-rendering/spec.md
new file mode 100644
index 0000000..fc1f3bc
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/element-rendering/spec.md
@@ -0,0 +1,76 @@
+# Element Rendering - Delta Spec
+
+本 spec 是对 `openspec/specs/element-rendering/spec.md` 的增量修改。
+
+## MODIFIED Requirements
+
+### Requirement: 系统必须支持图片元素渲染
+
+系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象,支持 `fit` 和 `background` 参数控制图片适配模式。
+
+#### Scenario: 渲染本地图片
+
+- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
+- **THEN** 系统从指定路径加载图片,在 (2, 3) 位置渲染为 4×3 英寸大小
+- **AND** 使用默认的 `stretch` 模式(向后兼容)
+
+#### Scenario: 使用 fit 模式渲染图片
+
+- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
+- **THEN** 系统使用 `contain` 模式渲染图片
+- **AND** 保持图片宽高比,完整显示在 box 内
+
+#### Scenario: 使用背景色渲染图片
+
+- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
+- **THEN** 系统使用 `contain` 模式渲染图片
+- **AND** 用 #f0f0f0 颜色填充留白区域
+
+#### Scenario: 图片文件不存在时报错
+
+- **WHEN** 图片 src 指向不存在的文件路径
+- **THEN** 系统抛出错误,明确指出图片文件未找到
+
+#### Scenario: 图片格式不支持时报错
+
+- **WHEN** 图片文件格式不被 Pillow 支持
+- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式
+
+#### Scenario: 图片处理失败时报错
+
+- **WHEN** Pillow 处理图片时发生异常(如文件损坏)
+- **THEN** 系统抛出 ERROR 级别错误,不降级到其他模式
+
+#### Scenario: 相对路径处理
+
+- **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"`
+- **THEN** 系统基于演示文稿文件所在目录解析相对路径
+
+#### Scenario: 使用 DPI 配置渲染图片
+
+- **WHEN** metadata 定义了 `dpi: 120` 且图片需要处理
+- **THEN** 系统使用该 DPI 值进行像素与英寸的转换
+
+#### Scenario: fit 参数值无效时报错
+
+- **WHEN** `fit` 参数值不是 stretch、contain、cover、center 之一
+- **THEN** 系统抛出 ERROR,并列出有效值
+
+#### Scenario: background 参数颜色格式无效时报错
+
+- **WHEN** `background` 值不是有效的颜色格式
+- **THEN** 系统抛出 ERROR,提示颜色格式应为 #RRGGBB 或 #RGB
+
+### Requirement: 图片元素的 box 参数必须存在
+
+系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
+
+#### Scenario: 缺少 box 参数时报错
+
+- **WHEN** 图片元素未定义 `box` 参数
+- **THEN** 系统抛出 ERROR,提示 box 参数为必需
+
+#### Scenario: box 参数格式正确
+
+- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]`
+- **THEN** 系统验证通过,将 box 用于图片定位和尺寸
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/html-rendering/spec.md b/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/html-rendering/spec.md
new file mode 100644
index 0000000..c2329c0
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/html-rendering/spec.md
@@ -0,0 +1,83 @@
+# HTML Rendering - Delta Spec
+
+本 spec 是对 `openspec/specs/html-rendering/spec.md` 的增量修改。
+
+## MODIFIED Requirements
+
+### Requirement: 系统必须渲染图片元素
+
+系统 SHALL 将 YAML 中的图片元素转换为 HTML `` 标签,支持 `fit` 和 `background` 参数,使用 CSS object-fit 实现适配模式。
+
+#### Scenario: 渲染本地图片
+
+- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
+- **THEN** 系统生成 `
` 标签,src 为图片的文件路径,位置为 (192px, 288px),尺寸为 384x288 像素
+- **AND** 使用默认的 CSS 样式 `object-fit: fill`(等同于 stretch 模式)
+
+#### Scenario: 使用 fit 模式渲染图片
+
+- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
+- **THEN** 系统应用 CSS `object-fit: contain`
+- **AND** 保持图片宽高比,完整显示在 box 内
+
+#### Scenario: fit 模式与 CSS object-fit 映射
+
+- **WHEN** `fit` 参数为 `stretch`
+- **THEN** 系统应用 CSS `object-fit: fill`
+
+- **WHEN** `fit` 参数为 `contain`
+- **THEN** 系统应用 CSS `object-fit: contain`
+
+- **WHEN** `fit` 参数为 `cover`
+- **THEN** 系统应用 CSS `object-fit: cover`
+
+- **WHEN** `fit` 参数为 `center`
+- **THEN** 系统应用 CSS `object-fit: none` 和 `object-position: center`
+
+#### Scenario: 使用背景色渲染图片
+
+- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
+- **THEN** 系统为图片容器添加 CSS `background-color: #f0f0f0`
+- **AND** 应用 CSS `object-fit: contain`
+
+#### Scenario: 图片容器支持背景色
+
+- **WHEN** 图片指定了 `background` 参数
+- **THEN** 系统创建包装容器,应用背景色到容器
+- **AND** 图片在容器内使用 object-fit 定位
+
+#### Scenario: 处理相对路径
+
+- **WHEN** 图片 src 使用相对路径 `"assets/logo.png"`
+- **THEN** 系统基于 YAML 文件所在目录解析相对路径
+
+#### Scenario: 图片不存在时显示占位符
+
+- **WHEN** 图片文件不存在
+- **THEN** 系统显示占位符或错误提示,而不是崩溃
+
+#### Scenario: 使用 DPI 配置渲染图片
+
+- **WHEN** metadata 定义了 `dpi: 120`
+- **THEN** 系统使用该 DPI 值进行英寸到像素的转换
+- **AND** box: [1, 1, 4, 3] 转换为 CSS: left: 120px; top: 120px; width: 480px; height: 360px
+
+#### Scenario: HTML 渲染与 PPTX 渲染效果一致
+
+- **WHEN** 同一图片元素使用相同的 fit 和 background 参数
+- **THEN** HTML 预览和 PPTX 输出的视觉效果应保持一致
+- **AND** 图片位置、尺寸、适配方式相同
+
+### Requirement: 图片元素的 box 参数必须存在
+
+系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
+
+#### Scenario: 缺少 box 参数时报错
+
+- **WHEN** 图片元素未定义 `box` 参数
+- **THEN** 系统抛出 ERROR,提示 box 参数为必需
+
+#### Scenario: box 参数转换为像素
+
+- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]` 且 DPI 为 96
+- **THEN** 系统转换为 CSS:left: 96px; top: 192px; width: 384px; height: 288px
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/image-fit-modes/spec.md b/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/image-fit-modes/spec.md
new file mode 100644
index 0000000..31b6e68
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/image-fit-modes/spec.md
@@ -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** 裁剪操作不损失图片质量
diff --git a/openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md b/openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md
new file mode 100644
index 0000000..9ebff48
--- /dev/null
+++ b/openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md
@@ -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 模式
diff --git a/openspec/specs/element-rendering/spec.md b/openspec/specs/element-rendering/spec.md
index b896d8d..09d4216 100644
--- a/openspec/specs/element-rendering/spec.md
+++ b/openspec/specs/element-rendering/spec.md
@@ -37,12 +37,25 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须支持图片元素渲染
-系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象。
+系统 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: 图片文件不存在时报错
@@ -51,14 +64,48 @@ Element rendering系统负责将 YAML 中定义的各类元素(文本、图片
#### Scenario: 图片格式不支持时报错
-- **WHEN** 图片文件格式不被 python-pptx 支持
+- **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 用于图片定位和尺寸
+
### Requirement: 系统必须支持形状元素渲染
系统 SHALL 将 YAML 中定义的形状元素渲染为 PPTX 形状对象。
diff --git a/openspec/specs/html-rendering/spec.md b/openspec/specs/html-rendering/spec.md
index d661ddb..a9d0f4b 100644
--- a/openspec/specs/html-rendering/spec.md
+++ b/openspec/specs/html-rendering/spec.md
@@ -133,12 +133,45 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
### Requirement: 系统必须渲染图片元素
-系统 SHALL 将 YAML 中的图片元素转换为 HTML `
` 标签。
+系统 SHALL 将 YAML 中的图片元素转换为 HTML `
` 标签,支持 `fit` 和 `background` 参数,使用 CSS object-fit 实现适配模式。
#### Scenario: 渲染本地图片
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
- **THEN** 系统生成 `
` 标签,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: 处理相对路径
@@ -150,6 +183,32 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
- **WHEN** 图片文件不存在
- **THEN** 系统显示占位符或错误提示,而不是崩溃
+#### Scenario: 使用 DPI 配置渲染图片
+
+- **WHEN** metadata 定义了 `dpi: 120`
+- **THEN** 系统使用该 DPI 值进行英寸到像素的转换
+- **AND** box: [1, 1, 4, 3] 转换为 CSS: left: 120px; top: 120px; width: 480px; height: 360px
+
+#### Scenario: HTML 渲染与 PPTX 渲染效果一致
+
+- **WHEN** 同一图片元素使用相同的 fit 和 background 参数
+- **THEN** HTML 预览和 PPTX 输出的视觉效果应保持一致
+- **AND** 图片位置、尺寸、适配方式相同
+
+### Requirement: 图片元素的 box 参数必须存在
+
+系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
+
+#### Scenario: 缺少 box 参数时报错
+
+- **WHEN** 图片元素未定义 `box` 参数
+- **THEN** 系统抛出 ERROR,提示 box 参数为必需
+
+#### Scenario: box 参数转换为像素
+
+- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]` 且 DPI 为 96
+- **THEN** 系统转换为 CSS:left: 96px; top: 192px; width: 384px; height: 288px
+
### Requirement: 系统必须渲染幻灯片背景
系统 SHALL 支持为幻灯片设置纯色背景。
diff --git a/openspec/specs/image-fit-modes/spec.md b/openspec/specs/image-fit-modes/spec.md
new file mode 100644
index 0000000..31b6e68
--- /dev/null
+++ b/openspec/specs/image-fit-modes/spec.md
@@ -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** 裁剪操作不损失图片质量
diff --git a/preview/server.py b/preview/server.py
index c90477d..8532ede 100644
--- a/preview/server.py
+++ b/preview/server.py
@@ -148,7 +148,7 @@ def generate_preview_html(yaml_file, template_dir):
"""生成完整的预览 HTML 页面"""
try:
pres = Presentation(yaml_file, template_dir)
- renderer = HtmlRenderer()
+ renderer = HtmlRenderer(pres.dpi)
slides_html = ""
for i, slide_data in enumerate(pres.data.get('slides', [])):
diff --git a/pyproject.toml b/pyproject.toml
index 14bb14b..f7e0ac5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,6 +8,7 @@ dependencies = [
"flask",
"watchdog",
"simpleeval",
+ "pillow",
]
[project.optional-dependencies]
@@ -15,7 +16,6 @@ dev = [
"pytest",
"pytest-cov",
"pytest-mock",
- "pillow",
]
[tool.setuptools]
diff --git a/renderers/html_renderer.py b/renderers/html_renderer.py
index 4f96712..76cdf64 100644
--- a/renderers/html_renderer.py
+++ b/renderers/html_renderer.py
@@ -8,13 +8,18 @@ from pathlib import Path
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
-# 固定 DPI 用于单位转换
-DPI = 96
-
-
class HtmlRenderer:
"""HTML 渲染器,将元素渲染为 HTML"""
+ def __init__(self, dpi=96):
+ """
+ 初始化 HTML 渲染器
+
+ Args:
+ dpi: DPI 配置,默认 96
+ """
+ self.dpi = dpi
+
def render_slide(self, slide_data, index, base_path):
"""
渲染单个幻灯片为 HTML
@@ -68,10 +73,10 @@ class HtmlRenderer:
str: HTML 代码
"""
style = f"""
- left: {elem.box[0] * DPI}px;
- top: {elem.box[1] * DPI}px;
- width: {elem.box[2] * DPI}px;
- height: {elem.box[3] * DPI}px;
+ left: {elem.box[0] * self.dpi}px;
+ top: {elem.box[1] * self.dpi}px;
+ width: {elem.box[2] * self.dpi}px;
+ height: {elem.box[3] * self.dpi}px;
font-size: {elem.font.get("size", 16)}pt;
color: {elem.font.get("color", "#000000")};
text-align: {elem.font.get("align", "left")};
@@ -105,10 +110,10 @@ class HtmlRenderer:
}.get(elem.shape, "0")
style = f"""
- left: {elem.box[0] * DPI}px;
- top: {elem.box[1] * DPI}px;
- width: {elem.box[2] * DPI}px;
- height: {elem.box[3] * DPI}px;
+ left: {elem.box[0] * self.dpi}px;
+ top: {elem.box[1] * self.dpi}px;
+ width: {elem.box[2] * self.dpi}px;
+ height: {elem.box[3] * self.dpi}px;
background: {elem.fill if elem.fill else "transparent"};
border-radius: {border_radius};
"""
@@ -131,8 +136,8 @@ class HtmlRenderer:
str: HTML 代码
"""
table_style = f"""
- left: {elem.position[0] * DPI}px;
- top: {elem.position[1] * DPI}px;
+ left: {elem.position[0] * self.dpi}px;
+ top: {elem.position[1] * self.dpi}px;
"""
rows_html = ""
@@ -166,11 +171,46 @@ class HtmlRenderer:
"""
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"""
- left: {elem.box[0] * DPI}px;
- top: {elem.box[1] * DPI}px;
- width: {elem.box[2] * DPI}px;
- height: {elem.box[3] * DPI}px;
+ left: {elem.box[0] * self.dpi}px;
+ top: {elem.box[1] * self.dpi}px;
+ width: {elem.box[2] * self.dpi}px;
+ height: {elem.box[3] * self.dpi}px;
+ object-fit: {object_fit};
+ object-position: center;
"""
- return f'
'
+ # 如果有背景色,需要创建包装容器
+ 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'''
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
diff --git a/utils.py b/utils/__init__.py
similarity index 74%
rename from utils.py
rename to utils/__init__.py
index 12d5227..dbb6067 100644
--- a/utils.py
+++ b/utils/__init__.py
@@ -54,21 +54,3 @@ def hex_to_rgb(hex_color):
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except ValueError:
raise ValueError(f"无效的颜色格式: #{hex_color}")
-
-
-def validate_color(color_value):
- """
- 验证颜色值格式(十六进制 #RRGGBB 或 #RGB)
-
- Args:
- color_value: 颜色字符串
-
- Returns:
- bool: 是否有效
- """
- import re
- if not isinstance(color_value, str):
- return False
- # 匹配 #RRGGBB 或 #RGB 格式
- pattern = r'^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$'
- return re.match(pattern, color_value) is not None
diff --git a/utils/image_utils.py b/utils/image_utils.py
new file mode 100644
index 0000000..18b7259
--- /dev/null
+++ b/utils/image_utils.py
@@ -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}")
diff --git a/uv.lock b/uv.lock
index 41d3947..c919908 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1655,6 +1655,9 @@ source = { virtual = "." }
dependencies = [
{ name = "flask", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "flask", version = "3.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
+ { name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
+ { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
+ { name = "pillow", version = "12.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "python-pptx" },
{ name = "pyyaml" },
{ name = "simpleeval", version = "0.9.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -1665,9 +1668,6 @@ dependencies = [
[package.optional-dependencies]
dev = [
- { name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
- { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
- { name = "pillow", version = "12.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
@@ -1680,7 +1680,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "flask" },
- { name = "pillow", marker = "extra == 'dev'" },
+ { name = "pillow" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-cov", marker = "extra == 'dev'" },
{ name = "pytest-mock", marker = "extra == 'dev'" },
diff --git a/validators/image_config.py b/validators/image_config.py
new file mode 100644
index 0000000..2bc8293
--- /dev/null
+++ b/validators/image_config.py
@@ -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
diff --git a/validators/validator.py b/validators/validator.py
index 1a2a09e..67183f4 100644
--- a/validators/validator.py
+++ b/validators/validator.py
@@ -9,6 +9,7 @@ from loaders.yaml_loader import load_yaml_file, validate_presentation_yaml, YAML
from validators.result import ValidationResult, ValidationIssue
from validators.geometry import GeometryValidator
from validators.resource import ResourceValidator
+from validators.image_config import validate_dpi_value
from core.elements import create_element
@@ -70,6 +71,12 @@ class Validator:
size_str = data.get("metadata", {}).get("size", "16:9")
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)
resource_validator = ResourceValidator(
diff --git a/yaml2pptx.py b/yaml2pptx.py
index 28784bd..cbdc89a 100644
--- a/yaml2pptx.py
+++ b/yaml2pptx.py
@@ -194,7 +194,7 @@ def handle_convert(args):
# 2. 创建 PPTX 生成器
log_info(f"创建演示文稿 ({pres.size})...")
- generator = PptxGenerator(pres.size)
+ generator = PptxGenerator(pres.size, pres.dpi)
# 3. 渲染所有幻灯片
slides_data = pres.data.get('slides', [])