diff --git a/.trae/docs/iter-22-image-processing.md b/.trae/docs/iter-22-image-processing.md new file mode 100644 index 0000000..20e8390 --- /dev/null +++ b/.trae/docs/iter-22-image-processing.md @@ -0,0 +1,174 @@ +# 迭代 22 — P10 图片处理(imagetool + image_pipeline) + +## 本轮目标 + +延续 P9 功能扩展后,补齐 PyFlowX 在图片处理场景的能力,覆盖三个方向: + +1. **P10.1 基础操作子命令**:`imagetool` 工具的 7 个基础操作子命令 + (resize/crop/rotate/flip/convert/watermark/compress)。 +2. **P10.2 元数据与信息子命令**:4 个元数据子命令 + (info/exif/histogram/colors)。 +3. **P10.3 批量 DAG 编排**:`image_pipeline` 链式 DAG 构造器, + 将多步图片操作封装为 `Graph`,前一步输出作为后一步输入。 + +工具形态:单一 `imagetool` + `office` extra(Pillow 通过 `pyflowx[office]` 安装), +不破坏项目零运行时依赖原则。 + +## 改动文件清单 + +### 新增 + +- `src/pyflowx/ops/files/imagetool.py` — 11 个 `@px.tool("imagetool", ...)` + 子命令 + `_save_image`/`_load_font`/`_resolve_position`/`_print_exif`/ + `_apply_exif_modifications`/`_apply_single_exif_set`/`_save_exif`/ + `_print_histogram_buckets` 辅助函数(520 行)。 +- `src/pyflowx/imaging.py` — `image_pipeline()` 链式 DAG 构造器 + + `_make_step_fn` 闭包工厂(130 行)。 +- `tests/cli/test_imagetool.py` — 25 用例覆盖 11 子命令 + `_require_pil` + 守卫(316 行)。 +- `tests/test_imaging.py` — 9 用例覆盖 DAG 拓扑与执行(158 行)。 + +### 修改 + +- `src/pyflowx/__init__.py` — 导出 `image_pipeline`,加入 `__all__`。 +- `src/pyflowx/cli/pf.py` — `_TOOL_MODULES` 注册 + `"imagetool": "pyflowx.ops.files.imagetool"`;`_TOOL_ALIASES` 添加 + `"image"`/`"imagetool"`/`"img"` 三个别名。 + +## 关键决策与依据 + +### 1. 单一 `imagetool` + office extra + +不拆分为 `imageops`/`imagemeta` 等多工具,所有图片操作集中在 +`ops/files/imagetool.py`。Pillow 通过 `pyproject.toml` 的 `[office]` +extra 安装(`pillow>=10.4.0`),运行时 try/except 导入 + `HAS_PIL` 标志, +未安装时 `_require_pil()` 打印 `pip install pyflowx[office]` 提示而非崩溃。 + +依据:项目零运行时依赖原则不可破坏;pdftool 已有相同模式 +(`pypdf` 通过 `[office]` extra)。 + +### 2. `_save_image` 统一保存逻辑 + +所有需要保存图片的子命令(resize/crop/rotate/flip/convert/watermark/compress) +委托 `_save_image(img, output, fmt, quality)` 统一处理: + +- **JPG → JPEG 规范化**:`save_fmt == "JPG"` 时改写为 `"JPEG"`, + 否则 Pillow `img.save(format="JPG")` 抛 `KeyError: 'JPG'`。 +- **JPEG 强制 RGB 转换**:JPEG 不支持 alpha 通道,保存前 + `img.convert("RGB")` 去 alpha,避免 `cannot write mode RGBA as JPEG`。 +- **JPEG/WEBP 应用 quality**:其他格式(PNG/BMP/GIF 等)忽略 quality。 + +依据:避免每个子命令重复处理格式规范化与 alpha 通道问题。 + +### 3. EXIF 用 `exif.tobytes()` 而非 `bytes(exif)` + +Pillow 10+ 的 `Image.Exif` 对象,`bytes(exif)` 调用抛 +`ValueError: bytes must be in range(0, 256)`。必须用 `exif.tobytes()` +序列化为字节串后再传给 `img.save(exif=exif_bytes)`。 + +`_save_exif` 辅助函数封装此逻辑: +```python +exif_bytes = exif.tobytes() if exif else b"" +img.save(output_path, exif=exif_bytes) +``` + +### 4. `quantize(colors=N)` 实际色数可能 < N + +`Image.quantize(colors=count)` 对简单图片(如纯色)可能返回少于 +请求色数的调色板。直接按 `count` 遍历会 `IndexError: list index out of range`。 + +修复:用 `actual_count = len(palette) // 3` 动态计算实际色数, +`min(count, actual_count)` 防越界: +```python +actual_count = len(palette) // 3 +print(f"主色调 (前 {min(count, actual_count)} 色):") +for i in range(min(count, actual_count)): + ... +``` + +### 5. Pillow 10+ Transpose 枚举迁移 + +`Image.FLIP_LEFT_RIGHT`(模块级常量)在 Pillow 10+ 已弃用, +pyrefly 无法解析。必须用 `Image.Transpose.FLIP_LEFT_RIGHT` +(枚举形式)。 + +`image_flip` 中根据 direction 选择枚举: +```python +method = Image.Transpose.FLIP_LEFT_RIGHT if direction == "horizontal" else Image.Transpose.FLIP_TOP_BOTTOM +flipped = img.transpose(method) +``` + +### 6. `_make_step_fn` 闭包工厂避免循环变量捕获 + +`image_pipeline` 中若直接在 for 循环内定义闭包: +```python +for i, (op_name, params) in enumerate(steps): + + def _run(): + base_fn(input_path, output_path, **params) # 陷阱! + + ... +``` +所有任务的 `_run` 会捕获**最后一次循环**的 `input_path`/`output_path`/`params`, +导致所有步骤操作同一文件。 + +修复:提取为独立函数 `_make_step_fn(base_fn, input_path, output_path, params)`, +利用每次调用的独立作用域正确捕获: +```python +def _make_step_fn(base_fn, input_path, output_path, params): + def _run() -> None: + base_fn(input_path, output_path, **params) + + return _run +``` +每次循环调用 `_make_step_fn(...)` 创建新作用域,闭包绑定该次调用的参数。 + +### 7. PLR0912 分支数控制 + +`image_exif` 函数最初 13 分支(show/clear/set 各分支 + 错误处理)超过 +ruff `PLR0912` 限制(≤12)。提取 4 个辅助函数降至合规: + +- `_print_exif(exif)` — 打印 EXIF 标签 +- `_apply_exif_modifications(exif, set_items, clear)` — 应用 clear + set +- `_apply_single_exif_set(exif, item)` — 解析单个 KEY=VALUE +- `_save_exif(img, exif, output_path)` — 保存图片与 EXIF + +## 验证结果 + +``` +ruff check . → All checks passed! +ruff format --check → 131 files already formatted +pyrefly check . → 0 errors (68 suppressed) +pytest --cov=pyflowx → 1560 passed in 11.72s +coverage → 97.15%(branch;门槛 95%) +``` + +各模块覆盖率(P10 新增部分): +- `imagetool.py` 88%(213 stmts, 19 miss — 主要是 ImportError 回退分支 + 与少量错误路径;总覆盖率 97.15% 不受影响) +- `imaging.py` 100%(`image_pipeline` 拓扑/执行/convert 扩展名/naming 模板 + 全覆盖) + +CLI 注册验证: +- `imagetool` 在 `_TOOL_MODULES` +- `img`/`image`/`imagetool` 三个别名在 `_TOOL_ALIASES` +- `tests/cli/test_tool_modules.py` 自动覆盖新工具注册 + +## 遗留事项 + +- **P11 候选方向**(用户已确认为后续阶段输入,不在 P10 范围内): + - OCR 文字识别(需 `pytesseract` 新依赖,违反零依赖原则,需用户确认) + - 滤镜与增强(模糊/锐化/边缘检测/浮雕/去噪,纯 Pillow 可实现) + - 批量文件夹处理(遍历目录批量应用同一操作,生成缩略图网格) + - 图片拼接与 GIF(多图水平/垂直拼接,GIF 帧拆分与合成) +- **`imagetool.py` 覆盖率 88%**:ImportError 回退分支(`HAS_PIL = False`) + 在已安装 Pillow 的环境中无法触发,属可接受缺口。 +- **`image_pipeline` 仅支持链式 DAG**:当前实现是单链(每步依赖前一步), + 不支持分叉/汇合(如同一源图应用多操作后合并)。如需分叉,可扩展 + `steps` 为 DAG 描述结构。 + +## 归档说明 + +iter-22 暂不归档(前 5 次 iter-16~20 已归档至 +`.trae/skills/pyflowx-development/SKILL.md`,iter-21/22 保留在 docs/)。 +下次清理窗口为 iter-26 前夕,届时 iter-21/22 一并归档。 diff --git a/.trae/documents/p10-image-processing-finalization-plan.md b/.trae/documents/p10-image-processing-finalization-plan.md new file mode 100644 index 0000000..1ceaf13 --- /dev/null +++ b/.trae/documents/p10-image-processing-finalization-plan.md @@ -0,0 +1,167 @@ +# P10 图片处理收尾计划(文档同步 + 提交) + +## Context + +P10 图片处理代码已完整实现并通过全部门禁,但**尚未提交**,且过程文档、SKILL 行为同步、project_memory 约定记录三项收尾工作未完成。本计划聚焦于把 P10 工作闭环:编写 iter-22 迭代记录、同步 SKILL.md 行为变更、追加 project_memory 约定、git commit + push。 + +用户已确认本次计划范围 = 仅收尾 P10(不含 P11 扩展)。P11 高级功能(OCR/滤镜/批量文件夹/拼接GIF)作为后续阶段输入参考,不在本计划内。 + +## 当前状态分析 + +### 已完成(未提交) + +``` +git status --short + M src/pyflowx/__init__.py # 导出 image_pipeline + M src/pyflowx/cli/pf.py # 注册 imagetool + 别名 +?? src/pyflowx/imaging.py # image_pipeline DAG 构造器 (130 行) +?? src/pyflowx/ops/files/imagetool.py # 11 子命令 (520 行) +?? tests/cli/test_imagetool.py # 25 测试 (316 行) +?? tests/test_imaging.py # 9 测试 (158 行) +?? .trae/documents/ # 本计划文件所在目录 +``` + +### 门禁状态(已验证通过) + +- ruff check / format → 全绿 +- pyrefly check → 0 错误(68 suppressed) +- pytest → 1560 passed +- coverage → 97.15%(branch;门槛 95%) +- CLI 注册 → `imagetool` 在 `_TOOL_MODULES`,`img`/`image`/`imagetool` 在 `_TOOL_ALIASES` + +### 分支跟踪 + +- 当前分支:`develop` +- 跟踪远程:`origin/develop`(push 允许,无需询问) + +## 实现步骤 + +### 步骤 1:创建 iter-22 迭代记录 + +**新建 `.trae/docs/iter-22-image-processing.md`**,参照 [iter-21-p9-feature-extension.md](file:///home/zhou/pyflowx/.trae/docs/iter-21-p9-feature-extension.md) 的结构: + +1. **本轮目标** — P10 三方向:基础操作 7 子命令 / 元数据 4 子命令 / `image_pipeline` DAG 构造器 +2. **改动文件清单** — 新增 4 文件 + 修改 2 文件(列出每个文件的职责) +3. **关键决策与依据**(6 条,基于实际实现踩坑): + - **JPG → JPEG 规范化**:`_save_image` 中 `save_fmt == "JPG"` 时改写为 `"JPEG"`,否则 Pillow `img.save(format="JPG")` 抛 `KeyError` + - **JPEG 强制 RGB 转换**:JPEG 不支持 alpha 通道,保存前 `img.convert("RGB")` 去 alpha + - **EXIF 用 `exif.tobytes()` 而非 `bytes(exif)`**:Pillow 10+ 的 `Image.Exif` 对象 `bytes()` 调用抛 `ValueError: bytes must be in range(0, 256)`,必须用 `tobytes()` + - **`quantize(colors=N)` 实际色数可能 < N**:简单图片量化后调色板可能少于请求色数,用 `actual_count = len(palette) // 3` 动态计算并 `min(count, actual_count)` 防越界 + - **Pillow 10+ Transpose 枚举**:`Image.FLIP_LEFT_RIGHT`(旧)已弃用,用 `Image.Transpose.FLIP_LEFT_RIGHT`(新) + - **`_make_step_fn` 闭包工厂**:`image_pipeline` 中若直接在循环内定义闭包,所有任务会捕获最后一次循环的 `input_path`/`output_path`/`params`;提取为独立函数 `_make_step_fn(base_fn, input_path, output_path, params)` 利用每次调用的独立作用域正确捕获 +4. **验证结果** — 贴上门禁输出(ruff/pyrefly/pytest 1560 passed/coverage 97.15%) +5. **遗留事项** — P11 候选方向(OCR/滤镜/批量文件夹/拼接GIF);`imagetool.py` 覆盖率 88%(ImportError 回退分支未覆盖,可接受) +6. **归档说明** — iter-22 暂不归档(下次清理窗口为 iter-26 前夕,与 iter-21 一并归档) + +### 步骤 2:同步 SKILL.md 行为变更 + +**修改 [.trae/skills/pyflowx-development/SKILL.md](file:///home/zhou/pyflowx/.trae/skills/pyflowx-development/SKILL.md)**: + +1. **更新 frontmatter description** — 末尾追加 "并同步迭代 22(P10)的行为变更",提及图片处理工具 +2. **更新开头说明**(第 8 行附近)— "并同步迭代 21(P9 功能扩展)与迭代 22(P10 图片处理)的行为变更" +3. **新增第十五章 `## 十五、图片处理(P10,iter-22)`**(追加在第十四 章之后),包含 4 个小节: + + #### 可复用模式 + - **可选依赖 try/except + `_require_*` 守卫**:顶部 `try: from PIL import Image; HAS_PIL = True; except ImportError: HAS_PIL = False`,每个子命令入口 `if not _require_pil(): return` 打印 `pip install pyflowx[office]` 提示 + - **`@px.tool` 装饰器注册子命令**:`@px.tool("imagetool", subcommand="r", help="调整尺寸")`,subcommand 短别名(r/c/ro/fl/cv/wm/cp/i/e/hi/co) + - **`image_pipeline` DAG 构造器**:参照 `compose()` 函数式构造器,每个 step 生成 `TaskSpec`,`depends_on` 链式依赖,`_make_step_fn` 闭包工厂正确捕获每轮参数 + - **`naming` 模板占位符**:`{stem}`(累积 stem)/`{step}`(操作名)/`{ext}`(当前扩展名,convert 步骤可能改变) + + #### 设计决策 + - **单一 `imagetool` + office extra**:不拆分为多个工具,所有图片操作集中在 `ops/files/imagetool.py`;Pillow 通过 `pyflowx[office]` extra 安装,零运行时依赖原则不破坏 + - **`_save_image` 统一保存逻辑**:JPG→JPEG 规范化 + JPEG 强制 RGB + JPEG/WEBP 应用 quality 参数;避免每个子命令重复处理 + - **`image_pipeline` 委托 imagetool 函数**:不重新实现图片操作,`operations` 字典映射操作名到 `imagetool.image_*` 函数,保持单一职责 + + #### 踩坑总结 + - **Pillow 10+ API 迁移**:`Image.FLIP_LEFT_RIGHT` 已弃用 → `Image.Transpose.FLIP_LEFT_RIGHT`;`bytes(exif)` 抛错 → `exif.tobytes()` + - **`quantize(colors=N)` 实际色数可能 < N**:简单图片量化后调色板少于请求色数,必须用 `actual_count = len(palette) // 3` 动态计算 + - **闭包捕获循环变量陷阱**:`image_pipeline` 中直接在循环内定义闭包会捕获最后一次循环的变量;提取 `_make_step_fn(base_fn, input_path, output_path, params)` 为独立函数利用每次调用的独立作用域 + - **PLR0912 分支数限制**:`image_exif` 函数最初 13 分支超过 12 限制,提取 `_print_exif`/`_apply_exif_modifications`/`_apply_single_exif_set`/`_save_exif` 4 个辅助函数降至合规 + +### 步骤 3:更新 project_memory.md + +**修改 [project_memory.md](file:///home/zhou/.trae-cn/memory/projects/-home-zhou-pyflowx/project_memory.md)**: + +1. **Engineering Conventions 末尾追加** P10 约定(约 8 条): + - `imagetool` 模式:try/except PIL 导入 + `HAS_PIL` 标志 + `_require_pil()` 守卫 + `@px.tool` 装饰器 + - `_save_image` 统一保存:JPG→JPEG 规范化 + JPEG 强制 RGB + JPEG/WEBP 应用 quality + - EXIF 序列化用 `exif.tobytes()`(Pillow 10+),不用 `bytes(exif)` + - `quantize(colors=N)` 实际色数可能 < N,用 `actual_count = len(palette) // 3` 动态计算 + - Pillow 10+ 用 `Image.Transpose.FLIP_LEFT_RIGHT`(非弃用的 `Image.FLIP_LEFT_RIGHT`) + - `image_pipeline` 用 `_make_step_fn(base_fn, input_path, output_path, params)` 闭包工厂避免循环变量捕获陷阱 + - `image_pipeline` 委托 `imagetool.image_*` 函数,不重新实现操作(单一职责) + - `pytest.importorskip("PIL")` 跳过未安装 Pillow 的测试模块 + +2. **Lessons Learned 末尾追加** iter-22 总结条目: + - `iter-22 (P10 image processing) completed: imagetool 11 subcommands (resize/crop/rotate/flip/convert/watermark/compress/info/exif/histogram/colors) + image_pipeline DAG constructor; 1560 tests green, coverage 97.15%; SKILL.md synced (added section 15); next archive window at iter-26` + +### 步骤 4:Git commit + push + +按文件名逐个 `git add`(不用 `git add .`/`-A`),然后 commit + push。 + +**暂存文件**(8 个): +``` +git add src/pyflowx/ops/files/imagetool.py +git add src/pyflowx/imaging.py +git add src/pyflowx/__init__.py +git add src/pyflowx/cli/pf.py +git add tests/cli/test_imagetool.py +git add tests/test_imaging.py +git add .trae/docs/iter-22-image-processing.md +git add .trae/skills/pyflowx-development/SKILL.md +``` + +**Commit message**(遵循 [.trae/rules/git-commit-message.md](file:///home/zhou/pyflowx/.trae/rules/git-commit-message.md) 中文风格,参照 `5ac7fd3` P9 提交): + +``` +feat: P10 图片处理 — imagetool 11 子命令(resize/crop/rotate/flip/convert/watermark/compress/info/exif/histogram/colors) + image_pipeline DAG 构造器;1560 测试全绿,覆盖率 97.15% +``` + +**Push**:`git push`(develop 跟踪 origin/develop,直接 push) + +## 关键文件 + +| 操作 | 文件 | +|------|------| +| 新建 | `.trae/docs/iter-22-image-processing.md`(iter-22 迭代记录) | +| 修改 | `.trae/skills/pyflowx-development/SKILL.md`(新增第十五章 + frontmatter 更新) | +| 修改 | `/home/zhou/.trae-cn/memory/projects/-home-zhou-pyflowx/project_memory.md`(追加 P10 约定) | +| 提交 | 上述 3 文件 + 已实现的 5 文件(imagetool.py/imaging.py/__init__.py/pf.py/2 测试文件) | + +## 复用的现有模式 + +- **iter-21 迭代记录结构**:[iter-21-p9-feature-extension.md](file:///home/zhou/pyflowx/.trae/docs/iter-21-p9-feature-extension.md)(本轮目标 / 改动文件清单 / 关键决策与依据 / 验证结果 / 遗留事项 / 归档说明) +- **SKILL.md 章节结构**:参照第十四章 `## 十四、任务编排与持久化(P9,iter-21)` 的 `### 可复用模式` / `### 设计决策` / `### 踩坑总结` 三段式 +- **commit message 风格**:参照 `5ac7fd3` P9 提交(`feat: P9 功能扩展 — ...;1525 测试全绿,覆盖率 97.58%`) +- **project_memory 条目风格**:参照第 125 行 iter-21 总结条目 + +## 验证步骤 + +1. **文档完整性**: + - `iter-22-image-processing.md` 包含全部 6 个小节(目标/文件/决策/验证/遗留/归档) + - `SKILL.md` 第十五章包含可复用模式/设计决策/踩坑总结三小节 + - `project_memory.md` Engineering Conventions 追加 8 条、Lessons Learned 追加 1 条 + +2. **Git 状态**: + - `git status` 干净(无未提交文件) + - `git log --oneline -1` 显示新 commit + - `git push` 成功(origin/develop 已更新) + +3. **门禁复跑**(可选,确保文档修改未误改代码): + ```bash + uvx --from pyflowx pymake tc # ruff + pyrefly + pytest + ``` + +## 验收标准 + +- `.trae/docs/iter-22-image-processing.md` 创建完成,结构对齐 iter-21 +- `.trae/skills/pyflowx-development/SKILL.md` 新增第十五章,frontmatter description 更新 +- `project_memory.md` 追加 P10 约定(Engineering Conventions + Lessons Learned) +- Git commit 成功,push 到 origin/develop 成功 +- `git status` 干净 + +## 不在范围内 + +- P11 高级功能(OCR/滤镜/批量文件夹/拼接GIF)— 用户已确认为后续阶段输入,本次不实现 +- 代码修改 — P10 代码已实现完成,本次仅文档同步 + 提交 +- 归档 — iter-22 暂不归档(下次清理窗口 iter-26 前夕,与 iter-21 一并归档到 SKILL.md) diff --git a/.trae/skills/pyflowx-development/SKILL.md b/.trae/skills/pyflowx-development/SKILL.md index 8c545d6..02b68ed 100644 --- a/.trae/skills/pyflowx-development/SKILL.md +++ b/.trae/skills/pyflowx-development/SKILL.md @@ -1,11 +1,12 @@ --- name: "pyflowx-development" -description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档迭代 06-20 中可复用的架构模式、踩坑总结与设计决策,并同步迭代 21(P9)的行为变更。在设计与调度器、CLI、YAML 编排、取消机制、序列化、观测性、错误诊断、状态后端、性能优化、任务编排、检查点恢复、监控导出相关的功能时参考。" +description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档迭代 06-20 中可复用的架构模式、踩坑总结与设计决策,并同步迭代 21(P9)与迭代 22(P10 图片处理)的行为变更。在设计与调度器、CLI、YAML 编排、取消机制、序列化、观测性、错误诊断、状态后端、性能优化、任务编排、检查点恢复、监控导出、图片处理相关的功能时参考。" --- # PyFlowX 开发知识库 -本技能归档自迭代 06-20 的过程记录,并同步迭代 21(P9 功能扩展)的行为变更。 +本技能归档自迭代 06-20 的过程记录,并同步迭代 21(P9 功能扩展)与 +迭代 22(P10 图片处理)的行为变更。 按主题分类整理可复用知识。过程性细节(覆盖率数字、命令输出)已剔除, 仅保留架构模式、设计依据与陷阱总结。 @@ -713,3 +714,71 @@ description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档 - **`metrics_text` 条件化输出**:最初实现总是输出 HELP/TYPE 行(即便无数据), 导致 `reset()` 后文本仍含 `pyflowx_task_total`,违反测试预期。所有 section 都应按数据存在性条件化,与 retries/run_total 一致。 + +## 十五、图片处理(P10,iter-22) + +### 可复用模式 + +- **可选依赖 try/except + `_require_*` 守卫**:模块顶部 + `try: from PIL import Image, ImageDraw, ImageFont; HAS_PIL = True; + except ImportError: HAS_PIL = False`,每个子命令入口 + `if not _require_pil(): return` 打印 `pip install pyflowx[office]` + 提示而非崩溃。与 `pdftool.py` 的 `pypdf` 守卫模式一致。 +- **`@px.tool` 装饰器注册子命令**:`@px.tool("imagetool", subcommand="r", + help="调整尺寸")`,subcommand 短别名(r/c/ro/fl/cv/wm/cp/i/e/hi/co) + 供 CLI 快捷调用(如 `pf img r input.png out.png --width 100`)。 +- **`image_pipeline` DAG 构造器**:参照 `compose()` 函数式构造器, + 每个 step 生成 `TaskSpec`,`depends_on` 链式依赖形成单链 DAG; + `operations` 字典映射操作名到 `imagetool.image_*` 函数,不重新实现操作。 +- **`naming` 模板占位符**:`{stem}`(累积 stem,每步更新为输出文件 stem)/ + `{step}`(操作名)/`{ext}`(当前扩展名,`convert` 步骤可能改变)。 + 默认 `"{stem}_{step}{ext}"`(如 `input_resize.jpg`)。 +- **`_make_step_fn` 闭包工厂**:提取为独立函数 + `_make_step_fn(base_fn, input_path, output_path, params)` 返回 `_run` + 闭包,利用每次调用的独立作用域正确捕获每轮参数,避免循环变量捕获陷阱。 +- **`pytest.importorskip("PIL")`**:测试模块顶部跳过未安装 Pillow 的环境, + 避免在未安装 `[office]` extra 的 CI 上失败。 + +### 设计决策 + +- **单一 `imagetool` + office extra**:不拆分为 `imageops`/`imagemeta` + 等多工具,所有图片操作集中在 `ops/files/imagetool.py`。Pillow 通过 + `pyproject.toml` 的 `[office]` extra 安装(`pillow>=10.4.0`), + 零运行时依赖原则不破坏。 +- **`_save_image` 统一保存逻辑**:所有需要保存图片的子命令委托 + `_save_image(img, output, fmt, quality)` 处理 JPG→JPEG 规范化 + + JPEG 强制 RGB 转换 + JPEG/WEBP 应用 quality 参数,避免每个子命令 + 重复处理格式与 alpha 通道问题。 +- **`image_pipeline` 委托 imagetool 函数**:不重新实现图片操作, + `operations` 字典映射操作名到 `imagetool.image_*` 函数,保持单一职责 + 与单一实现源。 +- **EXIF 用 `exif.tobytes()`**:Pillow 10+ 的 `Image.Exif` 对象 + `bytes(exif)` 抛 `ValueError`,必须用 `exif.tobytes()` 序列化。 + `_save_exif` 辅助函数封装此逻辑。 +- **PLR0912 分支数控制**:`image_exif` 函数最初 13 分支超过 12 限制, + 提取 `_print_exif`/`_apply_exif_modifications`/`_apply_single_exif_set`/ + `_save_exif` 4 个辅助函数降至合规。 + +### 踩坑总结 + +- **Pillow 10+ API 迁移**:`Image.FLIP_LEFT_RIGHT`(模块级常量)已弃用, + pyrefly 无法解析 → 改用 `Image.Transpose.FLIP_LEFT_RIGHT`(枚举形式); + `bytes(exif)` 抛 `ValueError: bytes must be in range(0, 256)` → + 改用 `exif.tobytes()`。 +- **`quantize(colors=N)` 实际色数可能 < N**:简单图片(如纯色)量化后 + 调色板可能少于请求色数,直接按 `count` 遍历会 `IndexError`。必须用 + `actual_count = len(palette) // 3` 动态计算并 `min(count, actual_count)`。 +- **JPEG 不支持 alpha 通道**:RGBA 模式图片直接保存为 JPEG 抛 + `cannot write mode RGBA as JPEG`。`_save_image` 中 JPEG 分支强制 + `img.convert("RGB")` 去 alpha。 +- **JPG 不是 Pillow 合法格式名**:`img.save(format="JPG")` 抛 + `KeyError: 'JPG'`。`_save_image` 中 `save_fmt == "JPG"` 时改写为 + `"JPEG"` 规范化。 +- **闭包捕获循环变量陷阱**:`image_pipeline` 中若直接在 for 循环内 + 定义闭包 `def _run(): base_fn(input_path, output_path, **params)`, + 所有任务的 `_run` 会捕获**最后一次循环**的变量。必须提取为独立函数 + `_make_step_fn(base_fn, input_path, output_path, params)` 利用每次 + 调用的独立作用域。 +- **`draw.textbbox()` 返回 float**:`text_w = bbox[2] - bbox[0]` 是 + float,传给 `_resolve_position`(期望 int)会触发类型警告。用 + `int(bbox[2] - bbox[0])` 显式转换。