How to quickly generate dynamic images with Node.js and Puppeteer.
Tags: javascript, tutorial, node, frontend
Published: 2023-11-27 15:28:17

In our time, numerous websites create pages that users share in different social networks or messengers. Thanks to Open Graph tags, links can have a preview image that attracts even more attention, for example with `og:image` tag. But usually, many websites don’t put much effort into preview images and simply add one image to the majority of pages. If there is no image, parsers try to automatically find the first available suitable image and use it.

But imagine how great it would be to have a personalized image on the page, for example, when you have profiles of your users or events in which they participate.

For example, take a look at the image below that we generate for sharing on social networks. It has a custom font, gradient, and even localization of text on the image into different languages.


![Open Graph image for user profile](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l2jymyviw3kcyjoyjuuv.png)

In our project, the initial versions of images were generated using PHP, and for a while, this was sufficient because the images were quite simple and only contained the user’s picture. However, as soon as we added the user’s name, problems with text positioning immediately arose. For instance, here’s a small example of creating a simple image in PHP:

```php

<?php

$width = 400;
$height = 200;
$image = imagecreatetruecolor($width, $height);

$backgroundColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $backgroundColor);

$textColor = imagecolorallocate($image, 0, 0, 0);

// Load a custom TrueType font
$fontFile = 'path/to/your/font.ttf';

// Set the text to be displayed (considering localization)
$language = isset($_GET['lang']) ? $_GET['lang'] : 'en';
$text = getLocalizedText($language);

$fontSize = 24;

// Set the position for the text to be displayed
$textbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$textX = ($width - ($textbox[2] - $textbox[0])) / 2;
$textY = ($height - ($textbox[5] - $textbox[3])) / 2 + ($textbox[5] - $textbox[3]);

imagettftext($image, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $text);

header("Content-Type: image/png");
imagepng($image);
imagedestroy($image);

function getLocalizedText($language) {
    switch ($language) {
        case 'en':
            return 'Hello, PHP!';
        case 'fr':
            return 'Bonjour, PHP!';
        default:
            return 'Hello, PHP!';
    }
}
```

As seen from the example, if the text is too long or if you would like change its size, it may extend beyond the boundaries of the image. And if you add gradients, transparency, different fonts, emojis, the code will become complex and challenging to maintain.

The solution I would like to present allows you to reduce development time and simplifies the maintenance of such images. Moreover, it is flexible and easily scalable for other purposes.

So every web developers knows that HTML is the simplest and most convenient markup language. Along with CSS styles, you can create an interface that is flexible and takes into account the positioning of any elements on the page — images, text, tables, lists and etc.

Let’s have a look and on the example below. This is simple HTML page with many css styles like gradient in text, shadows, limiting text rows for long text and other.

```html

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

            body {
                background-color: #0093E9;
                background-image: linear-gradient(160deg, #0093E9 0%, #80D0C7 100%);

                font-family: Helvetica, sans-serif;
                padding: 5%;
                text-align: center;
            }

            header {
                position: relative;
            }

            header .emoji {
                position: absolute;
                top: -10px;
                left: 0;
                transform: rotate(20deg);
                font-size: 3rem;
            }

            * {
                box-sizing: border-box;
            }

            h1 {
                text-transform: uppercase;
                font-size: 3rem;
                background: -webkit-linear-gradient(45deg, #85FFBD 0%, #FFFB7D 100%);
                -webkit-background-clip: text;
                -webkit-text-fill-color: transparent;
            }

            .wrapper {
                display: flex;
                padding-top: 2rem;
            }

            .avatar {
                display: flex;
                justify-content: center;
                align-items: center;
            }
            .avatar img {
                width: 140px;
                height: 140px;
                border-radius: 100px;
                border: 5px solid rgba(255,255,255, 0.5);
                box-shadow: 0 0 10px rgba(0,0,0,0.2);
                object-fit: cover;
            }

            .content {
                padding: 1rem 2rem;
            }

            .content .text {
                font-size: 1.5rem;
                display: -webkit-box;
                -webkit-line-clamp: 3; /* number of lines to show */
                        line-clamp: 3; 
                -webkit-box-orient: vertical;
                overflow: hidden;
                color: rgba(255,255,255, 0.8);
            }
        </style>
    </head>
    <body>
        <div>
            <header>
                <h1>Hello, Javascript</h1>
                <div class="emoji">🤖</div>
            <header>
            <div class="wrapper">
                <div class="avatar">
                    <img src="https://sun9-57.userapi.com/impg/O3egMIWPZjhcKSThZ2hn7ByaQmET8ySOq5e4ww/O_ngP3qqEd8.jpg?size=1178x1789&quality=95&sign=71fcbf49ffff80fad9f0ef39f598cf69&type=album" alt=""/>
                </div>
                <div class="content">
                    <div class="text"><strong>Lorem Ipsum</strong>  is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.</div>
                </div>
            </div>
        </div>
    </body>
</html>

```

What we need it is run a web server which can serve this HTML code and run node.js application with Puppeteer library, which launches a headless version of Google Chrome, an then you can take a screenshot of the page and get the desired image.

Below is an example code that allows you to capture the content of a page with specified width, height and scale. It is crucial to handle exceptions and close the browser pages if something goes wrong; otherwise, it may lead to a memory leak.

```typescript
const browser = await puppeteer.launch({
    headless: true,
    ignoreHTTPSErrors: true,
    executablePath: browserPath,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
    dumpio: true,
});

const page: Page = await browser.newPage();
await page.setJavaScriptEnabled(false);
await page.setViewport({
    width,
    height,
    deviceScaleFactor,
});

try {
    const result = await page.goto(url, {
        waitUntil: 'load',
    });

    if (result.status() !== 200) {
       page.close();
       throw new PageNotFoundError(`Incorrect status page ${result.status()}`);
    }
} catch (error) {
    page.close();
    throw new PageFetchError(error as string);
}

const data = await page.screenshot({
    type: imageType,
    quality: imageQuality,
    encoding: 'binary',
});

page.close();
```

And finally as result you can get image like that which you can upload to you S3 storage and serve it to users. This is really simple to support and manage.


![Example of image generation with Puppeteer](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qrelv2lfz47ldzeabjal.png)

In our case we create a small node.js application with Puppeteer that has been packaged into a Docker container and run HTTP server inside to manage external requests. This application allows us to generate images with various format (like png, jpgor webp )for any page of the website.

So, the full logic of service for OG images generation might be:

1. When your React app is rendering a page your generate special link in html meta tags `og:image` (with user id, required image size and extension) to nginx proxy.
2. The proxy server checks if the image is already in the S3 storage or not.
3. If the image exists, it is served; if not, a request is made to the node.js service, which generates the image, serves it, and asynchronously uploads it to S3.

During the development we faced few minor problems such as:

- Chromium issues on a Ubuntu Docker container were resolved by directly downloading Google Chrome.
- Font compatibility: one of our fonts broke Apple emoji font. We decided to replace it.

Despite minor technical hurdles, the new node.js service significantly reduced image generation and support time compared to the previous PHP code. The solution allows us quickly update images, just change HTML code, simple to test it in the browser and use all power of CSS.

It’s a great way to achieve flexibility and scalability in generating images dynamically for any of your purposes (not only to generate Open Graph images).

If you have any more questions or if there’s anything else I can help you with, feel free to ask!

Here is example of docker container which we are using with all required libraries to work with images.

```bash
FROM ubuntu:20.04

RUN ln -s /usr/share/zoneinfo/Etc/UTC /etc/localtime \
 && apt -y update \
 && apt -y install \
  git \
        openssh-server \
  gconf-service \
  libasound2 \
  libatk1.0-0 \
  libc6 \
  libcairo2 \
  libcups2 \
  libdbus-1-3 \
  libexpat1 \
  libfontconfig1 \
  libgcc1 \
  libgconf-2-4 \
  libgdk-pixbuf2.0-0 \
  libglib2.0-0 \
  libgtk-3-0 \
  libnspr4 \
  libpango-1.0-0 \
  libpangocairo-1.0-0 \
  libstdc++6 \
  libx11-6 \
  libx11-xcb1 \
  libxcb1 \
  libxcomposite1 \
  libxcursor1 \
  libxdamage1 \
  libxext6 \
  libxfixes3 \
  libxi6 \
  libxrandr2 \
  libxrender1 \
  libxss1 \
  libxtst6 \
  ca-certificates \
  fonts-liberation \
  libappindicator1 \
  libnss3 \
  lsb-release \
  xdg-utils \
  wget \
  curl \
  libnss3-dev \
  libgbm-dev \
  libu2f-udev \
  udev \
 && (curl -fsSL https://deb.nodesource.com/setup_14.x | bash -) \
 && apt-get install -y nodejs \
 && wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
 && apt install ./google-chrome-stable_current_amd64.deb \
 && rm -rf ./google-chrome-stable_current_amd64.deb \
 && apt-get clean \
 && rm -rf /var/cache/apt/lists

# Add new fonts
COPY ./fonts /root/.fonts
RUN fc-cache -fv

# Build and run your node.js app
...

# Run node
CMD ["node", "app.js"]
```