Author: Yurii Vlasiuk
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 was 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 as 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:
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:
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 (the 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.
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:
Then we need to open the page which will return the status of operation:
const status = await page.open(config.templatePath);
Also there it is a 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.
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:
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.
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:
Thus, basic functionality for rendering an image from the 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 an image) and destinationPath (location of the output image file), this will create the image in the JPEG format. Further, we will extend this example with additional options.
Method page.open()
returns the status of the operation. A good practice is checking if opening the page ended is success
and throw an exception otherwise. This will allow handling situations when Phantom hasn’t been able to open a template.
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.
For the example, I’ve created a simple template using the image with the meme, that was popular in summer of 2017. Here is the listing of ‘index.html’:
CSS styles are needed only for the positioning and sizing of textual blocks. All files related to the 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 - while rendering 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 declared 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()
:
As you may already notice, you also should standardise the 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. The second argument of page.evaluate()
is used to pass parameters to evaluateFunc
:
await page.evaluate(evaluateFunc, config);
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:
await page.render(config.destinationPath, { format: 'jpeg', quality: ‘96’ });
A 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 the 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 the level of compression, size
is a multiplication of height and width of the output image (known value), and x
is the level of compression we seek:
Next, we extended imageGenerator
with the method for calculating the compression level depending on the picture dimensions:
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:
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).
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. The 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 an image generated by Phantom with the resolution of 1152*768 and quality set to 100 and on the right is the original, opened in a 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 the 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 the image should be scaled:
page.property('zoomFactor', 2);
Both height and width must be multiplied by this value, otherwise only a cropped part of the 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:
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 the template:
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.
Originally published at blog.webbylab.com.