Don't Test Fonts

Published on Sunday, December 13th, 2020

Recently for Excalibur I found myself in need of testing text rendering on the canvas, and found myself in a whole world of hurt 🤕. I was unaware of all of the pain around font rendering differences on different platforms.

The Problems

TLDR the code and the jasmine matcher

Use a specific font

Loading a specific open font is probably your best bet, relying on system fonts existing will not be consistent across platforms. I used the google fonts to find a font with a compatible license for me, in this example I used "Open Sans"

https://fonts.google.com/specimen/Open+Sans

Let's disect the google font link quick.

The first link is this preconnect to the google cdn where the actual font files are stored. preconnect is a hint to the browser to start opening a connection to that domain so that it's ready to accept requests for fonts as soon as possible.

<link rel="preconnect" href="https://fonts.gstatic.com">

The second link is a style sheet that defines the CSS font-face for the font.

<link href="https://fonts.googleapis.com/css2?family=Open+Sans&display=swap" rel="stylesheet">

Popping that link into the browser you get something like below. Notice it's referencing the font CDN to grab the font files we mentioned earlier.

/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v18/mem8YaGs126MiZpBA-UFVZ0b.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
...

Fix the Flash of Unstyled Text

After loading your fonts, there is a period of time before they are loaded preventing consistent tests.

There is a new browser api FontFaceSet that works most places you'll want to test in.

The FontFaceSet.load() forces a specific font to be loaded and returns a promise when it's done. This needs to be a fairly specific font string to work, including the the size, style, and font you want to load. ProTiptm you must specify italic or bold explicitly if you want to wait for it to load.

await document.fonts.load('18px Open Sans');
await document.fonts.load('italic 18px Open Sans');
await document.fonts.load('bold 18px Open Sans');

For some reason the load() API doesn't seem to guarantee that the font is also "available". You must also check() that the font has been loaded and is available, but doesn't initiate a load itself.

if (document.fonts.check('18px Open Sans')) {
...
}

With all of that here is the final helper to wait for a font, sourced from this SO post

async function waitForFontLoad(font, timeout = 2000, interval = 100) {
return new Promise((resolve, reject) => {
// repeatedly poll check
const poller = setInterval(async () => {
try {
await document.fonts.load(font);
} catch (err) {
reject(err);
}
if (document.fonts.check(font)) {
clearInterval(poller);
resolve(true);
}
}, interval);
setTimeout(() => clearInterval(poller), timeout);
});
}

Putting it together

If you are using text in the dom, one trick is to use a marker CSS class on the document like font-loaded to control display.

async function main() {
await waitForFontLoad('18px Open Sans');
await waitForFontLoad('bold 18px Open Sans');
await waitForFontLoad('italic 18px Open Sans');
document.body.classList.add('font-loaded');
}
main();
.text {
display: none;
font-family: 'Open Sans';
}

.font-loaded .text {
display: block;
}

.text.italic {
font-style: italic;
}

.text.bold {
font-weight: bold;
}

This is great for testing! We can know if the font is present before running our test or drawing text in the canvas!

However, this is not great visually if you inflict this on users 😢.

Brief aside, here are a few options to fix this visual if you want to load fonts like this for people:

Writing the tests

If you're in jasmine, they support running async functions in your beforeAll, this means we can wait for things before starting our tests.

I recommend downloading the font files from google fonts and making your own fonts.css for the faces and characters you care about. I used the google sheet https://fonts.googleapis.com/css2?family=Open+Sans&display=swap as a reference. Some CI platforms won't allow fonts to be loaded from an outside link (like AppVeyor).

describe('My Text', () => {

beforeAll(async () => {
// Insert font into DOM
// Fonts are stored in the locally in the project
// path/to/my/font-normal.woff
const fontfaces = document.createElement('link');
fontfaces.href =
'path/to/my/fonts.css';
fontfaces.rel = 'stylesheet';
document.head.appendChild(fontfaces);

// Wait for all font flavors to be available before starting the tests
await document.fonts.load('18px Open Sans');
await document.fonts.load('bold 18px Open Sans');
await document.fonts.load('italic bold 18px Open Sans');
await document.fonts.load('italic 18px Open Sans');
});
});

Supporting different platforms

If you are doing pixel diffing (like me) the same font will not render exactly the same on each platform. So I built a couple helpers that take advantage of navigator.platform. Normally I would NOT recommend using navigator.platform for production code, but with CI I have control of the environment.

async function runOnWindows(asyncCtx) {
if (navigator.platform === 'Win32' || navigator.platform === 'Win64') {
await ctx();
return true;
}
return false;
}

async function runOnLinux(asyncCtx) {
if (navigator.platform.includes('Linux')) {
await ctx();
return true;
}
return false;
}

I'm using a very basic image matcher built for excalibur we implemented that can compare a canvas pixel by pixel with an expected image.

import { ExcaliburAsyncMatchers } from 'excalibur-jasmine';

...

beforeEach(() => {
jasmine.addAsyncMatchers(ExcaliburAsyncMatchers);
});

it('should match images', () => {
const canvasElement = document.createElement('canvas');
const ctx = canvasElement.getContext('2d');
ctx.font = 'bold 18px Open Sans';
ctx.fillText('Some Text');

...
await runOnWindows(async () => {
await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/text.png');
});

await runOnLinux(async () => {
await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/text-linux.png');
});
})

This outputs results as base64 to the console, not the best but works in all the CI platforms and can be copied to a browser to compare results.

For a more full featured image diffing in your tests I'd look into pixelmatch.

Hopes this helps you text and font peeps!

Here is the code, jasmine matcher, and the excalibur example

-Erik