Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
game-maker / suite (push) Successful in 9m10s
game-maker / suite (push) Successful in 9m10s
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
28c0789b19
commit
86b5363a01
35 files changed
+1451
-85
No files matched your search
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* A font is an asset holding an uploaded typeface, worn by a text object through its `font`
|
||||
* property the way a sprite wears a texture. Empty, or dangling, means the editor's own typeface.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAsset,
|
||||
CreateAssetCommand,
|
||||
CreateFontCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import type { FontAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { fontFamilyOf, fontFileOf, isFontFile } from '../../src/core/model/font';
|
||||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||
import { FONT_KEY, fontOf } from '../../src/core/properties/fonts';
|
||||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { makeProject, roundTrip } from '../helpers';
|
||||
|
||||
const FILE = `${'a1'.repeat(32)}.ttf`;
|
||||
|
||||
function makeFont(name = 'Serif', file = FILE) {
|
||||
const fixture = makeProject();
|
||||
const command = new CreateFontCommand(name, file);
|
||||
fixture.stack.execute(command);
|
||||
return { ...fixture, font: command.asset };
|
||||
}
|
||||
|
||||
describe('font files', () => {
|
||||
it('accepts only a content-addressed name in a supported format', () => {
|
||||
expect(isFontFile(FILE)).toBe(true);
|
||||
expect(isFontFile(`${'b3'.repeat(32)}.woff2`)).toBe(true);
|
||||
expect(isFontFile(`${'a1'.repeat(32)}.png`)).toBe(false);
|
||||
expect(isFontFile(`${'a1'.repeat(32)}.TTF`)).toBe(false);
|
||||
expect(isFontFile('DejaVuSerif.ttf')).toBe(false);
|
||||
expect(isFontFile('')).toBe(false);
|
||||
});
|
||||
|
||||
it('names one family per file, and never two for the same bytes', () => {
|
||||
expect(fontFamilyOf(FILE)).toBe(fontFamilyOf(FILE));
|
||||
expect(fontFamilyOf(FILE)).not.toBe(fontFamilyOf(`${'b3'.repeat(32)}.ttf`));
|
||||
// A CSS family, so it has to survive being written into a font shorthand unquoted.
|
||||
expect(fontFamilyOf(FILE)).toMatch(/^[A-Za-z][\w-]*$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the font asset', () => {
|
||||
it('is not placeable and starts without a file', () => {
|
||||
expect(isPlaceableType('font')).toBe(false);
|
||||
expect(buildAsset('font', 'Font')).toMatchObject({ type: 'font', file: '' });
|
||||
});
|
||||
|
||||
it('refuses a file name the server could not have produced', () => {
|
||||
const fixture = makeProject();
|
||||
// The stack lets the error through; the editor turns it into the notice the importer reports.
|
||||
expect(() => fixture.stack.execute(new CreateFontCommand('Bad', 'DejaVuSerif.ttf'))).toThrow(
|
||||
'is not a stored font file',
|
||||
);
|
||||
expect(fixture.project.assets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('survives a round trip and drops a file name it cannot trust', () => {
|
||||
const f = makeFont();
|
||||
expect(roundTrip(f.project)).toEqual(f.project);
|
||||
|
||||
const repaired = deserializeProject({
|
||||
name: 'P',
|
||||
assets: [
|
||||
{ id: 'f1', type: 'font', name: 'Local', file: '../../etc/passwd' },
|
||||
{ id: 'f2', type: 'font', name: 'Gone' },
|
||||
],
|
||||
});
|
||||
expect(repaired.assets.map((a) => (a as FontAsset).file)).toEqual(['', '']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Font row on a text', () => {
|
||||
it('offers font assets and nothing else', () => {
|
||||
const f = makeFont();
|
||||
f.stack.execute(new CreateAssetCommand('text', 'Title'));
|
||||
const row = schemaFor('text').find((def) => def.key === FONT_KEY);
|
||||
expect(row).toMatchObject({ kind: 'asset', default: '', assetTypes: ['font'] });
|
||||
expect(assetRefChoices(f.project, ['font']).map((a) => a.id)).toEqual([f.font.id]);
|
||||
});
|
||||
|
||||
it('resolves to a file, and to none when the id is unset or dangling', () => {
|
||||
const f = makeFont();
|
||||
const text = new CreateAssetCommand('text', 'Title');
|
||||
f.stack.execute(text);
|
||||
expect(fontOf(text.asset.props)).toBe('');
|
||||
expect(fontFileOf(f.project, '')).toBe('');
|
||||
|
||||
f.stack.execute(new SetAssetPropertyCommand(text.asset.id, FONT_KEY, f.font.id));
|
||||
expect(fontFileOf(f.project, fontOf(text.asset.props))).toBe(FILE);
|
||||
|
||||
expect(fontFileOf(f.project, 'no-such-asset')).toBe('');
|
||||
// A text asset is not a font asset: an id pointing at the wrong type wears nothing.
|
||||
expect(fontFileOf(f.project, text.asset.id)).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user