mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user