diff --git a/photoshop_mcp_server/ps_adapter/action_manager.py b/photoshop_mcp_server/ps_adapter/action_manager.py index d83e250..4653f46 100644 --- a/photoshop_mcp_server/ps_adapter/action_manager.py +++ b/photoshop_mcp_server/ps_adapter/action_manager.py @@ -86,6 +86,7 @@ def get_active_document_info(cls) -> dict[str, Any]: "bit_depth": 0, "layers": [], "layer_sets": [], + "layer_tree": [], "channels": [], "path": "", } @@ -150,8 +151,35 @@ def get_active_document_info(cls) -> dict[str, Any]: except Exception as e: print(f"Error getting document path: {e}") - # Get layers info would require more complex Action Manager code - # This is a simplified implementation + # Layer information is easier and more reliable through the DOM API. + # This provides accurate flat layer and group lists. + try: + doc = ps_app.get_active_document() + if doc: + for i, layer in enumerate(doc.artLayers): + is_background = bool(getattr(layer, "isBackgroundLayer", False)) + if is_background: + continue + + result["layers"].append( + { + "index": i, + "name": getattr(layer, "name", ""), + "visible": getattr(layer, "visible", True), + "kind": str(getattr(layer, "kind", "Unknown")), + } + ) + + for i, layer_set in enumerate(doc.layerSets): + result["layer_sets"].append( + { + "index": i, + "name": getattr(layer_set, "name", ""), + "visible": getattr(layer_set, "visible", True), + } + ) + except Exception as e: + print(f"Error getting layer info: {e}") return result @@ -163,6 +191,106 @@ def get_active_document_info(cls) -> dict[str, Any]: print(tb_text) return {"success": False, "error": str(e), "detailed_error": tb_text} + @classmethod + def get_document_layer_tree(cls) -> dict[str, Any]: + """Get nested layer/group hierarchy for the active document. + + Returns: + A dictionary containing a recursive layer tree structure. + + """ + try: + ps_app = PhotoshopApp() + app = ps_app.app + + if not hasattr(app, "documents") or not app.documents.length: + return { + "success": True, + "error": "No active document", + "no_document": True, + "layer_tree": [], + } + + doc = ps_app.get_active_document() + if not doc: + return { + "success": True, + "error": "No active document", + "no_document": True, + "layer_tree": [], + } + + def build_layer_node(layer: Any) -> dict[str, Any] | None: + is_background = bool(getattr(layer, "isBackgroundLayer", False)) + if is_background: + return None + + return { + "type": "layer", + "name": getattr(layer, "name", ""), + "visible": getattr(layer, "visible", True), + "kind": str(getattr(layer, "kind", "Unknown")), + } + + def build_group_node(group: Any) -> dict[str, Any]: + node = { + "type": "group", + "name": getattr(group, "name", ""), + "visible": getattr(group, "visible", True), + "children": [], + } + + try: + for sub_group in group.layerSets: + node["children"].append(build_group_node(sub_group)) + except Exception: + pass + + try: + for layer in group.artLayers: + layer_node = build_layer_node(layer) + if layer_node is not None: + node["children"].append(layer_node) + except Exception: + pass + + return node + + tree = [] + ungrouped_layers = [] + + # Add top-level groups. + try: + for top_group in doc.layerSets: + tree.append(build_group_node(top_group)) + except Exception: + pass + + # Add top-level layers that are direct children of the document. + try: + for top_layer in doc.artLayers: + layer_node = build_layer_node(top_layer) + if layer_node is not None: + tree.append(layer_node) + ungrouped_layers.append(layer_node) + except Exception: + pass + + return { + "success": True, + "document_name": getattr(doc, "name", ""), + "layer_tree": tree, + "ungrouped_layers": ungrouped_layers, + } + + except Exception as e: + import traceback + + tb_text = traceback.format_exc() + print(f"Error in get_document_layer_tree: {e}") + print(tb_text) + return {"success": False, "error": str(e), "detailed_error": tb_text} + @classmethod def get_selection_info(cls) -> dict[str, Any]: """Get information about the current selection using Action Manager. diff --git a/photoshop_mcp_server/ps_adapter/application.py b/photoshop_mcp_server/ps_adapter/application.py index cd3a32a..90e8592 100644 --- a/photoshop_mcp_server/ps_adapter/application.py +++ b/photoshop_mcp_server/ps_adapter/application.py @@ -1,6 +1,7 @@ """Photoshop application adapter.""" -from typing import Optional +import json +from typing import Any, Optional import photoshop.api as ps from photoshop import Session @@ -333,3 +334,308 @@ def execute_javascript(self, script): # Script already has try-catch, just return the error error_msg = str(e2).replace('"', '\\"') return '{"error": "' + error_msg + '", "success": false}' + + @staticmethod + def _escape_js_string(value: str) -> str: + """Escape Python string value for safe insertion into JavaScript string literals.""" + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + @staticmethod + def _parse_js_result(result: Any) -> dict[str, Any]: + """Parse JavaScript execution result into a dictionary when possible.""" + if isinstance(result, dict): + return result + + if isinstance(result, str): + try: + parsed = json.loads(result) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + + if result.startswith("Error:"): + return {"success": False, "error": result} + + return {"success": True, "result": result} + + @staticmethod + def _find_group_by_name(container: Any, name: str) -> Any | None: + """Find a group recursively by name in a document or group container.""" + try: + for group in container.layerSets: + if getattr(group, "name", "") == name: + return group + nested = PhotoshopApp._find_group_by_name(group, name) + if nested is not None: + return nested + except Exception: + return None + return None + + @staticmethod + def _find_direct_group_by_name(container: Any, name: str) -> Any | None: + """Find a direct child group by name in a document or group container.""" + try: + for group in container.layerSets: + if getattr(group, "name", "") == name: + return group + except Exception: + return None + return None + + @staticmethod + def _count_collection(collection: Any) -> int: + """Count Photoshop collection items safely.""" + try: + return sum(1 for _ in collection) + except Exception: + try: + return int(getattr(collection, "length", 0)) + except Exception: + return 0 + + @staticmethod + def _find_layer_by_name(container: Any, name: str) -> Any | None: + """Find an art layer recursively by name in a document or group container.""" + try: + for layer in container.artLayers: + if getattr(layer, "name", "") == name: + return layer + + for group in container.layerSets: + nested = PhotoshopApp._find_layer_by_name(group, name) + if nested is not None: + return nested + except Exception: + return None + return None + + def create_group( + self, + name: str, + parent_group_name: str | None = None, + if_exists_return_existing: bool = True, + ) -> dict[str, Any]: + """Create a new group in the active document. + + Args: + name: Group name. + parent_group_name: Optional parent group name for nested group creation. + if_exists_return_existing: If True, returns success for an existing + same-name group within the target scope instead of creating a duplicate. + + Returns: + dict: Operation result. + + """ + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + target_parent = doc + if parent_group_name: + target_parent = self._find_group_by_name(doc, parent_group_name) + if target_parent is None: + return { + "success": False, + "error": f"Parent group not found: {parent_group_name}", + } + + existing_group = self._find_direct_group_by_name(target_parent, name) + if existing_group is not None and if_exists_return_existing: + return { + "success": True, + "group_name": getattr(existing_group, "name", name), + "parent_group_name": parent_group_name, + "already_exists": True, + } + + try: + new_group = target_parent.layerSets.add() + new_group.name = name + return { + "success": True, + "group_name": getattr(new_group, "name", name), + "parent_group_name": parent_group_name, + "already_exists": False, + } + except Exception as e: + # Photoshop may throw COM -2147212704 after applying the action. + # Verify the desired state before returning failure. + error_text = str(e) + if "-2147212704" in error_text: + found_group = self._find_direct_group_by_name(target_parent, name) + if found_group is not None: + return { + "success": True, + "group_name": getattr(found_group, "name", name), + "parent_group_name": parent_group_name, + "already_exists": False, + "warning": "Recovered from Photoshop COM false-negative (-2147212704).", + } + + return {"success": False, "error": error_text} + + def move_layer_to_group(self, layer_name: str, group_name: str) -> dict[str, Any]: + """Move an existing layer into a target group by name. + + Args: + layer_name: Name of the layer to move. + group_name: Name of the destination group. + + Returns: + dict: Operation result. + + """ + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + target_group = self._find_group_by_name(doc, group_name) + if target_group is None: + return {"success": False, "error": f"Target group not found: {group_name}"} + + target_layer = self._find_layer_by_name(doc, layer_name) + if target_layer is None: + return {"success": False, "error": f"Layer not found: {layer_name}"} + + try: + current_parent = getattr(target_layer, "parent", None) + current_parent_name = getattr(current_parent, "name", "") + if current_parent_name == group_name: + return { + "success": True, + "layer_name": layer_name, + "group_name": group_name, + "already_in_group": True, + } + except Exception: + pass + + try: + target_layer.move(target_group, ps.ElementPlacement.PlaceAtBeginning) + return { + "success": True, + "layer_name": layer_name, + "group_name": group_name, + "already_in_group": False, + } + except Exception as e: + # Photoshop may throw COM -2147212704 after applying the action. + # Verify state before returning failure. + error_text = str(e) + if "-2147212704" in error_text: + found_layer = self._find_layer_by_name(doc, layer_name) + if found_layer is not None: + try: + parent = getattr(found_layer, "parent", None) + parent_name = getattr(parent, "name", "") + if parent_name == group_name: + return { + "success": True, + "layer_name": layer_name, + "group_name": group_name, + "already_in_group": False, + "warning": "Recovered from Photoshop COM false-negative (-2147212704).", + } + except Exception: + pass + + return {"success": False, "error": error_text} + + def layer_delete(self, layer_name: str) -> dict[str, Any]: + """Delete the first matching layer by name. + + Args: + layer_name: Layer name to delete. + + Returns: + dict: Operation result. + + """ + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + target_layer = self._find_layer_by_name(doc, layer_name) + if target_layer is None: + return {"success": False, "error": f"Layer not found: {layer_name}"} + + try: + if hasattr(target_layer, "delete"): + target_layer.delete() + else: + target_layer.remove() + return {"success": True, "layer_name": layer_name} + except Exception as e: + # If the remove succeeded but COM reported false-negative, verify state. + error_text = str(e) + if "-2147212704" in error_text: + still_exists = self._find_layer_by_name(doc, layer_name) is not None + if not still_exists: + return { + "success": True, + "layer_name": layer_name, + "warning": "Recovered from Photoshop COM false-negative (-2147212704).", + } + return {"success": False, "error": error_text} + + def group_delete(self, group_name: str, delete_contents: bool = True) -> dict[str, Any]: + """Delete the first matching group by name. + + Args: + group_name: Group name to delete. + delete_contents: If False, refuse deletion when the group is not empty. + + Returns: + dict: Operation result. + + """ + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + target_group = self._find_group_by_name(doc, group_name) + if target_group is None: + return {"success": False, "error": f"Group not found: {group_name}"} + + if not delete_contents: + child_group_count = self._count_collection(getattr(target_group, "layerSets", [])) + child_layer_count = self._count_collection(getattr(target_group, "artLayers", [])) + if child_group_count > 0 or child_layer_count > 0: + return { + "success": False, + "error": "Group is not empty", + "group_name": group_name, + "child_group_count": child_group_count, + "child_layer_count": child_layer_count, + } + + try: + if hasattr(target_group, "delete"): + target_group.delete() + else: + target_group.remove() + return { + "success": True, + "group_name": group_name, + "delete_contents": delete_contents, + } + except Exception as e: + error_text = str(e) + if "-2147212704" in error_text: + still_exists = self._find_group_by_name(doc, group_name) is not None + if not still_exists: + return { + "success": True, + "group_name": group_name, + "delete_contents": delete_contents, + "warning": "Recovered from Photoshop COM false-negative (-2147212704).", + } + return {"success": False, "error": error_text} diff --git a/photoshop_mcp_server/tools/layer_tools.py b/photoshop_mcp_server/tools/layer_tools.py index ec92635..173fcdf 100644 --- a/photoshop_mcp_server/tools/layer_tools.py +++ b/photoshop_mcp_server/tools/layer_tools.py @@ -252,5 +252,284 @@ def create_solid_color_layer( tool_name = register_tool(mcp, create_solid_color_layer, "create_solid_color_layer") registered_tools.append(tool_name) + def create_group( + name: str = "Group 1", + parent_group_name: str | None = None, + if_exists_return_existing: bool = True, + ) -> dict: + """Create a new layer group. + + Args: + name: Group name. + parent_group_name: Optional parent group name for nested group creation. + if_exists_return_existing: Return existing same-name group in the target + scope instead of creating a duplicate. + + Returns: + dict: Result of the operation. + + """ + # Sanitize input strings to ensure valid UTF-8 + try: + if isinstance(name, bytes): + name = name.decode("utf-8", errors="replace") + else: + name = name.encode("utf-8", errors="replace").decode( + "utf-8", errors="replace" + ) + + if isinstance(parent_group_name, bytes): + parent_group_name = parent_group_name.decode("utf-8", errors="replace") + elif isinstance(parent_group_name, str): + parent_group_name = parent_group_name.encode( + "utf-8", errors="replace" + ).decode("utf-8", errors="replace") + + print( + f"Sanitized group names: name='{name}', parent_group_name='{parent_group_name}'" + ) + except Exception as e: + print(f"Error sanitizing group inputs: {e}") + return { + "success": False, + "error": f"Invalid name encoding: {e!s}", + "detailed_error": ( + "The group name provided contains invalid characters that cannot be properly encoded in UTF-8. " + "Please check the name and try again with valid characters." + ), + } + + ps_app = PhotoshopApp() + doc = ps_app.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + try: + print( + f"Creating group with name='{name}', parent_group_name='{parent_group_name}'" + ) + result = ps_app.create_group( + name=name, + parent_group_name=parent_group_name, + if_exists_return_existing=if_exists_return_existing, + ) + print(f"Create group result: {result}") + return result + except Exception as e: + print(f"Error creating group: {e}") + import traceback + + tb_text = traceback.format_exc() + traceback.print_exc() + + return { + "success": False, + "error": str(e), + "detailed_error": ( + "Error creating group with parameters:\n" + f" name: {name}\n" + f" parent_group_name: {parent_group_name}\n\n" + f"Error: {e!s}\n\n" + f"Traceback:\n{tb_text}" + ), + "parameters": { + "name": name, + "parent_group_name": parent_group_name, + "if_exists_return_existing": if_exists_return_existing, + }, + } + + tool_name = register_tool(mcp, create_group, "create_group") + registered_tools.append(tool_name) + + def move_layer_to_group(layer_name: str, group_name: str) -> dict: + """Move a layer into a target group. + + Args: + layer_name: Name of the source layer. + group_name: Name of the destination group. + + Returns: + dict: Result of the operation. + + """ + # Sanitize input strings to ensure valid UTF-8 + try: + if isinstance(layer_name, bytes): + layer_name = layer_name.decode("utf-8", errors="replace") + else: + layer_name = layer_name.encode("utf-8", errors="replace").decode( + "utf-8", errors="replace" + ) + + if isinstance(group_name, bytes): + group_name = group_name.decode("utf-8", errors="replace") + else: + group_name = group_name.encode("utf-8", errors="replace").decode( + "utf-8", errors="replace" + ) + + print( + f"Sanitized move params: layer_name='{layer_name}', group_name='{group_name}'" + ) + except Exception as e: + print(f"Error sanitizing move params: {e}") + return { + "success": False, + "error": f"Invalid name encoding: {e!s}", + "detailed_error": ( + "One or more provided names contain invalid characters that cannot be properly encoded in UTF-8. " + "Please check the names and try again." + ), + } + + ps_app = PhotoshopApp() + doc = ps_app.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + try: + print( + f"Moving layer '{layer_name}' to group '{group_name}'" + ) + result = ps_app.move_layer_to_group(layer_name=layer_name, group_name=group_name) + print(f"Move layer result: {result}") + return result + except Exception as e: + print(f"Error moving layer to group: {e}") + import traceback + + tb_text = traceback.format_exc() + traceback.print_exc() + + return { + "success": False, + "error": str(e), + "detailed_error": ( + "Error moving layer to group with parameters:\n" + f" layer_name: {layer_name}\n" + f" group_name: {group_name}\n\n" + f"Error: {e!s}\n\n" + f"Traceback:\n{tb_text}" + ), + "parameters": { + "layer_name": layer_name, + "group_name": group_name, + }, + } + + tool_name = register_tool(mcp, move_layer_to_group, "move_layer_to_group") + registered_tools.append(tool_name) + + def layer_delete(layer_name: str) -> dict: + """Delete a layer by name. + + Args: + layer_name: Name of the layer to delete. + + Returns: + dict: Result of the operation. + + """ + try: + if isinstance(layer_name, bytes): + layer_name = layer_name.decode("utf-8", errors="replace") + else: + layer_name = layer_name.encode("utf-8", errors="replace").decode( + "utf-8", errors="replace" + ) + except Exception as e: + return { + "success": False, + "error": f"Invalid name encoding: {e!s}", + } + + ps_app = PhotoshopApp() + doc = ps_app.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + try: + return ps_app.layer_delete(layer_name=layer_name) + except Exception as e: + import traceback + + tb_text = traceback.format_exc() + traceback.print_exc() + + return { + "success": False, + "error": str(e), + "detailed_error": ( + "Error deleting layer with parameters:\n" + f" layer_name: {layer_name}\n\n" + f"Error: {e!s}\n\n" + f"Traceback:\n{tb_text}" + ), + "parameters": {"layer_name": layer_name}, + } + + tool_name = register_tool(mcp, layer_delete, "layer_delete") + registered_tools.append(tool_name) + + def group_delete(group_name: str, delete_contents: bool = True) -> dict: + """Delete a group by name. + + Args: + group_name: Name of the group to delete. + delete_contents: If False, refuse to delete non-empty groups. + + Returns: + dict: Result of the operation. + + """ + try: + if isinstance(group_name, bytes): + group_name = group_name.decode("utf-8", errors="replace") + else: + group_name = group_name.encode("utf-8", errors="replace").decode( + "utf-8", errors="replace" + ) + except Exception as e: + return { + "success": False, + "error": f"Invalid name encoding: {e!s}", + } + + ps_app = PhotoshopApp() + doc = ps_app.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + + try: + return ps_app.group_delete( + group_name=group_name, + delete_contents=delete_contents, + ) + except Exception as e: + import traceback + + tb_text = traceback.format_exc() + traceback.print_exc() + + return { + "success": False, + "error": str(e), + "detailed_error": ( + "Error deleting group with parameters:\n" + f" group_name: {group_name}\n" + f" delete_contents: {delete_contents}\n\n" + f"Error: {e!s}\n\n" + f"Traceback:\n{tb_text}" + ), + "parameters": { + "group_name": group_name, + "delete_contents": delete_contents, + }, + } + + tool_name = register_tool(mcp, group_delete, "group_delete") + registered_tools.append(tool_name) + # Return the list of registered tools return registered_tools diff --git a/photoshop_mcp_server/tools/session_tools.py b/photoshop_mcp_server/tools/session_tools.py index 81c071a..ed96e64 100644 --- a/photoshop_mcp_server/tools/session_tools.py +++ b/photoshop_mcp_server/tools/session_tools.py @@ -91,6 +91,37 @@ def get_active_document_info() -> dict[str, Any]: tool_name = register_tool(mcp, get_active_document_info, "get_active_document_info") registered_tools.append(tool_name) + def get_document_layer_tree() -> dict[str, Any]: + """Get nested layer/group tree for the active document. + + Returns: + dict: Recursive hierarchy of groups and layers. + + """ + try: + print("Getting document layer tree using Action Manager + JavaScript") + + tree_info = ActionManager.get_document_layer_tree() + print( + f"Document layer tree retrieved successfully: {tree_info.get('success', False)}" + ) + + return tree_info + + except Exception as e: + print(f"Error getting document layer tree: {e}") + import traceback + + tb_text = traceback.format_exc() + traceback.print_exc() + + detailed_error = f"Error getting document layer tree:\nError: {e!s}\n\nTraceback:\n{tb_text}" + + return {"success": False, "error": str(e), "detailed_error": detailed_error} + + tool_name = register_tool(mcp, get_document_layer_tree, "get_document_layer_tree") + registered_tools.append(tool_name) + def get_selection_info() -> dict[str, Any]: """Get information about the current selection in the active document.