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:
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 (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.
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:
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:
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.
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:
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.
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:
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.
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.
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.
page.property('onConsoleMessage', msg => { console.log('CONSOLE from phantom:' + msg); });
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’:
<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 – 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 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():
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:
await page.evaluate(evaluateFunc, config); Read Also: How to make AI Chatbot Using JavaScript and ChatScript
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’ });
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:
Next, we extended imageGenerator with method for calculating the compression level depending on the picture dimensions:
_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):
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).
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:
page.property('zoomFactor', 2);
Both height and width 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:
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(); }
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:
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.
Learn more about how we engage and what our experts can do for your business
Written by:
Technical Lead at 2Smart
Software developer with almost 6 years of experience, interested in smart-home products and learning new technologies
In this article we would like to talk about our experience of creating an AI chatbot with JavaScript and ChatScript. The main task was to…
Introduction The job of each developer is the constant occurrence of problems and the search for their solutions. The speed of solving problem depends on…
How to Use Docker Compose for Automated Testing Many engineers have been confused when designing a method for isolated testing. Fortunately, today we have great…
The use of microservices architecture is already widespread among global companies. For instance, Amazon, Coca-Cola, and Netflix started out as monolithic apps but have evolved…
Modern software development practices often contain just the most basic features in the core program, with additional functionality supplied via add-ons such as plugins. Browser…
This post is not a tutorial. There are enough of them on the Internet. It is always more interesting to look at real production app….