Introduction

reveal.js is a handy JavaScript framework used to create PowerPoint-like presentations in HTML. Many users use it to build their presentations and share/present them all via the web. For a project I am working on, the customer wanted users to have the ability to create, store, edit, and show presentations that can pull information from services in the application to supply the content. The application must also be able to provide the presentation to external people with no access in a way that is distributable and can be viewed offline, such as a PDF.

The Problem

While reveal.js is a feature-rich tool, one thing that it lacks is the ability to export to a PDF that is user-friendly and cross-browser compatible. There is built-in export functionality, but it only works in Chrome and requires the user to modify the URL query string. Our customer required Firefox compatibility so we needed to engineer a way to allow PDF export that is available with the click of a button, and works in Firefox. The customer didn’t need the ability to export the presentation in a format used for editing (i.e. preserving all the HTML/CSS etc…) they just needed to export something they could present and email (i.e. a slideshow).

What to do?

So whenever I am presented with a problem that doesn’t have an obvious solution, one of the first things I do is look at work that has already been done in the past to see if the problem (or a similar problem) has already been solved. In this case, one of our engineers (Wayne Eull) had already worked on a capability which would capture a screenshot of an area of our web application and create a JPEG of it to use as a thumbnail. So the idea arose amongst the team that what is a slideshow, but a simple collection of images. If we can just capture a screenshot of each slide, we could package them back into a PDF and provide the capability to the customer.

Enter html2canvas

html2canvas is the tool that we were using to capture “screenshots”. I put that in quotation marks because what html2canvas does is that it attempts to capture the information in the DOM and transfer it to an HTML5 Canvas. The canvas object has the useful capability of exporting its contents to an image. So the plan of attack was to step through each slide in the presentation, create a JPEG image of it, and send the images off to a PDF generation tool to package them together into a PDF.

The Solution

The first action we had to take was to create a container at the top level of our application to hold the contents of a slide that is going to have a screenshot taken of it. This was needed because our application has headers/footers/etc… that affect the size of the actual presentation area. This would cause html2canvas to capture a canvas that was scaled incorrectly. By transferring the contents to a top level div, this fixes the scaling issue. To accomplish this we just add an empty div (with class=”ui snapshot”) into our HTML at the top level.

<html>
<head></head>
<body>
<div class="ui snapshot"></div>
<div id="header"></div>
<div id="main"></div>
</body>
</html>

The first ‘gotcha’ that we came across was somewhat unique to our application, but could trip up some users who are using Semantic UI. We use the dimmer module for various things (modal transitions, loading widgets, etc…), and it was discovered that this could interfere with the work of html2canvas. We had to first remove the dimmable class. If we did not, occasionally the images that would be captured would have a large black bar covering the bottom third or so of the image.

$('body').removeClass('dimmable');

Before we begin to capture slides, we want to rewind the presentation back to the start so that we can step through all the slides. We accomplish this by using lodash to clone the current state and preserve all information about it, then set the slide indices to 0. We will also save off the original presentation state so that we can revert to it when we are finished.

var beginningPresentationState = _.clone(originalPresentationState);
beginningPresentationState.indexv = 0;
beginningPresentationState.indexh = 0;
Reveal.setState(beginningPresentationState);

So now that we are at the start of the presentation, we are ready to capture our first slide. To capture a slide, we copy the current slide contents into the container div using jquery.

var clone = $('.reveal').clone();
$('.ui.snapshot').html(clone);

After the contents have been cloned into our div, we can capture the image for the slide.

// the container holding the slide
var snapshotContainer;
// array to hold all the capture images
var imagesToCapture;

// Grab the HTML from the cloned container.
snapshotContainer = $('.ui.snapshot .ui.slides');
// Use the lodash defer method to help with timing issues.
_.defer(function () {
    // Grab the canvas that contains the slide.
    html2canvas(snapshotContainer[0]).then(function (canvas) {
        // Convert the canvas to an image and push into the array.
        imagesToCapture.push(canvas.toDataURL('image/jpeg'));
    });
});

You’ll notice we are using the lodash defer method here. The defer method defers invoking the method until the current call stack has cleared. We would occasionally run into timing issues that could affect whether everything was in place before we captured the slide. In our application we had a lot going on during this process, so you may or may not need this.

Once you have captured the first slide, start stepping through each slide of the presentation. The images of each slide are placed into the imagesToCapture array. Use the reveal.js next method to step through and capture each slide.

