Skip to content

Generate Images With Dynamic Content On A Symfony Project With High Performance And Code Maintainability

Antoine Roth10 min read

We mostly live in a static asset world. However, you might one day find yourself facing a dynamic image generation challenge. By dynamic image I mean an image that will have dynamic content such as text and/or other images.
This article is here to help avoid the different issues it could bring in such as
performance droppoor maintainability and image quality.

To do so, we will go through the different options we tested for a specific project and check out what we learnt during this experience.

tldr:

This article described how we first tried to generate dynamic images with imageMagick. We then evoke:

Some context

The day I had to generate images with dynamic content happened earlier this year for a project for a company delivering rankings and certification on a B2B platform.
In their app, they provide specific images custom to the companies and to the context of the certification.
That’s why we had to make image assets available with different constraints:

Any time such an image needs to be downloaded or displayed it is processed by our back-end to retrieve the different business information and to assemble the asset.

In our case, the good news was that this kind of image manipulation already existed within the project, some services in the app were already generating different assets with ImageMagick!

What is ImageMagick?

ImageMagick is an open-source software suite for displaying, editing, composing or converting images. It is easily operable from your terminal and is bundled within Symfony liip/imagine-bundle to ease up backend integration.

Was it good?

The fact that ImageMagick was already installed and used on the app lead us to give it a try for the first assets we had to dynamically generate. The tool is quite powerful and can be used to manipulate images in many ways, it seemed good enough for our needs.
Nevertheless, while using it, we spotted a great deal of painful features:

The syntax is very specific and quite wordy:
The ImageMagick syntax is full of specific classes such as Boxes, Palettes, Point, Fonts… All of them using an overwhelming number of position and offset constants.
Going through the existing code to understand how it works was quite hard.
To generate the image below in a dedicated service, 2 helpers were created to factorize image and text insertion and the image generator service was still 200 lines long. To sum up, it is long to write, even longer to re-read and hardly reusable.

The text insertion is not automatic at all:
If you want to insert text bit in an image with ImageMagick, you need to know that it won’t easily center or make newlines for your text blocks. You’ll need to handle all the logic to detect the available space@, how much each part of your string will take and create for each substring a new Box with a new Point etc…

Heavy work for heavy images
You can do a lot of different operation with ImageMagick, but it is not optimised for compressed image and quick calculation. By default the images we got from our brand new image generators were about 5 to 8 MB and took up to 5 second a piece to generate. We managed to add some image treatment after the generation to compress the images under 1MB but the calculation time was never improved.

Was it good enough?

Given the latest pain point I described, it won’t surprise you to read that our
customer was not pleased with the performance of the pages displaying our assets.
For some of them, we had to display up to 20 images on the same page which took around 15-20 seconds to load them all, it was not acceptable.

A newer better idea

Once the issue, the performance, was identified, we had to think of a better
solution to generate and display the assets.
Following our experience and reflexions, before implementing any image generation feature, you should ask yourself the following:

The answers to these questions for us was, drum roll, NO. Each user had
potentially access to thousands of different images. So we clearly had to
generate those dynamically and caching them was not an option. Because the performance was so dramatically bad, even lazy-loading didn’t help.
We had to think of a better solution to generate our assets.

HTML rendering in the front

As I said previously, the image we were generating had to be downloadable from the user’s space. That’s the reason why we didn’t use HTML in the first place, we needed to serve an image file at some point and not just some HTML.
However, we figured that we could still serve the image file through our existing controllers, and display the assets when the user is scrolling through the assets. It was quite simple to set up as our front (VueJS app) already had all the images information and the design was quite easy to do in basic CSS. We created some components and came from a 15 seconds performance to close to 0 seconds!

Although the result was very satisfying, the duplication and the ImageMagick un-optimised remaining code kept bothering me.
We were stuck with a redundant code structure looking like that : 

We had to find an optimization, that’s when I met with wkhtmltoimage.

wkhtmltoimage-pdf

wkhtmltoimage and wkhtmltopdf are command line tools to render HTML in PDF and/or image. It’s a simple binary file to render images/PDFs.
This tool is quite useful in our use case as we want to render super efficient
HTML in our front AND serve images with ONE implementation.
More importantly, the unique code we will use will be HTML/CSS, after tasting some ImageMagick syntax, I can guarantee you you’ll see the difference, so will your reviewer/reader do!

The main pros for using this library would be :

Install wkhtmltoimage on a Symfony project

The installation is quite simple and is compatible with a Symfony 3/4 project. You’ll have to add two dependencies in your composer.
First you need to add the package that will provide you the wkhtmltoimage binary.
You have two options here depending on your environment:
composer require h4cc/wkhtmltoimage-amd64 "0.12.4"
or composer require h4cc/wkhtmltoimage-i386 "0.12.4"
If you’re not sure which one will fit, you can try both of them and see how it goes.

Once the binary is available, you can install the knp-snappy-bundle that will abstract the work between your Symfony App and the wkhtmltoimage binary.
composer require knplabs/knp-snappy-bundle

You will have to register it in your AppKernel.php

