Screenshot a node with JavaScript

One of my kids wanted a way to record their sports predictions and their notes app system was getting unwieldy, so I built a website for them. It’s on a Netlify subdomain with no search engine indexing as it’s a personal record, not a way to show off how good the predictions are, and they have no interest in monetising it. That means they’d rather not share pages with other fans, but screenshots. If it’s a big event with multiple predictions it extends beyond the viewport, so a regular OS screenshot won’t work.

What I wanted to do, as his unofficial product manager/design/developer was put a link with a download attribute on the page, with an href attribute value of an image of the event.

I’ll go over a few things I learnt along the way, but you can skip to the final code.

Firstly, the basic idea is to create an SVG, put the HTML of the element inside the SVG, draw that SVG to a <canvas>, then convert the <canvas> to a base64 image.

SVG foreignObject

The way to get HTML into an SVG is to plop it inside <foreignObject> tags. I’d never heard of that. It looks like the snippet below, and element is the HTML element passed in as an argument.

<foreignObject width="100%" height="100%">
    <div xmlns="http://www.w3.org/1999/xhtml">
        ${element.outerHTML}
    </div>
</foreignObject>

Tainted canvas

My first attempt did sort of work, but only in Firefox. Part of the code is using the toDataURL() method on a canvas HTML element. Most browsers complained about the way I did that, as I had created a tainted canvas, and using that method on a tainted canvas is a security risk, therefore disallowed.

The way to get round it to convert the SVG to base64 using the btoa() method. I think it’s short for “binary to ASCII”, and there’s a complementary atob() method to go the other way. One thing to note when using btoa() is that it won’t accept anything that has a character representing more than 1 byte, and in my case that meant removing a flag emoji.

However, it also happens to accented letters, so to fix it I use the unescape() and encodeURIComponent() functions.

Potato quality

I’m not sure how to solve this one yet, but the resulting image is not the crisp screenshot I was expecting. I started with canvas.toDataURL('image/webp') and it was dreadful. Changing it to PNG (the default) was marginally better, but still not great. I tried AVIF but the result was a PNG. I’ve tried making the canvas twice as large but that gives me a very wide image with a gap below it. I’ve also tried making the SVG dimensions twice the size, then resizing the final image down, but that gives me a very slightly larger image. I’ll keep at it for now.

Here’s a comparison between a normal screenshot and this method for a boxing event:

A screenshot of a boxing prediction at normal resolution
Normal resolution using the screenshot utility on a Mac

A screenshot of a boxing prediction at low quality
Potato quality using JavaScript and canvas

Update: I fixed this.

The first part was when I discovered the toDataURL() method can take a second paramater. I already had the type in, but the second one is called encoderOptions and is a number between 0 and 1 that represents the quality of the final image. Setting that to 1 helped a little, but mainly doubled weight of the resulting image.

On top of that, and there’s not much difference between the two but I found JPEG to be marginally better than the original PNG.

So now instead of newImage.src = canvas.toDataURL('image/png'); it’s newImage.src = canvas.toDataURL('image/jpeg', 1.0);.

The real key to the whole thing was in 3 parts:

  1. Make the canvas element twice the width and twice the height of the node in question.
  2. Make the SVG the same size as the node.
  3. Draw the SVG onto the canvas at the size of the canvas.

iOS

Almost every browser just downloads the image in the background when I press on the screenshot link. Safari on my iPhone displays a prompt to view (doesn’t do anything) or download, and trying from inside a PWA presents me with this monstrosity. Pressing “More” then saving to Photos gets me there eventually. Sort it out Apple.

Weird image download screen in iOS that looks broken

The final code

Here’s it all together. I couldn’t have done it without the kindness of user2804429 on Stack Overflow.

const card = document.getElementById('card');
const css = document.querySelector('style');
const screenshot = document.getElementById('screenshot'); // the link with the download attribute

// This function takes any DOMDocumentElement as a first parameter
// And return a Data URL of its generated image
async function renderElement(element) {
  // Get the dimensions of the element
  const dimensions = element.getBoundingClientRect();

  // The canvas does not need to exist in document body
  const canvas = document.createElement('canvas');

  // The canvas is adjusted here
  canvas.width = dimensions.width * 2;
  canvas.height = dimensions.height * 2;

  const ctx = canvas.getContext('2d');

  // SVG scaffolding with CSS
  const imageSVG = `
    <svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width / 2}" height="${canvas.height / 2}">
      <style>* { font-family: sans-serif } span.weight { color: #fd0d96 }</style> ${css.outerHTML}
      <foreignObject width="100%" height="100%">
        <div xmlns="http://www.w3.org/1999/xhtml">
          ${element.outerHTML}
        </div>
      </foreignObject>
    </svg>`;

  // The image also does not need to exist in document body
  const imageElement = document.createElement('img');
  const newImage = document.createElement('img');

  // The svg image source is already converted to Data URL (Base64)
  imageElement.src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(imageSVG)))}`;

  imageElement.addEventListener('load', () => {
    ctx.drawImage(imageElement, 0, 0, canvas.width, canvas.height);

    newImage.src = canvas.toDataURL('image/jpeg', 1.0);

    newImage.addEventListener('load', () => {
      newImage.width  = newImage.naturalWidth;
      newImage.height = newImage.naturalHeight;

      // Update the screenshot link
      screenshot.href = newImage.src;
    });
  });
}

renderElement(card);

Tags