Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Eric Betts
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Eric Betts ( @AGuyNamedBettse )

Rendering Text To Canvas With Adjusted X,Y Offsets For Better Cross-Browser Consistency

By on

At InVision, I recently added the ability for any type of user to come in and generate a "placeholder" screen (see Video demo on LinkedIn). These placeholder screens are generated, in part, by rendering text to Canvas. As I soon discovered, rendering text at (0,0) coordinates means different things to each browser. As such, I had to slightly adjust the location of the rendered text in the various browsers. Right now, I'm doing this with some "user agent sniffing"; but, I'd like to evolve my approach to be more programmatic and less heavy-handed. And, to do that, I have render-and-detect the inconsistent offsets being used by each browser at runtime.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

First, let's look at the what I mean by "inconsistent rendering" of text. To demonstrate, I'm going to render a grid of lines to a Canvas. And then, I'm going to render a series of text values at different sizes and different X,Y coordinates. The grid lines should help us more easily see how each browser nudges the text in each axis:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<title>
		Rendering Text To Canvas With Default Offsets
	</title>
	<link rel="preconnect" href="https://fonts.googleapis.com" />
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
	<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@500" />
	<style type="text/css">
		body {
			font-family: "Roboto", sans-serif ;
		}
		canvas {
			box-shadow: 0px 0px 0px 2px #000000 ;
		}
	</style>
</head>
<body>

	<h1>
		Rendering Text To Canvas With Default Offsets
	</h1>

	<canvas width="400" height="400"></canvas>

	<script type="text/javascript">

		var canvas = document.querySelector( "canvas" );
		var canvasWidth = 400;
		var canvasHeight = 400;
		var context = canvas.getContext( "2d" );

		// We have to given the FONT time to load so that we can use it on the canvas.
		window.addEventListener(
			"load",
			() => {

				drawGridLines();
				drawText( "Frankly, my dear, I don't give a damn." );

			}
		);

		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //

		/**
		* I draw the sample text to canvas at various sizes.
		*/
		function drawText( textValue ) {

			var pairings = [
				{ fontSize: 10, y: 50 },
				{ fontSize: 20, y: 100 },
				{ fontSize: 30, y: 150 },
				{ fontSize: 40, y: 200 },
				{ fontSize: 50, y: 250 },
				{ fontSize: 60, y: 300 },
				{ fontSize: 70, y: 350 }
			];

			context.fillStyle = "#000000";
			context.textBaseline = "top";

			for ( var pairing of pairings ) {

				context.font = `500 ${ pairing.fontSize }px Roboto`;
				context.fillText( textValue, 50, pairing.y );

			}

		}


		/**
		* I draw the horizontal and vertical grid lines on the canvas so that we can more
		* easily see where the text is aligned on different browsers.
		*/
		function drawGridLines() {

			var step = 10;
			var jump = 50;

			context.lineWidth = 1;
			context.strokeStyle = "#cccccc";

			// Draw GREY horizontal grid lines.
			for ( var i = step ; i < canvasHeight ; i += step ) {

				context.beginPath();
				context.moveTo( 0, ( i - 0.5 ) );
				context.lineTo( canvasWidth, ( i - 0.5 ) );
				context.stroke();

			}
			// Draw GREY vertical grid lines.
			for ( var i = step ; i < canvasWidth ; i += step ) {

				context.beginPath();
				context.moveTo( ( i - 0.5 ), 0 );
				context.lineTo( ( i - 0.5 ), canvasHeight );
				context.stroke();

			}

			context.strokeStyle = "#ff3333";

			// Draw RED horizontal grid lines.
			for ( var i = jump ; i < canvasHeight ; i += jump ) {

				context.beginPath();
				context.moveTo( 0, ( i - 0.5 ) );
				context.lineTo( canvasWidth, ( i - 0.5 ) );
				context.stroke();

			}
			// Draw RED vertical grid lines.
			for ( var i = jump ; i < canvasWidth ; i += jump ) {

				context.beginPath();
				context.moveTo( ( i - 0.5 ), 0 );
				context.lineTo( ( i - 0.5 ), canvasHeight );
				context.stroke();

			}

		}

	</script>