// main/app/AppKernel.php 

   public function registerBundles()
    {	    
        $bundles = [
            (...)
            new Knp\Bundle\SnappyBundle\KnpSnappyBundle(),
        ];
        (...)
    }

You will also have to add some configuration in your services.yml to specify
the path of the binary you will be using to render your images. If your local
machine works with the amd64 binary and your production server works with the i386 one, it’s quite easy to set an environment variable here to switch easily.

# services.yml
knp_snappy:
    image:
        enabled:    true
        binary:     '%kernel.project_dir%/bin/wkhtmltoimage-amd64'
        options:    []

Once this is done, you can start implementing your image rendering controller and services. That’s what we are doing next!

(If you want more information and/or examples about the library, you should look up the documentation.)

Implementation with wkhtmltoimage

We will now see how to simplify our image generation with wkhtmltoimage to:

In order to do so, we will set:

To start with, we will set up the abstract service which will be responsible for all the dependency injections you will probably use in most of your image rendering services.
This step is not compulsory but if you want to set quite a lot of different services it could become very handy. The injections in this example are obviously not exhaustive.

<?php

namespace Images\ImageRenderer;

use Knp\Snappy\Image;
use Symfony\Component\Asset\Packages;
use Symfony\Component\Routing\RouterInterface as Router;
use Symfony\Component\Translation\TranslatorInterface;
use Twig\Environment;

abstract class ImageRenderer
{
    const DEFAULT_FILE_NAME = 'image.jpg';
    const HTML_SUFFIX = 'html';
    
    /**
     * @var Image
     */
    protected $snappy;

    /**
     * @var Environment
     */
    protected $twig;
    
    /**
     * @var Router
     */
    protected $router;

    /**
     * @var Packages
     */
    protected $assetsManager;

    /**
     * @var TranslatorInterface
     */
    protected $translator;

    public function __construct(
        Image $snappy,
        Environment $twig,
        Router $router,
        Packages $assetsManager,
        TranslatorInterface $translator
    ) {
        $this->snappy = $snappy;
        $this->twig = $twig;
        $this->router = $router;
        $this->assetsManager = $assetsManager;
        $this->translator = $translator;
    }
}

From this Abstract, we can now create a BusinessObjectImageRender, this service will be responsible for the logic necessary to translate our object into HTML.
This service should contain some very basic logic to retrieve the template arguments from the businessObject, render some html and return a JpegResponse.

<?php

namespace Images\JpegResponseGenerator;

class BusinessObjectImageRenderer extends ImageRenderer
{
    public function render(BusinessObject $businessObject)
    {
        // some  basic logic to get the arguments you need from your business
        // object
        $templateArgs = [
            'arg1' => $arg1,
            'arg2' => $arg2,
            'arg3' => $arg3,
        ];

        $html = $this->twig->render('images/businessObject.html.twig', $templateArgs);
        
        return new JpegResponse($this->snappy->getOutputFromHtml($html), self::DEFAULT_FILE_NAME);
    }
}

Now that we have a working service, we can set up a Controller responsible for the distribution of the images.
This Controller will retrieve a businessObject from a dedicated BusinessObjectService and simply give it as an argument to our BusinessObjectImageRenderer:

<?php

namespace Controller\Images;

use Components\Images\BusinessObjectImageRenderer;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/post-image")
 */
class ImageController
{
    // use .... 
    /**
     * @Route("/business-object/{arg1}/{arg2}.{suffix}", name="images_postimage_businessobject", methods={"GET"})
     */
    public function getBusinessObjectImage(
        BusinessObjectService $businessObjectService,
        BusinessObjectImageRenderer $businessObjectImageRenderer,
        string $arg1,
        int $arg2
    ) {
        $businessObject = $businessObjectService->generate($arg1, $arg2);
        
        return $businessObjectImageRenderer->render($businessObject);
    }
}

There you go! Our Controller can provide HTML and JPEG depending on you need. You just need to set some template (here in twig) and linked CSS to generate your image, as you can see the implementation is quite easy to develop and to update, moreover, the CSS bit is way simpler to implement than the one from a library such as ImageMagick. Big plus, you can design your templated image with the help of your favorite browser dev tools!

Going further

One last thing could still try to prevent you from completely factorizing your code.
The context in which you’re presenting your image as an HTML in the front might be an obstacle to use the exact same template to generate your image. You can either duplicate part of the HTML/CSS (which would still be better than using a completely different syntax to build you images) or use an in-between solution, setting your service so that it can either render an image or an html you can inject.

It would require to:

This option is quite acceptable in a performance point of view even if it is not as immediate as directly having your HTML written in your front.

The code structure would then look like this :

Another option you have in the image templating (that has nothing to do with our previous example) would be to use some templated SVG file in which you would have some scalars easily replaceable with a str_replace. This option has the advantage to be quite simple
and low resource consuming on a CPU point of view. The one limitation is you can only work with text/number variables, you wouldn’t be able to insert images in your template.

Conclusion

We have implemented an image generation code structure that is easy to maintain with great performance and image quality. You should now know when dynamic image generation is a valid option and you should have all the tools to perform it.