Adobe's ExtendScript: power with roadblocks

ExtendScript is a very powerful tool for adding custom functionality to Abobe products - in my case Photoshop. I’ve got a non-paid (family) project where I’ve got to convert a Microsoft PowerPoint file, slide-by-slide into a series of PSDs with lots of little rules: the correct images broken out into separate layers at the correct position and layer level, text combined from all locations in the slide and filled into the correct text layers, layers shown or hidden as needed, and more. Obviously I can’t just automate everything: I need a user to pick and choose information. Hello ScriptUI, and hello headaches.

Turns out that ExtendScript, a Javascript dialect, has a few quirks. Ever had the following code crash and tell you that the property DOESN’T EXIST? I did…

// Semi-deep copy object to clone.  Of course this is example code, not an actual C& P.
var clone = {};
for (item in object) if (object.hasOwnProperty(item)) {
  clone[item] = object[item];
}

Here’s the reason it crashed: my source object was the Photoshop preferences object, app.preferences. This “object” is not a true JS object: it’s a pseudo object injected into the JS runtime by the code that runs Photoshop. So when the forloop iterated through the properties and came to a function, it crashed trying to find the function itself. Or something like that - I’m guessing here as I’m not sure how Adobe implemented their JS interface.

But I got past that roadblock via the following function:

var copyDataOnlyShallow = function ( options ) {
	var extended = {};
	var prop;
	for (prop in options) {
		//console.log("processing property", prop);
		try {
			if (Object.prototype.hasOwnProperty.call(options, prop)
				typeof options[prop] !== 'function'
			) {
				extended[prop] = options[prop];
			}
		}
		catch(e){}
	}
	return extended;
};

Next up was how to convert the PPT/PPTX into something I can read. Thankfully I already had some practice with this one, but in BASH not in ExtendScript. Some snooping around the ’net got me to app.system(); - a nicely undocumented method that simply executes the passed string as if it was run on the commandline:

// Convert the PPT/X to an ODP by running the LibreOffice or OpenOffice.org executable headless
//  and using junk user data so it doesn't attempt to use any existing session.
// If it does use an existing session it causes the task to not happen.
var retval = app.system("cd '" + script_config.ppt_disassembly_path +"';" + script_config.office_converter_path +" '-env:UserInstallation=file://" + script_config.ppt_disassembly_path +"/junkuserdata' --headless --convert-to odp:impress8 '" + source_ppt_file.fsName +"'");

Once that’s done, it’s a simple unzip to get access to all the images and the content.xmlfile that contains all the text and slide information.

And then I hit a series of roadblocks. ExtendScript’s built-in XML tool is rather limited: no XPath. It just simply converts the XML into JS objects. Not what I call easy to use when working with large complex XML documents you didn’t hand-code for the task. But I had an ace-in-the-hole for this: in another project using Node.js I’d used the xmldom and xpath projects to get similar jobs done. Sliding right around that roadblock like it wasn’t even… Thud.

ExtendScript, at least in CS4, uses an ANCIENT edition of Javascript. No Array.map and its ilk, none of the nice tools that make life just a little bit easier - and the node.js-based code wants all that nice stuff. Enter es5-shim and es6-shim. Then I get to learn about how each project exposes it’s core functionality for node, and hack around that so I can get everything working in this environment:

// Node includes
{
	var XMLReader, XMLReader_backdoor;
	(function () { // Emulate a Node.js require. Has to be in a function to prevent extra stuff from escaping.
		#include "xmldom/sax.jsxinc";
		// The above defines a LOCAL XMLReader function, masking the higher-scope definition.
		XMLReader_backdoor = XMLReader; // Get that function into the higher scope.
	})();
	XMLReader = XMLReader_backdoor; // And get it renamed correctly.
	XMLReader_backdoor = undefined; // Drop the old name like an old AI-powersource potato.
	
	// Wash, rinse, repeat.
	var DOMImplementation, DOMImplementation_backdoor;
	var XMLSerializer, XMLSerializer_backdoor;
	(function () { // Emulate a Node.js require.
		#include "xmldom/dom.jsxinc";
		DOMImplementation_backdoor = DOMImplementation;
		XMLSerializer_backdoor = XMLSerializer;
	})();
	DOMImplementation = DOMImplementation_backdoor;
	DOMImplementation_backdoor = undefined;
	XMLSerializer = XMLSerializer_backdoor;
	XMLSerializer_backdoor = undefined;

		var DOMParser, DOMParser_backdoor;
		var DOMHandler, DOMHandler_backdoor;
		(function () { // Emulate a Node.js require.
			#include "xmldom/dom-parser.jsxinc";
			DOMParser_backdoor = DOMParser;
			DOMHandler_backdoor = DOMHandler;
		})();
		DOMParser = DOMParser_backdoor;
		DOMParser_backdoor = undefined;
		DOMHandler = DOMHandler_backdoor;
		DOMHandler_backdoor = undefined;

		// Already tuned for other universes like this one, doesn't need faking.
		#include "xpath/xpath.jsxinc";

}

HA. Serves you right! I’ve got my cake! So I’m off and running again. Reading the data, adding a user interface based around a series of dialog boxes interspersed by palettes that show progress bars for time-taking tasks. All was going so well until…

I needed to pop up another window when the user clicked on a thumbnail. Now I was using a dialog, but those are always on top, and I wanted the preview floater to be able to stay open and be moved around. I decided to let this bug it for a while while I worked on other tasks. Eventually I found another issue related to the window being a dialog that necessitated a change-up: I needed the ability to “delete” a slide. This meant that, for ease of coding, after stripping the data in my data store object I simply stripped the UI’s group containers and recreated them with the changed slide data. However, this caused Photoshop to hard crash. Press the button, and poof.

It seems that ScriptUI doesn’t like having an event handler remove the object that the handler is referencing, or even some parent thereof. In this case a button removing a parent group. Ok, that means let the button’s event handler only work the change in the data, and simply set a flag for some other, later task to handle. Not too later of course. Too bad the main code is stuck waiting on the dialog to close. Well, let’s just go use the windowtype instead! Yeah, yeah, not even ScriptUI for dummies touches much on windows. But it should be just like a palette, but with minimize and maximize added. Turns out I was right: but there was another problem.

Photoshop only has one script engine, and that one engine doesn’t have any long-lasting instances. This means that once the code hits the end of the script file it’s gone. No choice. But wait! David Barranca’s note on using BridgeTalk to show palettes gets me around that one. Hours of refactoring later, including working out how to pass a bucket-load of data from one script instance to another, I went with offloading it to a file; writing my own simplified preprocessor to handle #include; catching errors so I can debug them; and dealing with tighter memory constraints, I found myself looking at a working window that stayed.

And then I tried to click on a button. Any button. Hello? Code? Where my event handlers?!! A couple of hours of trying alternative ways of writing event handlers and I download Kasyan Servetsky’s example. Guess what? It doesn’t do anything either. I simplified the example just to be sure:

function CreatePalette() {
	var myDialog = new Window('palette', 'test window');
	var myGroup = myDialog.add('group', undefined, '');
	var myButton = myGroup.add('button', undefined, 'test', {name:'test'});
	myDialog.show();

	myDialog.onClose = function(){
		alert("And all the cake is gone. You don't even care, do you?");
	}

	myButton.onClick = function() {
		alert("CAKE!!!");
	}

}

(function () {
	var bt = new BridgeTalk();
	bt.target ="photoshop";
	var myScript = CreatePalette.toString() + '\';
	myScript += 'CreatePalette();';
	bt.body = myScript;
	bt.send();
})();

Sure enough. No alerts of any kind. Ok, this obviously worked for other people… Maybe a version issue? So I test on a nearby computer loaded with CC2014. It works. Ugh.

Updates soon, probably after I’ve got everything working!