Skip to main content
Participant
February 24, 2025
Question

Script for distributing and stacking simple objects with masked objects.

  • February 24, 2025
  • 2 replies
  • 373 views

Hello Community, I need help with an Adobe Illustrator script for Stacked Object Distribution. Simple objects work fine, but when they are objects with a clipping mask, the same does not happen. Objects with a mask are stacked according to the content they have inside in order to align with the simple objects. Let me explain in the sample image.

2 replies

m1b
Community Expert
Community Expert
February 24, 2025

Hi @Utson_Avila2888 for your learning, I've made two changes to your code:

1. I've written a function—getItemBoundsIllustrator—that does a bit more heavy lifting when trying to get the actual bounds, and I've called it from your distributeObjects function, and

2. I've changed the way the items are positioned: rather than settings "position" property, I use the translate method—you can do it either way, but I find translate more understandable.

 

I tested with your sample file and it seemed to work correctly for me. Let me know if that gets you moving forward.

- Mark

 

 

(function () {

    var doc = app.activeDocument;
    var selx = doc.selection;
    if (selx.length < 2) {
        alert("Por favor, selecciona al menos 2 objetos.");
    } else {
        var userValues = showDialog();
        if (userValues) {
            distributeObjects(selx, userValues);
        }
    }

})();

function showDialog() {
    var dialog = new Window('dialog', 'Distribuir Objetos Apilados');
    dialog.add('statictext', undefined, 'Separación:');
    var separationInput = dialog.add('edittext', undefined, '1');
    separationInput.characters = 5;
    var unitsGroup = dialog.add('group');
    var cmButton = unitsGroup.add('radiobutton', undefined, 'cm');
    var mmButton = unitsGroup.add('radiobutton', undefined, 'mm');
    var pxButton = unitsGroup.add('radiobutton', undefined, 'px');
    cmButton.value = true;
    dialog.add('statictext', undefined, 'Modo de distribución:');
    var modeGroup = dialog.add('group');
    var gridButton = modeGroup.add('radiobutton', undefined, 'Grid');
    var horizontalButton = modeGroup.add('radiobutton', undefined, 'Horizontal');
    var verticalButton = modeGroup.add('radiobutton', undefined, 'Vertical');
    verticalButton.value = true;
    dialog.add('statictext', undefined, 'Columnas (solo para Grid):');
    var columnsInput = dialog.add('edittext', undefined, '3');
    columnsInput.characters = 5;
    var buttons = dialog.add('group');
    buttons.alignment = 'center';
    var cancelButton = buttons.add('button', undefined, 'Cancelar', { name: 'cancel' });
    var okButton = buttons.add('button', undefined, 'Aceptar', { name: 'ok' });
    okButton.onClick = function () {
        dialog.close(1);
    };
    cancelButton.onClick = function () {
        dialog.close(0);
    };
    if (dialog.show() == 1) {
        return {
            separation: parseFloat(separationInput.text),
            unit: cmButton.value ? 'cm' : (mmButton.value ? 'mm' : 'px'),
            mode: gridButton.value ? 'grid' : (horizontalButton.value ? 'horizontal' : 'vertical'),
            columns: parseInt(columnsInput.text)
        };
    }
    return null;
}
function distributeObjects(sel, userValues) {
    var separation = userValues.separation;
    var unitMultiplier = (userValues.unit == 'cm') ? 28.3465 : (userValues.unit == 'mm' ? 2.83465 : 1);
    separation *= unitMultiplier;
    var mode = userValues.mode;
    var gridCols = (mode == 'grid') ? userValues.columns : (mode == 'horizontal' ? sel.length : 1);
    var currentX = 0, currentY = 0, maxRowH = 0;
    if (mode == 'horizontal' || mode == 'grid') {
        sel.sort(function (a, b) {
            return getVisibleBounds(a)[0] - getVisibleBounds(b)[0];
        });
    } else if (mode == 'vertical') {
        sel.sort(function (a, b) {
            return getVisibleBounds(a)[3] - getVisibleBounds(b)[3];
        });
    }
    for (var i = 0; i < sel.length; i++) {
        var bounds = getItemBoundsIllustrator(sel[i]);
        var objectWidth = bounds[2] - bounds[0];
        var objectHeight = bounds[1] - bounds[3];

        var dx = currentX - bounds[0];
        var dy = currentY - bounds[1];
        sel[i].translate(dx, dy, true, true, true, true);

        if (mode == 'horizontal') {
            currentX += (objectWidth + separation);
        } else if (mode == 'vertical') {
            currentY -= (objectHeight + separation);
        } else if (mode == 'grid') {
            currentX += (objectWidth + separation);
            if ((i % gridCols) == (gridCols - 1)) {
                currentX = 0;
                currentY -= (maxRowH + separation);
                maxRowH = 0;
            }
            maxRowH = Math.max(maxRowH, objectHeight);
        }
    }
}
function getVisibleBounds(item) {
    if (item.typename === 'GroupItem' && item.clipped) {
        for (var i = 0; i < item.pageItems.length; i++) {
            if (item.pageItems[i].clipping) {
                return item.pageItems[i].geometricBounds;
            }
        }
    }
    return item.geometricBounds;
}

