Rendering SVG with Text to HTML Canvas

Published Thursday, November 30th 2023 · 6min read

I’ve been working on our yearly X-Mas experience at work these past couple of weeks, and while I can’t reveal too much about what exactly we’re building, I can say that it definitely challenged my web dev skills. The experience requires that I render multiline text to a canvas in different fonts so it can be saved as an image, but it needs to be editable at the same time.

This presents a lot of hurdles, especially regarding accessibility, but for now I’d like to dive into the rendering of the text itself, something that should be rather straightforward, but isn’t. I’ve actually had to deal with this before, when building the export functionality of 256c, but back then, I decided to rely on a library, which unfortunately proved to be not that reliable in the end.

It’s a tough thing to achieve, apparently. While HTML canvases have a fillText() method, it only works for single lines! And even then it can be a little unreliable with loading custom fonts. So I needed a different solution.

SVG to the Rescue!

I love working with SVGs, they’re just so powerful! And I had some success rendering SVGs to a canvas in a different part of the experience, so it seemed only logical to me to use an SVG for the multiline text as well. However, working with multiline text in SVG is almost as annoying as drawing it onto the <canvas> itself.

Thankfully, while working on a new iteration of Magistan, my card game, a couple of years back, I found out about a magical element in SVG that can be extremely useful: <foreignObject>—it allows displaying arbitrary HTML elements within an SVG context and coordinate system. Arbitrary content like a paragraph or <textarea>—with full support for CSS styling!

Unfortunately, it comes with a cost: it’s a bit unreliable in the three big browser engines. Chrome works the best, but both Firefox and Safari struggle with a few aspects. Safari was especially terrible when I tried using foreign objects in Magistan—but the situation seems to have somewhat improved since then.

Big Issues (Little Fixes)

To make everything work, it is extremely important to inline any styling into the SVG itself, either directly into the tags, or with a dedicated <style>element, which in SVG has to be an SVGStyleElement not an HTMLStyleElement, yes they are different. So make sure to create it with document.createElementNS('http://www.w3.org/2000/svg', 'style') if you want to create it from a script!

Applying the styles to the SVG directly is important because when drawing the SVG to a canvas (for example by converting it into an image first), it becomes self-contained, meaning all CSS styles applied to the rest of the page no longer affect it. This self-containment will also have further implications, but more on that in a bit.

Absolute Sizing is a Must (for Firefox)

When I first got the rendering working, I kept wondering why my beautiful multiline text wouldn’t show up in Firefox. As it turns out, Firefox requires absolute values in the width and height attributes to be present on the SVG containing the foreign object, or it won’t be able to scale it correctly.

Thankfully, once I knew about this restriction, it was fairly easy to fix this issue. In my case, I could simply set the dimensions of the SVG to the dimensions of the canvas I was working with, since the SVG was filling the entire space.

HTML elements within the foreign object seem to be scaled in relation to the viewBox of the SVG (at least as long as you aren’t in Safari and have set a position other than static), so I’m guessing that while Chrome and Safari manage to infer the correct dimensions somehow, Firefox needs explicit and absolute values, so setting width or height to a percentage won’t work either.

Adventures with Data URLs

Speaking of Firefox, it seems that it is not able to correctly render an SVG encoded as a base64 string to a canvas if that SVG contains a foreign object. At least not straight away.

The drawImage method of a canvas context only accepts image data, so the SVG needs to be converted to an image first. The most straightforward option seemed to be to simply base64 encode it and pass it to an HTMLImageElement as a data URL. However, I quickly ran into multiple issues when doing that.

First, using atob() for encoding the data struggled with non-ASCII characters, then the XML wasn’t valid because the text in the foreign object contained <br> elements (for newlines) and after working around these issues, I learned that Firefox wouldn’t even be able to render it properly.

So instead, I converted the SVG to a Blob and used window.createObjectURL() to get a URL to use as a src for the image to be drawn to the canvas—which in turn caused the canvas to be flagged as ‘tainted’ in Chrome and Safari! When a canvas is tainted, it cannot be converted to an image programmatically, which basically makes it useless if you want to further process and export the result.

So I ended up reading the Blob as a data URL with a FileReader and lo and behold: it actually rendered untainted in all three browsers!

—except all my custom fonts were missing.

Hunting Correct Type

Remember how I said that SVG images drawn onto a canvas will become self-contained earlier? Well, this also means that they lose access to all external files, including fonts and images. This does make a lot of sense from a security perspective, but throws a wrench into the slightly convoluted, but still very effective strategy of using SVGs to render HTML into a canvas.

Bumping into this issue, I was faced with two solutions: hope that the typefaces were installed on the end-user’s machines and deal with fallback fonts if they weren’t, or find a way to somehow embed the font files into the SVG to be rendered. Since the first option was no option at all, we are a design agency, after all, I went with the second route.

Thankfully, all the fonts we are using in the experience have been released under permissive licences, so I think embedding them into an SVG, which is just an intermediary step anyway, shouldn’t be an issue. But how do you embed a web font into an SVG?

Using a data URL, of course! There are some tools out there that allow you to convert any font into a base64 encoded version, which you can then use directly as the url() parameter in the srcproperty of an @font-face rule. I personally found Transfonter worked best for my use-case, especially since it allowed selecting only a subset of specific letters to be included. That’s definitely something you should do to keep file sizes reasonable.

Rejoice: Flexible Multi-Line Text in Canvasses

And with that big little fix in place, I could finally flawlessly render multiline text in custom fonts to a canvas. I hope it was worth it—I certainly learned a ton while doing it, and I’m reasonably sure these techniques will come in very handy in the future. If nothing else, I will be able to use them to generate high-res printable versions of Magistan cards!

Could I just have used a text-engine library that made it easier to add multiline text to a canvas? Yes. I even found one that supported custom line heights, kerning and more, but it was still a far cry from the flexibility and raw power of CSS. In the end, I chose more control of the process and outcome over convenience. Besides, by the time I found that library, I was almost done with my idea anyway.

Despite everything running well so far, in the end, this code has become quite a mess, especially due to the extreme time constraints it was developed in. I’d love to revisit this subject in the future and perhaps turn my findings into an actual tutorial instead of a loose collection of notes.

Feel free to let me know over on Mastodon if you’d like that! And with the year slowly coming to a close, I’ll make sure to do a bit of a recap of 2023 before long. Until then! 😊