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:
77
README.md
77
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" # 白色背景
|
||||
```
|
||||
|
||||
### 形状元素
|
||||
|
||||
@@ -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` - 协调所有子验证器
|
||||
- 集成元素级验证、几何验证、资源验证
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -0,0 +1,260 @@
|
||||
# 图片适配模式技术设计
|
||||
|
||||
## Context
|
||||
|
||||
### 当前状态
|
||||
|
||||
当前系统图片渲染逻辑非常简单:直接使用 python-pptx 的 `add_picture()` 方法,传入 box 的宽高参数。python-pptx 会自动将图片拉伸到指定尺寸,无法保持宽高比,也无法进行居中或裁剪处理。
|
||||
|
||||
```python
|
||||
# 当前实现 (pptx_renderer.py)
|
||||
def _render_image(self, slide, elem: ImageElement, base_path):
|
||||
x, y, w, h = [Inches(v) for v in elem.box]
|
||||
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
|
||||
```
|
||||
|
||||
### 约束条件
|
||||
|
||||
- 必须保持向后兼容:未指定 `fit` 参数时,行为与当前一致
|
||||
- PPTX 渲染使用英寸单位,图片处理需要像素单位
|
||||
- HTML 预览需要与 PPTX 渲染保持一致的视觉效果
|
||||
- DPI 配置需要同时影响两个渲染器
|
||||
|
||||
### 利益相关者
|
||||
|
||||
- 最终用户:需要多样化的图片适配选项
|
||||
- HTML 预览用户:期望预览效果与最终 PPTX 一致
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 支持四种图片适配模式(stretch、contain、cover、center)
|
||||
- 支持留白区域的背景色填充
|
||||
- 支持可配置的 DPI 转换
|
||||
- HTML 预览与 PPTX 渲染效果一致
|
||||
- 向后兼容现有 YAML 文件
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不支持图片的旋转、翻转等变换
|
||||
- 不支持图片滤镜、水印等高级效果
|
||||
- 不支持 fill(平铺)模式
|
||||
- 不支持自适应 box 尺寸(box 必需,不是可选)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1: 使用 Pillow 作为图片处理库
|
||||
|
||||
**选择理由:**
|
||||
|
||||
- Pillow 是 Python 生态中最成熟的图片处理库,从 PIL 发展而来
|
||||
- 内置 `ImageOps` 模块,直接提供 `contain()` 和 `cover()` 方法,与我们的需求完全匹配
|
||||
- API 简洁直观,社区活跃,文档完善
|
||||
- 轻量级,专注图片处理,不像 OpenCV 那样重量级
|
||||
|
||||
**替代方案考虑:**
|
||||
|
||||
| 方案 | 优势 | 劣势 | 结论 |
|
||||
|------|------|------|------|
|
||||
| Pillow | API 简洁,ImageOps 直接匹配需求 | 需要额外依赖 | ✅ 选择 |
|
||||
| OpenCV | 功能强大,性能优异 | API 复杂,BGR 颜色顺序需转换,过度设计 | ❌ |
|
||||
| Wand | 功能丰富 | 依赖 ImageMagick,安装复杂,跨平台问题多 | ❌ |
|
||||
| 原生实现 | 无额外依赖 | 需要手动实现所有算法,容易出错 | ❌ |
|
||||
|
||||
### 决策 2: DPI 作为文档级配置
|
||||
|
||||
**选择:** 在 `metadata.dpi` 中配置,默认值 96
|
||||
|
||||
**理由:**
|
||||
|
||||
- DPI 影响整个文档的所有图片,不应在元素级别配置
|
||||
- 96 是 Web 标准 DPI,与当前 HTML 渲染器一致
|
||||
- 简洁的配置方式,符合 YAML 声明式风格
|
||||
|
||||
**替代方案:** `metadata.image.dpi`(更结构化,但过于复杂)
|
||||
|
||||
### 决策 3: PPTX 和 HTML 使用不同的实现方式
|
||||
|
||||
**PPTX 实现:**
|
||||
|
||||
- 使用 Pillow 处理图片(缩放、裁剪)
|
||||
- 计算居中位置
|
||||
- 添加背景色画布(如有需要)
|
||||
- 使用 python-pptx 添加处理后的图片
|
||||
|
||||
**HTML 实现:**
|
||||
|
||||
- 使用 CSS `object-fit` 属性
|
||||
- `stretch` → `object-fit: fill`
|
||||
- `contain` → `object-fit: contain`
|
||||
- `cover` → `object-fit: cover`
|
||||
- `center` → `object-fit: none` + `object-position: center`
|
||||
- 背景色使用 CSS `background-color`
|
||||
|
||||
**理由:** 两个平台的原生能力不同,使用各自的最佳实践,但确保视觉效果一致
|
||||
|
||||
### 决策 4: 图片适配模式算法设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 图片适配算法流程 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
输入: img (PIL.Image), box_size (width, height), fit, background
|
||||
|
||||
1. stretch 模式
|
||||
─────────────────────────────────────────────────────────────────
|
||||
直接使用 img.resize(box_size),不考虑宽高比
|
||||
|
||||
2. contain 模式
|
||||
─────────────────────────────────────────────────────────────────
|
||||
result = ImageOps.contain(img, box_size)
|
||||
# result.size <= box_size,保持宽高比
|
||||
if background:
|
||||
canvas = 创建 box_size 画布,填充背景色
|
||||
canvas.paste(result, 居中位置)
|
||||
result = canvas
|
||||
|
||||
3. cover 模式
|
||||
─────────────────────────────────────────────────────────────────
|
||||
result = ImageOps.cover(img, box_size)
|
||||
# result.size == box_size,保持宽高比,裁剪超出部分
|
||||
|
||||
4. center 模式
|
||||
─────────────────────────────────────────────────────────────────
|
||||
result = img # 不缩放
|
||||
if img.width > box_size.width or img.height > box_size.height:
|
||||
result = 裁剪到 box_size(居中裁剪)
|
||||
if background:
|
||||
canvas = 创建 box_size 画布,填充背景色
|
||||
canvas.paste(result, 居中位置)
|
||||
result = canvas
|
||||
|
||||
输出: result (PIL.Image), display_size, display_position
|
||||
```
|
||||
|
||||
### 决策 6: 模块化设计
|
||||
|
||||
```
|
||||
utils/image_utils.py
|
||||
├── inches_to_pixels(inches, dpi) -> float
|
||||
├── pixels_to_inches(pixels, dpi) -> float
|
||||
├── calculate_contain_size(img_size, box_size) -> tuple
|
||||
├── calculate_cover_size(img_size, box_size) -> tuple
|
||||
├── calculate_center_offset(img_size, box_size) -> tuple
|
||||
└── apply_fit_mode(img, box_size, fit, background) -> PIL.Image
|
||||
|
||||
validators/image_config.py (新建)
|
||||
├── validate_fit_value(fit) -> List[ValidationIssue]
|
||||
└── validate_fit_box_dependency(elem) -> List[ValidationIssue]
|
||||
```
|
||||
|
||||
**理由:** 单一职责原则,图片处理逻辑与渲染逻辑分离,便于测试和复用
|
||||
|
||||
### 决策 5: 模块化设计
|
||||
|
||||
```
|
||||
utils/image_utils.py
|
||||
├── inches_to_pixels(inches, dpi) -> float
|
||||
├── pixels_to_inches(pixels, dpi) -> float
|
||||
├── calculate_contain_size(img_size, box_size) -> tuple
|
||||
├── calculate_cover_size(img_size, box_size) -> tuple
|
||||
├── calculate_center_offset(img_size, box_size) -> tuple
|
||||
└── apply_fit_mode(img, box_size, fit, background) -> PIL.Image
|
||||
|
||||
validators/image_config.py (新建)
|
||||
├── validate_fit_value(fit) -> List[ValidationIssue]
|
||||
└── validate_background_color(color) -> List[ValidationIssue]
|
||||
```
|
||||
|
||||
**理由:** 单一职责原则,图片处理逻辑与渲染逻辑分离,便于测试和复用
|
||||
|
||||
**注意:** 由于 box 参数为必填,不存在"没有指定 box"的情况,因此 `validate_fit_box_dependency` 验证器已移除。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险 1: Pillow 依赖增加
|
||||
|
||||
**风险:** 新增外部依赖可能增加安装复杂度
|
||||
|
||||
**缓解措施:**
|
||||
- Pillow 是 Python 生态标准库,安装简单(`pip install pillow`)
|
||||
- 在 pyproject.toml 中不指定版本号,使用最新稳定版
|
||||
- 在 README 中明确说明依赖变更
|
||||
|
||||
### 风险 2: PPTX 和 HTML 渲染效果不完全一致
|
||||
|
||||
**风险:** 两个平台实现方式不同,可能在边缘情况下效果有差异
|
||||
|
||||
**缓解措施:**
|
||||
- 核心算法(尺寸计算)使用相同的 Python 函数
|
||||
- 编写集成测试,对比两种渲染器的输出
|
||||
- 在文档中说明已知差异(如抗锯齿算法不同)
|
||||
|
||||
### 风险 3: 大图片处理性能问题
|
||||
|
||||
**风险:** Pillow 处理大图片可能较慢,占用内存
|
||||
|
||||
**缓解措施:**
|
||||
- 仅在需要时才处理图片(contain/cover/center 模式)
|
||||
- stretch 模式直接使用 python-pptx 的原生处理,不经过 Pillow
|
||||
- 文档中建议用户使用适当尺寸的图片
|
||||
|
||||
### 风险 4: DPI 配置不当导致尺寸错误
|
||||
|
||||
**风险:** 用户设置的 DPI 与实际使用场景不符,导致图片尺寸不符合预期
|
||||
|
||||
**缓解措施:**
|
||||
- 默认值 96 适用于大多数场景
|
||||
- 在 README 中说明 DPI 的含义和影响
|
||||
- 验证器检查 DPI 值是否合理(如 72-300 之间)
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 部署步骤
|
||||
|
||||
1. **代码变更**
|
||||
- 添加 Pillow 依赖到 pyproject.toml
|
||||
- 创建 `utils/image_utils.py`
|
||||
- 创建 `validators/image_config.py`
|
||||
- 更新 `core/elements.py` 的 ImageElement
|
||||
- 更新 `renderers/pptx_renderer.py`
|
||||
- 更新 `renderers/html_renderer.py`
|
||||
|
||||
2. **测试**
|
||||
- 单元测试:image_utils 的各个函数
|
||||
- 单元测试:validators 的图片配置验证
|
||||
- 集成测试:四种 fit 模式的 PPTX 渲染
|
||||
- 集成测试:四种 fit 模式的 HTML 渲染
|
||||
- 端到端测试:完整 YAML 转换流程
|
||||
|
||||
3. **文档更新**
|
||||
- 更新 README.md,添加图片适配模式说明
|
||||
- 更新 README_DEV.md,添加架构说明
|
||||
|
||||
### 回滚策略
|
||||
|
||||
- 如果发现严重问题,可以回退到之前版本
|
||||
- 向后兼容设计确保未指定 fit 参数的 YAML 文件仍能正常工作
|
||||
- 回滚后用户只需删除 fit 和 background 参数即可
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Q1: 是否需要在图片处理失败时提供降级方案?
|
||||
|
||||
**决策:** 图片处理失败时抛出 ERROR 级别错误,让用户修复图片问题,不提供降级方案。
|
||||
|
||||
**理由:** 保持简单明确,图片问题应该由用户在源头解决,而不是掩盖问题。
|
||||
|
||||
### Q2: background 参数是否支持渐变色?
|
||||
|
||||
**决策:** background 参数仅支持纯色,不支持渐变色。
|
||||
|
||||
**理由:** 简化实现,满足绝大多数使用场景。如有后续需求,可以作为独立功能添加。
|
||||
|
||||
### Q3: 是否需要支持图片质量设置?
|
||||
|
||||
**决策:** 使用 Pillow 的最高质量重采样算法(LANCZOS),不向用户暴露配置选项。
|
||||
|
||||
**理由:** 演示文稿场景下图片质量优先于处理速度,使用最高质量算法避免用户困惑。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 图片适配模式支持
|
||||
|
||||
## Why
|
||||
|
||||
当前图片元素渲染仅支持简单的拉伸模式,图片会被强制缩放到 box 指定的尺寸,导致图片变形或宽高比失真。实际使用中,用户需要保持图片宽高比、居中显示、填充裁剪等多种适配模式,以满足不同场景的视觉需求。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增图片 `fit` 参数,支持四种适配模式:`stretch`(拉伸)、`contain`(包含)、`cover`(覆盖)、`center`(居中)
|
||||
- 新增图片 `background` 参数,支持指定留白区域的填充颜色(默认透明)
|
||||
- 新增文档级 `dpi` 配置(`metadata.dpi`),用于像素与英寸的转换,默认值为 96
|
||||
- 引入 Pillow 库进行图片处理,利用其 ImageOps 模块实现各种适配模式
|
||||
- 保持向后兼容:未指定 `fit` 时默认使用 `stretch` 模式(当前行为)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `image-fit-modes`: 图片元素适配模式处理,支持 stretch、contain、cover、center 四种模式,以及背景色填充和 DPI 配置
|
||||
|
||||
### Modified Capabilities
|
||||
- `element-rendering`: 扩展图片元素渲染能力,新增 `fit` 和 `background` 参数支持
|
||||
- `html-rendering`: 同步扩展 HTML 预览的图片渲染能力,支持 `fit` 和 `background` 参数,使用 CSS object-fit 实现
|
||||
|
||||
## Impact
|
||||
|
||||
### 依赖变更
|
||||
- 新增 Pillow 依赖(不指定版本号,使用最新版)
|
||||
|
||||
### 代码变更
|
||||
- `core/elements.py`: ImageElement 新增 `fit` 和 `background` 字段
|
||||
- `renderers/pptx_renderer.py`: 重写 `_render_image()` 方法,集成 Pillow 图片处理
|
||||
- `renderers/html_renderer.py`: 更新 `_render_image()` 方法,使用 CSS object-fit 实现适配模式
|
||||
- `validators/`: 新增图片参数验证器(fit 值校验、background 颜色校验)
|
||||
- `utils/image_utils.py`: 新增图片处理工具模块(像素转换、尺寸计算、居中定位)
|
||||
|
||||
### API 变更
|
||||
- YAML 语法扩展:
|
||||
- `metadata` 层级新增可选的 `dpi` 字段
|
||||
- 图片元素新增可选的 `fit` 字段
|
||||
- 图片元素新增可选的 `background` 字段
|
||||
|
||||
### 文档变更
|
||||
- `README.md`: 新增图片适配模式使用说明和示例
|
||||
- `README_DEV.md`: 新增图片处理架构说明
|
||||
@@ -0,0 +1,76 @@
|
||||
# Element Rendering - Delta Spec
|
||||
|
||||
本 spec 是对 `openspec/specs/element-rendering/spec.md` 的增量修改。
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 系统必须支持图片元素渲染
|
||||
|
||||
系统 SHALL 将 YAML 中定义的图片元素渲染为 PPTX 图片对象,支持 `fit` 和 `background` 参数控制图片适配模式。
|
||||
|
||||
#### Scenario: 渲染本地图片
|
||||
|
||||
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
|
||||
- **THEN** 系统从指定路径加载图片,在 (2, 3) 位置渲染为 4×3 英寸大小
|
||||
- **AND** 使用默认的 `stretch` 模式(向后兼容)
|
||||
|
||||
#### Scenario: 使用 fit 模式渲染图片
|
||||
|
||||
- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
|
||||
- **THEN** 系统使用 `contain` 模式渲染图片
|
||||
- **AND** 保持图片宽高比,完整显示在 box 内
|
||||
|
||||
#### Scenario: 使用背景色渲染图片
|
||||
|
||||
- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
|
||||
- **THEN** 系统使用 `contain` 模式渲染图片
|
||||
- **AND** 用 #f0f0f0 颜色填充留白区域
|
||||
|
||||
#### Scenario: 图片文件不存在时报错
|
||||
|
||||
- **WHEN** 图片 src 指向不存在的文件路径
|
||||
- **THEN** 系统抛出错误,明确指出图片文件未找到
|
||||
|
||||
#### Scenario: 图片格式不支持时报错
|
||||
|
||||
- **WHEN** 图片文件格式不被 Pillow 支持
|
||||
- **THEN** 系统抛出错误,提示图片格式不支持,并列出支持的格式
|
||||
|
||||
#### Scenario: 图片处理失败时报错
|
||||
|
||||
- **WHEN** Pillow 处理图片时发生异常(如文件损坏)
|
||||
- **THEN** 系统抛出 ERROR 级别错误,不降级到其他模式
|
||||
|
||||
#### Scenario: 相对路径处理
|
||||
|
||||
- **WHEN** 图片 src 使用相对路径 `"assets/images/logo.png"`
|
||||
- **THEN** 系统基于演示文稿文件所在目录解析相对路径
|
||||
|
||||
#### Scenario: 使用 DPI 配置渲染图片
|
||||
|
||||
- **WHEN** metadata 定义了 `dpi: 120` 且图片需要处理
|
||||
- **THEN** 系统使用该 DPI 值进行像素与英寸的转换
|
||||
|
||||
#### Scenario: fit 参数值无效时报错
|
||||
|
||||
- **WHEN** `fit` 参数值不是 stretch、contain、cover、center 之一
|
||||
- **THEN** 系统抛出 ERROR,并列出有效值
|
||||
|
||||
#### Scenario: background 参数颜色格式无效时报错
|
||||
|
||||
- **WHEN** `background` 值不是有效的颜色格式
|
||||
- **THEN** 系统抛出 ERROR,提示颜色格式应为 #RRGGBB 或 #RGB
|
||||
|
||||
### Requirement: 图片元素的 box 参数必须存在
|
||||
|
||||
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
|
||||
|
||||
#### Scenario: 缺少 box 参数时报错
|
||||
|
||||
- **WHEN** 图片元素未定义 `box` 参数
|
||||
- **THEN** 系统抛出 ERROR,提示 box 参数为必需
|
||||
|
||||
#### Scenario: box 参数格式正确
|
||||
|
||||
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]`
|
||||
- **THEN** 系统验证通过,将 box 用于图片定位和尺寸
|
||||
@@ -0,0 +1,83 @@
|
||||
# HTML Rendering - Delta Spec
|
||||
|
||||
本 spec 是对 `openspec/specs/html-rendering/spec.md` 的增量修改。
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 系统必须渲染图片元素
|
||||
|
||||
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签,支持 `fit` 和 `background` 参数,使用 CSS object-fit 实现适配模式。
|
||||
|
||||
#### Scenario: 渲染本地图片
|
||||
|
||||
- **WHEN** 元素定义为 `{type: image, src: "images/logo.png", box: [2, 3, 4, 3]}`
|
||||
- **THEN** 系统生成 `<img>` 标签,src 为图片的文件路径,位置为 (192px, 288px),尺寸为 384x288 像素
|
||||
- **AND** 使用默认的 CSS 样式 `object-fit: fill`(等同于 stretch 模式)
|
||||
|
||||
#### Scenario: 使用 fit 模式渲染图片
|
||||
|
||||
- **WHEN** 元素定义为 `{type: image, src: "photo.jpg", box: [1, 1, 4, 3], fit: contain}`
|
||||
- **THEN** 系统应用 CSS `object-fit: contain`
|
||||
- **AND** 保持图片宽高比,完整显示在 box 内
|
||||
|
||||
#### Scenario: fit 模式与 CSS object-fit 映射
|
||||
|
||||
- **WHEN** `fit` 参数为 `stretch`
|
||||
- **THEN** 系统应用 CSS `object-fit: fill`
|
||||
|
||||
- **WHEN** `fit` 参数为 `contain`
|
||||
- **THEN** 系统应用 CSS `object-fit: contain`
|
||||
|
||||
- **WHEN** `fit` 参数为 `cover`
|
||||
- **THEN** 系统应用 CSS `object-fit: cover`
|
||||
|
||||
- **WHEN** `fit` 参数为 `center`
|
||||
- **THEN** 系统应用 CSS `object-fit: none` 和 `object-position: center`
|
||||
|
||||
#### Scenario: 使用背景色渲染图片
|
||||
|
||||
- **WHEN** 元素定义为 `{type: image, src: "photo.png", box: [1, 1, 4, 3], fit: contain, background: "#f0f0f0"}`
|
||||
- **THEN** 系统为图片容器添加 CSS `background-color: #f0f0f0`
|
||||
- **AND** 应用 CSS `object-fit: contain`
|
||||
|
||||
#### Scenario: 图片容器支持背景色
|
||||
|
||||
- **WHEN** 图片指定了 `background` 参数
|
||||
- **THEN** 系统创建包装容器,应用背景色到容器
|
||||
- **AND** 图片在容器内使用 object-fit 定位
|
||||
|
||||
#### Scenario: 处理相对路径
|
||||
|
||||
- **WHEN** 图片 src 使用相对路径 `"assets/logo.png"`
|
||||
- **THEN** 系统基于 YAML 文件所在目录解析相对路径
|
||||
|
||||
#### Scenario: 图片不存在时显示占位符
|
||||
|
||||
- **WHEN** 图片文件不存在
|
||||
- **THEN** 系统显示占位符或错误提示,而不是崩溃
|
||||
|
||||
#### Scenario: 使用 DPI 配置渲染图片
|
||||
|
||||
- **WHEN** metadata 定义了 `dpi: 120`
|
||||
- **THEN** 系统使用该 DPI 值进行英寸到像素的转换
|
||||
- **AND** box: [1, 1, 4, 3] 转换为 CSS: left: 120px; top: 120px; width: 480px; height: 360px
|
||||
|
||||
#### Scenario: HTML 渲染与 PPTX 渲染效果一致
|
||||
|
||||
- **WHEN** 同一图片元素使用相同的 fit 和 background 参数
|
||||
- **THEN** HTML 预览和 PPTX 输出的视觉效果应保持一致
|
||||
- **AND** 图片位置、尺寸、适配方式相同
|
||||
|
||||
### Requirement: 图片元素的 box 参数必须存在
|
||||
|
||||
系统 SHALL 要求图片元素必须包含 `box` 参数,否则验证失败。
|
||||
|
||||
#### Scenario: 缺少 box 参数时报错
|
||||
|
||||
- **WHEN** 图片元素未定义 `box` 参数
|
||||
- **THEN** 系统抛出 ERROR,提示 box 参数为必需
|
||||
|
||||
#### Scenario: box 参数转换为像素
|
||||
|
||||
- **WHEN** 图片元素定义了 `box: [1, 2, 4, 3]` 且 DPI 为 96
|
||||
- **THEN** 系统转换为 CSS:left: 96px; top: 192px; width: 384px; height: 288px
|
||||
@@ -0,0 +1,201 @@
|
||||
# Image Fit Modes
|
||||
|
||||
## Purpose
|
||||
|
||||
图片适配模式能力为图片元素提供多种适配策略,允许用户控制图片在指定区域内的显示方式,包括拉伸、保持比例、填充裁剪和居中显示。同时支持 DPI 配置和背景色填充,满足不同场景的视觉需求。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 系统必须支持图片 fit 参数
|
||||
|
||||
系统 SHALL 支持图片元素的 `fit` 参数,允许用户指定图片适配模式。
|
||||
|
||||
#### Scenario: fit 参数支持四种模式
|
||||
|
||||
- **WHEN** 图片元素定义了 `fit` 参数
|
||||
- **THEN** 系统支持 `stretch`、`contain`、`cover`、`center` 四种模式
|
||||
|
||||
#### Scenario: fit 参数默认值为 stretch
|
||||
|
||||
- **WHEN** 图片元素未指定 `fit` 参数
|
||||
- **THEN** 系统使用 `stretch` 模式(向后兼容)
|
||||
|
||||
#### Scenario: fit 参数值无效时报错
|
||||
|
||||
- **WHEN** `fit` 参数值不是四种有效模式之一
|
||||
- **THEN** 系统抛出 ERROR,并列出有效值(stretch、contain、cover、center)
|
||||
|
||||
### Requirement: 系统必须支持 stretch 模式
|
||||
|
||||
系统 SHALL 在 `stretch` 模式下将图片强制缩放到 box 指定的尺寸,不考虑宽高比。
|
||||
|
||||
#### Scenario: stretch 模式拉伸图片
|
||||
|
||||
- **WHEN** 图片元素定义了 `fit: stretch` 或未指定 `fit`
|
||||
- **THEN** 系统将图片拉伸到 box 的宽高尺寸
|
||||
- **AND** 图片可能变形
|
||||
|
||||
#### Scenario: stretch 模式不考虑背景色
|
||||
|
||||
- **WHEN** 图片使用 `fit: stretch` 并指定了 `background`
|
||||
- **THEN** 背景色参数被忽略(无留白区域)
|
||||
|
||||
### Requirement: 系统必须支持 contain 模式
|
||||
|
||||
系统 SHALL 在 `contain` 模式下保持图片宽高比,完整显示图片在 box 内,可能有留白。
|
||||
|
||||
#### Scenario: contain 模式保持宽高比
|
||||
|
||||
- **WHEN** 图片元素定义了 `fit: contain`
|
||||
- **THEN** 系统缩放图片使其完整显示在 box 内
|
||||
- **AND** 保持原始宽高比
|
||||
- **AND** 图片尺寸不超过 box 尺寸
|
||||
|
||||
#### Scenario: contain 模式图片居中
|
||||
|
||||
- **WHEN** 图片使用 `fit: contain` 且小于 box 尺寸
|
||||
- **THEN** 系统将图片居中显示在 box 内
|
||||
|
||||
#### Scenario: contain 模式支持背景色
|
||||
|
||||
- **WHEN** 图片使用 `fit: contain` 并指定了 `background`
|
||||
- **THEN** 系统用指定颜色填充 box 内的留白区域
|
||||
|
||||
#### Scenario: contain 模式图片比 box 大
|
||||
|
||||
- **WHEN** 图片原始尺寸大于 box 尺寸
|
||||
- **THEN** 系统等比缩小图片使其完整显示在 box 内
|
||||
|
||||
#### Scenario: contain 模式图片比 box 小
|
||||
|
||||
- **WHEN** 图片原始尺寸小于 box 尺寸
|
||||
- **THEN** 系统保持原始尺寸,居中显示在 box 内
|
||||
|
||||
### Requirement: 系统必须支持 cover 模式
|
||||
|
||||
系统 SHALL 在 `cover` 模式下保持图片宽高比,填充整个 box,裁剪超出部分。
|
||||
|
||||
#### Scenario: cover 模式保持宽高比
|
||||
|
||||
- **WHEN** 图片元素定义了 `fit: cover`
|
||||
- **THEN** 系统缩放图片使其填满 box
|
||||
- **AND** 保持原始宽高比
|
||||
- **AND** 裁剪超出 box 的部分
|
||||
|
||||
#### Scenario: cover 模式图片居中裁剪
|
||||
|
||||
- **WHEN** 图片使用 `fit: cover` 且需要裁剪
|
||||
- **THEN** 系统从图片中心进行裁剪
|
||||
|
||||
#### Scenario: cover 模式不考虑背景色
|
||||
|
||||
- **WHEN** 图片使用 `fit: cover` 并指定了 `background`
|
||||
- **THEN** 背景色参数被忽略(无留白区域)
|
||||
|
||||
#### Scenario: cover 模式图片比 box 大
|
||||
|
||||
- **WHEN** 图片原始尺寸大于 box 尺寸
|
||||
- **THEN** 系统等比缩小图片并裁剪超出部分
|
||||
|
||||
#### Scenario: cover 模式图片比 box 小
|
||||
|
||||
- **WHEN** 图片原始尺寸小于 box 尺寸
|
||||
- **THEN** 系统等比放大图片并裁剪超出部分
|
||||
|
||||
### Requirement: 系统必须支持 center 模式
|
||||
|
||||
系统 SHALL 在 `center` 模式下按原始尺寸居中显示图片,不缩放,超出 box 的部分被裁剪。
|
||||
|
||||
#### Scenario: center 模式不缩放图片
|
||||
|
||||
- **WHEN** 图片元素定义了 `fit: center`
|
||||
- **THEN** 系统保持图片原始尺寸,不进行缩放
|
||||
|
||||
#### Scenario: center 模式图片居中
|
||||
|
||||
- **WHEN** 图片使用 `fit: center`
|
||||
- **THEN** 系统将图片居中显示在 box 内
|
||||
|
||||
#### Scenario: center 模式裁剪超出部分
|
||||
|
||||
- **WHEN** 图片原始尺寸大于 box 尺寸
|
||||
- **THEN** 系统裁剪超出 box 的部分(从中心裁剪)
|
||||
|
||||
#### Scenario: center 模式支持背景色
|
||||
|
||||
- **WHEN** 图片使用 `fit: center` 并指定了 `background`
|
||||
- **THEN** 系统用指定颜色填充 box 内的留白区域
|
||||
|
||||
### Requirement: 系统必须支持 background 参数
|
||||
|
||||
系统 SHALL 支持图片元素的 `background` 参数,允许用户指定留白区域的填充颜色。
|
||||
|
||||
#### Scenario: background 参数默认透明
|
||||
|
||||
- **WHEN** 图片元素未指定 `background` 参数
|
||||
- **THEN** 留白区域保持透明
|
||||
|
||||
#### Scenario: background 参数支持纯色
|
||||
|
||||
- **WHEN** 图片元素指定了 `background: "#ff0000"`
|
||||
- **THEN** 系统使用指定颜色填充留白区域
|
||||
|
||||
#### Scenario: background 参数不支持渐变
|
||||
|
||||
- **WHEN** 图片元素指定了渐变色(如 `"linear-gradient(...)"`)
|
||||
- **THEN** 系统抛出 ERROR,提示仅支持纯色
|
||||
|
||||
#### Scenario: background 参数颜色格式验证
|
||||
|
||||
- **WHEN** `background` 值不是有效的颜色格式
|
||||
- **THEN** 系统抛出 ERROR,提示颜色格式应为 #RRGGBB 或 #RGB
|
||||
|
||||
### Requirement: 系统必须支持文档级 DPI 配置
|
||||
|
||||
系统 SHALL 支持在 `metadata.dpi` 中配置 DPI 值,用于像素与英寸的转换。
|
||||
|
||||
#### Scenario: DPI 默认值为 96
|
||||
|
||||
- **WHEN** metadata 未指定 `dpi` 参数
|
||||
- **THEN** 系统使用默认值 96
|
||||
|
||||
#### Scenario: DPI 配置影响所有图片
|
||||
|
||||
- **WHEN** metadata 指定了 `dpi: 120`
|
||||
- **THEN** 系统使用该值进行所有图片的像素与英寸转换
|
||||
|
||||
#### Scenario: DPI 值验证
|
||||
|
||||
- **WHEN** `dpi` 值超出合理范围(如小于 72 或大于 300)
|
||||
- **THEN** 系统发出 WARNING,提示 DPI 值可能不合适
|
||||
|
||||
### Requirement: 系统必须在图片处理失败时抛出错误
|
||||
|
||||
系统 SHALL 在图片处理失败时抛出 ERROR 级别错误,不提供降级方案。
|
||||
|
||||
#### Scenario: 损坏的图片文件
|
||||
|
||||
- **WHEN** Pillow 无法读取图片文件(文件损坏或格式不支持)
|
||||
- **THEN** 系统抛出 ERROR,明确指出图片文件问题
|
||||
- **AND** 不降级到其他模式
|
||||
|
||||
#### Scenario: 图片处理异常
|
||||
|
||||
- **WHEN** Pillow 处理图片时发生异常(如内存不足)
|
||||
- **THEN** 系统抛出 ERROR,包含异常信息
|
||||
- **AND** 不降级到其他模式
|
||||
|
||||
### Requirement: 系统必须使用最高质量的图片处理算法
|
||||
|
||||
系统 SHALL 使用 Pillow 的最高质量重采样算法(LANCZOS)进行图片缩放。
|
||||
|
||||
#### Scenario: 图片缩放使用 LANCZOS
|
||||
|
||||
- **WHEN** 系统需要缩放图片(contain、cover 模式)
|
||||
- **THEN** 使用 Pillow 的 LANCZOS 重采样算法
|
||||
- **AND** 不向用户暴露质量配置选项
|
||||
|
||||
#### Scenario: 图片裁剪保持质量
|
||||
|
||||
- **WHEN** 系统需要裁剪图片(cover、center 模式)
|
||||
- **THEN** 裁剪操作不损失图片质量
|
||||
114
openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md
Normal file
114
openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 图片适配模式实现任务清单
|
||||
|
||||
## 1. 依赖配置
|
||||
|
||||
- [x] 1.1 在 pyproject.toml 中添加 Pillow 依赖(不指定版本号)
|
||||
- [x] 1.2 运行 `uv sync` 安装新增依赖
|
||||
|
||||
## 2. 核心元素扩展
|
||||
|
||||
- [x] 2.1 更新 `core/elements.py` 的 ImageElement 类,添加 `fit` 字段(可选,默认 None)
|
||||
- [x] 2.2 更新 `core/elements.py` 的 ImageElement 类,添加 `background` 字段(可选,默认 None)
|
||||
- [x] 2.3 更新 `core/elements.py` 的 ImageElement.__post_init__,验证 box 参数为必需
|
||||
- [x] 2.4 更新 `core/elements.py` 的 ImageElement.validate 方法,添加 fit 和 background 验证
|
||||
|
||||
## 3. 图片处理工具模块
|
||||
|
||||
- [x] 3.1 创建 `utils/image_utils.py` 模块
|
||||
- [x] 3.2 实现 `inches_to_pixels(inches, dpi) -> float` 函数
|
||||
- [x] 3.3 实现 `pixels_to_inches(pixels, dpi) -> float` 函数
|
||||
- [x] 3.4 实现 `apply_fit_mode(img, box_size, fit, background) -> PIL.Image` 函数,支持四种模式
|
||||
- [x] 3.5 实现 `create_canvas_with_background(size, background_color) -> PIL.Image` 函数
|
||||
- [x] 3.6 在 `apply_fit_mode` 中使用 Pillow.ImageOps.contain 实现 contain 模式
|
||||
- [x] 3.7 在 `apply_fit_mode` 中使用 Pillow.ImageOps.cover 实现 cover 模式
|
||||
- [x] 3.8 在 `apply_fit_mode` 中实现 center 模式(不缩放,居中裁剪)
|
||||
- [x] 3.9 在 `apply_fit_mode` 中使用 LANCZOS 重采样算法保证图片质量
|
||||
|
||||
## 4. 验证器
|
||||
|
||||
- [x] 4.1 创建 `validators/image_config.py` 模块
|
||||
- [x] 4.2 实现 `validate_fit_value(fit) -> List[ValidationIssue]` 函数,验证 fit 为有效值
|
||||
- [x] 4.3 实现 `validate_background_color(color) -> List[ValidationIssue]` 函数,验证颜色格式
|
||||
- [x] 4.4 实现 `validate_dpi_value(dpi) -> List[ValidationIssue]` 函数,验证 DPI 在合理范围
|
||||
- [x] 4.5 在主验证流程中集成图片配置验证器
|
||||
|
||||
## 5. PPTX 渲染器更新
|
||||
|
||||
- [x] 5.1 更新 `renderers/pptx_renderer.py`,导入 Pillow 和 image_utils
|
||||
- [x] 5.2 重写 `_render_image` 方法,读取 metadata.dpi 配置
|
||||
- [x] 5.3 在 `_render_image` 中实现 stretch 模式(直接使用 python-pptx)
|
||||
- [x] 5.4 在 `_render_image` 中实现 contain 模式(使用 Pillow 处理)
|
||||
- [x] 5.5 在 `_render_image` 中实现 cover 模式(使用 Pillow 处理)
|
||||
- [x] 5.6 在 `_render_image` 中实现 center 模式(使用 Pillow 处理)
|
||||
- [x] 5.7 在 `_render_image` 中实现背景色填充(创建画布并粘贴图片)
|
||||
- [x] 5.8 在 `_render_image` 中添加图片处理失败的错误处理(抛出 ERROR)
|
||||
- [x] 5.9 更新 PptxGenerator 类,传递 dpi 参数到渲染方法
|
||||
|
||||
## 6. HTML 渲染器更新
|
||||
|
||||
- [x] 6.1 更新 `renderers/html_renderer.py` 的 `_render_image` 方法
|
||||
- [x] 6.2 实现 fit 模式到 CSS object-fit 的映射(stretch->fill, contain->contain, cover->cover, center->none)
|
||||
- [x] 6.3 在 HTML 渲染中添加 object-position: center 样式(center 模式)
|
||||
- [x] 6.4 实现背景色支持(创建包装容器,应用 background-color)
|
||||
- [x] 6.5 更新 HTML 渲染器读取 metadata.dpi 配置
|
||||
- [x] 6.6 确保 HTML 和 PPTX 渲染效果一致
|
||||
|
||||
## 7. 加载器和配置支持
|
||||
|
||||
- [x] 7.1 更新 `loaders/yaml_loader.py`,解析 metadata.dpi 配置
|
||||
- [x] 7.2 将 dpi 配置传递到 Presentation 对象
|
||||
- [x] 7.3 在 PptxGenerator 和 HtmlRenderer 中访问 dpi 配置
|
||||
|
||||
## 8. 单元测试
|
||||
|
||||
- [x] 8.1 创建 `tests/unit/test_image_utils.py`
|
||||
- [x] 8.2 测试 `inches_to_pixels` 和 `pixels_to_inches` 函数
|
||||
- [x] 8.3 测试 `apply_fit_mode` 函数的四种模式
|
||||
- [x] 8.4 测试 `create_canvas_with_background` 函数
|
||||
- [x] 8.5 创建 `tests/unit/test_validators/test_image_config.py`
|
||||
- [x] 8.6 测试 `validate_fit_value` 函数(有效值和无效值)
|
||||
- [x] 8.7 测试 `validate_background_color` 函数(有效颜色和无效颜色)
|
||||
- [x] 8.8 测试 `validate_dpi_value` 函数(合理范围和超出范围)
|
||||
- [x] 8.9 更新 `tests/unit/test_elements.py`,测试 ImageElement 新字段
|
||||
|
||||
## 9. 集成测试
|
||||
|
||||
- [x] 9.1 创建 `tests/integration/test_image_fit_modes.py`
|
||||
- [x] 9.2 测试 stretch 模式的 PPTX 渲染
|
||||
- [x] 9.3 测试 contain 模式的 PPTX 渲染(图片比 box 大)
|
||||
- [x] 9.4 测试 contain 模式的 PPTX 渲染(图片比 box 小)
|
||||
- [x] 9.5 测试 contain 模式的 PPTX 渲染(带背景色)
|
||||
- [x] 9.6 测试 cover 模式的 PPTX 渲染(图片比 box 大)
|
||||
- [x] 9.7 测试 cover 模式的 PPTX 渲染(图片比 box 小)
|
||||
- [x] 9.8 测试 center 模式的 PPTX 渲染(带背景色)
|
||||
- [x] 9.9 测试不同 DPI 配置的渲染结果
|
||||
- [x] 9.10 测试图片处理失败时的错误处理
|
||||
- [x] 9.11 测试 HTML 渲染器的四种 fit 模式
|
||||
- [x] 9.12 测试 HTML 渲染器的背景色支持
|
||||
- [x] 9.13 对比 HTML 和 PPTX 渲染效果的一致性
|
||||
|
||||
## 10. 端到端测试
|
||||
|
||||
- [x] 10.1 创建测试 YAML 文件,包含所有 fit 模式
|
||||
- [x] 10.2 创建测试 YAML 文件,包含背景色配置
|
||||
- [x] 10.3 创建测试 YAML 文件,包含 DPI 配置
|
||||
- [x] 10.4 创建测试 YAML 文件,包含无效参数(测试验证)
|
||||
- [x] 10.5 运行 `check` 命令,验证错误检测
|
||||
- [x] 10.6 运行 `convert` 命令,验证 PPTX 生成
|
||||
- [x] 10.7 运行 `preview` 命令,验证 HTML 预览
|
||||
|
||||
## 11. 文档更新
|
||||
|
||||
- [x] 11.1 更新 `README.md`,添加图片适配模式章节
|
||||
- [x] 11.2 在 README.md 中添加 fit 参数说明和示例
|
||||
- [x] 11.3 在 README.md 中添加 background 参数说明和示例
|
||||
- [x] 11.4 在 README.md 中添加 metadata.dpi 配置说明
|
||||
- [x] 11.5 更新 `README_DEV.md`,添加图片处理架构说明
|
||||
- [x] 11.6 在 README_DEV.md 中说明 Pillow 依赖和用途
|
||||
- [x] 11.7 在 README_DEV.md 中说明 image_utils 模块的设计
|
||||
|
||||
## 12. 向后兼容性验证
|
||||
|
||||
- [x] 12.1 测试现有 YAML 文件(无 fit 参数)仍能正常转换
|
||||
- [x] 12.2 测试现有 YAML 文件的渲染结果与之前一致
|
||||
- [x] 12.3 验证未指定 fit 时默认使用 stretch 模式
|
||||
@@ -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 形状对象。
|
||||
|
||||
@@ -133,12 +133,45 @@ HTML Rendering 系统负责将 YAML 中定义的各类元素(文本、图片
|
||||
|
||||
### Requirement: 系统必须渲染图片元素
|
||||
|
||||
系统 SHALL 将 YAML 中的图片元素转换为 HTML `<img>` 标签。
|
||||
系统 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: 处理相对路径
|
||||
|
||||
@@ -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 支持为幻灯片设置纯色背景。
|
||||
|
||||
201
openspec/specs/image-fit-modes/spec.md
Normal file
201
openspec/specs/image-fit-modes/spec.md
Normal 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** 裁剪操作不损失图片质量
|
||||
@@ -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', [])):
|
||||
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"flask",
|
||||
"watchdog",
|
||||
"simpleeval",
|
||||
"pillow",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -15,7 +16,6 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"pillow",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -8,13 +8,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'<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}">'
|
||||
|
||||
@@ -10,23 +10,28 @@ from pptx.util import Inches, Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.dml.color import RGBColor
|
||||
from PIL import Image
|
||||
import tempfile
|
||||
|
||||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from utils import hex_to_rgb
|
||||
from utils.image_utils import inches_to_pixels, apply_fit_mode
|
||||
|
||||
|
||||
class PptxGenerator:
|
||||
"""PPTX 生成器,封装 python-pptx 操作"""
|
||||
|
||||
def __init__(self, size='16:9'):
|
||||
def __init__(self, size='16:9', dpi=96):
|
||||
"""
|
||||
初始化 PPTX 生成器
|
||||
|
||||
Args:
|
||||
size: 幻灯片尺寸("16:9" 或 "4:3")
|
||||
dpi: DPI 配置,默认 96
|
||||
"""
|
||||
self.prs = PptxPresentation()
|
||||
self.dpi = dpi
|
||||
|
||||
# 设置幻灯片尺寸
|
||||
if size == '16:9':
|
||||
@@ -148,9 +153,39 @@ class PptxGenerator:
|
||||
# 获取位置和尺寸
|
||||
x, y, w, h = [Inches(v) for v in elem.box]
|
||||
|
||||
# 添加图片
|
||||
# 获取 fit 模式,默认为 stretch
|
||||
fit = elem.fit if elem.fit else 'stretch'
|
||||
|
||||
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:
|
||||
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")
|
||||
|
||||
|
||||
35
tests/e2e/test_background_colors.yaml
Normal file
35
tests/e2e/test_background_colors.yaml
Normal 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"
|
||||
20
tests/e2e/test_dpi_config.yaml
Normal file
20
tests/e2e/test_dpi_config.yaml
Normal 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"
|
||||
40
tests/e2e/test_fit_modes.yaml
Normal file
40
tests/e2e/test_fit_modes.yaml
Normal 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"
|
||||
19
tests/e2e/test_invalid_params.yaml
Normal file
19
tests/e2e/test_invalid_params.yaml
Normal 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"
|
||||
304
tests/integration/test_image_fit_modes.py
Normal file
304
tests/integration/test_image_fit_modes.py
Normal 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
|
||||
@@ -89,6 +89,19 @@ class TestImageElement:
|
||||
assert elem.type == 'image'
|
||||
assert elem.src == "test.png"
|
||||
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):
|
||||
"""测试空 src 会引发错误"""
|
||||
|
||||
120
tests/unit/test_image_utils.py
Normal file
120
tests/unit/test_image_utils.py
Normal 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")
|
||||
136
tests/unit/test_validators/test_image_config.py
Normal file
136
tests/unit/test_validators/test_image_config.py
Normal 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
|
||||
@@ -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
|
||||
149
utils/image_utils.py
Normal file
149
utils/image_utils.py
Normal 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
8
uv.lock
generated
@@ -1655,6 +1655,9 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "flask", version = "3.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
||||
{ name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
|
||||
{ name = "pillow", version = "12.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "python-pptx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "simpleeval", version = "0.9.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
@@ -1665,9 +1668,6 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
|
||||
{ name = "pillow", version = "12.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
|
||||
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
@@ -1680,7 +1680,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "flask" },
|
||||
{ name = "pillow", marker = "extra == 'dev'" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pytest", marker = "extra == 'dev'" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'" },
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'" },
|
||||
|
||||
101
validators/image_config.py
Normal file
101
validators/image_config.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
Reference in New Issue
Block a user