Skip to content

Commit ba269d6

Browse files
committed
Add restricted fonts and case insensitivity to font manager
1 parent fed099c commit ba269d6

File tree

4 files changed

+245
-24
lines changed

4 files changed

+245
-24
lines changed

src/engine/tw-font-manager.js

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ const AssetUtil = require('../util/tw-asset-util');
33
const StringUtil = require('../util/string-util');
44
const log = require('../util/log');
55

6+
/*
7+
* In general in this file, note that font names in browsers are case-insensitive
8+
* but are whitespace-sensitive.
9+
*/
10+
611
/**
712
* @typedef InternalFont
813
* @property {boolean} system True if the font is built in to the system
@@ -11,40 +16,105 @@ const log = require('../util/log');
1116
* @property {Asset} [asset] scratch-storage asset if system: false
1217
*/
1318

19+
/**
20+
* @param {string} font
21+
* @returns {string}
22+
*/
23+
const removeInvalidCharacters = font => font.replace(/[^-\w ]/g, '');
24+
1425
class FontManager extends EventEmitter {
1526
/**
1627
* @param {Runtime} runtime
1728
*/
1829
constructor (runtime) {
1930
super();
31+
32+
/** @type {Runtime} */
2033
this.runtime = runtime;
34+
2135
/** @type {Array<InternalFont>} */
2236
this.fonts = [];
37+
38+
/**
39+
* All entries should be lowercase.
40+
* @type {Set<string>}
41+
*/
42+
this.restrictedFonts = new Set();
2343
}
2444

2545
/**
26-
* @param {string} family An unknown font family
27-
* @returns {boolean} true if the family is valid
46+
* Prevents a family from being overridden by a custom font. The project may still use it as a system font.
47+
* @param {string} family
2848
*/
29-
isValidFamily (family) {
49+
restrictFont (family) {
50+
if (!this.isValidSystemFont(family)) {
51+
throw new Error('Invalid font');
52+
}
53+
54+
this.restrictedFonts.add(family.toLowerCase());
55+
56+
const oldLength = this.fonts.length;
57+
this.fonts = this.fonts.filter(font => font.system || this.isValidCustomFont(font.family));
58+
if (this.fonts.length !== oldLength) {
59+
this.updateRenderer();
60+
this.changed();
61+
}
62+
}
63+
64+
/**
65+
* @param {string} family Untrusted font name input
66+
* @returns {boolean} true if the family is valid for a system font
67+
*/
68+
isValidSystemFont (family) {
3069
return /^[-\w ]+$/.test(family);
3170
}
3271

3372
/**
34-
* @param {string} family
35-
* @returns {boolean}
73+
* @deprecated only exists for extension compatibility, use isValidSystemFont or isValidCustomFont instead
3674
*/
37-
hasFont (family) {
38-
return !!this.fonts.find(i => i.family === family);
75+
isValidFamily (family) {
76+
return this.isValidSystemFont(family) && this.isValidCustomFont(family);
77+
}
78+
79+
/**
80+
* @param {string} family Untrusted font name input
81+
* @returns {boolean} true if the family is valid for a custom font
82+
*/
83+
isValidCustomFont (family) {
84+
return /^[-\w ]+$/.test(family) && !this.restrictedFonts.has(family.toLowerCase());
85+
}
86+
87+
/**
88+
* @param {string} family Untrusted font name input
89+
* @returns {string}
90+
*/
91+
getUnusedSystemFont (family) {
92+
return StringUtil.caseInsensitiveUnusedName(
93+
removeInvalidCharacters(family),
94+
this.fonts.map(i => i.family)
95+
);
96+
}
97+
98+
/**
99+
* @param {string} family Untrusted font name input
100+
* @returns {string}
101+
*/
102+
getUnusedCustomFont (family) {
103+
return StringUtil.caseInsensitiveUnusedName(
104+
removeInvalidCharacters(family),
105+
[
106+
...this.fonts.map(i => i.family),
107+
...this.restrictedFonts
108+
]
109+
);
39110
}
40111

41112
/**
42113
* @param {string} family
43114
* @returns {boolean}
44115
*/
45-
getSafeName (family) {
46-
family = family.replace(/[^-\w ]/g, '');
47-
return StringUtil.unusedName(family, this.fonts.map(i => i.family));
116+
hasFont (family) {
117+
return !!this.fonts.find(i => i.family.toLowerCase() === family.toLowerCase());
48118
}
49119

50120
changed () {
@@ -56,8 +126,8 @@ class FontManager extends EventEmitter {
56126
* @param {string} fallback
57127
*/
58128
addSystemFont (family, fallback) {
59-
if (!this.isValidFamily(family)) {
60-
throw new Error('Invalid family');
129+
if (!this.isValidSystemFont(family)) {
130+
throw new Error('Invalid system font family');
61131
}
62132
this.fonts.push({
63133
system: true,
@@ -73,8 +143,8 @@ class FontManager extends EventEmitter {
73143
* @param {Asset} asset scratch-storage asset
74144
*/
75145
addCustomFont (family, fallback, asset) {
76-
if (!this.isValidFamily(family)) {
77-
throw new Error('Invalid family');
146+
if (!this.isValidCustomFont(family)) {
147+
throw new Error('Invalid custom font family');
78148
}
79149

80150
this.fonts.push({

src/util/string-util.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ class StringUtil {
1515
return name + i;
1616
}
1717

18+
/**
19+
* @param {string} name
20+
* @param {string[]} existingNames
21+
* @returns {string}
22+
*/
23+
static caseInsensitiveUnusedName (name, existingNames) {
24+
const exists = needle => existingNames.some(i => i.toLowerCase() === needle.toLowerCase());
25+
if (!exists(name)) return name;
26+
name = StringUtil.withoutTrailingDigits(name);
27+
let i = 2;
28+
while (exists(`${name}${i}`)) i++;
29+
return `${name}${i}`;
30+
}
31+
1832
/**
1933
* Split a string on the first occurrence of a split character.
2034
* @param {string} text - the string to split.

test/integration/tw_font_manager.js

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,75 @@ const makeTestStorage = () => {
1919
return storage;
2020
};
2121

22-
test('isValidFamily', t => {
22+
test('isValidSystemFont', t => {
2323
const {fontManager} = new Runtime();
24-
t.ok(fontManager.isValidFamily('Roboto'));
25-
t.ok(fontManager.isValidFamily('sans-serif'));
26-
t.ok(fontManager.isValidFamily('helvetica neue'));
27-
t.notOk(fontManager.isValidFamily('Roboto;Bold'));
28-
t.notOk(fontManager.isValidFamily('Arial, sans-serif'));
24+
t.ok(fontManager.isValidSystemFont('Roboto'));
25+
t.ok(fontManager.isValidSystemFont('sans-serif'));
26+
t.ok(fontManager.isValidSystemFont('helvetica neue'));
27+
t.notOk(fontManager.isValidSystemFont('Roboto;Bold'));
28+
t.notOk(fontManager.isValidSystemFont('Arial, sans-serif'));
29+
30+
fontManager.restrictFont('Roboto');
31+
t.ok(fontManager.isValidSystemFont('Roboto'));
32+
33+
t.end();
34+
});
35+
36+
test('isValidCustomFont', t => {
37+
const {fontManager} = new Runtime();
38+
t.ok(fontManager.isValidCustomFont('Roboto'));
39+
t.ok(fontManager.isValidCustomFont('sans-serif'));
40+
t.ok(fontManager.isValidCustomFont('helvetica neue'));
41+
t.notOk(fontManager.isValidCustomFont('Roboto;Bold'));
42+
t.notOk(fontManager.isValidCustomFont('Arial, sans-serif'));
43+
44+
fontManager.restrictFont('Roboto');
45+
t.notOk(fontManager.isValidCustomFont('Roboto'));
46+
t.notOk(fontManager.isValidCustomFont('roboto'));
47+
t.notOk(fontManager.isValidCustomFont('ROBOTO'));
48+
t.ok(fontManager.isValidCustomFont('Roboto '));
49+
t.ok(fontManager.isValidCustomFont('Roboto2'));
50+
t.ok(fontManager.isValidCustomFont('sans-serif'));
51+
t.ok(fontManager.isValidCustomFont('helvetica neue'));
52+
t.notOk(fontManager.isValidCustomFont('Roboto;Bold'));
53+
t.notOk(fontManager.isValidCustomFont('Arial, sans-serif'));
54+
2955
t.end();
3056
});
3157

32-
test('getSafeName', t => {
58+
test('getSafeSystemFont', t => {
3359
const {fontManager} = new Runtime();
34-
t.equal(fontManager.getSafeName('Arial'), 'Arial');
60+
t.equal(fontManager.getUnusedSystemFont('Arial'), 'Arial');
3561
fontManager.addSystemFont('Arial', 'sans-serif');
36-
t.equal(fontManager.getSafeName('Arial'), 'Arial2');
37-
t.equal(fontManager.getSafeName('Weird123!@"<>?'), 'Weird123');
62+
t.equal(fontManager.getUnusedSystemFont('Arial'), 'Arial2');
63+
t.equal(fontManager.getUnusedSystemFont('Weird123!@"<>?'), 'Weird123');
64+
65+
fontManager.restrictFont('Restricted');
66+
t.equal(fontManager.getUnusedSystemFont('Restricted'), 'Restricted');
67+
68+
t.end();
69+
});
70+
71+
test('getSafeCustomFont', t => {
72+
const {fontManager} = new Runtime();
73+
t.equal(fontManager.getUnusedCustomFont('Arial'), 'Arial');
74+
fontManager.addSystemFont('Arial', 'sans-serif');
75+
t.equal(fontManager.getUnusedCustomFont('Arial'), 'Arial2');
76+
t.equal(fontManager.getUnusedCustomFont('Weird123!@"<>?'), 'Weird123');
77+
78+
fontManager.restrictFont('Restricted');
79+
t.equal(fontManager.getUnusedCustomFont('Restricted'), 'Restricted2');
80+
t.equal(fontManager.getUnusedCustomFont('restricted'), 'restricted2');
81+
t.equal(fontManager.getUnusedCustomFont(' restricted'), ' restricted');
82+
83+
fontManager.restrictFont('Restricted2');
84+
t.equal(fontManager.getUnusedCustomFont('Restricted'), 'Restricted3');
85+
t.equal(fontManager.getUnusedCustomFont('restricted'), 'restricted3');
86+
87+
fontManager.addSystemFont('Restricted3');
88+
t.equal(fontManager.getUnusedCustomFont('Restricted'), 'Restricted4');
89+
t.equal(fontManager.getUnusedCustomFont('restricted'), 'restricted4');
90+
3891
t.end();
3992
});
4093

@@ -58,6 +111,7 @@ test('system font', t => {
58111
fontManager.addSystemFont('Noto Sans Mono', 'monospace');
59112
t.ok(changed, 'addSystemFont() emits change');
60113
t.ok(fontManager.hasFont('Noto Sans Mono'), 'updated hasFont()');
114+
t.ok(fontManager.hasFont('noto sans mono'), 'updated hasFont() case insensitively');
61115
t.same(fontManager.getFonts(), [
62116
{
63117
system: true,
@@ -115,9 +169,13 @@ test('system font', t => {
115169

116170
test('system font validation', t => {
117171
const {fontManager} = new Runtime();
172+
fontManager.restrictFont('Restricted');
118173
t.throws(() => {
119174
fontManager.addCustomFont(';', 'monospace');
120175
});
176+
t.throws(() => {
177+
fontManager.addCustomFont('Restricted', 'monospace');
178+
});
121179
t.end();
122180
});
123181

@@ -587,3 +645,72 @@ test('deserializes ignores invalid fonts', t => {
587645
t.end();
588646
});
589647
});
648+
649+
test('restrict throws on invalid input', t => {
650+
const {fontManager} = new Runtime();
651+
t.throws(() => {
652+
fontManager.restrictFont('(#@*$');
653+
}, 'Invalid font');
654+
t.end();
655+
});
656+
657+
test('restrict removes existing fonts', t => {
658+
let setCustomFontsCalls = 0;
659+
const mockRenderer = {
660+
setLayerGroupOrdering: () => {},
661+
setCustomFonts: () => {
662+
setCustomFontsCalls++;
663+
}
664+
};
665+
666+
const rt = new Runtime();
667+
rt.attachRenderer(mockRenderer);
668+
rt.attachStorage(makeTestStorage());
669+
const {fontManager, storage} = rt;
670+
671+
let changeEvents = 0;
672+
fontManager.on('change', () => {
673+
changeEvents++;
674+
});
675+
676+
fontManager.addSystemFont('System Font', 'sans-serif');
677+
fontManager.addCustomFont('Important Font', 'sans-serif', storage.createAsset(
678+
storage.AssetType.Font,
679+
'ttf',
680+
new Uint8Array([11, 12, 13]),
681+
null,
682+
true
683+
));
684+
fontManager.addCustomFont('Not Important Font', 'sans-serif', storage.createAsset(
685+
storage.AssetType.Font,
686+
'ttf',
687+
new Uint8Array([11, 12, 13]),
688+
null,
689+
true
690+
));
691+
692+
t.equal(changeEvents, 3, 'sanity check');
693+
t.equal(setCustomFontsCalls, 2, 'sanity check');
694+
695+
fontManager.restrictFont('Not Used');
696+
t.equal(changeEvents, 3, 'does not emit change when unused font restricted');
697+
t.equal(setCustomFontsCalls, 2, 'does not emit change when unused font restricted');
698+
699+
fontManager.restrictFont('System Font');
700+
t.equal(changeEvents, 3, 'does not emit change when system font restricted');
701+
t.equal(setCustomFontsCalls, 2, 'does not emit change when system font restricted');
702+
703+
fontManager.restrictFont('important font');
704+
t.equal(changeEvents, 4, 'emits change when custom font restricted');
705+
t.equal(setCustomFontsCalls, 3, 'emits change when custom font restricted');
706+
t.same(fontManager.getFonts().map(i => i.name), [
707+
'System Font',
708+
'Not Important Font'
709+
]);
710+
711+
fontManager.restrictFont('Important Font');
712+
t.equal(changeEvents, 4, 'does not emit change when restricted font restricted again');
713+
t.equal(setCustomFontsCalls, 3, 'does not emit change when restricted font restricted again');
714+
715+
t.end();
716+
});

test/unit/tw_util_string.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const {test} = require('tap');
2+
const StringUtil = require('../../src/util/string-util');
3+
4+
test('caseInsensitiveUnusedName', t => {
5+
t.equal(StringUtil.caseInsensitiveUnusedName('test', []), 'test');
6+
t.equal(StringUtil.caseInsensitiveUnusedName('test', ['Test']), 'test2');
7+
t.equal(StringUtil.caseInsensitiveUnusedName('TEST3', ['test3']), 'TEST2');
8+
t.equal(StringUtil.caseInsensitiveUnusedName('TEST', ['test', 'TESt1', 'teST2']), 'TEST3');
9+
t.end();
10+
});

0 commit comments

Comments
 (0)