From 42af2b220b6379cc475b7d79b896960947098c14 Mon Sep 17 00:00:00 2001 From: ahmed-novalabs <91528256+ahmed-novalabs@users.noreply.github.com> Date: Sat, 28 May 2022 18:44:14 +0300 Subject: [PATCH 1/6] Add Font Fallback --- packages/font/src/font.js | 19 ++++++-- packages/font/src/index.js | 43 +++++++++++++++---- packages/layout/src/steps/resolveAssets.js | 16 ++++--- packages/layout/src/svg/layoutText.js | 33 +++++++++++--- packages/layout/src/text/fontSubstitution.js | 30 ++++++++----- .../layout/src/text/getAttributedString.js | 32 ++++++++++++-- packages/layout/src/text/ignoreChars.js | 15 +++++-- .../src/engines/fontSubstitution/index.js | 2 +- .../textkit/src/layout/applyDefaultStyles.js | 2 +- .../tests/engines/fontSubstitution.test.js | 8 ++-- .../tests/layout/applyDefaultStyles.test.js | 4 +- 11 files changed, 153 insertions(+), 51 deletions(-) diff --git a/packages/font/src/font.js b/packages/font/src/font.js index 455553b64..b497fe753 100644 --- a/packages/font/src/font.js +++ b/packages/font/src/font.js @@ -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); @@ -46,11 +48,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.loading = false; @@ -93,11 +99,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, + ), ); } diff --git a/packages/font/src/index.js b/packages/font/src/index.js index e3fa8a381..e28592112 100644 --- a/packages/font/src/index.js +++ b/packages/font/src/index.js @@ -14,11 +14,13 @@ function FontStore() { if (!fonts[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); @@ -48,18 +50,41 @@ 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 = []; + + let remainingChars = text; + + 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 f = this.getFont({ ...descriptor, fontFamily: family }); - // We cache the font to avoid fetching it many times - if (!f.data && !f.loading) { - await f.load(); + const lengthBeforeReplace = remainingChars.length; + remainingChars = remainingChars.replace(f.unicodeRange, ''); + + const didReplace = lengthBeforeReplace !== remainingChars.length; + + if (didReplace) { + if (!f.data && !f.loading) { + promises.push(f.load()); + } + } + if (!remainingChars.length) { + break; + } } + + await Promise.all(promises); }; this.reset = () => { diff --git a/packages/layout/src/steps/resolveAssets.js b/packages/layout/src/steps/resolveAssets.js index cd368d997..45c3359b9 100644 --- a/packages/layout/src/steps/resolveAssets.js +++ b/packages/layout/src/steps/resolveAssets.js @@ -14,31 +14,33 @@ const isImage = R.propEq('type', P.Image); */ const fetchAssets = (fontStore, node) => { const promises = []; - const listToExplore = node.children?.slice(0) || []; + const listToExplore = node.children ? node.children.map(n => [n, {}]) : []; + const emojiSource = fontStore ? fontStore.getEmojiSource() : null; while (listToExplore.length > 0) { - const n = listToExplore.shift(); + const [n, { parentStyle = {} }] = listToExplore.shift(); if (isImage(n)) { promises.push(fetchImage(n)); } - if (fontStore && n.style?.fontFamily) { - promises.push(fontStore.load(n.style)); - } - if (typeof n === 'string') { promises.push(...fetchEmojis(n, emojiSource)); + promises.push(fontStore.load(parentStyle, n)); } if (typeof n.value === 'string') { promises.push(...fetchEmojis(n.value, emojiSource)); + promises.push(fontStore.load(n.style || parentStyle, n.value)); } if (n.children) { n.children.forEach(childNode => { - listToExplore.push(childNode); + listToExplore.push([ + childNode, + { parentStyle: { ...parentStyle, ...n.style } }, + ]); }); } } diff --git a/packages/layout/src/svg/layoutText.js b/packages/layout/src/svg/layoutText.js index 3049b8c17..2d5ae3b9a 100644 --- a/packages/layout/src/svg/layoutText.js +++ b/packages/layout/src/svg/layoutText.js @@ -22,6 +22,28 @@ const engines = { textDecoration: decorationEngine, }; +const toFontNameStack = (...fontFamilyObjects) => + fontFamilyObjects + .map(fontFamilies => + typeof fontFamilies === 'string' + ? fontFamilies.split(',').map(f => f.trim()) + : Array.from(fontFamilies || []), + ) + .flat() + .reduce( + (fonts, font) => (fonts.includes(font) ? fonts : [...fonts, font]), + [], + ); + +const getFontStack = (fontStore, { fontFamily, fontStyle, fontWeight }) => + toFontNameStack(fontFamily).map(fontFamilyName => { + if (typeof fontFamilyName !== 'string') return fontFamilyName; + + const opts = { fontFamily: fontFamilyName, fontWeight, fontStyle }; + const obj = fontStore ? fontStore.getFont(opts) : null; + return obj ? obj.data : fontFamilyName; + }); + const engine = layoutEngine(engines); const getFragments = (fontStore, instance) => { @@ -42,13 +64,14 @@ const getFragments = (fontStore, instance) => { opacity, } = instance.props; - const obj = fontStore - ? fontStore.getFont({ fontFamily, fontWeight, fontStyle }) - : null; - const font = obj ? obj.data : fontFamily; + const fontStack = getFontStack(fontStore, { + fontFamily, + fontStyle, + fontWeight, + }); const attributes = { - font, + fontStack, opacity, fontSize, color: fill, diff --git a/packages/layout/src/text/fontSubstitution.js b/packages/layout/src/text/fontSubstitution.js index fa8075258..210c4ceac 100644 --- a/packages/layout/src/text/fontSubstitution.js +++ b/packages/layout/src/text/fontSubstitution.js @@ -19,11 +19,21 @@ const getOrCreateFont = name => { const getFallbackFont = () => getOrCreateFont('Helvetica'); -const shouldFallbackToFont = (codePoint, font) => - !font || - (!IGNORED_CODE_POINTS.includes(codePoint) && - !font.hasGlyphForCodePoint(codePoint) && - getFallbackFont().hasGlyphForCodePoint(codePoint)); +const pickFontFromFontStack = (codePoint, fontStack) => { + const fontStackWithFallback = [...fontStack, getFallbackFont()]; + for (let i = 0; i < fontStackWithFallback.length; i += 1) { + const font = fontStackWithFallback[i]; + if ( + !IGNORED_CODE_POINTS.includes(codePoint) && + font && + font.hasGlyphForCodePoint && + font.hasGlyphForCodePoint(codePoint) + ) { + return font; + } + } + return getFallbackFont(); +}; const fontSubstitution = () => ({ string, runs }) => { let lastFont = null; @@ -37,9 +47,9 @@ const fontSubstitution = () => ({ string, runs }) => { const run = runs[i]; const defaultFont = - typeof run.attributes.font === 'string' - ? getOrCreateFont(run.attributes.font) - : run.attributes.font; + run.attributes.fontStack?.[0] === 'string' + ? getOrCreateFont(run.attributes.fontStack[0]) + : run.attributes.fontStack[0]; if (string.length === 0) { res.push({ start: 0, end: 0, attributes: { font: defaultFont } }); @@ -47,13 +57,11 @@ const fontSubstitution = () => ({ string, runs }) => { } const chars = string.slice(run.start, run.end); - for (let j = 0; j < chars.length; j += 1) { const char = chars[j]; const codePoint = char.codePointAt(); - const shouldFallback = shouldFallbackToFont(codePoint, defaultFont); // If the default font does not have a glyph and the fallback font does, we use it - const font = shouldFallback ? getFallbackFont() : defaultFont; + const font = pickFontFromFontStack(codePoint, run.attributes.fontStack); const fontSize = getFontSize(run); // If anything that would impact res has changed, update it diff --git a/packages/layout/src/text/getAttributedString.js b/packages/layout/src/text/getAttributedString.js index 8e6d78c7e..73f76fce4 100644 --- a/packages/layout/src/text/getAttributedString.js +++ b/packages/layout/src/text/getAttributedString.js @@ -14,6 +14,28 @@ const isImage = isType(P.Image); const isTextInstance = isType(P.TextInstance); +const toFontNameStack = (...fontFamilyObjects) => + fontFamilyObjects + .map(fontFamilies => + typeof fontFamilies === 'string' + ? fontFamilies.split(',').map(f => f.trim()) + : Array.from(fontFamilies || []), + ) + .flat() + .reduce( + (fonts, font) => (fonts.includes(font) ? fonts : [...fonts, font]), + [], + ); + +const getFontStack = (fontStore, { fontFamily, fontStyle, fontWeight }) => + toFontNameStack(fontFamily).map(fontFamilyName => { + if (typeof fontFamilyName !== 'string') return fontFamilyName; + + const opts = { fontFamily: fontFamilyName, fontWeight, fontStyle }; + const obj = fontStore ? fontStore.getFont(opts) : null; + return obj ? obj.data : fontFamilyName; + }); + /** * Get textkit fragments of given node object * @@ -43,15 +65,17 @@ const getFragments = (fontStore, instance, parentLink, level = 0) => { opacity, } = instance.style; - const opts = { fontFamily, fontWeight, fontStyle }; - const obj = fontStore ? fontStore.getFont(opts) : null; - const font = obj ? obj.data : fontFamily; + const fontStack = getFontStack(fontStore, { + fontFamily, + fontStyle, + fontWeight, + }); // Don't pass main background color to textkit. Will be rendered by the render package instead const backgroundColor = level === 0 ? null : instance.style.backgroundColor; const attributes = { - font, + fontStack, color, opacity, fontSize, diff --git a/packages/layout/src/text/ignoreChars.js b/packages/layout/src/text/ignoreChars.js index 5b86ef33c..a43ffa303 100644 --- a/packages/layout/src/text/ignoreChars.js +++ b/packages/layout/src/text/ignoreChars.js @@ -3,17 +3,24 @@ const IGNORABLE_CODEPOINTS = [ 8233, // PARAGRAPH_SEPARATOR ]; -const buildSubsetForFont = font => +const buildSubsetForFontStack = fonts => IGNORABLE_CODEPOINTS.reduce((acc, codePoint) => { - if (font && font.hasGlyphForCodePoint && font.hasGlyphForCodePoint(codePoint)) { - return acc; + for (let i = 0; i < fonts.length; i += 1) { + const font = fonts[i]; + if ( + font && + font.hasGlyphForCodePoint && + font.hasGlyphForCodePoint(codePoint) + ) { + return acc; + } } return [...acc, String.fromCharCode(codePoint)]; }, []); const ignoreChars = fragments => fragments.map(fragment => { - const charSubset = buildSubsetForFont(fragment.attributes.font); + const charSubset = buildSubsetForFontStack(fragment.attributes.fontStack); const subsetRegex = new RegExp(charSubset.join('|')); return { diff --git a/packages/textkit/src/engines/fontSubstitution/index.js b/packages/textkit/src/engines/fontSubstitution/index.js index 5974ac44b..430a036eb 100644 --- a/packages/textkit/src/engines/fontSubstitution/index.js +++ b/packages/textkit/src/engines/fontSubstitution/index.js @@ -25,7 +25,7 @@ const fontSubstitution = (options, attributedString) => { for (const run of runs) { const fontSize = getFontSize(run); - const defaultFont = run.attributes.font; + const defaultFont = run.attributes.fontStack?.[0]; if (string.length === 0) { res.push({ start: 0, end: 0, attributes: { font: defaultFont } }); diff --git a/packages/textkit/src/layout/applyDefaultStyles.js b/packages/textkit/src/layout/applyDefaultStyles.js index 4397ffd0a..486d690ad 100644 --- a/packages/textkit/src/layout/applyDefaultStyles.js +++ b/packages/textkit/src/layout/applyDefaultStyles.js @@ -19,7 +19,7 @@ const applyRunStyles = R.evolve({ color: a.color || 'black', features: a.features || [], fill: a.fill !== false, - font: a.font || null, + fontStack: a.fontStack || [], fontSize: a.fontSize || 12, hangingPunctuation: a.hangingPunctuation || false, hyphenationFactor: a.hyphenationFactor || 0, diff --git a/packages/textkit/tests/engines/fontSubstitution.test.js b/packages/textkit/tests/engines/fontSubstitution.test.js index 3bc6507a7..0a8521e13 100644 --- a/packages/textkit/tests/engines/fontSubstitution.test.js +++ b/packages/textkit/tests/engines/fontSubstitution.test.js @@ -20,8 +20,8 @@ describe('FontSubstitution', () => { }); test('should merge consecutive runs with same font', () => { - const run1 = { start: 0, end: 3, attributes: { font: 'Helvetica' } }; - const run2 = { start: 3, end: 5, attributes: { font: 'Helvetica' } }; + const run1 = { start: 0, end: 3, attributes: { fontStack: ['Helvetica'] } }; + const run2 = { start: 3, end: 5, attributes: { fontStack: ['Helvetica'] } }; const string = instance({ string: 'Lorem', runs: [run1, run2] }); expect(string).toHaveProperty('string', 'Lorem'); @@ -31,8 +31,8 @@ describe('FontSubstitution', () => { }); test('should substitute many runs', () => { - const run1 = { start: 0, end: 3, attributes: { font: 'Courier' } }; - const run2 = { start: 3, end: 5, attributes: { font: 'Helvetica' } }; + const run1 = { start: 0, end: 3, attributes: { fontStack: ['Courier'] } }; + const run2 = { start: 3, end: 5, attributes: { fontStack: ['Helvetica'] } }; const string = instance({ string: 'Lorem', runs: [run1, run2] }); expect(string).toHaveProperty('string', 'Lorem'); diff --git a/packages/textkit/tests/layout/applyDefaultStyles.test.js b/packages/textkit/tests/layout/applyDefaultStyles.test.js index 831b22dd5..6c4620214 100644 --- a/packages/textkit/tests/layout/applyDefaultStyles.test.js +++ b/packages/textkit/tests/layout/applyDefaultStyles.test.js @@ -12,7 +12,7 @@ const DEFAULTS = { color: 'black', features: [], fill: true, - font: null, + fontStack: [], fontSize: 12, hangingPunctuation: false, hyphenationFactor: 0, @@ -46,7 +46,7 @@ const OVERRIDES = { color: 'red', features: ['test'], fill: false, - font: { font: true }, + fontStack: { font: true }, fontSize: 15, hangingPunctuation: true, hyphenationFactor: 5, From c9be38edbf6ab74f2365bbab18c6a3927a2b3f3e Mon Sep 17 00:00:00 2001 From: ahmed-novalabs <91528256+ahmed-novalabs@users.noreply.github.com> Date: Sat, 28 May 2022 18:44:50 +0300 Subject: [PATCH 2/6] Add font fallback tests --- packages/font/tests/index.test.js | 374 ++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 packages/font/tests/index.test.js diff --git a/packages/font/tests/index.test.js b/packages/font/tests/index.test.js new file mode 100644 index 000000000..aac864e30 --- /dev/null +++ b/packages/font/tests/index.test.js @@ -0,0 +1,374 @@ +import FontStore from '../src'; +import Font from '../src/font'; + +const mockRegister = jest.fn(); +const mockResolve = jest.fn(); + +jest.mock('../src/font', () => ({ + create: jest.fn(() => { + return { + register: mockRegister, + resolve: mockResolve, + }; + }), +})); + +describe('font store', () => { + beforeEach(() => { + Font.create.mockClear(); + mockRegister.mockClear(); + mockResolve.mockClear(); + }); + test('Should register a single font', () => { + const fontStore = new FontStore(); + const font = { + family: 'Arial', + src: '', + fontWeight: 'normal', + unicodeRange: /[\u0600-\u06FF]/gu, + }; + fontStore.register(font); + + expect(Font.create).toBeCalledTimes(1); + expect(mockRegister).toBeCalledTimes(1); + + expect(mockRegister).toBeCalledWith(font); + + expect(fontStore.getRegisteredFontFamilies()).toEqual(['Arial']); + }); + + test('Should register multiple font families', () => { + const fontStore = new FontStore(); + const myFont1 = { + family: 'MyFont1', + src: '', + fontWeight: 'Bold', + unicodeRange: /[\u0600-\u06FF]/gu, + }; + const myFont2 = { + family: 'MyFont2', + src: '', + fontWeight: 'normal', + unicodeRange: /[\u0600-\u06FF]/gu, + }; + fontStore.register(myFont1); + fontStore.register(myFont2); + + expect(Font.create).toBeCalledTimes(2); + expect(mockRegister).toBeCalledTimes(2); + + expect(mockRegister).toBeCalledWith(myFont1); + expect(mockRegister).toBeCalledWith(myFont2); + + expect(fontStore.getRegisteredFontFamilies()).toEqual([ + 'MyFont1', + 'MyFont2', + ]); + }); + + test('Should register a multiple fonts', () => { + expect(Font.create).toBeCalledTimes(0); + const fontStore = new FontStore(); + const fontFamily = { + family: 'Arial', + unicodeRange: /[\u0600-\u06FF]/gu, + fonts: [ + { src: '', fontWeight: 'normal' }, + { src: '', fontWeight: 'bold' }, + ], + }; + fontStore.register(fontFamily); + + expect(Font.create).toBeCalledTimes(1); + expect(mockRegister).toBeCalledTimes(fontFamily.fonts.length); + + const defaultValues = { ...fontFamily }; + delete defaultValues.fonts; + + expect(mockRegister).toBeCalledWith({ + ...defaultValues, + ...fontFamily.fonts[0], + }); + expect(mockRegister).toBeCalledWith({ + ...defaultValues, + ...fontFamily.fonts[1], + }); + }); + + test('Should register override family properties by font properties', () => { + expect(Font.create).toBeCalledTimes(0); + const fontStore = new FontStore(); + const fontFamily = { + family: 'Arial', + unicodeRange: /[\u0600-\u06FF]/gu, + fonts: [ + { src: '', fontWeight: 'normal', unicodeRange: /[\u0600-\u06DD]/gu }, + { src: '', fontWeight: 'bold', unicodeRange: /[\u0600-\u06CC]/gu }, + ], + }; + fontStore.register(fontFamily); + + expect(Font.create).toBeCalledTimes(1); + expect(mockRegister).toBeCalledTimes(fontFamily.fonts.length); + + const defaultValues = { ...fontFamily }; + delete defaultValues.fonts; + + expect(mockRegister).toBeCalledWith({ + ...defaultValues, + ...fontFamily.fonts[0], + }); + expect(mockRegister).toBeCalledWith({ + ...defaultValues, + ...fontFamily.fonts[1], + }); + }); + + test('Should clear fonts', () => { + const fontStore = new FontStore(); + const font = { + family: 'Arial', + src: '', + fontWeight: 'normal', + unicodeRange: /[\u0600-\u06FF]/gu, + }; + fontStore.register(font); + + expect(fontStore.getRegisteredFontFamilies()).toEqual(['Arial']); + fontStore.clear(); + expect(fontStore.getRegisteredFontFamilies()).toEqual([]); + }); + + test('should resolve the font', () => { + const fontStore = new FontStore(); + const fontFamily = { + family: 'Arial', + fontFamily: 'Arial', + src: '', + fontWeight: 'normal', + unicodeRange: /[\u0600-\u06FF]/gu, + }; + fontStore.register(fontFamily); + + fontStore.getFont(fontFamily); + expect(mockResolve).toBeCalledTimes(1); + expect(mockResolve).toBeCalledWith(fontFamily); + }); + + test('should load the font', async () => { + const fontStore = new FontStore(); + + fontStore.getFont = jest.fn(() => ({ + unicodeRange: /\u0048/gu, // H + data: null, + loading: false, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1', + }, + 'H', + ); + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(1); + + expect(fontStore.getFont).toBeCalledTimes(1); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + }); + + test('should not load any font', async () => { + const fontStore = new FontStore(); + + fontStore.getFont = jest.fn(() => ({ + unicodeRange: /\u0049/gu, // I + data: null, + loading: false, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1, MyFont2, MyFont3', + }, + 'ZXC', + ); + expect(fontStore.getFont).toBeCalledTimes(3); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont2', + }); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont3', + }); + + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(0); + expect(fontStore.getFont.mock.results[1].value.load).toBeCalledTimes(0); + expect(fontStore.getFont.mock.results[2].value.load).toBeCalledTimes(0); + }); + + test('should not load loading font', async () => { + const fontStore = new FontStore(); + + fontStore.getFont = jest.fn(() => ({ + unicodeRange: /\u0048/gu, // H + data: null, + loading: true, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1', + }, + 'H', + ); + + expect(fontStore.getFont).toBeCalledTimes(1); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(0); + }); + + test('should not load loaded font', async () => { + const fontStore = new FontStore(); + + fontStore.getFont = jest.fn(() => ({ + unicodeRange: /\u0048/gu, // H + data: true, + loading: false, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1', + }, + 'H', + ); + + expect(fontStore.getFont).toBeCalledTimes(1); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(0); + }); + + test('should load only one font', async () => { + const fontStore = new FontStore(); + fontStore.getFont = jest + .fn() + .mockImplementationOnce(() => ({ + unicodeRange: /\u0049/gu, // I + data: null, + loading: false, + load: jest.fn(), + })) + .mockImplementation(() => ({ + unicodeRange: /\u0048/gu, // H + data: null, + loading: false, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1', + }, + 'HI', + ); + expect(fontStore.getFont).toBeCalledTimes(1); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(1); + }); + + test('should load two fonts', async () => { + const fontStore = new FontStore(); + fontStore.getFont = jest + .fn() + .mockImplementationOnce(() => ({ + unicodeRange: /\u0049/gu, // I + data: null, + loading: false, + load: jest.fn(), + })) + .mockImplementation(() => ({ + unicodeRange: /\u0048/gu, // H + data: null, + loading: false, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1, MyFont2', + }, + 'HI', + ); + expect(fontStore.getFont).toBeCalledTimes(2); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont2', + }); + + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(1); + expect(fontStore.getFont.mock.results[1].value.load).toBeCalledTimes(1); + }); + + test('should load only two fonts', async () => { + const fontStore = new FontStore(); + fontStore.getFont = jest + .fn() + .mockImplementationOnce(() => ({ + unicodeRange: /\u0049/gu, // I + data: null, + loading: false, + load: jest.fn(), + })) + .mockImplementationOnce(() => ({ + unicodeRange: /\u0048/gu, // H + data: null, + loading: false, + load: jest.fn(), + })) + .mockImplementationOnce(() => ({ + unicodeRange: /\u0050/gu, // P + data: null, + loading: false, + load: jest.fn(), + })); + + await fontStore.load( + { + fontFamily: 'MyFont1, MyFont2, MyFont3', + }, + 'HIJ', + ); + expect(fontStore.getFont).toBeCalledTimes(3); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont1', + }); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont2', + }); + expect(fontStore.getFont).toBeCalledWith({ + fontFamily: 'MyFont3', + }); + + expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(1); + expect(fontStore.getFont.mock.results[1].value.load).toBeCalledTimes(1); + expect(fontStore.getFont.mock.results[2].value.load).toBeCalledTimes(0); + }); +}); From 05f8ec3b51b47793bbc7efd6968674e4aed719c8 Mon Sep 17 00:00:00 2001 From: ahmed-novalabs <91528256+ahmed-novalabs@users.noreply.github.com> Date: Sat, 28 May 2022 18:47:24 +0300 Subject: [PATCH 3/6] Update types --- packages/types/font.d.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/types/font.d.ts b/packages/types/font.d.ts index ea098c625..2bda73c4e 100644 --- a/packages/types/font.d.ts +++ b/packages/types/font.d.ts @@ -23,6 +23,7 @@ interface FontSource { fontFamily: string; fontStyle: FontStyle; fontWeight: number; + unicodeRange?: RegExp; data: any; loading: boolean; options: any; @@ -54,6 +55,7 @@ interface EmojiSource { interface SingleLoad { family: string; src: string; + unicodeRange: RegExp, fontStyle?: string; fontWeight?: string | number; [key: string]: any; @@ -61,10 +63,12 @@ interface SingleLoad { interface BulkLoad { family: string; + unicodeRange?: RegExp, fonts: { src: string; fontStyle?: string; fontWeight?: string | number; + unicodeRange?: RegExp, [key: string]: any; }[]; } From fabbee4474655170f6d97666db21740e491fc7bc Mon Sep 17 00:00:00 2001 From: ahmed-novalabs <91528256+ahmed-novalabs@users.noreply.github.com> Date: Sat, 28 May 2022 20:49:57 +0300 Subject: [PATCH 4/6] Prefer last font --- packages/layout/src/text/fontSubstitution.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/layout/src/text/fontSubstitution.js b/packages/layout/src/text/fontSubstitution.js index 210c4ceac..061318f4a 100644 --- a/packages/layout/src/text/fontSubstitution.js +++ b/packages/layout/src/text/fontSubstitution.js @@ -19,8 +19,11 @@ const getOrCreateFont = name => { const getFallbackFont = () => getOrCreateFont('Helvetica'); -const pickFontFromFontStack = (codePoint, fontStack) => { +const pickFontFromFontStack = (codePoint, fontStack, lastFont) => { const fontStackWithFallback = [...fontStack, getFallbackFont()]; + if (lastFont) { + fontStackWithFallback.unshift(lastFont); + } for (let i = 0; i < fontStackWithFallback.length; i += 1) { const font = fontStackWithFallback[i]; if ( @@ -61,7 +64,11 @@ const fontSubstitution = () => ({ string, runs }) => { const char = chars[j]; const codePoint = char.codePointAt(); // If the default font does not have a glyph and the fallback font does, we use it - const font = pickFontFromFontStack(codePoint, run.attributes.fontStack); + const font = pickFontFromFontStack( + codePoint, + run.attributes.fontStack, + lastFont, + ); const fontSize = getFontSize(run); // If anything that would impact res has changed, update it From 230f6bac44c12f6d69f7615c8a5106dd1a8c5271 Mon Sep 17 00:00:00 2001 From: ahmed-novalabs <91528256+ahmed-novalabs@users.noreply.github.com> Date: Sat, 11 Jun 2022 02:09:03 +0300 Subject: [PATCH 5/6] Fixed multiple calls to font.load() race condition While resolving assets, the first call to load the font will await loading the font correctly, while subsequent calls to load the font will finish before the font is loaded. This sometimes leads to characters not rendering correctly. --- packages/font/src/font.js | 17 ++-- packages/font/src/index.js | 12 ++- packages/font/tests/font.test.js | 137 ++++++++++++++++++++++++++++++ packages/font/tests/index.test.js | 50 ----------- 4 files changed, 154 insertions(+), 62 deletions(-) create mode 100644 packages/font/tests/font.test.js diff --git a/packages/font/src/font.js b/packages/font/src/font.js index b497fe753..7f53a4339 100644 --- a/packages/font/src/font.js +++ b/packages/font/src/font.js @@ -59,13 +59,11 @@ class FontSource { ); this.data = null; - this.loading = false; + this.loadingPromise = null; this.options = options; } - async load() { - this.loading = true; - + async loadFont() { const { postscriptName } = this.options; if (isDataUrl(this.src)) { @@ -84,8 +82,17 @@ class FontSource { ), ); } + } - this.loading = false; + async load() { + if (this.data) return; + if (this.loadingPromise) { + await this.loadingPromise; + return; + } + this.loadingPromise = this.loadFont(); + await this.loadingPromise; + this.loadingPromise = null; } } diff --git a/packages/font/src/index.js b/packages/font/src/index.js index e28592112..1f1a5d869 100644 --- a/packages/font/src/index.js +++ b/packages/font/src/index.js @@ -1,4 +1,4 @@ -import font from './font'; +import Font from './font'; import standard from './standard'; function FontStore() { @@ -12,7 +12,7 @@ function FontStore() { const { family } = data; if (!fonts[family]) { - fonts[family] = font.create(family); + fonts[family] = Font.create(family); } // Bulk loading if (data.fonts) { @@ -67,17 +67,15 @@ function FontStore() { const isStandard = standard.includes(family); if (isStandard) return; - const f = this.getFont({ ...descriptor, fontFamily: family }); + const font = this.getFont({ ...descriptor, fontFamily: family }); const lengthBeforeReplace = remainingChars.length; - remainingChars = remainingChars.replace(f.unicodeRange, ''); + remainingChars = remainingChars.replace(font.unicodeRange, ''); const didReplace = lengthBeforeReplace !== remainingChars.length; if (didReplace) { - if (!f.data && !f.loading) { - promises.push(f.load()); - } + promises.push(font.load()); } if (!remainingChars.length) { break; diff --git a/packages/font/tests/font.test.js b/packages/font/tests/font.test.js new file mode 100644 index 000000000..1d093f824 --- /dev/null +++ b/packages/font/tests/font.test.js @@ -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); + }); +}); diff --git a/packages/font/tests/index.test.js b/packages/font/tests/index.test.js index aac864e30..ff13df67e 100644 --- a/packages/font/tests/index.test.js +++ b/packages/font/tests/index.test.js @@ -211,56 +211,6 @@ describe('font store', () => { expect(fontStore.getFont.mock.results[2].value.load).toBeCalledTimes(0); }); - test('should not load loading font', async () => { - const fontStore = new FontStore(); - - fontStore.getFont = jest.fn(() => ({ - unicodeRange: /\u0048/gu, // H - data: null, - loading: true, - load: jest.fn(), - })); - - await fontStore.load( - { - fontFamily: 'MyFont1', - }, - 'H', - ); - - expect(fontStore.getFont).toBeCalledTimes(1); - expect(fontStore.getFont).toBeCalledWith({ - fontFamily: 'MyFont1', - }); - - expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(0); - }); - - test('should not load loaded font', async () => { - const fontStore = new FontStore(); - - fontStore.getFont = jest.fn(() => ({ - unicodeRange: /\u0048/gu, // H - data: true, - loading: false, - load: jest.fn(), - })); - - await fontStore.load( - { - fontFamily: 'MyFont1', - }, - 'H', - ); - - expect(fontStore.getFont).toBeCalledTimes(1); - expect(fontStore.getFont).toBeCalledWith({ - fontFamily: 'MyFont1', - }); - - expect(fontStore.getFont.mock.results[0].value.load).toBeCalledTimes(0); - }); - test('should load only one font', async () => { const fontStore = new FontStore(); fontStore.getFont = jest From eef7b4ab04bd3a2cc09e46f454c2692c0fa41908 Mon Sep 17 00:00:00 2001 From: ahmed-novalabs <91528256+ahmed-novalabs@users.noreply.github.com> Date: Sat, 11 Jun 2022 02:14:50 +0300 Subject: [PATCH 6/6] Matched behavior of unicode-range css property --- packages/font/src/index.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/font/src/index.js b/packages/font/src/index.js index 1f1a5d869..de3c4665d 100644 --- a/packages/font/src/index.js +++ b/packages/font/src/index.js @@ -59,8 +59,6 @@ function FontStore() { const promises = []; - let remainingChars = text; - for (let len = fontFamilies.length, i = 0; i < len; i += 1) { const family = fontFamilies[i]; @@ -69,17 +67,11 @@ function FontStore() { const font = this.getFont({ ...descriptor, fontFamily: family }); - const lengthBeforeReplace = remainingChars.length; - remainingChars = remainingChars.replace(font.unicodeRange, ''); - - const didReplace = lengthBeforeReplace !== remainingChars.length; + const didMatch = !font.unicodeRange || font.unicodeRange.test(text); - if (didReplace) { + if (didMatch) { promises.push(font.load()); } - if (!remainingChars.length) { - break; - } } await Promise.all(promises);