Adobe Illustrator Contact Sheet JSX Plugin
I don't like performing tedious, time-consuming tasks, especially when those tasks are non-revenue generating, which means they take time away from things I could be doing to increase revenue. The most time-consuming and tedious task I have to perform over-and-over is creating contacts sheet previews of my icons. The problem is that every marketplace has different requirements for preview image sizes and so a new contact sheet has to be created for each marketplace.
I wrote the Contact Sheet for Adobe Illustrator for Iconfinder to help our designers spend more time creating products and less time creating previews.
/**
* Name that script.
*/
#script "Contact Sheet";
#target Illustrator
var originalInteractionLevel = userInteractionLevel;
userInteractionLevel = UserInteractionLevel.DONTDISPLAYALERTS;
/**
* @author Iconfinder.com - http://iconfinder.com
* @date 2016-09-13
*
* Installation:
*
* 1. Copy this file to Illustrator > Presets > Scripting
* 2. Restart Adobe Illustrator
* 3. Go to File > Scripts > Contact Sheet
* 4. Follow the prompts
*
* Usage:
*
* This script will create a contact sheet of vector objects from a folder structure
* that you specify. As of 13-09-2016 the script will only work with folder structures
* nested 1 level deep (Parent > Subfolders). This was done intentionally to allow
* for creating contacts sheets of categorized icons where the user wants to
* be able to specify the order of the categories.
*
* Inputs:
*
* Page Width: The width of the contact sheet in pixels
* Page Height: The height of the contact sheet in pixels
* Column Width: The width of the columns in pixels
* Row Height: The height of the rows in pixels
* Scale: The percentage (100 = 100%) to scale the objects being placed
*
* The resulting contact sheet will have margins that are calculated thus: subtracting
* Left & Right Margins = (Page Width - Column Width * Column Count) / 2
* Top & Bottom Margins = (Page Height - Row Height * Row Count) / 2
*
* Copyright:
*
* (c) copyright: Iconfinder.com - http://iconfinder.com
* copyright full text can be found in the accompanying file license.txt
*/
var LANG = {
CHOOSE_FOLDER: "Please choose your Folder of files to place...",
NO_SELECTION: "No selection",
LABEL_SETTINGS: "Contact Sheet Settings",
LABEL_PG_WIDTH: "Page Width:",
LABEL_PG_HEIGHT: "Page Height:",
LABEL_COL_COUNT: "Column Count:",
LABEL_ROW_COUNT: "Row Count:",
LABEL_SCALE: "Scale:",
LABEL_FILE_NAME: "File Name:",
LABEL_LOGGING: "Logging?",
BUTTON_CANCEL: "Cancel",
BUTTON_OK: "Ok",
DOES_NOT_EXIST: " does not exist",
LAYER_NOT_CREATED: "Could not create layer. "
}
var CONFIG = {
/**
* Whether or not to add the file name as text
* under the imported icons.
*/
ADD_LABELS: true,
/**
* Number of rows
*/
ROWS: 20,
/**
* Number of columns
*/
COLS: 10,
/**
* Top & bottom page margins
*/
VOFF: 64,
/**
* Left & Right page margins
*/
HOFF: 64,
/**
* Row height. This is set programmatically.
*/
ROW_WIDTH: 128,
/**
* Column Height. This is set programmatically.
*/
COL_WIDTH: 128,
/**
* @deprecated
*/
FRM_WIDTH: 128,
/**
* @deprecated
*/
FRM_HEIGHT: 128,
/**
* Artboard width
*
* 10 columns 128 px wide, with 64 px page margins
*/
PG_WIDTH: 1408,
/**
* Artboard height
*
* 20 rows 128 px tall, with 64 px page margins
*/
PG_HEIGHT: 2688,
/**
* Not yet fully-implemented. Will support multiple units
*/
PG_UNITS: "px",
/**
* @deprecated
*/
GUTTER: 0,
/**
* Enter scale in percentage 1-100
*/
SCALE: 100,
/**
* Illustrator version compatibility
*/
AIFORMAT: Compatibility.ILLUSTRATOR10,
/**
* If the icon is larger than the cell size, shrink it to the cell size
*/
SHRINK_TO_FIT: true,
/**
* Start folder for selection
*/
START_FOLDER: Folder.desktop,
/**
* The contact sheet file name
*/
FILENAME: "contact-sheet",
/**
* Enable logging?
*/
LOGGING: true,
/**
* Log file location
*/
LOG_FILE_PATH: Folder.desktop + "/ai-contactsheet-log.txt",
/**
* Verbose logging output?
*/
DEBUG: true,
/**
* @deprecated
*/
SKIP_COLS: 0,
/**
* Not fully-implemented
*/
STRIP: ["svg", "ai", "eps", "txt", "pdf"]
}
/**
* Displays the settings dialog
*
* Inputs:
* - skip columns
* - page width
* - page height
* - cell width
* - cell height
* - scale
* - logging enabled
*
* - number of cols = divide page width by cell width
* - number of rows = divide page height by cell height
* - side margins = (page width - (col count * col width))/2
* - top/bottom margins = (page height - (row count * row width))/2
*
* @return Settings object
*/
function doDisplayDialog() {
var dialog = new Window("dialog", LANG.LABEL_SETTINGS, [550, 350, 900, 700]);
var response = false;
try {
dialog.pageWidthLabel = dialog.add("statictext", [32, 30, 132, 60], LANG.LABEL_PG_WIDTH);
dialog.pageWidth = dialog.add("edittext", [150, 30, 200, 60], CONFIG.PG_WIDTH);
dialog.pageWidth.active = true;
dialog.pageHeightLabel = dialog.add("statictext", [32, 70, 132, 100], LANG.LABEL_PG_HEIGHT);
dialog.pageHeight = dialog.add("edittext", [150, 70, 200, 100], CONFIG.PG_HEIGHT);
dialog.pageHeight.active = true;
dialog.colsLabel = dialog.add("statictext", [32, 110, 132, 140], LANG.LABEL_COL_COUNT);
dialog.cols = dialog.add("edittext", [150, 110, 200, 140], CONFIG.COLS);
dialog.cols.active = true;
dialog.rowsLabel = dialog.add("statictext", [32, 150, 132, 180], LANG.LABEL_ROW_COUNT);
dialog.rows = dialog.add("edittext", [150, 150, 200, 180], CONFIG.ROWS);
dialog.rows.active = true;
dialog.scaleLabel = dialog.add("statictext", [32, 190, 132, 220], LANG.LABEL_SCALE);
dialog.scale = dialog.add("edittext", [150, 190, 200, 220], CONFIG.SCALE);
dialog.scale.active = true;
dialog.filenameLabel = dialog.add("statictext", [32, 230, 132, 260], LANG.LABEL_FILE_NAME);
dialog.filename = dialog.add("edittext", [150, 230, 320, 260], CONFIG.FILENAME);
dialog.filename.active = true;
dialog.logging = dialog.add('checkbox', [32, 270, 132, 340], LANG.LABEL_LOGGING);
dialog.logging.value = CONFIG.LOGGING;
dialog.cancelBtn = dialog.add("button", [80, 300, 170, 330], LANG.BUTTON_CANCEL, {
name: "cancel"
});
dialog.openBtn = dialog.add("button", [180, 300, 270, 330], LANG.BUTTON_OK, {
name: "ok"
});
dialog.cancelBtn.onClick = function() {
dialog.close();
response = false;
return false;
};
dialog.openBtn.onClick = function() {
CONFIG.PG_WIDTH = parseInt(dialog.pageWidth.text);
CONFIG.PG_HEIGHT = parseInt(dialog.pageHeight.text);
CONFIG.LOGGING = dialog.logging.value;
CONFIG.SCALE = parseInt(dialog.scale.text);
CONFIG.COLS = parseInt(dialog.cols.text);
CONFIG.ROWS = parseInt(dialog.rows.text);
CONFIG.COL_WIDTH = parseInt((CONFIG.PG_WIDTH - (CONFIG.HOFF * 2)) / CONFIG.COLS);
CONFIG.ROW_HEIGHT = parseInt((CONFIG.PG_HEIGHT - (CONFIG.VOFF * 2)) / CONFIG.ROWS);
CONFIG.FRM_WIDTH = CONFIG.COL_WIDTH;
CONFIG.FRM_HEIGHT = CONFIG.ROW_HEIGHT;
if (CONFIG.DEBUG) {
logger("CONFIG.PG_WIDTH: " + CONFIG.PG_WIDTH);
logger("CONFIG.PG_HEIGHT: " + CONFIG.PG_HEIGHT);
logger("CONFIG.FRM_WIDTH: " + CONFIG.FRM_WIDTH);
logger("CONFIG.FRM_HEIGHT: " + CONFIG.FRM_HEIGHT);
logger("CONFIG.COL_WIDTH: " + CONFIG.COL_WIDTH);
logger("CONFIG.ROW_HEIGHT: " + CONFIG.ROW_HEIGHT);
logger("CONFIG.SCALE: " + CONFIG.SCALE);
logger("CONFIG.ROWS: " + CONFIG.ROWS);
logger("CONFIG.COLS: " + CONFIG.COLS);
logger("CONFIG.VOFF: " + CONFIG.VOFF);
logger("CONFIG.HOFF: " + CONFIG.HOFF);
}
dialog.close();
response = true;
return true;
};
dialog.show();
} catch (ex) {
logger(ex);
alert(ex);
}
return response;
}
/**
* Utility function to strip the file extension from a user-supplied file name
* @param <string> filename
* @return <string> The new file name sans extension
*/
function stripFileExtension(filename) {
var bits = filename.split(".");
var bit = bits[bits.length - 1];
var found = false;
if (bits.length > 1 && bit) {
for (ext in CONFIG.STRIP) {
if (ext.toLowerCase() == bit.toLowerCase()) {
found = true;
}
}
}
if (found) bits = bits[bits.length - 1] = "";
return bits.join(".");
}
/**
* Main logic to create the contact sheet.
* @return void
*/
function doCreateContactSheet() {
var doc, fileList, i, srcFolder, svgFile,
svgFilePath, saveCompositeFile, allFiles,
theFolders, svgFileList, theLayer;
var saveCompositeFile = false;
srcFolder = Folder.selectDialog(LANG.CHOOSE_FOLDER, CONFIG.START_FOLDER);
if (srcFolder != null) {
allFiles = srcFolder.getFiles();
theFolders = [];
for (var x = 0; x < allFiles.length; x++) {
if (allFiles[x] instanceof Folder) {
theFolders.push(allFiles[x]);
}
}
svgFileList = [];
if (theFolders.length == 0) {
svgFileList = srcFolder.getFiles(/\.svg$/i);
} else {
for (var x = 0; x < theFolders.length; x++) {
// Gets just the SVG files...
fileList = theFolders[x].getFiles(/\.svg$/i);
for (var n = 0; n < fileList.length; n++) {
svgFileList.push(fileList[n]);
}
}
}
if (svgFileList.length > 0) {
if (!doDisplayDialog()) {
return;
}
if (CONFIG.FILENAME.replace(" ", "") == "") {
CONFIG.FILENAME = srcFolder.name.replace(" ", "-") + "-all";
}
// CONFIG.FILENAME = stripFileExtension(CONFIG.FILENAME);
app.coordinateSystem = CoordinateSystem.ARTBOARDCOORDINATESYSTEM;
doc = app.documents.add(
DocumentColorSpace.RGB,
CONFIG.PG_WIDTH,
CONFIG.PG_HEIGHT,
CONFIG.PG_COUNT = Math.ceil(svgFileList.length / (CONFIG.ROWS * CONFIG.COLS)),
DocumentArtboardLayout.GridByCol,
CONFIG.GUTTER,
Math.round(Math.sqrt(Math.ceil(svgFileList.length / (CONFIG.ROWS * CONFIG.COLS))))
);
for (var i = 0; i < svgFileList.length; i++) {
var board;
var bounds;
var x1 = y1 = x2 = y2 = 0;
var myRowHeight = CONFIG.ROW_HEIGHT + CONFIG.GUTTER;
var myColumnWidth = CONFIG.COL_WIDTH + CONFIG.GUTTER
var myFrameWidth = CONFIG.FRM_WIDTH
var myFrameHeight = CONFIG.FRM_HEIGHT
for (var pageCounter = CONFIG.PG_COUNT - 1; pageCounter >= 0; pageCounter--) {
doc.artboards.setActiveArtboardIndex(pageCounter);
board = doc.artboards[pageCounter];
bounds = board.artboardRect;
boardWidth = Math.round(bounds[2] - bounds[0]);
// loop through rows
var rowCount = Math.ceil((svgFileList.length / CONFIG.COLS));
rowCount = CONFIG.ROWS > rowCount ? rowCount : CONFIG.ROWS;
// If we are skipping a column, chances are we need to
// add a new row for the overflow of the shift. Even if there
// is not a new row needed, there are no consequences for
// adding one, so just in case.
if (CONFIG.SKIP_COLS > 0) {
rowCount++;
}
for (var rowCounter = 1; rowCounter <= rowCount; rowCounter++) {
myY1 = bounds[1] + CONFIG.VOFF + (myRowHeight * (rowCounter - 1));
myY2 = myY1 + CONFIG.FRM_HEIGHT;
// loop through columns
var colCount = CONFIG.COLS;
if (rowCounter > 1) {
var remaining = Math.ceil(svgFileList.length - i);
if (remaining < colCount) {
colCount = remaining;
}
}
for (var columnCounter = 1; columnCounter <= colCount; columnCounter++) {
try {
// A hack to allow merging multiple contact sheets
// Shift the starting row so it aligns nicely with
// the icons already in the master contact sheet.
if (CONFIG.SKIP_COLS > 0 && rowCounter == 1 && columnCounter <= CONFIG.SKIP_COLS) {
continue;
}
var f = new File(svgFileList[i]);
if (f.exists) {
try {
if (i == 0) {
theLayer = doc.layers[0];
} else {
theLayer = doc.layers.add();
}
theLayer.name = f.name;
} catch (ex) {
logger(LANG.LAYER_NOT_CREATED + ex);
}
svgFile = doc.groupItems.createFromFile(f);
var liveWidth = (CONFIG.COLS * (CONFIG.FRM_WIDTH + CONFIG.GUTTER)) - CONFIG.GUTTER;
var hoff = Math.ceil((CONFIG.PG_WIDTH - liveWidth) / 2);
myX1 = bounds[0] + hoff + (myColumnWidth * (columnCounter - 1));
myX2 = myX1 + CONFIG.FRM_HEIGHT;
var shiftX = Math.ceil((CONFIG.FRM_WIDTH - svgFile.width) / 2);
var shiftY = Math.ceil((CONFIG.FRM_WIDTH - svgFile.height) / 2);
x1 = myX1 + shiftX;
y1 = (myY1 + shiftY) * -1;
try {
svgFile.position = [x1, y1];
if (typeof(svgFile.resize) == "function") {
svgFile.resize(CONFIG.SCALE, CONFIG.SCALE);
}
if (CONFIG.ADD_LABELS) {
addLabel(theLayer, [x1, y1 - (svgFile.height + 20)], f.name)
}
// Only save the composite file if at least one
// icon exists and is successfully imported.
saveCompositeFile = true;
redraw();
} catch (ex) {
try {
svgFile.position = [0, 0];
logger(ex);
} catch (ex) {
/*Exit Gracefully*/ }
}
} else {
logger(svgFileList[i] + LANG.DOES_NOT_EXIT);
}
} catch (ex) {
logger(ex);
alert(ex);
}
i++;
}
}
};
if (saveCompositeFile)
saveFileAsAi(srcFolder.path + "/" + CONFIG.FILENAME);
}
};
};
};
/**
* Arranges items in the selection on a grid
* @param <selection> sel The current selection
* @return void
*/
function arrangeItems(sel) {
var board;
var bounds;
var itemBounds;
var cols;
var cellSize;
var x1 = y1 = 0;
var boardWidth, boardHeight;
board = doc.artboards[doc.artboards.getActiveArtboardIndex()];
bounds = board.artboardRect;
boardWidth = Math.round(bounds[2] - bounds[0]);
cols = CONFIG.NUM_COLS;
rows = CONFIG.NUM_ROWS;
x1 = bounds[0] + cellSize;
y1 = bounds[1] - cellSize;
for (var i = 0, slen = sel.length; i < slen; i++) {
theItem = sel[i];
itemBounds = theItem.visibleBounds;
theItem.top = y1 - ((cellSize - theItem.height) / 2);
theItem.left = x1 + ((cellSize - theItem.width) / 2);
alignToNearestPixel(theItem);
x1 += cellSize;
if (i % cols == cols - 1) {
x1 = bounds[0] + cellSize;
y1 -= cellSize;
}
}
if (CONFIG.SHRINK_TO_FIT) {
// The bounds are plotted on a Cartesian Coordinate System.
// So a 32 x 32 pixel artboard with have the following coords:
// (assumes the artboard is positioned at 0, 0)
// x1 = -16, y1 = 16, x2 = 16, y2 = -16
// board.artboardRect = [x1, y1, x2, y2];
board.artboardRect = [
bounds[0],
bounds[1],
bounds[0] + ((cols * cellSize) + (2 * cellSize)),
bounds[1] - (rows * cellSize)
];
}
};
/**
* Places a text label
* @param {string} text
* @param {string} pos - The X/Y position of the label
* @param {string} size - The text content of the label
* @returns void
*/
function addLabel(layer, pos, theText) {
try {
var theLabel = layer.textFrames.add();
theLabel.contents = theText;
var charAttributes = theLabel.textRange.characterAttributes;
var parAttributes = theLabel.paragraphs[0].paragraphAttributes;
charAttributes.size = 8;
parAttributes.justification = Justification.CENTER;
try {
theLabel.position = pos;
} catch (e) {
alert('labelPosition : ' + e)
}
return theLabel;
} catch (e) {
alert('addLabel : ' + e)
}
}
/**
* Saves the file in AI format.
* @param <string> The file destination path
* @return void
*/
function saveFileAsAi(dest) {
if (app.documents.length > 0) {
var options = new IllustratorSaveOptions();
var theDoc = new File(dest);
options.compatibility = CONFIG.AIFORMAT;
options.flattenOutput = OutputFlattening.PRESERVEAPPEARANCE;
options.pdfCompatible = true;
app.activeDocument.saveAs(theDoc, options);
}
}
/**
* Aligns selection to nearest whole pixel
* @param <selection> sel The selection object
* @return void
*/
function alignToNearestPixel(sel) {
try {
if (typeof sel != "object") {
logger(LANG.NO_SELECTION);
} else {
for (i = 0; i < sel.length; i++) {
sel[i].left = Math.round(sel[i].left);
sel[i].top = Math.round(sel[i].top);
}
redraw();
}
} catch (ex) {
logger(ex);
}
}
/**
* Logging for this script.
* @param <string> The logging text
* @return void
*/
function logger(txt) {
if (CONFIG.LOGGING == 0) return;
var file = new File(CONFIG.LOG_FILE_PATH);
file.open("e", "TEXT", "????");
file.seek(0, 2);
$.os.search(/windows/i) != -1 ? file.lineFeed = 'windows' : file.lineFeed = 'macintosh';
file.writeln("[" + new Date().toUTCString() + "] " + txt);
file.close();
}
/**
* Aligns the item to the nearest pixel for crisp rendering.
* @param <object> item The item to align
* @return void
*/
function alignToNearestPixel(item) {
if (item.height) {
item.height = moveToPixel(item.height);
}
if (item.width) {
item.width = moveToPixel(item.width);
}
item.top = moveToPixel(item.top);
item.left = moveToPixel(item.left);
};
/**
* Adjusts a value to the nearest whole number
* @param <float> n The value to adjust
* @return <int>
*/
function moveToPixel(n) {
return Math.round(n)
};
doCreateContactSheet();
userInteractionLevel = originalInteractionLevel;