Save Expand icon

Ron Valstar
front-end developer

Rendering HTML to an image

With HTML/JavaScript trivial things are sometimes quite ... eeh well, not difficult ... but weird. Turning HTML into images is weird..

MDN used to have an article about it but it got removed. It's still on the Waybackmachine but it might be smart to have it as a blog post on the real interwebs, not the archive.

The trick

The funny thing is that this is just a weird trick. There really is no existing DOM method to easily turn rendered HTML into a bitmap. It is a bit of a pity for something so fundamental and maybe even a step back if you consider we had this functionality in Flash more than ten years ago. Let's reinvent the wheel in four weird steps:

That last step is optional, it could also be the href of an anchor with a download attribute.

In Code that would be

const {body} = document

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = canvas.height = 100

const tempImg = document.createElement('img')
tempImg.addEventListener('load', onTempImageLoad)
tempImg.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml"><style>em{color:red;}</style><em>I</em> lick <span>cheese</span></div></foreignObject></svg>')

const targetImg = document.createElement('img')
body.appendChild(targetImg)

function onTempImageLoad(e){
  ctx.drawImage(e.target, 0, 0)
  targetImg.src = canvas.toDataURL()
}

magic!

a bit better

If you check the above you'll see the it's an image. But you probably want to generate something on the fly, not something predefined. So here:

Oh

Oh, that's not right. The fonts don't load.
You see, the foreignObject does not really like foreign stuff after all. The name is about including foreign XML namespaces. But for security reasons it will never load any external files.
This means not only your styles should be inlined, but also images and fonts.

The size is also a bit off. We used the offsetWidth and offsetHeight of the <section> element but the child <p> has a margin. Normally this would collapse but not inside the SVG. To make it a bit easier we'll just make two CSS rules that collapse the margins manually so the SVG can read it.

A small detail that is easily overlooked: since we're copying the HTMLSectionElement.outerHTML into the SVG, CSS rules assigned to outer elements (like <body>) are not applied.

Some images

That wasn't so bad was it? So maybe add some images for good measure?
Well this is easier said than done: the HTML having to be XHTML. Because even if you write your document in XHTML syntax, if your doctype is declared like <!DOCTYPE html> any outerHTML will come out as HTML5. So self-closing tags like <img src="photo.jpg" /> come out like <img src="photo.jpg"> which is invalid XHTML and will cause the entire foreignObject to fail.
Luckily there is an easy way to create XML from HTML:

const html5 = '<p>Foo<br>here<img src="photo.jpg"></p>'
const doc = new DOMParser().parseFromString(html5, 'text/html');
const xhtml = new XMLSerializer().serializeToString(doc);
// <p>Foo<br />here<img src="photo.jpg" /></p>

But we also have to inline both images and CSS backgrounds.
Let's start with traversing the DOM for images. We're doing a querySelectorAll('img') that we'll feed into a method that turns an image uri into base64. We'll have to load the uri so it's async and returns a promise.

function loadImageBase64(src) {
  return new Promise((resolve,reject)=>{
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    const img = document.createElement('img')
    img.addEventListener('error', reject)
    img.addEventListener('load', ({target})=>{
      canvas.width = target.naturalWidth
      canvas.height = target.naturalHeight
      ctx.drawImage(target, 0, 0)
      resolve(canvas.toDataURL())
    })
    img.src = src
  })
}

It is used in getOuterHTMLInlined and getStyleInlined which are also promise-based. And since our earlier font-inliner is also a promise we just do a Promise.all for the three on the click of the button.

Strangely (contrary to what I said earlier) this example is affected by body CSS. Even though DOM inspection in the SVG doesn't show it: the moment the SVG is set as img[src] it appears to have a body. To kill this Schrödingers cat we just added svg body { padding: 0; }.

To sum up

Turning HTML into an image at runtime is easy but the implementation is a bit insane.
You need some async code to inline external files into XHTML.

Simply measuring offsetWidth/height will work fine in most cases. But what you measure should be in an isolated CSS scope, so it is better to render the SVG data into an iframe and measure that.

The last example does an adequate job of loading fonts and images but does not account for everything: you could have inlined images already, you could load fonts from within CSS instead of using the <link> element etc. Plenty of stuff left for you to do yourself.