HorusKol

Adventures and musings in the world of web development

Convert SVG images in the browser using JavaScript and the Canvas API

August 31, 2020

SVG (Scalable Vector Graphics) have a few advantages over the usual image formats we use on websites. Firstly, it is possible to style them using CSS, making them very flexible. For some applications, they can also be smaller in filesize than the equivalent high quality PNG or JPEG. They can also be animated. Finally, just as it says in the name, they are scalable. This means they can be enlarged or shrunk down without pixelation or other shenanigans that you would get with a PNG or JPEG image - although photographs and other high-detail images are not suitable for SVG.


SVG (top) and PNG (bottom) images at 50 (left) and 100 (right) pixels.

However, I found myself at the limit of the usefulness of SVG recently (although, that's not SVGs fault).

The problem

I've been putting together a Game of Life board together as one of my experiments, and wanted to replace the solid black squares I was using with something with little more texture to it.


Comparing the old black tile on the left with a new "textured" tile on the right, at different sizes.

The game uses a <canvas> element, which can be manipulated with the Canvas API, and the original code for drawing a cell looked this (the project is built in Vue.js and the source is available on GitHub):

drawCell(x, y, state) {
    let context = this.$refs.canvas.getContext('2d');

    switch (state) {
        case DEAD:
            context.fillStyle = 'white';
            break;

        case ALIVE:
            context.fillStyle = 'black';
            break;
    }

    // fill the grid square but leave the grid outline
    context.fillRect(
        (x * this.cellSize) + 1,
        (y * this.cellSize) + 1,
        this.cellSize - 2,
        this.cellSize - 2
    );
}

Cloning images

As a first pass, I replaced that method with this much shorter one:

drawCell(x, y, state) {
    let context = this.$refs.canvas.getContext('2d');

    context.drawImage(this.images[state], (x * this.cellSize), (y * this.cellSize), this.cellSize, this.cellSize);
}

drawImage(Image, x, y, height, width) accepts any previously loaded image data - including PNG, JPEG, and SVG.

Unfortunately, because the cell size can be controlled by the user, any PNG image I used would be subjected to the usual problems that come from resizing images, so I went ahead with using SVG.

I used the component's mounted method to preload the two images:

async mounted() {
    this.images = {
        'alive': await loadImage("./img/alive.svg"),
        'dead': await loadImage("./img/dead.svg"),
    }

    this.initialiseMap();
}

loadImage is a simple function which allows the images to be loaded asynchronously, and so ensures that the images are available before the component tries to render them.

function loadImage(url, height, width) {
    return new Promise((resolve, reject) => {
        let image = new Image();

        image.onload = () => {
            resolve(image);
        }
        image.onerror = reject;

        image.src = url;
    });
}

Ground to a halt

Unfortunately, this implementation caused the whole drawing cycle to grind down drastically, and was taking tens of seconds to redraw each generation. This was quite the setback, considering the old filled square method was lightning fast, even on quite large boards.

I had hoped to fend off the worst performance issues by preloading the SVGs - but it seems the resizing and drawing for each cell was still a pretty expensive operation.

Was I doomed to have flat black tiles? I had one more thing to try.

Converting SVG to PNG

Remember that drawImage can accept any loaded image data? This data doesn't have to come directly from a file. It's possible to extract image data from another canvas element and use that where any other image can be used, including drawImage.

I updated my loadImage function:

function loadImageAsPNG(url, height, width) {
    return new Promise((resolve, reject) => {
        let sourceImage = new Image();

        sourceImage.onload = () => {
            let png = new Image();
            let cnv = document.createElement('canvas'); // doesn't actually create an element until it's appended to a parent, 
                                                        // so will be discarded once this function has done it's job
            cnv.height = height;
            cnv.width = width;

            let ctx = cnv.getContext('2d');

            ctx.drawImage(sourceImage, 0, 0, height, width);
            png.src = cnv.toDataURL(); // defaults to image/png
            resolve(png);
        }
        image.onerror = reject;

        image.src = url;
    });
}

The toDataURL(type, encoderOptions) method here accepts a mime-type string (image/png, image/jpeg, etc), and return base-64 encoded image data that can be used as the source for another Image. If it doesn't support the requested mime-type, or if you don't provide one, it returns a PNG. The second parameter is used to control image quality for "lossy" types like JPEG. This takes a number between 0 and 1 - the default is 0.92.

Finally, toDataURL can also output the newer WebP type - but only in Chrome (which is okay because it will default to PNG in other browsers).

Final thoughts

This isn't ideal - drawImage is still a costly function, and larger boards are still quite slow, so I need to search for other optimisations.

However, if you ever need to convert from one image type to any other (supported) type in a browser, think about the Canvas API and its toDataURL method. The toDataURL can even be used to get the image data from a Canvas that has been drawn on by the user (if your app supports that) and send it up to a server to be saved as a file.