Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
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:
rootandClaude Opus 5 committed 2026-09-13 20:09:59 +02:00
1 parent 28c0789b19
commit 86b5363a01
35 files changed
+1451 -85

No files matched your search

+101
View File
@@ -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('');
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Importing a typeface: the same two ways a picture arrives – picked from what the server already
* holds, or uploaded from a dropped file – with the same content-addressed reuse.
*/
import { describe, expect, it, vi } from 'vitest';
import type { FontAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import type { FontStore } from '../../src/core/store/FontStore';
import { importFonts, importStoredFont, isFontFileName } from '../../src/editor/fontImport';
import { EditorState } from '../../src/editor/state';
import { importNotice } from '../../src/editor/textureImport';
const FILE = `${'a'.repeat(64)}.ttf`;
const font = { file: FILE, bytes: 380_660 };
function setup(): EditorState {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
return state;
}
describe('isFontFileName', () => {
it('goes by the extension, because a dropped font often has no MIME type at all', () => {
expect(isFontFileName('DejaVuSerif.ttf')).toBe(true);
expect(isFontFileName('Inter.WOFF2')).toBe(true);
expect(isFontFileName('cover.png')).toBe(false);
expect(isFontFileName('ttf')).toBe(false);
});
});
describe('importStoredFont', () => {
const fonts = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
it('makes an asset out of a typeface the server already holds', () => {
const state = setup();
const outcome = importStoredFont(font, { state, fonts });
const asset = state.project.assets.find((a): a is FontAsset => a.type === 'font');
expect(asset).toMatchObject({ file: FILE, name: 'Font' });
expect(outcome).toMatchObject({ assetId: asset?.id, error: null, reused: false });
expect(state.selection).toEqual({ kind: 'asset', assetId: asset?.id });
expect(state.notice).toBe('Added the font to the project.');
state.undo();
expect(state.project.assets.filter((a) => a.type === 'font')).toEqual([]);
});
it('reuses the asset that already names the file instead of making a second one', () => {
const state = setup();
const first = importStoredFont(font, { state, fonts });
const again = importStoredFont(font, { state, fonts });
expect(again.assetId).toBe(first.assetId);
expect(again.reused).toBe(true);
expect(state.project.assets.filter((a) => a.type === 'font')).toHaveLength(1);
expect(state.notice).toBe('That font is already in the project.');
});
});
describe('importFonts', () => {
it('uploads the file untouched and names the asset after it', async () => {
const state = setup();
const upload = vi.fn(() => Promise.resolve(font));
const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
const file = new File([new Uint8Array(8)], 'DejaVuSerif.ttf', { type: 'font/ttf' });
const [outcome] = await importFonts([file], { state, fonts });
// The blob goes up as it came in: a typeface cannot be re-encoded smaller on the way.
expect(upload).toHaveBeenCalledWith(file, 'font/ttf');
expect(outcome).toMatchObject({ error: null, reused: false });
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
file: FILE,
name: 'DejaVuSerif',
});
expect(state.notice).toBe('Imported 1 font.');
});
it('reports a file that is not a font without asking the server about it', async () => {
const state = setup();
const upload = vi.fn(() => Promise.resolve(font));
const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
const [outcome] = await importFonts([new File([new Uint8Array(8)], 'cover.png')], {
state,
fonts,
});
expect(upload).not.toHaveBeenCalled();
expect(outcome?.assetId).toBeNull();
expect(state.notice).toContain('Could not import "cover.png"');
});
});
describe('importNotice', () => {
it('says what was imported, so fonts do not report as images', () => {
const done = { name: 'a.ttf', assetId: 'id', error: null, reused: false };
expect(importNotice([done], 'font')).toBe('Imported 1 font.');
expect(importNotice([done, { ...done, name: 'b.ttf' }], 'font')).toBe('Imported 2 fonts.');
expect(importNotice([{ ...done, reused: true }], 'font')).toBe('That font is already in the project.');
});
});