PS Layer Property Extractor
🧩 Syntax:
/*
* PSD Layer Property Extractor
* =============================
* Extracts text styles, dimensions, colors, and positions from PSD layers
* into a structured JSON file for AI-assisted WordPress theme development.
*
* Usage: File > Scripts > Browse > select this file
* Output: JSON file saved next to the PSD (same name + _specs.json)
*
* What it extracts:
* - Document: dimensions, color mode, resolution
* - Text layers: font, size, weight, color, line-height, tracking,
* alignment, content, bounding box
* - Shape/pixel layers: bounding box, fill color (where available)
* - Smart objects: bounding box, name
* - Groups: bounding box, child layer names
* - Artboards: dimensions (for identifying breakpoints)
*
* Notes:
* - Skips hidden layers by default (toggle INCLUDE_HIDDEN below)
* - Positions are in pixels from top-left of document
* - Colors are in hex format
*/
// ============================================================
// CONFIGURATION
// ============================================================
var CONFIG = {
INCLUDE_HIDDEN: false, // Set true to include hidden layers
MAX_TEXT_PREVIEW: 200, // Max characters of text content to capture
EXTRACT_CSS_COLORS: true, // Convert colors to hex
GROUP_DEPTH_LIMIT: 20, // Max nesting depth to prevent infinite loops
SCALE_FACTOR: 1 // Scales positions & dimensions only (not font sizes/line heights).
// Set to 0.5 for 2x retina PSDs, 1 for 1x PSDs.
};
// ============================================================
// UTILITIES
// ============================================================
function rgbToHex(r, g, b) {
function toHex(c) {
var hex = Math.round(c).toString(16);
return hex.length === 1 ? "0" + hex : hex;
}
return "#" + toHex(r) + toHex(g) + toHex(b);
}
function solidColorToHex(color) {
try {
return rgbToHex(
color.rgb.red,
color.rgb.green,
color.rgb.blue
);
} catch (e) {
return null;
}
}
// Scale a value by the configured scale factor
function scale(val) {
if (typeof val !== "number" || isNaN(val)) return val;
return Math.round(val * CONFIG.SCALE_FACTOR * 100) / 100;
}
// Get bounds as a usable object {x, y, width, height}
function parseBounds(bounds) {
try {
var x = parseInt(bounds[0], 10);
var y = parseInt(bounds[1], 10);
var w = parseInt(bounds[2], 10) - x;
var h = parseInt(bounds[3], 10) - y;
return { x: scale(x), y: scale(y), width: scale(w), height: scale(h) };
} catch (e) {
return null;
}
}
// Photoshop's ExtendScript doesn't have JSON.stringify
// Minimal JSON serializer
function jsonStringify(obj, indent, currentIndent) {
if (typeof indent === "undefined") indent = 2;
if (typeof currentIndent === "undefined") currentIndent = 0;
var pad = "";
var childPad = "";
for (var p = 0; p < currentIndent; p++) pad += " ";
for (var cp = 0; cp < currentIndent + indent; cp++) childPad += " ";
if (obj === null || typeof obj === "undefined") return "null";
if (typeof obj === "boolean") return obj ? "true" : "false";
if (typeof obj === "number") {
if (isNaN(obj) || !isFinite(obj)) return "null";
return String(obj);
}
if (typeof obj === "string") {
// Escape special characters
var escaped = obj
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
return '"' + escaped + '"';
}
// Array
if (obj instanceof Array) {
if (obj.length === 0) return "[]";
var arrItems = [];
for (var i = 0; i < obj.length; i++) {
arrItems.push(childPad + jsonStringify(obj[i], indent, currentIndent + indent));
}
return "[\n" + arrItems.join(",\n") + "\n" + pad + "]";
}
// Object
if (typeof obj === "object") {
var keys = [];
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
keys.push(key);
}
}
if (keys.length === 0) return "{}";
var objItems = [];
for (var k = 0; k < keys.length; k++) {
objItems.push(
childPad + '"' + keys[k] + '": ' +
jsonStringify(obj[keys[k]], indent, currentIndent + indent)
);
}
return "{\n" + objItems.join(",\n") + "\n" + pad + "}";
}
return String(obj);
}
// ============================================================
// TEXT LAYER EXTRACTION (uses Action Manager for detailed props)
// ============================================================
function getTextLayerDetails(layerRef) {
var info = {
content: "",
font_family: null,
font_size: null,
font_weight: null,
font_style: null,
color: null,
line_height: null,
letter_spacing: null,
text_align: null,
text_transform: null,
all_styles: [] // If text has mixed styles, capture each run
};
try {
var textItem = layerRef.textItem;
info.content = textItem.contents.substring(0, CONFIG.MAX_TEXT_PREVIEW);
// Justification / alignment
try {
var j = textItem.justification;
if (j == Justification.LEFT) info.text_align = "left";
else if (j == Justification.CENTER) info.text_align = "center";
else if (j == Justification.RIGHT) info.text_align = "right";
else if (j == Justification.FULLYJUSTIFIED || j == Justification.CENTERJUSTIFIED) info.text_align = "justify";
} catch (e) {}
// Use Action Manager to get per-run text styles
var ref = new ActionReference();
ref.putEnumerated(charIDToTypeID("Lyr "), charIDToTypeID("Ordn"), charIDToTypeID("Trgt"));
var desc = executeActionGet(ref);
if (desc.hasKey(stringIDToTypeID("textKey"))) {
var textDesc = desc.getObjectValue(stringIDToTypeID("textKey"));
// Get text style runs
if (textDesc.hasKey(stringIDToTypeID("textStyleRange"))) {
var styleRangeList = textDesc.getList(stringIDToTypeID("textStyleRange"));
for (var i = 0; i < styleRangeList.count; i++) {
var rangeDesc = styleRangeList.getObjectValue(i);
var styleDesc = rangeDesc.getObjectValue(stringIDToTypeID("textStyle"));
var run = {};
// Font name
try {
run.font_family = styleDesc.getString(stringIDToTypeID("fontName"));
} catch (e) {}
// Font PostScript name (useful for identifying weight)
try {
run.font_postscript = styleDesc.getString(stringIDToTypeID("fontPostScriptName"));
} catch (e) {}
// Font size (NOT scaled — designers set text at intended CSS size in retina canvases)
try {
run.font_size = Math.round(styleDesc.getDouble(stringIDToTypeID("size")) * 100) / 100;
} catch (e) {}
// Font style name (Regular, Bold, Italic, etc.)
try {
run.font_style_name = styleDesc.getString(stringIDToTypeID("fontStyleName"));
// Derive weight and style
var sn = run.font_style_name.toLowerCase();
if (sn.indexOf("bold") !== -1) run.font_weight = "bold";
else if (sn.indexOf("black") !== -1) run.font_weight = "900";
else if (sn.indexOf("heavy") !== -1) run.font_weight = "800";
else if (sn.indexOf("semibold") !== -1 || sn.indexOf("semi bold") !== -1 || sn.indexOf("demibold") !== -1) run.font_weight = "600";
else if (sn.indexOf("medium") !== -1) run.font_weight = "500";
else if (sn.indexOf("light") !== -1) run.font_weight = "300";
else if (sn.indexOf("thin") !== -1 || sn.indexOf("hairline") !== -1) run.font_weight = "100";
else run.font_weight = "normal";
if (sn.indexOf("italic") !== -1 || sn.indexOf("oblique") !== -1) run.font_style = "italic";
else run.font_style = "normal";
} catch (e) {}
// Color
try {
var colorDesc = styleDesc.getObjectValue(stringIDToTypeID("color"));
var r = colorDesc.getDouble(stringIDToTypeID("red"));
var g = colorDesc.getDouble(stringIDToTypeID("grain")); // Yes, Photoshop calls green "grain"
var b = colorDesc.getDouble(stringIDToTypeID("blue"));
run.color = rgbToHex(r, g, b);
} catch (e) {}
// Leading (line-height) — NOT scaled, same reason as font_size
try {
if (styleDesc.hasKey(stringIDToTypeID("leading"))) {
run.line_height = Math.round(styleDesc.getDouble(stringIDToTypeID("leading")) * 100) / 100;
} else {
run.line_height = "auto";
}
} catch (e) {}
// Tracking (letter-spacing)
try {
var tracking = styleDesc.getDouble(stringIDToTypeID("tracking"));
// Photoshop tracking is in 1/1000 em
run.letter_spacing = tracking;
run.letter_spacing_em = Math.round((tracking / 1000) * 1000) / 1000;
} catch (e) {}
// Text transform
try {
if (styleDesc.hasKey(stringIDToTypeID("fontCaps"))) {
var caps = styleDesc.getEnumerationValue(stringIDToTypeID("fontCaps"));
var allCapsID = stringIDToTypeID("allCaps");
var smallCapsID = stringIDToTypeID("smallCaps");
if (caps === allCapsID) run.text_transform = "uppercase";
else if (caps === smallCapsID) run.text_transform = "small-caps";
else run.text_transform = "none";
}
} catch (e) {}
// Range info
try {
run.range_start = rangeDesc.getInteger(stringIDToTypeID("from"));
run.range_end = rangeDesc.getInteger(stringIDToTypeID("to"));
} catch (e) {}
info.all_styles.push(run);
}
// Set primary style from first run
if (info.all_styles.length > 0) {
var primary = info.all_styles[0];
info.font_family = primary.font_family;
info.font_size = primary.font_size;
info.font_weight = primary.font_weight;
info.font_style = primary.font_style;
info.color = primary.color;
info.line_height = primary.line_height;
info.letter_spacing = primary.letter_spacing;
info.letter_spacing_em = primary.letter_spacing_em;
info.text_transform = primary.text_transform;
info.font_postscript = primary.font_postscript;
}
// If all runs have the same style, simplify
if (info.all_styles.length === 1) {
info.all_styles = []; // No need for per-run data
}
}
// Paragraph styles (alignment at paragraph level)
if (textDesc.hasKey(stringIDToTypeID("paragraphStyleRange"))) {
var paraList = textDesc.getList(stringIDToTypeID("paragraphStyleRange"));
if (paraList.count > 0) {
var paraDesc = paraList.getObjectValue(0);
if (paraDesc.hasKey(stringIDToTypeID("paragraphStyle"))) {
var pStyle = paraDesc.getObjectValue(stringIDToTypeID("paragraphStyle"));
try {
if (pStyle.hasKey(stringIDToTypeID("align"))) {
var align = pStyle.getEnumerationValue(stringIDToTypeID("align"));
var leftID = stringIDToTypeID("left");
var centerID = stringIDToTypeID("center");
var rightID = stringIDToTypeID("right");
var justifyID = stringIDToTypeID("justifyAll");
if (align === leftID) info.text_align = "left";
else if (align === centerID) info.text_align = "center";
else if (align === rightID) info.text_align = "right";
else if (align === justifyID) info.text_align = "justify";
}
} catch (e) {}
}
}
}
}
} catch (e) {
// Fallback: try basic textItem properties
try {
info.font_family = layerRef.textItem.font;
info.font_size = layerRef.textItem.size.as("px");
info.color = solidColorToHex(layerRef.textItem.color);
} catch (e2) {}
}
return info;
}
// ============================================================
// GET FILL COLOR via Action Manager (for shape layers)
// ============================================================
function getLayerFillColor(layer) {
try {
// Must select the layer first
app.activeDocument.activeLayer = layer;
var ref = new ActionReference();
ref.putEnumerated(charIDToTypeID("Lyr "), charIDToTypeID("Ordn"), charIDToTypeID("Trgt"));
var desc = executeActionGet(ref);
// Check for adjustment/fill content
if (desc.hasKey(stringIDToTypeID("adjustment"))) {
var adjList = desc.getList(stringIDToTypeID("adjustment"));
if (adjList.count > 0) {
var adjDesc = adjList.getObjectValue(0);
if (adjDesc.hasKey(stringIDToTypeID("color"))) {
var colorDesc = adjDesc.getObjectValue(stringIDToTypeID("color"));
var r = colorDesc.getDouble(stringIDToTypeID("red"));
var g = colorDesc.getDouble(stringIDToTypeID("grain"));
var b = colorDesc.getDouble(stringIDToTypeID("blue"));
return rgbToHex(r, g, b);
}
}
}
} catch (e) {}
return null;
}
// ============================================================
// GET LAYER OPACITY
// ============================================================
function getLayerOpacity(layer) {
try {
return Math.round(layer.opacity);
} catch (e) {
return 100;
}
}
// ============================================================
// LAYER WALKER
// ============================================================
function processLayer(layer, depth) {
if (depth > CONFIG.GROUP_DEPTH_LIMIT) return null;
if (!CONFIG.INCLUDE_HIDDEN && !layer.visible) return null;
var data = {
name: layer.name,
type: null,
visible: layer.visible,
opacity: getLayerOpacity(layer),
bounds: null
};
// Get bounds
try {
data.bounds = parseBounds(layer.bounds);
} catch (e) {}
// Check typename FIRST — layer.kind is unreliable for groups
// in many PS versions (returns undefined instead of throwing)
if (layer.typename === "LayerSet") {
// It's a group — check if it's an artboard
data.type = "group";
data.children = [];
try {
app.activeDocument.activeLayer = layer;
var ref = new ActionReference();
ref.putEnumerated(charIDToTypeID("Lyr "), charIDToTypeID("Ordn"), charIDToTypeID("Trgt"));
var desc = executeActionGet(ref);
if (desc.hasKey(stringIDToTypeID("artboardEnabled"))) {
var isArtboard = desc.getBoolean(stringIDToTypeID("artboardEnabled"));
if (isArtboard) {
data.type = "artboard";
if (desc.hasKey(stringIDToTypeID("artboard"))) {
var abDesc = desc.getObjectValue(stringIDToTypeID("artboard"));
if (abDesc.hasKey(stringIDToTypeID("artboardRect"))) {
var rectDesc = abDesc.getObjectValue(stringIDToTypeID("artboardRect"));
data.artboard = {
top: scale(rectDesc.getDouble(stringIDToTypeID("top"))),
left: scale(rectDesc.getDouble(stringIDToTypeID("left"))),
bottom: scale(rectDesc.getDouble(stringIDToTypeID("bottom"))),
right: scale(rectDesc.getDouble(stringIDToTypeID("right"))),
width: scale(rectDesc.getDouble(stringIDToTypeID("right")) - rectDesc.getDouble(stringIDToTypeID("left"))),
height: scale(rectDesc.getDouble(stringIDToTypeID("bottom")) - rectDesc.getDouble(stringIDToTypeID("top")))
};
}
}
}
}
} catch (e2) {}
// Recurse into child layers
for (var i = 0; i < layer.layers.length; i++) {
var childData = processLayer(layer.layers[i], depth + 1);
if (childData !== null) {
data.children.push(childData);
}
}
} else {
// It's an ArtLayer — determine kind
try {
var kind = layer.kind;
if (kind == LayerKind.TEXT) {
data.type = "text";
app.activeDocument.activeLayer = layer;
var textInfo = getTextLayerDetails(layer);
data.text = textInfo;
} else if (kind == LayerKind.SOLIDFILL) {
data.type = "shape";
data.fill_color = getLayerFillColor(layer);
} else if (kind == LayerKind.SMARTOBJECT) {
data.type = "smart_object";
try { data.smart_object_name = layer.name; } catch (e) {}
} else if (kind == LayerKind.NORMAL) {
data.type = "pixel";
} else if (kind == LayerKind.GRADIENTFILL) {
data.type = "gradient";
} else {
data.type = "other";
data.layer_kind = String(kind);
}
} catch (e) {
data.type = "unknown";
}
}
return data;
}
// ============================================================
// COLLECT UNIQUE FONTS
// ============================================================
function collectFonts(layers, fonts) {
if (!fonts) fonts = {};
for (var i = 0; i < layers.length; i++) {
var layer = layers[i];
if (layer.type === "text" && layer.text) {
var key = (layer.text.font_family || "unknown") + " " + (layer.text.font_weight || "normal");
if (!fonts[key]) {
fonts[key] = {
font_family: layer.text.font_family,
font_postscript: layer.text.font_postscript,
font_weight: layer.text.font_weight,
font_style: layer.text.font_style
};
}
}
if (layer.children) {
collectFonts(layer.children, fonts);
}
}
return fonts;
}
// ============================================================
// COLLECT UNIQUE COLORS
// ============================================================
function collectColors(layers, colors) {
if (!colors) colors = {};
for (var i = 0; i < layers.length; i++) {
var layer = layers[i];
if (layer.type === "text" && layer.text && layer.text.color) {
colors[layer.text.color] = (colors[layer.text.color] || 0) + 1;
}
if (layer.fill_color) {
colors[layer.fill_color] = (colors[layer.fill_color] || 0) + 1;
}
if (layer.children) {
collectColors(layer.children, colors);
}
}
return colors;
}
// ============================================================
// DETECT BREAKPOINTS FROM ARTBOARDS
// ============================================================
function detectBreakpoints(layers) {
var breakpoints = [];
for (var i = 0; i < layers.length; i++) {
if (layers[i].type === "artboard" && layers[i].artboard) {
var w = layers[i].artboard.width;
var label = "unknown";
if (w >= 1200) label = "desktop";
else if (w >= 768) label = "tablet";
else label = "mobile";
breakpoints.push({
name: layers[i].name,
width: w,
height: layers[i].artboard.height,
detected_breakpoint: label
});
}
// Check children for nested artboards
if (layers[i].children) {
var nested = detectBreakpoints(layers[i].children);
for (var j = 0; j < nested.length; j++) {
breakpoints.push(nested[j]);
}
}
}
return breakpoints;
}
// ============================================================
// MAIN
// ============================================================
function main() {
if (!app.documents.length) {
alert("No document open. Please open a PSD file first.");
return;
}
var doc = app.activeDocument;
// Build output structure
var output = {
_meta: {
generator: "PSD Layer Property Extractor",
version: "1.1",
extracted_at: new Date().toString(),
scale_factor: CONFIG.SCALE_FACTOR,
instructions: "Feed this JSON to Claude/Claude Code along with your existing theme templates for AI-assisted WordPress theme development. Positions and dimensions are CSS-ready (scale factor " + CONFIG.SCALE_FACTOR + " applied). Font sizes and line heights are NOT scaled (designers set these at intended CSS values in retina canvases)."
},
document: {
name: doc.name,
psd_width: parseInt(doc.width, 10),
psd_height: parseInt(doc.height, 10),
css_width: scale(parseInt(doc.width, 10)),
css_height: scale(parseInt(doc.height, 10)),
resolution: doc.resolution,
color_mode: String(doc.mode)
},
breakpoints: [],
fonts_used: {},
colors_used: {},
layers: []
};
// Process all layers
var totalLayers = 0;
for (var i = 0; i < doc.layers.length; i++) {
var layerData = processLayer(doc.layers[i], 0);
if (layerData !== null) {
output.layers.push(layerData);
totalLayers++;
}
}
// Collect summary data
output.fonts_used = collectFonts(output.layers);
output.colors_used = collectColors(output.layers);
output.breakpoints = detectBreakpoints(output.layers);
// Serialize to JSON
var jsonStr = jsonStringify(output);
// Save next to the PSD
var savePath;
try {
var docPath = doc.path;
var baseName = doc.name.replace(/\.[^\.]+$/, "");
savePath = docPath + "/" + baseName + "_specs.json";
} catch (e) {
// Document hasn't been saved yet, use desktop
savePath = "~/Desktop/" + doc.name.replace(/\.[^\.]+$/, "") + "_specs.json";
}
var saveFile = new File(savePath);
saveFile.encoding = "UTF-8";
saveFile.open("w");
saveFile.write(jsonStr);
saveFile.close();
alert(
"Extraction complete!\n\n" +
"Saved to:\n" + savePath + "\n\n" +
"Layers processed: " + totalLayers + "\n" +
"Artboards/breakpoints found: " + output.breakpoints.length + "\n\n" +
"Next step: Feed this JSON + your existing theme code to Claude Code."
);
}
// Run it
main();