Automatic Android resource generation with Photoshop scripts

by Alexander Gazarov in Android development

Foreword

As it is known, when developing for Android, you have to keep in mind that you should create resources for all the pixel densities. Originally there were only three of them: ldpi, mdpi, and hdpi. However, the progress waits for no one: pixel densities have been growing to crazy numbers, and Google has been slyly adding the letter "x" and got to xxxhdpi by now, which means that now there are six major screen configurations. But even that's not all, because some resources have several different states. Buttons on the action panel have two states, and that would be decent enough, but common buttons have much more of them.

There are several ways out of this situation: nag your artist, let multiple density support slide and hope that the system is going to somehow handle resource scaling on its own, or you could also do what programmers like to do the most: automation. There are various tools which can help with this. The most advanced one is probably Android Asset Studio. But the icons are, of course, drawn only for the common cases, and if you need to draw something custom, with unique styles, it is a poor helper. And that's where we have something to rescue us: scripting support in a fairly well-known instrument, Adobe Photoshop. In order to simplify the whole process, your humble servant has written several scripts for myself just for the cases like this, and now I'm sharing them with the readers. This article is where I'm going to describe how they work and how they are used. All the sources can be grabbed on BitBucket, and here I will show major points of interest and also I will shed some light on various tricks of working with Photoshop scripts which could be not obvious for beginners.

Using the scripts and how they work

For those who are completely unfamiliar with the scripting for Adobe Photoshop, I will briefly describe what it involves. The standard tool for this is ExtendScript Toolkit which comes bundled with Photoshop. Some guys on the Internet mentioned that the tool is rather silly, and I have to sorely agree with them that it is completely true, but that's what we have. It also has documentation on the function built into Photoshop, on the F1 key, which is as unwieldy as the editor itself, but at least it does its main function. The scripts themselves can be written in many languages, and personally I used JavaScript.

Adobe ExtendScript Toolkit

Creating icons for all densities

Returning to the matter at hand, all the scripts for working with resources can be divided into two categories: some of them directly launch the necessary actions (all of them start with "Make"), and the others serve as libraries with functions. The most important and and ubiquitous instrument is MakeForAllDensities which does exactly what says its name: it creates resources for all densities. The document which we use as the source has some requirements:

  1. It should be created for the mdpi densitiy. It is condidered the baseline, and then it is scaled to the required size.
  2. The document should already be saved somewhere so that the script reads the filename correctly (and also determines whether this is a nine-patch or not by the postfix ".9"). It is best to save in a subfolder of the main Android project folder, this way the script will find the resources folder by itself.
  3. An additional requirement: if this is a nine-patch, the lines should be drawn on a separate layer, the lowermost one.
  4. And the image should certainly be vector and not raster, because otherwise there's little point in scaling it with Photoshop instead of relying on Android. Just in case I should point out that nine-patch lines are an exception, and they can be raster.

If all the requirements are met, the rest is easy: open the document in Photoshop and launch the script by double clicking. After it launches, it will ask to show where the folder with resources is ("res"). If the document is saved in a subfolder of the main project folder, it will find out where to save, and will finish up the rest by itself.

The script itself looks very simple:

//@include ResizingAndSaving.jsx

#target photoshop

var outputFolder = detectFolder();
if (outputFolder) saveForAllDensities(outputFolder, 0, "");

Typical JavaScript, except for the first two weird lines. The first weird line looks like a common comment which is actually not a comment at all, but an import of a different file with the functions we need. This is the trick number one, because in the standard JavaScript you can't get by with things like this. The second weird line, as you can guess, hints that this is a script for Photoshop. As for what the rest of the lines do, we're going to find that out by looking at the ResizingAndSaving file.

I'm not going to give detectFolder here as it has nothing too special in it: the function check if there is a "res" folder in the parent folder of the document's folder, and returns it if it is found. If not, then the user will be asked. But then something more interesting begins:

