HTML5 canvas text line height measurement

Take your protein pills and put your helmet on, strap yourselves in and hold on tight – we’re embarking on a rollercoaster ride to try and find a reliable, robust & repeatable approach to measuring line height on the HTML5 canvas. Am I trying too hard to sell this as exciting?

There are two sections to this article – first we’ll find algorithm then we’ll check it visually on a bunch of web-safe and Google fonts, as shown in the image above.

The mission

What I’ll do for the test visualisation is set up an absolute-positioned DIV on top of an absolute-positioned canvas. I’ll shove the same text string into both and recognise success when I am able to lay out the canvas text so that it is exactly in alignment with the DIV text. I’ll also link in some Google fonts and allow font size selection. That’s the mission. You can see it in action in the GIF above and in the code below – this is best seen full screen! Have a play and come back if you want to understand what I’m banging on about.

Why would you want an accurate text measure anyway?

I’m working on a text layout project wherein I have to lay out a long passage of text in pleasant, readable, lines and paragraphs. The text can have multiple fonts, sizes of decoration, meaning a mix of text heights, weights and sizes which must line up at their baselines. Word does, Pages does it, Google Docs does it. I’ve got to do it.

The layout process is simple IF you know the size of each word. It is then just a case of positioning rectangles and drawing the text into them. Alignment and performance optimisations just fall into place when dealing with rectangles.

But it all hinges on getting an accurate size for the text, including the height.

The anatomy of text metrics

As you can see in this excellent image I grabbed from https://fonts.scannerlicker.net/ (you should go and have a read if you are into typography – the author is a pro and it’s useful stuff) – a font is defined as having:

  • a baseline which is the Y-position where the bottom of the non-dangly letters sit;
  • an ascender which is the distance above the baseline that the tall letters stray to;
  • and a descender which is the distance below the baseline that the dangly characters might reach;
  • a caps height which is the top of capital letters;
  • an x-height which is the top of lower-case letters;
  • And lastly there is the line-gap shown shaded green here. That line gap is important to stop the descenders on line 1 overlapping with the ascenders of line 2.
Anatomy of text metrics

So from this excellent diagram, it would seem reasonable to infer that the overall line height is Ascender + Descender + Line Gap. So how can we get all that from the canvas?

OK already – just get the canvas text metrics and be done !

Yep – you would think it would be so simples. The HTML5 canvas gives us the text.measureText() method via which any string we care to throw at it will generate a textMetric response containing all kinds of interesting information about how that string looks in the active font and size, etc. Here’s an example of what you get from a random 100pt Arial string.

TextMetrics: {
    actualBoundingBoxAscent: 69
    actualBoundingBoxDescent: 0
    actualBoundingBoxLeft: 0
    actualBoundingBoxRight: 66
    fontBoundingBoxAscent: 121
    fontBoundingBoxDescent: 28
    width: 66.66499328613281
}

As an aside, I didn’t initially understand what I was looking at regarding the various boundingBox properties in the TextMetric so I read and re-read the definition at MDN. As a double-aside, the writeup for fontBoundingBoxAscent says the following

fontBoundingBoxAscent: Is a double giving the distance from the horizontal line indicated by the textBaseline attribute to the top of the highest bounding rectangle of all the fonts used to render the text, in CSS pixels.

Anyone else confused by the mention of “of all the fonts used to render the text”. Doesn’t that imply there is some means to fire in multiple fonts for the measureText() method to measure? I could not find anything on the web that talked about a multiple font facility – let me know if you can!

Back at the first aside, I now know from my visualisation test, that the font ascent and font descent are the top and bottom extent of the font. Meanwhile the mysterious actual ascent is the x-height – the height of the non-capital letters. The purpose of the actual descent remains a mystery since it appears to be, for most fonts I tested, the same as or just below the baseline.

Back to the mission…while measureText() is good for measuring width, it’s just a tease as far as height is concerned, because I see no mention of the line gap or overall line height. It appears that the textMetric may be giving us some clues but not enough to be conclusive. Look at the image below and you can see why the line gap is so critical to know when laying out text in paragraphs that require multiple lines – it’s the Y-axis space after one line ends and the next line begins. In typography it is considered important to have just the right amount of line gap for optimum legibility and reading speed.

Here’s another good example from my visualisation for Georgia and Time New Roman where we can clearly see that for Georgia, with no line gap, the accent above the A on line 2 runs the risk of overlapping the dangly parts of the g, j and y from the line above. Georgia does not have a line-gap built in. Meanwhile Times New Roman has a small line gap so you can get away with laying it out without any additional leading (gap between lines – pronounced ‘ledding’) and it will just about survive – but it won’t past muster for anyone with a design focus.

Wait – what was the mission again ?

The mission is to make text in the canvas look like text in HTML DIV elements in the browser. To do that we need to know the line heights that HTML uses. Hmmm – maybe there is some way to get HTML or CSS to help?

Enter the CSS sniffer

We could, in fact, employ CSS sniffing. What this comes down to is that we set up a dummy DIV element, set its font properties, add some text and get back the height of the DIV. If we position it outside the flow of the page, and make the DIV plain – no padding, margin, border or anything else that would add size to it, then it will auto-size itself to the size of the contents.

How this helps is that if we have the height of the line from CSS, and the ascent and descent from the canvas, with a little subtraction we will know the line gap!