/**
 * Returns bounds of item(s).
 * @author m1b
 * @version 2025-02-25
 * @param {PageItem|Array<PageItem>} item - an Illustrator PageItem or array of PageItems.
 * @param {Boolean} [geometric] - if false, returns visible bounds.
 * @param {Array} [bounds] - private parameter, used when recursing.
 * @returns {Array} - the calculated bounds.
 */
function getItemBoundsIllustrator(item, geometric, bounds) {

    var newBounds = [],
        boundsKey = geometric ? 'geometricBounds' : 'visibleBounds';

    if (undefined == item)
        return;

    if (
        item.typename == 'GroupItem'
        || item.constructor.name == 'Array'
    ) {

        var children = item.typename == 'GroupItem' ? item.pageItems : item,
            contentBounds = [],
            isClippingGroup = (item.hasOwnProperty('clipped') && item.clipped == true),
            clipBounds;

        for (var i = 0, child; i < children.length; i++) {

            child = children[i];

            if (
                child.hasOwnProperty('clipping')
                && true === child.clipping
                && true !== child.stroked
                && true !== child.filled
            )
                // the clipping item
                clipBounds = child.geometricBounds;

            else
                contentBounds.push(getItemBoundsIllustrator(child, geometric, bounds));

        }

        newBounds = combineBounds(contentBounds);

        if (
            isClippingGroup
            && clipBounds
        )
            newBounds = intersectionOfBounds([clipBounds, newBounds]);

    }

    else if (
        'TextFrame' === item.constructor.name
        && TextType.AREATEXT !== item.kind
    ) {

        // get bounds of outlined text
        var dup = item.duplicate().createOutline();
        newBounds = dup[boundsKey];
        dup.remove();

    }

    else if (item.hasOwnProperty(boundsKey)) {

        newBounds = item[boundsKey];

    }

    // `bounds` will exist if this is a recursive execution
    bounds = (undefined == bounds)
        ? bounds = newBounds
        : bounds = combineBounds([newBounds, bounds]);

    return bounds;

};

/**
 * Returns the combined bounds of all bounds supplied.
 * Works with Illustrator or Indesign bounds.
 * @author m1b
 * @version 2024-03-09
 * @param {Array<bounds>} boundsArray - an array of bounds [L, T, R, B] or [T, L , B, R].
 * @returns {bounds?} - the combined bounds.
 */
function combineBounds(boundsArray) {

    var combinedBounds = boundsArray[0],
        comparator;

    if (/indesign/i.test(app.name))
        comparator = [Math.min, Math.min, Math.max, Math.max];

    else
        comparator = [Math.min, Math.max, Math.max, Math.min];

    // iterate through the rest of the bounds
    for (var i = 1; i < boundsArray.length; i++) {

        var bounds = boundsArray[i];

        combinedBounds = [
            comparator[0](combinedBounds[0], bounds[0]),
            comparator[1](combinedBounds[1], bounds[1]),
            comparator[2](combinedBounds[2], bounds[2]),
            comparator[3](combinedBounds[3], bounds[3]),
        ];

    }

    return combinedBounds;

};

/**
 * Returns the overlapping rectangle
 * of two or more rectangles.
 * NOTE: Returns undefined if ANY
 * rectangles do not intersect.
 * @author m1b
 * @version 2024-09-05
 * @param {Array<bounds>} arrayOfBounds - an array of bounds [L, T, R, B] or [T, L , B, R].
 * @returns {bounds?} - intersecting bounds.
 */
function intersectionOfBounds(arrayOfBounds) {

    var comparator;

    if (/indesign/i.test(app.name))
        comparator = [Math.max, Math.max, Math.min, Math.min];

    else
        comparator = [Math.max, Math.min, Math.min, Math.max];

    // sort a copy of array
    var bounds = arrayOfBounds
        .slice(0)
        .sort(function (a, b) { return b[0] - a[0] || a[1] - b[1] });

    // start with first bounds
    var intersection = bounds.shift(),
        b;

    // compare each bounds, getting smaller
    while (b = bounds.shift()) {

        // if doesn't intersect, bail out
        if (!boundsDoIntersect(intersection, b))
            return;

        intersection = [
            comparator[0](intersection[0], b[0]),
            comparator[1](intersection[1], b[1]),
            comparator[2](intersection[2], b[2]),
            comparator[3](intersection[3], b[3]),
        ];

    }

    return intersection;

};

/**
 * Returns true if the two bounds intersect.
 * @author m1b
 * @version 2024-03-10
 * @param {Array} bounds1 - bounds array.
 * @param {Array} bounds2 - bounds array.
 * @param {Boolean} [TLBR] - whether bounds arrays are interpreted as [t, l, b, r] or [l, t, r, b] (default: based on app).
 * @returns {Boolean}
 */
function boundsDoIntersect(bounds1, bounds2, TLBR) {

    if (undefined == TLBR)
        TLBR = (/indesign/i.test(app.name));

    return !(

        TLBR

            // TLBR
            ? (
                bounds2[0] > bounds1[2]
                || bounds2[1] > bounds1[3]
                || bounds2[2] < bounds1[0]
                || bounds2[3] < bounds1[1]
            )

            // LTRB
            : (
                bounds2[0] > bounds1[2]
                || bounds2[1] < bounds1[3]
                || bounds2[2] < bounds1[0]
                || bounds2[3] > bounds1[1]
            )
    );

};

Edit 2025-02-25: added a check for a filled or stroked clipping mask.

 

Participant
February 24, 2025

Hello @m1b  works great, what I noticed when the content inside is small and does not protrude from the mask does not stack correctly, I added a preview and overridden the stroke thickness when aligning.

(function () {
var doc = app.activeDocument;
var selx = doc.selection;
if (selx.length < 2) {
alert("Por favor, selecciona al menos 2 objetos.");
} else {
var initialPositions = saveInitialPositions(selx); // Guardar posiciones iniciales
var userValues = showDialog(initialPositions);
if (!userValues) {
restoreInitialPositions(selx, initialPositions); // Restaurar si se cancela
}
}
})();

function saveInitialPositions(selection) {
var positions = [];
for (var i = 0; i < selection.length; i++) {
var bounds = getItemBoundsIllustrator(selection[i], true);
positions.push({x: bounds[0], y: bounds[1]});
}
return positions;
}

function restoreInitialPositions(selection, positions) {
for (var i = 0; i < selection.length; i++) {
var bounds = getItemBoundsIllustrator(selection[i], true);
var dx = positions[i].x - bounds[0];
var dy = positions[i].y - bounds[1];
selection[i].translate(dx, dy, true, true, true, true);
}
app.redraw();
}

function showDialog(initialPositions) {
var dialog = new Window('dialog', 'Distribuir Objetos Apilados');
dialog.add('statictext', undefined, 'Separación:');
var separationInput = dialog.add('edittext', undefined, '1');
separationInput.characters = 5;
var unitsGroup = dialog.add('group');
var cmButton = unitsGroup.add('radiobutton', undefined, 'cm');
var mmButton = unitsGroup.add('radiobutton', undefined, 'mm');
var pxButton = unitsGroup.add('radiobutton', undefined, 'px');
cmButton.value = true;
dialog.add('statictext', undefined, 'Modo de distribución:');
var modeGroup = dialog.add('group');
var gridButton = modeGroup.add('radiobutton', undefined, 'Grid');
var horizontalButton = modeGroup.add('radiobutton', undefined, 'Horizontal');
var verticalButton = modeGroup.add('radiobutton', undefined, 'Vertical');
verticalButton.value = true;
dialog.add('statictext', undefined, 'Columnas (solo para Grid):');
var columnsInput = dialog.add('edittext', undefined, '3');
columnsInput.characters = 5;

var previewCheckbox = dialog.add('checkbox', undefined, 'Vista previa');
previewCheckbox.value = false;

var buttons = dialog.add('group');
buttons.alignment = 'center';
var cancelButton = buttons.add('button', undefined, 'Cancelar', { name: 'cancel' });
var okButton = buttons.add('button', undefined, 'Aceptar', { name: 'ok' });

okButton.onClick = function () {
dialog.close(1);
};
cancelButton.onClick = function () {
dialog.close(0);
};

// Función para actualizar la vista previa
function updatePreview() {
if (previewCheckbox.value) {
var userValues = {
separation: parseFloat(separationInput.text),
unit: cmButton.value ? 'cm' : (mmButton.value ? 'mm' : 'px'),
mode: gridButton.value ? 'grid' : (horizontalButton.value ? 'horizontal' : 'vertical'),
columns: parseInt(columnsInput.text)
};
distributeObjects(app.activeDocument.selection, userValues, true); // Vista previa
} else {
restoreInitialPositions(app.activeDocument.selection, initialPositions); // Restaurar estado inicial
}
}

// Escuchar cambios en los controles para actualizar la vista previa
separationInput.onChange = unitsGroup.onClick = columnsInput.onChange = function () {
updatePreview();
};

// Escuchar cambios en los botones de modo de distribución
gridButton.onClick = horizontalButton.onClick = verticalButton.onClick = function () {
updatePreview();
};

// Escuchar el clic en la casilla de vista previa
previewCheckbox.onClick = function () {
updatePreview();
};

if (dialog.show() == 1) {
return {
separation: parseFloat(separationInput.text),
unit: cmButton.value ? 'cm' : (mmButton.value ? 'mm' : 'px'),
mode: gridButton.value ? 'grid' : (horizontalButton.value ? 'horizontal' : 'vertical'),
columns: parseInt(columnsInput.text)
};
}
return null;
}

function distributeObjects(sel, userValues, isPreview) {
var separation = userValues.separation;
var unitMultiplier = (userValues.unit == 'cm') ? 28.3465 : (userValues.unit == 'mm' ? 2.83465 : 1);
separation *= unitMultiplier;
var mode = userValues.mode;
var gridCols = (mode == 'grid') ? userValues.columns : (mode == 'horizontal' ? sel.length : 1);
var currentX = 0, currentY = 0, maxRowH = 0;

if (mode == 'horizontal' || mode == 'grid') {
sel.sort(function (a, b) {
return getItemBoundsIllustrator(a, true)[0] - getItemBoundsIllustrator(b, true)[0];
});
} else if (mode == 'vertical') {
sel.sort(function (a, b) {
return getItemBoundsIllustrator(a, true)[3] - getItemBoundsIllustrator(b, true)[3];
});
}

for (var i = 0; i < sel.length; i++) {
var bounds = getItemBoundsIllustrator(sel[i], true);
var objectWidth = bounds[2] - bounds[0];
var objectHeight = bounds[1] - bounds[3];

var dx = currentX - bounds[0];
var dy = currentY - bounds[1];
sel[i].translate(dx, dy, true, true, true, true);

if (mode == 'horizontal') {
currentX += (objectWidth + separation);
} else if (mode == 'vertical') {
currentY -= (objectHeight + separation);
} else if (mode == 'grid') {
currentX += (objectWidth + separation);
if ((i % gridCols) == (gridCols - 1)) {
currentX = 0;
currentY -= (maxRowH + separation);
maxRowH = 0;
}
maxRowH = Math.max(maxRowH, objectHeight);
}
}

if (isPreview) {
app.redraw();
}
}

function getItemBoundsIllustrator(item, geometric, bounds) {
var newBounds = [],
boundsKey = geometric ? 'geometricBounds' : 'visibleBounds';

if (undefined == item)
return;

if (
item.typename == 'GroupItem'
|| item.constructor.name == 'Array'
) {
var children = item.typename == 'GroupItem' ? item.pageItems : item,
contentBounds = [],
isClippingGroup = (item.hasOwnProperty('clipped') && item.clipped == true),
clipBounds;

for (var i = 0, child; i < children.length; i++) {
child = children[i];

if (
child.hasOwnProperty('clipping')
&& true === child.clipping
)
clipBounds = child.geometricBounds;
else
contentBounds.push(getItemBoundsIllustrator(child, geometric, bounds));
}

newBounds = combineBounds(contentBounds);

if (isClippingGroup)
newBounds = intersectionOfBounds([clipBounds, newBounds]);
} else if (
'TextFrame' === item.constructor.name
&& TextType.AREATEXT !== item.kind
) {
var dup = item.duplicate().createOutline();
newBounds = dup[boundsKey];
dup.remove();
} else if (item.hasOwnProperty(boundsKey)) {
newBounds = item[boundsKey];
}

bounds = (undefined == bounds)
? bounds = newBounds
: bounds = combineBounds([newBounds, bounds]);

return bounds;
}

function combineBounds(boundsArray) {
var combinedBounds = boundsArray[0],
comparator;

if (/indesign/i.test(app.name))
comparator = [Math.min, Math.min, Math.max, Math.max];
else
comparator = [Math.min, Math.max, Math.max, Math.min];

for (var i = 1; i < boundsArray.length; i++) {
var bounds = boundsArray[i];
combinedBounds = [
comparator[0](combinedBounds[0], bounds[0]),
comparator[1](combinedBounds[1], bounds[1]),
comparator[2](combinedBounds[2], bounds[2]),
comparator[3](combinedBounds[3], bounds[3]),
];
}

return combinedBounds;
}

function intersectionOfBounds(arrayOfBounds) {
var comparator;

if (/indesign/i.test(app.name))
comparator = [Math.max, Math.max, Math.min, Math.min];
else
comparator = [Math.max, Math.min, Math.min, Math.max];

var bounds = arrayOfBounds
.slice(0)
.sort(function (a, b) { return b[0] - a[0] || a[1] - b[1] });

var intersection = bounds.shift(),
b;

while (b = bounds.shift()) {
if (!boundsDoIntersect(intersection, b))
return;

intersection = [
comparator[0](intersection[0], b[0]),
comparator[1](intersection[1], b[1]),
comparator[2](intersection[2], b[2]),
comparator[3](intersection[3], b[3]),
];
}

return intersection;
}

function boundsDoIntersect(bounds1, bounds2, TLBR) {
if (undefined == TLBR)
TLBR = (/indesign/i.test(app.name));

return !(
TLBR
? (
bounds2[0] > bounds1[2]
|| bounds2[1] > bounds1[3]
|| bounds2[2] < bounds1[0]
|| bounds2[3] < bounds1[1]
)
: (
bounds2[0] > bounds1[2]
|| bounds2[1] < bounds1[3]
|| bounds2[2] < bounds1[0]
|| bounds2[3] > bounds1[1]
)
);
}

 

m1b
Community Expert
Community Expert
February 24, 2025

Hi @Utson_Avila2888 I see. Yes I hadn't expected anyone to fill or stroke a clipping mask, but I've updated my code above with a check for it. Should work correctly now.

- Mark

 