Reveal.next();

When all slides have been captured, use one of the various PDF generation tools out there that can be used to build a PDF from the images. In this example, we will use jspdf.

Once we have completed building our PDF, we need to remember to clean everything up and revert the presentation back to its original state.

// Clear out the div that we copied information into.
$('.ui.snapshot').empty();

// Put the presentation back to the original state.
Reveal.setState(beginningPresentationState);

// Add the dimmable class back to the body.
$('body').addClass('dimmable');

Putting it all Together

Here is an example that pulls together the different steps into one place.

function rewindAndBegin() {
    var originalPresentationState,
        imagesToCapture = [];

    // Save off the original state of the presentation so that we can return there when we are finished.
    originalPresentationState = Reveal.getState();

    // Rewind the presentation back to the start.
    var beginningPresentationState = _.clone(originalPresentationState);
    beginningPresentationState.indexv = 0;
    beginningPresentationState.indexh = 0;
    Reveal.setState(beginningPresentationState);

    $('body').removeClass('dimmable');
    generateImagesForPDF(imagesToCapture, originalPresentationState);
}

function generateImagesForPDF(imagesToCapture, originalPresentationState) {
    // Use the lodash defer method to help with timing issues.
    _.defer(function () {
        var clone = $('.reveal').clone();
        $('.ui.snapshot').html(clone);

        // Grab the HTML from the cloned container.
        var snapshotContainer = $('.ui.snapshot .ui.slides');

        // Grab the canvas that contains the slide.
        html2canvas(snapshotContainer[0]).then(function (canvas) {
            // Convert the canvas to an image and push into the array.
            imagesToCapture.push(canvas.toDataURL('image/jpeg'));

            // Empty out the contents of the container.
            $('.ui.snapshot').empty();

            if (Reveal.isLastSlide()) {
                // If we are done, then send imagesToCapture off to PDF generation and cleanup.
                var exportedPresentation = new jsPDF();
                for (var i = 0; i < imagesToCapture.length; i++) {
                    exportedPresentation.addImage(imagesToCapture[i], 'JPEG', 15, 40, 180, 160);

                    if (i < (imagesToCapture.length - 1)) {
                        exportedPresentation.addPage();
                    }
                }

                exportedPresentation.save('presentation.pdf');
                Reveal.setState(originalPresentationState);
                $('body').addClass('dimmable');
            }
            else {
                Reveal.next();
                generateImagesForPDF(imagesToCapture, originalPresentationState);
            }
        });
    });
}

Bonus Points - Canvas within a Canvas

One of the more interesting features of reveal.js is that basically anything that can be displayed in a webpage can be integrated into the slides of the presentation. One of the components that users are allowed to incorporate into presentations in our application is a Cesium widget. This posed an extra problem for us. We needed to determine a way to include the contents of the cesium widget as well, as the html2canvas tool wouldn’t capture the contents of it. To accomplish this, we had to access the contents of the cesium canvas, determine its position on the slide, and then stitch one canvas into another at the correct location. This code is similar to the above code, but adds in handling for the cesium widget.

// the container holding a cesium component
var geospatialContainer = $('body').find('.slide-component.geospatial');
// the container holding the slide
var snapshotContainer = $('.ui.snapshot .ui.slides');

_.defer(function () {
    // Grab the canvas that contains the slide and any non geospatial content.
    html2canvas(snapshotContainer[0]).then(function (canvas) {

        if (!_.isUndefined(geospatialContainer) && geospatialContainer.length > 0) {
            // If there is geospatial content, extract the cesium canvas.
            var slideContext = canvas.getContext('2d'),
                cesiumCanvas = cesium.scene.canvas,
                offsetValue = 5, // account for things like padding/border size/etc...
                cesiumLeftPosition = ((geospatialContainer.position().left / Reveal.getScale()) + offsetValue),
                cesiumTopPosition = ((geospatialContainer.position().top / Reveal.getScale()) + offsetValue);

            // Draw the content of the geospatial component.
            slideContext.drawImage(cesiumCanvas, cesiumLeftPosition, cesiumTopPosition);
        }

        imagesToCapture.push(canvas.toDataURL('image/jpeg'));
    });
});

Conclusion

reveal.js is a really awesome tool for building and showing presentations. This little fix provides users with a bit of a band-aid to solve one of the holes in their functionality. Hopefully it can help someone out that needs it.