function saveForAllDensities(outputFolder, version, postfix, ninePatchLines) {
    if (!ninePatchLines) ninePatchLines = computeNinePatchLines();

    var versionStr = version ?  "-v" + version : "";
    saveInFolder(outputFolder, "drawable-mdpi" + versionStr, 100, postfix, ninePatchLines);
    saveInFolder(outputFolder, "drawable-hdpi" + versionStr, 150, postfix, ninePatchLines);
    saveInFolder(outputFolder, "drawable-xhdpi" + versionStr, 200, postfix, ninePatchLines);
    saveInFolder(outputFolder, "drawable-xxhdpi" + versionStr, 300, postfix, ninePatchLines);
    saveInFolder(outputFolder, "drawable-xxxhdpi" + versionStr, 400, postfix, ninePatchLines);
};

I will answer the question in advance if someone has it: there's no ldpi density here because Google doesn't recommend creating resources for it explicitly. As it has been mentioned, the file could happen to be a nine-patch, and in terms of editing the file that means that it has a separate layer with the black lines on the sides. And we can't just take these lines and scale them: we have to paint the pixels fully black or not paint them at all, and also we can't touch the adjacent pixels. Besides, we have to take into account that the lines could not be continuous. That's where the computeNinePatchLines function comes in.

function computeNinePatchLines() {
    var docName = getDocName(false);
    if (!isNinePatch(docName)) return null;

    var ninePatchLines = null;

    var doc = app.activeDocument;
    var areaCheckingFunctions = [
        function(pos) {return areaIsEmpty(doc, pos, 0);},
        function(pos) {return areaIsEmpty(doc, 0, pos);},
        function(pos) {return areaIsEmpty(doc, pos, doc.height - 1);},
        function(pos) {return areaIsEmpty(doc, doc.width - 1, pos);}
    ];
    maxPositions = [doc.width, doc.height, doc.width, doc.height];
    ninePatchLines = new Array();
    for (var pos = 0; pos < areaCheckingFunctions.length; pos++) {
        ninePatchLines.push(findLines(maxPositions[pos], areaCheckingFunctions[pos]));
    }

    return ninePatchLines;
}

It is somewhat interesting to look into the function called areaIsEmpty():

function areaIsEmpty(doc, x, y) {
   var state = getState();

    if (doc.colorSamplers.length == 0) {
        var colorSampler = doc.colorSamplers.add([x,y]);
    } else {
        var colorSampler = doc.colorSamplers[0];
        colorSampler.move([x, y]);
    }

    var areaEmpty;
    try {
        areaEmpty = colorSampler.color.rgb.hexValue !== "000000";
    } catch (e) {
        areaEmpty = true;
    }

    restoreState(state);

    return areaEmpty;
}

This function's purpose is to check if the pixel is filled with black or not. But the thing is, Photoshop, as it turns out, is unable to tell us if the pixel is empty or not. Because of that we have to put a color sampler on it and check if an exception is going to be thrown. If yes, then the pixel indeed is empty. If not, then we can take a look at its color. That's the trick number two. The findLines function, which is not shown here, simply applies areaIsEmpty for all the pixels along each of the four edges of the screen and records their positions.

After this we can scale the resources and save them into a folder.

function saveInFolder(outputFolder, subFolder, scaling, postfix, ninePatchLines) {
    var opts = new ExportOptionsSaveForWeb(); 
    opts.format = SaveDocumentType.PNG; 
    opts.PNG8 = false; 
    opts.transparency = true; 
    opts.quality = 100;

    var state = getState();

    if (ninePatchLines) {
        var doc = app.activeDocument;   
        doc.resizeCanvas(doc.width - 2, doc.height - 2);
        resize(scaling, true);
        doc.resizeCanvas(doc.width + 2, doc.height + 2);
        drawLines(doc, scaling / 100, ninePatchLines);
    } else {
        resize(scaling, true);
    }
    activeDocument.exportDocument(createFile(outputFolder, subFolder, postfix, ".png", false), ExportType.SAVEFORWEB, opts);
    restoreState(state);
}