</body>
</html>

As you can see, we're rendering the same line of text to the Canvas at seven different font sizes: 10px, 20px, 30px, 40px, 50px, 60px, and 70px. Each of these lines of text is intended to render just below one of the red grid lines. And, if we render this in Chrome, Firefox, and Safari, we get the following output:

As you can see, each major browser nudges the text in different directions, with Safari being especially egregious in its interpretation of X,Y coordinates. In fact, the "user agent sniffing" that I am doing in production today is based on whether or not the current browser is Safari.

While I was researching for a cleaner way to handle this problem, I came across an article by Rik Schennink on the Pqina blog. In his article, Rik describes an approach in which he draws the letter F at 100px font-size. He then scans the low-level pixel data in both the horizontal and vertical axis in order to locate where the edges of the F were being rendered by the current browser. His approach then uses those findings to calculate an offset coefficient for the browser which he then applies to various font-sizes.

I tried taking his approach and shoe-horning it into my Screen Placeholders feature at InVision. Unfortunately, I couldn't get it to work properly - something was being lost in translation. I believe something was going wrong in how I was taking the "coefficient" and trying to apply it on an @2x scaled-up rendering of the Canvas. As such, I wanted to step back and code the algorithm in isolation.

When I started this, I tried to take Rik's algorithm as-is. But, for whatever reason, I just couldn't get it to work well, even in isolation. It would work fine at one font-size; but then, it would break-down when I tried to apply the same coefficient at a different font-size.

It also seemed to be different for each Font Family. Meaning, the browser rendered sans-serif using different offsets when compared to rendering Roboto or Times New Roman (etc).

Instead of calculating a single coefficient, I decided to just calculate the coefficient individually for every unique font-size and font-face being used. This means more processing overhead; but, it means no guess work or extrapolation - each adjustment offset is targeted at a very specific rendering. And, it still seems to render instantaneously for me on my 2015 MacBook Pro.

To see this in action, let's take previous demo and update it so that it calculates a browser / font-size / font-family specific offset to apply inside our demo for-loop. This is inspired by Rik's approach, but deviates in that I just perform a linear scan of the pixel data, assuming that the first point that I find is the top-left corner of the F character.

What this means is that this is not a general purpose solution! It makes several critical assumptions: notably that the edges of the F character are straight and do not contain any unexpected rises or falls that might cause the linear scan of the pixel data to hit a false-positive.

With that said, here's my approach to normalizing text rendering on the Canvas across different browsers. Note that each iteration of the for-loop calls getTextOffsets(), which creates a non-rendered temporary Canvas element on which to render the character F. The offsets returned from that function are then used to adjust the .fillText() command on the Canvas:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<title>
		Rendering Text To Canvas With Adjusted X,Y Offsets For Better Cross-Browser Consistency
	</title>
	<link rel="preconnect" href="https://fonts.googleapis.com" />
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
	<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap" />
	<style type="text/css">
		body {
			font-family: "Roboto", sans-serif ;
		}
		canvas {
			box-shadow: 0px 0px 0px 2px #000000 ;
		}
	</style>