I’ll break the approach down into two steps:

CSS measuring

It turns out the CSS measuring process is quite simple. The code is shown below. I pass in parameters for the font name and font size in points, although as this is CSS we could enhance it to handle any CSS font size unit. By the time we are removing the dummy div we have the CSS size of the DIV and the font size in pixels.

// get the CSS metrics.
// NB: NO CSS lineHeight value !
let div = document.createElement('DIV');
div.id = '__textMeasure';
div.innerHTML = text;
div.style.position = 'absolute';
div.style.top = '-500px';
div.style.left = '0';
div.style.fontFamily = fontName;
div.style.fontWeight = bold ? 'bold' : 'normal';
div.style.fontSize = FontSizePt + 'pt';
document.body.appendChild(div);

let cssSize = {width: div.offsetWidth, height: div.offsetHeight},
    cssInfo = window.getComputedStyle(div, null),
    fontSizePx = parseFloat(cssInfo['fontSize']);

document.body.removeChild(div);

Canvas measuring

Continuing the flow of code we move into the canvas measuring section. We already have a canvas in the page so after getting the context we set the font name and size using the pixels size we got from CSS. I should point out that I am using the baseline option for the canvas textBaseline value – other options are available but this one just works in my head. We ask for the text metrics then return with the useful CSS properties we found.

You will see that I return the full line gap and values for the top and bottom line gaps – this is just just to keep things simple.

// get the canvas metrics.
let canvas = document.getElementById('myCanvas'),
    context = canvas.getContext('2d');

context.font = fontSizePx + 'px ' + fontName;
context.textAlign = 'left';
context.fillStyle = 'blue';
context.textBaseline = 'baseline';

let metrics = context.measureText(text),
    lineGap = (cssSize.height - (metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent)),
    advMetrics = {
    width: metrics.width,
    cssHeight: cssSize.height,
    cssFontSizePx: fontSizePx,
    fontAscent: metrics.fontBoundingBoxAscent,
    fontDescent: metrics.fontBoundingBoxDescent,
    actualAscent: metrics.actualBoundingBoxAscent,
    actualDescent: metrics.actualBoundingBoxDescent,
    lineHeight: cssSize.height,
    lineGap: lineGap,
    lineGapTop: lineGap / 2,
    lineGapBottom: lineGap / 2
    };  

return advMetrics;

As an aside, I know that I could specify a line height for myself – say 120% of the font size. However, I want my text to appear reasonably similar to what you get from Word etc. So I will continue hunting a solution knowing I have this fall-back.

Visualisation and mission accomplished

Putting all this together we get the visualisation tool. This places am HTML DIV exactly over a canvas. We select a font and size from the list and watch the visualisation take place. We know our measurements work because the HTML text and the canvas text overlap precisely – you can use the slider to move the canvas horizontally and observe the match.

Points to note:

  1. When I started the visualisation with Google fonts I thought I might have wasted my time because most of those I picked to sample did not have a line gap! Take a look at Anton, Lobster, Bad Script, Open Sans, Roboto and Ultra. Then I looked at Ubuntu and the web-safe fonts Arial, Times New Roman and felt vindicated.
  2. For example, take font ‘Ubuntu’ at 140px+ and look at the bottom of the third line and toggle the ‘Use gap’ value. With use gap switched off you will see that by line 3 we have a failure of our mission to exactly position the canvas text as the DV text – illustrated by the blue text (canvas) being slightly higher up the page than the pink (HTML) text. This underlines that when the font has a line gap then CSS/HTML will use it.
illustrating affect of using the line gap – blue canvas text higher up page than pink HTML text.

I still need a user-controlled line-height

While it was nice to be proven right in my search for the line-gap and overall quest to calculate the CSS height to use for a line of text on the canvas, I still need to let the user specify line height. Why – because otherwise a paragraph of text for some of the fonts will look appalling! In the visualiser, look at Ultra – it is a very ‘heavy’ font with a lot of solid color. It is probably designed for posters and the like, rather than pages of text, but I still have to make it look pleasing if the user opts to use it.

Ultra is very ink-heavy making line spacing an imperative

For the other extreme see Catamaran with its massive descender space – soooo much white space there that in a paragraph setting the lines might feel unrelated.

Catamaran has a huge descent value making for a lot of white space what could leave the lines feeling unrelated.

So – I need to provide through my UI a means for the user to increase and decrease the line spacing compared to the default for the font.

Summary

I started off with a mission to discover how to mimic HTML DIV text sizing on the canvas. Along the way I learned about some of the meanings of the properties of the TextMetric structure and observed that fonts may or may not have a built-in line gap. My hunch is that those with a line gap originate from way back in time when there were no options to set line-spacing in the early DTP tools. Meanwhile those fonts that ignore the line gap have been created since the DTP and design tools have included the line-spacing option which makes a built-in line gap an amusing artefact of font technology.

Ultimately I now have a means of getting the CSS line height which I can use in my text layout project. Tally-ho!

Thanks for reading.

PS. The code in this blog was developed with Chrome 88.0.4324.150 (Official Build) (64-bit) around Feb 10th 2021. It is not tested cross-browser but since it uses standard CSS and canvas methods it is likely to work cross-browser without adjustment. If you find any cross-browser issues please let me know and I will adjust the material.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: