Skip to content

Commit

Permalink
Add restricted fonts and case insensitivity to font manager
Browse files Browse the repository at this point in the history
  • Loading branch information
GarboMuffin committed Nov 3, 2024
1 parent fed099c commit ba269d6
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 24 deletions.
98 changes: 84 additions & 14 deletions src/engine/tw-font-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,40 +16,105 @@ 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, '');

class FontManager extends EventEmitter {
/**
* @param {Runtime} runtime
*/
constructor (runtime) {
super();

/** @type {Runtime} */
this.runtime = runtime;

/** @type {Array<InternalFont>} */
this.fonts = [];

/**
* All entries should be lowercase.
* @type {Set<string>}
*/
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}
* @deprecated only exists for extension compatibility, use isValidSystemFont or isValidCustomFont instead
*/
hasFont (family) {
return !!this.fonts.find(i => i.family === family);
isValidFamily (family) {
return this.isValidSystemFont(family) && this.isValidCustomFont(family);
}

/**
* @param {string} family Untrusted font name input
* @returns {boolean} true if the family is valid for a custom font
*/
isValidCustomFont (family) {
return /^[-\w ]+$/.test(family) && !this.restrictedFonts.has(family.toLowerCase());
}

/**
* @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 () {
Expand All @@ -56,8 +126,8 @@ 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({
system: true,
Expand All @@ -73,8 +143,8 @@ 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({
Expand Down
14 changes: 14 additions & 0 deletions src/util/string-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
147 changes: 137 additions & 10 deletions test/integration/tw_font_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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,
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -587,3 +645,72 @@ 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();
});
10 changes: 10 additions & 0 deletions test/unit/tw_util_string.js
Original file line number Diff line number Diff line change
@@ -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();
});

0 comments on commit ba269d6

Please sign in to comment.