Skip to content

Commit

Permalink
Merge pull request #156 from TurboWarp/fonts
Browse files Browse the repository at this point in the history
Custom fonts - VM part
  • Loading branch information
GarboMuffin authored Aug 16, 2023
2 parents 0690b9b + b9e84b7 commit c787bcc
Show file tree
Hide file tree
Showing 8 changed files with 983 additions and 16 deletions.
7 changes: 7 additions & 0 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const StageLayering = require('./stage-layering');
const Variable = require('./variable');
const xmlEscape = require('../util/xml-escape');
const ScratchLinkWebSocket = require('../util/scratch-link-websocket');
const FontManager = require('./tw-font-manager');

// Virtual I/O devices.
const Clock = require('../io/clock');
Expand Down Expand Up @@ -493,6 +494,11 @@ class Runtime extends EventEmitter {
* @type {Map<string, function>}
*/
this.extensionButtons = new Map();

/**
* Responsible for managing custom fonts.
*/
this.fontManager = new FontManager(this);
}

/**
Expand Down Expand Up @@ -2153,6 +2159,7 @@ class Runtime extends EventEmitter {
}
this.emit(Runtime.RUNTIME_DISPOSED);
this.ioDevices.clock.resetProjectTimer();
this.fontManager.clear();
// @todo clear out extensions? turboMode? etc.

// *********** Cloud *******************
Expand Down
230 changes: 230 additions & 0 deletions src/engine/tw-font-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
const EventEmitter = require('events');
const AssetUtil = require('../util/tw-asset-util');
const StringUtil = require('../util/string-util');
const log = require('../util/log');

/**
* @typedef InternalFont
* @property {boolean} system True if the font is built in to the system
* @property {string} family The font's name
* @property {string} fallback Fallback font family list
* @property {Asset} [asset] scratch-storage asset if system: false
*/

class FontManager extends EventEmitter {
/**
* @param {Runtime} runtime
*/
constructor (runtime) {
super();
this.runtime = runtime;
/** @type {Array<InternalFont>} */
this.fonts = [];
}

/**
* @param {string} family An unknown font family
* @returns {boolean} true if the family is valid
*/
isValidFamily (family) {
return /^[-\w ]+$/.test(family);
}

/**
* @param {string} family
* @returns {boolean}
*/
hasFont (family) {
return !!this.fonts.find(i => i.family === family);
}

/**
* @param {string} family
* @returns {boolean}
*/
getSafeName (family) {
family = family.replace(/[^-\w ]/g, '');
return StringUtil.unusedName(family, this.fonts.map(i => i.family));
}

changed () {
this.emit('change');
}

/**
* @param {string} family
* @param {string} fallback
*/
addSystemFont (family, fallback) {
if (!this.isValidFamily(family)) {
throw new Error('Invalid family');
}
this.fonts.push({
system: true,
family,
fallback
});
this.changed();
}

/**
* @param {string} family
* @param {string} fallback
* @param {Asset} asset scratch-storage asset
*/
addCustomFont (family, fallback, asset) {
if (!this.isValidFamily(family)) {
throw new Error('Invalid family');
}

this.fonts.push({
system: false,
family,
fallback,
asset
});

this.updateRenderer();
this.changed();
}

/**
* @returns {Array<{system: boolean; name: string; family: string; data: Uint8Array | null; format: string | null}>}
*/
getFonts () {
return this.fonts.map(font => ({
system: font.system,
name: font.family,
family: `"${font.family}", ${font.fallback}`,
data: font.asset ? font.asset.data : null,
format: font.asset ? font.asset.dataFormat : null
}));
}

/**
* @param {number} index Corresponds to index from getFonts()
*/
deleteFont (index) {
const [removed] = this.fonts.splice(index, 1);
if (!removed.system) {
this.updateRenderer();
}
this.changed();
}

clear () {
const hadNonSystemFont = this.fonts.some(i => !i.system);
this.fonts = [];
if (hadNonSystemFont) {
this.updateRenderer();
}
this.changed();
}

updateRenderer () {
if (!this.runtime.renderer || !this.runtime.renderer.setCustomFonts) {
return;
}

const fontfaces = {};
for (const font of this.fonts) {
if (!font.system) {
const uri = font.asset.encodeDataURI();
const fontface = `@font-face { font-family: "${font.family}"; src: url("${uri}"); }`;
const family = `"${font.family}", ${font.fallback}`;
fontfaces[family] = fontface;
}
}
this.runtime.renderer.setCustomFonts(fontfaces);
}

/**
* Get data to save in project.json and sb3 files.
*/
serializeJSON () {
if (this.fonts.length === 0) {
return null;
}

return this.fonts.map(font => {
const serialized = {
system: font.system,
family: font.family,
fallback: font.fallback
};

if (!font.system) {
const asset = font.asset;
serialized.md5ext = `${asset.assetId}.${asset.dataFormat}`;
}

return serialized;
});
}

/**
* @returns {Asset[]} list of scratch-storage assets
*/
serializeAssets () {
return this.fonts
.filter(i => !i.system)
.map(i => i.asset);
}

/**
* @param {unknown} json
* @param {JSZip} [zip]
* @param {boolean} [keepExisting]
* @returns {Promise<void>}
*/
async deserialize (json, zip, keepExisting) {
if (!keepExisting) {
this.clear();
}

if (!Array.isArray(json)) {
return;
}

for (const font of json) {
if (!font || typeof font !== 'object') {
continue;
}

try {
const system = font.system;
const family = font.family;
const fallback = font.fallback;
if (
typeof system !== 'boolean' ||
typeof family !== 'string' ||
typeof fallback !== 'string' ||
this.hasFont(family)
) {
continue;
}

if (system) {
this.addSystemFont(family, fallback);
} else {
const md5ext = font.md5ext;
if (typeof md5ext !== 'string') {
continue;
}

const asset = await AssetUtil.getByMd5ext(
this.runtime,
zip,
this.runtime.storage.AssetType.Font,
md5ext
);
this.addCustomFont(family, fallback, asset);
}
} catch (e) {
log.error('could not add font', e);
}
}
}
}

