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

Add restricted fonts and case insensitivity to font manager #231

Merged
merged 3 commits into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 105 additions & 18 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,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<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}
* @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 () {
Expand All @@ -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();
}

Expand All @@ -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();
}
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
Loading