Using CFDocument To Save InVision Prototypes As Interactive PDFs In Lucee CFML 5.3.4.80
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:
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!
Want to use code from this post? Check out the license.
Reader Comments
I have just one thing to say to this - 😱 WHOA 😱!
@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.
Good Work and Thank you for the experience sharing.