Rect Text

2020-03-08

Oisin Moran, my room-mate, and (ex-)coleague atInscribe, showed me his interesting solution to fitting several lines of text to a fixed-width. The aim is to adjust the font-size on each line such that each line is the exact same width. Much like the text-style in Instagram stories, on my homepage, or the example below.

THE QUICK BROWN FOXJUMPEDOVER THELAZYDOG

It sounds easy, right? Just find the width of the text on each line and then set that width to be the width you want. Surely Javascript has a way to do that, right? Surely.

Well it doesn't, at least not easily


Fortunately, stackoverflow had answersto this question. You can put the text into a canvas and get the width of the text that way. Then Oisín used a simple binary search algorithm to find the font-size that gave the desired width in pixels for a particular line.

const getTextWidth = text => { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context.font = '72px Helvetica'; const metrics = context.measureText(text); return metrics.width; };

That sounds like it should work, right? Guess again.

The div below is exactly 329.59375px wide, the result of getTextWidth('Hello World'), with a red background to highlight its size. Using this approach, you'd expect the text to fit exactly between the outline. But it doesn't.

Hello World

That's because the width of the text takes into account the leading and trailing space of the first and last letter on the line. If you don't believe me, simply zoom in and highlight any letter and see that the space it consumes goes beyond the visual elements of the letter. This makes the calculations much, much harder.


We might have a solution

But the official MDN docs for the CanvasTextMetricsincludes more metrics than just width. It also includesactualBoundingBoxLeft and actualBoundingBoxRightwhich looks like they might be more specific to what we want. So we could rewrite our function to find the difference between right and left.

const getAdvancedTextWidth = text => { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context.font = '72px Helvetica'; const metrics = context.measureText(text); return metrics.actualBoundingBoxRight + metrics.actualBoundingBoxLeft; };

But as you can see from the example below, this actually over compensates. Which might appear to be better but it's still not the accuracy we're looking for. There's still leading space before the first letter, and the width seems to be too small to include the entire word.

Hello World

But this can actually be fixed by translating the text horizontally by the actualBoundingBoxLeft distance. Such that it fits perfectly in our bounding box. The final code, with a JSX (React) example would look like this

const getAdvancedTextMetrics= text => { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context.font = '72px Helvetica'; const metrics = context.measureText(text); return { width: metrics.actualBoundingBoxRight + metrics.actualBoundingBoxLeft, actualBoundingBoxLeft: metrics.actualBoundingBoxLeft, }; }; const textMetrics = getAdvancedTextMetrics('Hello World'); <div style={{ width: textMetrics.width }}> <span style={{ marginLeft: textMetrics.actualBoundingBoxLeft }}> Hello World </span> </div>
Hello World

See for yourself

The input below lets you pick any letter and see the positions of each metric. As before, the width is represented by the red background, the light-blue line is actualBoundingBoxLeft and the dark-blue line isactualBoundingBoxRight. (Be patient, it's kind of slow)

G

It's also worth mentioning that the only downside is thatbrowser supportfor these properties are limited.

Putting it all together

Now that we can accurately get the width of a line of text for a given font-size using canvas, we can write a simple search function that will efficiently find the best font-size, to a given tollerance, that will give you text of the desired width.

const getMetricsForWidth = (string, width, fontWeight, font) => { const tolerance = 0.05; // tolerance in pixels let difference = Infinity; let maxPt = 1000; let minPt = 0; let fontPt; let iterations = 0; let marginLeft; while (Math.abs(difference) > tolerance && iterations < 100) { fontPt = (maxPt + minPt) / 2; const { width: fontWidth, actualBoundingBoxLeft } = getAdvancedTextMetrics( string, `${fontWeight} ${fontPt}px ${font}`, ); marginLeft = actualBoundingBoxLeft; difference = width - fontWidth; if (width > fontWidth) { minPt = fontPt; } else { maxPt = fontPt; } iterations = iterations + 1; } return { fontSize: fontPt, marginLeft, }; };

Try it out

HELLOWORLD

There's still a few things left to figure out, such as how to get a fixed width between each line of text. That is surprisingly difficult actually and needs to be solved in a similar way to how we solved for the width. So when I figure that out I'll probably write another post.