Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Font Fallback #1833

Closed
wants to merge 10 commits into from
19 changes: 16 additions & 3 deletions packages/font/src/font.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const FONT_WEIGHTS = {
black: 900,
};

const ALL_CHARS_REGEX = /./u;

const fetchFont = async (src, options) => {
const response = await fetch(src, options);
const data = await response.arrayBuffer();
Expand All @@ -43,11 +45,15 @@ const resolveFontWeight = value => {
const sortByFontWeight = (a, b) => a.fontWeight - b.fontWeight;

class FontSource {
constructor(src, fontFamily, fontStyle, fontWeight, options) {
constructor(src, fontFamily, fontStyle, fontWeight, unicodeRange, options) {
this.src = src;
this.fontFamily = fontFamily;
this.fontStyle = fontStyle || 'normal';
this.fontWeight = fontWeight || 400;
this.unicodeRange = new RegExp(
unicodeRange instanceof RegExp ? unicodeRange : ALL_CHARS_REGEX,
'gu',
);

this.data = null;
this.options = options;
Expand Down Expand Up @@ -92,11 +98,18 @@ class Font {
this.sources = [];
}

register({ src, fontWeight, fontStyle, ...options }) {
register({ src, fontWeight, fontStyle, unicodeRange, ...options }) {
const numericFontWeight = resolveFontWeight(fontWeight);

this.sources.push(
new FontSource(src, this.family, fontStyle, numericFontWeight, options),
new FontSource(
src,
this.family,
fontStyle,
numericFontWeight,
unicodeRange,
options,
),
);
}

Expand Down
37 changes: 27 additions & 10 deletions packages/font/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import font from './font';
import Font from './font';
import standard from './standard';

function FontStore() {
Expand All @@ -12,13 +12,15 @@ function FontStore() {
const { family } = data;

if (!fonts[family]) {
fonts[family] = font.create(family);
fonts[family] = Font.create(family);
}

// Bulk loading
if (data.fonts) {
for (let i = 0; i < data.fonts.length; i += 1) {
fonts[family].register({ family, ...data.fonts[i] });
const defaultValues = { ...data };
delete defaultValues.fonts;

fonts[family].register({ ...defaultValues, ...data.fonts[i] });
}
} else {
fonts[family].register(data);
Expand Down Expand Up @@ -48,16 +50,31 @@ function FontStore() {
return fonts[fontFamily].resolve(descriptor);
};

this.load = async descriptor => {
this.load = async (descriptor, text = '') => {
const { fontFamily } = descriptor;
const isStandard = standard.includes(fontFamily);
const fontFamilies =
typeof fontFamily === 'string'
? fontFamily.split(',').map(family => family.trim())
: [...(fontFamily || [])];

const promises = [];

for (let len = fontFamilies.length, i = 0; i < len; i += 1) {
const family = fontFamilies[i];

if (isStandard) return;
const isStandard = standard.includes(family);
if (isStandard) return;

const f = this.getFont(descriptor);
const font = this.getFont({ ...descriptor, fontFamily: family });

const didMatch = !font.unicodeRange || font.unicodeRange.test(text);

if (didMatch) {
promises.push(font.load());
}
}

// We cache the font to avoid fetching it many times
await f.load();
await Promise.all(promises);
};

this.reset = () => {
Expand Down
137 changes: 137 additions & 0 deletions packages/font/tests/font.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import Font from '../src/font';

// should load the font only once (no race condition)
// should not load loading font
// should not load loaded font

describe('Font', () => {
test('Should register font', async () => {
const font = Font.create('MyFont1');

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'normal',
unicodeRange: /\u0048/gu,
});

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'bold',
unicodeRange: /\u0048/gu,
});

expect(font.sources).toHaveLength(2);
expect(font.sources[0].fontFamily).toBe('MyFont1');
expect(font.sources[0].src).toBe('');
expect(font.sources[0].fontWeight).toBe(400);
expect(font.sources[0].unicodeRange.source).toBe(/\u0048/gu.source);

expect(font.sources).toHaveLength(2);
expect(font.sources[1].fontFamily).toBe('MyFont1');
expect(font.sources[1].src).toBe('');
expect(font.sources[1].fontWeight).toBe(700);
expect(font.sources[1].unicodeRange.source).toBe(/\u0048/gu.source);
});

test('Should resolve font to closest weight', async () => {
const font = Font.create('MyFont1');

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'normal',
unicodeRange: /\u0048/gu,
});

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'bold',
unicodeRange: /\u0048/gu,
});
font.register({
family: 'MyFont1',
src: '',
fontWeight: 'thin',
unicodeRange: /\u0048/gu,
});

expect(font.resolve({ fontWeight: 700 })).toBe(font.sources[1]);
expect(font.resolve({ fontWeight: 400 })).toBe(font.sources[0]);

expect(font.resolve({ fontWeight: 100 })).toBe(font.sources[2]);
expect(font.resolve({ fontWeight: 300 })).toBe(font.sources[2]);
expect(font.resolve({ fontWeight: 200 })).toBe(font.sources[2]);
});
});

const mockLoadFont = jest.fn(async function() {
this.data = {};
});
describe('FontSource', () => {
beforeEach(() => {
mockLoadFont.mockClear();
});

test('Should only try to load font once', async () => {
const font = Font.create('MyFont1');

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'normal',
unicodeRange: /\u0048/gu,
});

const fontResolved = font.resolve({ fontWeight: 400 });
fontResolved.loadFont = mockLoadFont;

await Promise.all([
fontResolved.load(),
fontResolved.load(),
fontResolved.load(),
fontResolved.load(),
fontResolved.load(),
]);
expect(fontResolved.loadFont).toBeCalledTimes(1);
});

test('All calls should await font to be loaded', async () => {
const font = Font.create('MyFont1');

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'normal',
unicodeRange: /\u0048/gu,
});

const fontResolved = font.resolve({ fontWeight: 400 });
fontResolved.loadFont = mockLoadFont;
const promise1 = fontResolved.load();
const promise2 = fontResolved.load();

expect(promise1).toStrictEqual(promise2);
await expect(promise1).resolves.toBe(await promise2);
});

test('Should not load already loaded font', async () => {
const font = Font.create('MyFont1');

font.register({
family: 'MyFont1',
src: '',
fontWeight: 'normal',
unicodeRange: /\u0048/gu,
});

const fontResolved = font.resolve({ fontWeight: 400 });
fontResolved.loadFont = mockLoadFont;
await fontResolved.load();
await fontResolved.load();

expect(fontResolved.loadFont).toBeCalledTimes(1);
});
});
Loading