Printing / Printing

Printing

The jsPlumb Toolkit offers client side support for printing on the server. Given that the Toolkit operates inside a web page, any solution that is able to render a web page server side can be used to generate a PDF or screenshot from a Toolkit instance.

There are complications with server side printing, though - most notably nailing down when the page is fully loaded and ready to print. The Toolkit offers an optional package to assist with ensuring the client side is ready to print.

The package can be loaded via package.json:

"dependencies":{
  ...
  "jsplumbtoolkit-print":"file:/path/to/jsplumbtoolkit-print.tgz" 
  ...
}

In a vanilla setup:

<script src="/path/to/jsplumbtoolkit-print.js"></script>

In ES6/Typescript:

import { jsPlumbToolkitPrint } from "jsplumbtoolkit-print"

The basic setup required to print using the print helper package is:

  • import the print handler in your JS and create an instance of it that is tied to some Surface widget

  • configure an endpoint on your server that can serve the page containing the Toolkit instance without any content other than the Toolkit UI. How you do this is a matter of preference: a simple yet effective approach is simply to include a stylesheet that removes everything you do not want to see.

  • configure an endpoint on your server that can process print requests. This endpoint is where you write the code that interacts with headless Chrome, support for which is now available in many languages. On this page we'll show our endpoint code, which is written in Scala, but it uses a Java library, so it applies for Java too.

jsPlumbToolkitPrint.registerHandler(renderer, "jsplumb-demo-print");

This comes from our Flowchart Builder demonstration. renderer is an instance of the Surface widget. We also pass in the ID to use for this handler - which our server side code will look for.

jsplumbtoolkit.com serves all of its demonstrations from a single controller, with the ID of the desired demonstration forming part of the URL, for instance:

https://jsplumbtoolkit.com/demonstration/flowchart-builder

We have a second endpoint which can serve each demonstration without any content other than the demonstration itself:

https://jsplumbtoolkit.com/demonstration-print/flowchart-builder

On our site, we achieve this by the simple expedient of including a stylesheet that hides everything we don't want to see:

.jtk-site-demonstrations {
    display:none;
}

.jtk-site-header {
    display:none;
}

.jtk-site-footer {
    display:none;
}

.jtk-demo-rhs {
    display:none;
}

.jtk-miniview {
    display:none !important;
}

.controls {
    display:none;
}

This demonstration-print endpoint is the URL we will pass to headless Chrome. Under the hood the page is loading exactly as it does when the rest of the content is visible. The key is that the javascript is creating a print handler, as shown above, and that print handler knows when the Surface it is associated with is ready.

The last thing you need is an endpoint to process incoming print requests, which will communicate with headless Chrome to serve them. As mentioned above, we use Scala on jsplumbtoolkit.com, and to talk to headless Chrome we currently use this library:

https://github.com/kklisura/chrome-devtools-java-client

The print endpoint code looks like this:

def print(demoId:String, size:String) = Action.async { implicit request =>

    logger.info(s"Print request for demo $demoId and size $size")

    val method = com.jsplumb.print.PrintMethod.valueOf(size)
    
    val url = ... get the absolute url to the print generator
    
    val launcher = new ChromeLauncher
    
    val args = ChromeArguments.defaults(true)
      .headless()
      .additionalArguments("no-sandbox", null).build()
          
    val chromeService = launcher.launch(args)

    // Create empty tab ie about:blank.
    val tab = chromeService.createTab

    // Get DevTools service to this tab
    val devToolsService = chromeService.createDevToolsService(tab)

    // Get individual commands
    val page = devToolsService.getPage
    page.enable

    // load the page we want
    page.navigate(url)

    val listener = new PdfDataGenerator(method, devToolsService, "jsplumb-demo-print")

    val result:Future[Result] =  scala.concurrent.Future {
        page.onLoadEventFired(listener)
        devToolsService.waitUntilClosed
        val data = listener.getData
        chromeService.closeTab(tab)
        if (data != null)
          Ok(data).as("application/pdf")
        else {
          logger.error("could not generate pdf")
          InternalServerError(...)
        }
    }

    result
  }

A lot of the heavy lifting is done here by PdfDataGenerator, so we'll include the full code for it, and its superclasses.

AbstractListener.java

