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,244 @@
1
+ """Session information tools - PS version, document info, selection info."""
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.context import get_context_info
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all session tools with MCP server.
15
+
16
+ Args:
17
+ mcp: MCP server instance.
18
+
19
+ Returns:
20
+ List of registered tool names.
21
+ """
22
+ registered_tools = []
23
+
24
+ @debug_tool
25
+ @log_tool_call
26
+ def get_session_info() -> dict[str, Any]:
27
+ """Get Photoshop version and session status information.
28
+
29
+ Returns:
30
+ dict: Session information including:
31
+ - success: bool
32
+ - ps_version: Photoshop version string
33
+ - ps_running: Whether PS is accessible
34
+ - has_document: Whether any document is open
35
+ - context: Current PS context
36
+ """
37
+ ps_app = PhotoshopApp()
38
+
39
+ try:
40
+ version = ps_app.get_photoshop_version()
41
+ has_doc = ps_app.has_active_document()
42
+ context = get_context_info()
43
+
44
+ return {
45
+ "success": True,
46
+ "ps_version": version,
47
+ "ps_running": True,
48
+ "has_document": has_doc,
49
+ "context": context,
50
+ }
51
+
52
+ except Exception as e:
53
+ logger.error(f"Failed to get session info: {e}")
54
+ return {
55
+ "success": False,
56
+ "error": str(e),
57
+ "ps_running": False,
58
+ "context": get_context_info(),
59
+ }
60
+
61
+ @debug_tool
62
+ @log_tool_call
63
+ def get_active_document_info() -> dict[str, Any]:
64
+ """Get detailed information about the currently active document.
65
+
66
+ Returns:
67
+ dict: Document information including:
68
+ - success: bool
69
+ - document: Document details (name, dimensions, resolution, etc.)
70
+ - layer_count: Number of layers
71
+ - has_selection: Whether a selection is active
72
+ - context: Current PS context
73
+ """
74
+ ps_app = PhotoshopApp()
75
+ doc = ps_app.get_active_document()
76
+
77
+ if not doc:
78
+ return {
79
+ "success": False,
80
+ "error": "No active document",
81
+ "context": get_context_info(),
82
+ }
83
+
84
+ try:
85
+ # Get comprehensive document info via JavaScript
86
+ doc_info_script = """
87
+ (function() {
88
+ if (!app.documents.length) return null;
89
+
90
+ var doc = app.activeDocument;
91
+
92
+ var colorModeMap = {
93
+ 1: "BITMAP",
94
+ 2: "GRAYSCALE",
95
+ 3: "INDEXED",
96
+ 4: "RGB",
97
+ 5: "CMYK",
98
+ 7: "MULTICHANNEL",
99
+ 8: "DUOTONE",
100
+ 9: "LAB"
101
+ };
102
+
103
+ var bitDepthMap = {
104
+ 1: 1,
105
+ 8: 8,
106
+ 16: 16,
107
+ 32: 32
108
+ };
109
+
110
+ var info = {
111
+ name: doc.name,
112
+ path: doc.fullName ? doc.fullName.toString() : null,
113
+ width: parseInt(doc.width),
114
+ height: parseInt(doc.height),
115
+ resolution: parseFloat(doc.resolution),
116
+ color_mode: colorModeMap[doc.mode] || "UNKNOWN",
117
+ bit_depth: bitDepthMap[doc.bitsPerChannel] || 8,
118
+ layer_count: doc.layers.length,
119
+ has_background_layer: false,
120
+ has_selection: false,
121
+ saved: !doc.saved
122
+ };
123
+
124
+ // Check for background layer
125
+ try {
126
+ var bg = doc.backgroundLayer;
127
+ if (bg) info.has_background_layer = true;
128
+ } catch(e) {}
129
+
130
+ // Check for selection
131
+ try {
132
+ var selBounds = doc.selection.bounds;
133
+ if (selBounds) info.has_selection = true;
134
+ } catch(e) {}
135
+
136
+ return JSON.stringify(info);
137
+ })();
138
+ """
139
+
140
+ result = ps_app.execute_javascript(doc_info_script)
141
+
142
+ import json
143
+
144
+ doc_info = json.loads(result) if isinstance(result, str) else result
145
+
146
+ return {
147
+ "success": True,
148
+ "document": doc_info,
149
+ "context": get_context_info(),
150
+ }
151
+
152
+ except Exception as e:
153
+ logger.error(f"Failed to get document info: {e}")
154
+ return {
155
+ "success": False,
156
+ "error": str(e),
157
+ "context": get_context_info(),
158
+ }
159
+
160
+ @debug_tool
161
+ @log_tool_call
162
+ def get_selection_info() -> dict[str, Any]:
163
+ """Get information about the current selection (if any).
164
+
165
+ Returns:
166
+ dict: Selection information including:
167
+ - success: bool
168
+ - has_selection: Whether a selection exists
169
+ - bounds: Selection bounds {left, top, right, bottom} if exists
170
+ - width: Selection width (if exists)
171
+ - height: Selection height (if exists)
172
+ - context: Current PS context
173
+ """
174
+ ps_app = PhotoshopApp()
175
+ doc = ps_app.get_active_document()
176
+
177
+ if not doc:
178
+ return {
179
+ "success": False,
180
+ "error": "No active document",
181
+ "context": get_context_info(),
182
+ }
183
+
184
+ try:
185
+ selection_script = """
186
+ (function() {
187
+ if (!app.documents.length) return null;
188
+
189
+ var doc = app.activeDocument;
190
+ var result = {
191
+ has_selection: false,
192
+ bounds: null,
193
+ width: 0,
194
+ height: 0
195
+ };
196
+
197
+ try {
198
+ var bounds = doc.selection.bounds;
199
+ if (bounds && bounds.length === 4) {
200
+ result.has_selection = true;
201
+ result.bounds = {
202
+ left: parseInt(bounds[0]),
203
+ top: parseInt(bounds[1]),
204
+ right: parseInt(bounds[2]),
205
+ bottom: parseInt(bounds[3])
206
+ };
207
+ result.width = result.bounds.right - result.bounds.left;
208
+ result.height = result.bounds.bottom - result.bounds.top;
209
+ }
210
+ } catch(e) {
211
+ // No selection
212
+ }
213
+
214
+ return JSON.stringify(result);
215
+ })();
216
+ """
217
+
218
+ result = ps_app.execute_javascript(selection_script)
219
+
220
+ import json
221
+
222
+ selection_info = json.loads(result) if isinstance(result, str) else result
223
+
224
+ return {
225
+ "success": True,
226
+ **selection_info,
227
+ "context": get_context_info(),
228
+ }
229
+
230
+ except Exception as e:
231
+ logger.error(f"Failed to get selection info: {e}")
232
+ return {
233
+ "success": False,
234
+ "error": str(e),
235
+ "has_selection": False,
236
+ "context": get_context_info(),
237
+ }
238
+
239
+ # Register all tools
240
+ registered_tools.append(register_tool(mcp, get_session_info, "get_session_info"))
241
+ registered_tools.append(register_tool(mcp, get_active_document_info, "get_active_document_info"))
242
+ registered_tools.append(register_tool(mcp, get_selection_info, "get_selection_info"))
243
+
244
+ return registered_tools
@@ -0,0 +1,390 @@
1
+ """Text layer tools - create, update content, font, color, alignment."""
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, validate_color_channel
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all text tools with MCP server.
15
+
16
+ Args:
17
+ mcp: MCP server instance.
18
+
19
+ Returns:
20
+ List of registered tool names.
21
+ """
22
+ registered_tools = []
23
+
24
+ @debug_tool
25
+ @log_tool_call
26
+ def create_text_layer(
27
+ text: str,
28
+ x: float = 100,
29
+ y: float = 100,
30
+ font_size: float = 24,
31
+ color_r: int = 0,
32
+ color_g: int = 0,
33
+ color_b: int = 0,
34
+ ) -> dict[str, Any]:
35
+ """Create a new text layer with specified content and styling.
36
+
37
+ Args:
38
+ text: Text content to display.
39
+ x: Horizontal position in pixels (default: 100).
40
+ y: Vertical position in pixels (default: 100).
41
+ font_size: Font size in points (default: 24).
42
+ color_r: Red channel 0-255 (default: 0).
43
+ color_g: Green channel 0-255 (default: 0).
44
+ color_b: Blue channel 0-255 (default: 0).
45
+
46
+ Returns:
47
+ dict: Operation result with text layer info and context.
48
+ """
49
+ validate_color_channel(color_r, "color_r")
50
+ validate_color_channel(color_g, "color_g")
51
+ validate_color_channel(color_b, "color_b")
52
+
53
+ ps_app = PhotoshopApp()
54
+ doc = ps_app.get_active_document()
55
+
56
+ if not doc:
57
+ return {
58
+ "success": False,
59
+ "error": "No active document",
60
+ }
61
+
62
+ try:
63
+ text_escaped = js_escape_string(text)
64
+
65
+ create_text_script = f"""
66
+ var doc = app.activeDocument;
67
+
68
+ // Create text layer
69
+ var textLayer = doc.artLayers.add();
70
+ textLayer.kind = LayerKind.TEXT;
71
+
72
+ var textItem = textLayer.textItem;
73
+ textItem.contents = "{text_escaped}";
74
+ textItem.position = [{x}, {y}];
75
+ textItem.size = {font_size};
76
+
77
+ // Set color
78
+ var textColor = new SolidColor();
79
+ textColor.rgb.red = {color_r};
80
+ textColor.rgb.green = {color_g};
81
+ textColor.rgb.blue = {color_b};
82
+ textItem.color = textColor;
83
+
84
+ textLayer.name;
85
+ """
86
+
87
+ layer_name = ps_app.execute_javascript(create_text_script)
88
+
89
+ return {
90
+ "success": True,
91
+ "message": f"Created text layer '{layer_name}'",
92
+ "layer_name": layer_name,
93
+ "text": text,
94
+ "position": {"x": x, "y": y},
95
+ "font_size": font_size,
96
+ "color": {"r": color_r, "g": color_g, "b": color_b},
97
+ }
98
+
99
+ except Exception as e:
100
+ logger.error(f"Failed to create text layer: {e}")
101
+ return {
102
+ "success": False,
103
+ "error": str(e),
104
+ }
105
+
106
+ @debug_tool
107
+ @log_tool_call
108
+ def update_text_content(new_text: str) -> dict[str, Any]:
109
+ """Update the text content of the currently active text layer.
110
+
111
+ Args:
112
+ new_text: New text content.
113
+
114
+ Returns:
115
+ dict: Operation result and context.
116
+ """
117
+ ps_app = PhotoshopApp()
118
+ doc = ps_app.get_active_document()
119
+
120
+ if not doc:
121
+ return {
122
+ "success": False,
123
+ "error": "No active document",
124
+ }
125
+
126
+ try:
127
+ text_escaped = js_escape_string(new_text)
128
+
129
+ update_script = f"""
130
+ var layer = app.activeDocument.activeLayer;
131
+
132
+ // Check if it's a text layer
133
+ if (layer.kind !== LayerKind.TEXT) {{
134
+ throw new Error("Active layer is not a text layer");
135
+ }}
136
+
137
+ var oldText = layer.textItem.contents;
138
+ layer.textItem.contents = "{text_escaped}";
139
+
140
+ JSON.stringify({{
141
+ layer_name: layer.name,
142
+ old_text: oldText,
143
+ new_text: "{text_escaped}"
144
+ }});
145
+ """
146
+
147
+ import json
148
+
149
+ result_str = ps_app.execute_javascript(update_script)
150
+ result = json.loads(result_str) if isinstance(result_str, str) else result_str
151
+
152
+ return {
153
+ "success": True,
154
+ "message": f"Updated text in layer '{result['layer_name']}'",
155
+ "layer_name": result["layer_name"],
156
+ "old_text": result["old_text"],
157
+ "new_text": new_text,
158
+ }
159
+
160
+ except Exception as e:
161
+ error_msg = str(e)
162
+ if "not a text layer" in error_msg.lower():
163
+ return {
164
+ "success": False,
165
+ "error": "Active layer is not a text layer",
166
+ }
167
+
168
+ logger.error(f"Failed to update text content: {e}")
169
+ return {
170
+ "success": False,
171
+ "error": str(e),
172
+ }
173
+
174
+ @debug_tool
175
+ @log_tool_call
176
+ def set_text_font(font_name: str, font_size: float = None) -> dict[str, Any]:
177
+ """Set the font family and/or size of the currently active text layer.
178
+
179
+ Args:
180
+ font_name: Font family name (e.g., "Arial", "Times New Roman").
181
+ font_size: Optional font size in points.
182
+
183
+ Returns:
184
+ dict: Operation result and context.
185
+ """
186
+ ps_app = PhotoshopApp()
187
+ doc = ps_app.get_active_document()
188
+
189
+ if not doc:
190
+ return {
191
+ "success": False,
192
+ "error": "No active document",
193
+ }
194
+
195
+ try:
196
+ font_escaped = js_escape_string(font_name)
197
+
198
+ if font_size is not None:
199
+ set_font_script = f"""
200
+ var layer = app.activeDocument.activeLayer;
201
+
202
+ if (layer.kind !== LayerKind.TEXT) {{
203
+ throw new Error("Active layer is not a text layer");
204
+ }}
205
+
206
+ layer.textItem.font = "{font_escaped}";
207
+ layer.textItem.size = {font_size};
208
+
209
+ layer.name;
210
+ """
211
+ else:
212
+ set_font_script = f"""
213
+ var layer = app.activeDocument.activeLayer;
214
+
215
+ if (layer.kind !== LayerKind.TEXT) {{
216
+ throw new Error("Active layer is not a text layer");
217
+ }}
218
+
219
+ layer.textItem.font = "{font_escaped}";
220
+
221
+ layer.name;
222
+ """
223
+
224
+ layer_name = ps_app.execute_javascript(set_font_script)
225
+
226
+ message = f"Set font of layer '{layer_name}' to '{font_name}'"
227
+ if font_size:
228
+ message += f" at {font_size}pt"
229
+
230
+ return {
231
+ "success": True,
232
+ "message": message,
233
+ "layer_name": layer_name,
234
+ "font_name": font_name,
235
+ "font_size": font_size,
236
+ }
237
+
238
+ except Exception as e:
239
+ error_msg = str(e)
240
+ if "not a text layer" in error_msg.lower():
241
+ return {
242
+ "success": False,
243
+ "error": "Active layer is not a text layer",
244
+ }
245
+
246
+ logger.error(f"Failed to set text font: {e}")
247
+ return {
248
+ "success": False,
249
+ "error": str(e),
250
+ }
251
+
252
+ @debug_tool
253
+ @log_tool_call
254
+ def set_text_color(red: int, green: int, blue: int) -> dict[str, Any]:
255
+ """Set the color of the currently active text layer.
256
+
257
+ Args:
258
+ red: Red channel 0-255.
259
+ green: Green channel 0-255.
260
+ blue: Blue channel 0-255.
261
+
262
+ Returns:
263
+ dict: Operation result and context.
264
+ """
265
+ validate_color_channel(red, "red")
266
+ validate_color_channel(green, "green")
267
+ validate_color_channel(blue, "blue")
268
+
269
+ ps_app = PhotoshopApp()
270
+ doc = ps_app.get_active_document()
271
+
272
+ if not doc:
273
+ return {
274
+ "success": False,
275
+ "error": "No active document",
276
+ }
277
+
278
+ try:
279
+ set_color_script = f"""
280
+ var layer = app.activeDocument.activeLayer;
281
+
282
+ if (layer.kind !== LayerKind.TEXT) {{
283
+ throw new Error("Active layer is not a text layer");
284
+ }}
285
+
286
+ var textColor = new SolidColor();
287
+ textColor.rgb.red = {red};
288
+ textColor.rgb.green = {green};
289
+ textColor.rgb.blue = {blue};
290
+ layer.textItem.color = textColor;
291
+
292
+ layer.name;
293
+ """
294
+
295
+ layer_name = ps_app.execute_javascript(set_color_script)
296
+
297
+ return {
298
+ "success": True,
299
+ "message": f"Set color of text layer '{layer_name}' to RGB({red}, {green}, {blue})",
300
+ "layer_name": layer_name,
301
+ "color": {"red": red, "green": green, "blue": blue},
302
+ }
303
+
304
+ except Exception as e:
305
+ error_msg = str(e)
306
+ if "not a text layer" in error_msg.lower():
307
+ return {
308
+ "success": False,
309
+ "error": "Active layer is not a text layer",
310
+ }
311
+
312
+ logger.error(f"Failed to set text color: {e}")
313
+ return {
314
+ "success": False,
315
+ "error": str(e),
316
+ }
317
+
318
+ @debug_tool
319
+ @log_tool_call
320
+ def set_text_alignment(alignment: str) -> dict[str, Any]:
321
+ """Set the alignment of the currently active text layer.
322
+
323
+ Args:
324
+ alignment: Text alignment - LEFT, CENTER, or RIGHT.
325
+
326
+ Returns:
327
+ dict: Operation result and context.
328
+ """
329
+ alignment = alignment.upper()
330
+ valid_alignments = ["LEFT", "CENTER", "RIGHT"]
331
+
332
+ if alignment not in valid_alignments:
333
+ return {
334
+ "success": False,
335
+ "error": f"Invalid alignment '{alignment}'. Must be: {', '.join(valid_alignments)}",
336
+ }
337
+
338
+ ps_app = PhotoshopApp()
339
+ doc = ps_app.get_active_document()
340
+
341
+ if not doc:
342
+ return {
343
+ "success": False,
344
+ "error": "No active document",
345
+ }
346
+
347
+ try:
348
+ set_alignment_script = f"""
349
+ var layer = app.activeDocument.activeLayer;
350
+
351
+ if (layer.kind !== LayerKind.TEXT) {{
352
+ throw new Error("Active layer is not a text layer");
353
+ }}
354
+
355
+ layer.textItem.justification = Justification.{alignment};
356
+
357
+ layer.name;
358
+ """
359
+
360
+ layer_name = ps_app.execute_javascript(set_alignment_script)
361
+
362
+ return {
363
+ "success": True,
364
+ "message": f"Set alignment of text layer '{layer_name}' to {alignment}",
365
+ "layer_name": layer_name,
366
+ "alignment": alignment,
367
+ }
368
+
369
+ except Exception as e:
370
+ error_msg = str(e)
371
+ if "not a text layer" in error_msg.lower():
372
+ return {
373
+ "success": False,
374
+ "error": "Active layer is not a text layer",
375
+ }
376
+
377
+ logger.error(f"Failed to set text alignment: {e}")
378
+ return {
379
+ "success": False,
380
+ "error": str(e),
381
+ }
382
+
383
+ # Register all tools
384
+ registered_tools.append(register_tool(mcp, create_text_layer, "create_text_layer"))
385
+ registered_tools.append(register_tool(mcp, update_text_content, "update_text_content"))
386
+ registered_tools.append(register_tool(mcp, set_text_font, "set_text_font"))
387
+ registered_tools.append(register_tool(mcp, set_text_color, "set_text_color"))
388
+ registered_tools.append(register_tool(mcp, set_text_alignment, "set_text_alignment"))
389
+
390
+ return registered_tools