Intro

When creating this website, my intention was to post every blog and project post on my Twitter to try and spread the word. As this site is created using Gatsby, this allows me to use NodeJS during the build process of the site to do a number of things. One of which is to create individual images for use within the meta tags for each blog and project post page. In this article I will discuss how this was done.

Whilst I am using Gatsby for my site, the only thing you absolutely need for this is NodeJS. This post will only discuss the image creation process, not how to integrate it with your sites build process.

What purpose will these images serve?

The images generated by NodeJS will be placed onto the page in the as metadata using a Meta tag. Meta tags allow web services to find out more information about a webpage. The main purpose for my image meta tag is to allow social platforms to grab the defined image and display it wherever it gets shared.

As an example, take a look at this image below. This is what is displayed on Twitter when sharing an article from CSS Tricks.

Example Twitter social card

If you would like to read more about meta tags, including the image meta tag, take a look at it’s MDN Web Docs page.

How did I do it?

Firstly, I created two separate background templates using Figma, consisting of my site’s URL, site logo and a blog or project subheading. These background templates look like this…

Templates to be used as background image

Once these were create, I could begin putting together the logic to utilise these templates to automatically generate my images.

This functionality requires the use of 2 node modules, fs and canvas. Whilst fs is included a part of the NodeJS core, meaning that it is already installed with NodeJS, we do need to install the canvas package.

$ npm install canvas

With this installed, we can then create a canvas instance using the “createCanvas” function which we can get from the canvas NodeJS package that we just installed!

const {createCanvas} = require(“canvas");

const width = 1200;
const height = 630;
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");

Next we can add some text to the canvas instance. I am adding my own font, which is Poppins. To do this you need to download your font and register it using the “registerFont” function which, again, is part of the canvas NodeJS package. So lets get that…

const {createCanvas, registerFont} = require("canvas");

…then we can add some text to the canvas instance like so.

// Register Font
 registerFont(`${__dirname}/Poppins-Bold.ttf`, { family: 'Poppins', weight: 'bold' });
// Font Colour
context.fillStyle = `#FFFFFF`
// Set Font Properties
context.font = `${35}px 'Poppins' bold`;
// Place text on canvas at x(35) y(35)
context.fillText("This is some test text", 35, 35)

Let’s see what that will look like when rendered.

Canvas generated image with basic text

Nice, we’re getting somewhere! Next we’ll introduce those background template files that we created earlier. For my implementation I call all of this functionality as a function and pass the “template type” as an argument. For simplicity, this example will just use the blog template. To load our image we need to use the “loadImage” function, which will be the 3rd and final function required from the NodeJS canvas package that we installed earlier.

const {createCanvas, registerFont, loadImage} = require("canvas");

With this imported, we can then add/draw the template image to the canvas like this.

// Load the image using loadImage (async/await)
let loaded_img = await loadImage(template_img);
// Draw the image at 0, 0 
context.drawImage(loaded_img, 0, 0);

It is worth noting that the template background image that I have created has the same dimensions as the canvas created (1200x630). If these sizes don’t line up you may end up with the final result having excess space or the edges cut off. This will now look like this (with black text).

Canvas generated image with basic text and background image

Great, now we have text AND the background image. As you may have noticed, the text isn’t in the right place, you may think that positioning the text is as simple as changing the coordinates within the “fillText” method… sadly this is not the case. As the length of the titles for each of my blog and project posts will inevitably vary, the text may end up running off the edge of the image. Let’s add a longer string and see what happens.

Canvas generated image with long, overrunning text

As you can see the text runs off the right side of the image.

I needed a way of wrapping/resizing my text if it gets too wide for my specified location. Luckily Shuhei Kagawa has already encountered this issue and created a solution that he has kindly shared. The solution that Shuhei came up with was to wrap the text if it was wider than the given width, and to reduce the font size if it was then taller than the given height. The full code can be found at Suhei’s website, on GitHub or you can find my full code at the end of this post.

With the “fitTextIntoRectangle” function added, we need to call it.

const {lines, fontSize} = fitTextIntoRectangle({
    ctx: context, 
    text: title,
    maxFontSize: 75,
    rect: {
        x: 50,
        y: 200,
        width: 580,
        height: 200
    }
})

This will return the amount of lines that the text is required to wrap to, along with the font size required to fit the text within the 580x200 rectangle. With this info the code that was created earlier to add the text to the canvas can now be updated to this.

// Font Colour
context.fillStyle = `#BF2932`
// Set Font Properties
context.font = `${fontSize}px 'Poppins' bold`;
// Add each line
lines.forEach(({ text, x, y }) => {
  context.fillText(text, x, y); // Trying to modify line height
});

The updates added are dynamically changing the font the the required font size and then looping though each “line” and adding it to the canvas instance. Now when we run the code, it should output an image with our background template and text in the correct place. After updating the title variable, our output looks like this!

Final generated image, with background template and correct title sizing and placement

Finally, to save the generated image to somewhere within the directory we need to utilise the fs NodeJS module.

const fs = require('fs');

After adding this, the generated image can be saved with a filename of your choice. For this example I am simply saving the generated image to the current directory with a filename of ‘post.png’.

// Render and save the image
const buffer = canvas.toBuffer("image/png")
fs.writeFileSync(`${__dirname}/post.png`, buffer)

Outro

With this functionality, you can now add it to your post’s Meta image tag and it will show when your post gets shared on various social media platforms!

To see it in action, paste this page’s URL into your social media of choice and you should see a similar image with the title of this blog post!

See my full code below.

const {createCanvas, registerFont, loadImage} = require("canvas");
const fs = require('fs');


const title = 'Test Title Here.  Here is some extra text.'

// Canvas width and height
const width = 1200;
const height = 630;

const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");

// Path to the template image
const template_img = `./path/to/your/template_image.png`;

// Load the image using loadImage (async/await)
let loaded_img = await loadImage(template_img);
// Draw the image at 0, 0 
context.drawImage(loaded_img, 0, 0);

// This function will return the amount of lines we need to wrap to, along with the font size required
const {lines, fontSize} = fitTextIntoRectangle({
    ctx: context, 
    text: title,
    maxFontSize: 75,
    rect: {
        x: 50, // Padding left
        y: 200, // Padding top
        width: 580, // Width of text boundary rectangle
        height: 200 // Height of text boundary rectangle
    }
})

// Register Font
registerFont(`${__dirname}/Poppins-Bold.ttf`, { family: 'Poppins', weight: 'bold' });
// Font Colour
context.fillStyle = `#BF2932`
// Set Font Properties
context.font = `${fontSize}px 'Poppins' bold`;
// Add each line
lines.forEach(({ text, x, y }) => {
    context.fillText(text, x, y); // Trying to modify line height
});

// Render and save the image
const buffer = canvas.toBuffer("image/png")
fs.writeFileSync(`${__dirname}/post.png`, buffer)



// The function below has been kindly shared by Shuhei Kagawa (https://shuheikagawa.com/blog/2019/10/13/generating-twitter-card-images/)
// https://github.com/shuhei/shuhei.github.com/blob/f30cb5cd85a4ef35a4fb73d94a01da44e03ae116/plugins/title-image.js

function fitTextIntoRectangle({ ctx, text, maxFontSize, rect }) {

    
  
    // Try font sizes from a big one until the title fits into the image.
    for (let fontSize = maxFontSize; fontSize > 0; fontSize -= 1) {
      ctx.font = `${fontSize}px 'Poppins' bold`
      let words = text.split(" ");
      let { y } = rect;
      const lines = [];
      while (words.length > 0) {
        let i;
        let size;
        let subtext;
        // Remove words until the rest fit into the width.
        for (i = words.length; i >= 0; i -= 1) {
          subtext = words.slice(0, i).join(" ");
          size = ctx.measureText(subtext);
  
          if (size.width <= rect.width) {
            break;
          }
        }
  
        if (i <= 0) {
          // A word doesn't fit into a line. Try a smaller font size.
          break;
        }
  
        lines.push({
          text: subtext,
          x: rect.x,
          y: y + size.emHeightAscent
        });
  
        words = words.slice(i);
        y += size.emHeightAscent + size.emHeightDescent;
      }
  
      const space = rect.y + rect.height - y;
      if (words.length === 0 && space >= 0) {
        // The title fits into the image with the font size.
        // Vertically centering the text in the given rectangle.
        const centeredLines = lines.map(line => {
          return {
            ...line,
            y: line.y + space / 2
          };
        });
        return {
          fontSize,
          lines: centeredLines
        };
      }
    }
  
    throw new Error(
      `Text layout failed: The given text '${text}' did not fit into the given rectangle ${JSON.stringify(
        rect
      )} even with the smallest font size (1)`
    );
  }