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
- Finding a font shared by all platforms is hard, web safe fonts get you close sometimes
- Platforms Windows/Linux/MacOS/Mobile render the exact same font slightly differently
- Flash of unstyled text, ensuring a font is available for rendering
- Writing tests for text in a font
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:
-
Block loading the WHOLE site until fonts are ready (not recommended, it's better to show your users something).
-
Add placeholder rectangles while things are loading. This can work great if you need to load other data that'll take some time. Or if the text is being loaded from an endpoint.
.text {
font-family: 'Open Sans';
display: inline-block;
color: transparent;
background-color: #ccc;
border: solid #ccc;
border-radius: 3px;
}
.font-loaded .text {
border: none;
border-radius: none;
background-color: transparent;
color: black;
} -
My favorite, some CSS animations to fade in as things load
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
.font-loaded .text {
display: block;
animation: fadeIn ease-in-out .2s
}
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
Help support me on Github Sponsors or Patreon
Become a Patron!