Skip to main content
Ben Nadel at CFinNC 2009 (Raleigh, North Carolina) with: Asif Rasheed and Qasim Rasheed
Ben Nadel at CFinNC 2009 (Raleigh, North Carolina) with: Asif Rasheed Qasim Rasheed ( @qasimrasheed )

Generating An Interactive Craft Sketch File From An InVision Prototype In Lucee CFML 5.3.6.61

By on
Tags:

At InVision, one of the tools that we offer is Craft / Craft-Manager, which provides a suite of functionality for generating interactive prototypes in Sketch and Photoshop. In recent years, the Sketch open file-format has evolved into a ZIP archive consisting of images and JSON (JavaScript Object Notation) data files. As such, I thought it would be a fun experiment to see if I could generate a Sketch file that includes Craft interactivity from the data that I can retrieve from an InVision prototype. And, because ColdFusion is the bee's knees, I'm going to do it using Lucee CFML 5.3.6.61.

CAUTION: I am not very familiar with Sketch, or Craft - it's not the team that I work on. As such, the entirety of this experiment is based on reverse-engineering - not some core understanding that I had prior to this post. So, please take anything I say here with a grain of salt.

The Sketch file-format is an "open standard" which means that it is documented in public. However, I could not make heads-or-tails of their documentation, which seemed to just be a series of circular references to different GitHub repositories. As such, I started this experiment by creating a Sketch file with the desired characteristics; and then, unzipping the Sketch file and examining the contents.

Even the most basic Sketch file has loads of data, much of which appears to be optional. However, since I couldn't understand how to best leverage the documented file-format, I just kept deleting parts of the data and checking to see if the Sketch file was still valid (ie, that I could open the file in Sketch and sync it to InVision using Craft).

Once I had what I thought was the bare-minimum of Sketch data files, I created a "source" directory that incorporated the type of data that I can get out of an InVision prototype. This included a set of Images and the following JSON file which has 1 prototype, 5 screens, and a myriad of hotspots that link the screens together using simple click-transitions:

{
	"prototype": {
		"id": 1,
		"name": "Steps Testing"
	},
	"screens": [
		{ "id": 1, "name": "Step-1", "clientFilename": "step-1.png", "imageUrl": "./prototype/images/step-1.png", "width": 600, "height": 531 },
		{ "id": 2, "name": "Step-2", "clientFilename": "step-2.png", "imageUrl": "./prototype/images/step-2.png", "width": 600, "height": 531 },
		{ "id": 3, "name": "Step-3", "clientFilename": "step-3.png", "imageUrl": "./prototype/images/step-3.png", "width": 600, "height": 531 },
		{ "id": 4, "name": "Step-4", "clientFilename": "step-4.png", "imageUrl": "./prototype/images/step-4.png", "width": 600, "height": 531 },
		{ "id": 5, "name": "Step-5", "clientFilename": "step-5.png", "imageUrl": "./prototype/images/step-5.png", "width": 600, "height": 531 }
	],
	"hotspots": [
		{ "screenID": 1, "targetScreenID": 1, "height": 33, "y": 87, "width": 29, "x": 371 },
		{ "screenID": 1, "targetScreenID": 2, "height": 34, "y": 87, "width": 33, "x": 405 },
		{ "screenID": 1, "targetScreenID": 3, "height": 33, "y": 88, "width": 26, "x": 445 },
		{ "screenID": 1, "targetScreenID": 4, "height": 32, "y": 88, "width": 31, "x": 476 },
		{ "screenID": 1, "targetScreenID": 5, "height": 32, "y": 88, "width": 32, "x": 512 },
		{ "screenID": 1, "targetScreenID": 2, "height": 51, "y": 438, "width": 101, "x": 445 },
		{ "screenID": 2, "targetScreenID": 1, "height": 33, "y": 87, "width": 29, "x": 371 },
		{ "screenID": 2, "targetScreenID": 2, "height": 34, "y": 87, "width": 33, "x": 405 },
		{ "screenID": 2, "targetScreenID": 3, "height": 33, "y": 88, "width": 26, "x": 445 },
		{ "screenID": 2, "targetScreenID": 4, "height": 32, "y": 88, "width": 31, "x": 476 },
		{ "screenID": 2, "targetScreenID": 5, "height": 32, "y": 88, "width": 32, "x": 512 },
		{ "screenID": 2, "targetScreenID": 3, "height": 49, "y": 437, "width": 104, "x": 443 },
		{ "screenID": 3, "targetScreenID": 1, "height": 33, "y": 87, "width": 29, "x": 371 },
		{ "screenID": 3, "targetScreenID": 2, "height": 34, "y": 87, "width": 33, "x": 405 },
		{ "screenID": 3, "targetScreenID": 3, "height": 33, "y": 88, "width": 26, "x": 445 },
		{ "screenID": 3, "targetScreenID": 4, "height": 32, "y": 88, "width": 31, "x": 476 },
		{ "screenID": 3, "targetScreenID": 5, "height": 32, "y": 88, "width": 32, "x": 512 },
		{ "screenID": 3, "targetScreenID": 4, "height": 53, "y": 435, "width": 105, "x": 443 },
		{ "screenID": 4, "targetScreenID": 1, "height": 33, "y": 87, "width": 29, "x": 371 },
		{ "screenID": 4, "targetScreenID": 2, "height": 34, "y": 87, "width": 33, "x": 405 },
		{ "screenID": 4, "targetScreenID": 3, "height": 33, "y": 88, "width": 26, "x": 445 },
		{ "screenID": 4, "targetScreenID": 4, "height": 32, "y": 88, "width": 31, "x": 476 },
		{ "screenID": 4, "targetScreenID": 5, "height": 32, "y": 88, "width": 32, "x": 512 },
		{ "screenID": 4, "targetScreenID": 5, "height": 52, "y": 435, "width": 103, "x": 445 },
		{ "screenID": 5, "targetScreenID": 1, "height": 33, "y": 87, "width": 29, "x": 371 },
		{ "screenID": 5, "targetScreenID": 2, "height": 34, "y": 87, "width": 33, "x": 405 },
		{ "screenID": 5, "targetScreenID": 3, "height": 33, "y": 88, "width": 26, "x": 445 },
		{ "screenID": 5, "targetScreenID": 4, "height": 32, "y": 88, "width": 31, "x": 476 },
		{ "screenID": 5, "targetScreenID": 5, "height": 32, "y": 88, "width": 32, "x": 512 },
		{ "screenID": 5, "targetScreenID": 1, "height": 54, "y": 434, "width": 104, "x": 443 }
	]
}

From this data, each screen will be translated into an artboard. And, each hotspot will be translated into a rect that links one artboard to another. Each artboard will consist of a single layer for the screen images; and then, N-additional layers for the N-hotspots that are associated with a given screen.

The algorithm for this ended-up being quite brute-force. There's not much that's elegant about it - it's just translating one data format into another. The trickiest part of this is that I couldn't use Lucee's native compress() function to generate the .sketch ZIP archive.

Well, that's not entirely true - when using Lucee's compress() function, I was able to generate the Sketch file; and, Sketch could open it. However, Craft-Manager couldn't expand the .sketch file during the syncing process. As such, I had to use the zip Command-Line tool that I invoked using CFExecute from a working-directory.

Before we look at the code, let's look at the outcome - in the following GIF, I'm going to:

  1. Generate a prototype.sketch file from the source data.
  2. Open it in Sketch.
  3. Sync it to InVision using Craft.
  4. Open up the new prototype in the InVision web-app.

How cool is that?! As you can see, I am generating an interactive Sketch file using my prototype data. Then, I'm syncing that interactive Sketch file up to InVision using Craft / Craft-Manager. And once synced, we can see that the resultant web prototype has hotspots!

Ok, here's the ColdFusion code behind this - it's several hundred lines. I've tried to break it down into steps at the top, each of which is implemented by a ColdFusion User Defined Function (UDF) farther down in the file. I hope this lends well to readability:

<cfscript>

	withWorkingDirectory(
		( workingDirectory ) => {

			// All of the Sketch data and assets will go into the "zip directory", which
			// will then be zipped-up at the end. A "Sketch" file is really just a ZIP
			// archive with a different file-extension. The Sketch archive / file format
			// consists of several known directories and JSON configuration files.
			var sketchDirectory = ( workingDirectory & "/sketch-archive" );
			var imagesDirectory = ( sketchDirectory & "/images" );
			var pagesDirectory = ( sketchDirectory & "/pages" );

			directoryCreate( sketchDirectory );
			directoryCreate( imagesDirectory );
			directoryCreate( pagesDirectory );

			// This is our prototype demo data - it has screens and hotspots (for the
			// sake of simplicity, I'm just exploring CLICK hotspots).
			var sourceData = deserializeJson( fileRead( "./prototype/data.json" ) );

			// STEP 1: Copy the screen images into the "images" folder within the Sketch
			// archive. As the images are copied, they are renamed to include an MD5 hash
			// of the byte-content.
			// --
			// CAUTION: I am not actually sure if the hash used for the filename is a
			// hash of the binary content - that's just a guess on my part. I just know
			// that is is NOT a UUID, like most of the other IDs in the Sketch data.
			var imagesIndex = copyScreensToImages( imagesDirectory, sourceData.screens );

			// STEP 2: Translate the source data into the master Page data. We will use
			// the Page data to generate the Page JSON as well as all of the other JSON
			// files.
			var pageData = getPageData( sourceData, imagesIndex );
			var pageJsonFilePath = ( pagesDirectory & "/" & pageData.do_objectID & ".json" );
			fileWrite( pageJsonFilePath, serializeJson( pageData ) );

			// STEP 3: Translate the Page data into User data.
			var userData = getUserData( pageData );
			var userJsonFilePath = ( sketchDirectory & "/user.json" );
			fileWrite( userJsonFilePath, serializeJson( userData ) );

			// STEP 4: Translate Page data into Meta data.
			var metaData = getMetaData_( pageData )
			var metaJsonFilePath = ( sketchDirectory & "/meta.json" );
			fileWrite( metaJsonFilePath, serializeJson( metaData ) );

			// STEP 5: Translate Page data into Document data.
			var documentData = getDocumentData( pageData );
			var documentJsonFilePath = ( sketchDirectory & "/document.json" );
			fileWrite( documentJsonFilePath, serializeJson( documentData ) );
			
			// STEP 6: Compress the sketch-source data into an actual Sketch file.
			compressSketchDirectory( sketchDirectory, expandPath( "./prototype.sketch" ) );

		}
	);

	echo( "<p> Your sketch file has been produced - #timeFormat( now() )#! </p>" );

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	/**
	* I compress the Sketch source documents into a Sketch file, which is really just a
	* ZIP archive with a different file-extension.
	* 
	* @sourceDirectory I am the directory of data to compress.
	* @zipFilepath I am the path at which to generate the ZIP file.
	*/
	private void function compressSketchDirectory(
		required string sourceDirectory,
		required string zipFilepath
		) {

		// In order to create a shallow ZIP with a local directory structure, we have to
		// execute the "zip" binary from WITHIN the SOURCE DIRECTORY.
		executeZipFromDirectory(
			sourceDirectory,
			[
				// Regulate the speed of compression: 0 means NO compression. This is
				// setting the compression method to STORE, as opposed to DEFLATE, which
				// is the default method. Since images are already compressed, there's no
				// need to waste CPU trying to compress them more.
				// --
				// NOTE: This also means that the JSON files will be stored without
				// compression as well. But, since the weight of the JSON files is likely
				// to be much smaller than the cumulative weight of all the screen
				// images, I think this will be OK - we can always optimize for this in
				// the future.
				"-0",
				// Recurse the source directory.
				"-r",
				zipFilepath,
				// Define the INPUT file - NOTE that this path is RELATIVE TO THE WORKING
				// DIRECTORY! By using a relative directory, it allows us to generate a
				// ZIP file in which the relative paths become the entries in the
				// resultant archive.
				"./"
			]
		);

	}


	/**
	* I copy the given screens into the images directory, renaming them based on an MD5
	* hash of the binary content. I return an index mapping of the screen ID to the new
	* MD5-based filename.
	* 
	* @imagesDirectory I am the target directory.
	* @screens I am the screens to be copied.
	*/
	private struct function copyScreensToImages(
		required string imagesDirectory,
		required array screens
		) {

		var imagesIndex = {};

		for ( var screen in screens ) {

			// First, let's copy the image to the target location.
			// --
			// NOTE: For this demo, I could have read the binary content first; however,
			// in a "production" setting, these images would be on a remote storage
			// server. As such, it would make sense to write them to the local disk
			// first and then read the contents (or event use an ETag).
			fileCopy( screen.imageUrl, ( imagesDirectory & "/" & screen.clientFilename ) );

			// Generate the new MD5-based filename.
			var md5Hash = hash( fileReadBinary( ( imagesDirectory & "/" & screen.clientFilename ) ) );
			var fileExtension = listLast( screen.clientFilename, "." );
			var imageClientFilename = ( md5Hash & "." & fileExtension );

			// Rename the temporary file.
			fileMove(
				( imagesDirectory & "/" & screen.clientFilename ),
				( imagesDirectory & "/" & imageClientFilename )
			);

			imagesIndex[ screen.id ] = imageClientFilename;

		}

		return( imagesIndex );

	}


	/**
	* I execute the zip command-line utility from the given WORKING DIRECTORY using the
	* given arguments. If error-output is returned from the utility, an error with the
	* details is thrown.
	* 
	* @workingDirectory I am the working directory from which to execute the zip command.
	* @zipArguments I am the command-line arguments for zip.
	*/
	private string function executeZipFromDirectory(
		required string workingDirectory,
		required array zipArguments
		) {

		// The Shell Script that's going to proxy the ZIP command is expecting the
		// working directory to be the first argument. As such, let's create a normalized
		// set of arguments for our proxy that contains the working directory first,
		// followed by the rest of the commands.
		var normalizedArguments = [ workingDirectory ]
			.append( "zip" )
			.append( zipArguments, true )
		;

		execute
			name = expandPath( "../../../cfbin/execute_from_directory.sh" )
			arguments = normalizedArguments.toList( " " )
			variable = "local.successOutput"
			errorVariable = "local.errorOutput"
			timeout = 30
			terminateOnTimeout = true
		;

		if ( len( errorOutput ?: "" ) ) {

			throw(
				type = "SketchExport.ZipError",
				message = "The zip command-line proxy returned error output.",
				detail = "Error: #errorOutput#",
				extendedInfo = "Working directory: #workingDirectory#, Command-line arguments: #serializeJson( zipArguments )#"
			);

		}

		return( successOutput ?: "" );

	}


	/**
	* I build the Document JSON payload from the master Page data.
	* 
	* @pageData I am the master Page data that has been compiled from the prototype.
	*/
	private struct function getDocumentData( required struct pageData ) {

		return({
			"_class": "document",
			"do_objectID": createUUID(),
			"pages": [
				{
					"_class": "MSJSONFileReference",
					"_ref_class": "MSImmutablePage",
					"_ref": "pages/#pageData.do_objectID#"
				}
			]
		});

	}


	/**
	* I build the MetaData JSON payload from the master Page data.
	* 
	* NOTE: The (_) at the end of the function name is to prevent collision with the
	* built-in ColdFusion function.
	* 
	* @pageData I am the master Page data that has been compiled from the prototype.
	*/
	private struct function getMetaData_( required struct pageData ) {

		var commitID = createUUID();

		var artboards = pageData.layers.reduce(
			( reduction, layer ) => {

				reduction[ layer.do_objectID ] = {
					name: layer.name
				};

				return( reduction );

			},
			{}
		);

		return({
			"commit": commitID,
			"pagesAndArtboards": {
				"#pageData.do_objectID#": {
					"name": pageData.name,
					"artboards": artboards
				}
			},
			"version": 123,
			"compatibilityVersion": 99,
			"app": "com.bohemiancoding.sketch3",
			"created": {
				"commit": "#commitID#",
				"appVersion": "63.1",
				"build": 92452,
				"app": "com.bohemiancoding.sketch3",
				"compatibilityVersion": 99,
				"version": 123
			},
			"appVersion": "63.1",
			"build": 92452
		});

	}


	/**
	* I build the Page JSON payload from the given source data and images index.
	* 
	* @sourceData I am the prototype source data.
	* @imagesIndex I am the mapping of screen IDs to Sketch image filenames.
	*/
	private struct function getPageData(
		required struct sourceData,
		required struct imagesIndex
		) {

		var prototype = sourceData.prototype;
		var screens = sourceData.screens;
		var hotspots = sourceData.hotspots;

		// The overall structure of our Sketch data is going to be fairly simple. I've
		// attempted to strip-out everything that didn't seem necessary through trial-
		// and-error; basically, I just kept removing properties and then running the
		// demo to see if the Sketch file was still valid and could be synced using
		// Craft-Manager.

		// The Sketch file will consist of a single Page.
		var page = {
			"_class": "page",
			"do_objectID": createUUID(),
			"name": prototype.name,
			"layers": nullValue() // Populated below.
		};

		// Each screen is going to be added to the Sketch document as an individual
		// artboard. The goal here is that the user will eventually "override" the
		// artboard with more granular shapes. But, for now, each artboard will
		// consist of a single image object with overlaid rectangle objects that acts as
		// hotspot-links between artboards. We have a bit of a chicken-and-egg situation
		// in so much as the hotspots link to "artboards"; but, we don't have artboards
		// yet. As such, let's create a separate mapping of screen IDs to artboards so
		// that we know how to map the hotspots afterwards.
		var artboardIdMapping = {};

		for ( var screen in screens ) {

			artboardIdMapping[ screen.id ] = createUUID(); // The artboard ID.

		}

		// We're going to space each artboard on a single horizontal axis with 100px
		// spacing between each. As such, we need to keep track of the running offset.
		var nextArtboardOffset = 0;

		page.layers = screens.map(
			( screen ) => {

				// The first layer in the artboard is always the screen image. We will
				// also be adding the hotspots as individual layers; but, that will be in
				// a later step.
				var bitmap = {
					"_class": "bitmap",
					"do_objectID": createUUID(),
					"name": screen.clientFilename,
					"frame": {
						"_class": "rect",
						"height": screen.height,
						"width": screen.width,
						"x": 0,
						"y": 0
					},
					"image": {
						"_class": "MSJSONFileReference",
						"_ref_class": "MSImageData",
						"_ref": "images/#imagesIndex[ screen.id ]#"
					}
				};

				// Get the hotspots that live on this screen and map them to rects.
				var rects = hotspots
					.filter(
						( hotspot ) => {

							return( hotspot.screenID == screen.id );

						}
					)
					.map(
						( hotspot, i ) => {

							// NOTE: For this demo, we're just implementing simple Click
							// links. I am not sure at this time how the following data
							// would change if the links get more complicated.
							return({
								"_class": "rectangle",
								"do_objectID": createUUID(),
								"name": "Hotspot #i#",
								"userInfo": {
									"com.invision.prototype": {
										"closeOnClickOutside": 1,
										"stayOnTargetScreen": 0,
										"maintainScrollPositionAfterRedirect": 0,
										"openURLInNewWindow": 0,
										"reverseTransitionOnClose": 1,
										"gesture": 0,
										"overlayPosition": 0,
										"scrollToPosition": 0,
										"maintainScrollPositionAfterGesture": 0,
										"smoothScrolling": 0,
										"overlayTransition": 0,
										"redirectAfter": 0,
										"componentType": "SRLink",
										"backgroundOpacity": 0,
										"linkType": 0,
										"transition": 0,
										"targetArtboardID": artboardIdMapping[ hotspot.targetScreenID ],
										"fixOverlayPosition": 1
									}
								},
								"frame": {
									"_class": "rect",
									"height": hotspot.height,
									"width": hotspot.width,
									"x": hotspot.x,
									"y": hotspot.y
								}
							});

						}
					)
				;

				var arboardOffset = nextArtboardOffset;
				nextArtboardOffset += ( screen.width + 100 );

				return({
					"_class": "artboard",
					"do_objectID": artboardIdMapping[ screen.id ],
					"name": screen.name,
					"frame": {
						"_class": "rect",
						"height": screen.height,
						"width": screen.width,
						"x": arboardOffset,
						"y": 0
					},
					"layers": rects.prepend( bitmap )
				});

			}
		);

		return( page );

	}


	/**
	* I build the User JSON payload from the master Page data.
	* 
	* @pageData I am the master Page data that has been compiled from the prototype.
	*/
	private struct function getUserData( required struct pageData ) {

		return({
			"#pageData.do_objectID#": {
				"scrollOrigin": "{50, 50}",
				"zoomValue": 0.5
			}
		});

	}


	/**
	* I create and manage a temporary working directory. The working directory is passed
	* to the given callback; and return value is bubbled-up; and then, the working
	* directory is deleted.
	* 
	* @callback I am the callback to invoke with the working directory.
	*/
	public any function withWorkingDirectory( required function callback ) {

		var workingDirectory = expandPath( "./temp/#createUniqueID()#" );

		directoryCreate( workingDirectory );

		try {

			return( callback( workingDirectory ) );

		} finally {

			directoryDelete( workingDirectory, true ); // True = recurse.

		}

	}
	
</cfscript>

I tried to leave a lot of comments in the code, so I'm not going to go into it any further.

The goal here would be to give users a way to "kick start" a Craft-based prototyping workflow using an existing web-prototype. This would give the user an avenue to streamline their workflow without having to do a big-bang rebuild of their entire prototype.

If nothing else, this was just a fun experiment in ColdFusion. It's really cool that the Sketch file is just an open format - that makes interoperability really exciting! I do wish the documentation was a bit easier to read; but, I suppose it's just all auto-generated.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel