Best Approaches to Building a Banner Generation Tool with PhantomJS

Posted by Yurii Vlasiuk (Developer) on 2018-03-03

Introduction

Today we want to share our experience of implementing the backend tool for the generation of graphical banners from HTML templates. WebbyLab’s customer was interested in automation of advertisement banners customisation based on the prepared templates. Basically, you have an index.html with fields where you can insert different components e.g. buttons, images or text. So I will tell about approaches we’ve used to implement such functionality. For our project we chose PhantomJS. You may argue that there are more innovative solutions in the npm repository. But when we started, none of these libraries were stable or well documented yet (Puppeteer, for example, - a Node.js library that provides a high-level API to control headless Chromium-based engines; among its most notable features - awesome capabilities for taking screenshots). The project’s specs required a great deal of stability. That’s why we decided to employ the tried and well-documented PhantomJS. The task was to create a function for taking screenshots with headless Chrome like described on Medium in the article by David Schnurr.

Ok, enough with introductions. Let’s have a closer look at the framework we’ve decided to use. PhantomJS (https://github.com/ariya/phantomjs) is a headless WebKit browser scriptable with a JavaScript API.

A list of features according to creators is as follows:

  • Headless web testing;
  • Page automation;
  • Screen capture;
  • Network monitoring.

I will not go deeper into each of them but concentrate on the task and approaches used for its resolution instead. The task implied implementing the following functions:

  • HTML template-based image generation (which could be customized);
  • image compression - to fit certain size restrictions;
  • zoom images to gain quality improvement for Retina screens.

Preparation

To set the stage up, you first need to download and install PhantomJS using npm. I’ve set up small express server with two routes - one for static hosting and another for sending generation requests (complete example is available at my Github).

First, we must create a core class for image generation. Start by creating an instance of Phantom and use it for creating the page, which will provide the image rendering on our back-end.

1
2
3
4
import phantom from  'phantom';

const instance = await phantom.create();
const page = await instance.createPage();

Next, we must set the properties to our page - size of a page using viewportSize property and clipRect to define coordinates of the coordinates of the rectangle to render:

1
2
page.property('viewportSize', { width, height });
page.property('clipRect', { top: 0, left: 0, width, height });

Then we need to open the page which will return the status of operation:

1
const status = await page.open(config.templatePath);

Also there it is possibility of evaluating JavaScript code in the context of the web page. For this, evaluate() function is used, which accepts another function as an argument. We used evalFunction to make changes in templates before rendering to images.

1
await page.evaluate(evalFunction);

But for the beginning we will leave it out as it is optional and actually not needed for getting an image.

The last step which is needed for screenshotting in headless mode is launching the actual rendering and exiting our instance of PhantomJS:

1
2
await page.render('/path/to/save/image', { format: 'png' });
await instance.exit();

Also good practice is to check if the image was successfully created and throw ‘Error’ otherwise. This allows handling situations when Phantom haven’t been able to open a template.

1
2
3
4
if (status !==  'success') {
await instance.exit(1);
throw new Error('PHANTOM_FAILED');
}

Now we can collect all the pieces into the image generation class. We separated the operation of this class to three parts: creating the page, setting its size, and generating image. Here’s the listing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import phantom from  'phantom';

export default class ImageGenerator {
async generate(width, height, config) {
const { instance, page } = await this._createPage()
this._setSize(page, width, height);

await page.open(config.templatePath);
await page.render(config.destinationPath, { format: 'jpeg' });
await instance.exit();
}

async _createPage() {
const instance = await phantom.create();
const page = await instance.createPage();

return { instance, page };
}

_setSize(page, width, height) {
page.property('viewportSize', { width, height });
page.property('clipRect', { top: 0, left: 0, width, height });
}
}

Thus, basic functionality for rendering image from template is ready and actually this is already a working example. If you call the generate() method passing to it the page sizes and config object with keys templatePath (location of HTML template which you want to render into image) and destinationPath (location of output image file), this will create image in the JPEG format. Further we will extend this example with additional options.

Error Handling and Debugging

Method page.open() returns the status of operation. Good practice is checking if opening the page ended is success and throw exception otherwise. This will allow handling situations when Phantom haven’t been able to open a template.

1
2
3
4
5
6
const status = await page.open(config.templatePath);

if (status !== 'success') {
await instance.exit(1);
throw new Error('PHANTOM_FAILED');
}

To control what is happening during page rendering in the headless mode, I’ve added logging to Phantom page instance using onConsoleMessage property to output the page messages to the terminal console. This peace can be added to our _createPage() method of ImageGenerator class.

1
2
3
page.property('onConsoleMessage', msg  => {
console.log('CONSOLE from phantom:' + msg);
});

Template Customization

For the example, I’ve created simple template using image with meme, that was popular in summer of 2017. Here is the listing of ‘index.html’:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<html>
<head>
<title>distructed boyfriend</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>

<body style="background-color:#fff; margin:0px">
<div id="container">
<div id="image1">
<img src="./distructred-boyfriend.png">
</div>

<div id="text1" class="text"><div>Text 1</div></div>
<div id="text2" class="text"><div>Text 2</div></div>
<div id="text3" class="text"><div>Text 3</div></div>
</div>
</body>
</html>

CSS styles are needed only for the positioning and sizing of textual blocks. All files related to template are located in ‘static/templates’ directory. When you’ve finished preparing the template, it’s time to extend the class example with evaluate function and add any JS scripts you might additionally require to the template. Keep in mind that any JavaScript code in the template would be evaluated and executed by Phantom, which doesn’t recognize any of the ES6-specific features (e.g. no string templating and only concatenation). I personally find it not fun. Also, Phantom does not support some CSS properties which are widely used in modern browsers. For example, if you are keen on using Flexbox, you can forget about it when prepare templates - when rendering the them with Phantom, all the unsupported features will be ignored or even cause an error.

I think the easiest way to customise page texts in eval function is using getElementById and then replacing innerHtml in selected node or changing src property in the img tag. Also I’ve declarated this function not in the imageGenerator class itself but right before calling the generate() method. This provides the flexibility to pass different functions in different cases. For example, you can pass such function to page.evaluate():

1
2
3
4
5
function  evaluateFunc(config) {
for (var id in config.texts) {
document.getElementById(id).innerHTML = '<div>' + texts\[id\] + '</div>'
}
}

As you may already noticed, you also should standardise format of your templates and content IDs because they would be used by the evaluation function. That’s why better to keep naming conventions consistent across all templates so as not to write different evaluate functions for each of them. Second argument of page.evaluate() is used to pass parameters to evaluateFunc:

1
await page.evaluate(evaluateFunc, config);

Image Compression

The project, where we used PhantomJS, had another requirement for output images. There were file size and image dimensions (fixed height and width) restrictions on the platform for which the project was developed. Images should be as high-quality as possible while satisfying the restrictions. PhantomJS provides an option to choose the quality (or compression level) for PNG and JPEG formats:

1
await page.render(config.destinationPath, { format: 'jpeg', quality: ‘96’ });

Range is between 0 and 100, the default value being 75. Thus, we had to calculate the needed compression level before rendering somehow. For most cases I’ve used an approach of calculating value by resolving simple proportion for content combination. It gave me the biggest image size and the empirically-found value of quality parameter. Using this value, it became possible to calculate the coefficient (of course, we should not forget that compression level and output file size are inversely proportional).

In the following example, 96 is level of compression, size is multiplication of height and width of the output image (known value), and x is the level of compression we seek:

Proportion

Next, we extended imageGenerator with method for calculating the compression level depending on the picture dimensions:

1
2
3
4
5
6
7
_getQuality(width, height) {
const pictureSize = width * height;

result = Math.round(staticCoeff / pictureSize);

return result > 100 ? '100' : result.toString();
}

But this was not all. Other content (e.g. different backgrounds) may also exceed the size restriction. Moreover, upon compression, file size does not decrease linearly, which is illustrated by the following graph (dependency of quality to file size):


In the end, we employed another approach, still rather straightforward but working. If the content exceeds the size limit, we start iterating image rendering decrementing quality step by step and returning the found quality value from generate method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async  function  rerenderInSizeLimit(limit, quality, size, config, generator) {
const currStat = await fs.stat(config.destinationPath);

if (currStat.size < limit) return;

const newQuality = quality - 3;

const currQuality = await generator.generate(
size[0],
size[1],
config,
newQuality,
evaluateFunc
);

const newStat = await fs.stat(config.destinationPath);

if (newStat.size > limit) {
await rerenderInSizeLimit(limit, currQuality, size, config, generator);
}
}

Combination of both approaches - pre-calculated factor and iterative quality decrementing - gives better results in performance because image rendering is a high-load operation. In the practice, the acceptable result was achieved immediately with static coefficient in most cases and only some pictures required 2-3 additional iterations (not a bad result, I think).

Picture Quality Improvement

Another interesting task was improving the resulting images’ quality. Due to limitations, rendering in Phantom produces the compressed images. That’s why the HTML template opened in browser was looking better than rendered image with fixed dimension even with quality set to 100. Compressed image never looks as sharp as original thanks to the higher dimension of the latter - twice or even more times. It is apparent in the example below - on the left is image generated by Phantom with resolution of 1152*768 and quality set to 100 and on the right is the original, opened in browser:

In the left picture you can notice the pixel grain, which becomes even more notable when zooming.

That’s why despite the image dimension limits, we also included the possibility to generate bigger pictures but with the same file size restriction. It was possible because some of pictures did not reach size limit at all in their normal dimensions. By the way, with Phantom it could be done simply. For this, zoomFactor property is set, which specifies how many times image should be scaled:

1
page.property('zoomFactor', 2);

Both height and width of the must be multiplied by this value, otherwise only a cropped part of zoomed image would be rendered.

To make this optional, we passed the flag to switch the zooming mode on to arguments of generate method of ImageGenerator class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async  generate(width, height, config, zoom) {
const { instance, page } = await this._createPage();

if (zoom) {
this._setSize(page, width * zoom, height * zoom);
page.property('zoomFactor', zoom);
} else {
this._setSize(page, width, height);
}

await page.open(config.templatePath);
await page.render(config.destinationPath, { format: 'jpeg', quality: '96' });
await instance.exit();
}

Events

Another powerful mechanism in PhantomJS that can be used in template rendering is a page event system. It could be useful in case when some reactions are defined in the template - on hover or mouse click, for instance. Here is how to initiate the mouse click event at (10, 10) coordinates in template:

1
await page.sendEvent('click', 10, 10, 'left');

Other supported types of events are ‘mouseup’, ‘mousedown’, ‘mousemove’, ‘doubleclick’. Two arguments representing the mouse position for the event are optional. And the last one indicates, which mouse button should be “clicked”, by default it is left.

Also, Phantom supports sending keyboard events but I will not cover them in this article. If you’re interested, you can read more on this in the library’s documentation.

To get the source code, use the subscribe form below, and you will recieve the link in the confirmation message.


Comments: