Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Rolando Lopez
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Rolando Lopez@rolando_lopez )

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

Post A Comment — I'd Love To Hear From You!

Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.