The HTML5 Canvas is a wonderful thing. Started from something Apple was pushing to create simple graphics on web pages, it has exploded into the key for all manner of visual projects. From advanced interactive, diagrams to 60 frames-per-second games, it is at the very root of drawing things in any user agent, including both 2D and 3D (WebGL) support.
However, the currently implemented specification has one little hole in it. One small problem that has the potential to be incredibly confusing for those who didn’t know it was there. And it all has to do with how text is drawn and measured.
See, the specification lays out ways to measure text using a function named exactly that: measureText(). Called from the drawing context, it is passed a string and it returns a TextMetrics object with a property called “width.” This is the standard way of knowing how long, in CSS pixels, a certain string of text is from any given font measurement or family. If a valid CSS font style string, something like “24px Arial,” for example, is set for the drawing context, that is what is used for the measureText() call. It will return the width of some string in that font style.
However, there isn’t a way to get a font height easily. Width can be computed and returned using the above function call, but font height wasn’t included in the specification. Newer versions have ways of doing this, but most user agents don’t have that implemented yet. For those wanting to compute the font height of some string, they have to turn to some rather creative solutions.
The first, most common way to compute font height is to read the offsetHeight value of some text added to the DOM and calculated using the user agent’s CSS model.
The following code from Pixi.js shows how to do this.
/* @license | |
* pixi.js - v1.5.1 | |
* Copyright (c) 2012-2014, Mat Groves | |
* http://goodboydigital.com/ | |
* | |
* pixi.js is licensed under the MIT License. | |
* http://www.opensource.org/licenses/mit-license.php | |
* | |
* http://stackoverflow.com/users/34441/ellisbben | |
* great solution to the problem! | |
* returns the height of the given font | |
* | |
* @method determineFontHeight | |
* @param fontStyle {Object} | |
* @private | |
*/ | |
PIXI.Text.prototype.determineFontHeight = function(fontStyle) | |
{ | |
var result = PIXI.Text.heightCache[fontStyle]; | |
if (!result) | |
{ | |
var body = document.getElementsByTagName('body')[0]; | |
var dummy = document.createElement('div'); | |
var dummyText = document.createTextNode('M'); | |
dummy.appendChild(dummyText); | |
dummy.setAttribute('style', fontStyle + ';position:absolute;top:0;left:0'); | |
body.appendChild(dummy); | |
result = dummy.offsetHeight; | |
PIXI.Text.heightCache[fontStyle] = result; | |
body.removeChild(dummy); | |
} | |
return result; | |
}; |
Since the letter ‘M’ is most likely to be the tallest in any font, it is added via a text node to a div and then its offsetHeight, the space between an element’s uppermost edge and its parent, is returned. For those user agents with a CSS model, this is a clean way to quickly calculate the line height of some text. Since the Canvas shares this CSS model within the page, the text will be the same in the text node as it would be drawn to the Canvas itself.
However, what if the user agent doesn’t have a CSS model? In the increasingly common Canvas-emulated environments like Ejecta and CocoonJS, this is often the case. While there might be some limited DOM support, CSS doesn’t exist and text nodes, if they are able to be created at all, won’t match their contents. Its height will be the same as its parent’s, that of the screen or canvas used.
To figure out the font height for these user agents, another solution is needed. One that computes the exact pixel height of text as drawn on a canvas.
Again, going back to Pixi.js, the solution might look like the following:
/* | |
* http://stackoverflow.com/posts/13730758/revisions | |
* | |
* @method determineFontHeightInPixels | |
* @param fontStyle {String} | |
* @private | |
*/ | |
PIXI.Text.prototype.determineFontHeightInPixels = function(fontStyle) | |
{ | |
var result = PIXI.Text.heightCache[fontStyle]; | |
if (!result) | |
{ | |
var fontDraw = document.createElement("canvas"); | |
var ctx = fontDraw.getContext('2d'); | |
ctx.fillRect(0, 0, fontDraw.width, fontDraw.height); | |
ctx.textBaseline = 'top'; | |
ctx.fillStyle = 'white'; | |
ctx.font = fontStyle; | |
ctx.fillText('gM', 0, 0); | |
var pixels = ctx.getImageData(0, 0, fontDraw.width, fontDraw.height).data; | |
var start = -1; | |
var end = -1; | |
for (var row = 0; row < fontDraw.height; row++) | |
{ | |
for (var column = 0; column < fontDraw.width; column++) | |
{ | |
var index = (row * fontDraw.width + column) * 4; | |
if (pixels[index] === 0) { | |
if (column === fontDraw.width - 1 && start !== -1) { | |
end = row; | |
row = fontDraw.height; | |
break; | |
} | |
continue; | |
} | |
else | |
{ | |
if (start === -1) | |
{ | |
start = row; | |
} | |
break; | |
} | |
} | |
} | |
result = end - start; | |
PIXI.Text.heightCache[fontStyle] = result; | |
} | |
return result; | |
}; |
This time, a canvas is created, the text is drawn, and then a line-by-line calculation is done. Instead of testing just the letter ‘M’, a combination of ‘gM’ is used, testing for not only the most likely tallest letter, ‘M’, but for ‘g’ too, a letter that often extends to the bottom text baseline in many fonts. This way, the computed font height takes into account the bottom of a ‘g’ and the top of a ‘M’ as well. However, this too has some issues.
The first is the assumption the code makes that the drawn text of ‘gM’ will fit within the values of 300×150 of a default canvas space. If the drawn text font style is greater, the function will not return the correct value. And without expanding out the canvas to some arbitrarily large values, which in turn increases the time needed to check line-by-line, there isn’t an easy answer to this.
The other issue is that, without a CSS model, other details like line spacing settings for fonts are not taken into account with the calculations. The function returns the exact font height, not that as determined by other factors that may affect the font height within a CSS model like line or character spacing. It does not and can not know such information within the environments it is needed most.
Unfortunately, there isn’t a solution to this issue. Not one that produces the exact value needed, anyway. However, some approximation can be done by multiplying the computed height in pixels by 1.5 to take into account the computed line height plus half for padding between it and the next string drawn underneath it. Such a value acts like line spacing would, and can, of course, be increased or decreased as needed to adjust the spacing between one row of text and the next.
Hopefully, as the “living draft” of HTML with measureText() is implemented in more user agents, the advanced measurement functionality will come sooner rather than later.