Local and cloud-based workflows for extracting and updating text layers in PSD files via ExtendScript and Adobe PS API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
500 lines
No EOL
22 KiB
JavaScript
500 lines
No EOL
22 KiB
JavaScript
/**
|
|
* Photoshop Script to Update Text Layers
|
|
*
|
|
* This script reads a JSON file containing text layer information
|
|
* and updates the text content of matching layers in the open PSD.
|
|
*
|
|
* Usage:
|
|
* 1. Open the PSD file in Photoshop
|
|
* 2. Run this script: File > Scripts > Browse... > updateTextLayers.jsx
|
|
* 3. Select the JSON file with text layer data
|
|
* 4. The script will update matching text layers
|
|
*/
|
|
|
|
// Enable double clicking from the Finder/Explorer
|
|
#target photoshop
|
|
|
|
// Function to read text file
|
|
function readTextFile(fileObj) {
|
|
fileObj.open('r');
|
|
var content = fileObj.read();
|
|
fileObj.close();
|
|
return content;
|
|
}
|
|
|
|
// Parse JSON function (safe for ExtendScript)
|
|
function parseJson(text) {
|
|
try {
|
|
// First try the native JSON parser if available
|
|
if (typeof JSON !== 'undefined' && JSON.parse) {
|
|
return JSON.parse(text);
|
|
}
|
|
} catch (e) {
|
|
$.writeln("Native JSON parse failed: " + e);
|
|
}
|
|
|
|
// Skip safety checks and just try eval directly
|
|
try {
|
|
// Direct eval as fallback
|
|
var data = eval('(' + text + ')');
|
|
return data;
|
|
} catch (e) {
|
|
$.writeln("JSON eval failed: " + e);
|
|
|
|
// Ultimate fallback: try to manually extract the layer data
|
|
try {
|
|
$.writeln("Attempting emergency JSON parsing...");
|
|
|
|
// Try to extract the textLayers array with a basic regex
|
|
var layersMatch = text.match(/"textLayers"\s*:\s*\[([\s\S]*?)\]\s*\}/);
|
|
if (layersMatch && layersMatch[1]) {
|
|
var layersText = layersMatch[1];
|
|
|
|
// Parse the text layers
|
|
var result = {
|
|
textLayers: []
|
|
};
|
|
|
|
// Find all layer objects
|
|
var layerRegex = /\{\s*"id"[\s\S]*?styleInfo[\s\S]*?\}\s*\}/g;
|
|
var layerMatch;
|
|
while ((layerMatch = layerRegex.exec(layersText)) !== null) {
|
|
var layerText = layerMatch[0];
|
|
|
|
// Extract the key fields
|
|
var nameMatch = layerText.match(/"name"\s*:\s*"([^"]*)"/);
|
|
var textMatch = layerText.match(/"text"\s*:\s*"([^"]*)"/);
|
|
var updatedTextMatch = layerText.match(/"updatedText"\s*:\s*"([^"]*)"/);
|
|
|
|
if (nameMatch && textMatch && updatedTextMatch) {
|
|
result.textLayers.push({
|
|
name: nameMatch[1].replace(/\\n/g, "\n"),
|
|
text: textMatch[1].replace(/\\n/g, "\n"),
|
|
updatedText: updatedTextMatch[1].replace(/\\n/g, "\n"),
|
|
exists: false // Will be checked later
|
|
});
|
|
}
|
|
}
|
|
|
|
if (result.textLayers.length > 0) {
|
|
$.writeln("Successfully extracted " + result.textLayers.length + " layers with emergency parser");
|
|
return result;
|
|
}
|
|
}
|
|
|
|
throw new Error("Emergency parsing failed");
|
|
} catch (emergencyError) {
|
|
$.writeln("Emergency parsing failed: " + emergencyError);
|
|
throw new Error("Failed to parse JSON: " + e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function to find a layer by name
|
|
function findLayerByName(doc, layerName) {
|
|
$.writeln("Looking for layer: " + layerName);
|
|
|
|
// Helper function to search in layer sets
|
|
function searchLayers(layers, indent) {
|
|
indent = indent || "";
|
|
for (var i = 0; i < layers.length; i++) {
|
|
var layer = layers[i];
|
|
|
|
$.writeln(indent + "- Checking: " + layer.name + " (Type: " + layer.typename + ")");
|
|
|
|
// Check if this is the layer we're looking for
|
|
if (layer.name === layerName) {
|
|
$.writeln(indent + " ✓ FOUND MATCH!");
|
|
return layer;
|
|
}
|
|
|
|
// If it's a group, search inside
|
|
if (layer.typename === "LayerSet") {
|
|
$.writeln(indent + " > Searching inside group: " + layer.name);
|
|
var found = searchInGroup(layer, indent + " ");
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper function to search in a layer group
|
|
function searchInGroup(group, indent) {
|
|
indent = indent || "";
|
|
for (var i = 0; i < group.layers.length; i++) {
|
|
var layer = group.layers[i];
|
|
|
|
$.writeln(indent + "- Checking: " + layer.name + " (Type: " + layer.typename + ")");
|
|
|
|
// Check if this is the layer we're looking for
|
|
if (layer.name === layerName) {
|
|
$.writeln(indent + " ✓ FOUND MATCH!");
|
|
return layer;
|
|
}
|
|
|
|
// If it's a group, search inside
|
|
if (layer.typename === "LayerSet") {
|
|
$.writeln(indent + " > Searching inside group: " + layer.name);
|
|
var found = searchInGroup(layer, indent + " ");
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Start search from the root layers
|
|
var result = searchLayers(doc.layers);
|
|
|
|
if (!result) {
|
|
$.writeln("❌ Layer not found: " + layerName);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Function to update text in a text layer
|
|
function updateTextLayer(layer, newText, styleData) {
|
|
if (layer.kind === LayerKind.TEXT) {
|
|
$.writeln("Updating text layer: " + layer.name);
|
|
$.writeln("Original layer text: " + layer.textItem.contents);
|
|
$.writeln("New text from JSON: " + newText);
|
|
|
|
try {
|
|
// Process line breaks - handle various escape sequences
|
|
var processedText = newText;
|
|
|
|
// Replace encoded newlines with actual newlines
|
|
processedText = processedText.replace(/\\n/g, "\n");
|
|
processedText = processedText.replace(/\\r/g, "\r");
|
|
|
|
// Check if we have rich text formatting data for this layer
|
|
var hasRichFormatting = styleData &&
|
|
styleData.styleInfo &&
|
|
styleData.styleInfo.styles &&
|
|
styleData.styleInfo.styles.length > 1 &&
|
|
styleData.hasRichTextFormatting;
|
|
|
|
if (hasRichFormatting) {
|
|
$.writeln("Layer has rich text formatting - attempting to preserve styles with the new text");
|
|
|
|
try {
|
|
// First update the basic text content
|
|
$.writeln("Setting basic text to: " + processedText);
|
|
layer.textItem.contents = processedText;
|
|
|
|
// Make this the active layer to work with it
|
|
app.activeDocument.activeLayer = layer;
|
|
|
|
// For rich text, we need to use action descriptors to apply styling
|
|
$.writeln("Rich text update is experimental and may not work perfectly");
|
|
$.writeln("Original style ranges count: " + styleData.styleInfo.styles.length);
|
|
|
|
// Now we'll attempt to recreate the style formatting on the new text
|
|
// This is a complex operation and may not work perfectly in all cases
|
|
|
|
// Only apply this if we have the advanced formatting data
|
|
if (styleData.styleInfo.styles.length > 0) {
|
|
$.writeln("Attempting to apply rich text formatting to " + styleData.styleInfo.styles.length + " style ranges");
|
|
|
|
// Get the current layer ID - needed for targeting our actions
|
|
var ref = new ActionReference();
|
|
ref.putEnumerated(charIDToTypeID("Lyr "), charIDToTypeID("Ordn"), charIDToTypeID("Trgt"));
|
|
var layerDesc = executeActionGet(ref);
|
|
|
|
// For each style range in our data, we'll attempt to apply it to the corresponding text
|
|
for (var i = 0; i < styleData.styleInfo.styles.length; i++) {
|
|
var style = styleData.styleInfo.styles[i];
|
|
|
|
// Get the corresponding text range in the new text
|
|
// For simplicity, we'll use the same character positions
|
|
// This is imperfect but should work for direct translations
|
|
var rangeStart = style.start;
|
|
var rangeEnd = style.end;
|
|
|
|
// Safety check - make sure our range is within the text bounds
|
|
if (rangeEnd > processedText.length) {
|
|
rangeEnd = processedText.length;
|
|
}
|
|
|
|
// Only apply if we have a valid range
|
|
if (rangeStart < rangeEnd && rangeStart >= 0 && rangeEnd <= processedText.length) {
|
|
$.writeln("Applying style to range [" + rangeStart + "-" + rangeEnd + "]: " +
|
|
style.font + " " + style.style + " " + style.size + "pt");
|
|
|
|
// Select the text range
|
|
var rangeDesc = new ActionDescriptor();
|
|
var fromDesc = new ActionDescriptor();
|
|
fromDesc.putInteger(stringIDToTypeID("from"), rangeStart);
|
|
fromDesc.putInteger(stringIDToTypeID("to"), rangeEnd);
|
|
rangeDesc.putObject(stringIDToTypeID("range"), stringIDToTypeID("textRange"), fromDesc);
|
|
|
|
// Create the font style descriptor
|
|
var styleDesc = new ActionDescriptor();
|
|
|
|
// Set font family and style if available
|
|
if (style.font && style.font !== "Unknown") {
|
|
styleDesc.putString(stringIDToTypeID("fontName"), style.font);
|
|
}
|
|
|
|
if (style.style && style.style !== "Regular") {
|
|
styleDesc.putString(stringIDToTypeID("fontStyleName"), style.style);
|
|
}
|
|
|
|
// Set font size
|
|
if (style.size) {
|
|
styleDesc.putUnitDouble(stringIDToTypeID("size"), charIDToTypeID("#Pnt"), style.size);
|
|
}
|
|
|
|
// Set color if available
|
|
if (style.color && style.color.length > 0) {
|
|
// Color handling is complex and would need to match the document's color mode
|
|
// This is a simplified approach
|
|
var colorDesc = new ActionDescriptor();
|
|
var rgb = new ActionDescriptor();
|
|
|
|
// Assuming RGB values in 0-255 range
|
|
if (style.color.length >= 3) {
|
|
rgb.putDouble(stringIDToTypeID("red"), style.color[0]);
|
|
rgb.putDouble(stringIDToTypeID("green"), style.color[1]);
|
|
rgb.putDouble(stringIDToTypeID("blue"), style.color[2]);
|
|
colorDesc.putObject(stringIDToTypeID("color"), stringIDToTypeID("RGBColor"), rgb);
|
|
styleDesc.putObject(stringIDToTypeID("color"), stringIDToTypeID("color"), colorDesc);
|
|
}
|
|
}
|
|
|
|
// Apply the styling to the range
|
|
rangeDesc.putObject(stringIDToTypeID("textStyle"), stringIDToTypeID("textStyle"), styleDesc);
|
|
executeAction(stringIDToTypeID("setd"), rangeDesc, DialogModes.NO);
|
|
} else {
|
|
$.writeln("Skipping invalid range [" + rangeStart + "-" + rangeEnd + "]");
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (richTextError) {
|
|
$.writeln("ERROR applying rich text formatting: " + richTextError);
|
|
$.writeln("Falling back to basic text update");
|
|
|
|
// If rich text formatting fails, fall back to basic update
|
|
layer.textItem.contents = processedText;
|
|
return true;
|
|
}
|
|
} else {
|
|
// Basic text update
|
|
$.writeln("Setting text to: " + processedText);
|
|
layer.textItem.contents = processedText;
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
$.writeln("ERROR updating text: " + e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$.writeln("Not a text layer: " + layer.name + " (kind: " + layer.kind + ")");
|
|
return false;
|
|
}
|
|
|
|
// Function to list all available text layers in the document
|
|
function listAvailableTextLayers(doc) {
|
|
var allLayers = [];
|
|
|
|
function collectLayers(layers, path) {
|
|
path = path || "";
|
|
|
|
for (var i = 0; i < layers.length; i++) {
|
|
var layer = layers[i];
|
|
var layerPath = path ? path + "/" + layer.name : layer.name;
|
|
|
|
// Add this layer to our collection if it's a text layer
|
|
if (layer.kind === LayerKind.TEXT) {
|
|
allLayers.push({
|
|
name: layer.name,
|
|
path: layerPath,
|
|
text: layer.textItem.contents
|
|
});
|
|
}
|
|
|
|
// If it's a group, collect layers from inside
|
|
if (layer.typename === "LayerSet") {
|
|
collectLayers(layer.layers, layerPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start collecting from the root
|
|
collectLayers(doc.layers);
|
|
return allLayers;
|
|
}
|
|
|
|
// Main function
|
|
function main() {
|
|
try {
|
|
// Check if a document is open
|
|
if (!documents.length) {
|
|
alert("Please open a PSD file before running this script.");
|
|
return;
|
|
}
|
|
|
|
// Get the active document
|
|
var doc = app.activeDocument;
|
|
|
|
// Prompt user to select the JSON file
|
|
var jsonFile = File.openDialog("Select the JSON file with text layer data", "*.json");
|
|
if (!jsonFile) {
|
|
return; // User cancelled
|
|
}
|
|
|
|
// Read and parse the JSON file
|
|
var jsonContent = readTextFile(jsonFile);
|
|
|
|
// Clean up common JSON errors
|
|
jsonContent = jsonContent
|
|
// Remove "px" or other units from numeric values
|
|
.replace(/(\d+)\s*px/g, '$1')
|
|
// Fix unescaped backslashes
|
|
.replace(/([^\\])\\([^\\"])/g, '$1\\\\$2')
|
|
// Remove trailing commas in arrays and objects
|
|
.replace(/,\s*([}\]])/g, '$1')
|
|
// Fix newline characters
|
|
.replace(/\r\n/g, '\\n')
|
|
.replace(/\r/g, '\\n');
|
|
|
|
try {
|
|
var textData = parseJson(jsonContent);
|
|
|
|
if (!textData.textLayers || !textData.textLayers.length) {
|
|
alert("No text layers found in the JSON file.");
|
|
return;
|
|
}
|
|
|
|
// First, get all available text layers in the document
|
|
$.writeln("\n=== SCANNING DOCUMENT FOR TEXT LAYERS ===");
|
|
var availableLayers = listAvailableTextLayers(doc);
|
|
$.writeln("Found " + availableLayers.length + " text layers in document");
|
|
|
|
for (var i = 0; i < availableLayers.length; i++) {
|
|
$.writeln((i+1) + ". " + availableLayers[i].name + ": \"" +
|
|
availableLayers[i].text.substring(0, 30) +
|
|
(availableLayers[i].text.length > 30 ? "..." : "") + "\"");
|
|
}
|
|
|
|
// Map layer names to their information for quick lookup
|
|
var availableLayerMap = {};
|
|
for (var i = 0; i < availableLayers.length; i++) {
|
|
availableLayerMap[availableLayers[i].name] = availableLayers[i];
|
|
}
|
|
|
|
// Start undo group for the entire operation
|
|
app.activeDocument.suspendHistory("Update Text Layers", "updateLayers()");
|
|
|
|
function updateLayers() {
|
|
// Counter for successfully updated layers
|
|
var updatedCount = 0;
|
|
var failedCount = 0;
|
|
var skippedCount = 0;
|
|
var failedLayers = [];
|
|
|
|
// First, check which layers from JSON exist in the document
|
|
$.writeln("\n=== MATCHING JSON LAYERS WITH DOCUMENT ===");
|
|
for (var i = 0; i < textData.textLayers.length; i++) {
|
|
var layerName = textData.textLayers[i].name;
|
|
if (availableLayerMap[layerName]) {
|
|
$.writeln("✓ Found match for: " + layerName);
|
|
// Add a flag to indicate this layer exists in the document
|
|
textData.textLayers[i].exists = true;
|
|
} else {
|
|
$.writeln("❌ No match found for: " + layerName);
|
|
// Mark as not existing
|
|
textData.textLayers[i].exists = false;
|
|
}
|
|
}
|
|
|
|
// Process each text layer in the JSON file
|
|
for (var i = 0; i < textData.textLayers.length; i++) {
|
|
var layerData = textData.textLayers[i];
|
|
|
|
// Debug: Print layer data to console
|
|
$.writeln("\n=== PROCESSING LAYER: " + layerData.name + " ===");
|
|
|
|
// Skip layers that don't exist in the document
|
|
if (!layerData.exists) {
|
|
$.writeln("⚠️ Skipping - layer doesn't exist in document");
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
$.writeln("Original text: \"" + layerData.text + "\"");
|
|
$.writeln("Updated text: \"" + layerData.updatedText + "\"");
|
|
|
|
// Skip if no updatedText
|
|
if (!layerData.updatedText) {
|
|
$.writeln("⚠️ Skipping - no updated text");
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Simple comparison to see if text has changed at all
|
|
var hasExactSameText = layerData.text === layerData.updatedText;
|
|
|
|
$.writeln("Exact text match? " + hasExactSameText);
|
|
|
|
if (hasExactSameText) {
|
|
$.writeln("⚠️ Skipping - completely identical text");
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Find the layer in the document using our direct search
|
|
$.writeln("Looking for layer: " + layerData.name);
|
|
var layer = findLayerByName(doc, layerData.name);
|
|
|
|
if (layer) {
|
|
// Check if the layer has rich text formatting information
|
|
var hasRichTextInfo = layerData.hasRichTextFormatting ||
|
|
(layerData.styleInfo &&
|
|
layerData.styleInfo.styles &&
|
|
layerData.styleInfo.styles.length > 1);
|
|
|
|
if (hasRichTextInfo) {
|
|
$.writeln("Layer has rich text formatting information");
|
|
}
|
|
|
|
// Update the text, passing the entire layer data for styling
|
|
var success = updateTextLayer(layer, layerData.updatedText, layerData);
|
|
if (success) {
|
|
updatedCount++;
|
|
} else {
|
|
failedCount++;
|
|
failedLayers.push(layerData.name + " (update failed)");
|
|
}
|
|
} else {
|
|
failedCount++;
|
|
failedLayers.push(layerData.name + " (not found)");
|
|
}
|
|
}
|
|
|
|
// Show results
|
|
var resultMessage = "Text Layer Update Complete\n\n";
|
|
resultMessage += "Successfully updated: " + updatedCount + " layers\n";
|
|
resultMessage += "Skipped (no change): " + skippedCount + " layers\n";
|
|
resultMessage += "Failed to update: " + failedCount + " layers\n";
|
|
|
|
if (failedCount > 0) {
|
|
resultMessage += "\nFailed layers:\n" + failedLayers.join("\n");
|
|
}
|
|
|
|
alert(resultMessage);
|
|
}
|
|
|
|
} catch (e) {
|
|
alert("Error parsing JSON file: " + e.message + "\n\nPlease check that the file is valid JSON.");
|
|
}
|
|
} catch (err) {
|
|
alert("Error: " + err.message);
|
|
}
|
|
}
|
|
|
|
// Run the script
|
|
main(); |