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();