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('');
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user