Everything is overall pretty clear here, but the way the picture is resized deserves a separate explanation. You'd think that there is a function Document.resize, and you could simply call it, right? But nothing will come out of this: the layer styles are not scaled this way. We could record an action and then play it programmatically. This works, but this solution has a problem that we will have to also keep a library of actions in addition to the script itself which must be imported into Photoshop before launching the script, which is kind of inconvenient.

Another solution is to use an interesting tool called ScriptListener.8li. It allows us to record all the actions done in Photoshop as a script regardless of whether these actions are in the standard API. The scripts that come out are rather inarticulate, but do their job perfectly. With a certain effort it is possible to find out specific functions of specific parameters and to create a working function out of the recorded actions. That's how this incomprehensible, but working function came to be:

function resize(size, relative) {
    var idImgS = charIDToTypeID( "ImgS" );
        var desc89 = new ActionDescriptor();
        var idWdth = charIDToTypeID( "Wdth" );
        var idPxl = charIDToTypeID( relative ? "#Prc" : "#Pxl" );
        desc89.putUnitDouble( idWdth, idPxl, size );
        var idscaleStyles = stringIDToTypeID( "scaleStyles" );
        desc89.putBoolean( idscaleStyles, true );
        var idCnsP = charIDToTypeID( "CnsP" );
        desc89.putBoolean( idCnsP, true );
        var idIntr = charIDToTypeID( "Intr" );
        var idIntp = charIDToTypeID( "Intp" );
        var idbicubicSharper = stringIDToTypeID( "bicubicAutomatic" );
        desc89.putEnumerated( idIntr, idIntp, idbicubicSharper );
    executeAction( idImgS, desc89, DialogModes.NO );
}

That sums up the trick number three. After we have the necessary size of the image, we can draw the nine-patch lines (if we need to), and the freshly made resource goes in the corresponding folder.

Creating icons for the action bar

Besides MakeForAllDensities there are four scripts MakeActionBarIcons, which make the icons for the action bar: for the black and white theme, with and without the disabled state. They are used in the same manner as MakeForAllDensities with the exception that the document is now supposed to contain just one layer. The most important part for this layer is to maintain the shape of the icon, and the styles will be applied automatically.

Now the difficulty is that Google has certain requirements for the button style depending on their state. If the button has just one state, then everything is simple and easy, but if it can be disabled, he have to devise a way to modify the layer appearance programmatically. In the case of action bar icons we have to know how we can change the layer opacity and color. There are no problems with the former, but with the latter the standard API shows its weak points again. That means we have to turn to our trusty ScriptListener.8li again. As a result of its use we got the function setLayerColor in the Styles file which is going to help us with changing the color of the vector layer. I think the abracadabra in the function body is best to be omitted.

Considering the action bar icons, nothing besides already mentioned is required to create them per se. But those who already looked into the Styles file, have already found out that there are lots of functions which came to be with the help of ScriptListener.8li, and which purpose is to manipulate layer effects. They were written for my own icons, for which I didn't add the scripts into the repository. Therefore someone may find them to be not enough and will have to create more. Again, it is possible to record actions or to make some styles and apply them programmatically. But this is not really convenient, so it's best to stick to the function again, especially now that we mastered ScriptListener.8li. And that's when we're going to have another quirk to deal with: if we create a script to add a certain effect to a layer and make a function out of it, we'll find out that when we apply it, previous effects are gone. This is where the trick number four comes useful. If we look closer at the beginning of the gibberish produced by ScriptListener.8li when we apply a layer effect, we'll keep seeing the following lines:

var idsetd = charIDToTypeID( "setd" );
    var desc = new ActionDescriptor();
    var idnull = charIDToTypeID( "null" );
        var ref = new ActionReference();
        var idPrpr = charIDToTypeID( "Prpr" );
        var idLefx = charIDToTypeID( "Lefx" );
        ref.putProperty( idPrpr, idLefx );
        var idLyr = charIDToTypeID( "Lyr " );
        var idOrdn = charIDToTypeID( "Ordn" );
        var idTrgt = charIDToTypeID( "Trgt" );
        ref.putEnumerated( idLyr, idOrdn, idTrgt );
    desc.putReference( idnull, ref );
    var idT = charIDToTypeID( "T   " );
        var desc2 = new ActionDescriptor();
        var idScl = charIDToTypeID( "Scl " );
        var idPrc = charIDToTypeID( "#Prc" );
        desc2.putUnitDouble( idScl, idPrc, 100.000000 );

