Generating An Interactive Craft Sketch File From An InVision Prototype In Lucee CFML 5.3.6.61
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:
- Generate a
prototype.sketch
file from the source data. - Open it in Sketch.
- Sync it to InVision using Craft.
- 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