Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Using CFDocument To Save InVision Prototypes As Interactive PDFs In Lucee CFML 5.3.4.80

By Ben Nadel on
Tags: ColdFusion

As I talked about in my post yesterday regarding "Dark Matter Designers", I've been playing around with some more advanced ways to generate PDFs at InVision. One idea that I had recently was to try and save one of our interactive prototypes as an interactive PDF. Meaning, a PDF in which the embedded screens had "hotspots" that would actually link to other pages (ie, screens) within the PDF. To explore this idea, I hard-coded some JSON (JavaScript Object Notation) and spun-up a Docker instance using CommandBox, Lucee CFML 5.3.4.80, and the PDF Extension version 1.0.0.75-SNAPSHOT that uses the Flying Saucer PDF rendering engine.

To get started with this experiment, I downloaded some of my old test screens and then hard-coded a JSON payload that included the screens and their hotpots (JSON truncated for demo):

[
	{
		"id": 1,
		"name": "Step 1",
		"clientFilename": "step-1.png",
		"width": 600,
		"height": 531,
		"hotspots": [
			{
				"x": 370,
				"y": 89,
				"width": 33,
				"height": 30,
				"targetScreenID": 1
			},
			{
				"x": 406,
				"y": 89,
				"width": 33,
				"height": 30,
				"targetScreenID": 2
			},
			// ....
		]
	},
	// ...
]

To keep things simple, all the IDs are static; and each hotspot is a simple "click" hotspot that just uses the static screen ID as its target. The dimensions and locations of the screens and hostpots are all using production pixel values.

Ok, so now the fun part - can we take the screen images and JSON data and turn them into an interactive PDF!

To do this, we have a few hurdles. First, the PDF uses inches as its unit of measurement; but, our screens and hotspots are all defined using pixels. To overcome this issue, I just used trial-and-error to figure out what mapping of pixels-to-inches lead to a good-enough looking PDF. I also built-in some wiggle room around the embedded screens (ie, made the PDF pages larger than they had to be) in order to allow for some fuzzy sizing.

The second hurdle was getting the anchor links to work. When I first started constructing the PDF, my initial instinct was to break each screen out into its own CFDocumentSection so that it would have natural line-breaks. As it turns out, however, anchor links do not work across sections. As such, I had to leave the entire document in one section and then manually insert page-breaks using CFDocumentItem[type="pagebreak"].

The third hurdle was general CSS support - I had to make choices in my document layout specifically because "better choices" didn't work in the PDF. For example, I have to center the screen on the page using a table tag since sizing and centering a div using margin:auto didn't seem to work.

With that said, here's the ColdFusion code that I came up with - I've broken the top-level CFDocument and its content out into two different files for easier reading. Here's the top-level page:

<cfscript>
	
	screens = deserializeJson( fileRead( "./data.json" ) );

	// Since all pages in a generated PDF need to be the same size, we're going to use
	// the largest Width and Height values as the PDF page size. Then, each screen will
	// be centered within the page.
	maxImageWidth = getMaxValue( screens, "width" );
	maxImageHeight = getMaxValue( screens, "height" );

	// The page dimensions have to be calculated in Inches; but, our image and hotspots
	// are all sized using Pixels. As such, we have to ROUGHLY TRANSLATE pixels-to-inches
	// using a good-enough approximation. Then, we'll leave in some wiggle-room and just
	// center the images so as to keep them in a consistent place.
	wiggleRoom = 25;
	pageMargin = 0.5;
	// NOTE: The PDF uses a "content-box" model. As such, we have to build the margin
	// value into the dimensions of the page.
	pageWidth = ( px2in( maxImageWidth + wiggleRoom ) + pageMargin + pageMargin );
	pageHeight = ( px2in( maxImageHeight + wiggleRoom ) + pageMargin + pageMargin );

	// Generate the PDF document using one screen per PDF page.
	// --
	// NOTE: We're leaving the BOTTOM MARGIN of each page 0 - again, this gives us some
	// wiggle-room in terms of translating pixels-to-inches. If the heights of the
	// screens aren't exactly correct, having no margin gives us some bleeding-room.
	document
		format = "pdf"
		filename = "./pages.pdf"
		overwrite = true
		localUrl = true
		pageType = "custom"
		pageWidth = pageWidth
		pageHeight = pageHeight
		unit = "in"
		marginTop = 0
		marginRight = pageMargin
		marginBottom = 0
		marginLeft = pageMargin
		bookmark = true
		htmlbookmark = true
		{

		include template = "./content.cfm";

	}
	
	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	/**
	* I get the max value out of the collection (using the given property).
	* 
	* @collection I am the collection being inspected.
	* @key I am the key whose value is being plucked.
	*/
	public numeric function getMaxValue(
		required array collection,
		required string key
		) {

		var maxValue = collection
			.map(
				( item ) => {

					return( item[ key ] );
				}
			)
			.max()
		;

		return( maxValue );

	}


	/**
	* I roughly translate Pixels to Inches for PDF generation.
	* 
	* @pixelValue I am the value being converted.
	*/
	public numeric function px2in( required numeric pixelValue ) {

		// This conversion value is based on trial-and-error and seems to generate a
		// good-enough rendering.
		return( pixelValue / 96 );

	}

