Guide :: ChartJS Image Exports Using Puppeteer & NodeJS

In this guide, we will create a web service that takes a Chart JS config and exports that in the form of an image. The idea is that you can use all the incredible power of Chart JS to generate charts. And since what you get back is an image, you can easily use these in a wide variety of places. For example in emails.

In order to generate ChartJS image exports, we will be setting up an Express JS application. This application will use ChartJS along with Puppeteer in order to create the final images.

We will also build in some caching for efficiency. The goal is that, if the application has already generated an image based on a ChartJS config, we will just directly show that image instead of creating a new one each time.

Let’s Look At A Working Demo

Here is a video demo of the service in action.

A Quick Intro To Our Stack & Main Libraries & What Role They Play

Chart JS (In case you need an intro)

Chart JS is a beautiful charting library that generates a lot of different chart types. It is designed to be used in a “browser” context. In this guide, we are going to use it in the context of a browser. But then, we will take a screenshot of what gets generated.

Here is a guide to ChartJs if you need it:

 

What is Puppeteer?

Puppeteer is a library that allows you to programmatically control Chrome and other browsers. We are going to use it to load up the ChartJS library as well as the chart config. Then, once the chart is rendered, we are going to use Puppeteer to take a screenshot of what is seen on the screen. This will happen via Node without the user or anybody even looking at the browser. This is known as running in “headless” mode.

In order to manage multiple browser instances that get created when different users use our application by hitting our server, we are going to use Puppter Cluster. As the name suggests, this creates a “cluster” or “pool” of browsers that are used as and when needed when different users make requests.

Here is a Puppeteer crash course if you need it…

 

What is Express JS?

Express JS is a really fast way to set up a Node JS web server. We will use this to create a “route” or “URL” via which the user will interact with our application.

Here is an Express JS crash course in case you need it.

 

The Complete Express Server Code

Below is the complete Node Express JS server code. In order to set up the application properly, you will need to take some additional steps.

Those steps are explained below in the section called: “Setting Up The Application”.

// Bringing in the dependencies
const express = require('express')
const app = express()
const port = 3000
const { Cluster } = require('puppeteer-cluster');
const fs = require('fs');
const md5 = require('md5');
const { execSync } = require('child_process');
const path = require('path');

// The section below is the creation of a "task" that we
// will later run via the Puppeteer Cluster
// It is defined as a normal function that takes in a Puppeteer Page
// as well as the ChartJs config
const taskToRun = async ({ page, data: chartJsConfig }) => {

    // You can set this to the size
    // in width that you want the final
    // image to be
    const desiredWidthOfScreenshot = 1000;

    // Setting up the page screen size
    await page.setViewport({ width: desiredWidthOfScreenshot, height: desiredWidthOfScreenshot * 2 });

    // Setting the content of the page to the HTML template
    await page.setContent(global.htmlTemplate);

    // page.evaluate is a function that allows us to run JavaScript
    // in the page that we are currently on
    let dimensions = await page.evaluate((chartJsConfig) => {

        // This is the JavaScript that will be run in the page
        const ctx = document.getElementById("myChart");

        // ChartJS by default has animations enabled
        // we need to turn them off so that as soon as the chart us rendered
        // we can take a screenshot
        // This code just adds the animation: duration: 0 to the options
        if (chartJsConfig['options'] == undefined) {
            chartJsConfig['options'] = {};
        }

        chartJsConfig['options']['animation'] = {
            duration: 0
        };

        // This is the code that finally draws the chart
        new Chart(ctx, chartJsConfig);

        // Once the chart is drawn we return the dimensions of the canvas
        // we dont know the dimensions of the canvas until the chart is drawn
        // We will use this to crop the image later
        return {
            width: document.querySelector('canvas').offsetWidth,
            height: document.querySelector('canvas').offsetHeight
        }
    }, chartJsConfig);

    // We are creating a unique file name based on the chartJsConfig
    // This is so that we can cache the images
    const fileName = md5(JSON.stringify(chartJsConfig));
    const pathToScreenshot = `./charts/${fileName}.png`;

    // This is the code that takes the screenshot
    // We are using the dimensions that we got from the page.evaluate
    // function above to crop the image
    await page.screenshot({
        path: pathToScreenshot,
        clip: {
            x: 0,
            y: 0,
            width: desiredWidthOfScreenshot,
            height: dimensions.height
        }
    });

    // We return the path to the screenshot and the dimensions
    return {
        pathToScreenshot,
        dimensions
    };
}

(async () => {
    // This is the code that launches the Puppeteer Cluster
    // We are setting the concurrency to CONCURRENCY_PAGE
    // This means that each task will be run in its own page
    // We are also setting the maxConcurrency to 10
    // This means that we will only run 10 tasks at a time in parallel
    // Finally we are assigning it to "global.cluster" so that we can
    // access it later via Express
    global.cluster = await Cluster.launch({
        concurrency: Cluster.CONCURRENCY_PAGE,
        maxConcurrency: 10,
    });

    // This is the code that adds the task to the cluster
    // We created this task above in the "taskToRun" function (line 15)
    await global.cluster.task(taskToRun);

    // This is the code that reads the HTML template
    // We are storing it in "global.htmlTemplate" so that we can
    // access it later via Express
    global.htmlTemplate = fs.readFileSync('./index.template.html', 'utf8');
})();



// This function helps us convert the base64 encoded config
// to a JSON object
const convertBase64ConfigToChartJsConfig = (base64Config) => {
    const chartJsConfigAsJson = Buffer.from(base64Config, 'base64').toString('ascii');
    const chartJsConfig = JSON.parse(chartJsConfigAsJson);
    return chartJsConfig;
}


// This is the code for the route that we will be using
app.get('/', async (req, res) => {
    // We are just going to call the functions
    // that we have defined above
    // in order to co-ordinate everything and get the final
    // screenshot
    const chartJsConfig = convertBase64ConfigToChartJsConfig(req.query.config);
    const fileName = md5(JSON.stringify(chartJsConfig));
    const pathToFinalScreenshot = path.join(__dirname, 'charts', `${fileName}.png`)

    // This is the code that checks if the screenshot already exists
    // Since the config is converted to an MD5 hash
    // if we hit the URL with the same config
    // it will just return the same screenshot
    if (fs.existsSync(pathToFinalScreenshot) == false) {
        await global.cluster.execute(chartJsConfig);
    }
    res.sendFile(pathToFinalScreenshot);
})


app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
})

The above code is heavily commented and it should be clear what is going on. But, I have gone over all the sections in detail below.

There is also an HTML template needed as you can see on line 104. The contents of that file are below:

<html>
    <style>
        body {
            padding: 0px;
            margin: 0px;
        }

        canvas {
            padding: 5px;
        }
    </style>
    <div>
        <canvas id="myChart"></canvas>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</html>

As you can see, this is just a blank HTML file. It has a canvas and a link to the ChartJS CDN. We will be inserting our chart into this HTML using Puppeteer.

Understanding The Code

Section 1: Taking The Base64 ChartJS Config & Converting It Into A JSON Object

// This function helps us convert the base64 encoded config
// to a JSON object
const convertBase64ConfigToChartJsConfig = (base64Config) => {
    const chartJsConfigAsJson = Buffer.from(base64Config, 'base64').toString('ascii');
    const chartJsConfig = JSON.parse(chartJsConfigAsJson);
    return chartJsConfig;
}

This function is fairly easy to understand. Here, we are just taking the Base64 encoded ChartJS config and converting it back into ascii. Then, we are parsing the text into a JSON object. We are doing this because later in the code, we are going to inject some JS that stops ChartJS from doing any animations (see section 3).

Section 2: Setting Up A Puppter Cluster & The Task

(async () => {
    // This is the code that launches the Puppeteer Cluster
    // We are setting the concurrency to CONCURRENCY_PAGE
    // This means that each task will be run in its own page
    // We are also setting the maxConcurrency to 10
    // This means that we will only run 10 tasks at a time in parallel
    // Finally we are assigning it to "global.cluster" so that we can
    // access it later via Express
    global.cluster = await Cluster.launch({
        concurrency: Cluster.CONCURRENCY_PAGE,
        maxConcurrency: 10,
    });

    // This is the code that adds the task to the cluster
    // We created this task above in the "taskToRun" function (line 15)
    await global.cluster.task(taskToRun);

    // This is the code that reads the HTML template
    // We are storing it in "global.htmlTemplate" so that we can
    // access it later via Express
    global.htmlTemplate = fs.readFileSync('./index.template.html', 'utf8');
})();

This code runs before we start our ExpresJS server. We are setting up some “global” variables by adding keys to the “global” node JS object.

There are 2 things we are adding to the global object. The first one is the “Puppter Cluster”. We are using this to manage multiple instances of Puppter that will get created as many users simultaneously hit out the application. We are using the library:  puppeteer-cluster

The library describes what problem it solves in its documentation as stated below…

Create a cluster of puppeteer workers. This library spawns a pool of Chromium instances via Puppeteer and helps to keep track of jobs and errors. Puppeteer Cluster takes care of reusing Chromium and restarting the browser in case of errors.

As you can see in line 16 above, the cluster needs to be given a “task” that it has to run. That task is defined below in the next section. The task is just the Puppter JS code that you need run.

There is one more thing we are adding to the “global” object. This is the HTML Template. We are going to load this HTML into the browser via Pauppter as you will see in the next section.

We are just doing this because we will need this on each and every request. So, putting it into “global” makes things happen one time and more efficiently.

Section 3: Running JS on the Page Via Puppeter To Prevent Animations, Taking A Screenshot & Getting Rendred Chart Dimensions

const taskToRun = async ({ page, data: chartJsConfig }) => {

    // You can set this to the size
    // in width that you want the final
    // image to be
    const desiredWidthOfScreenshot = 1000;

    // Setting up the page screen size
    await page.setViewport({ width: desiredWidthOfScreenshot, height: desiredWidthOfScreenshot * 2 });

    // Setting the content of the page to the HTML template
    await page.setContent(global.htmlTemplate);

    // page.evaluate is a function that allows us to run JavaScript
    // in the page that we are currently on
    let dimensions = await page.evaluate((chartJsConfig) => {

        // This is the JavaScript that will be run in the page
        const ctx = document.getElementById("myChart");

        // ChartJS by default has animations enabled
        // we need to turn them off so that as soon as the chart us rendered
        // we can take a screenshot
        // This code just adds the animation: duration: 0 to the options
        if (chartJsConfig['options'] == undefined) {
            chartJsConfig['options'] = {};
        }

        chartJsConfig['options']['animation'] = {
            duration: 0
        };

        // This is the code that finally draws the chart
        new Chart(ctx, chartJsConfig);

        // Once the chart is drawn we return the dimensions of the canvas
        // we dont know the dimensions of the canvas until the chart is drawn
        // We will use this to crop the image later
        return {
            width: document.querySelector('canvas').offsetWidth,
            height: document.querySelector('canvas').offsetHeight
        }
    }, chartJsConfig);

    // We are creating a unique file name based on the chartJsConfig
    // This is so that we can cache the images
    const fileName = md5(JSON.stringify(chartJsConfig));
    const pathToScreenshot = `./charts/${fileName}.png`;

    // This is the code that takes the screenshot
    // We are using the dimensions that we got from the page.evaluate
    // function above to crop the image
    await page.screenshot({
        path: pathToScreenshot,
        clip: {
            x: 0,
            y: 0,
            width: desiredWidthOfScreenshot,
            height: dimensions.height
        }
    });

    // We return the path to the screenshot and the dimensions
    return {
        pathToScreenshot,
        dimensions
    };
}

This is the “task” we are going to run with the Puppeter instances that are managed by the Puppter cluster we set up above. In the first few lines, we are setting the width of the window. It’s set to 800px wide. If you want larger or smaller images, this is the place you would set things as desired. The height is set to double the width. Because we have no idea what the size of the chart will be. But, we are assuming that the chart should surely fit within the “double of the width” size.

Using “setContent” we set the HTML on the page.

Next, we run some JS on the page using “page.evaluate. The idea of the JS is to draw the chart using the ChartJS config. Once the chart is drawn, we will get the width and height of the canvas. We are going to use this in the next step in order to crop the image perfectly.

By default, ChartJs has animations in the charts that it draws. This looks very cool in the browser, but we need to take a screenshot of the drawn chart here. So, we need to tell ChartJS not to do any animations. That is what is going on from lines 25 to 31.

Finally, we are using “page.screenshot” to take a screenshot of the page and store it in the “charts” folder. In order to do this we are using the Puppeters page.screenshot method.

From the official docs, you can see that we are using the “clip” option in order to just take a screenshot of the area of the screen we need.

chartjs image export using puppeter take screenshot and clip option

Above is the meaning of all the settings in the clip option.

Also note that when creating the file, we name the file with the MD5 hash of the ChartJS config. We are going to use this for caching purposes as explained in the next section.

Section 6: Bypassing The Whole Process & Just Showing A Cached Image If We Have Seen This Config Before