</head>
<body>

	<h1>
		Rendering Text To Canvas With Adjusted X,Y Offsets For Better Cross-Browser Consistency
	</h1>

	<canvas id="demo" width="400" height="400"></canvas>

	<script type="text/javascript">

		var canvas = document.querySelector( "#demo" );
		var canvasWidth = 400;
		var canvasHeight = 400;
		var context = canvas.getContext( "2d" );

		// We have to given the FONT time to load so that we can use it on the canvas.
		window.addEventListener(
			"load",
			() => {

				drawGridLines();
				drawText( "Frankly, my dear, I don't give a damn." );

			}
		);

		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //

		/**
		* I draw the sample text to canvas at various sizes.
		*/
		function drawText( textValue ) {

			var pairings = [
				{ fontSize: 10, y: 50 },
				{ fontSize: 20, y: 100 },
				{ fontSize: 30, y: 150 },
				{ fontSize: 40, y: 200 },
				{ fontSize: 50, y: 250 },
				{ fontSize: 60, y: 300 },
				{ fontSize: 70, y: 350 }
			];

			context.fillStyle = "#000000";
			context.textBaseline = "top";

			for ( var pairing of pairings ) {

				var font = context.font = `500 ${ pairing.fontSize }px Roboto`;
				var offsets = getTextOffsets( font );
				// NOTE: As we're rendering the text, we're adjusting the X,Y coordinates
				// based on the offsets we just calculated. This will give us a more
				// consistent cross-browser rendering of the text.
				context.fillText(
					textValue,
					( 50 + offsets.x ),
					( pairing.y + offsets.y )
				);

				console.group( "Text Offsets" );
				console.log( "Font:", font );
				console.log( "X:", offsets.x );
				console.log( "Y:", offsets.y );
				console.groupEnd();

			}

		}


		/**
		* I get the X,Y offsets that should be applied to the given font in order to get a
		* better cross-browser rendering of text.
		*/
		function getTextOffsets( font ) {

			// We're going to create a small, non-rendered canvas onto which we will draw
			// the letter "F", which has a hard top-left point. Seeing how far this point
			// is from 0,0 will give us the offset that is being used by this browser for
			// this font at this font-size.
			var tempCanvasWidth = 30;
			var tempCanvasHeight = 50;
			var tempCanvas = document.createElement( "canvas" );
			tempCanvas.setAttribute( "width", tempCanvasWidth );
			tempCanvas.setAttribute( "height", tempCanvasHeight );

			var tempContext = tempCanvas.getContext( "2d" );
			tempContext.fillStyle = "#ffffff";
			tempContext.textBaseline = "top";
			tempContext.font = font;
			tempContext.fillText( "F", 0, 0 );

			var imageData = tempContext.getImageData( 0, 0, tempCanvasWidth, tempCanvasHeight );
			var pixelData = imageData.data;
			// The pixel data for the canvas is stored as a linear series of R,G,B,A
			// readings. Which means, each pixel consumes 4 indices in the data array;
			// hence the concept of a "pixel width".
			var pixelWidth = 4;
			// When the text is rendered to the canvas, it is anti-aliased, which means
			// that it has soft, partially-transparent edges. As we're scanning for
			// pixels within the pixel data, we want to skip over "mostly transparent"
			// pixels so that we can find a nice, dark pixel that better represents the
			// visual edge of the text glyph.
			var alphaCutoff = 127;

			// CAUTION: This is NOT A GENERAL PURPOSE approach. This is working based on
			// several assumptions: that the font is using a SANS-SERIF face and that the
			// test letter, "F", has no unexpected rising or falling in either the
			// vertical or the horizontal axis. What this means is that as we scan the
			// liner pixel data, the first "strong" pixel (ie, a pixel that crosses the
			// non-transparent threshold) that we find should represent BOTH the X AND Y
			// delta between the origin point and where the browser is rendering the text
			// characters.
			for ( var i = 0 ; i < pixelData.length ; i += pixelWidth ) {

				// Check the A threshold (of R,G,B,A), which is the last reading in the
				// pixel tuple.
				if ( pixelData[ i + pixelWidth - 1 ] > alphaCutoff ) {

					// Since the pixel data is one linear series of readings, we have to
					// convert the linear offset into a set of X,Y offsets.
					var x = ( ( i / pixelWidth ) % tempCanvasWidth );
					var y = Math.floor( i / pixelWidth / tempCanvasWidth );

					return({
						x: -x,
						y: -y
					});

				}

			}

			// If we found no pixel data (maybe the font was SO LARGE that it actually
			// didn't render on our small, temporary canvas), just default to zero.
			return({
				x: 0,
				y: 0
			});

		}


		/**
		* I draw the horizontal and vertical grid lines on the canvas so that we can more
		* easily see where the text is aligned on different browsers.
		*/
		function drawGridLines() {

			var step = 10;
			var jump = 50;

			context.lineWidth = 1;
			context.strokeStyle = "#cccccc";

			// Draw GREY horizontal grid lines.
			for ( var i = step ; i < canvasHeight ; i += step ) {

				context.beginPath();
				context.moveTo( 0, ( i - 0.5 ) );
				context.lineTo( canvasWidth, ( i - 0.5 ) );
				context.stroke();

			}
			// Draw GREY vertical grid lines.
			for ( var i = step ; i < canvasWidth ; i += step ) {

				context.beginPath();
				context.moveTo( ( i - 0.5 ), 0 );
				context.lineTo( ( i - 0.5 ), canvasHeight );
				context.stroke();

			}

			context.strokeStyle = "#ff3333";

			// Draw RED horizontal grid lines.
			for ( var i = jump ; i < canvasHeight ; i += jump ) {

				context.beginPath();
				context.moveTo( 0, ( i - 0.5 ) );
				context.lineTo( canvasWidth, ( i - 0.5 ) );
				context.stroke();

			}
			// Draw RED vertical grid lines.
			for ( var i = jump ; i < canvasWidth ; i += jump ) {

				context.beginPath();
				context.moveTo( ( i - 0.5 ), 0 );
				context.lineTo( ( i - 0.5 ), canvasHeight );
				context.stroke();

			}

		}

	</script>

