@agent-webui/ai-desk-harness-gimp 1.0.29-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/manifest.json +22 -0
  3. package/package.json +11 -0
  4. package/python/agent-harness/GIMP.md +301 -0
  5. package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
  6. package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
  7. package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
  8. package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
  9. package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
  10. package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
  11. package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
  12. package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
  13. package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
  14. package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
  15. package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
  16. package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
  17. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
  18. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  19. package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
  20. package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
  21. package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
  22. package/python/agent-harness/cli_anything/gimp/README.md +202 -0
  23. package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
  24. package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
  25. package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
  26. package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
  27. package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
  28. package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
  29. package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
  30. package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
  31. package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
  32. package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
  33. package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
  34. package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
  35. package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
  36. package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
  37. package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  38. package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
  39. package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
  40. package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
  41. package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
  42. package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
  43. package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
  44. package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
  45. package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
  46. package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
  47. package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
  48. package/python/agent-harness/setup.py +54 -0
package/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # @agent-webui/ai-desk-harness-gimp
2
+
3
+ Vendored `gimp/agent-harness` package from `HKUDS/CLI-Anything`, packaged for
4
+ AI Desk installation flows.
package/manifest.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "gimp",
3
+ "display_name": "CLI-Anything GIMP Harness",
4
+ "module": "cli_anything.gimp",
5
+ "version": "1.0.0",
6
+ "source_path": "python/agent-harness",
7
+ "commands": [
8
+ "cli-anything-gimp"
9
+ ],
10
+ "python_requirements": [
11
+ "numpy>=2.0.0"
12
+ ],
13
+ "capabilities": [
14
+ "json-output",
15
+ "repl-mode",
16
+ "agent-native"
17
+ ],
18
+ "upstream": {
19
+ "repository": "https://github.com/HKUDS/CLI-Anything",
20
+ "subpath": "gimp/agent-harness"
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@agent-webui/ai-desk-harness-gimp",
3
+ "version": "1.0.29-beta1",
4
+ "description": "Vendored CLI-Anything GIMP harness for AI Desk",
5
+ "files": [
6
+ "manifest.json",
7
+ "python/agent-harness/",
8
+ "README.md"
9
+ ],
10
+ "license": "MIT"
11
+ }
@@ -0,0 +1,301 @@
1
+ # GIMP: Project-Specific Analysis & SOP
2
+
3
+ ## Architecture Summary
4
+
5
+ GIMP (GNU Image Manipulation Program) is a GTK-based raster image editor built on
6
+ the **GEGL** (Generic Graphics Library) processing engine and **Babl** color management.
7
+
8
+ ```
9
+ ┌──────────────────────────────────────────────┐
10
+ │ GIMP GUI │
11
+ │ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
12
+ │ │ Canvas │ │ Layers │ │ Filters │ │
13
+ │ │ (GTK) │ │ (GTK) │ │ (GTK) │ │
14
+ │ └────┬──────┘ └────┬─────┘ └──────┬──────┘ │
15
+ │ │ │ │ │
16
+ │ ┌────┴─────────────┴──────────────┴───────┐ │
17
+ │ │ PDB (Procedure Database) │ │
18
+ │ │ 500+ registered procedures for all │ │
19
+ │ │ image operations, filters, I/O │ │
20
+ │ └─────────────────┬───────────────────────┘ │
21
+ │ │ │
22
+ │ ┌─────────────────┴───────────────────────┐ │
23
+ │ │ GEGL Processing Engine │ │
24
+ │ │ DAG-based image processing pipeline │ │
25
+ │ │ 70+ built-in operations │ │
26
+ │ └─────────────────┬───────────────────────┘ │
27
+ └────────────────────┼─────────────────────────┘
28
+
29
+ ┌───────────┴──────────┐
30
+ │ Babl (color mgmt) │
31
+ │ + GEGL operations │
32
+ │ + File format I/O │
33
+ └──────────────────────┘
34
+ ```
35
+
36
+ ## CLI Strategy: Pillow + External Tools
37
+
38
+ Unlike Shotcut (which manipulates XML project files), GIMP's native .xcf format
39
+ is a complex binary format. Our strategy:
40
+
41
+ 1. **Pillow** — Python's standard imaging library. Handles image I/O (PNG, JPEG,
42
+ TIFF, BMP, GIF, WebP, etc.), pixel manipulation, basic filters, color
43
+ adjustments, drawing, and compositing. This is our primary engine.
44
+ 2. **GEGL CLI** — If available, use `gegl` command for advanced operations.
45
+ 3. **GIMP batch mode** — If `gimp` is installed, use `gimp -i -b` for XCF
46
+ operations and advanced filters via Script-Fu/Python-Fu.
47
+
48
+ ### Why Not XCF Directly?
49
+
50
+ XCF is a tile-based binary format with compression, layers, channels, paths,
51
+ and GEGL filter graphs. Parsing it from scratch is extremely complex (5000+ lines
52
+ of C in GIMP's xcf-load.c). Instead:
53
+ - For new projects, we build layer stacks in memory using Pillow
54
+ - For XCF import/export, we delegate to GIMP batch mode if available
55
+ - Our "project file" is a JSON manifest tracking layers, operations, and history
56
+
57
+ ## The Project Format (.gimp-cli.json)
58
+
59
+ Since we can't easily manipulate XCF directly, we use a JSON project format:
60
+
61
+ ```json
62
+ {
63
+ "version": "1.0",
64
+ "name": "my_project",
65
+ "canvas": {
66
+ "width": 1920,
67
+ "height": 1080,
68
+ "color_mode": "RGB",
69
+ "background": "#ffffff",
70
+ "dpi": 300
71
+ },
72
+ "layers": [
73
+ {
74
+ "id": 0,
75
+ "name": "Background",
76
+ "type": "image",
77
+ "source": "/path/to/image.png",
78
+ "visible": true,
79
+ "opacity": 1.0,
80
+ "blend_mode": "normal",
81
+ "offset_x": 0,
82
+ "offset_y": 0,
83
+ "filters": [
84
+ {"name": "brightness", "params": {"factor": 1.2}},
85
+ {"name": "gaussian_blur", "params": {"radius": 3}}
86
+ ]
87
+ },
88
+ {
89
+ "id": 1,
90
+ "name": "Text Layer",
91
+ "type": "text",
92
+ "text": "Hello World",
93
+ "font": "Arial",
94
+ "font_size": 48,
95
+ "color": "#000000",
96
+ "visible": true,
97
+ "opacity": 0.8,
98
+ "blend_mode": "normal",
99
+ "offset_x": 100,
100
+ "offset_y": 50,
101
+ "filters": []
102
+ }
103
+ ],
104
+ "selection": null,
105
+ "guides": [],
106
+ "metadata": {}
107
+ }
108
+ ```
109
+
110
+ ## Core Operations via Pillow
111
+
112
+ ### Image I/O
113
+ | Operation | Pillow API |
114
+ |-----------|-----------|
115
+ | Open image | `Image.open(path)` |
116
+ | Save image | `image.save(path, format)` |
117
+ | Create blank | `Image.new(mode, (w,h), color)` |
118
+ | Convert mode | `image.convert("RGB"/"L"/"RGBA")` |
119
+ | Resize | `image.resize((w,h), resample)` |
120
+ | Crop | `image.crop((l, t, r, b))` |
121
+ | Rotate | `image.rotate(angle, expand=True)` |
122
+ | Flip | `image.transpose(Image.FLIP_LEFT_RIGHT)` |
123
+
124
+ ### Filters & Adjustments
125
+ | Operation | Pillow API |
126
+ |-----------|-----------|
127
+ | Brightness | `ImageEnhance.Brightness(img).enhance(factor)` |
128
+ | Contrast | `ImageEnhance.Contrast(img).enhance(factor)` |
129
+ | Saturation | `ImageEnhance.Color(img).enhance(factor)` |
130
+ | Sharpness | `ImageEnhance.Sharpness(img).enhance(factor)` |
131
+ | Gaussian blur | `image.filter(ImageFilter.GaussianBlur(radius))` |
132
+ | Box blur | `image.filter(ImageFilter.BoxBlur(radius))` |
133
+ | Unsharp mask | `image.filter(ImageFilter.UnsharpMask(radius, percent, threshold))` |
134
+ | Find edges | `image.filter(ImageFilter.FIND_EDGES)` |
135
+ | Emboss | `image.filter(ImageFilter.EMBOSS)` |
136
+ | Contour | `image.filter(ImageFilter.CONTOUR)` |
137
+ | Detail | `image.filter(ImageFilter.DETAIL)` |
138
+ | Smooth | `image.filter(ImageFilter.SMOOTH_MORE)` |
139
+ | Grayscale | `ImageOps.grayscale(image)` |
140
+ | Invert | `ImageOps.invert(image)` |
141
+ | Posterize | `ImageOps.posterize(image, bits)` |
142
+ | Solarize | `ImageOps.solarize(image, threshold)` |
143
+ | Autocontrast | `ImageOps.autocontrast(image)` |
144
+ | Equalize | `ImageOps.equalize(image)` |
145
+ | Sepia | Custom kernel via `ImageOps.colorize()` |
146
+
147
+ ### Compositing & Drawing
148
+ | Operation | Pillow API |
149
+ |-----------|-----------|
150
+ | Paste layer | `Image.alpha_composite(base, overlay)` |
151
+ | Blend modes | Custom implementations (multiply, screen, overlay, etc.) |
152
+ | Draw rectangle | `ImageDraw.rectangle(xy, fill, outline)` |
153
+ | Draw ellipse | `ImageDraw.ellipse(xy, fill, outline)` |
154
+ | Draw text | `ImageDraw.text(xy, text, font, fill)` |
155
+ | Draw line | `ImageDraw.line(xy, fill, width)` |
156
+
157
+ ## Blend Modes
158
+
159
+ Pillow doesn't natively support Photoshop/GIMP blend modes. We implement the
160
+ most common ones using NumPy-style pixel math:
161
+
162
+ | Mode | Formula |
163
+ |------|---------|
164
+ | Normal | `top` (with alpha compositing) |
165
+ | Multiply | `base * top / 255` |
166
+ | Screen | `255 - (255-base)*(255-top)/255` |
167
+ | Overlay | `if base < 128: 2*base*top/255 else: 255 - 2*(255-base)*(255-top)/255` |
168
+ | Soft Light | Photoshop-style formula |
169
+ | Hard Light | Overlay with base/top swapped |
170
+ | Difference | `abs(base - top)` |
171
+ | Darken | `min(base, top)` |
172
+ | Lighten | `max(base, top)` |
173
+ | Color Dodge | `base / (255 - top) * 255` |
174
+ | Color Burn | `255 - (255-base) / top * 255` |
175
+
176
+ ## Command Map: GUI Action -> CLI Command
177
+
178
+ | GUI Action | CLI Command |
179
+ |-----------|-------------|
180
+ | File -> New | `project new --width 1920 --height 1080 [--mode RGB]` |
181
+ | File -> Open | `project open <path>` |
182
+ | File -> Save | `project save [path]` |
183
+ | File -> Export As | `export render <output> [--format png] [--quality 95]` |
184
+ | Image -> Canvas Size | `canvas resize --width W --height H` |
185
+ | Image -> Scale Image | `canvas scale --width W --height H` |
186
+ | Image -> Crop to Selection | `canvas crop --left L --top T --right R --bottom B` |
187
+ | Image -> Mode -> RGB | `canvas mode RGB` |
188
+ | Layer -> New Layer | `layer new [--name "Layer"] [--width W] [--height H]` |
189
+ | Layer -> Duplicate | `layer duplicate <index>` |
190
+ | Layer -> Delete | `layer remove <index>` |
191
+ | Layer -> Flatten Image | `layer flatten` |
192
+ | Layer -> Merge Down | `layer merge-down <index>` |
193
+ | Move layer | `layer move <index> --to <position>` |
194
+ | Set layer opacity | `layer set <index> opacity <value>` |
195
+ | Set blend mode | `layer set <index> mode <mode>` |
196
+ | Toggle visibility | `layer set <index> visible <true/false>` |
197
+ | Layer -> Add from File | `layer add-from-file <path> [--name N] [--position P]` |
198
+ | Filters -> Blur -> Gaussian | `filter add gaussian_blur --layer L --param radius=5` |
199
+ | Colors -> Brightness-Contrast | `filter add brightness --layer L --param factor=1.2` |
200
+ | Colors -> Hue-Saturation | `filter add saturation --layer L --param factor=1.3` |
201
+ | Colors -> Invert | `filter add invert --layer L` |
202
+ | Draw text on layer | `draw text --layer L --text "Hi" --x 10 --y 10 --font Arial --size 24` |
203
+ | Draw rectangle | `draw rect --layer L --x1 0 --y1 0 --x2 100 --y2 100 --fill "#ff0000"` |
204
+ | View layers | `layer list` |
205
+ | View project info | `project info` |
206
+ | Undo | `session undo` |
207
+ | Redo | `session redo` |
208
+
209
+ ## Filter Registry
210
+
211
+ ### Image Adjustments
212
+ | CLI Name | Pillow Implementation | Key Parameters |
213
+ |----------|----------------------|----------------|
214
+ | `brightness` | `ImageEnhance.Brightness` | `factor` (1.0 = neutral, >1 = brighter) |
215
+ | `contrast` | `ImageEnhance.Contrast` | `factor` (1.0 = neutral) |
216
+ | `saturation` | `ImageEnhance.Color` | `factor` (1.0 = neutral, 0 = grayscale) |
217
+ | `sharpness` | `ImageEnhance.Sharpness` | `factor` (1.0 = neutral, >1 = sharper) |
218
+ | `autocontrast` | `ImageOps.autocontrast` | `cutoff` (0-49, percent to clip) |
219
+ | `equalize` | `ImageOps.equalize` | (no params) |
220
+ | `invert` | `ImageOps.invert` | (no params) |
221
+ | `posterize` | `ImageOps.posterize` | `bits` (1-8) |
222
+ | `solarize` | `ImageOps.solarize` | `threshold` (0-255) |
223
+ | `grayscale` | `ImageOps.grayscale` | (no params) |
224
+ | `sepia` | Custom colorize | `strength` (0.0-1.0) |
225
+
226
+ ### Blur & Sharpen
227
+ | CLI Name | Pillow Implementation | Key Parameters |
228
+ |----------|----------------------|----------------|
229
+ | `gaussian_blur` | `ImageFilter.GaussianBlur` | `radius` (pixels) |
230
+ | `box_blur` | `ImageFilter.BoxBlur` | `radius` (pixels) |
231
+ | `unsharp_mask` | `ImageFilter.UnsharpMask` | `radius`, `percent`, `threshold` |
232
+ | `smooth` | `ImageFilter.SMOOTH_MORE` | (no params) |
233
+
234
+ ### Stylize
235
+ | CLI Name | Pillow Implementation | Key Parameters |
236
+ |----------|----------------------|----------------|
237
+ | `find_edges` | `ImageFilter.FIND_EDGES` | (no params) |
238
+ | `emboss` | `ImageFilter.EMBOSS` | (no params) |
239
+ | `contour` | `ImageFilter.CONTOUR` | (no params) |
240
+ | `detail` | `ImageFilter.DETAIL` | (no params) |
241
+
242
+ ### Transform
243
+ | CLI Name | Pillow Implementation | Key Parameters |
244
+ |----------|----------------------|----------------|
245
+ | `rotate` | `Image.rotate` | `angle` (degrees), `expand` (bool) |
246
+ | `flip_h` | `Image.transpose(FLIP_LEFT_RIGHT)` | (no params) |
247
+ | `flip_v` | `Image.transpose(FLIP_TOP_BOTTOM)` | (no params) |
248
+ | `resize` | `Image.resize` | `width`, `height`, `resample` (nearest/bilinear/bicubic/lanczos) |
249
+ | `crop` | `Image.crop` | `left`, `top`, `right`, `bottom` |
250
+
251
+ ## Export Formats
252
+
253
+ | Format | Extension | Quality Param | Notes |
254
+ |--------|-----------|---------------|-------|
255
+ | PNG | .png | `compress_level` (0-9) | Lossless, supports alpha |
256
+ | JPEG | .jpg/.jpeg | `quality` (1-95) | Lossy, no alpha |
257
+ | WebP | .webp | `quality` (1-100) | Both lossy/lossless |
258
+ | TIFF | .tiff | `compression` (none/lzw/jpeg) | Professional |
259
+ | BMP | .bmp | (none) | Uncompressed |
260
+ | GIF | .gif | (none) | 256 colors max |
261
+ | ICO | .ico | (none) | Icon format |
262
+ | PDF | .pdf | (none) | Multi-page possible |
263
+
264
+ ## Rendering Pipeline
265
+
266
+ For GIMP CLI, "rendering" means flattening the layer stack with all filters
267
+ applied and exporting to a target format.
268
+
269
+ ### Pipeline Steps:
270
+ 1. Start with canvas (background color or transparent)
271
+ 2. For each visible layer (bottom to top):
272
+ a. Load/create the layer content
273
+ b. Apply all layer filters in order
274
+ c. Position at layer offset
275
+ d. Composite onto canvas using blend mode and opacity
276
+ 3. Export final composited image
277
+
278
+ ### Rendering Gap Assessment: **Medium**
279
+ - Most operations (resize, crop, filters, compositing) work via Pillow directly
280
+ - Advanced GEGL operations (high-pass filter, wavelet decompose) not available
281
+ - No XCF round-trip without GIMP installed
282
+ - Blend modes require custom implementation but are mathematically straightforward
283
+
284
+ ## Test Coverage Plan
285
+
286
+ 1. **Unit tests** (`test_core.py`): Synthetic data, no real images needed
287
+ - Project create/open/save/info
288
+ - Layer add/remove/reorder/properties
289
+ - Filter application and parameter validation
290
+ - Canvas operations (resize, scale, crop, mode conversion)
291
+ - Session undo/redo
292
+ - JSON project serialization/deserialization
293
+
294
+ 2. **E2E tests** (`test_full_e2e.py`): Real images
295
+ - Full workflow: create project, add layers, apply filters, export
296
+ - Format conversion (PNG->JPEG, etc.)
297
+ - Blend mode compositing verification
298
+ - Filter effect pixel-level verification
299
+ - Multi-layer compositing
300
+ - Text rendering
301
+ - CLI subprocess invocation
@@ -0,0 +1 @@
1
+ """GIMP CLI - A stateful CLI for image editing."""
@@ -0,0 +1,3 @@
1
+ """Allow running as python3 -m cli.gimp_cli"""
2
+ from cli_anything.gimp.gimp_cli import main
3
+ main()
@@ -0,0 +1 @@
1
+ """GIMP CLI - Core modules."""
@@ -0,0 +1,193 @@
1
+ """GIMP CLI - Canvas operations module."""
2
+
3
+ from typing import Dict, Any
4
+
5
+
6
+ VALID_MODES = ("RGB", "RGBA", "L", "LA", "CMYK", "P")
7
+ RESAMPLE_METHODS = ("nearest", "bilinear", "bicubic", "lanczos")
8
+
9
+
10
+ def resize_canvas(
11
+ project: Dict[str, Any],
12
+ width: int,
13
+ height: int,
14
+ anchor: str = "center",
15
+ ) -> Dict[str, Any]:
16
+ """Resize the canvas (does not scale content, adds/removes space).
17
+
18
+ Args:
19
+ project: The project dict
20
+ width: New canvas width
21
+ height: New canvas height
22
+ anchor: Where to anchor existing content:
23
+ "center", "top-left", "top-right", "bottom-left", "bottom-right",
24
+ "top", "bottom", "left", "right"
25
+ """
26
+ if width < 1 or height < 1:
27
+ raise ValueError(f"Canvas dimensions must be positive: {width}x{height}")
28
+
29
+ valid_anchors = [
30
+ "center", "top-left", "top-right", "bottom-left", "bottom-right",
31
+ "top", "bottom", "left", "right",
32
+ ]
33
+ if anchor not in valid_anchors:
34
+ raise ValueError(f"Invalid anchor: {anchor}. Valid: {valid_anchors}")
35
+
36
+ old_w = project["canvas"]["width"]
37
+ old_h = project["canvas"]["height"]
38
+
39
+ # Calculate offset for existing layers based on anchor
40
+ dx, dy = _anchor_offset(old_w, old_h, width, height, anchor)
41
+
42
+ project["canvas"]["width"] = width
43
+ project["canvas"]["height"] = height
44
+
45
+ # Adjust layer offsets
46
+ for layer in project.get("layers", []):
47
+ layer["offset_x"] = layer.get("offset_x", 0) + dx
48
+ layer["offset_y"] = layer.get("offset_y", 0) + dy
49
+
50
+ return {
51
+ "old_size": f"{old_w}x{old_h}",
52
+ "new_size": f"{width}x{height}",
53
+ "anchor": anchor,
54
+ "offset_applied": f"({dx}, {dy})",
55
+ }
56
+
57
+
58
+ def scale_canvas(
59
+ project: Dict[str, Any],
60
+ width: int,
61
+ height: int,
62
+ resample: str = "lanczos",
63
+ ) -> Dict[str, Any]:
64
+ """Scale the canvas and all layers proportionally.
65
+
66
+ This marks layers for rescaling at render time.
67
+ """
68
+ if width < 1 or height < 1:
69
+ raise ValueError(f"Canvas dimensions must be positive: {width}x{height}")
70
+ if resample not in RESAMPLE_METHODS:
71
+ raise ValueError(f"Invalid resample method: {resample}. Valid: {list(RESAMPLE_METHODS)}")
72
+
73
+ old_w = project["canvas"]["width"]
74
+ old_h = project["canvas"]["height"]
75
+ scale_x = width / old_w
76
+ scale_y = height / old_h
77
+
78
+ project["canvas"]["width"] = width
79
+ project["canvas"]["height"] = height
80
+
81
+ # Mark layers for proportional scaling
82
+ for layer in project.get("layers", []):
83
+ layer["_scale_x"] = scale_x
84
+ layer["_scale_y"] = scale_y
85
+ layer["_resample"] = resample
86
+ layer["offset_x"] = round(layer.get("offset_x", 0) * scale_x)
87
+ layer["offset_y"] = round(layer.get("offset_y", 0) * scale_y)
88
+ if "width" in layer:
89
+ layer["width"] = round(layer["width"] * scale_x)
90
+ if "height" in layer:
91
+ layer["height"] = round(layer["height"] * scale_y)
92
+
93
+ return {
94
+ "old_size": f"{old_w}x{old_h}",
95
+ "new_size": f"{width}x{height}",
96
+ "scale": f"({scale_x:.3f}, {scale_y:.3f})",
97
+ "resample": resample,
98
+ }
99
+
100
+
101
+ def crop_canvas(
102
+ project: Dict[str, Any],
103
+ left: int,
104
+ top: int,
105
+ right: int,
106
+ bottom: int,
107
+ ) -> Dict[str, Any]:
108
+ """Crop the canvas to a rectangle."""
109
+ if left < 0 or top < 0:
110
+ raise ValueError(f"Crop coordinates must be non-negative: left={left}, top={top}")
111
+ if right <= left or bottom <= top:
112
+ raise ValueError(f"Invalid crop region: ({left},{top})-({right},{bottom})")
113
+
114
+ old_w = project["canvas"]["width"]
115
+ old_h = project["canvas"]["height"]
116
+
117
+ if right > old_w or bottom > old_h:
118
+ raise ValueError(
119
+ f"Crop region ({left},{top})-({right},{bottom}) exceeds canvas {old_w}x{old_h}"
120
+ )
121
+
122
+ new_w = right - left
123
+ new_h = bottom - top
124
+
125
+ project["canvas"]["width"] = new_w
126
+ project["canvas"]["height"] = new_h
127
+
128
+ # Adjust layer offsets
129
+ for layer in project.get("layers", []):
130
+ layer["offset_x"] = layer.get("offset_x", 0) - left
131
+ layer["offset_y"] = layer.get("offset_y", 0) - top
132
+
133
+ return {
134
+ "old_size": f"{old_w}x{old_h}",
135
+ "new_size": f"{new_w}x{new_h}",
136
+ "crop_region": f"({left},{top})-({right},{bottom})",
137
+ }
138
+
139
+
140
+ def set_mode(project: Dict[str, Any], mode: str) -> Dict[str, Any]:
141
+ """Set the canvas color mode."""
142
+ mode = mode.upper()
143
+ if mode not in VALID_MODES:
144
+ raise ValueError(f"Invalid color mode: {mode}. Valid: {list(VALID_MODES)}")
145
+ old_mode = project["canvas"].get("color_mode", "RGB")
146
+ project["canvas"]["color_mode"] = mode
147
+ return {"old_mode": old_mode, "new_mode": mode}
148
+
149
+
150
+ def set_dpi(project: Dict[str, Any], dpi: int) -> Dict[str, Any]:
151
+ """Set the canvas DPI (dots per inch)."""
152
+ if dpi < 1:
153
+ raise ValueError(f"DPI must be positive: {dpi}")
154
+ old_dpi = project["canvas"].get("dpi", 72)
155
+ project["canvas"]["dpi"] = dpi
156
+ return {"old_dpi": old_dpi, "new_dpi": dpi}
157
+
158
+
159
+ def get_canvas_info(project: Dict[str, Any]) -> Dict[str, Any]:
160
+ """Get canvas information."""
161
+ c = project["canvas"]
162
+ w, h = c["width"], c["height"]
163
+ dpi = c.get("dpi", 72)
164
+ return {
165
+ "width": w,
166
+ "height": h,
167
+ "color_mode": c.get("color_mode", "RGB"),
168
+ "background": c.get("background", "#ffffff"),
169
+ "dpi": dpi,
170
+ "size_inches": f"{w/dpi:.2f} x {h/dpi:.2f}",
171
+ "megapixels": f"{w * h / 1_000_000:.2f} MP",
172
+ }
173
+
174
+
175
+ def _anchor_offset(
176
+ old_w: int, old_h: int, new_w: int, new_h: int, anchor: str
177
+ ) -> tuple:
178
+ """Calculate pixel offset for content based on anchor position."""
179
+ dx_map = {
180
+ "top-left": 0, "left": 0, "bottom-left": 0,
181
+ "top": (new_w - old_w) // 2, "center": (new_w - old_w) // 2,
182
+ "bottom": (new_w - old_w) // 2,
183
+ "top-right": new_w - old_w, "right": new_w - old_w,
184
+ "bottom-right": new_w - old_w,
185
+ }
186
+ dy_map = {
187
+ "top-left": 0, "top": 0, "top-right": 0,
188
+ "left": (new_h - old_h) // 2, "center": (new_h - old_h) // 2,
189
+ "right": (new_h - old_h) // 2,
190
+ "bottom-left": new_h - old_h, "bottom": new_h - old_h,
191
+ "bottom-right": new_h - old_h,
192
+ }
193
+ return dx_map.get(anchor, 0), dy_map.get(anchor, 0)