// This script exports photoshop layers as individual PNGs. It also // writes a JSON file that can be imported into Spine where the images // will be displayed in the same positions and draw order. // Setting defaults. var writePngs = true; var writeTemplate = false; var writeJson = true; var ignoreHiddenLayers = true; var pngScale = 1; var groupsAsSkins = false; var useRulerOrigin = false; var imagesDir = "./images/"; var projectDir = ""; var padding = 1; // IDs for saving settings. const settingsID = stringIDToTypeID("settings"); const writePngsID = stringIDToTypeID("writePngs"); const writeTemplateID = stringIDToTypeID("writeTemplate"); const writeJsonID = stringIDToTypeID("writeJson"); const ignoreHiddenLayersID = stringIDToTypeID("ignoreHiddenLayers"); const groupsAsSkinsID = stringIDToTypeID("groupsAsSkins"); const useRulerOriginID = stringIDToTypeID("useRulerOrigin"); const pngScaleID = stringIDToTypeID("pngScale"); const imagesDirID = stringIDToTypeID("imagesDir"); const projectDirID = stringIDToTypeID("projectDir"); const paddingID = stringIDToTypeID("padding"); var originalDoc; try { originalDoc = app.activeDocument; } catch (ignored) {} var settings, progress; loadSettings(); showDialog(); function run () { // Output dirs. var absProjectDir = absolutePath(projectDir); new Folder(absProjectDir).create(); var absImagesDir = absolutePath(imagesDir); var imagesFolder = new Folder(absImagesDir); imagesFolder.create(); var relImagesDir = imagesFolder.getRelativeURI(absProjectDir); relImagesDir = relImagesDir == "." ? "" : (relImagesDir + "/"); // Get ruler origin. var xOffSet = 0, yOffSet = 0; if (useRulerOrigin) { var ref = new ActionReference(); ref.putEnumerated(charIDToTypeID("Dcmn"), charIDToTypeID("Ordn"), charIDToTypeID("Trgt")); var desc = executeActionGet(ref); xOffSet = desc.getInteger(stringIDToTypeID("rulerOriginH")) >> 16; yOffSet = desc.getInteger(stringIDToTypeID("rulerOriginV")) >> 16; } activeDocument.duplicate(); // Output template image. if (writeTemplate) { if (pngScale != 1) { scaleImage(); storeHistory(); } var file = new File(absImagesDir + "template" + ".png"); if (file.exists) file.remove(); var opts; opts = new ExportOptionsSaveForWeb(); opts.format = SaveDocumentType.PNG; opts.PNG8 = false; opts.quality = 100; activeDocument.exportDocument(file, ExportType.SAVEFORWEB, opts); if (pngScale != 1) restoreHistory(); } if (!writeJson && !writePngs) { activeDocument.close(SaveOptions.DONOTSAVECHANGES); return; } // Rasterize all layers. try { executeAction(stringIDToTypeID( "rasterizeAll" ), undefined, DialogModes.NO); } catch (ignored) {} // Collect and hide layers. var layers = []; collectLayers(activeDocument, layers); var layersCount = layers.length; storeHistory(); // Store the slot names and layers for each skin. var slots = {}, skins = { "default": [] }; for (var i = layersCount - 1; i >= 0; i--) { var layer = layers[i]; // Use groups as skin names. var potentialSkinName = trim(layer.parent.name); var layerGroupSkin = potentialSkinName.indexOf("-NOSKIN") == -1; var skinName = (groupsAsSkins && layer.parent.typename == "LayerSet" && layerGroupSkin) ? potentialSkinName : "default"; var skinLayers = skins[skinName]; if (!skinLayers) skins[skinName] = skinLayers = []; skinLayers[skinLayers.length] = layer; slots[layerName(layer)] = true; } // Output skeleton and bones. var json = '{"skeleton":{"images":"' + relImagesDir + '"},\n"bones":[{"name":"root"}],\n"slots":[\n'; // Output slots. var slotsCount = countAssocArray(slots); var slotIndex = 0; for (var slotName in slots) { if (!slots.hasOwnProperty(slotName)) continue; // Use image prefix if slot's attachment is in the default skin. var attachmentName = slotName; var defaultSkinLayers = skins["default"]; for (var i = defaultSkinLayers.length - 1; i >= 0; i--) { if (layerName(defaultSkinLayers[i]) == slotName) { attachmentName = slotName; break; } } json += '\t{"name":"' + slotName + '","bone":"root","attachment":"' + attachmentName + '"}'; slotIndex++; json += slotIndex < slotsCount ? ",\n" : "\n"; } json += '],\n"skins":{\n'; // Output skins. var skinsCount = countAssocArray(skins); var skinIndex = 0; for (var skinName in skins) { if (!skins.hasOwnProperty(skinName)) continue; json += '\t"' + skinName + '":{\n'; var skinLayers = skins[skinName]; var skinLayersCount = skinLayers.length; var skinLayerIndex = 0; for (var i = skinLayersCount - 1; i >= 0; i--) { var layer = skinLayers[i]; var slotName = layerName(layer); var placeholderName, attachmentName; if (skinName == "default") { placeholderName = slotName; attachmentName = placeholderName; } else { placeholderName = slotName; attachmentName = skinName + "/" + slotName; } var x = activeDocument.width.as("px") * pngScale; var y = activeDocument.height.as("px") * pngScale; layer.visible = true; if (!layer.isBackgroundLayer) activeDocument.trim(TrimType.TRANSPARENT, false, true, true, false); x -= activeDocument.width.as("px") * pngScale; y -= activeDocument.height.as("px") * pngScale; if (!layer.isBackgroundLayer) activeDocument.trim(TrimType.TRANSPARENT, true, false, false, true); var width = activeDocument.width.as("px") * pngScale + padding * 2; var height = activeDocument.height.as("px") * pngScale + padding * 2; // Save image. if (writePngs) { if (pngScale != 1) scaleImage(); if (padding > 0) activeDocument.resizeCanvas(width, height, AnchorPosition.MIDDLECENTER); if (skinName != "default") new Folder(absImagesDir + skinName).create(); var opts; opts = new ExportOptionsSaveForWeb(); opts.format = SaveDocumentType.PNG; opts.PNG8 = false; opts.quality = 100; activeDocument.exportDocument(new File(absImagesDir + attachmentName + ".png"), ExportType.SAVEFORWEB, opts); } restoreHistory(); layer.visible = false; x += Math.round(width) / 2; y += Math.round(height) / 2; // Make relative to the Photoshop document ruler origin. if (useRulerOrigin) { x -= xOffSet * pngScale; y -= activeDocument.height.as("px") * pngScale - yOffSet * pngScale; // Invert y. } if (attachmentName == placeholderName) { json += '\t\t"' + slotName + '":{"' + placeholderName + '":{' + '"x":' + x + ',"y":' + y + ',"width":' + Math.round(width) + ',"height":' + Math.round(height) + '}}'; } else { json += '\t\t"' + slotName + '":{"' + placeholderName + '":{"name":"' + attachmentName + '", ' + '"x":' + x + ',"y":' + y + ',"width":' + Math.round(width) + ',"height":' + Math.round(height) + '}}'; } skinLayerIndex++; json += skinLayerIndex < skinLayersCount ? ",\n" : "\n"; } json += "\t\}"; skinIndex++; json += skinIndex < skinsCount ? ",\n" : "\n"; } json += '},\n"animations":{"animation":{}}\n}'; activeDocument.close(SaveOptions.DONOTSAVECHANGES); // Output JSON file. if (writeJson) { var name = decodeURI(originalDoc.name); name = name.substring(0, name.indexOf(".")); var file = new File(absProjectDir + name + ".json"); file.remove(); file.open("w", "TEXT"); file.lineFeed = "\n"; file.write(json); file.close(); } } // Dialog and settings: function showDialog () { if (!originalDoc) { alert("Please open a document before running the LayersToPNG script."); return; } if (!hasFilePath()) { alert("Please save the document before running the LayersToPNG script."); return; } var dialog = new Window("dialog", "Spine LayersToPNG"); dialog.alignChildren = "fill"; var checkboxGroup = dialog.add("group"); var group = checkboxGroup.add("group"); group.orientation = "column"; group.alignChildren = "left"; var writePngsCheckbox = group.add("checkbox", undefined, " Write layers as PNGs"); writePngsCheckbox.value = writePngs; var writeTemplateCheckbox = group.add("checkbox", undefined, " Write a template PNG"); writeTemplateCheckbox.value = writeTemplate; var writeJsonCheckbox = group.add("checkbox", undefined, " Write Spine JSON"); writeJsonCheckbox.value = writeJson; group = checkboxGroup.add("group"); group.orientation = "column"; group.alignChildren = "left"; var ignoreHiddenLayersCheckbox = group.add("checkbox", undefined, " Ignore hidden layers"); ignoreHiddenLayersCheckbox.value = ignoreHiddenLayers; var groupsAsSkinsCheckbox = group.add("checkbox", undefined, " Use groups as skins"); groupsAsSkinsCheckbox.value = groupsAsSkins; var useRulerOriginCheckbox = group.add("checkbox", undefined, " Use ruler origin as 0,0"); useRulerOriginCheckbox.value = useRulerOrigin; var slidersGroup = dialog.add("group"); group = slidersGroup.add("group"); group.orientation = "column"; group.alignChildren = "right"; group.add("statictext", undefined, "PNG scale:"); group.add("statictext", undefined, "Padding:"); group = slidersGroup.add("group"); group.orientation = "column"; var scaleText = group.add("edittext", undefined, pngScale * 100); scaleText.characters = 4; var paddingText = group.add("edittext", undefined, padding); paddingText.characters = 4; group = slidersGroup.add("group"); group.orientation = "column"; group.add("statictext", undefined, "%"); group.add("statictext", undefined, "px"); group = slidersGroup.add("group"); group.alignment = ["fill", ""]; group.orientation = "column"; group.alignChildren = ["fill", ""]; var scaleSlider = group.add("slider", undefined, pngScale * 100, 1, 100); var paddingSlider = group.add("slider", undefined, padding, 0, 4); scaleText.onChanging = function () { scaleSlider.value = scaleText.text; }; scaleSlider.onChanging = function () { scaleText.text = Math.round(scaleSlider.value); }; paddingText.onChanging = function () { paddingSlider.value = paddingText.text; }; paddingSlider.onChanging = function () { paddingText.text = Math.round(paddingSlider.value); }; var outputGroup = dialog.add("panel", undefined, "Output directories"); outputGroup.alignChildren = "fill"; outputGroup.margins = [10,15,10,10]; var textGroup = outputGroup.add("group"); group = textGroup.add("group"); group.orientation = "column"; group.alignChildren = "right"; group.add("statictext", undefined, "Images:"); group.add("statictext", undefined, "JSON:"); group = textGroup.add("group"); group.orientation = "column"; group.alignChildren = "fill"; group.alignment = ["fill", ""]; var imagesDirText = group.add("edittext", undefined, imagesDir); var projectDirText = group.add("edittext", undefined, projectDir); outputGroup.add("statictext", undefined, "Begin paths with \"./\" to be relative to the PSD file.").alignment = "center"; var group = dialog.add("group"); group.alignment = "center"; var runButton = group.add("button", undefined, "OK"); var cancelButton = group.add("button", undefined, "Cancel"); cancelButton.onClick = function () { dialog.close(0); return; }; function updateSettings () { writePngs = writePngsCheckbox.value; writeTemplate = writeTemplateCheckbox.value; writeJson = writeJsonCheckbox.value; ignoreHiddenLayers = ignoreHiddenLayersCheckbox.value; var scaleValue = parseFloat(scaleText.text); if (scaleValue > 0 && scaleValue <= 100) pngScale = scaleValue / 100; groupsAsSkins = groupsAsSkinsCheckbox.value; useRulerOrigin = useRulerOriginCheckbox.value; imagesDir = imagesDirText.text; projectDir = projectDirText.text; var paddingValue = parseInt(paddingText.text); if (paddingValue >= 0) padding = paddingValue; } dialog.onClose = function() { updateSettings(); saveSettings(); }; runButton.onClick = function () { if (scaleText.text <= 0 || scaleText.text > 100) { alert("PNG scale must be between > 0 and <= 100."); return; } if (paddingText.text < 0) { alert("Padding must be >= 0."); return; } dialog.close(0); var rulerUnits = app.preferences.rulerUnits; app.preferences.rulerUnits = Units.PIXELS; try { run(); } catch (e) { alert("An unexpected error has occurred.\n\nTo debug, run the LayersToPNG script using Adobe ExtendScript " + "with \"Debug > Do not break on guarded exceptions\" unchecked."); debugger; } finally { if (activeDocument != originalDoc) activeDocument.close(SaveOptions.DONOTSAVECHANGES); app.preferences.rulerUnits = rulerUnits; } }; dialog.center(); dialog.show(); } function loadSettings () { try { settings = app.getCustomOptions(settingsID); } catch (e) { saveSettings(); } if (typeof settings == "undefined") saveSettings(); settings = app.getCustomOptions(settingsID); if (settings.hasKey(writePngsID)) writePngs = settings.getBoolean(writePngsID); if (settings.hasKey(writeTemplateID)) writeTemplate = settings.getBoolean(writeTemplateID); if (settings.hasKey(writeJsonID)) writeJson = settings.getBoolean(writeJsonID); if (settings.hasKey(ignoreHiddenLayersID)) ignoreHiddenLayers = settings.getBoolean(ignoreHiddenLayersID); if (settings.hasKey(pngScaleID)) pngScale = settings.getDouble(pngScaleID); if (settings.hasKey(groupsAsSkinsID)) groupsAsSkins = settings.getBoolean(groupsAsSkinsID); if (settings.hasKey(useRulerOriginID)) useRulerOrigin = settings.getBoolean(useRulerOriginID); if (settings.hasKey(imagesDirID)) imagesDir = settings.getString(imagesDirID); if (settings.hasKey(projectDirID)) projectDir = settings.getString(projectDirID); if (settings.hasKey(paddingID)) padding = settings.getDouble(paddingID); } function saveSettings () { var settings = new ActionDescriptor(); settings.putBoolean(writePngsID, writePngs); settings.putBoolean(writeTemplateID, writeTemplate); settings.putBoolean(writeJsonID, writeJson); settings.putBoolean(ignoreHiddenLayersID, ignoreHiddenLayers); settings.putDouble(pngScaleID, pngScale); settings.putBoolean(groupsAsSkinsID, groupsAsSkins); settings.putBoolean(useRulerOriginID, useRulerOrigin); settings.putString(imagesDirID, imagesDir); settings.putString(projectDirID, projectDir); settings.putDouble(paddingID, padding); app.putCustomOptions(settingsID, settings, true); } // Photoshop utility: function scaleImage () { var imageSize = activeDocument.width.as("px"); activeDocument.resizeImage(UnitValue(imageSize * pngScale, "px"), null, null, ResampleMethod.BICUBICSHARPER); } var historyIndex; function storeHistory () { historyIndex = activeDocument.historyStates.length - 1; } function restoreHistory () { activeDocument.activeHistoryState = activeDocument.historyStates[historyIndex]; } function collectLayers (layer, collect) { for (var i = 0, n = layer.layers.length; i < n; i++) { var child = layer.layers[i]; if (ignoreHiddenLayers && !child.visible) continue; if (child.bounds[2] == 0 && child.bounds[3] == 0) continue; if (child.layers && child.layers.length > 0) collectLayers(child, collect); else if (child.kind == LayerKind.NORMAL) { collect.push(child); child.visible = false; } } } function hasFilePath () { var ref = new ActionReference(); ref.putEnumerated(charIDToTypeID("Dcmn"), charIDToTypeID("Ordn"), charIDToTypeID("Trgt")); return executeActionGet(ref).hasKey(stringIDToTypeID("fileReference")); } function absolutePath (path) { path = trim(path); if (path.length == 0) path = activeDocument.path.toString(); else if (imagesDir.indexOf("./") == 0) path = activeDocument.path + path.substring(1); path = path.replace(/\\/g, "/"); if (path.substring(path.length - 1) != "/") path += "/"; return path; } // JavaScript utility: function countAssocArray (obj) { var count = 0; for (var key in obj) if (obj.hasOwnProperty(key)) count++; return count; } function trim (value) { return value.replace(/^\s+|\s+$/g, ""); } function endsWith (str, suffix) { return str.indexOf(suffix, str.length - suffix.length) !== -1; } function stripSuffix (str, suffix) { if (endsWith(str.toLowerCase(), suffix.toLowerCase())) str = str.substring(0, str.length - suffix.length); return str; } function layerName (layer) { return stripSuffix(trim(layer.name), ".png").replace(/[:\/\\*\?\"\<\>\|]/g, ""); }