At InVision, I just lately added the flexibility for any sort of person to return in and generate a “placeholder” display screen (see Video demo on LinkedIn). These placeholder screens are generated, partially, by rendering textual content to Canvas. As I quickly found, rendering textual content at (0,0)
coordinates means various things to every browser. As such, I needed to barely modify the placement of the rendered textual content within the varied browsers. Proper now, I am doing this with some “person agent sniffing”; however, I might wish to evolve my method to be extra programmatic and fewer heavy-handed. And, to do this, I’ve render-and-detect the inconsistent offsets being utilized by every browser at runtime.
Run this demo in my JavaScript Demos venture on GitHub.
View this code in my JavaScript Demos venture on GitHub.
First, let’s take a look at the what I imply by “inconsistent rendering” of textual content. To display, I will render a grid of traces to a Canvas. After which, I will render a sequence of textual content values at totally different sizes and totally different X,Y coordinates. The grid traces ought to assist us extra simply see how every browser nudges the textual content in every axis:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta identify="viewport" content material="width=device-width, initial-scale=1" />
<title>
Rendering Textual content To Canvas With Default Offsets
</title>
<hyperlink rel="preconnect" href="https://fonts.googleapis.com" />
<hyperlink rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<hyperlink rel="stylesheet" href="https://fonts.googleapis.com/css2?household=Roboto:wght@500" />
<model sort="textual content/css">
physique {
font-family: "Roboto", sans-serif ;
}
canvas {
box-shadow: 0px 0px 0px 2px #000000 ;
}
</model>
</head>
<physique>
<h1>
Rendering Textual content To Canvas With Default Offsets
</h1>
<canvas width="400" top="400"></canvas>
<script sort="textual content/javascript">
var canvas = doc.querySelector( "canvas" );
var canvasWidth = 400;
var canvasHeight = 400;
var context = canvas.getContext( "second" );
// We've to given the FONT time to load in order that we will apply it to the canvas.
window.addEventListener(
"load",
() => {
drawGridLines();
drawText( "Frankly, my pricey, I do not give a rattling." );
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
/**
* I draw the pattern textual content to canvas at varied sizes.
*/
perform 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 = "prime";
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 traces on the canvas in order that we will extra
* simply see the place the textual content is aligned on totally different browsers.
*/
perform drawGridLines() {
var step = 10;
var soar = 50;
context.lineWidth = 1;
context.strokeStyle = "#cccccc";
// Draw GREY horizontal grid traces.
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 traces.
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 traces.
for ( var i = soar ; i < canvasHeight ; i += soar ) {
context.beginPath();
context.moveTo( 0, ( i - 0.5 ) );
context.lineTo( canvasWidth, ( i - 0.5 ) );
context.stroke();
}
// Draw RED vertical grid traces.
for ( var i = soar ; i < canvasWidth ; i += soar ) {
context.beginPath();
context.moveTo( ( i - 0.5 ), 0 );
context.lineTo( ( i - 0.5 ), canvasHeight );
context.stroke();
}
}
</script>
</physique>
</html>
As you possibly can see, we’re rendering the identical line of textual content to the Canvas at seven totally different font sizes: 10px
, 20px
, 30px
, 40px
, 50px
, 60px
, and 70px
. Every of those traces of textual content is supposed to render just under one of many pink grid traces. And, if we render this in Chrome, Firefox, and Safari, we get the next output:
As you possibly can see, every main browser nudges the textual content in numerous instructions, with Safari being particularly egregious in its interpretation of X,Y coordinates. In reality, the “person agent sniffing” that I’m doing in manufacturing right this moment relies on whether or not or not the present browser is Safari.
Whereas I used to be researching for a cleaner method to deal with this drawback, I got here throughout an article by Rik Schennink on the Pqina weblog. In his article, Rik describes an method during which he attracts the letter F
at 100px
font-size. He then scans the low-level pixel knowledge in each the horizontal and vertical axis with a view to find the place the sides of the F
had been being rendered by the present browser. His method then makes use of these findings to calculate an offset coefficient for the browser which he then applies to numerous font-sizes.
I attempted taking his method and shoe-horning it into my Display Placeholders characteristic at InVision. Sadly, I could not get it to work correctly – one thing was being misplaced in translation. I consider one thing was going incorrect in how I used to be taking the “coefficient” and attempting to use it on an @2x
scaled-up rendering of the Canvas. As such, I wished to step again and code the algorithm in isolation.
After I began this, I attempted to take Rik’s algorithm as-is. However, for no matter purpose, I simply could not get it to work effectively, even in isolation. It could work advantageous at one font-size; however then, it might break-down after I tried to use the identical coefficient at a unique font-size.
It additionally gave the impression to be totally different for every Font Household. That means, the browser rendered sans-serif
utilizing totally different offsets when in comparison with rendering Roboto
or Instances New Roman
(and many others).
As a substitute of calculating a single coefficient, I made a decision to simply calculate the coefficient individually for each distinctive font-size and font-face getting used. This implies extra processing overhead; however, it means no guess work or extrapolation – every adjustment offset is focused at a really particular rendering. And, it nonetheless appears to render instantaneously for me on my 2015 MacBook Professional.
To see this in motion, let’s take earlier demo and replace it in order that it calculates a browser / font-size / font-family particular offset to use inside our demo for
-loop. That is impressed by Rik’s method, however deviates in that I simply carry out a linear scan of the pixel knowledge, assuming that the primary level that I discover is the top-left nook of the F
character.
What this implies is that that is not a common objective resolution! It makes a number of vital assumptions: notably that the sides of the F
character are straight and don’t include any sudden rises or falls which may trigger the linear scan of the pixel knowledge to hit a false-positive.
With that mentioned, here is my method to normalizing textual content rendering on the Canvas throughout totally different browsers. Be aware that every iteration of the for
-loop calls getTextOffsets()
, which creates a non-rendered non permanent Canvas component on which to render the character F
. The offsets returned from that perform are then used to regulate the .fillText()
command on the Canvas:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta identify="viewport" content material="width=device-width, initial-scale=1" />
<title>
Rendering Textual content To Canvas With Adjusted X,Y Offsets For Higher Cross-Browser Consistency
</title>
<hyperlink rel="preconnect" href="https://fonts.googleapis.com" />
<hyperlink rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<hyperlink rel="stylesheet" href="https://fonts.googleapis.com/css2?household=Roboto:wght@500&show=swap" />
<model sort="textual content/css">
physique {
font-family: "Roboto", sans-serif ;
}
canvas {
box-shadow: 0px 0px 0px 2px #000000 ;
}
</model>
</head>
<physique>
<h1>
Rendering Textual content To Canvas With Adjusted X,Y Offsets For Higher Cross-Browser Consistency
</h1>
<canvas id="demo" width="400" top="400"></canvas>
<script sort="textual content/javascript">
var canvas = doc.querySelector( "#demo" );
var canvasWidth = 400;
var canvasHeight = 400;
var context = canvas.getContext( "second" );
// We've to given the FONT time to load in order that we will apply it to the canvas.
window.addEventListener(
"load",
() => {
drawGridLines();
drawText( "Frankly, my pricey, I do not give a rattling." );
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
/**
* I draw the pattern textual content to canvas at varied sizes.
*/
perform 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 = "prime";
for ( var pairing of pairings ) {
var font = context.font = `500 ${ pairing.fontSize }px Roboto`;
var offsets = getTextOffsets( font );
// NOTE: As we're rendering the textual content, we're adjusting the X,Y coordinates
// primarily based on the offsets we simply calculated. It will give us a extra
// constant cross-browser rendering of the textual content.
context.fillText(
textValue,
( 50 + offsets.x ),
( pairing.y + offsets.y )
);
console.group( "Textual content Offsets" );
console.log( "Font:", font );
console.log( "X:", offsets.x );
console.log( "Y:", offsets.y );
console.groupEnd();
}
}
/**
* I get the X,Y offsets that must be utilized to the given font with a view to get a
* higher cross-browser rendering of textual content.
*/
perform getTextOffsets( font ) {
// We will create a small, non-rendered canvas onto which we'll draw
// the letter "F", which has a tough top-left level. Seeing how far this level
// is from 0,0 will give us the offset that's being utilized by this browser for
// this font at this font-size.
var tempCanvasWidth = 30;
var tempCanvasHeight = 50;
var tempCanvas = doc.createElement( "canvas" );
tempCanvas.setAttribute( "width", tempCanvasWidth );
tempCanvas.setAttribute( "top", tempCanvasHeight );
var tempContext = tempCanvas.getContext( "second" );
tempContext.fillStyle = "#ffffff";
tempContext.textBaseline = "prime";
tempContext.font = font;
tempContext.fillText( "F", 0, 0 );
var imageData = tempContext.getImageData( 0, 0, tempCanvasWidth, tempCanvasHeight );
var pixelData = imageData.knowledge;
// The pixel knowledge for the canvas is saved as a linear sequence of R,G,B,A
// readings. Which implies, every pixel consumes 4 indices within the knowledge array;
// therefore the idea of a "pixel width".
var pixelWidth = 4;
// When the textual content is rendered to the canvas, it's anti-aliased, which implies
// that it has gentle, partially-transparent edges. As we're scanning for
// pixels throughout the pixel knowledge, we wish to skip over "principally clear"
// pixels in order that we will discover a good, darkish pixel that higher represents the
// visible fringe of the textual content glyph.
var alphaCutoff = 127;
// CAUTION: That is NOT A GENERAL PURPOSE method. That is working primarily based on
// a number of assumptions: that the font is utilizing a SANS-SERIF face and that the
// check letter, "F", has no sudden rising or falling in both the
// vertical or the horizontal axis. What this implies is that as we scan the
// liner pixel knowledge, the primary "robust" pixel (ie, a pixel that crosses the
// non-transparent threshold) that we discover ought to characterize BOTH the X AND Y
// delta between the origin level and the place the browser is rendering the textual content
// characters.
for ( var i = 0 ; i < pixelData.size ; i += pixelWidth ) {
// Examine the A threshold (of R,G,B,A), which is the final studying within the
// pixel tuple.
if ( pixelData[ i + pixelWidth - 1 ] > alphaCutoff ) {
// Because the pixel knowledge is one linear sequence of readings, we now have to
// convert the linear offset right into a set of X,Y offsets.
var x = ( ( i / pixelWidth ) % tempCanvasWidth );
var y = Math.flooring( i / pixelWidth / tempCanvasWidth );
return({
x: -x,
y: -y
});
}
}
// If we discovered no pixel knowledge (perhaps the font was SO LARGE that it truly
// did not render on our small, non permanent canvas), simply default to zero.
return({
x: 0,
y: 0
});
}
/**
* I draw the horizontal and vertical grid traces on the canvas in order that we will extra
* simply see the place the textual content is aligned on totally different browsers.
*/
perform drawGridLines() {
var step = 10;
var soar = 50;
context.lineWidth = 1;
context.strokeStyle = "#cccccc";
// Draw GREY horizontal grid traces.
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 traces.
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 traces.
for ( var i = soar ; i < canvasHeight ; i += soar ) {
context.beginPath();
context.moveTo( 0, ( i - 0.5 ) );
context.lineTo( canvasWidth, ( i - 0.5 ) );
context.stroke();
}
// Draw RED vertical grid traces.
for ( var i = soar ; i < canvasWidth ; i += soar ) {
context.beginPath();
context.moveTo( ( i - 0.5 ), 0 );
context.lineTo( ( i - 0.5 ), canvasHeight );
context.stroke();
}
}
</script>
</physique>
</html>
I am not very skilled with the Canvas API; so, I will not go into it in any element. I attempted to go away numerous feedback within the code each explaining the method to You and Me. Hopefully, I did not biff it too exhausting on any of the main points.
However now, if we run this Canvas textual content demo in Chrome, Firefox, and Safari, we get the next output:
As you possibly can see, it is not 100% pixel-perfect consistency in every browser; however, they more-or-less look precisely the identical. And, when there isn’t any grid-lines to spotlight the variations, any inconsistencies ought to fade away.
Now that I’ve constant textual content rendering on the Canvas working in isolation in Chrome, Firefox, and Safari, the following step is to get this working in manufacturing. However, now that I am recalculating the text-offsets for every rendering of textual content, I am feeling assured that I will get it work this time!
Need to use code from this publish?
Try the license.