5

Can an HTML canvas element be internally cropped to fit its content?

For example, if I have a 500x500 pixel canvas with only a 10x10 pixel square at a random location inside it, is there a function which will crop the entire canvas to 10x10 by scanning for visible pixels and cropping?


Edit: this was marked as a duplicate of Javascript Method to detect area of a PNG that is not transparent but it's not. That question details how to find the bounds of non-transparent content in the canvas, but not how to crop it. The first word of my question is "cropping" so that's what I'd like to focus on.

WackGet
  • 2,667
  • 3
  • 36
  • 50
  • 1
    @K3N Edit explains why it's not a duplicate. Please re-open. – WackGet Aug 24 '17 at 17:54
  • 1
    Reopened, however, to crop you simply use the coordinates you get from that method and then use drawImage() to a new canvas to region. –  Aug 24 '17 at 19:26

2 Answers2

13

A better trim function.

Though the given answer works it contains a potencial dangerous flaw, creates a new canvas rather than crop the existing canvas and (the linked region search) is somewhat inefficient.

Creating a second canvas can be problematic if you have other references to the canvas, which is common as there are usually two references to the canvas eg canvas and ctx.canvas. Closure could make it difficult to remove the reference and if the closure is over an event you may never get to remove the reference.

The flaw is when canvas contains no pixels. Setting the canvas to zero size is allowed (canvas.width = 0; canvas.height = 0; will not throw an error), but some functions can not accept zero as an argument and will throw an error (eg ctx.getImageData(0,0,ctx.canvas.width,ctx.canvas.height); is common practice but will throw an error if the canvas has no size). As this is not directly associated with the resize this potencial crash can be overlooked and make its way into production code.

The linked search checks all pixels for each search, the inclusion of a simple break when an edge is found would improve the search, there is still an on average quicker search. Searching in both directions at the same time, top and bottom then left and right will reduce the number of iterations. And rather than calculate the address of each pixel for each pixel test you can improve the performance by stepping through the index. eg data[idx++] is much quicker than data[x + y * w]

A more robust solution.

The following function will crop the transparent edges from a canvas in place using a two pass search, taking in account the results of the first pass to reduce the search area of the second.

It will not crop the canvas if there are no pixels, but will return false so that action can be taken. It will return true if the canvas contains pixels.

There is no need to change any references to the canvas as it is cropped in place.

// ctx is the 2d context of the canvas to be trimmed
// This function will return false if the canvas contains no or no non transparent pixels.
// Returns true if the canvas contains non transparent pixels
function trimCanvas(ctx) { // removes transparent edges
    var x, y, w, h, top, left, right, bottom, data, idx1, idx2, found, imgData;
    w = ctx.canvas.width;
    h = ctx.canvas.height;
    if (!w && !h) { return false } 
    imgData = ctx.getImageData(0, 0, w, h);
    data = new Uint32Array(imgData.data.buffer);
    idx1 = 0;
    idx2 = w * h - 1;
    found = false; 
    // search from top and bottom to find first rows containing a non transparent pixel.
    for (y = 0; y < h && !found; y += 1) {
        for (x = 0; x < w; x += 1) {
            if (data[idx1++] && !top) {  
                top = y + 1;
                if (bottom) { // top and bottom found then stop the search
                    found = true; 
                    break; 
                }
            }
            if (data[idx2--] && !bottom) { 
                bottom = h - y - 1; 
                if (top) { // top and bottom found then stop the search
                    found = true; 
                    break;
                }
            }
        }
        if (y > h - y && !top && !bottom) { return false } // image is completely blank so do nothing
    }
    top -= 1; // correct top 
    found = false;
    // search from left and right to find first column containing a non transparent pixel.
    for (x = 0; x < w && !found; x += 1) {
        idx1 = top * w + x;
        idx2 = top * w + (w - x - 1);
        for (y = top; y <= bottom; y += 1) {
            if (data[idx1] && !left) {  
                left = x + 1;
                if (right) { // if left and right found then stop the search
                    found = true; 
                    break;
                }
            }
            if (data[idx2] && !right) { 
                right = w - x - 1; 
                if (left) { // if left and right found then stop the search
                    found = true; 
                    break;
                }
            }
            idx1 += w;
            idx2 += w;
        }
    }
    left -= 1; // correct left
    if(w === right - left + 1 && h === bottom - top + 1) { return true } // no need to crop if no change in size
    w = right - left + 1;
    h = bottom - top + 1;
    ctx.canvas.width = w;
    ctx.canvas.height = h;
    ctx.putImageData(imgData, -left, -top);
    return true;            
}
Community
  • 1
  • 1
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • 1
    Wow, this worked straight away even with `fillText` which I experienced problems with using the other method (see my comment on the other answer). Here is a working demo: http://jsfiddle.net/umasz04w/ Thank you. – WackGet Aug 25 '17 at 02:54
  • Ok but why are you calling twice getImageData? Put the one you analysed with the cropping arguments of putImageData – Kaiido Aug 25 '17 at 05:07
  • @Kaiido Sorry missed that. Will fix – Blindman67 Aug 25 '17 at 05:14
  • omg it is 2022 and this still works!! – Rotem Sep 30 '21 at 19:54
  • And now it is 2023. I know my x,y,w,h. You inspired me to do `imgdata = ctx.getImageData(x,y,w,h);` , `canvas.width = w; canvas.height = h;` , `ctx.putImageData(imgdata, 0,0);` Thanks! – dcromley Jan 21 '23 at 17:03
  • I modified the final return from 'return true' to 'return [left, top]' because my click events need offsetting after canvas crop... – Milos Mar 19 '23 at 19:19
2

Can an HTML canvas element be internally cropped to fit its content?

Yes, using this method (or a similar one) will give you the needed coordinates. The background don't have to be transparent, but uniform (modify code to fit background instead) for any practical use.

When the coordinates are obtained simply use drawImage() to render out that region:

Example (since no code is provided in question, adopt as needed):

// obtain region here (from linked method)
var region = {
  x: x1, 
  y: y1, 
  width: x2-x1, 
  height: y2-y1
};

var croppedCanvas = document.createElement("canvas");
croppedCanvas.width = region.width;
croppedCanvas.height = region.height;

var cCtx = croppedCanvas.getContext("2d");
cCtx.drawImage(sourceCanvas, region.x, region.y, region.width, region.height, 
                             0, 0, region.width, region.height);

Now croppedCanvas contains only the cropped part of the original canvas.

HoldOffHunger
  • 18,769
  • 10
  • 104
  • 133
  • Thank you. The related question certainly helps but I wasn't aware of how to actually extract the region. – WackGet Aug 24 '17 at 22:45
  • I'm having trouble using your linked method for finding boundaries when using `fillText`. The "right edge" is incorrect if I use `0,0` as the `fillText` co-ordinates. If I change them to e.g. `1,0` then the right edge is correct. See this fiddle: http://jsfiddle.net/9jj7o5az/2/ – WackGet Aug 25 '17 at 02:39