canvas-txt v5

canvas-txt is a small library I wrote years ago that draws multiline text inside a box on HTML5 canvas. If you’ve ever tried to do this yourself you know the pain — canvas gives you fillText and measureText and wishes you good luck. The library wraps your text, aligns it, and that’s it. It picked up a few hundred stars over the years, and then I stopped touching it. The last commit before this month was from 2023.

There was one thing about it that quietly bugged me the whole time.

Back in 2022, the folks at Glide opened an issue on my repo. Very polite. They liked the library but it was too slow for their data grid, so they forked it until it was, in their words, “basically unrecognizable” and published it as canvas-hypertxt. Their README has a benchmark table where canvas-txt loses by about 10x. That table has been sitting on the internet for four years.

This month I finally sat down with it and ended up rewriting the whole thing.

The rewrite

The old wrapping code walked through the string character by character, guessing where to break lines. It worked, mostly, but it had real bugs — it could split an emoji in half because JavaScript strings are UTF-16 and slicing at the wrong index gives you half a surrogate pair. It also only knew how to break at spaces, so CJK text wrapped badly.

v5 uses Intl.Segmenter, which browsers have shipped for a while now and does proper Unicode word segmentation for free. Chinese and Thai break at real word boundaries, emoji never get cut in half, and the wrapping code got smaller.

The other big change is splitting layout from painting. layoutText() figures out where every line goes and returns it, drawTextLayout() just paints it. If you’re redrawing the same text every frame — a canvas editor, a grid — you lay out once and paint forever. drawText() still exists and does both, like it always has.

Some smaller things that were overdue: line heights now come from the font’s real metrics instead of measuring a capital M (custom fonts used to overlap lines), justified text positions each word instead of injecting invisible hair space characters into your string, and there’s finally overflow: 'ellipsis' — the single most requested feature in the issues.

Making it fast

Then I benchmarked it against canvas-hypertxt, because I had to know.

It was bad. Laying out a 600 character string took my new version about 1,400 microseconds. Theirs: 50. All that rewriting and I was still losing by the same margin as four years ago.

The thing I eventually understood — and the thing Glide understood in 2022 — is that the wrapping algorithm barely matters. measureText is the only expensive operation in the whole pipeline, and I was calling it on every line, on every call. They almost never call it: they keep a global cache of word widths per font, and a line’s width is just the sum of its words. After warmup, their layout is arithmetic.

So I copied the homework, with credit. v5 keeps a per-font cache of segment widths, sums them for line breaks, and memoizes whole layouts for repeated inputs. If you’re worried about a shared cache (web fonts loading late change your widths — there’s a clearMeasurementCache() for that), you can pass cache: false and get exact, stateless measuring back.

Same 600 character benchmark now: canvas-txt does never-repeating strings in ~51µs, canvas-hypertxt’s standard mode takes ~566µs. For content that repeats — which is what a real app renders — it’s 5.6µs against 48. Their “hyper wrapping” mode, which guesses widths instead of measuring them, still beats me on long text it’s never seen. I can live with that; mine measures.

I’m sending them an issue about the outdated table in their README, which feels like a fair trade four years later.

Everything else

The demo playground got rebuilt too. The text box is draggable now, with resize handles like a text layer in a design tool, and the inspector writes the drawText() code for you as you fiddle with it.

For the first time since I created the repo, it has zero open issues. v5 closed all of them — overflow, ellipsis, custom font heights, draw styles, OffscreenCanvas types. If you’re on v4 there’s a migration guide, but honestly, most code just keeps working.

npm i canvas-txt

canvas-txt repo