psforge 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,124 @@
1
+ """Batch tools - execute multiple JS operations in a single COM round trip."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from psforge.decorators import debug_tool, log_tool_call
8
+ from psforge.ps_adapter.application import PhotoshopApp
9
+ from psforge.ps_adapter.utils import js_escape_string
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all batch tools with MCP server."""
15
+ registered_tools = []
16
+
17
+ @debug_tool
18
+ @log_tool_call
19
+ def execute_batch(scripts: list[str]) -> dict[str, Any]:
20
+ """Execute multiple ExtendScript snippets in a single COM call.
21
+
22
+ Each snippet runs sequentially; results are collected into an array.
23
+ If any snippet fails, subsequent snippets still execute and the
24
+ error is captured in the results array.
25
+
26
+ Args:
27
+ scripts: List of JavaScript/ExtendScript code strings.
28
+
29
+ Returns:
30
+ dict: {success, results: [{index, success, result/error}, ...]}
31
+ """
32
+ if not scripts:
33
+ return {"success": False, "error": "scripts list is empty"}
34
+
35
+ ps_app = PhotoshopApp()
36
+
37
+ # Build a wrapper script that runs all snippets and collects results
38
+ parts = []
39
+ for i, script in enumerate(scripts):
40
+ parts.append(f"""
41
+ try {{
42
+ var _r{i} = (function(){{ {script} }})();
43
+ _results.push({{index:{i}, success:true, result:String(_r{i})}});
44
+ }} catch(_e{i}) {{
45
+ _results.push({{index:{i}, success:false, error:_e{i}.toString()}});
46
+ }}""")
47
+
48
+ wrapper = "(function(){var _results=[];" + "".join(parts) + "return JSON.stringify(_results);})();"
49
+
50
+ try:
51
+ raw = ps_app.execute_javascript(wrapper)
52
+
53
+ import json
54
+ results = json.loads(raw) if isinstance(raw, str) else raw
55
+
56
+ all_ok = all(r.get("success", False) for r in results)
57
+ return {
58
+ "success": all_ok,
59
+ "message": f"Executed {len(scripts)} scripts ({sum(1 for r in results if r.get('success'))} succeeded)",
60
+ "results": results,
61
+ }
62
+
63
+ except Exception as e:
64
+ logger.error(f"Batch execution failed: {e}")
65
+ return {"success": False, "error": str(e)}
66
+
67
+ @debug_tool
68
+ @log_tool_call
69
+ def select_layer_by_name(layer_name: str) -> dict[str, Any]:
70
+ """Select (activate) a layer by its name.
71
+
72
+ Args:
73
+ layer_name: Name of the layer to activate.
74
+
75
+ Returns:
76
+ dict: Operation result.
77
+ """
78
+ ps_app = PhotoshopApp()
79
+ doc = ps_app.get_active_document()
80
+
81
+ if not doc:
82
+ return {"success": False, "error": "No active document"}
83
+
84
+ try:
85
+ name_escaped = js_escape_string(layer_name)
86
+ script = f"""
87
+ (function() {{
88
+ var doc = app.activeDocument;
89
+ var found = false;
90
+
91
+ function searchLayers(layers) {{
92
+ for (var i = 0; i < layers.length; i++) {{
93
+ if (layers[i].name === "{name_escaped}") {{
94
+ doc.activeLayer = layers[i];
95
+ return true;
96
+ }}
97
+ if (layers[i].typename === "LayerSet") {{
98
+ if (searchLayers(layers[i].layers)) return true;
99
+ }}
100
+ }}
101
+ return false;
102
+ }}
103
+
104
+ found = searchLayers(doc.layers);
105
+ if (!found) throw new Error("Layer not found: {name_escaped}");
106
+ return doc.activeLayer.name;
107
+ }})();
108
+ """
109
+
110
+ result = ps_app.execute_javascript(script)
111
+ return {
112
+ "success": True,
113
+ "message": f"Activated layer '{result}'",
114
+ "layer_name": result,
115
+ }
116
+
117
+ except Exception as e:
118
+ logger.error(f"Failed to select layer: {e}")
119
+ return {"success": False, "error": str(e)}
120
+
121
+ registered_tools.append(register_tool(mcp, execute_batch, "execute_batch"))
122
+ registered_tools.append(register_tool(mcp, select_layer_by_name, "select_layer_by_name"))
123
+
124
+ return registered_tools
@@ -0,0 +1,341 @@
1
+ """Document management tools - create, open, save, close, crop."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+ from photoshop.api._document import Document
7
+
8
+ from psforge.decorators import debug_tool, log_tool_call
9
+ from psforge.ps_adapter.application import PhotoshopApp
10
+ from psforge.ps_adapter.utils import js_escape_string, validate_numeric_range
11
+ from psforge.registry import register_tool
12
+
13
+
14
+ def register(mcp) -> list[str]:
15
+ """Register all document tools with MCP server.
16
+
17
+ Args:
18
+ mcp: MCP server instance.
19
+
20
+ Returns:
21
+ List of registered tool names.
22
+ """
23
+ registered_tools = []
24
+
25
+ @debug_tool
26
+ @log_tool_call
27
+ def create_document(
28
+ width: int,
29
+ height: int,
30
+ resolution: float = 72.0,
31
+ name: str = "Untitled",
32
+ color_mode: str = "RGB",
33
+ ) -> dict[str, Any]:
34
+ """Create a new Photoshop document.
35
+
36
+ Args:
37
+ width: Document width in pixels (1-300000).
38
+ height: Document height in pixels (1-300000).
39
+ resolution: Document resolution in DPI (1-999), default 72.
40
+ name: Document name, default "Untitled".
41
+ color_mode: Color mode - RGB, CMYK, GRAYSCALE, LAB, BITMAP (default: RGB).
42
+
43
+ Returns:
44
+ dict: Operation result with document info and context.
45
+ """
46
+ # Validate parameters
47
+ validate_numeric_range(width, 1, 300000, "width")
48
+ validate_numeric_range(height, 1, 300000, "height")
49
+ validate_numeric_range(resolution, 1, 999, "resolution")
50
+
51
+ color_mode = color_mode.upper()
52
+ valid_modes = ["RGB", "CMYK", "GRAYSCALE", "LAB", "BITMAP"]
53
+ if color_mode not in valid_modes:
54
+ return {
55
+ "success": False,
56
+ "error": f"Invalid color_mode '{color_mode}'. Must be one of: {', '.join(valid_modes)}",
57
+ }
58
+
59
+ ps_app = PhotoshopApp()
60
+
61
+ try:
62
+ # Use photoshop-python-api to create document
63
+ from photoshop.api._artlayer import ArtLayer
64
+
65
+ # Map color mode string to PS constant
66
+ color_mode_map = {
67
+ "RGB": 4, # NewDocumentMode.RGB
68
+ "CMYK": 5, # NewDocumentMode.CMYK
69
+ "GRAYSCALE": 2, # NewDocumentMode.GRAYSCALE
70
+ "LAB": 9, # NewDocumentMode.LAB
71
+ "BITMAP": 1, # NewDocumentMode.BITMAP
72
+ }
73
+
74
+ mode_value = color_mode_map.get(color_mode, 4)
75
+
76
+ # Create document via JavaScript for better control
77
+ create_script = f"""
78
+ var docRef = app.documents.add({width}, {height}, {resolution}, "{js_escape_string(name)}", NewDocumentMode.{color_mode});
79
+ docRef.name;
80
+ """
81
+
82
+ result = ps_app.execute_javascript(create_script)
83
+
84
+ return {
85
+ "success": True,
86
+ "message": f"Created document '{name}' ({width}x{height}px, {resolution}dpi, {color_mode})",
87
+ "document_name": name,
88
+ "width": width,
89
+ "height": height,
90
+ "resolution": resolution,
91
+ "color_mode": color_mode,
92
+ }
93
+
94
+ except Exception as e:
95
+ logger.error(f"Failed to create document: {e}")
96
+ return {
97
+ "success": False,
98
+ "error": str(e),
99
+ }
100
+
101
+ @debug_tool
102
+ @log_tool_call
103
+ def open_image(file_path: str) -> dict[str, Any]:
104
+ """Open an image file as a new Photoshop document.
105
+
106
+ Args:
107
+ file_path: Full path to the image file to open.
108
+
109
+ Returns:
110
+ dict: Operation result with opened document info and context.
111
+ """
112
+ import os
113
+
114
+ if not os.path.exists(file_path):
115
+ return {
116
+ "success": False,
117
+ "error": f"File not found: {file_path}",
118
+ }
119
+
120
+ ps_app = PhotoshopApp()
121
+
122
+ try:
123
+ # Convert to Windows path format and escape
124
+ file_path_escaped = file_path.replace("\\", "\\\\")
125
+
126
+ open_script = f"""
127
+ var fileRef = new File("{file_path_escaped}");
128
+ var docRef = app.open(fileRef);
129
+ docRef.name;
130
+ """
131
+
132
+ doc_name = ps_app.execute_javascript(open_script)
133
+
134
+ return {
135
+ "success": True,
136
+ "message": f"Opened image: {doc_name}",
137
+ "file_path": file_path,
138
+ "document_name": doc_name,
139
+ }
140
+
141
+ except Exception as e:
142
+ logger.error(f"Failed to open image: {e}")
143
+ return {
144
+ "success": False,
145
+ "error": str(e),
146
+ "file_path": file_path,
147
+ }
148
+
149
+ @debug_tool
150
+ @log_tool_call
151
+ def save_document(
152
+ path: str,
153
+ format: str = "psd",
154
+ quality: int = 10,
155
+ ) -> dict[str, Any]:
156
+ """Save the active document.
157
+
158
+ Args:
159
+ path: Full file path where to save (without extension).
160
+ format: File format - psd, jpg, png (default: psd).
161
+ quality: JPEG quality 1-12 (only for jpg), default 10.
162
+
163
+ Returns:
164
+ dict: Operation result with save info and context.
165
+ """
166
+ ps_app = PhotoshopApp()
167
+ doc = ps_app.get_active_document()
168
+
169
+ if not doc:
170
+ return {
171
+ "success": False,
172
+ "error": "No active document to save",
173
+ }
174
+
175
+ format = format.lower()
176
+ if format not in ["psd", "jpg", "png"]:
177
+ return {
178
+ "success": False,
179
+ "error": f"Invalid format '{format}'. Must be: psd, jpg, or png",
180
+ }
181
+
182
+ if format == "jpg":
183
+ validate_numeric_range(quality, 1, 12, "quality")
184
+
185
+ try:
186
+ path_escaped = path.replace("\\", "\\\\")
187
+
188
+ if format == "psd":
189
+ save_script = f"""
190
+ var saveFile = new File("{path_escaped}.psd");
191
+ var psdOptions = new PhotoshopSaveOptions();
192
+ psdOptions.embedColorProfile = true;
193
+ psdOptions.alphaChannels = true;
194
+ psdOptions.layers = true;
195
+ app.activeDocument.saveAs(saveFile, psdOptions, true);
196
+ "{path_escaped}.psd";
197
+ """
198
+
199
+ elif format == "jpg":
200
+ save_script = f"""
201
+ var saveFile = new File("{path_escaped}.jpg");
202
+ var jpgOptions = new JPEGSaveOptions();
203
+ jpgOptions.quality = {quality};
204
+ jpgOptions.embedColorProfile = true;
205
+ app.activeDocument.saveAs(saveFile, jpgOptions, true);
206
+ "{path_escaped}.jpg";
207
+ """
208
+
209
+ elif format == "png":
210
+ save_script = f"""
211
+ var saveFile = new File("{path_escaped}.png");
212
+ var pngOptions = new PNGSaveOptions();
213
+ pngOptions.compression = 6;
214
+ pngOptions.interlaced = false;
215
+ app.activeDocument.saveAs(saveFile, pngOptions, true);
216
+ "{path_escaped}.png";
217
+ """
218
+
219
+ saved_path = ps_app.execute_javascript(save_script)
220
+
221
+ return {
222
+ "success": True,
223
+ "message": f"Document saved as {format.upper()}",
224
+ "saved_path": saved_path,
225
+ "format": format,
226
+ }
227
+
228
+ except Exception as e:
229
+ logger.error(f"Failed to save document: {e}")
230
+ return {
231
+ "success": False,
232
+ "error": str(e),
233
+ }
234
+
235
+ @debug_tool
236
+ @log_tool_call
237
+ def close_document(save: bool = False) -> dict[str, Any]:
238
+ """Close the active document.
239
+
240
+ Args:
241
+ save: Whether to save before closing (default: False).
242
+
243
+ Returns:
244
+ dict: Operation result and context.
245
+ """
246
+ ps_app = PhotoshopApp()
247
+ doc = ps_app.get_active_document()
248
+
249
+ if not doc:
250
+ return {
251
+ "success": False,
252
+ "error": "No active document to close",
253
+ }
254
+
255
+ try:
256
+ save_option = "SaveOptions.SAVECHANGES" if save else "SaveOptions.DONOTSAVECHANGES"
257
+
258
+ close_script = f"""
259
+ app.activeDocument.close({save_option});
260
+ "Document closed";
261
+ """
262
+
263
+ ps_app.execute_javascript(close_script)
264
+
265
+ return {
266
+ "success": True,
267
+ "message": "Document closed" + (" and saved" if save else ""),
268
+ "saved": save,
269
+ }
270
+
271
+ except Exception as e:
272
+ logger.error(f"Failed to close document: {e}")
273
+ return {
274
+ "success": False,
275
+ "error": str(e),
276
+ }
277
+
278
+ @debug_tool
279
+ @log_tool_call
280
+ def crop_document(top: int, left: int, bottom: int, right: int) -> dict[str, Any]:
281
+ """Crop the active document to specified bounds.
282
+
283
+ Args:
284
+ top: Top edge position in pixels.
285
+ left: Left edge position in pixels.
286
+ bottom: Bottom edge position in pixels.
287
+ right: Right edge position in pixels.
288
+
289
+ Returns:
290
+ dict: Operation result and context.
291
+ """
292
+ ps_app = PhotoshopApp()
293
+ doc = ps_app.get_active_document()
294
+
295
+ if not doc:
296
+ return {
297
+ "success": False,
298
+ "error": "No active document to crop",
299
+ }
300
+
301
+ # Validate bounds
302
+ if left >= right:
303
+ return {"success": False, "error": "left must be < right"}
304
+ if top >= bottom:
305
+ return {"success": False, "error": "top must be < bottom"}
306
+
307
+ try:
308
+ crop_script = f"""
309
+ var bounds = [{left}, {top}, {right}, {bottom}];
310
+ app.activeDocument.crop(bounds);
311
+ "Document cropped";
312
+ """
313
+
314
+ ps_app.execute_javascript(crop_script)
315
+
316
+ new_width = right - left
317
+ new_height = bottom - top
318
+
319
+ return {
320
+ "success": True,
321
+ "message": f"Document cropped to {new_width}x{new_height}px",
322
+ "new_width": new_width,
323
+ "new_height": new_height,
324
+ "bounds": {"top": top, "left": left, "bottom": bottom, "right": right},
325
+ }
326
+
327
+ except Exception as e:
328
+ logger.error(f"Failed to crop document: {e}")
329
+ return {
330
+ "success": False,
331
+ "error": str(e),
332
+ }
333
+
334
+ # Register all tools
335
+ registered_tools.append(register_tool(mcp, create_document, "create_document"))
336
+ registered_tools.append(register_tool(mcp, open_image, "open_image"))
337
+ registered_tools.append(register_tool(mcp, save_document, "save_document"))
338
+ registered_tools.append(register_tool(mcp, close_document, "close_document"))
339
+ registered_tools.append(register_tool(mcp, crop_document, "crop_document"))
340
+
341
+ return registered_tools