javascripthtmlhtml5-canvas

Image edge transparency when using `drawImage` in combination with `setTransform`


I'd like to draw a red rectangle and an image on a HTML canvas. My goal is to completely cover the rectangle with the image, in combination with scaling all content using setTransform so the image fits the canvas. The issue I'm having is that the rectangle is still visible on the edges of the image.

I tried experimenting with the scale and it seems this is caused by rounding, as I'm scaling all content using a single setTransform call.

I can fix the issue by applying adjusted dimensions to both the rectangle and the image, so the resulting dimensions the rectangle and image are integer values. That works in Safari and Chrome, but it doesn't in Firefox.

Here's the reproduction code:

const DPR = window.devicePixelRatio || 1;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const checkbox = document.querySelector('input');

// Logical canvas size (matches physical pixels)
const container = document.getElementById('container');
const width = container.clientWidth * DPR;
const height = container.clientHeight * DPR;

// Desired content size
const contentWidth = 3508;
const contentHeight = 2480;

// Compute scale to fit content inside canvas
const scale = Math.min(width / contentWidth, height / contentHeight);
console.log(scale);

// Compute adjusted input size so output aligns to integers
const targetOutputWidth = Math.round(contentWidth * scale);
const targetOutputHeight = Math.round(contentHeight * scale);
const adjustedWidth = targetOutputWidth / scale;
const adjustedHeight = targetOutputHeight / scale;

function draw() {
  canvas.width = width;
  canvas.height = height;

  // Set transform and draw
  ctx.setTransform(scale, 0, 0, scale, 0, 0);

  // Determine to fix issue
  const fixIssue = checkbox.checked;

  // Draw background rect
  ctx.fillStyle = 'red';
  if (!fixIssue) {
    ctx.fillRect(0, 0, contentWidth, contentHeight);
  } else {
    ctx.fillRect(0, 0, adjustedWidth, adjustedHeight);
  }

  // Load and draw image
  const img = new Image();
  img.onload = () => {
    // ctx.imageSmoothingEnabled = false; // Uncommenting this doesn't help

    if (!fixIssue) {
      ctx.drawImage(img, 0, 0, contentWidth, contentHeight);

    } else {
      ctx.drawImage(img, 0, 0, adjustedWidth, adjustedHeight);
    }
  };
  img.src = `https://placehold.co/${contentWidth}x${contentHeight}`;
}

checkbox.oninput = draw;
draw();
<label>Fix issue: <input type="checkbox"></label>
<div id="container" style="width: 838px; height: 446px;">
  <canvas id="canvas" style="width: 100%; height: 100%; background: white; display: block;"></canvas>
</div>

When fixIssue is set to false, this is what browsers display (make sure to view these screenshots at 100% to see the red rectangle at the edge of the image):

Chrome Safari Firefox
Chrome without fixIssue Safari without fixIssue Firefox without fixIssue

When setting fixIssue to true, the issue is fixed in Chrome and Safari but not in Firefox:

Chrome Safari Firefox
Chrome fixed Safari fixed Firefox fixed

You can see that the rectangle is still visible at the top of the image in Firefox.

How can I prevent this from happening? I tried disabling image smoothing which doesn't help. I'd like to keep the single setTransform call as it's convenient for scaling all content.


Solution

  • The issue is that your image service actually returns an SVG image, and that Firefox and Chrome do not treat the SVG image's preserveAspectRatio="xMidYMid meet" (the default) the same way. Firefox assumes the SVG author should be the one that wins, and thus will treat the drawImage output rectangle as the resizing source for this aspect-ratio. Chrome on the other hand assumes the canvas author should win, and will apply the aspect-ratio factor before the SVG image is even passed to drawImage, making the stretching inherent with drawImage(src, x, y, width, height) actually not preserve the SVG's preserveAspectRatio attribute value.

    I did open a specs issue some times ago, but it's still not resolved.

    Here is a snippet showing what's happening:

    (async () => {
      const canvas = document.querySelector("canvas");
      const ctx = canvas.getContext('2d');
      const img = new Image();
      img.src = `https://placehold.co/800x600`;
      await img.decode();
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
    })().catch(console.error)
    canvas { background: red }
    <canvas></canvas>

    Resulting, in Chrome:
    The text "800 x 600" stretched on the x-axis, over a gray background,
    while in Firefox:
    The same text, not stretched, over the same gray background, but surrounded by two red rectangles on the left and right

    To get Chrome's behavior in Firefox, you can convert your <img> to an ImageBitmap before drawing it on your context.

    const DPR = window.devicePixelRatio || 1;
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    const checkbox = document.querySelector('input');
    
    // Logical canvas size (matches physical pixels)
    const container = document.getElementById('container');
    const width = container.clientWidth * DPR;
    const height = container.clientHeight * DPR;
    
    // Desired content size
    const contentWidth = 3508;
    const contentHeight = 2480;
    
    // Compute scale to fit content inside canvas
    const scale = Math.min(width / contentWidth, height / contentHeight);
    console.log(scale);
    
    // Compute adjusted input size so output aligns to integers
    const targetOutputWidth = Math.round(contentWidth * scale);
    const targetOutputHeight = Math.round(contentHeight * scale);
    const adjustedWidth = targetOutputWidth / scale;
    const adjustedHeight = targetOutputHeight / scale;
    
    function draw() {
      canvas.width = width;
      canvas.height = height;
    
      // Set transform and draw
      ctx.setTransform(scale, 0, 0, scale, 0, 0);
    
      // Determine to fix issue
      const fixIssue = checkbox.checked;
    
      // Draw background rect
      ctx.fillStyle = 'red';
      if (!fixIssue) {
        ctx.fillRect(0, 0, contentWidth, contentHeight);
      } else {
        ctx.fillRect(0, 0, adjustedWidth, adjustedHeight);
      }
    
      // Load and draw image
      const img = new Image();
      img.onload = async () => {
        // Make it a bitmap
        const bmp = await createImageBitmap(img);
        if (!fixIssue) {
          ctx.drawImage(bmp, 0, 0, contentWidth, contentHeight);
        } else {
          ctx.drawImage(bmp, 0, 0, adjustedWidth, adjustedHeight);
        }
      };
      img.src = `https://placehold.co/${contentWidth}x${contentHeight}`;
    }
    
    checkbox.oninput = draw;
    draw();
    <label>Fix issue: <input type="checkbox"></label>
    <div id="container" style="width: 838px; height: 446px;">
      <canvas id="canvas" style="width: 100%; height: 100%; background: white; display: block;"></canvas>
    </div>