Files
001code-html--cocos/scratch-vm/test/integration/tw_font_manager.js
刘宇飞 6e0a1fbcbb Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:37:45 +08:00

590 lines
18 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const _makeTestStorage = require('../fixtures/make-test-storage');
const Runtime = require('../../src/engine/runtime');
const VirtualMachine = require('../../src/virtual-machine');
const emptyProjectFixture = path.join(__dirname, '..', 'fixtures', 'tw-empty-project.sb3');
const makeTestStorage = () => {
const storage = _makeTestStorage();
storage.DataFormat.TTF = 'ttf';
storage.AssetType.Font = {
contentType: 'font/ttf',
name: 'Font',
runtimeFormat: storage.DataFormat.TTF,
immutable: true
};
return storage;
};
test('isValidFamily', 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.end();
});
test('getSafeName', t => {
const {fontManager} = new Runtime();
t.equal(fontManager.getSafeName('Arial'), 'Arial');
fontManager.addSystemFont('Arial', 'sans-serif');
t.equal(fontManager.getSafeName('Arial'), 'Arial2');
t.equal(fontManager.getSafeName('Weird123!@"<>?'), 'Weird123');
t.end();
});
test('system font', t => {
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: () => {
t.fail('Should not call renderer.setCustomFonts()');
}
};
const rt = new Runtime();
rt.attachRenderer(mockRenderer);
const {fontManager} = rt;
let changed = false;
fontManager.on('change', () => {
changed = true;
});
fontManager.addSystemFont('Noto Sans Mono', 'monospace');
t.ok(changed, 'addSystemFont() emits change');
t.ok(fontManager.hasFont('Noto Sans Mono'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: true,
name: 'Noto Sans Mono',
family: '"Noto Sans Mono", monospace',
data: null,
format: null
}
]);
t.same(fontManager.serializeJSON(), [
{
system: true,
family: 'Noto Sans Mono',
fallback: 'monospace'
}
]);
t.same(fontManager.serializeAssets(), []);
changed = false;
fontManager.addSystemFont('Lobster', 'fantasy, sans-serif');
t.ok(changed, 'addSystemFont() emits change');
t.ok(fontManager.hasFont('Lobster'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: true,
name: 'Noto Sans Mono',
family: '"Noto Sans Mono", monospace',
data: null,
format: null
},
{
system: true,
name: 'Lobster',
family: '"Lobster", fantasy, sans-serif',
data: null,
format: null
}
]);
t.same(fontManager.serializeJSON(), [
{
system: true,
family: 'Noto Sans Mono',
fallback: 'monospace'
},
{
system: true,
family: 'Lobster',
fallback: 'fantasy, sans-serif'
}
]);
t.same(fontManager.serializeAssets(), []);
t.end();
});
test('system font validation', t => {
const {fontManager} = new Runtime();
t.throws(() => {
fontManager.addCustomFont(';', 'monospace');
});
t.end();
});
test('clear', t => {
const setCustomFontsCalls = [];
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: fonts => {
setCustomFontsCalls.push(fonts);
}
};
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
rt.attachRenderer(mockRenderer);
const {fontManager, storage} = rt;
fontManager.addSystemFont('Arial', 'sans-serif');
t.equal(fontManager.getFonts().length, 1);
let changed = false;
fontManager.on('change', () => {
changed = true;
});
fontManager.clear();
t.ok(changed, 'clear() emits change');
t.equal(fontManager.getFonts().length, 0, 'removed font');
t.same(setCustomFontsCalls, [], 'clear() does not call setCustomFonts() if only system fonts');
fontManager.addCustomFont('Wingdings', 'monospace', storage.createAsset(
storage.AssetType.Font,
'ttf',
new Uint8Array([11, 12, 13]),
null,
true
));
changed = false;
setCustomFontsCalls.length = 0;
fontManager.clear();
t.ok(changed, 'clear() emits change');
t.same(setCustomFontsCalls, [{}], 'clear() clears setCustomFonts()');
t.end();
});
test('custom fonts', t => {
const setCustomFontsCalls = [];
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: customFonts => {
setCustomFontsCalls.push(customFonts);
}
};
const rt = new Runtime();
rt.attachRenderer(mockRenderer);
rt.attachStorage(makeTestStorage());
const {fontManager, storage} = rt;
let changed = false;
fontManager.on('change', () => {
changed = true;
});
fontManager.addCustomFont('Arial', 'sans-serif', storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([1, 2, 3]),
null,
true
));
t.ok(changed, 'addCustomFont() emits change');
t.ok(fontManager.hasFont('Arial'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: false,
name: 'Arial',
family: '"Arial", sans-serif',
data: new Uint8Array([1, 2, 3]),
format: 'ttf'
}
]);
t.same(fontManager.serializeJSON(), [
{
system: false,
family: 'Arial',
fallback: 'sans-serif',
md5ext: '5289df737df57326fcdd22597afb1fac.ttf'
}
]);
t.same(setCustomFontsCalls, [
{
// eslint-disable-next-line max-len
'"Arial", sans-serif': '@font-face { font-family: "Arial"; src: url("data:font/ttf;base64,AQID"); }'
}
]);
const assets = fontManager.serializeAssets();
t.equal(assets.length, 1);
t.same(assets[0].data, new Uint8Array([1, 2, 3]));
changed = false;
setCustomFontsCalls.length = 0;
const asset = storage.createAsset(
storage.AssetType.Font,
'woff2',
new Uint8Array([4, 5, 6]),
null,
true
);
fontManager.addCustomFont('Comic Sans MS', 'serif', asset);
t.ok(changed, 'addCustomFont() emits change');
t.ok(fontManager.hasFont('Comic Sans MS'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: false,
name: 'Arial',
family: '"Arial", sans-serif',
data: new Uint8Array([1, 2, 3]),
format: 'ttf'
},
{
system: false,
name: 'Comic Sans MS',
family: '"Comic Sans MS", serif',
data: new Uint8Array([4, 5, 6]),
format: 'woff2'
}
]);
t.same(fontManager.serializeJSON(), [
{
system: false,
family: 'Arial',
fallback: 'sans-serif',
md5ext: '5289df737df57326fcdd22597afb1fac.ttf'
},
{
system: false,
family: 'Comic Sans MS',
fallback: 'serif',
md5ext: 'b4a3ba90641372b4e4eaa841a5a400ec.woff2'
}
]);
t.same(setCustomFontsCalls, [
{
// eslint-disable-next-line max-len
'"Arial", sans-serif': '@font-face { font-family: "Arial"; src: url("data:font/ttf;base64,AQID"); }',
// eslint-disable-next-line max-len
'"Comic Sans MS", serif': '@font-face { font-family: "Comic Sans MS"; src: url("data:font/ttf;base64,BAUG"); }'
}
]);
const assets2 = fontManager.serializeAssets();
t.equal(assets2.length, 2);
t.equal(assets2[0], assets[0]);
t.equal(assets2[1], asset);
t.end();
});
test('custom font validation', t => {
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
const {fontManager, storage} = rt;
t.throws(() => {
fontManager.addCustomFont('family;', 'sans-serif', storage.createAsset(
storage.DataFormat.Font,
storage.DataFormat.TTF,
new Uint8Array([1]),
null,
true
));
});
t.end();
});
test('deleteFont', t => {
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
const {fontManager, storage} = rt;
fontManager.addSystemFont('Liberation Mono', 'monospace');
fontManager.addCustomFont('Noto Sans Mono', 'monospace', storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([17, 18, 19]),
null,
true
));
t.ok(fontManager.hasFont('Liberation Mono'), 'has font initially');
t.ok(fontManager.hasFont('Noto Sans Mono'), 'has font initially');
let changed = false;
fontManager.on('change', () => {
changed = true;
});
const setCustomFontsCalls = [];
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: customFonts => {
setCustomFontsCalls.push(customFonts);
}
};
rt.attachRenderer(mockRenderer);
fontManager.deleteFont(1);
t.ok(changed, 'deleteFont() emits change');
t.ok(fontManager.hasFont('Liberation Mono'), 'kept font');
t.notOk(fontManager.hasFont('Noto Sans Mono'), 'deleted font');
t.same(setCustomFontsCalls, [{}], 'called setCustomFonts() after deleting non-system font');
t.same(fontManager.getFonts(), [
{
system: true,
name: 'Liberation Mono',
family: '"Liberation Mono", monospace',
data: null,
format: null
}
], 'updated getFonts() after deleting');
changed = false;
fontManager.deleteFont(0);
t.ok(changed, 'deleteFont() emits change');
t.same(setCustomFontsCalls, [{}], 'did not call setCustomFonts() again after deleting system font');
t.same(fontManager.getFonts(), [], 'updated getFonts() after deleting');
t.end();
});
test('fonts are serialized by VM', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
const {storage, fontManager} = vm.runtime;
fontManager.addSystemFont('DejaVu Sans', 'sans-serif');
const fontAsset = storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([10, 11, 12]),
null,
true
);
fontManager.addCustomFont('Noto Color Emoji', 'emoji', fontAsset);
const assets = vm.assets;
t.same(assets, [fontAsset], 'font is in vm.assets');
const serializedAssets = vm.serializeAssets();
t.same(serializedAssets, [
{
fileName: '94263e4d553bcec128704e354b659526.ttf',
fileContent: new Uint8Array([10, 11, 12])
}
], 'font is in vm.serializeAssets()');
const notZippedProject = vm.saveProjectSb3DontZip();
t.equal(
notZippedProject['94263e4d553bcec128704e354b659526.ttf'],
fontAsset.data,
'font is in saveProjectSb3DontZip()'
);
const projectJSON = JSON.parse(vm.toJSON());
t.same(projectJSON.customFonts, [
{
system: true,
family: 'DejaVu Sans',
fallback: 'sans-serif'
},
{
system: false,
family: 'Noto Color Emoji',
fallback: 'emoji',
md5ext: '94263e4d553bcec128704e354b659526.ttf'
}
], 'font is in vm.toJSON()');
t.end();
});
test('does not serialize fonts if there are none', t => {
const vm = new VirtualMachine();
const json = JSON.parse(vm.toJSON());
t.not('customFonts' in json);
t.end();
});
test('serialization and deserialization roundtrip - project', t => {
const originalVM = new VirtualMachine();
originalVM.attachStorage(makeTestStorage());
const {storage, fontManager} = originalVM.runtime;
originalVM.loadProject(fs.readFileSync(emptyProjectFixture)).then(() => {
// Add our custom fonts here
fontManager.addSystemFont('Ubuntu Mono', 'monospace');
const fontAsset = storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([20, 21, 22, 23, 24]),
null,
true
);
fontManager.addCustomFont('Inter', 'sans-serif', fontAsset);
originalVM.saveProjectSb3('arraybuffer').then(projectSb3 => {
const newVM = new VirtualMachine();
newVM.attachStorage(makeTestStorage());
const newFontManager = newVM.runtime.fontManager;
newFontManager.addSystemFont('ShouldBeRemoved', 'sans-serif');
let changed = false;
newFontManager.on('change', () => {
changed = true;
});
newVM.loadProject(projectSb3).then(() => {
t.ok(changed, 'loadProject() emits change');
t.same(newFontManager.getFonts(), [
{
system: true,
name: 'Ubuntu Mono',
family: '"Ubuntu Mono", monospace',
data: null,
format: null
},
{
system: false,
name: 'Inter',
family: '"Inter", sans-serif',
data: new Uint8Array([20, 21, 22, 23, 24]),
format: 'ttf'
}
], 'preserved in getFonts()');
t.same(newFontManager.serializeJSON(), [
{
system: true,
family: 'Ubuntu Mono',
fallback: 'monospace'
},
{
system: false,
family: 'Inter',
fallback: 'sans-serif',
md5ext: '316f84429ec778137b2f5c6f893c7e41.ttf'
}
], 'preserved in serializeJSON()');
const assets = newFontManager.serializeAssets();
t.equal(assets.length, 1);
t.same(assets[0].data, new Uint8Array([20, 21, 22, 23, 24]), 'preserved in serializeAssets()');
t.end();
});
});
});
});
test('serialization and deserialization roundtrip - target', t => {
const originalVM = new VirtualMachine();
originalVM.attachStorage(makeTestStorage());
const {fontManager, storage} = originalVM.runtime;
originalVM.loadProject(fs.readFileSync(emptyProjectFixture)).then(() => {
// The fixture we use only contains a stage. We'll convert it to a sprite so we can
// addSprite() it later.
const sprite = originalVM.runtime.targets[0];
sprite.isStage = false;
const noFontsJSON = JSON.parse(originalVM.toJSON(sprite.id));
t.notOk('customFonts' in noFontsJSON, 'does not serialize fonts in target if no fonts');
fontManager.addCustomFont('Noto Sans Traditional Chinese', 'sans-serif', storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([97, 98, 99]),
null,
true
));
fontManager.addSystemFont('FreeSans', 'sans-serif');
const spriteJSON = JSON.parse(originalVM.toJSON(sprite.id));
t.same(spriteJSON.customFonts, [
{
system: false,
family: 'Noto Sans Traditional Chinese',
fallback: 'sans-serif',
md5ext: '900150983cd24fb0d6963f7d28e17f72.ttf'
},
{
system: true,
family: 'FreeSans',
fallback: 'sans-serif'
}
], 'serializes custom fonts to target');
originalVM.exportSprite(sprite.id, 'uint8array').then(exportedSprite => {
const newVM = new VirtualMachine();
newVM.attachStorage(makeTestStorage());
const newFontManager = newVM.runtime.fontManager;
newVM.loadProject(fs.readFileSync(emptyProjectFixture)).then(() => {
// The existing fonts should not be removed or overwritten
newFontManager.addSystemFont('Liberation Sans', 'sans-serif');
newFontManager.addSystemFont('FreeSans', 'monospace');
let changed = false;
newFontManager.on('change', () => {
changed = true;
});
newVM.addSprite(exportedSprite).then(() => {
t.ok(changed, 'addSprite() emits change');
t.same(newFontManager.getFonts(), [
// Importing a sprite should not overwrite old fonts.
{
system: true,
name: 'Liberation Sans',
family: '"Liberation Sans", sans-serif',
data: null,
format: null
},
{
system: true,
name: 'FreeSans',
family: '"FreeSans", monospace',
data: null,
format: null
},
{
system: false,
name: 'Noto Sans Traditional Chinese',
family: '"Noto Sans Traditional Chinese", sans-serif',
data: new Uint8Array([97, 98, 99]),
format: 'ttf'
}
], 'imported fonts from sprite');
t.end();
});
});
});
});
});
test('deserializes ignores invalid fonts', t => {
const {fontManager} = new Runtime();
fontManager.deserialize([
{
system: true,
family: ';} body { display: none; }',
fallback: 'sans-serif'
},
{
system: true,
family: 'Source Code Pro',
fallback: 'monospace'
}
], null, false).then(() => {
t.equal(fontManager.getFonts().length, 1);
t.equal(fontManager.getFonts()[0].name, 'Source Code Pro');
t.end();
});
});