</body>
</html>

I'm not very experienced with the Canvas API; so, I won't go into it in any detail. I tried to leave a lot of comments in the code both explaining the approach to You and Me. Hopefully, I didn't biff it too hard on any of the details.

But now, if we run this Canvas text demo in Chrome, Firefox, and Safari, we get the following output:

As you can see, it's not 100% pixel-perfect consistency in each browser; but, they more-or-less look exactly the same. And, when there's no grid-lines to highlight the differences, any inconsistencies should fade away.

Now that I have consistent text rendering on the Canvas working in isolation in Chrome, Firefox, and Safari, the next step is to get this working in production. But, now that I'm recalculating the text-offsets for each rendering of text, I'm feeling confident that I'll get it work this time!

Want to use code from this post? Check out the license.

Reader Comments

15,674 Comments

@Hassam,

Agreed! Way too much work for something that feels like it should just work out-of-the-box. I'd be curious to see how the html2canvas library handles it. That library can reproduce a more-or-less pixel-perfect screenshot of HTML by recreating it on Canvas. They must have to take this stuff into account. It's a pretty huge library; I'll try to poke around and see if I can find where they might do this.

15,674 Comments

@Hassam,

I was just talking to Jan Sedivy, who's the lead engineer on the Freehand project, and he was saying they solved this problem by using the alphabetic text baseline. Apparently, this is more consistent across browsers, and more consistent with the way the browser handles it natively in the DOM-rendering. I'll have to play around with that as well.

Looking at the html2canvas library, it looks like they use alphabetic as well. So, it must be a more consistent approach.

15,674 Comments

@Hassam,

Hmmm, yeah. For me, when I was designing some algorithms around rendering text, it was just easier to think about "top" as an approach. But, perhaps it will be easy to change the way I think about the text layout.

21 Comments

I think it's a good solution apart from that it's hard to reason about text from bottom in xy. I will bookmark this post 😀 in case I need it.

15,674 Comments

@Hassam,

Totally agree. And DUDE!!! You're the first person to use the "reply to" functionality to post a comment via email! Outstanding! 🔥🔥🔥

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel