#!/usr/bin/env python3 """ Mac Photoshop Text Updater -------------------------- A macOS-specific script to update text in PSD files using AppleScript to control Photoshop and execute ExtendScript (JSX) code. This is designed to work on macOS without requiring the photoshop-python-api package which has Windows dependencies. """ import os import sys import time import json import argparse import subprocess from pathlib import Path import logging from typing import List, Dict, Any, Optional, Tuple # 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 UPDATE_TEXT_SCRIPT = r""" // Photoshop Script to Update Text Layers #target photoshop // Disable all dialogs app.displayDialogs = DialogModes.NO; app.displayStatusDialogs = false; 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;n1; 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); } function main(){try{if(!documents.length)return void alert("Please open a PSD file before running this script.");var e=app.activeDocument,t=File(JSON_FILE_PATH);if(!t)return;var r=readTextFile(t);r=(r=(r=(r=r.replace(/(\d+)\\s*px/g,"$1")).replace(/([^\\\\])\\\\([^\\\\"])/g,"$1\\\\\\\\$2")).replace(/,\\s*([}\\]])/g,"$1")).replace(/\\r\\n/g,"\\\\n").replace(/\\r/g,"\\\\n");try{var n=parseJson(r);if(!n.textLayers||!n.textLayers.length)return void alert("No text layers found in the JSON file.");$.writeln("\\n=== SCANNING DOCUMENT FOR TEXT LAYERS ===");var a=listAvailableTextLayers(e);$.writeln("Found "+a.length+" text layers in document");for(var i=0;i30?"...":"")+'"');var l={};for(i=0;i Optional[str]: """Find the Photoshop application path""" ps_paths = [ "/Applications/Adobe Photoshop 2025/Adobe Photoshop 2025.app", "/Applications/Adobe Photoshop 2024/Adobe Photoshop 2024.app", "/Applications/Adobe Photoshop/Adobe Photoshop.app", "/Applications/Adobe Photoshop CC 2025/Adobe Photoshop CC 2025.app", "/Applications/Adobe Photoshop CC 2024/Adobe Photoshop CC 2024.app", ] for path in ps_paths: if os.path.exists(path): logger.info(f"Found Photoshop at: {path}") return path return None def _launch_photoshop(self) -> bool: """Launch Photoshop if it's not already running""" try: # First determine the correct AppleScript name for Photoshop ps_names = [ "Adobe Photoshop 2025", "Adobe Photoshop 2024", "Adobe Photoshop CC 2025", "Adobe Photoshop CC 2024", "Adobe Photoshop" ] # Try to find a running instance first ps_running_script = """ tell application "System Events" set ps_processes to (every process whose name begins with "Adobe Photoshop") if (count of ps_processes) > 0 then return true else return false end if end tell """ result = subprocess.run( ["osascript", "-e", ps_running_script], capture_output=True, text=True, check=True ) is_running = result.stdout.strip() == "true" if is_running: logger.info("Photoshop is already running") return True # Try each possible application name for ps_name in ps_names: logger.info(f"Trying to launch Photoshop as: {ps_name}") try: # Check if this application exists check_app_script = f""" tell application "System Events" return exists application process "{ps_name}" end tell """ check_result = subprocess.run( ["osascript", "-e", check_app_script], capture_output=True, text=True, check=False ) if check_result.returncode == 0 and check_result.stdout.strip() == "true": # Launch this version of Photoshop launch_script = f""" tell application "{ps_name}" activate end tell """ subprocess.run(["osascript", "-e", launch_script], check=True) logger.info(f"Photoshop ({ps_name}) launched successfully") time.sleep(5) # Give Photoshop more time to initialize self.ps_app_name = ps_name return True else: # Try launching it anyway try: launch_script = f""" tell application "{ps_name}" activate end tell """ result = subprocess.run(["osascript", "-e", launch_script], check=False, capture_output=True, text=True) if result.returncode == 0: logger.info(f"Photoshop ({ps_name}) launched successfully") time.sleep(5) # Give Photoshop more time to initialize self.ps_app_name = ps_name return True except Exception as ex: logger.debug(f"Failed to launch {ps_name}: {ex}") continue except Exception as ex: logger.debug(f"Failed to check {ps_name}: {ex}") continue # If we got here, we couldn't launch any version logger.error("Couldn't launch any version of Photoshop") return False except Exception as e: logger.error(f"Error launching Photoshop: {e}") return False def open_file(self, file_path: str) -> bool: """Open a PSD file in Photoshop""" try: file_path = os.path.abspath(file_path) logger.debug(f"Attempting to open file: {file_path}") # Escape quotes and backslashes in the path file_path_escaped = file_path.replace('\\', '\\\\').replace('"', '\\"') # Use a more reliable approach with shell quoting # Create a temporary AppleScript file with properly formatted path temp_script_path = os.path.expanduser("~/Desktop/temp_ps_open.scpt") # The key is to use single quotes for the outer string and double quotes for the inner POSIX file path script_content = f''' tell application "{self.ps_app_name}" set theFile to POSIX file "{file_path_escaped}" open theFile end tell ''' with open(temp_script_path, "w") as f: f.write(script_content) # Run the AppleScript directly from the file result = subprocess.run(["osascript", temp_script_path], capture_output=True, text=True) if result.returncode != 0: logger.error(f"AppleScript error: {result.stderr}") # Try alternate method using the 'do shell script' approach logger.debug("Trying alternate method...") alt_script_content = f''' tell application "{self.ps_app_name}" activate end tell do shell script "open -a '{self.ps_app_name}' '{file_path_escaped}'" ''' with open(temp_script_path, "w") as f: f.write(alt_script_content) result = subprocess.run(["osascript", temp_script_path], capture_output=True, text=True) if result.returncode != 0: logger.error(f"Alternate method also failed: {result.stderr}") return False # Clean up the temporary script file try: os.remove(temp_script_path) except: pass logger.info(f"Opened file: {file_path}") return True except Exception as e: logger.error(f"Error opening file: {e}") return False def run_jsx_script(self, script: str, script_args: dict = None) -> bool: """Run a JSX script in Photoshop with optional arguments""" try: # Create a temporary script file script_path = os.path.expanduser("~/Desktop/temp_ps_script.jsx") # If we have arguments to pass to the script if script_args: # Add variable declarations at the top of the script var_declarations = "" for var_name, var_value in script_args.items(): if isinstance(var_value, str): # Escape backslashes and quotes in string values escaped_value = var_value.replace('\\', '\\\\').replace('"', '\\"') var_declarations += f'var {var_name} = "{escaped_value}";\n' else: var_declarations += f'var {var_name} = {var_value};\n' script = var_declarations + script # Write the script to a file with open(script_path, "w") as f: f.write(script) logger.debug(f"JSX script written to: {script_path}") # Create a temporary AppleScript file with properly formatted path temp_applescript_path = os.path.expanduser("~/Desktop/temp_ps_run.scpt") # Escape quotes and backslashes in the path script_path_escaped = script_path.replace('\\', '\\\\').replace('"', '\\"') # Use the proper syntax for the do javascript file command script_content = f''' tell application "{self.ps_app_name}" do javascript file "{script_path_escaped}" end tell ''' with open(temp_applescript_path, "w") as f: f.write(script_content) # Run the AppleScript file directly result = subprocess.run(["osascript", temp_applescript_path], capture_output=True, text=True) if result.returncode != 0: logger.error(f"AppleScript error: {result.stderr}") # Try alternate method using the 'open' command logger.debug("Trying alternate method to run JSX...") result = subprocess.run( ["open", "-a", self.ps_app_name, script_path], check=False, capture_output=True, text=True ) if result.returncode != 0: logger.error(f"Alternate method also failed: {result.stderr}") return False # Clean up the temporary files try: os.remove(script_path) os.remove(temp_applescript_path) except: pass logger.info("JSX script executed successfully") return True except Exception as e: logger.error(f"Error running JSX script: {e}") return False def close_document(self, save_changes: bool = False) -> bool: """Close the active document""" try: close_script = f""" tell application "{self.ps_app_name}" close current document saving {"yes" if save_changes else "no"} end tell """ subprocess.run(["osascript", "-e", close_script], check=True) logger.info(f"Closed document (save={save_changes})") return True except Exception as e: logger.error(f"Error closing document: {e}") return False 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 both -textonly.json and -textonly-updated.json 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(psd_path: Path, json_path: Path, save_changes: bool = False) -> bool: """ Updates text layers in a PSD file using data from a JSON file Args: psd_path: Path to the PSD file json_path: Path to the JSON file with updated text save_changes: Whether to save changes to the PSD file Returns: True if the update was successful, False otherwise """ ps = MacPhotoshop() try: # Open the PSD file if not ps.open_file(str(psd_path)): logger.error(f"Failed to open {psd_path}") return False # Run the update script with the JSON file path script_args = { "JSON_FILE_PATH": json_path.as_posix() } # Replace the part that opens a file dialog with our direct file path 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=File(JSON_FILE_PATH);if(!t)return;' ) if not ps.run_jsx_script(modified_script, script_args): logger.error(f"Failed to run update script on {psd_path}") return False # Close the document, optionally saving changes ps.close_document(save_changes=save_changes) logger.info(f"Successfully updated text in {psd_path.name}" + (" and saved changes" if save_changes else "")) return True except Exception as e: logger.error(f"Error updating text in {psd_path}: {str(e)}") return False def batch_update_text(json_dir: str, psd_dir: str, save_changes: bool = False) -> List[Tuple[str, str, bool]]: """ Updates text in PSD files based on JSON files in the input directory 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 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 = [] 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(psd_file, json_file, save_changes) 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='Update text in PSD files on macOS using JSON data', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Update PSD files using JSON files in the current directory python mac_ps_update.py . # Update PSD files in one directory using JSON files from another python mac_ps_update.py /path/to/json_files -p /path/to/psd_files # Update and save changes to PSD files python mac_ps_update.py /path/to/json_files --save # Preview updates without making changes python mac_ps_update.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()