</cfscript>

As you can see, the top-level page reads in the screens and calculates the size of the generated PDF, converting pixels to inches and building-in some wiggle-room in terms of page-dimensions. Notice that I've included the Lucee-CFML-Only attribute, htmlbookmark. This allows us to turns H1-6 tags into bookmarks in the generated PDF.

And now, the actual HTML / CFML content in the PDF. In this code, each page has a position: relative wrapper (td element). The hotspots are then anchor tags (a) that use position: absolute such that they can be stacked over the embedded screen image.

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<style type="text/css">

		html,
		body {
			margin: 0 ;
		}

		h2 {
			color: #ffffff ;
			font-size: 0px ;
			height: 1px ;
			line-height: 0px ;
		}

		td.screen {
			position: relative ;
			border: 2px solid #f0f0f0 ;
		}

		td.screen img {
			display: block ;
		}

		td.screen a.hotspot {
			border: 1px dashed blue ;
			border-radius: 8px 8px 8px 8px ;
			position: absolute ;
			text-decoration: none ;
		}

		td.blank-note {
			color: #999999 ;
			font-size: 16px ;
			font-family: monospace ;
			font-weight: bold ;
			text-transform: uppercase ;
		}

	</style>
</head>
<body>
	<cfoutput>

		<cfloop index="screen" array="#screens#">

			<!--
				Each hotspot will link to an anchor within the PDF. In order for anchor
				tags to work, we must use a SINGLE BODY. If we attempted to break this
				content up using CFDocumentSection, then our anchor links would break.
			-->
			<a name="screen#screen.id#"></a>

			<!--
				Using the H2 tags to generate HTML Bookmarks.
				--
				NOTE: Also using the H2 tag to implement the TOP MARGIN of the page. This
				is important because we want the A[NAME] anchor to be ABOVE the margin
				otherwise the link goes too far down on the target page.
			-->
			<h2 style="margin-bottom: #pageMargin#in ;">
				#encodeForHtml( screen.name )#
			</h2>

			<!-- Using table to center the page content. -->
			<table width="100%" cellspace="0" cellpadding="0" border="0">
			<tr>
				<td><br /></td>
				<td class="screen" style="width: #screen.width#px ;">

					<img
						src="file:///#expandPath( './images/#screen.clientFilename#' )#"
						width="#screen.width#"
						height="#screen.height#"
					/>

					<cfloop index="hotspot" array="#screen.hotspots#">

						<a
							href="##screen#hotspot.targetScreenID#"
							class="hotspot"
							style="width: #hotspot.width#px ; height: #hotspot.height#px ; left: #hotspot.x#px ; top: #hotspot.y#px ;">
							<br />
						</a>

					</cfloop>

				</td>
				<td><br /></td>
			</tr>
			</table>

			<cfdocumentitem type="pagebreak" />

		</cfloop>

		<!--
			Since we have a page break after each screen, we are going to be left with a
			blank page at the end. Let us add a note so that this does not look like a
			mistake.
		-->
		<table width="100%" border="0" style="height: #maxImageHeight#px ;">
		<tr>
			<td align="center" valign="center" class="blank-note">

				Page Intentionally Left Blank

			</td>
		</tr>
		</table>

	</cfoutput>
</body>
</html>

Now, when we run this Lucee CFML code and open up the generated PDF, we get the following experience:

An interactive PDF generated from an InVision prototype using Lucee CFML.

Yooooooo! That's kind of awesome! Obviously, the type of interactions in a PDF are going to be extremely limited - basically, we only have "click" (anchor tags) actions. But, encoding those relationships into an interactive PDF - that's kind of player! Just another reason that Lucee CFML is so much fun to work with!



Reader Comments

@Chris,

Ha ha ha, thanks :D I'm gonna see if I can flesh this out into something more scalable with a larger prototype. Will be interesting to see what the user-experience is like.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
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.