And the script is ended with the following chord:

var idLefx = charIDToTypeID( "Lefx" );
desc.putObject( idT, idLefx, desc2 );
executeAction( idsetd, desc, DialogModes.NO );

The lines responsible for the creation of the style itself are the last four lines from the first portion of the code above, the ones which create desc2 and set the scale for the style. The rest is exactly what applies the style. Now that we know which lines do this, we can separate veins from meat and cut the boilerplate code from the code which applies the effect itself. The repeated lines have been placed into special functions in Styles which are used in the following way:

var style = newStyle(); //Create new style
addColorOverlay(style, 0xFF, 0xFF, 0xFF, 100); //Apply the effect
addStroke(style, 0x00, 0x00, 0x00, 3); //Apply one more effect
applyStyle(style); //And, finally, apply the whole style

Now we've got the instruments in our hands, it's about time to use them. I have to remind that all these miraculous things were contrived to apply different styles to the resource depending on its state. The makeIcons function in MenuIcons does exactly this: it applies different styles to the icon for the action bar and saves the results. I give the most important part here:

if (makeStateful) {
    var selectorData = [
        {
            state_enabled: false,
            postfix: "disabled"
        },
        {
            postfix: "normal"
        }
    ];
    makeSelectorXml(selectorData, outputFolder, "drawable");
}

var styleFunctions = [function(style) {applyActionBarItemStyle(whiteTheme, false)}];
var postfixes = ["normal"];
if (makeStateful) {
    styleFunctions.unshift(function(style) {applyActionBarItemStyle(whiteTheme, true)});
    postfixes.unshift("disabled");
}
saveStyledDrawables(outputFolder, styleFunctions, postfixes);

The first part of the function create a selector for our resource. You can read more about selectors in the Android documentation. In our case we have two states for the action bar: when the button is disabled and when it is in the normal condition. Correspondingly, an array describing the states is passed into the function makeSelectorXml. The objects have a field called postfix and, if required, one or more fields beginning with "state_". After that, makeSelectorXml makes an XML-file of the selector out of this monster which goes into the drawables folder.

The second part of the function creates two arrays: one of them creates function which apply the styles for the states, and the second one contains postfixes corresponding to the states. Each style applying function has the style argument at its disposal. It is exactly that object which comes out of the newStyle, which we were struggling with no so long ago. There's no need to call applyStyle, the saveStyledDrawavles will take care of everything. The functions makeSelectorXml and saveStyledDrawables shouldn't be put here, I think, because there's nothing in them except for plain and boring JavaScript.

Conclusion

So this is how it is possible to not draw a bunch of icons, and use a ready solution instead to cook up everything from a single image. You could use Android Asset Studio, of course, but the scripting way has its advantages. First of all, you can create scripts for your own icons which are simply absent in a third party tool (which is exactly why I started writing all this stuff). Secondly, having to upload a file to the website, dancing around the settings, and then downloading the images and shoving them into the necessary folders is not exactly as easy as just double clicking a script so it does all properly by itself. Besides that, Android Asset Studio doesn't wish to work with PSDs directly (at least at the moment of writing this article), doesn't see the difference between a common button and a nine patch, and batch file processing is a can't do.

I hope the article will be useful for those who develop applications for Android, and also for the ones who want to learn how to write scripts for Adobe Photoshop.

Some useful links

  1. Project page on BitBucket - that's what this whole article is about.
  2. Iconography of Android - everyone has read this, I think, but if not, this should be fixed as soon as possible.
  3. Nine patches in Android - some information about what a nine-patch is.
  4. Selectors for drawables with state - official documentation on the stateful drawables.

Comments:

No comments here yet, want to be the first one?

Leave a comment:

Want to use an avatar? Go to Gravatar and upload one!