Files
astrolabe/src/core/font-asset.test.ts
T

171 lines
5.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
CURRENT_FONT_VERSION,
createFontAsset,
detectFontFormat,
familyNameFromFileName,
type FontAxis,
fontFamilyStack,
isVariableFont,
parseFontAxes,
variableFontDescriptors,
} from './font-asset';
const bytes = (n: number): ArrayBuffer => new Uint8Array(n).fill(1).buffer;
/**
* Build a minimal SFNT with an `fvar` table carrying the given axes, so the
* parser is exercised against real byte layout without committing a font binary.
* Layout: 12-byte sfnt header, one 16-byte table record ('fvar'), then the fvar
* table (16-byte header + 20 bytes per axis). Mirrors the offsets parseFontAxes
* reads — verified against a real Fixel variable file during development.
*/
function buildVariableSfnt(axes: Array<[string, number, number, number]>): ArrayBuffer {
const axisSize = 20;
const fvarHeaderLen = 16;
const fvarLen = fvarHeaderLen + axes.length * axisSize;
const tableOffset = 12 + 16; // sfnt header + one table record
const buf = new ArrayBuffer(tableOffset + fvarLen);
const v = new DataView(buf);
v.setUint32(0, 0x00010000); // sfntVersion (TrueType)
v.setUint16(4, 1); // numTables
for (let i = 0; i < 4; i++) v.setUint8(12 + i, 'fvar'.charCodeAt(i));
v.setUint32(12 + 8, tableOffset); // table record: offset
v.setUint32(12 + 12, fvarLen); // table record: length
v.setUint16(tableOffset, 1); // fvar major version
v.setUint16(tableOffset + 4, fvarHeaderLen); // axesArrayOffset
v.setUint16(tableOffset + 8, axes.length); // axisCount
v.setUint16(tableOffset + 10, axisSize); // axisSize
const base = tableOffset + fvarHeaderLen;
axes.forEach(([tag, min, def, max], i) => {
const a = base + i * axisSize;
for (let j = 0; j < 4; j++) v.setUint8(a + j, tag.charCodeAt(j));
v.setInt32(a + 4, min * 65536);
v.setInt32(a + 8, def * 65536);
v.setInt32(a + 12, max * 65536);
});
return buf;
}
describe('detectFontFormat', () => {
it('maps supported extensions, case-insensitively', () => {
expect(detectFontFormat('Inter.woff2')).toBe('woff2');
expect(detectFontFormat('Inter.WOFF')).toBe('woff');
expect(detectFontFormat('Inter.ttf')).toBe('ttf');
expect(detectFontFormat('Inter.OTF')).toBe('otf');
});
it('reads the last extension and rejects unsupported / extensionless names', () => {
expect(detectFontFormat('My.Font.woff2')).toBe('woff2');
expect(detectFontFormat('Inter.eot')).toBeNull();
expect(detectFontFormat('Inter')).toBeNull();
expect(detectFontFormat('')).toBeNull();
});
});
describe('familyNameFromFileName', () => {
it('drops the extension and normalizes separators', () => {
expect(familyNameFromFileName('Inter-Regular.ttf')).toBe('Inter Regular');
expect(familyNameFromFileName('my_cool_font.woff2')).toBe('my cool font');
expect(familyNameFromFileName('Spaced Out.otf')).toBe('Spaced Out');
});
it('falls back when the name reduces to nothing', () => {
expect(familyNameFromFileName('.woff2')).toBe('Custom font');
});
});
describe('createFontAsset', () => {
it('stamps version/timestamps/size and defaults source to file', () => {
const now = new Date('2026-06-15T12:00:00.000Z');
const asset = createFontAsset({
family: 'My Font',
data: bytes(2048),
format: 'woff2',
fileName: 'My-Font.woff2',
now,
id: 7,
});
expect(asset).toMatchObject({
id: 7,
version: CURRENT_FONT_VERSION,
family: 'My Font',
format: 'woff2',
fileName: 'My-Font.woff2',
source: 'file',
size: 2048,
created: now.toISOString(),
modified: now.toISOString(),
});
});
it('carries an explicit source', () => {
expect(
createFontAsset({
family: 'G',
data: bytes(1),
format: 'woff2',
fileName: 'g.woff2',
source: 'google',
}).source,
).toBe('google');
});
});
describe('fontFamilyStack', () => {
it('quotes the family and appends a generic fallback', () => {
expect(fontFamilyStack('My Font')).toBe('"My Font", sans-serif');
});
});
describe('parseFontAxes', () => {
it('reads variation axes from an SFNT fvar table (ttf/otf)', () => {
const sfnt = buildVariableSfnt([
['wght', 100, 400, 900],
['wdth', 75, 100, 100],
]);
expect(parseFontAxes(sfnt, 'ttf')).toEqual([
{ tag: 'wght', min: 100, default: 400, max: 900 },
{ tag: 'wdth', min: 75, default: 100, max: 100 },
]);
expect(parseFontAxes(sfnt, 'otf')).toHaveLength(2);
});
it('returns [] for a static font (no fvar table)', () => {
// A valid sfnt header claiming zero tables — no fvar to find.
const buf = new ArrayBuffer(12);
new DataView(buf).setUint32(0, 0x00010000);
expect(parseFontAxes(buf, 'ttf')).toEqual([]);
});
it('treats woff/woff2 as static — their tables are compressed, not parsed', () => {
const sfnt = buildVariableSfnt([['wght', 100, 400, 900]]);
expect(parseFontAxes(sfnt, 'woff2')).toEqual([]);
expect(parseFontAxes(sfnt, 'woff')).toEqual([]);
});
it('degrades to [] on a malformed file rather than throwing', () => {
expect(parseFontAxes(new Uint8Array([1, 2, 3]).buffer, 'ttf')).toEqual([]);
});
});
describe('variable-font helpers', () => {
const wght: FontAxis = { tag: 'wght', min: 100, default: 400, max: 900 };
const wdth: FontAxis = { tag: 'wdth', min: 75, default: 100, max: 100 };
it('isVariableFont reflects whether axes are present', () => {
expect(isVariableFont([wght])).toBe(true);
expect(isVariableFont([])).toBe(false);
expect(isVariableFont(undefined)).toBe(false);
});
it('maps wght→weight range and wdth→stretch range; ignores other axes', () => {
expect(variableFontDescriptors([wght, wdth])).toEqual({
weight: '100 900',
stretch: '75% 100%',
});
expect(variableFontDescriptors([{ tag: 'opsz', min: 8, default: 14, max: 144 }])).toEqual({});
expect(variableFontDescriptors(undefined)).toEqual({});
});
});