diff --git a/src/engine/tw-font-manager.js b/src/engine/tw-font-manager.js index b58eeffca2f..f617a67f95e 100644 --- a/src/engine/tw-font-manager.js +++ b/src/engine/tw-font-manager.js @@ -3,6 +3,11 @@ const AssetUtil = require('../util/tw-asset-util'); const StringUtil = require('../util/string-util'); const log = require('../util/log'); +/* + * In general in this file, note that font names in browsers are case-insensitive + * but are whitespace-sensitive. + */ + /** * @typedef InternalFont * @property {boolean} system True if the font is built in to the system @@ -11,40 +16,121 @@ const log = require('../util/log'); * @property {Asset} [asset] scratch-storage asset if system: false */ +/** + * @param {string} font + * @returns {string} + */ +const removeInvalidCharacters = font => font.replace(/[^-\w ]/g, ''); + +/** + * @param {InternalFont[]} fonts Modified in-place + * @param {InternalFont} newFont + * @returns {InternalFont|null} + */ +const addOrUpdateFont = (fonts, newFont) => { + let oldFont; + const oldIndex = fonts.findIndex(i => i.family.toLowerCase() === newFont.family.toLowerCase()); + if (oldIndex !== -1) { + oldFont = fonts[oldIndex]; + fonts.splice(oldIndex, 1); + } + fonts.push(newFont); + return oldFont; +}; + class FontManager extends EventEmitter { /** * @param {Runtime} runtime */ constructor (runtime) { super(); + + /** @type {Runtime} */ this.runtime = runtime; + /** @type {Array} */ this.fonts = []; + + /** + * All entries should be lowercase. + * @type {Set} + */ + this.restrictedFonts = new Set(); } /** - * @param {string} family An unknown font family - * @returns {boolean} true if the family is valid + * Prevents a family from being overridden by a custom font. The project may still use it as a system font. + * @param {string} family */ - isValidFamily (family) { + restrictFont (family) { + if (!this.isValidSystemFont(family)) { + throw new Error('Invalid font'); + } + + this.restrictedFonts.add(family.toLowerCase()); + + const oldLength = this.fonts.length; + this.fonts = this.fonts.filter(font => font.system || this.isValidCustomFont(font.family)); + if (this.fonts.length !== oldLength) { + this.updateRenderer(); + this.changed(); + } + } + + /** + * @param {string} family Untrusted font name input + * @returns {boolean} true if the family is valid for a system font + */ + isValidSystemFont (family) { return /^[-\w ]+$/.test(family); } /** - * @param {string} family - * @returns {boolean} + * @param {string} family Untrusted font name input + * @returns {boolean} true if the family is valid for a custom font */ - hasFont (family) { - return !!this.fonts.find(i => i.family === family); + isValidCustomFont (family) { + return /^[-\w ]+$/.test(family) && !this.restrictedFonts.has(family.toLowerCase()); + } + + /** + * @deprecated only exists for extension compatibility, use isValidSystemFont or isValidCustomFont instead + */ + isValidFamily (family) { + return this.isValidSystemFont(family) && this.isValidCustomFont(family); + } + + /** + * @param {string} family Untrusted font name input + * @returns {string} + */ + getUnusedSystemFont (family) { + return StringUtil.caseInsensitiveUnusedName( + removeInvalidCharacters(family), + this.fonts.map(i => i.family) + ); + } + + /** + * @param {string} family Untrusted font name input + * @returns {string} + */ + getUnusedCustomFont (family) { + return StringUtil.caseInsensitiveUnusedName( + removeInvalidCharacters(family), + [ + ...this.fonts.map(i => i.family), + ...this.restrictedFonts + ] + ); } /** * @param {string} family * @returns {boolean} */ - getSafeName (family) { - family = family.replace(/[^-\w ]/g, ''); - return StringUtil.unusedName(family, this.fonts.map(i => i.family)); + hasFont (family) { + return !!this.fonts.find(i => i.family.toLowerCase() === family.toLowerCase()); } changed () { @@ -56,14 +142,17 @@ class FontManager extends EventEmitter { * @param {string} fallback */ addSystemFont (family, fallback) { - if (!this.isValidFamily(family)) { - throw new Error('Invalid family'); + if (!this.isValidSystemFont(family)) { + throw new Error('Invalid system font family'); } - this.fonts.push({ + const oldFont = addOrUpdateFont(this.fonts, { system: true, family, fallback }); + if (oldFont && !oldFont.system) { + this.updateRenderer(); + } this.changed(); } @@ -73,17 +162,15 @@ class FontManager extends EventEmitter { * @param {Asset} asset scratch-storage asset */ addCustomFont (family, fallback, asset) { - if (!this.isValidFamily(family)) { - throw new Error('Invalid family'); + if (!this.isValidCustomFont(family)) { + throw new Error('Invalid custom font family'); } - - this.fonts.push({ + addOrUpdateFont(this.fonts, { system: false, family, fallback, asset }); - this.updateRenderer(); this.changed(); } diff --git a/src/util/string-util.js b/src/util/string-util.js index 6fc99530f96..59550b6b29b 100644 --- a/src/util/string-util.js +++ b/src/util/string-util.js @@ -15,6 +15,20 @@ class StringUtil { return name + i; } + /** + * @param {string} name + * @param {string[]} existingNames + * @returns {string} + */ + static caseInsensitiveUnusedName (name, existingNames) { + const exists = needle => existingNames.some(i => i.toLowerCase() === needle.toLowerCase()); + if (!exists(name)) return name; + name = StringUtil.withoutTrailingDigits(name); + let i = 2; + while (exists(`${name}${i}`)) i++; + return `${name}${i}`; + } + /** * Split a string on the first occurrence of a split character. * @param {string} text - the string to split. diff --git a/test/integration/tw_font_manager.js b/test/integration/tw_font_manager.js index fc8c33bfa7b..452fabc687a 100644 --- a/test/integration/tw_font_manager.js +++ b/test/integration/tw_font_manager.js @@ -19,22 +19,75 @@ const makeTestStorage = () => { return storage; }; -test('isValidFamily', t => { +test('isValidSystemFont', t => { const {fontManager} = new Runtime(); - t.ok(fontManager.isValidFamily('Roboto')); - t.ok(fontManager.isValidFamily('sans-serif')); - t.ok(fontManager.isValidFamily('helvetica neue')); - t.notOk(fontManager.isValidFamily('Roboto;Bold')); - t.notOk(fontManager.isValidFamily('Arial, sans-serif')); + t.ok(fontManager.isValidSystemFont('Roboto')); + t.ok(fontManager.isValidSystemFont('sans-serif')); + t.ok(fontManager.isValidSystemFont('helvetica neue')); + t.notOk(fontManager.isValidSystemFont('Roboto;Bold')); + t.notOk(fontManager.isValidSystemFont('Arial, sans-serif')); + + fontManager.restrictFont('Roboto'); + t.ok(fontManager.isValidSystemFont('Roboto')); + + t.end(); +}); + +test('isValidCustomFont', t => { + const {fontManager} = new Runtime(); + t.ok(fontManager.isValidCustomFont('Roboto')); + t.ok(fontManager.isValidCustomFont('sans-serif')); + t.ok(fontManager.isValidCustomFont('helvetica neue')); + t.notOk(fontManager.isValidCustomFont('Roboto;Bold')); + t.notOk(fontManager.isValidCustomFont('Arial, sans-serif')); + + fontManager.restrictFont('Roboto'); + t.notOk(fontManager.isValidCustomFont('Roboto')); + t.notOk(fontManager.isValidCustomFont('roboto')); + t.notOk(fontManager.isValidCustomFont('ROBOTO')); + t.ok(fontManager.isValidCustomFont('Roboto ')); + t.ok(fontManager.isValidCustomFont('Roboto2')); + t.ok(fontManager.isValidCustomFont('sans-serif')); + t.ok(fontManager.isValidCustomFont('helvetica neue')); + t.notOk(fontManager.isValidCustomFont('Roboto;Bold')); + t.notOk(fontManager.isValidCustomFont('Arial, sans-serif')); + t.end(); }); -test('getSafeName', t => { +test('getSafeSystemFont', t => { const {fontManager} = new Runtime(); - t.equal(fontManager.getSafeName('Arial'), 'Arial'); + t.equal(fontManager.getUnusedSystemFont('Arial'), 'Arial'); fontManager.addSystemFont('Arial', 'sans-serif'); - t.equal(fontManager.getSafeName('Arial'), 'Arial2'); - t.equal(fontManager.getSafeName('Weird123!@"<>?'), 'Weird123'); + t.equal(fontManager.getUnusedSystemFont('Arial'), 'Arial2'); + t.equal(fontManager.getUnusedSystemFont('Weird123!@"<>?'), 'Weird123'); + + fontManager.restrictFont('Restricted'); + t.equal(fontManager.getUnusedSystemFont('Restricted'), 'Restricted'); + + t.end(); +}); + +test('getSafeCustomFont', t => { + const {fontManager} = new Runtime(); + t.equal(fontManager.getUnusedCustomFont('Arial'), 'Arial'); + fontManager.addSystemFont('Arial', 'sans-serif'); + t.equal(fontManager.getUnusedCustomFont('Arial'), 'Arial2'); + t.equal(fontManager.getUnusedCustomFont('Weird123!@"<>?'), 'Weird123'); + + fontManager.restrictFont('Restricted'); + t.equal(fontManager.getUnusedCustomFont('Restricted'), 'Restricted2'); + t.equal(fontManager.getUnusedCustomFont('restricted'), 'restricted2'); + t.equal(fontManager.getUnusedCustomFont(' restricted'), ' restricted'); + + fontManager.restrictFont('Restricted2'); + t.equal(fontManager.getUnusedCustomFont('Restricted'), 'Restricted3'); + t.equal(fontManager.getUnusedCustomFont('restricted'), 'restricted3'); + + fontManager.addSystemFont('Restricted3'); + t.equal(fontManager.getUnusedCustomFont('Restricted'), 'Restricted4'); + t.equal(fontManager.getUnusedCustomFont('restricted'), 'restricted4'); + t.end(); }); @@ -58,6 +111,7 @@ test('system font', t => { fontManager.addSystemFont('Noto Sans Mono', 'monospace'); t.ok(changed, 'addSystemFont() emits change'); t.ok(fontManager.hasFont('Noto Sans Mono'), 'updated hasFont()'); + t.ok(fontManager.hasFont('noto sans mono'), 'updated hasFont() case insensitively'); t.same(fontManager.getFonts(), [ { system: true, @@ -115,9 +169,13 @@ test('system font', t => { test('system font validation', t => { const {fontManager} = new Runtime(); + fontManager.restrictFont('Restricted'); t.throws(() => { fontManager.addCustomFont(';', 'monospace'); }); + t.throws(() => { + fontManager.addCustomFont('Restricted', 'monospace'); + }); t.end(); }); @@ -587,3 +645,122 @@ test('deserializes ignores invalid fonts', t => { t.end(); }); }); + +test('restrict throws on invalid input', t => { + const {fontManager} = new Runtime(); + t.throws(() => { + fontManager.restrictFont('(#@*$'); + }, 'Invalid font'); + t.end(); +}); + +test('restrict removes existing fonts', t => { + let setCustomFontsCalls = 0; + const mockRenderer = { + setLayerGroupOrdering: () => {}, + setCustomFonts: () => { + setCustomFontsCalls++; + } + }; + + const rt = new Runtime(); + rt.attachRenderer(mockRenderer); + rt.attachStorage(makeTestStorage()); + const {fontManager, storage} = rt; + + let changeEvents = 0; + fontManager.on('change', () => { + changeEvents++; + }); + + fontManager.addSystemFont('System Font', 'sans-serif'); + fontManager.addCustomFont('Important Font', 'sans-serif', storage.createAsset( + storage.AssetType.Font, + 'ttf', + new Uint8Array([11, 12, 13]), + null, + true + )); + fontManager.addCustomFont('Not Important Font', 'sans-serif', storage.createAsset( + storage.AssetType.Font, + 'ttf', + new Uint8Array([11, 12, 13]), + null, + true + )); + + t.equal(changeEvents, 3, 'sanity check'); + t.equal(setCustomFontsCalls, 2, 'sanity check'); + + fontManager.restrictFont('Not Used'); + t.equal(changeEvents, 3, 'does not emit change when unused font restricted'); + t.equal(setCustomFontsCalls, 2, 'does not emit change when unused font restricted'); + + fontManager.restrictFont('System Font'); + t.equal(changeEvents, 3, 'does not emit change when system font restricted'); + t.equal(setCustomFontsCalls, 2, 'does not emit change when system font restricted'); + + fontManager.restrictFont('important font'); + t.equal(changeEvents, 4, 'emits change when custom font restricted'); + t.equal(setCustomFontsCalls, 3, 'emits change when custom font restricted'); + t.same(fontManager.getFonts().map(i => i.name), [ + 'System Font', + 'Not Important Font' + ]); + + fontManager.restrictFont('Important Font'); + t.equal(changeEvents, 4, 'does not emit change when restricted font restricted again'); + t.equal(setCustomFontsCalls, 3, 'does not emit change when restricted font restricted again'); + + t.end(); +}); + +test('overriding existing fonts', t => { + let setCustomFontsCalls = 0; + const mockRenderer = { + setLayerGroupOrdering: () => {}, + setCustomFonts: () => { + setCustomFontsCalls++; + } + }; + + const rt = new Runtime(); + rt.attachRenderer(mockRenderer); + rt.attachStorage(makeTestStorage()); + const {fontManager, storage} = rt; + + let changeEvents = 0; + fontManager.on('change', () => { + changeEvents++; + }); + + const asset = storage.createAsset( + storage.AssetType.Font, + 'ttf', + new Uint8Array([11, 12, 13]), + null, + true + ); + + fontManager.addCustomFont('TestFont', 'sans-serif', asset); + t.equal(changeEvents, 1); + t.equal(setCustomFontsCalls, 1); + t.same(fontManager.getFonts().map(i => i.name), ['TestFont']); + + fontManager.addSystemFont('TestFonT', 'sans-serif'); + t.equal(changeEvents, 2); + t.equal(setCustomFontsCalls, 2); + t.same(fontManager.getFonts().map(i => i.name), ['TestFonT']); + + fontManager.addSystemFont('TestFONT', 'sans-serif'); + t.equal(changeEvents, 3); + t.equal(setCustomFontsCalls, 2); + t.same(fontManager.getFonts().map(i => i.name), ['TestFONT']); + + fontManager.addCustomFont('TESTFONT', 'sans-serif', asset); + t.equal(changeEvents, 4); + t.equal(setCustomFontsCalls, 3); + t.same(fontManager.getFonts().map(i => i.name), ['TESTFONT']); + + t.end(); +}); diff --git a/test/unit/tw_util_string.js b/test/unit/tw_util_string.js new file mode 100644 index 00000000000..c316aeadfe9 --- /dev/null +++ b/test/unit/tw_util_string.js @@ -0,0 +1,10 @@ +const {test} = require('tap'); +const StringUtil = require('../../src/util/string-util'); + +test('caseInsensitiveUnusedName', t => { + t.equal(StringUtil.caseInsensitiveUnusedName('test', []), 'test'); + t.equal(StringUtil.caseInsensitiveUnusedName('test', ['Test']), 'test2'); + t.equal(StringUtil.caseInsensitiveUnusedName('TEST3', ['test3']), 'TEST2'); + t.equal(StringUtil.caseInsensitiveUnusedName('TEST', ['test', 'TESt1', 'teST2']), 'TEST3'); + t.end(); +});