indexpost archiveatom feed syndication feed icon

HTML5 Canvas: Images

2016-03-22

Another dive into the HTML5 canvas element, last time I made a client-side image placeholder, this time I'll be working with existing images. Dry reading.

What's More Fun Than an Image Placeholder

This time around I thought I'd try and make something a bit more visually interesting, so I'll be adding an image onto the canvas element. I'll be using the following SVG image (but any image should work):

adorable floating cat thing

How Hard Could It Be

I've found that it's not entirely straight-forward to place an image on the canvas, certainly not an SVG image. The issue seems to be how the image is scaled in the context of the canvas element and independent of the rest of the browser.

Take for example this naive implementation:

  function improperScaling() {
      var image = new Image();
      var canvas = document.querySelector("#canvas-1");
      var context = canvas.getContext("2d");

      image.onload = function() {
          context.drawImage(image, 0, 0);
      };
      image.src = "/2016/static/lcat.svg";
  };
  improperScaling();

The .onload function is used to ensure the image is entirely loaded before it is added to the canvas. You'll notice of course that there are several things wrong with the result.

  1. the image is too big for the canvas
  2. the image hasn't scaled properly and looks ragged/pixelated

The second is especially an issue on a "retina" display.

Fixing Image Scaling

The first issue seems to be a result of the image's embedded width and height, rather than fitting within the bounds of the canvas it simply runs over. The .drawImage function allows for several options beyond those specified above (where 0, 0 specify the placement of the image on the canvas as x and y-coordinates). Most promisingly, .drawImage allows for an arity of 4:

ctx.drawImage(image, dx, dy, dWidth, dHeight);

where dWidth and dHeight are the image's width and height on the destination canvas. The only tricky bit here is that there is no option to scale down one dimension from the other. It isn't really an issue once you remember to apply the scaling yourself (height as a function of width or vice versa).

context.drawImage(image, 0, 0, 100, 100*(image.height / image.width));

Here I am setting the destination width to 100 and scaling the height based on the fraction of height to width. If the image were square, it would come out an even 1 and result in 100x100, in reality it's about 1.189 so the result is 100x118.

  function betterScaling() {
      var image = new Image();
      var canvas = document.querySelector("#canvas-2");
      var context = canvas.getContext("2d");

      image.onload = function() {
          context.drawImage(image, 0, 0, 100, 100*(image.height / image.width));
      };
      image.src = "/2016/static/lcat.svg";
  };
  betterScaling();

Fixing "Fuzziness"

This required some searching, but I eventually found my answer here, which turned up the useful search term "devicePixelRatio"; as MDN explains it:

The Window.devicePixelRatio read-only property returns the ratio of the (vertical) size of one physical pixel on the current display device to the size of one device independent pixels(dips).

Basically, high-DPI displays pack multiple (in my case 2) pixels into what would traditionally be 1 pixel. So giving a canvas element a value of 100px causes havoc because it is getting upscaled and not taking advantage of the S in SVG (scalable).

So, the solution is to first define the canvas element's style width/height and then promptly redefine the canvas's width and height property by a factor of this devicePixelRatio, to "pack in" the correct number of pixels for the context. The contents of the canvas are then scaled back up with the call to .scale so that you don't have a half-sized image. Round-about, but it works.

  function scaledDownNotFuzzy() {
      var image = new Image();
      var ratio = window.devicePixelRatio || 1;
      var canvas = document.querySelector("#canvas-3");
      var context = canvas.getContext("2d");

      canvas.style.width = canvas.width + "px";
      canvas.style.height = canvas.height + "px";

      canvas.width *= ratio;
      canvas.height *= ratio;

      image.onload = function() {
          context.scale(ratio, ratio);
          context.drawImage(image, 0, 0, 100, 100 * image.height / image.width);
      };
      image.src = "/2016/static/lcat.svg";
  };
  scaledDownNotFuzzy();

Enough For Now

This whole thing was a bit of a rabbit hole. I now have a better appreciation for how involved working with the canvas can be, certainly more than creating just the image placeholder. I plan on picking back up later with something more interesting because this whole thing was pretty anti-climactic.