Skip to content

Commit

Permalink
Implement custom font manager
Browse files Browse the repository at this point in the history
  • Loading branch information
GarboMuffin committed Aug 15, 2023
1 parent efa7e9d commit 1ba70ac
Show file tree
Hide file tree
Showing 7 changed files with 830 additions and 8 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
227 changes: 227 additions & 0 deletions src/engine/tw-font-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
const EventEmitter = require('events');

/**
* @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
*/

const AssetUtil = require('../util/tw-asset-util');

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

this.runtime = runtime;

/**
* Maps font family names to font metadata
* @type {Array<InternalFont>}
*/
this.fonts = [];

this.suppressEvents = false;
}

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

/**
* @param {string} familyList A list of font families
* @returns {boolean} true if the list is valid
*/
isValidFamilyList (familyList) {
const families = familyList.split(',');
return families.every(i => this.isValidFamily(i));
}

changed () {
if (!this.suppressEvents) {
this.emit('change');
}
}

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

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

const storage = this.runtime.storage;
const asset = data instanceof storage.Asset ?
data :
storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
data,
null,
true
);

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

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

/**
* @returns {Array<{name: string; family: string;}>}
*/
getFonts () {
return this.fonts.map(font => ({
name: font.family,
family: `${font.family}, ${font.fallback}`
}));
}

/**
* @param {number} index Corresponds to index from getFonts()
*/
deleteFont (index) {
this.fonts.splice(index, 1);
this.updateRenderer();
this.changed();
}

clear () {
this.fonts = [];
this.updateRenderer();
this.changed();
}

updateRenderer () {
if (!this.runtime.renderer) {
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.
* @returns {{json: any; assets: Array<any>;}|null}
*/
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 (!Array.isArray(json)) {
if (!keepExisting) {
this.clear();
}
return;
}

this.suppressEvents = true;
if (!keepExisting) {
this.clear();
}

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

const system = font.system;
const family = font.family;
const fallback = font.fallback;
if (typeof system !== 'boolean' || typeof family !== 'string' || typeof fallback !== 'string') 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);
}
}

this.suppressEvents = false;
if (json.length || !keepExisting) {
this.changed();
}
}
}

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;
21 changes: 17 additions & 4 deletions src/virtual-machine.js
Original file line number Diff line number Diff line change
Expand Up @@ -554,15 +554,20 @@ class VirtualMachine extends EventEmitter {
return files;
}

/*
* @type {Array<object>} Array of all costumes and sounds currently in the runtime
/**
* @type {Array<object>} Array of all assets currently in the runtime
*/
get assets () {
return this.runtime.targets.reduce((acc, target) => (
const costumesAndSounds = this.runtime.targets.reduce((acc, target) => (
acc
.concat(target.sprite.sounds.map(sound => sound.asset))
.concat(target.sprite.costumes.map(costume => costume.asset))
), []);
const fonts = this.runtime.fontManager.serializeAssets();
return [
...costumesAndSounds,
...fonts
];
}

/**
Expand All @@ -572,7 +577,15 @@ class VirtualMachine extends EventEmitter {
serializeAssets (targetId) {
const costumeDescs = serializeCostumes(this.runtime, targetId);
const soundDescs = serializeSounds(this.runtime, targetId);
return costumeDescs.concat(soundDescs);
const fontDescs = this.runtime.fontManager.serializeAssets().map(asset => ({
fileName: `${asset.assetId}.${asset.dataFormat}`,
fileContent: asset.data
}));
return [
...costumeDescs,
...soundDescs,
...fontDescs
];
}

_addFileDescsToZip (fileDescs, zip) {
Expand Down
Loading

0 comments on commit 1ba70ac

Please sign in to comment.