From 19d6661381922316046d52e0b08f9094e81555f6 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 4 Mar 2026 10:29:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=80=82=E9=85=8D=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持四种图片适配模式: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 模式 --- README.md | 77 +++++ README_DEV.md | 69 +++- core/elements.py | 32 +- core/presentation.py | 1 + .../.openspec.yaml | 2 + .../2026-03-04-add-image-fit-modes/design.md | 260 +++++++++++++++ .../proposal.md | 44 +++ .../specs/element-rendering/spec.md | 76 +++++ .../specs/html-rendering/spec.md | 83 +++++ .../specs/image-fit-modes/spec.md | 201 ++++++++++++ .../2026-03-04-add-image-fit-modes/tasks.md | 114 +++++++ openspec/specs/element-rendering/spec.md | 51 ++- openspec/specs/html-rendering/spec.md | 61 +++- openspec/specs/image-fit-modes/spec.md | 201 ++++++++++++ preview/server.py | 2 +- pyproject.toml | 2 +- renderers/html_renderer.py | 78 +++-- renderers/pptx_renderer.py | 41 ++- tests/e2e/test_background_colors.yaml | 35 ++ tests/e2e/test_dpi_config.yaml | 20 ++ tests/e2e/test_fit_modes.yaml | 40 +++ tests/e2e/test_invalid_params.yaml | 19 ++ tests/integration/test_image_fit_modes.py | 304 ++++++++++++++++++ tests/unit/test_elements.py | 13 + tests/unit/test_image_utils.py | 120 +++++++ .../unit/test_validators/test_image_config.py | 136 ++++++++ utils.py => utils/__init__.py | 18 -- utils/image_utils.py | 149 +++++++++ uv.lock | 8 +- validators/image_config.py | 101 ++++++ validators/validator.py | 7 + yaml2pptx.py | 2 +- 32 files changed, 2310 insertions(+), 57 deletions(-) create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/design.md create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/proposal.md create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/element-rendering/spec.md create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/html-rendering/spec.md create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/specs/image-fit-modes/spec.md create mode 100644 openspec/changes/archive/2026-03-04-add-image-fit-modes/tasks.md create mode 100644 openspec/specs/image-fit-modes/spec.md create mode 100644 tests/e2e/test_background_colors.yaml create mode 100644 tests/e2e/test_dpi_config.yaml create mode 100644 tests/e2e/test_fit_modes.yaml create mode 100644 tests/e2e/test_invalid_params.yaml create mode 100644 tests/integration/test_image_fit_modes.py create mode 100644 tests/unit/test_image_utils.py create mode 100644 tests/unit/test_validators/test_image_config.py rename utils.py => utils/__init__.py (74%) create mode 100644 utils/image_utils.py create mode 100644 validators/image_config.py 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'''
+ +
''' + else: + return f'' diff --git a/renderers/pptx_renderer.py b/renderers/pptx_renderer.py index f7e2246..d36e0db 100644 --- a/renderers/pptx_renderer.py +++ b/renderers/pptx_renderer.py @@ -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)}") diff --git a/tests/e2e/test_background_colors.yaml b/tests/e2e/test_background_colors.yaml new file mode 100644 index 0000000..1e498c7 --- /dev/null +++ b/tests/e2e/test_background_colors.yaml @@ -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" diff --git a/tests/e2e/test_dpi_config.yaml b/tests/e2e/test_dpi_config.yaml new file mode 100644 index 0000000..837c3db --- /dev/null +++ b/tests/e2e/test_dpi_config.yaml @@ -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" diff --git a/tests/e2e/test_fit_modes.yaml b/tests/e2e/test_fit_modes.yaml new file mode 100644 index 0000000..db7594d --- /dev/null +++ b/tests/e2e/test_fit_modes.yaml @@ -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" diff --git a/tests/e2e/test_invalid_params.yaml b/tests/e2e/test_invalid_params.yaml new file mode 100644 index 0000000..46af9d7 --- /dev/null +++ b/tests/e2e/test_invalid_params.yaml @@ -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" diff --git a/tests/integration/test_image_fit_modes.py b/tests/integration/test_image_fit_modes.py new file mode 100644 index 0000000..89e7f58 --- /dev/null +++ b/tests/integration/test_image_fit_modes.py @@ -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 ' 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', [])