Using Apache POI 3.17 To Save InVision Prototypes As Interactive PowerPoints In Lucee CFML 5.3.6.61
At InVision, I spend a lot of time lurking in our #Support
Slack channel, watching all of the questions that get tossed around in hopes that I see something that sparks a moment of inspiration. And, just the other day, I saw one of our Customer Success associates mention that they had a client that wanted to export a number of InVision screens as a MicroSoft PowerPoint (PPT) presentation. That's the first time I'd ever heard that particular request; but, given the fact that I just recently looked at how to use CFDocument
to save InVision prototypes as interactive PDFs in Lucee CFML, I was curious to see if the same technique was possible with the PPT file format. This post is my proof-of-concept, using the Apache POI library to generate interacted PPT presentations in Lucee CFML 5.3.6.61.
I started off this journey by Googling for ways to create a PowerPoint presentation in Java (which is the layer below ColdFusion). And, wouldn't you know it, the POI Project can generate PPT files. I haven't looked at the POI project in years; but, over a decade ago, I used to use the POI library to generate MicroSoft Excel documents in ColdFusion. At the time, I don't think I realized that the POI library could do more than Excel files - apparently, it can work with a number of MicroSoft document types.
One of the first links that came up was for TutorialsPoint, which had a decent intro to using the POI library with PPT documents. From there, I went to the JavaDocs for POI version 3.17 and started experimenting with the API (along with many Google searches).
NOTE: I started out using POI 4.1.2. However, I couldn't get the
SlideShow
class to instantiate due to some sort of Stream metrics error:
InputStream of class class org.apache.commons.compress.archivers.zip.ZipArchiveInputStream is not implementing InputStreamStatistics
As such, I reverted to POI 3.17 and that error seemed to go away.
Once I was able to generate some test MicroSoft PowerPoint documents with POI and Lucee CFML, I set about trying to generate an interactive PPT from some sample InVision data. First, I created a JSON payload that represented a collection of Screens and their HotSpots (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 PPT!
In a PowerPoint document - just as with a PDF document - all of the Slides need to be the same dimension. As such, I'm going to set the Slide dimensions to be the max-width and max-height of the screens within the prototype. In my case, all the screens are the same size, so it doesn't really matter; but, in a real-world prototype, you're likely to have variable-sized screens.
ASIDE: PowerPoint (or, at least Keynote for Mac) doesn't seem to allow slides to "scroll". As such, when your slides are taller than your monitor, the Slide contents shrinks to show the entire Slide. I don't know how you deal with this for larger screens. But, like I said, this is just a proof-of-concept. If someone has any advice on this matter, please feel free to share.
When you create a HyperLink in POI, you can give it things like an external URL or a reference to another Slide. As such, I ended up having to generate the PPT document in two passes:
Pass One: Create the slides and embed the screens. And, as part of the process, map the screen IDs to the resultant Slide instances.
Pass Two: Loop over the slides again, this time adding the hotspots as HyperLinks. And, since we now have a mapping of screenIDs to Slide, we can map the
targetScreenID
property of each Hotspot to a Slide within the PPT document.
With that said, here's what I came up with after hours of trial-and-error. Each prototype screen is a "Picture Shape" and each prototype hotspot is a "Rounded Rectangle Shape". I've added a 30-pixel (?point?) buffer to each Slide; so, when you see 30
and 70
in the following code, that's just the absolute coordinates of the Title, Screen, and Hotspots:
<cfscript>
// In Lucee CFML, when we call the createObject() method to instantiate Java objects,
// we can pass in an array of JAR files / directories from which Lucee will load the
// desired classes. In this demo, usage of this Array will be encapsulated within the
// javaNew() User Defined Function (UDF) at the bottom.
poiJarPaths = [
expandPath( "../poi-3.17/" ),
expandPath( "../poi-3.17/lib" ),
expandPath( "../poi-3.17/ooxml-lib" )
];
// Create some POI Class definitions that we'll need to reference below.
PictureType = javaNew( "org.apache.poi.sl.usermodel.PictureData$PictureType" );
SlideLayout = javaNew( "org.apache.poi.xslf.usermodel.SlideLayout" );
ShapeType = javaNew( "org.apache.poi.sl.usermodel.ShapeType" );
TextAlign = javaNew( "org.apache.poi.sl.usermodel.TextParagraph$TextAlign" );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Read in our InVision prototype data (this would normally come from the database).
screens = deserializeJson( fileRead( "./data.json" ) );
// In a PowerPoint (PPT) / Keynote presentation, all of the slides need to be the
// same size. As such, we're going to find the max dimensions of all the screens so
// that we can choose slide dimensions that can accommodate the largest screen.
maxScreenWidth = getMaxProperty( screens, "width" );
maxScreenHeight = getMaxProperty( screens, "height" );
// When calculating the slide dimensions, we're going to add some margin around each
// image which will give us some wiggle room and give us a place to add a Title.
slideWidth = ( maxScreenWidth + 30 + 30 );
slideHeight = ( maxScreenHeight + 70 + 30 );
// Create our PowerPoint slide-show with the given dimensions.
slideShow = javaNew( "org.apache.poi.xslf.usermodel.XMLSlideShow" )
.init()
;
slideShow.setPageSize(
javaNew( "java.awt.Dimension" )
.init( slideWidth, slideHeight )
);
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// When creating the PowerPoint slides, we have to perform TWO PASSES: one pass to
// create the individual slides and then another pass to add the hotspots / inter-
// slide hyperlinks. We have to do this in two passes because the hyperlinks take
// Slide instances as their targets.
// -- PASS ONE -- Create the slides and map screen IDs to Slide instances.
screenToSlideMap = {};
// Each slide is going to use the Title layout, which gives us a single placeholder
// into which we will set the Screen name.
titleOnlyLayout = slideShow.getSlideMasters()
.first()
.getLayout( SlideLayout.TITLE_ONLY )
;
for ( screen in screens ) {
// Create the slide and map it to the screenID at the same time.
slide
= screenToSlideMap[ screen.id ]
= slideShow.createSlide( titleOnlyLayout )
;
// Set the title and place it at the top-left of the slide.
title = slide.getPlaceholder( 0 );
title.clearText();
titleText = title.addNewTextParagraph();
titleText.setTextAlign( TextAlign.LEFT );
textRun = titleText.addNewTextRun();
textRun.setText( screen.name );
textRun.setFontSize( 22 );
title.setAnchor(
javaNew( "java.awt.Rectangle" )
.init( 30, 22, maxScreenWidth, 30 )
);
// In a PowerPoint presentation, the images are added to the slide show
// separately from their inclusion into a slide. This way, one picture can be
// reused (I assume) on several different slides. So, we're going to first add
// the screen binary to the slideshow.
// --
// NOTE: In a more variable environment, we would have to pass a different
// picture type (PNG, JPEG, GIF, etc). However, in this controlled demo, I know
// that all of the images are PNGs.
pictureData = slideShow.addPicture(
fileReadBinary( "./images/" & screen.clientFilename ),
pictureType.PNG
);
// Then, we're going to paint the screen images as shape on the slide, positioned
// below the title.
pictureShape = slide.createPicture( pictureData );
pictureShape.setAnchor(
javaNew( "java.awt.Rectangle" )
.init( 30, 70, screen.width, screen.height )
);
}
// -- PASS TWO -- Add the hotspots that link to existing Slides.
// Now that we've mapped all the screens to Slide instances, we can go back over
// the slides and add the hotspots (each of which links to another slide).
for ( screen in screens ) {
for ( hotspot in screen.hotspots ) {
// Get the Slide references for which slide we're on and which Slide we're
// about to link-to.
slide = screenToSlideMap[ screen.id ];
targetSlide = screenToSlideMap[ hotspot.targetScreenID ];
// Draw the hotspot shape on the slide.
hotspotShape = slide.createAutoShape();
hotspotShape.setShapeType( ShapeType.ROUND_RECT );
hotspotShape.setFillColor( colorNew( 0, 185, 255, 50 ) );
hotspotShape.setLineColor( colorNew( 0, 185, 255, 255 ) );
hotspotShape.setLineWidth( 2 );
hotspotShape.setAnchor(
javaNew( "java.awt.Rectangle" )
.init(
( hotspot.x + 30 ),
( hotspot.y + 70 ),
hotspot.width,
hotspot.height
)
);
// Link the hotspot shape to the target slide.
hotspotLink = hotspotShape.createHyperlink();
hotspotLink.linkToSlide( targetSlide );
}
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Now that we've generated the PowerPoint presentation from the InVision prototype,
// we need to save it to disk.
outputFile = javaNew( "java.io.File" )
.init( expandPath( "./prototype.ppt" ) )
;
outputFileStream = javaNew( "java.io.FileOutputStream" )
.init( outputFile )
;
try {
slideShow.write( outputFileStream );
echo( "Your InVision prototype has been generated as PPT!" );
} finally {
outputFileStream.close();
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
/**
* I create a new Color object from the given RGBA values with ranges 0-255.
*
* @red255 I am the red channel (0-255).
* @green255 I am the green channel (0-255).
* @blue255 I am the blue channel (0-255).
* @alpha255 I am the alpha channel (0-255).
*/
public any function colorNew(
required numeric red255,
required numeric green255,
required numeric blue255,
required numeric alpha255
) {
var color = javaNew( "java.awt.Color" )
.init(
( red255 / 255 ),
( green255 / 255 ),
( blue255 / 255 ),
( alpha255 / 255 )
)
;
return( color );
}
/**
* I get the max value for the given property within the given collection.
*
* @collection I am the collection being inspected.
* @collectionProperty I am the property being evaluated.
*/
public numeric function getMaxProperty(
required array collection,
required string collectionProperty
) {
var maxValue = 0;
for ( var item in collection ) {
maxValue = max( maxValue, item[ collectionProperty ] );
}
return( maxValue );
}
/**
* I am a short-hand for Java class creation that will use the POI JAR paths for
* classes that appear to be POI-related.
*
* @className I am the Java class being created.
*/
public any function javaNew( required string className ) {
if ( className.find( "org.apache.poi." ) == 1 ) {
return( createObject( "java", className, poiJarPaths ) );
} else {
return( createObject( "java", className ) );
}
}
</cfscript>
As you can see, I'm using one of Lucee CFML's coolest features, which is the ability to dynamically load JAR files using createObject()
. In this case, I've encapsulated all my Java Class creation in a UDF, javaNew()
, which looks to see if the class is POI-related; and, if so, points to my POI project directories.
So freaking cool! I love Lucee CFML so hard!
And, when we run the above ColdFusion code, we generate a MicroSoft PowerPoint presentation that kind of, sort of looks like an InVision prototype:
Soooo cool! At least it works for screens that are of a reasonable size. I am not sure what you would do with InVision prototypes that have much longer screens. I'm sure there's a way to deal with it; but, I have next to no experience with MicroSoft PowerPoint (PPT) documents. In fact, as I said above, I only just learned that the Apache POI library could be used to generated PPT documents in Lucee CFML.
Want to use code from this post? Check out the license.
Reader Comments
Again, another great article Ben. Thanks for sharing this.
Going to bookmark it.
@Andreas,
Thank you -- I'm glad you found it interesting. The POI library is pretty nifty!