/** * 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`)); // The same bytes in two accounts' folders (#344) are one face, and a slash is no family name. expect(fontFamilyOf(`0123456789ab/${FILE}`)).toBe(fontFamilyOf(FILE)); // 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(''); }); });