// This is the code for the route that we will be using
app.get('/', async (req, res) => {
    // We are just going to call the functions
    // that we have defined above
    // in order to co-ordinate everything and get the final
    // screenshot
    const chartJsConfig = convertBase64ConfigToChartJsConfig(req.query.config);
    const fileName = md5(JSON.stringify(chartJsConfig));
    const pathToFinalScreenshot = path.join(__dirname, 'charts', `${fileName}.png`)

    // This is the code that checks if the screenshot already exists
    // Since the config is converted to an MD5 hash
    // if we hit the URL with the same config
    // it will just return the same screenshot
    if (fs.existsSync(pathToFinalScreenshot) == false) {
        await global.cluster.execute(chartJsConfig);
    }
    res.sendFile(pathToFinalScreenshot);
})

This is the function that is actually executed as our Express JS server is hit. It just coordinates the action between all the other parts of the code we have seen so far.

But, as you can see, we check to see if the chart file is already generated. We do this, by taking the ChartJS config, turning it into an MD5 hash, and seeing if a file with that name already exists in the “charts” folder. If it does, we simply send that file back. If not, we use the Puppter cluster in order to generate the file. This makes the whole process a whole lot more efficient.

If you, for example, use a URL to the chart in an email: The chart will only be generated the first time the email is opened. The next time, it will just simply send the pre-generated file back from the “charts” folder.

Setting Up The Application

You can set up this application like you would any other Node JS, or Express JS application.

Just something like:

npm init
npm install --save puppeteer
npm install --save puppeteer-cluster
npm install --save express
npm install --save md5

As you can see above, we are just creating a new Node application and then installing all the dependencies we need.

Next just create the 2 files. “index.js” and “index.template.html” with the code shared above.

Setting Up Empty Folders To Store The Generated Images

As you might have noticed from the above application code, we need a folder where the charts are generated and stored. So, please make a folder called “charts” in your application folder.

Running The Application

Once you have everything set up, you are ready to run the application. You can do that easily by running the following command on the console from the application folder:

> node index.js

The server should start running. Now, you can go to the following URL:

http://127.0.0.1:3000/?config=ewogICJ0eXBlIjoiYmFyIiwKICAiZGF0YSI6ewogICAgImxhYmVscyI6WwogICAgICAiQWZyaWNhIiwKICAgICAgIkFzaWEiLAogICAgICAiRXVyb3BlIiwKICAgICAgIkxhdGluIEFtZXJpY2EiLAogICAgICAiTm9ydGggQW1lcmljYSIKICAgIF0sCiAgICAiZGF0YXNldHMiOlsKICAgICAgewogICAgICAgICJsYWJlbCI6IlBvcHVsYXRpb24gKG1pbGxpb25zKSIsCiAgICAgICAgImJhY2tncm91bmRDb2xvciI6WwogICAgICAgICAgIiMzZTk1Y2QiLAogICAgICAgICAgIiM4ZTVlYTIiLAogICAgICAgICAgIiMzY2JhOWYiLAogICAgICAgICAgIiNlOGMzYjkiLAogICAgICAgICAgIiNjNDU4NTAiCiAgICAgICAgXSwKICAgICAgICAiZGF0YSI6WwogICAgICAgICAgMjQ3OCwKICAgICAgICAgIDUyNjcsCiAgICAgICAgICA3MzQsCiAgICAgICAgICA3ODQsCiAgICAgICAgICA0MzMKICAgICAgICBdCiAgICAgIH0KICAgIF0KICB9LAogICJvcHRpb25zIjp7CiAgICAibGVnZW5kIjp7CiAgICAgICJkaXNwbGF5IjpmYWxzZQogICAgfSwKICAgICJ0aXRsZSI6ewogICAgICAiZGlzcGxheSI6dHJ1ZSwKICAgICAgInRleHQiOiJQcmVkaWN0ZWQgd29ybGQgcG9wdWxhdGlvbiAobWlsbGlvbnMpIGluIDIwNTAiCiAgICB9CiAgfQp9

And you should see the below chart:

Image exported via the ChartJS config

As you can see the above URL is just hitting the Express JS server with one URL parameter: “config”

The value of “config” is a Base64 string which is:

ewogICJ0eXBlIjoiYmFyIiwKICAiZGF0YSI6ewogICAgImxhYmVscyI6WwogICAgICAiQWZyaWNhIiwKICAgICAgIkFzaWEiLAogICAgICAiRXVyb3BlIiwKICAgICAgIkxhdGluIEFtZXJpY2EiLAogICAgICAiTm9ydGggQW1lcmljYSIKICAgIF0sCiAgICAiZGF0YXNldHMiOlsKICAgICAgewogICAgICAgICJsYWJlbCI6IlBvcHVsYXRpb24gKG1pbGxpb25zKSIsCiAgICAgICAgImJhY2tncm91bmRDb2xvciI6WwogICAgICAgICAgIiMzZTk1Y2QiLAogICAgICAgICAgIiM4ZTVlYTIiLAogICAgICAgICAgIiMzY2JhOWYiLAogICAgICAgICAgIiNlOGMzYjkiLAogICAgICAgICAgIiNjNDU4NTAiCiAgICAgICAgXSwKICAgICAgICAiZGF0YSI6WwogICAgICAgICAgMjQ3OCwKICAgICAgICAgIDUyNjcsCiAgICAgICAgICA3MzQsCiAgICAgICAgICA3ODQsCiAgICAgICAgICA0MzMKICAgICAgICBdCiAgICAgIH0KICAgIF0KICB9LAogICJvcHRpb25zIjp7CiAgICAibGVnZW5kIjp7CiAgICAgICJkaXNwbGF5IjpmYWxzZQogICAgfSwKICAgICJ0aXRsZSI6ewogICAgICAiZGlzcGxheSI6dHJ1ZSwKICAgICAgInRleHQiOiJQcmVkaWN0ZWQgd29ybGQgcG9wdWxhdGlvbiAobWlsbGlvbnMpIGluIDIwNTAiCiAgICB9CiAgfQp9

If you decode the Base64 string, you will get a JSON string that looks like this:

{
  "type":"bar",
  "data":{
    "labels":[
      "Africa",
      "Asia",
      "Europe",
      "Latin America",
      "North America"
    ],
    "datasets":[
      {
        "label":"Population (millions)",
        "backgroundColor":[
          "#3e95cd",
          "#8e5ea2",
          "#3cba9f",
          "#e8c3b9",
          "#c45850"
        ],
        "data":[
          2478,
          5267,
          734,
          784,
          433
        ]
      }
    ]
  },
  "options":{
    "legend":{
      "display":false
    },
    "title":{
      "display":true,
      "text":"Predicted world population (millions) in 2050"
    }
  }
}

If you would like to generate another chart, you just have to do the process in reverse like so:

  1. Make the ChartJS config (like the one above)
  2. Then encode that to Base64. All programming languages allow you to do this. During testing, you can do it manually, using a tool like Base 64 Encode Decode
  3. Once you do this, you can update the config parameter and update the URL above. When you hit it, you should get a new chart.

Here is a little script to generate URLs in JS on the browser console. (This is the script I have used in the demo video above). It automates the above-described process:

config1 = {
  type: 'line',
  data: {
    labels: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
    datasets: [{ 
        data: [86,114,106,106,107,111,133],
        label: "Total",
        borderColor: "#3e95cd",
        backgroundColor: "#7bb6dd",
        fill: false,
      }, { 
        data: [70,90,44,60,83,90,100],
        label: "Accepted",
        borderColor: "#3cba9f",
        backgroundColor: "#71d1bd",
        fill: false,
      }, { 
        data: [10,21,60,44,17,21,17],
        label: "Pending",
        borderColor: "#ffa500",
        backgroundColor:"#ffc04d",
        fill: false,
      }, { 
        data: [6,3,2,2,7,0,16],
        label: "Rejected",
        borderColor: "#c45850",
        backgroundColor:"#d78f89",
        fill: false,
      }
    ]
  },
}

config_as_json_string = JSON.stringify(config1)
config_as_base64 = btoa(config_as_json_string)

console.log('http://127.0.0.1:3000/?config=' + config_as_base64)

How To Deploy This?

In order to deploy this I would use an Ubuntu VPS server. You can get one from a service provider like Digital Ocean for as low as $5 per month. If your little micro-service gets a lot of traffic, you can then scale it up and re-size it as needed.

DigitalOcean makes great guides on how to deploy stuff. Here is one that is called: How To Set Up a Node.js Application for Production on Ubuntu 20.04

This guide does not specifically talk about deploying an ExpressJS app. But, the steps are pretty much the same. So, it can be used as a guide.

Conclusion

So, using a combination of Puppeteer and Express JS we are able to create a simple micro-service API that can help with ChartJS image exports.