This class is the base class for all content generators (this page talks about PDF output, but we're also working on PNG/JPG output using the same architecture).

The key thing to note in this code is the jsPlumbToolkit.isReadyToPrint call. This executes on the headless Chrome instance, and we pass in the ID of the handler we want to test (in this case, jsplumb-demo-print as shown above). The code repeatedly tests for readiness until either the JS reports it is ready, or we exceed the number of attempts or timeout.

package com.jsplumb.print;

import com.github.kklisura.cdt.protocol.types.runtime.Evaluate;
import com.github.kklisura.cdt.services.ChromeDevToolsService;

public abstract class AbstractListener {

    protected ChromeDevToolsService devToolsService;

    public AbstractListener(ChromeDevToolsService devToolsService) {
        this.devToolsService = devToolsService;
    }

    protected void waitForReady(String handlerId, com.github.kklisura.cdt.protocol.commands.Runtime runtime, int attempts, int timeoutMillis) {
        int attempt = 0;
        String r_expr = "jsPlumbToolkitPrint.isReadyToPrint('" + handlerId + "');";
        Evaluate r_evaluate = runtime.evaluate(r_expr);
        boolean ready = (Boolean)r_evaluate.getResult().getValue();

        while (!ready && attempt < attempts) {
            System.out.println("Content not ready, load attempt " + attempt);
            try {
                Thread.sleep(timeoutMillis);
                r_evaluate = runtime.evaluate(r_expr);
                ready = (Boolean)r_evaluate.getResult().getValue();
            } catch (InterruptedException ie) {
                System.out.println("Thread sleep interrupted");
                throw new RuntimeException("Could not wait for content to be ready");
            }
            attempt++;
        }

        if (!ready) {
            throw new RuntimeException("Content was not ready within timeout.");
        }
    }
}
PdfListener.java

This class generates the PDF. EventHandler is an interface from the library we use, the key being that its onEvent method will be executed once the page loads. So that's where we've got all our code for generating the PDF.

There's a bunch of setup code first, followed by one of the key pieces:

String expr = "jsPlumbToolkitPrint.scaleToPageSize('" + this.printHandlerId + "', jsPlumbToolkitPrint.PageSizes." + printMethod.name() + ","  + margins +", jsPlumbToolkitPrint.Units.INCHES)";

This produces a string like:

jsPlumbToolkitPrint.scaleToPageSize('jsplumb-demo-print', jsPlumbToolkitPrint.PageSizes.A4,[0.25,0.25,0.55,0.25], jsPlumbToolkitPrint.Units.INCHES)

Which is then executed in the headless Chrome instance, instructing the Toolkit print handler with id jsplumb-demo-print to zoom/pan the content such that it will fit into an A4 (in this case) page, with the given margins.

package com.jsplumb.print;

import com.github.kklisura.cdt.protocol.events.page.LoadEventFired;
import com.github.kklisura.cdt.protocol.support.types.EventHandler;
import com.github.kklisura.cdt.protocol.types.page.PrintToPDF;
import com.github.kklisura.cdt.protocol.types.page.PrintToPDFTransferMode;
import com.github.kklisura.cdt.protocol.types.runtime.Evaluate;
import com.github.kklisura.cdt.services.ChromeDevToolsService;

import java.util.Base64;

abstract class PdfListener extends AbstractListener implements EventHandler<LoadEventFired> {

    private PrintMethod printMethod;
    private byte[] data = null;
    private String printHandlerId;
    private play.Logger.ALogger logger;

    PdfListener(PrintMethod printMethod, ChromeDevToolsService devToolsService, String printHandlerId) {
        super(devToolsService);
        this.printMethod = printMethod;
        this.printHandlerId = printHandlerId;
        this.logger = play.Logger.of("print");
    }

    private byte[] generateData(PrintToPDF printToPDF) {
        return Base64.getDecoder().decode(printToPDF.getData());
    }

    public byte[] getData() {
        return data;
    }

    abstract void dataRetrieved(byte[] data);

    @Override
    public void onEvent(LoadEventFired event) {
        logger.debug("Generating PDF");

        try {

            com.github.kklisura.cdt.protocol.commands.Runtime runtime = devToolsService.getRuntime();

            Double scale = 1d;
            Double marginTop = 0.25d;
            Double marginBottom = 0.55d;
            Double marginLeft = 0.25d;
            Double marginRight = 0.25d;

            Double paperWidth = null;
            Double paperHeight = null;

            String margins = "[" + marginTop + "," + marginRight + "," + marginBottom + "," + marginLeft + "]";

            waitForReady(this.printHandlerId, runtime, 25, 200);

            String expr = "jsPlumbToolkitPrint.scaleToPageSize('" + this.printHandlerId + "', jsPlumbToolkitPrint.PageSizes." + printMethod.name() + ","  + margins +", jsPlumbToolkitPrint.Units.INCHES)";
            Evaluate evaluate = runtime.evaluate(expr);
            com.fasterxml.jackson.databind.node.ArrayNode paperSize =  (com.fasterxml.jackson.databind.node.ArrayNode)play.libs.Json.parse((String)evaluate.getResult().getValue());
            paperWidth = paperSize.get(0).doubleValue();
            paperHeight = paperSize.get(1).doubleValue();

            data = generateData(devToolsService
                    .getPage()
                    .printToPDF(
                            false,
                            false,
                            false,
                            scale,
                            paperWidth,
                            paperHeight,
                            marginTop,
                            marginBottom,
                            marginLeft,
                            marginRight,
                            "1",
                            false,
                            "",
                            "",
                            true,
                            PrintToPDFTransferMode.RETURN_AS_BASE_64));

            dataRetrieved(data);

            logger.info("PDF generated successfully");

        } catch (Exception e) {
            logger.error("Exception occurred generating pdf", e);
        }
        finally {
            devToolsService.close();
        }
    }
}

PdfDataGenerator.java

This class exists because we also have a PdfFileWriter class, which has the same requirements in terms of generating a PDF, but which writes the output to a file instead. You don't necessarily need this extra level in the class hierarchy.

package com.jsplumb.print;

import com.github.kklisura.cdt.services.ChromeDevToolsService;

public class PdfDataGenerator extends PdfListener {

    public PdfDataGenerator(PrintMethod printMethod, ChromeDevToolsService devToolsService, String printHandleId) {
        super(printMethod, devToolsService, printHandleId);
    }

    @Override
    void dataRetrieved(byte[] data) { }
}

There are a few moving parts to the setup here but the end result is reliable and adaptable. Much of the code shown here is specific to the language and library we are using on jsplumbtoolkit.com, but the client side jsPlumbToolkitPrint handler is server agnostic.

If you'd like a PDF of our Flowchart Builder demonstration, click here. That one's A4 sized. If you'd like one in A3, click here. Or for a full size PDF, try this one.