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:
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:
- Make the
canvas
element twice the width and twice the height of the node in question. - Make the SVG the same size as the node.
- 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.
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);