P.S. A couple of notes on posting code: (1) please use the </> button to format your code, and (2) if you are posting someone else's code publicly, it is impolite to remove their authorship notice.

Participant
February 24, 2025

here the script.

var doc = app.activeDocument;
var selx = doc.selection;

if (selx.length < 2) {
alert("Por favor, selecciona al menos 2 objetos.");
} else {
var userValues = showDialog();
if (userValues) {
distributeObjects(selx, userValues);
}
}

function showDialog() {
var dialog = new Window('dialog', 'Distribuir Objetos Apilados');

dialog.add('statictext', undefined, 'Separación:');
var separationInput = dialog.add('edittext', undefined, '1');
separationInput.characters = 5;

var unitsGroup = dialog.add('group');
var cmButton = unitsGroup.add('radiobutton', undefined, 'cm');
var mmButton = unitsGroup.add('radiobutton', undefined, 'mm');
var pxButton = unitsGroup.add('radiobutton', undefined, 'px');
cmButton.value = true;

dialog.add('statictext', undefined, 'Modo de distribución:');
var modeGroup = dialog.add('group');
var gridButton = modeGroup.add('radiobutton', undefined, 'Grid');
var horizontalButton = modeGroup.add('radiobutton', undefined, 'Horizontal');
var verticalButton = modeGroup.add('radiobutton', undefined, 'Vertical');
verticalButton.value = true;

dialog.add('statictext', undefined, 'Columnas (solo para Grid):');
var columnsInput = dialog.add('edittext', undefined, '3');
columnsInput.characters = 5;

var buttons = dialog.add('group');
buttons.alignment = 'center';
var cancelButton = buttons.add('button', undefined, 'Cancelar', { name: 'cancel' });
var okButton = buttons.add('button', undefined, 'Aceptar', { name: 'ok' });

okButton.onClick = function () {
dialog.close(1);
};

cancelButton.onClick = function () {
dialog.close(0);
};

if (dialog.show() == 1) {
return {
separation: parseFloat(separationInput.text),
unit: cmButton.value ? 'cm' : (mmButton.value ? 'mm' : 'px'),
mode: gridButton.value ? 'grid' : (horizontalButton.value ? 'horizontal' : 'vertical'),
columns: parseInt(columnsInput.text)
};
}
return null;
}

function distributeObjects(sel, userValues) {
var separation = userValues.separation;
var unitMultiplier = (userValues.unit == 'cm') ? 28.3465 : (userValues.unit == 'mm' ? 2.83465 : 1);
separation *= unitMultiplier;

var mode = userValues.mode;
var gridCols = (mode == 'grid') ? userValues.columns : (mode == 'horizontal' ? sel.length : 1);

var currentX = 0, currentY = 0, maxRowH = 0;

if (mode == 'horizontal' || mode == 'grid') {
sel.sort(function (a, b) {
return getVisibleBounds(a)[0] - getVisibleBounds(b)[0];
});
} else if (mode == 'vertical') {
sel.sort(function (a, b) {
return getVisibleBounds(a)[3] - getVisibleBounds(b)[3];
});
}

for (var i = 0; i < sel.length; i++) {
var bounds = getVisibleBounds(sel[i]);
var objectWidth = bounds[2] - bounds[0];
var objectHeight = bounds[1] - bounds[3];

var posX = currentX;
var posY = currentY;

sel[i].position = [posX, posY];

if (mode == 'horizontal') {
currentX += (objectWidth + separation);
} else if (mode == 'vertical') {
currentY -= (objectHeight + separation);
} else if (mode == 'grid') {
currentX += (objectWidth + separation);
if ((i % gridCols) == (gridCols - 1)) {
currentX = 0;
currentY -= (maxRowH + separation);
maxRowH = 0;
}
maxRowH = Math.max(maxRowH, objectHeight);
}
}
}

function getVisibleBounds(item) {
if (item.typename === 'GroupItem' && item.clipped) {
for (var i = 0; i < item.pageItems.length; i++) {
if (item.pageItems[i].clipping) {
return item.pageItems[i].geometricBounds;
}
}
}
return item.geometricBounds;
}