#!/usr/bin/env python3 """ Batch Text Updater for Photoshop -------------------------------- This script automates updating text layers in Photoshop PSD files using JSON data. It uses the updateTextLayers.jsx script to apply translated text while preserving formatting. Requirements: - Python 3.6+ - Adobe Photoshop installed - photoshop_python_api package (install with: pip install photoshop_python_api) """ import os import sys import time import json import argparse import platform from pathlib import Path import logging from typing import List, Dict, Any, Optional, Tuple # Check platform is_windows = platform.system() == "Windows" is_mac = platform.system() == "Darwin" if is_mac: # On macOS, we need to ensure we're using the right Python environment try: # Try to fix Mac-specific PATH issues if "PYTHONPATH" not in os.environ: os.environ["PYTHONPATH"] = "" # Add the site-packages directory to path - this helps with venv environments import site site_packages = site.getsitepackages() for site_path in site_packages: if site_path not in sys.path: sys.path.append(site_path) print(f"Added {site_path} to Python path") except Exception as e: print(f"Warning: Could not set paths: {e}") # Print some debug info on Mac print(f"Python: {sys.version}") print(f"System: {platform.system()} {platform.release()}") print(f"Site packages: {', '.join(site.getsitepackages())}") # First try to import the photoshop module - the import might be in different places # depending on how the package was installed found_ps_module = False # Try to directly import the photoshop module - work around importing issues by adding to sys.modules import importlib import inspect # Print path information to help debug print("Python module search paths:") for path in sys.path: if 'site-packages' in path: print(f" - {path}") # Try to directly locate the module file found_ps_path = None for path in sys.path: ps_module_path = os.path.join(path, 'photoshop') if os.path.exists(ps_module_path): found_ps_path = ps_module_path print(f"Found photoshop module at: {ps_module_path}") break if found_ps_path: # Check for specific module structure init_path = os.path.join(found_ps_path, '__init__.py') if os.path.exists(init_path): with open(init_path, 'r') as f: init_content = f.read() print(f"Found __init__.py, size: {len(init_content)} bytes") # Attempt various import strategies, trying to be very flexible try_imports = [ # Standard import lambda: exec('from photoshop import Session'), # Alternative path lambda: exec('from photoshop.api import Session'), # Manual import construction lambda: exec('import importlib; photoshop = importlib.import_module("photoshop"); Session = photoshop.Session'), # Import for photoshop-python-api (with hyphen) lambda: exec('import photoshop_python_api as photoshop; Session = photoshop.Session') ] found_ps_module = False last_error = None for importer in try_imports: try: importer() found_ps_module = True break except Exception as e: last_error = str(e) print(f"Import attempt failed: {e}") continue # Last resort - try to manually find and load the module if not found_ps_module: print("Attempting manual module discovery...") try: import subprocess # Find where the module is installed result = subprocess.run( [sys.executable, "-m", "pip", "show", "photoshop-python-api"], capture_output=True, text=True ) if result.returncode == 0: location_line = [line for line in result.stdout.split('\n') if line.startswith('Location:')] if location_line: location = location_line[0].split('Location:')[1].strip() print(f"Package location: {location}") # Add to Python path if location not in sys.path: sys.path.append(location) print(f"Added {location} to Python path") # Try importing again after adjusting the path try: from photoshop import Session found_ps_module = True print("Successfully imported after path adjustment") except ImportError as e: print(f"Still cannot import after path adjustment: {e}") except Exception as e: print(f"Manual module discovery failed: {e}") # If the import failed because of 'winreg' on macOS, try creating a compatibility layer if not found_ps_module and is_mac and "No module named 'winreg'" in str(last_error): print("Detected 'winreg' compatibility issue on macOS.") print("Creating a compatibility layer for Windows-specific modules...") # Create a mock winreg module to satisfy the import try: import types # Create a fake winreg module mock_winreg = types.ModuleType("winreg") # Add necessary constants and functions mock_winreg.HKEY_CURRENT_USER = 0 mock_winreg.HKEY_LOCAL_MACHINE = 1 mock_winreg.KEY_ALL_ACCESS = 2 # Add mock functions def mock_open_key(*args, **kwargs): return None def mock_query_value(*args, **kwargs): # Return default Photoshop path for macOS return "/Applications/Adobe Photoshop 2025/Adobe Photoshop 2025.app" def mock_close_key(*args, **kwargs): pass # Attach functions to the mock module mock_winreg.OpenKey = mock_open_key mock_winreg.QueryValueEx = mock_query_value mock_winreg.CloseKey = mock_close_key # Add the mock module to sys.modules sys.modules["winreg"] = mock_winreg print("Mock winreg module created. Trying to import photoshop again...") # Try importing again try: from photoshop import Session found_ps_module = True print("Successfully imported after adding compatibility layer") except Exception as e: print(f"Still cannot import after adding compatibility layer: {e}") except Exception as e: print(f"Failed to create compatibility layer: {e}") # Create a custom Session class for macOS if needed if not found_ps_module and is_mac: print("Attempting to create a custom Photoshop Session class for macOS...") try: # Define a basic Session class that will work on macOS class Session: def __init__(self, ps_version=None): self.app = None self.version = ps_version or "2023" # Try to locate Photoshop on macOS ps_paths = [ f"/Applications/Adobe Photoshop {self.version}/Adobe Photoshop {self.version}.app", f"/Applications/Adobe Photoshop {self.version}/Adobe Photoshop.app", f"/Applications/Adobe Photoshop/Adobe Photoshop.app", "/Applications/Adobe Photoshop CC 2025/Adobe Photoshop CC 2025.app", "/Applications/Adobe Photoshop 2025/Adobe Photoshop 2025.app", "/Applications/Adobe Photoshop 2024/Adobe Photoshop 2024.app" ] self.ps_path = None for path in ps_paths: if os.path.exists(path): self.ps_path = path print(f"Found Photoshop at: {path}") break if not self.ps_path: print("Warning: Couldn't find Photoshop application path") # Initialize the app through AppleScript self._initialize_app() def _initialize_app(self): try: import subprocess # Check if Photoshop is running ps_running_script = """ tell application "System Events" set isRunning to (count of (every process whose name is "Adobe Photoshop")) > 0 end tell """ # Launch Photoshop if needed launch_script = f""" tell application "Adobe Photoshop" activate end tell """ # Execute AppleScript to launch Photoshop subprocess.run(["osascript", "-e", launch_script], check=True) print("Photoshop launched successfully") # Create a simple app object with the required methods class PhotoshopApp: def __init__(self): self.activeDocument = None def Open(self, file_path): print(f"Opening file: {file_path}") open_script = f""" tell application "Adobe Photoshop" open POSIX file "{file_path}" end tell """ subprocess.run(["osascript", "-e", open_script], check=True) self.activeDocument = self.ActiveDocument() return True def DoJavaScript(self, script): # Save the script to a temporary file script_path = os.path.expanduser("~/Desktop/temp_ps_script.jsx") with open(script_path, "w") as f: f.write(script) # Run the script run_script = f""" tell application "Adobe Photoshop" do javascript POSIX file "{script_path}" end tell """ subprocess.run(["osascript", "-e", run_script], check=True) # Clean up the temporary file os.remove(script_path) return True class ActiveDocument: def __init__(self): pass def Close(self, save_option): close_script = f""" tell application "Adobe Photoshop" close current document saving {'yes' if save_option == 3 else 'no'} end tell """ subprocess.run(["osascript", "-e", close_script], check=True) self.app = PhotoshopApp() except Exception as e: print(f"Error initializing Photoshop on macOS: {e}") self.app = None def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): pass # We'll let AppleScript handle the cleanup # Set the Session class found_ps_module = True print("Created custom macOS Session class for Photoshop") except Exception as e: print(f"Failed to create custom Session class: {e}") # If all attempts fail, notify the user if not found_ps_module: print("Error: Could not import the Photoshop Python API.") print(f"Last error: {last_error}") print("\nTroubleshooting steps:") print("1. Verify the package is installed: pip list | grep photoshop") print("2. Try reinstalling: pip uninstall photoshop-python-api; pip install photoshop-python-api") print("3. Check if you're using the right version of Python") print("4. On macOS, the photoshop-python-api package might not be compatible") print(" - The package was designed for Windows and uses Windows-specific modules") sys.exit(1) else: print("Successfully imported or created Photoshop API session handler") # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) logger = logging.getLogger(__name__) # The updateTextLayers.jsx script as a string (minified version) UPDATE_TEXT_SCRIPT = r""" // Photoshop Script to Update Text Layers #target photoshop function readTextFile(e){return e.open("r"),content=e.read(),e.close(),content}function parseJson(e){try{if("undefined"!=typeof JSON&&JSON.parse)return JSON.parse(e)}catch(e){$.writeln("Native JSON parse failed: "+e)}try{var t=eval("("+e+")");return t}catch(e){$.writeln("JSON eval failed: "+e);try{$.writeln("Attempting emergency JSON parsing...");var r=e.match(/"textLayers"\\s*:\\s*\\[(([\\s\\S]*?))\\]\\s*\\}/);if(r&&r[1]){for(var n={textLayers:[]},a=/\\{\\s*"id"[\\s\\S]*?styleInfo[\\s\\S]*?\\}\\s*\\}/g,i;null!==(i=a.exec(r[1]));){var l=i[0],s=l.match(/"name"\\s*:\\s*"([^"]*)"$/),o=l.match(/"text"\\s*:\\s*"([^"]*)"$/),y=l.match(/"updatedText"\\s*:\\s*"([^"]*)"$/);s&&o&&y&&n.textLayers.push({name:s[1].replace(/\\n/g,"\\n"),text:o[1].replace(/\\n/g,"\\n"),updatedText:y[1].replace(/\\n/g,"\\n"),exists:!1})}if(n.textLayers.length>0)return $.writeln("Successfully extracted "+n.textLayers.length+" layers with emergency parser"),n}throw new Error("Emergency parsing failed")}catch(t){throw $.writeln("Emergency parsing failed: "+t),new Error("Failed to parse JSON: "+e.message)}}}function findLayerByName(e,t){function r(e,r){r=r||"";for(var n=0;n Searching inside group: "+a.name);var i=l(a,r+" ");if(i)return i}}return null}function l(e,t){t=t||"";for(var r=0;r Searching inside group: "+n.name);var a=l(n,t+" ");if(a)return a}}return null}return $.writeln("Looking for layer: "+t),result=r(e.layers),result||($.writeln("❌ Layer not found: "+t),null)}function updateTextLayer(e,t,r){if(e.kind===LayerKind.TEXT){$.writeln("Updating text layer: "+e.name),$.writeln("Original layer text: "+e.textItem.contents),$.writeln("New text from JSON: "+t);try{var n=t;if(n=n.replace(/\\n/g,"\\n"),n=n.replace(/\\r/g,"\\r"),r&&r.styleInfo&&r.styleInfo.styles&&r.styleInfo.styles.length>1&&r.hasRichTextFormatting){$.writeln("Layer has rich text formatting - attempting to preserve styles with the new text");try{$.writeln("Setting basic text to: "+n),e.textItem.contents=n,app.activeDocument.activeLayer=e,$.writeln("Rich text update is experimental and may not work perfectly"),$.writeln("Original style ranges count: "+r.styleInfo.styles.length),r.styleInfo.styles.length>0&&($.writeln("Attempting to apply rich text formatting to "+r.styleInfo.styles.length+" style ranges"),new ActionReference,new ActionReference,ref.putEnumerated(charIDToTypeID("Lyr "),charIDToTypeID("Ordn"),charIDToTypeID("Trgt")),executeActionGet(ref),function(){for(var a=0;an.length&&(s=n.length),l=0&&s<=n.length&&($.writeln("Applying style to range ["+l+"-"+s+"]: "+i.font+" "+i.style+" "+i.size+"pt"),function(){var e=new ActionDescriptor,t=new ActionDescriptor;t.putInteger(stringIDToTypeID("from"),l),t.putInteger(stringIDToTypeID("to"),s),e.putObject(stringIDToTypeID("range"),stringIDToTypeID("textRange"),t);var r=new ActionDescriptor;i.font&&"Unknown"!==i.font&&r.putString(stringIDToTypeID("fontName"),i.font),i.style&&"Regular"!==i.style&&r.putString(stringIDToTypeID("fontStyleName"),i.style),i.size&&r.putUnitDouble(stringIDToTypeID("size"),charIDToTypeID("#Pnt"),i.size),i.color&&i.color.length>0&&function(){var e=new ActionDescriptor,t=new ActionDescriptor;if(i.color.length>=3){t.putDouble(stringIDToTypeID("red"),i.color[0]),t.putDouble(stringIDToTypeID("green"),i.color[1]),t.putDouble(stringIDToTypeID("blue"),i.color[2]),e.putObject(stringIDToTypeID("color"),stringIDToTypeID("RGBColor"),t),r.putObject(stringIDToTypeID("color"),stringIDToTypeID("color"),e)}}(),e.putObject(stringIDToTypeID("textStyle"),stringIDToTypeID("textStyle"),r),executeAction(stringIDToTypeID("setd"),e,DialogModes.NO)}())}else $.writeln("Skipping invalid range ["+l+"-"+s+"]")}})();return!0}catch(r){return $.writeln("ERROR applying rich text formatting: "+r),$.writeln("Falling back to basic text update"),e.textItem.contents=n,!0}}else return $.writeln("Setting text to: "+n),e.textItem.contents=n,!0}catch(r){return $.writeln("ERROR updating text: "+r),!1}}return $.writeln("Not a text layer: "+e.name+" (kind: "+e.kind+")"),!1}function listAvailableTextLayers(e){function t(e,r){r=r||"";for(var n=0;n30?"...":"")+'"');var l={};for(i=0;i1;c&&$.writeln("Layer has rich text formatting information");updateTextLayer(u,y.updatedText,y)?t++:(r++,i.push(y.name+" (update failed)"))}else r++,i.push(y.name+" (not found)")}}}var g="Text Layer Update Complete\\n\\n";g+="Successfully updated: "+t+" layers\\n",g+="Skipped (no change): "+a+" layers\\n",g+="Failed to update: "+r+" layers\\n",r>0&&(g+="\\nFailed layers:\\n"+i.join("\\n")),alert(g)}}catch(e){alert("Error parsing JSON file: "+e.message+"\\n\\nPlease check that the file is valid JSON.")}}catch(e){alert("Error: "+e.message)}}main(); """ def find_psd_for_json(json_path: Path, psd_dir: Path) -> Optional[Path]: """ Attempts to find the matching PSD file for a given JSON file Args: json_path: Path to the JSON file psd_dir: Directory to search for PSD files Returns: Path to the matching PSD file, or None if not found """ # First check if the JSON filename contains the PSD name json_base = json_path.stem # Handle different naming conventions psd_name = json_base.replace("-textonly-updated", "").replace("-textonly", "") # Look for exact filename match psd_path = psd_dir / f"{psd_name}.psd" if psd_path.exists(): logger.info(f"Found matching PSD: {psd_path.name} for {json_path.name}") return psd_path # If exact match not found, try to load JSON and check documentName try: with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) if 'documentName' in data: doc_name = data['documentName'] # Remove extension if present doc_name = doc_name.rsplit('.', 1)[0] if '.' in doc_name else doc_name # Try to find PSD matching the document name psd_path = psd_dir / f"{doc_name}.psd" if psd_path.exists(): logger.info(f"Found matching PSD based on documentName: {psd_path.name}") return psd_path # Try searching for any PSD with a similar name for psd_file in psd_dir.glob("*.psd"): # Simple similarity check - if document name is contained in PSD name if doc_name.lower() in psd_file.stem.lower(): logger.info(f"Found likely matching PSD: {psd_file.name}") return psd_file except Exception as e: logger.warning(f"Error reading JSON {json_path.name}: {str(e)}") logger.warning(f"No matching PSD found for {json_path.name}") return None def update_text_in_psd(ps_app, psd_path: Path, json_path: Path) -> bool: """ Opens a PSD file in Photoshop and updates text layers using the JSON data Args: ps_app: The Photoshop application instance psd_path: Path to the PSD file json_path: Path to the JSON file with updated text Returns: True if update was successful, False otherwise """ try: # Open the PSD file logger.info(f"Opening {psd_path}") ps_app.Open(psd_path.as_posix()) # Modify the script to automatically use our JSON file modified_script = UPDATE_TEXT_SCRIPT.replace( 'var t=File.openDialog("Select the JSON file with text layer data","*.json");if(!t)return;', f'var t=new File("{json_path.as_posix().replace("\\", "\\\\")}");' ) # Execute the script logger.info(f"Updating text in {psd_path.name} using {json_path.name}") ps_app.DoJavaScript(modified_script) # Wait a bit for the script to complete time.sleep(2) logger.info(f"Successfully updated text in {psd_path.name}") return True except Exception as e: logger.error(f"Error updating text in {psd_path.name}: {str(e)}") return False finally: # Close the document without saving try: ps_app.ActiveDocument.Close(2) # 2 = Don't save changes except: pass def batch_update_text(json_dir: str, psd_dir: str, save_changes: bool = False) -> List[Tuple[str, str, bool]]: """ Processes all JSON files in the input directory and updates matching PSD files Args: json_dir: Directory containing JSON files psd_dir: Directory containing PSD files save_changes: Whether to save changes to PSD files Returns: List of tuples (json_path, psd_path, success) """ json_path = Path(json_dir).resolve() psd_path = Path(psd_dir).resolve() # Find all JSON files with our naming convention json_files = list(json_path.glob('*-textonly*.json')) if not json_files: logger.warning(f"No text JSON files found in {json_path}") return [] logger.info(f"Found {len(json_files)} JSON files to process") # Process each JSON file results = [] with Session() as ps: app = ps.app # Set close option based on save_changes parameter close_option = 3 if save_changes else 2 # 3 = Save changes, 2 = Don't save for json_file in json_files: # Find matching PSD file psd_file = find_psd_for_json(json_file, psd_path) if not psd_file: logger.warning(f"Skipping {json_file.name}: No matching PSD found") results.append((json_file.as_posix(), None, False)) continue # Update text in PSD success = update_text_in_psd(app, psd_file, json_file) # If requested to save changes and update was successful if success and save_changes: try: # Save the PSD file app.Open(psd_file.as_posix()) app.ActiveDocument.Save() app.ActiveDocument.Close(1) # 1 = Close without dialog logger.info(f"Saved changes to {psd_file.name}") except Exception as e: logger.error(f"Error saving {psd_file.name}: {str(e)}") results.append((json_file.as_posix(), psd_file.as_posix(), success)) logger.info(f"Successfully processed {len([r for r in results if r[2]])} of {len(results)} files") return results def parse_arguments(): """Parse command line arguments""" parser = argparse.ArgumentParser( description='Batch update text in PSD files using JSON data', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Update PSD files using JSON files in the current directory python batch_update_text.py . # Update PSD files in one directory using JSON files from another python batch_update_text.py /path/to/json_files -p /path/to/psd_files # Update and save changes to PSD files python batch_update_text.py /path/to/json_files --save # Dry-run (preview updates without saving) python batch_update_text.py /path/to/json_files --dry-run """ ) parser.add_argument('json_dir', help='Directory containing JSON files with text data') parser.add_argument('--psd-dir', '-p', default=None, help='Directory containing PSD files (defaults to json_dir)') parser.add_argument('--save', '-s', action='store_true', help='Save changes to PSD files') parser.add_argument('--dry-run', '-d', action='store_true', help='Preview updates without making changes') parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose logging') return parser.parse_args() def main(): """Main function""" args = parse_arguments() json_dir = args.json_dir psd_dir = args.psd_dir or json_dir save_changes = args.save and not args.dry_run # Set logging level based on verbose flag if args.verbose: logger.setLevel(logging.DEBUG) # Set log format to include more details for handler in logger.handlers: handler.setFormatter(logging.Formatter( '%(asctime)s - %(levelname)s - %(message)s', '%Y-%m-%d %H:%M:%S' )) if args.dry_run: print("\nDRY RUN MODE: Changes will be previewed but not applied.\n") logger.info(f"Processing JSON files from: {json_dir}") logger.info(f"Looking for PSD files in: {psd_dir}") logger.info(f"Save changes to PSDs: {save_changes}") if args.dry_run: # In dry-run mode, we only validate the files exist json_path = Path(json_dir).resolve() psd_path = Path(psd_dir).resolve() json_files = list(json_path.glob('*-textonly*.json')) if not json_files: logger.warning(f"No JSON files found in {json_path}") print(f"\nNo JSON files found in {json_path}. Check directory or naming convention.") return print(f"\nFound {len(json_files)} JSON files that would be processed:") for json_file in json_files: psd_file = find_psd_for_json(json_file, psd_path) if psd_file: print(f" MATCH: {json_file.name} → {psd_file.name}") else: print(f" NO MATCH: {json_file.name} (No matching PSD found)") print("\nDry run complete. No changes were made to PSD files.") return results = batch_update_text(json_dir, psd_dir, save_changes) if results: success_count = len([r for r in results if r[2]]) logger.info(f"Update complete. Successfully processed {success_count} of {len(results)} files:") for json_path, psd_path, success in results: status = "SUCCESS" if success else "FAILED" if psd_path: logger.info(f" {status}: {Path(json_path).name} → {Path(psd_path).name}") else: logger.info(f" {status}: {Path(json_path).name} (No matching PSD found)") print(f"\nUpdate complete:") print(f" Successfully processed: {success_count} of {len(results)} files") if save_changes: print(f" Changes have been saved to PSD files") else: print(f" Changes were applied but NOT saved (use --save to save changes)") else: logger.warning("No files were processed.") print(f"\nNo files were processed. Check that JSON files exist with '-textonly.json' suffix.") if __name__ == "__main__": main()