Why does converting a blob to a data URI result in a different URI than the direct data URI method?

Viewed 123

I'm doing an experiment and I discovered converting a Canvas to a blob then to a data URI results in a different URI than getting the data URI direct from the canvas. The contents when opened are pretty much the same on both URIs.

How can I get the same URI result as the direct data URI method when using the blob method?

let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
let text = "Bufferoverrun";

ctx.textBaseline = "top";
ctx.font = "16px 'Arial'";
ctx.textBaseline = "alphabetic";
ctx.rotate(.05);
ctx.fillStyle = "#f60";
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069";
ctx.fillText(text, 2, 15);
ctx.fillStyle = "rgba(102, 200, 0, 0.7)";
ctx.fillText(text, 4, 17);
ctx.shadowBlur = 10;
ctx.shadowColor = "blue";
ctx.fillRect(-20, 10, 234, 5);

const blobToBase64 = blob => {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    return new Promise(resolve => {
        reader.onloadend = () => {
            resolve(reader.result);
        };
    });
};

canvas.toBlob(async function (result) {
    let blobToURL = await blobToBase64(result)
    if (blobToURL != canvas.toDataURL()) {
        console.log("Data mismatch");
    } else {
        console.log("Match")
    }
})

I've looked at Chrome's Blink internals and cannot find anything that explains the change.

Canvas Element Source Code - Blink

1 Answers

The current difference lies in how toBlob and toDataURL handle differently the color-space conversion.

toBlob may in some case retain the current color-space and include it in the produced Blob, toDataURL won't ever, and when toBlob does, it uses an other path to do that conversion.

Here is where they do the conversion for toDataURL and here is for toBlob.


Funnily enough, Chrome will generate the same bitmaps from both files when painting them back on a canvas again, but if you save both results to disk and draw them in a browser that does check at the source color-space like Firefox, you'll see they actually are different.


Note that it may not be the only place where you will find difference between both methods.

For instance, I also noticed that toBlob will get influenced by the fact the canvas is accelerated or not (below demo requires chrome://flags/#enable-experimental-web-platform-features):

const test = (accelerated) => {
  const HW = accelerated ? "accelerated" : "not-accelerated"
  let canvas = document.createElement('canvas');
  let ctx = canvas.getContext('2d', {
    willReadFrequently: !accelerated
  });
  let text = "Bufferoverrun";

  ctx.fillStyle = "#f60";
  ctx.fillRect(125, 1, 62, 20);
  ctx.fillStyle = "#069";
  ctx.fillText(text, 2, 15);
  ctx.fillStyle = "rgba(102, 200, 0, 0.7)";
  ctx.shadowBlur = 10;
  ctx.shadowColor = "blue";
  ctx.fillRect(-20, 10, 234, 5);

  canvas.toBlob(async function(result) {
    let blobToURL = await blobToBase64(result)
    const data = canvas.toDataURL();
    if (blobToURL != data) {
      console.log(HW, "Data mismatch");
    } else {
      console.log(HW, "Match")
    }
  });

  function blobToBase64(blob) {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    return new Promise(resolve => {
      reader.onloadend = () => {
        resolve(reader.result);
      };
    });
  }
};
test(true);
test(false);


And I should note that you should not expect these methods to return the same values anyway. Nothing in the specs requires that and quite on the contrary one could expect that since toBlob is asynchronous and performs its encoding in parallel, it would use slower but better quality options for its encoder than the synchronous toDataURL.

But if for your own case you want to have the same results through both methods, you can force software rendering by disabling Hardware Acceleration (chrome://settings/?search=hardware acceleration), and they will produce the same result.


From the comments OP explained that they actually are in a web extension, and area actually dealing with an OffscreenCanvas, which doesn't have a toDataURL method.

So first, in Firefox there is a demote method on the 2D context available to ChromeContexts (web extensions), which should allow to force using the software-renderer, but Chrome didn't implement this feature.

Now regarding the OffscreenCanvas in a worker case, one way around is to call toDataURL() from the main thread on an HTMLCanvasElement from which we grabbed our OffscreenCanvas:

const worker = new Worker( worker_url );
const workercanvas = document.createElement( "canvas" );
const offscreen = workercanvas.transferControlToOffscreen();
worker.postMessage(offscreen, [offscreen]);
worker.onmessage = (evt) => {
  const fromworker = workercanvas.toDataURL();
  const frommain = getDataURLFromMain();
  console.log( fromworker === frommain ? "Match" : "Data mismatch" );
};
<script>
// boiler plate prepare scripts content for SO's StackSnippet
const drawing_ops = `
  const ctx = canvas.getContext("2d");
  const text = "Bufferoverrun";

  ctx.textBaseline = "top";
  ctx.font = "16px 'Arial'";
  ctx.textBaseline = "alphabetic";
  ctx.rotate(.05);
  ctx.fillStyle = "#f60";
  ctx.fillRect(125, 1, 62, 20);
  ctx.fillStyle = "#069";
  ctx.fillText(text, 2, 15);
  ctx.fillStyle = "rgba(102, 200, 0, 0.7)";
  ctx.fillText(text, 4, 17);
  ctx.shadowBlur = 10;
  ctx.shadowColor = "blue";
  ctx.fillRect(-20, 10, 234, 5);
`;
const getDataURLFromMain = new Function(`
  const canvas = document.createElement("canvas");
${ drawing_ops }
  return canvas.toDataURL();
`);
const worker_script = new Blob( [ `
onmessage = (evt) => {
  const canvas = evt.data;
${ drawing_ops }
  if( ctx.commit ) { // might require WebPlatformFeatures flags
    ctx.commit();
    postMessage("");
  }
  else {
    requestAnimationFrame(() => postMessage(""));  
  }
};` ] );
const worker_url = URL.createObjectURL( worker_script );
</script>

Also available as a glitch since it might be clearer.

Related