Categories
javascript Uncategorized web

Clipping image edges at runtime with CSS clip-path

This is going to be a rare front-end post from me, but I thought I’d share a handy technique for making a web browser treat the non-transparent edge of an image as the actual edge, rather than using the bounding box as it does by default.

I spent some time this year building a collage editing tool for an artist friend of mine, who runs workshops where students create collages out of existing images; I created a digital app to help out in the time of COVID, which you can find at https://collageit.co.uk (it’s actually pretty fun to play with).

The thing is that, when editing a collage, images overlap with each other a lot, and users interact with images in a more physical manner than just with ‘layers’, as you might find in a lot of image editors; that’s the nature of collage art. So, why does the default browser behaviour make this so difficult to implement?

Let’s say I have this image below. The image has a transparent background. From a visual perspective, the edge of this image is the edge of the circle, right?

Just a red button.

But, as you may know, the browser (and mostly every other image rendering system) will treat the edge of the image as the bounding box.

The actual image box.

This means that any mouse-over events or other interactions will all be with the image’s bounding box edge. If I want things to happen only when a user clicks on the actual image, things get tricky.

CSS clip-path

I’m going to start off just showing the working solution, which I’ve set up in codepen. Try it out with some of your own images if you like.

Due to canvas security restrictions, any images you want to load into the codepen need an Access-Control-Allow-Origin header in order to work. Imgur is a pretty good source that works.

So, we can display an image, and have the browser interact with the ‘actual’ edge of the image, rather than the bounding box.

How does this work?

The actual clipping behaviour is done with a CSS clip-path property. This property allows you to define a custom polygon that clips both the actual displayed portion of an image, as well as the area where mouse interactions are detected.

/* A simple clip example */
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);

However, we can actually exploit this property to provide explicit x + y positions for the clip:

clip-path: polygon(55px 49px,55px 50px,54px 50px,54px 51px,53px 51px,53px 52px,52px 52px, ....);

There isn’t really a limit to the length of the clip-path, so we could actually define the entire edge of the image using this mechanism if we wanted.

This is how I’m clipping the image in my approach.

Marching Squares

You may have realised (especially if you load your own images in the codepen) that I haven’t actually pre-computed this path and loaded it in, or embedded it in my HTML ahead of time. This path is computed at runtime when we load the image!

We do this using something called the Marching Squares algorithm. Basically, it’s an algorithm that detects a contour (or edge) of something. In this case, I use it to detect the path around the image where the alpha component of the image transitions to opaque.

I’m using an implementation of Marching Squares by Mike Bostock, from the d3 graphics library. The license is included in the codepen source; if you use that code, please maintain the license!

So, what are the steps here?

  1. Load the image from whatever source.
let loadPromise = new Promise((resolve, reject) => {        
        const imageElement = new Image();
        imageElement.crossOrigin = "anonymous";
        imageElement.onload = ev => {
            resolve(imageElement);
        };
        imageElement.onerror = ev => {
            reject(`Could not load image from ${imageUrl}.`);
        }
        imageElement.src = imageUrl;
});

// Wait for the load to finish.
const loadedImage = await loadPromise;

  1. Resize an in-memory canvas and render the image into it.
// I've defined 'workingCanvas' elsewhere.
const canvasWidth = workingCanvas.width = loadedImage.width;
const canvasHeight = workingCanvas.height = loadedImage.height;

const drawContext = workingCanvas.getContext("2d");

// Wipe the canvas.
drawContext.clearRect(0, 0, workingCanvas.width, workingCanvas.height);

// Draw the image.
drawContext.drawImage(loadedImage, 0 ,0);

  1. Get the raw pixel data for the image so we can analyse it.
// Get the raw pixel data that we can analyse.
const pixelData = drawContext.getImageData(0, 0, workingCanvas.width, workingCanvas.height)
                             .data;
  1. Define a function that, for any given x,y coordinate, can assess whether the point is part of the image.
// This is used by the marching squares algorithm
// to determine the outline of the non-transparent
// pixels on the image.    
const defineNonTransparent = function (x, y)
{
    // Get the alpha value for a pixel.
    var a=pixelData[(y*canvasWidth+x)*4+3];
    
    // This determines the alpha tolerance; I'm extremely intolerant of transparency;
    // any transparency is basically counted as the edge of the image.
    return(a>99);
}
  1. Finally, get the edge points of the image.
// Get the edges.
var points = contour(defineNonTransparent);

From the edge points we can trivially construct a clip path to use in the css polygon construct.

function getClipPath(imgData) 
{
    let clipPathSet = [];

    imgData.boundingPoints.forEach(p => clipPathSet.push(`${p.x}px ${p.y}px`));

    return clipPathSet.join(',');
};

Limitations

There are a couple of limitations to this approach (which I can largely accept for my purposes):

  1. The algorithm can only detect one edge in an image; so if your image is made up of multiple disconnected parts, only one of them is going to be picked up and clipped.
  2. I can’t detect internal transparency with this algorithm right now, so if you have a blank space inside the image, that’ll be treated as part of the image rather than something you can click through.
  3. This isn’t going to work in old browsers (IE is out, basically).

It’s quite likely that there are better ways to do this, and I’d be interested to learn of better solutions if you have them!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s