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.
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.
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.
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.
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>
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)
It's also worth mentioning that the only downside is thatbrowser supportfor these properties are limited.
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,
};
};
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.