module.exports = FontManager;
21 changes: 17 additions & 4 deletions src/serialization/sb3.js
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {})
}

const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions));
const fonts = runtime.fontManager.serializeJSON();

if (targetId) {
const target = serializedTargets[0];
Expand All @@ -704,6 +705,9 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {})
if (extensionURLs) {
target.extensionURLs = extensionURLs;
}
if (fonts) {
target.customFonts = fonts;
}
return serializedTargets[0];
}

Expand All @@ -717,6 +721,10 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {})
obj.extensionURLs = extensionURLs;
}

if (fonts) {
obj.customFonts = fonts;
}

// Assemble metadata
const meta = Object.create(null);
meta.semver = '3.0.0';
Expand Down Expand Up @@ -1427,6 +1435,14 @@ const deserialize = function (json, runtime, zip, isSingleSprite) {
}
}

// Extract any custom fonts before loading costumes.
let fontPromise;
if (json.customFonts) {
fontPromise = runtime.fontManager.deserialize(json.customFonts, zip, isSingleSprite);
} else {
fontPromise = Promise.resolve();
}

// First keep track of the current target order in the json,
// then sort by the layer order property before parsing the targets
// so that their corresponding render drawables can be created in
Expand All @@ -1437,10 +1453,7 @@ const deserialize = function (json, runtime, zip, isSingleSprite) {

const monitorObjects = json.monitors || [];

return Promise.resolve(
targetObjects.map(target =>
parseScratchAssets(target, runtime, zip))
)
return fontPromise.then(() => targetObjects.map(target => parseScratchAssets(target, runtime, zip)))
// Force this promise to wait for the next loop in the js tick. Let
// storage have some time to send off asset requests.
.then(assets => Promise.resolve(assets))
Expand Down
43 changes: 43 additions & 0 deletions src/util/tw-asset-util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const StringUtil = require('./string-util');

class AssetUtil {
/**
* @param {Runtime} runtime runtime with storage attached
* @param {JSZip} zip optional JSZip to search for asset in
* @param {Storage.assetType} assetType scratch-storage asset type
* @param {string} md5ext full md5 with file extension
* @returns {Promise<Storage.Asset>} scratch-storage asset object
*/
static getByMd5ext (runtime, zip, assetType, md5ext) {
const storage = runtime.storage;
const idParts = StringUtil.splitFirst(md5ext, '.');
const md5 = idParts[0];
const ext = idParts[1].toLowerCase();

if (zip) {
// Search the root of the zip
let file = zip.file(md5ext);

// Search subfolders of the zip
// This matches behavior of deserialize-assets.js
if (!file) {
const fileMatch = new RegExp(`^([^/]*/)?${md5ext}$`);
file = zip.file(fileMatch)[0];
}

if (file) {
return file.async('uint8array').then(data => runtime.storage.createAsset(
assetType,
ext,
data,
md5,
false
));
}
}

return storage.load(assetType, md5, ext);
}
}

module.exports = AssetUtil;
Loading

0 comments on commit c787bcc

Please sign in to comment.