Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support

This commit is contained in:
2026-06-16 21:41:04 +03:00
parent 713f396c5c
commit 5d3aba608a
20 changed files with 1191 additions and 40 deletions
+6 -2
View File
@@ -514,9 +514,13 @@ is in the maintained plan, not the archive.
**Chart theming** (`exploration/chart-theming-scope.md`): **Chart theming** (`exploration/chart-theming-scope.md`):
- **User font upload** — FontFace-from-IndexedDB tier; theme `fonts: { family, source: 'file' }`. - ~~**User font upload** — FontFace-from-IndexedDB tier~~ ✅ (incl. variable-font weight
support; arch 05 → User-uploaded fonts). Font dependencies derive from the config, not a
theme field.
- **Google Fonts opt-in CDN tier** — keyless catalog, opt-in only. - **Google Fonts opt-in CDN tier** — keyless catalog, opt-in only.
- **SVG export font embedding** — embed face data so exported SVGs render off-app. - **SVG export font embedding** — embed face data so exported SVGs render off-app; carries
the **§08 export font-byte round-trip** too (shared base64 machinery — user fonts don't yet
travel with an exported workspace).
- **Theme↔font pairing metadata** — a suggestion nicety. - **Theme↔font pairing metadata** — a suggestion nicety.
- **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch". - **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch".
@@ -149,6 +149,8 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
swallow under §7's fail-loud rule). Chart fonts are self-hosted in swallow under §7's fail-loud rule). Chart fonts are self-hosted in
`styles/chart-fonts.css` (offered by the Theme Builder's font control); only `styles/chart-fonts.css` (offered by the Theme Builder's font control); only
their latin subsets are precached, the rest runtime-cached (vite.config Workbox). their latin subsets are precached, the rest runtime-cached (vite.config Workbox).
User-uploaded faces are registered on `document.fonts` too, so the same gate
resolves them (§3 → User-uploaded fonts).
- **Do** call `view.finalize()` on every previous view before rendering a new - **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount. one, and on component unmount.
- **Do** keep exactly one live view per preview node. - **Do** keep exactly one live view per preview node.
@@ -248,6 +250,41 @@ key-by-key), so a snippet can always override or opt out locally. The
that boundary deliberately: merge bakes the selected theme into `spec.config` that boundary deliberately: merge bakes the selected theme into `spec.config`
(spec keys win — rendering unchanged), extract lifts `spec.config` out. (spec keys win — rendering unchanged), extract lifts `spec.config` out.
### User-uploaded fonts
Beyond the self-hosted roster, a user can upload font files (`.woff2`/`.woff`/
`.ttf`/`.otf`) for chart themes. A `FontAsset` (`core/font-asset.ts`) holds the
raw bytes and persists through the standard entity-store stack — a `fonts`
IndexedDB store, `infrastructure/font-store.ts` (+ `font-migrations.ts`),
`stores/FontStore.ts`, `orchestration/font-persistence.ts` — the same
one-tier-each shape as datasets and custom themes. The browser seam that turns
stored bytes into a live face, `infrastructure/font-faces.ts`, registers on
`document.fonts` at startup (before the first render, so the §2 font gate
resolves user faces like the roster) and on each upload; uploads/deletes are
orchestrated by `services/fonts.ts`. The Type panel lists user fonts ahead of the
roster and applies one via the same `applyFontToConfig` transform.
**Font dependencies are derived from the config, never stored on the theme.** A
`CustomTheme` carries no font field: the family a theme (or a snippet) uses is
already in its config's `font`/`*Font` slots, that JSON config is the source of
truth, and snippets use uploaded fonts with no theme to carry such a field. So a
used face is discovered by scanning configs/specs for the families they reference
(`collectFontFamilies`, core) and matching against the font library — a stored
field would only drift from the config it duplicates.
(Embedding the matched faces' bytes into the §08 workspace export and per-chart
SVG export is the remaining transfer work, on shared base64 machinery.)
**Variable fonts: weight is the only leverageable axis.** `parseFontAxes` reads
the OpenType `fvar` table (uncompressed `ttf`/`otf` only — `woff`/`woff2` wrap
their tables in compression we don't unpack, so those register as static), and a
variable face is registered with its `wght``weight` and `wdth``stretch` ranges
so one file serves the whole weight range, driven by the Type panel's weight
controls. Only those two axes take effect because Vega's text rendering emits a
CSS font shorthand (family/size/weight/style) with **no `font-variation-settings`
hook** — optical size, grade, and custom axes pin at the registered default and
can't be exposed. Declaring the `wdth` range also defaults the face to normal
width rather than a variable font's possibly-condensed default instance.
### Structured controls ### Structured controls
The builder's panels — Color, Type, Layout, Axes & grid, Legend The builder's panels — Color, Type, Layout, Axes & grid, Legend
+22 -4
View File
@@ -162,10 +162,20 @@ ships, it is an explicit per-font user action, never automatic.
subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin) subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin)
CacheFirst so a script works offline after first use. Roster picked from a visual CacheFirst so a script works offline after first use. Roster picked from a visual
specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred). specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred).
6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field 6. **User font upload** ✅ (2026-06-16) — `FontAsset` (`core/font-asset.ts`) + the `fonts`
carries `{ family, source: 'file' }`. store @ DB v3, registered as a `FontFace` at startup so the render gate resolves user
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in faces like the roster (full entity-store stack: adapter/migration, `FontStore`,
expressive preset gallery ("Editorial", "Terminal", "Sketch") showcasing the roster. `font-persistence`, `services/fonts`; arch 05 → User-uploaded fonts). The Type panel
offers uploads ahead of the roster. **Variable fonts** are supported: `parseFontAxes`
reads `fvar` (uncompressed ttf/otf) and the face registers with `wght`/`wdth` ranges, so
one file drives the whole weight range — only weight/width survive Vega's text rendering
(no `font-variation-settings` hook). No `CustomTheme.fonts` field: a used face is derived
by scanning configs/specs (`collectFontFamilies`) — the config is the
source of truth, and snippets use fonts with no theme to carry a field. Not done here:
embedding font bytes into the §08 export + SVG export (shared base64 machinery, with item 7).
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding (+ §08 font-byte
round-trip, shared machinery); built-in expressive preset gallery ("Editorial",
"Terminal", "Sketch") showcasing the roster.
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers **Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
it without a second mechanism). it without a second mechanism).
@@ -224,6 +234,14 @@ the app standardizes on v6.
## 6. Status log ## 6. Status log
- **2026-06-16 (slice 6)** — **user font upload + variable-font weight support.** New
`FontAsset` entity (instance #4 of the entity-store kind, confirmed by an eng-council
pre-build consult): `fonts` store @ DB v3, adapter/migration, `FontStore`,
`font-persistence`, `services/fonts`, and the `font-faces` `FontFace`-registration seam
wired into `startup`. Type panel gains an upload control + a managed list; user fonts lead
the dropdown. Variable fonts parse `fvar` and register with `wght`/`wdth` ranges (weight
is the only axis Vega's text rendering can drive). Font dependencies are derived from the
config at export, not stored on the theme. Deferred: font bytes in the §08/SVG exports.
- **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme - **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme
was written into `config.range.*` as a bare string, which vega-lite compiles but Vega was written into `config.range.*` as a bare string, which vega-lite compiles but Vega
rejects at render ("Unrecognized scale range value") — silently caught by the gallery's rejects at render ("Unrecognized scale range value") — silently caught by the gallery's
+50
View File
@@ -75,3 +75,53 @@
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Managed list of user-uploaded fonts under the Type panel's font control. */
.fontList {
display: grid;
gap: var(--space-2);
margin: var(--space-2) 0 0;
padding: 0;
list-style: none;
}
.fontItem {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-3);
}
.fontNameWrap {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.fontName {
font-size: 13px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* "Variable" tag on a variable-font row in the managed list — passive chrome,
bordered rather than filled (arch 09 §4). */
.fontBadge {
flex: none;
padding: 1px var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.fontMeta {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
+106 -12
View File
@@ -8,10 +8,17 @@
* the per-domain panels (Axes, Legend); this panel is family, size, and weight. * the per-domain panels (Axes, Legend); this panel is family, size, and weight.
*/ */
import { useMemo, useRef } from 'react';
import { THEME_FONT_OPTIONS } from '@core/custom-theme'; import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset';
import type { JsonObject } from '@core/spec-config'; import type { JsonObject } from '@core/spec-config';
import { humanizeBytes } from '@core/storage-estimate';
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls'; import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { removeFont, uploadFontFiles } from '../services/fonts';
import { confirm } from '../stores/ConfirmStore';
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore'; import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
import { useFontStore } from '../stores/FontStore';
import { Button } from './Button';
import { SelectControl, type SelectControlOption } from './SelectControl'; import { SelectControl, type SelectControlOption } from './SelectControl';
import { ControlSection, NumberRow, SelectRow } from './ThemeFields'; import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
import styles from './ThemeFields.module.css'; import styles from './ThemeFields.module.css';
@@ -24,24 +31,36 @@ const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight']; const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight'];
/** /**
* Font options, each labelled in its own family so the dropdown previews the * The built-in roster, each labelled in its own family so the dropdown previews
* typeface (the type analogue of the color dropdowns' swatches). The roster * the typeface (the type analogue of the color dropdowns' swatches). Loaded
* (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate. * before render by the chart-renderer's font gate. User-uploaded fonts are merged
* ahead of these at render (see `TypeControls`).
*/ */
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({ const ROSTER_FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(
value, ({ value, label }) => ({ value, label, labelStyle: { fontFamily: value } }),
label, );
labelStyle: { fontFamily: value },
}));
// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel. /** A variable font's badge text — its weight range when present, else just "Variable". */
type Weight = '' | '400' | '500' | '600' | '700' | 'custom'; function variableBadge(axes: FontAxis[] | undefined): string {
const wght = axes?.find((a) => a.tag === 'wght');
return wght ? `Variable ${wght.min}${wght.max}` : 'Variable';
}
// Numeric font weights as an enum; '' is the unset (default) sentinel. The full
// 100900 range is offered so a variable font's weight axis is fully selectable;
// a static face simply faux-renders the weights it doesn't physically carry.
type Weight = '' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | 'custom';
const weightOptions: SelectControlOption<Weight>[] = [ const weightOptions: SelectControlOption<Weight>[] = [
{ value: '', label: 'Theme default' }, { value: '', label: 'Theme default' },
{ value: '100', label: 'Thin' },
{ value: '200', label: 'Extra Light' },
{ value: '300', label: 'Light' },
{ value: '400', label: 'Normal' }, { value: '400', label: 'Normal' },
{ value: '500', label: 'Medium' }, { value: '500', label: 'Medium' },
{ value: '600', label: 'Semibold' }, { value: '600', label: 'Semibold' },
{ value: '700', label: 'Bold' }, { value: '700', label: 'Bold' },
{ value: '800', label: 'Extra Bold' },
{ value: '900', label: 'Black' },
]; ];
const weightValue = (v: unknown): Weight => { const weightValue = (v: unknown): Weight => {
if (v === undefined) return ''; if (v === undefined) return '';
@@ -57,22 +76,58 @@ const weightValue = (v: unknown): Weight => {
export function TypeControls({ config }: { config: JsonObject }) { export function TypeControls({ config }: { config: JsonObject }) {
const set = useConfigSetter(); const set = useConfigSetter();
const fonts = useFontStore((s) => s.fonts);
const fileRef = useRef<HTMLInputElement>(null);
// User uploads first, then a divider, then the built-in roster — so a brand's
// own faces lead the list (SelectControl's `dividerBefore` draws the boundary).
const fontOptions = useMemo<SelectControlOption<string>[]>(() => {
const userOptions = fonts.map((f) => ({
value: fontFamilyStack(f.family),
label: f.family,
labelStyle: { fontFamily: fontFamilyStack(f.family) },
}));
const roster = ROSTER_FONT_OPTIONS.map((o, i) =>
i === 0 && userOptions.length > 0 ? { ...o, dividerBefore: true } : o,
);
return [...userOptions, ...roster];
}, [fonts]);
const currentFont = const currentFont =
typeof config.font === 'string' ? FONT_OPTIONS.find((o) => o.value === config.font) : undefined; typeof config.font === 'string' ? fontOptions.find((o) => o.value === config.font) : undefined;
const setWeight = (path: ConfigPath) => (w: Weight) => const setWeight = (path: ConfigPath) => (w: Weight) =>
set(path, w === '' ? undefined : Number(w)); set(path, w === '' ? undefined : Number(w));
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files;
if (picked && picked.length > 0) void uploadFontFiles(picked);
e.target.value = ''; // let the same file be re-picked after a removal
};
const onRemoveFont = async (id: number, family: string) => {
const ok = await confirm({
title: 'Remove font',
message:
`Remove "${family}"? Charts using it fall back to a default font. ` +
'This only removes the uploaded file from Astrolabe.',
confirmLabel: 'Remove',
danger: true,
});
if (ok) removeFont(id);
};
return ( return (
<div className={styles.panel}> <div className={styles.panel}>
<ControlSection title="Font family" hint="Applied to every text slot in the config."> <ControlSection title="Font family" hint="Applied to every text slot in the config.">
<div className={styles.field}> <div className={styles.field}>
<span className={styles.fieldLabel}>Font</span> <span className={styles.fieldLabel}>Font</span>
<div className={styles.control}>
<SelectControl <SelectControl
id="theme-builder-font" id="theme-builder-font"
label="Font family" label="Font family"
heading="Apply font" heading="Apply font"
options={FONT_OPTIONS} options={fontOptions}
value={currentFont?.value} value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)} onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={ triggerContent={
@@ -87,7 +142,46 @@ export function TypeControls({ config }: { config: JsonObject }) {
} }
triggerTitle="Write one font family into every font slot of the config" triggerTitle="Write one font family into every font slot of the config"
/> />
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
Upload font
</Button>
<input
ref={fileRef}
type="file"
accept=".woff2,.woff,.ttf,.otf"
multiple
hidden
onChange={onPickFiles}
/>
</div> </div>
</div>
{fonts.length > 0 && (
<ul className={styles.fontList}>
{fonts.map((f) => (
<li key={f.id} className={styles.fontItem}>
<span className={styles.fontNameWrap}>
<span
className={styles.fontName}
style={{ fontFamily: fontFamilyStack(f.family) }}
>
{f.family}
</span>
{isVariableFont(f.axes) && (
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
)}
</span>
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
<Button
variant="ghost"
onClick={() => void onRemoveFont(f.id, f.family)}
aria-label={`Remove ${f.family}`}
>
Remove
</Button>
</li>
))}
</ul>
)}
</ControlSection> </ControlSection>
<ControlSection title="Title"> <ControlSection title="Title">
+2 -1
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IDBFactory } from 'fake-indexeddb'; import { IDBFactory } from 'fake-indexeddb';
import { import {
DATASETS_STORE, DATASETS_STORE,
FONTS_STORE,
SNIPPETS_STORE, SNIPPETS_STORE,
THEMES_STORE, THEMES_STORE,
_resetDbForTests, _resetDbForTests,
@@ -16,7 +17,7 @@ import {
put, put,
} from './db'; } from './db';
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE]; const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE];
/** Open the raw database at `version` with a custom (or absent) upgrade body. */ /** Open the raw database at `version` with a custom (or absent) upgrade body. */
function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> { function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> {
+5 -3
View File
@@ -11,16 +11,18 @@ const DB_NAME = 'astrolabe';
/** /**
* Store-layout version. Bump only when the set of object stores / indexes * Store-layout version. Bump only when the set of object stores / indexes
* changes — independent of per-record schema versions (see snippet-migrations). * changes — independent of per-record schema versions (see snippet-migrations).
* v2 added the `themes` store (custom chart themes). * v2 added the `themes` store (custom chart themes); v3 added the `fonts` store
* (user-uploaded font faces — binary bytes, stored verbatim by structured clone).
*/ */
const DB_VERSION = 2; const DB_VERSION = 3;
export const SNIPPETS_STORE = 'snippets'; export const SNIPPETS_STORE = 'snippets';
export const DATASETS_STORE = 'datasets'; export const DATASETS_STORE = 'datasets';
export const THEMES_STORE = 'themes'; export const THEMES_STORE = 'themes';
export const FONTS_STORE = 'fonts';
/** Every object store the app expects — the open-time verification checklist. */ /** Every object store the app expects — the open-time verification checklist. */
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE] as const; const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE] as const;
let dbPromise: Promise<IDBDatabase> | null = null; let dbPromise: Promise<IDBDatabase> | null = null;
+5
View File
@@ -35,6 +35,11 @@ export function readTextFile(file: File): Promise<string> {
return file.text(); return file.text();
} }
/** Read a picked file's raw bytes (rejects on an unreadable file) — for font uploads. */
export function readBinaryFile(file: File): Promise<ArrayBuffer> {
return file.arrayBuffer();
}
/** Copy `text` to the clipboard (rejects when the browser blocks access). The /** Copy `text` to the clipboard (rejects when the browser blocks access). The
* one place outside a component that touches the clipboard API. */ * one place outside a component that touches the clipboard API. */
export function copyText(text: string): Promise<void> { export function copyText(text: string): Promise<void> {
+57
View File
@@ -0,0 +1,57 @@
/**
* FontFace registration seam (docs/architecture/02; arch 00 — only
* infrastructure touches browser APIs).
*
* Turns stored FontAsset bytes into live `FontFace`s on `document.fonts`, so the
* chart renderer's font gate (`ensureFontsLoaded` in chart-renderer.ts) and the
* Type-panel dropdown resolve user fonts exactly like the @fontsource roster.
* Registration is keyed by font id (so a delete removes precisely the right
* face), idempotent (re-registering an id replaces its face), and a no-op where
* the Font Loading API is absent (tests, SSR).
*
* A static file is one weight, registered with default descriptors (normal/400);
* a config requesting bold falls back to the browser's faux-bold. A **variable**
* file is registered with its parsed weight/width ranges (`variableFontDescriptors`),
* so the whole weight axis renders from one upload and the Type panel's weight
* controls drive it for real.
*/
import { type FontAsset, variableFontDescriptors } from '@core/font-asset';
/** id → the live face we added, so we can remove exactly it on delete/replace. */
const registered = new Map<number, FontFace>();
/** Register (or replace) a stored font as a live `FontFace` on `document.fonts`. */
export function registerFontAsset(asset: FontAsset): void {
if (typeof document === 'undefined' || !document.fonts) return;
unregisterFont(asset.id); // replace any prior face for this id (re-import)
let face: FontFace;
try {
// Variable fonts get weight/width ranges so the browser resolves a requested
// weight against the axis; a static font passes no descriptors (normal/400).
face = new FontFace(asset.family, asset.data, variableFontDescriptors(asset.axes));
} catch {
return; // a malformed family name or unreadable source — nothing to register
}
registered.set(asset.id, face);
document.fonts.add(face);
// Decode eagerly so the next chart render finds the face ready. A decode
// failure is non-fatal — the chart renders with fallback metrics — which is
// arch 02's sanctioned graceful-fallback case, kept explicit, not buried.
face.load().catch(() => {});
}
/** Register a batch (startup hydration). */
export function registerFontAssets(assets: ReadonlyArray<FontAsset>): void {
for (const asset of assets) registerFontAsset(asset);
}
/** Remove a previously-registered face by font id. No-op if not registered. */
export function unregisterFont(id: number): void {
if (typeof document === 'undefined' || !document.fonts) return;
const face = registered.get(id);
if (face) {
document.fonts.delete(face);
registered.delete(id);
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_FONT_VERSION } from '@core/font-asset';
import { migrateFontAsset } from './font-migrations';
const buf = (n: number): ArrayBuffer => new Uint8Array(n).buffer;
describe('migrateFontAsset', () => {
it('passes a current record through unchanged (plus version stamp)', () => {
const record = {
id: 4,
version: CURRENT_FONT_VERSION,
family: 'Brand Sans',
data: buf(2048),
format: 'woff2' as const,
fileName: 'Brand-Sans.woff2',
source: 'file' as const,
size: 2048,
created: '2026-06-15T10:00:00.000Z',
modified: '2026-06-15T10:00:00.000Z',
};
expect(migrateFontAsset(record)).toEqual(record);
});
it('fills missing or invalid fields with safe defaults', () => {
const migrated = migrateFontAsset({ id: '9', fileName: 'x.ttf' });
expect(migrated.id).toBe(9);
expect(migrated.version).toBe(CURRENT_FONT_VERSION);
expect(migrated.family).toBe('Custom font');
expect(migrated.format).toBe('ttf'); // recovered from the file extension
expect(migrated.data).toBeInstanceOf(ArrayBuffer);
expect(migrated.size).toBe(0);
expect(migrated.source).toBe('file');
});
it('keeps valid variation axes and drops a malformed axes value', () => {
const withAxes = migrateFontAsset({
id: 2,
family: 'Fixel',
data: buf(1),
format: 'ttf',
fileName: 'Fixel.ttf',
axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }],
});
expect(withAxes.axes).toEqual([{ tag: 'wght', min: 100, default: 400, max: 900 }]);
const badAxes = migrateFontAsset({
id: 3,
family: 'X',
data: buf(1),
format: 'ttf',
fileName: 'x.ttf',
axes: 'not-an-array',
});
expect(badAxes.axes).toBeUndefined();
});
it('keeps unknown fields written by a newer build', () => {
const migrated = migrateFontAsset({
id: 1,
family: 'Next',
data: buf(1),
format: 'otf',
fileName: 'n.otf',
futureField: 'kept',
});
expect((migrated as unknown as Record<string, unknown>).futureField).toBe('kept');
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Read-time migration for FontAsset records (docs/architecture/02 §4).
*
* Mirrors snippet/dataset/theme migrations: every font read from storage passes
* through `migrateFontAsset`, which fills missing/invalid fields and stamps the
* current version. Unknown fields are tolerated (spread the original, only fill
* gaps) so a record written by a newer build round-trips without loss.
*/
import {
CURRENT_FONT_VERSION,
detectFontFormat,
type FontAsset,
type FontAxis,
type FontFormat,
type FontSource,
} from '@core/font-asset';
const FORMATS: ReadonlyArray<FontFormat> = ['woff2', 'woff', 'ttf', 'otf'];
/** Accept a stored `axes` array only if it's shaped like FontAxis records. */
function migrateAxes(raw: unknown): FontAxis[] | undefined {
if (!Array.isArray(raw)) return undefined;
const axes = raw.filter(
(a): a is FontAxis =>
!!a &&
typeof (a as FontAxis).tag === 'string' &&
typeof (a as FontAxis).min === 'number' &&
typeof (a as FontAxis).max === 'number',
);
return axes.length > 0 ? axes : undefined;
}
/** Upgrade a raw stored record to the current FontAsset shape. */
export function migrateFontAsset(raw: unknown): FontAsset {
const r = { ...(raw as Record<string, unknown>) };
const fileName = typeof r.fileName === 'string' ? r.fileName : '';
const format: FontFormat = FORMATS.includes(r.format as FontFormat)
? (r.format as FontFormat)
: (detectFontFormat(fileName) ?? 'woff2');
const data = r.data instanceof ArrayBuffer ? r.data : new ArrayBuffer(0);
const source: FontSource = r.source === 'google' ? 'google' : 'file';
const axes = migrateAxes(r.axes);
delete r.axes; // re-attach the validated value below (or omit it entirely)
return {
...r,
id: typeof r.id === 'number' ? r.id : Number(r.id),
version: CURRENT_FONT_VERSION,
family: typeof r.family === 'string' ? r.family : 'Custom font',
data,
format,
fileName,
source,
...(axes ? { axes } : {}),
size: typeof r.size === 'number' ? r.size : data.byteLength,
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Font asset persistence adapter (docs/architecture/02; scope doc §4 → fonts).
*
* The typed seam between the FontStore and IndexedDB's `fonts` object store.
* Exposes plain async functions returning domain `FontAsset` objects and migrates
* every record on read — same contract as dataset-store / theme-store. The
* payload is binary (`ArrayBuffer`), stored verbatim by the structured-clone
* algorithm the generic `put`/`getAll` already use; no special handling needed.
*/
import { CURRENT_FONT_VERSION, type FontAsset } from '@core/font-asset';
import { FONTS_STORE, del, getAll, put } from './db';
import { migrateFontAsset } from './font-migrations';
/** Load every uploaded font, upgrading each record to the current shape. */
export async function loadFonts(): Promise<FontAsset[]> {
const records = await getAll<unknown>(FONTS_STORE);
return records.map(migrateFontAsset);
}
/** Persist a font asset at the current schema version. Propagates failures. */
export async function saveFont(font: FontAsset): Promise<void> {
await put(FONTS_STORE, { ...font, version: CURRENT_FONT_VERSION });
}
/** Permanently remove a font asset by id. */
export async function deleteFont(id: number): Promise<void> {
await del(FONTS_STORE, id);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* User font persistence wiring (docs/architecture/01 §5; scope doc §4 → fonts).
*
* The font sibling of `theme-persistence.ts`: a startup subscriber that diffs the
* `fonts` array against the previous snapshot and writes upserts/deletes through
* to the IndexedDB adapter. The store stays browser-free; failures surface as a
* toast rather than silent loss. Fonts change on explicit add/delete, so there is
* no debounce.
*/
import { deleteFont, saveFont } from '../infrastructure/font-store';
import { notify } from '../stores/NotificationStore';
import { useFontStore } from '../stores/FontStore';
type Unsubscribe = () => void;
function fontError(op: 'save' | 'delete', err: unknown) {
notify({
kind: 'error',
title: op === 'delete' ? "Couldn't delete the font" : "Couldn't save the font",
message:
'A storage error stopped Astrolabe from completing the last font change, so it may not ' +
'survive a reload. If this keeps happening, your browser may be blocking local storage.',
detail: err instanceof Error ? `Font ${op} failed: ${err.name}: ${err.message}` : String(err),
});
}
/** Persist font upserts and deletions whenever the array changes. */
export function wireFontPersistence(): Unsubscribe {
let prevFonts = useFontStore.getState().fonts;
return useFontStore.subscribe((s) => {
const next = s.fonts;
if (next === prevFonts) return;
const prev = prevFonts;
prevFonts = next;
for (const old of prev) {
if (!next.some((n) => n.id === old.id)) {
deleteFont(old.id).catch((err) => fontError('delete', err));
}
}
for (const n of next) {
const old = prev.find((p) => p.id === n.id);
if (old !== n) saveFont(n).catch((err) => fontError('save', err));
}
});
}
+21
View File
@@ -11,17 +11,22 @@
import type { Snippet } from '@core/snippet'; import type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset'; import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme'; import type { CustomTheme } from '@core/custom-theme';
import type { FontAsset } from '@core/font-asset';
import { loadSnippets } from '../infrastructure/snippet-store'; import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store'; import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store'; import { loadCustomThemes } from '../infrastructure/theme-store';
import { loadFonts } from '../infrastructure/font-store';
import { registerFontAssets } from '../infrastructure/font-faces';
import { storageErrorNotification } from '../services/storage-errors'; import { storageErrorNotification } from '../services/storage-errors';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useFontStore } from '../stores/FontStore';
import { wireSnippetPersistence } from './snippet-persistence'; import { wireSnippetPersistence } from './snippet-persistence';
import { wireDatasetPersistence } from './dataset-persistence'; import { wireDatasetPersistence } from './dataset-persistence';
import { wireThemePersistence } from './theme-persistence'; import { wireThemePersistence } from './theme-persistence';
import { wireFontPersistence } from './font-persistence';
import { startRouting } from '../modals/UrlStateSync'; import { startRouting } from '../modals/UrlStateSync';
import { startEventRouter } from './EventRouter'; import { startEventRouter } from './EventRouter';
@@ -61,15 +66,31 @@ export async function initApp(): Promise<void> {
notify(storageErrorNotification('load', err)); notify(storageErrorNotification('load', err));
} }
// User-uploaded font faces (scope doc §4). Same failure posture: a font that
// can't be loaded just won't be offered or rendered, falling back to the stack's
// generic family.
let fonts: FontAsset[] = [];
try {
fonts = await loadFonts();
} catch (err) {
notify(storageErrorNotification('load', err));
}
useSnippetStore.getState().hydrate(snippets); useSnippetStore.getState().hydrate(snippets);
useDatasetStore.getState().hydrate(datasets); useDatasetStore.getState().hydrate(datasets);
useCustomThemeStore.getState().hydrate(themes); useCustomThemeStore.getState().hydrate(themes);
useFontStore.getState().hydrate(fonts);
// Register the loaded faces on document.fonts BEFORE the first chart render so
// the renderer's font gate (ensureFontsLoaded) can resolve them like the roster.
registerFontAssets(fonts);
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set // Wire persistence AFTER hydrate so write-through's baseline is the loaded set
// — otherwise it would redundantly re-save every record on each startup. // — otherwise it would redundantly re-save every record on each startup.
wireSnippetPersistence(); wireSnippetPersistence();
wireDatasetPersistence(); wireDatasetPersistence();
wireThemePersistence(); wireThemePersistence();
wireFontPersistence();
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet // Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04). // / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
+117
View File
@@ -0,0 +1,117 @@
/**
* Font upload service (scope doc §4 → User font upload) — orchestration over the
* pure core helpers, the FontStore, and the browser seams.
*
* The deterministic work (format detection, family derivation, uniqueness) lives
* in `@core/font-asset` + `@core/naming`; this layer reads the file bytes through
* the infrastructure adapter, validates, commits to the store (the persistence
* subscriber writes through to IndexedDB), registers the live `FontFace`, and
* reports one summary toast. Deleting a font unregisters its face and removes the
* record (the subscriber deletes it from IndexedDB).
*/
import {
createFontAsset,
detectFontFormat,
familyNameFromFileName,
MAX_FONT_BYTES,
parseFontAxes,
} from '@core/font-asset';
import { makeUniqueName } from '@core/naming';
import { humanizeBytes } from '@core/storage-estimate';
import { readBinaryFile } from '../infrastructure/file-transfer';
import { registerFontAsset, unregisterFont } from '../infrastructure/font-faces';
import { notify } from '../stores/NotificationStore';
import { useFontStore } from '../stores/FontStore';
/** Why a picked file was skipped, for the summary toast. */
interface Skip {
fileName: string;
reason: string;
}
/**
* Add one or more picked font files to the library. Each file is validated
* independently (unsupported format / over the size cap are skipped, not fatal),
* given a unique family name, stored, and registered. Reports how many were added
* and why any were skipped — never throws on a bad file.
*/
export async function uploadFontFiles(files: FileList | File[]): Promise<void> {
const list = Array.from(files);
if (list.length === 0) return;
const added: string[] = [];
const skipped: Skip[] = [];
for (const file of list) {
const format = detectFontFormat(file.name);
if (!format) {
skipped.push({ fileName: file.name, reason: 'unsupported format' });
continue;
}
let data: ArrayBuffer;
try {
data = await readBinaryFile(file);
} catch {
skipped.push({ fileName: file.name, reason: 'could not be read' });
continue;
}
if (data.byteLength > MAX_FONT_BYTES) {
skipped.push({
fileName: file.name,
reason: `larger than ${humanizeBytes(MAX_FONT_BYTES)}`,
});
continue;
}
// Unique within the library (and within this batch — the store grew on the
// previous iteration, so re-read its families each time).
const family = makeUniqueName(
familyNameFromFileName(file.name),
useFontStore.getState().fonts.map((f) => f.family),
);
// Detect variation axes (variable fonts) so the face registers with the
// right weight/width ranges — uncompressed ttf/otf only; others stay static.
const axes = parseFontAxes(data, format);
const stored = useFontStore
.getState()
.add(createFontAsset({ family, data, format, fileName: file.name, axes }));
registerFontAsset(stored);
added.push(family);
}
reportUpload(added, skipped);
}
/** Remove a font: unregister its live face, then drop the record. */
export function removeFont(id: number): void {
unregisterFont(id);
useFontStore.getState().remove(id);
}
/** One summary toast covering what was added and what was skipped. */
function reportUpload(added: string[], skipped: Skip[]): void {
const skips = skipped.length
? `Skipped ${skipped.map((s) => `${s.fileName} (${s.reason})`).join(', ')}.`
: '';
if (added.length === 0) {
notify({
kind: skipped.length ? 'error' : 'info',
title: skipped.length ? "Couldn't add the font" : 'No fonts added',
message:
skips || 'Choose a .woff2, .woff, .ttf, or .otf file to add a font to your chart themes.',
});
return;
}
const noun = added.length === 1 ? 'font' : 'fonts';
const message = `Added ${added.join(', ')}. ${skips}`.trim();
notify({
kind: skipped.length ? 'warning' : 'success',
title: `Added ${added.length} ${noun}`,
message,
});
}
+4
View File
@@ -60,6 +60,10 @@ export function exportWorkspace(now: Date = new Date()): void {
return; return;
} }
// TODO(fonts §08): the envelope does not yet carry user-uploaded FontAsset
// bytes, so a theme/snippet referencing an uploaded font imports on another
// machine with the fallback family. Embed the referenced faces (base64) here —
// best done with task #3's shared font-bytes→embeddable-string helper.
const envelope = buildExportEnvelope(snippets, datasets, themes, { now }); const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2)); downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createFontAsset, type FontAsset } from '@core/font-asset';
import { useFontStore } from './FontStore';
const asset = (family: string, id = 1): FontAsset =>
createFontAsset({
family,
data: new Uint8Array(8).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
id,
});
beforeEach(() => useFontStore.getState().reset());
describe('FontStore', () => {
it('add assigns the next free id (one past the max)', () => {
const a = useFontStore.getState().add(asset('A'));
const b = useFontStore.getState().add(asset('B'));
expect(a.id).toBe(1);
expect(b.id).toBe(2);
expect(useFontStore.getState().fonts.map((f) => f.family)).toEqual(['A', 'B']);
});
it('addFonts reassigns ids and appends without clobbering', () => {
useFontStore.getState().add(asset('Existing'));
useFontStore.getState().addFonts([asset('X', 1), asset('Y', 1)]);
const fonts = useFontStore.getState().fonts;
expect(fonts.map((f) => f.id)).toEqual([1, 2, 3]);
expect(fonts.map((f) => f.family)).toEqual(['Existing', 'X', 'Y']);
});
it('remove drops only the matching id; hydrate replaces wholesale', () => {
const a = useFontStore.getState().add(asset('A'));
useFontStore.getState().add(asset('B'));
useFontStore.getState().remove(a.id);
expect(useFontStore.getState().fonts.map((f) => f.family)).toEqual(['B']);
useFontStore.getState().hydrate([asset('Loaded', 5)]);
expect(useFontStore.getState().fonts).toHaveLength(1);
expect(useFontStore.getState().fonts[0].family).toBe('Loaded');
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* User font library (scope doc §4 → User font upload; spec §04 → Chart theme).
*
* The durable collection of uploaded font faces, referenced by `family` from a
* config's font slots. Same two-layer shape as DatasetStore / CustomThemeStore:
* low-level mutators (`add`/`addFonts`/`remove`) are the single place the `fonts`
* array changes; a startup subscriber writes them through to IndexedDB.
*
* Persistence is NOT done here — `font-persistence.ts` observes this store and
* writes through to the adapter — and neither is `FontFace` registration (a
* browser side effect, owned by `services/fonts.ts` + `infrastructure/font-faces`),
* so the store stays browser-free.
*/
import { create } from 'zustand';
import type { FontAsset } from '@core/font-asset';
export interface FontState {
fonts: FontAsset[];
/** Replace the library from storage. */
hydrate: (fonts: FontAsset[]) => void;
/** Add a fully-formed asset, assigning the next free id. Returns the stored record. */
add: (font: FontAsset) => FontAsset;
/** Batch add (import path): reassign ids from the store's authority and append. */
addFonts: (incoming: FontAsset[]) => void;
/** Remove a font by id. */
remove: (id: number) => void;
/** Reset to initial state (tests). */
reset: () => void;
}
/** Next free numeric id — one past the max (same contract as `nextDatasetId`/`nextThemeId`). */
function nextFontId(fonts: ReadonlyArray<FontAsset>): number {
return fonts.reduce((max, f) => Math.max(max, f.id), 0) + 1;
}
export const useFontStore = create<FontState>((set, get) => ({
fonts: [],
hydrate: (fonts) => set({ fonts }),
add: (font) => {
const withId = { ...font, id: nextFontId(get().fonts) };
set((s) => ({ fonts: [...s.fonts, withId] }));
return withId;
},
addFonts: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
let nextId = nextFontId(s.fonts);
const withIds = incoming.map((f) => ({ ...f, id: nextId++ }));
return { fonts: [...s.fonts, ...withIds] };
});
},
remove: (id) => set((s) => ({ fonts: s.fonts.filter((f) => f.id !== id) })),
reset: () => set({ fonts: [] }),
}));
+170
View File
@@ -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({});
});
});
+265
View File
@@ -0,0 +1,265 @@
/**
* FontAsset — a user-provided font face stored once and reused across themes and
* snippets (docs/chart-theming-scope.md §4 → User font upload).
*
* Portable core: record shape, factory, format detection, family-name
* derivation, and variable-font (`fvar`) parsing. The raw bytes
* live in `data` (an `ArrayBuffer`, stored verbatim by IndexedDB's structured
* clone — `ArrayBuffer` is a platform global, not a DOM API, so this stays
* browser-free). Registering the face as a `FontFace` and persisting it are
* infrastructure concerns; this module never touches `document`/`indexedDB`.
*
* A font is referenced by its `family` — the CSS family name written into a
* config's `font`/`*Font` slots — exactly as a dataset is referenced by name.
* The family is unique across the font library (case-insensitive, via `naming`),
* so a single family maps to a single uploaded face.
*
* `source` records provenance so later tiers reuse this one store: `file` is an
* uploaded file; `google` (reserved) is a face fetched and cached from the
* keyless Google Fonts catalog. Either way the bytes live in `data`, so export
* and the SVG embed treat every FontAsset the same regardless of source.
*/
/** A FontAsset record's schema version (read-time migration target). */
export const CURRENT_FONT_VERSION = 1;
/** Supported font container formats, keyed by file extension. */
export type FontFormat = 'woff2' | 'woff' | 'ttf' | 'otf';
/**
* One variation axis of a variable font (the OpenType `fvar` table), e.g.
* `wght` (weight) or `wdth` (width). Stored on a FontAsset so registration can
* declare the right `FontFace` ranges and the UI can flag the face as variable.
*/
export interface FontAxis {
/** 4-char OpenType axis tag, e.g. `wght`, `wdth`, `opsz`. */
tag: string;
min: number;
default: number;
max: number;
}
/** Where an uploaded face came from. Only `file` ships today; `google` is reserved. */
export type FontSource = 'file' | 'google';
/**
* Soft cap on a single uploaded face, in bytes. A Latin display woff2 is tens of
* KB; this 10 MB ceiling admits a full TrueType (even a small CJK face) while
* refusing a pathological upload that would bloat IndexedDB. Enforced at the
* upload boundary (`services/fonts`), surfaced as a clear error — not silently here.
*/
export const MAX_FONT_BYTES = 10 * 1024 * 1024;
export interface FontAsset {
/** Unique numeric identifier (IndexedDB key). */
id: number;
/** Record schema version, for read-time migration. */
version: number;
/** Unique, human-readable CSS family name — the key configs reference. */
family: string;
/** The raw font-file bytes (stored verbatim; registered as a `FontFace`). */
data: ArrayBuffer;
/** Container format, from the file extension. */
format: FontFormat;
/** Original file name, kept for display and provenance. */
fileName: string;
/** Provenance: an uploaded file, or (reserved) a cached Google face. */
source: FontSource;
/**
* Variation axes if this is a variable font (parsed from `fvar` at upload),
* else absent/empty for a static face. Drives the `FontFace` weight/width
* ranges so one variable file serves the whole weight range.
*/
axes?: FontAxis[];
/** Byte length of `data`. */
size: number;
/** ISO timestamp — when first added. */
created: string;
/** ISO timestamp — when last changed (e.g. renamed). */
modified: string;
}
/** Map each accepted file extension to its container format. */
const EXTENSION_FORMAT: Record<string, FontFormat> = {
woff2: 'woff2',
woff: 'woff',
ttf: 'ttf',
otf: 'otf',
};
/**
* The container format for a file name, or `null` when the extension isn't a
* supported font format. Extension-based on purpose: font MIME types are
* inconsistent across browsers and OSes (`.ttf` arrives as `font/ttf`,
* `application/x-font-ttf`, `application/octet-stream`, or `''`), so the
* extension is the reliable signal.
*/
export function detectFontFormat(fileName: string): FontFormat | null {
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
return EXTENSION_FORMAT[ext] ?? null;
}
/**
* A clean default family name derived from a file name: drop the extension,
* turn `-`/`_` separators into spaces, collapse whitespace. `Inter-Regular.ttf`
* → `Inter Regular`. A starting point the user can rename; never authoritative.
*/
export function familyNameFromFileName(fileName: string): string {
const base = fileName.replace(/\.[^.]+$/, '');
const cleaned = base.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
return cleaned || 'Custom font';
}
export interface CreateFontAssetOptions {
/** The CSS family name (uniqueness enforced upstream — see `naming`). */
family: string;
/** The raw font-file bytes. */
data: ArrayBuffer;
/** Container format. */
format: FontFormat;
/** Original file name. */
fileName: string;
/** Provenance; defaults to `file`. */
source?: FontSource;
/** Variation axes (parsed from `fvar`) when the file is a variable font. */
axes?: FontAxis[];
/** Clock injection for deterministic tests; defaults to the current time. */
now?: Date;
/** Id injection for deterministic tests; defaults to `Date.now()`. */
id?: number;
}
/**
* Build a FontAsset: stamps version/timestamps and computes `size`. The default
* id is provisional — the store's id authority reassigns it on insert (same
* contract as `createDataset`/`createCustomTheme`).
*/
export function createFontAsset(options: CreateFontAssetOptions): FontAsset {
const iso = (options.now ?? new Date()).toISOString();
return {
id: options.id ?? Date.now(),
version: CURRENT_FONT_VERSION,
family: options.family,
data: options.data,
format: options.format,
fileName: options.fileName,
source: options.source ?? 'file',
...(options.axes && options.axes.length > 0 ? { axes: options.axes } : {}),
size: options.data.byteLength,
created: iso,
modified: iso,
};
}
/**
* The CSS font stack written into a config for an uploaded family: the quoted
* family plus a generic fallback. The upload tier doesn't know a face's category
* (serif/sans/mono), so `sans-serif` is the neutral last resort if the face
* itself fails to load. Mirrors the roster's stacks in `THEME_FONT_OPTIONS`.
*/
export function fontFamilyStack(family: string): string {
return `"${family}", sans-serif`;
}
// --- Variable-font (fvar) parsing ------------------------------------------
const SFNT_TTF = 0x00010000; // TrueType outlines
const SFNT_OTTO = 0x4f54544f; // 'OTTO' — CFF (OpenType) outlines
const SFNT_TRUE = 0x74727565; // 'true' — legacy TrueType
/** Read a 4-byte tag as ASCII. */
function readTag(view: DataView, offset: number): string {
return String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3),
);
}
/** OpenType 16.16 fixed-point (big-endian) → number. */
function readFixed(view: DataView, offset: number): number {
return view.getInt32(offset) / 65536;
}
/**
* Parse a font's variation axes from its OpenType `fvar` table, returning `[]`
* for a static font (or one we can't read).
*
* Only **uncompressed SFNT** (`ttf`/`otf`) is read directly. `woff`/`woff2` wrap
* the table data in zlib/brotli compression we don't unpack, so those uploads
* are treated as static (registered pinned) — a deliberate, safe fallback rather
* than a failure. A malformed file likewise degrades to `[]`, not a throw: axis
* detection is an enhancement, and the face still registers and renders.
*/
export function parseFontAxes(data: ArrayBuffer, format: FontFormat): FontAxis[] {
if (format !== 'ttf' && format !== 'otf') return [];
try {
if (data.byteLength < 12) return [];
const view = new DataView(data);
const sfnt = view.getUint32(0);
if (sfnt !== SFNT_TTF && sfnt !== SFNT_OTTO && sfnt !== SFNT_TRUE) return [];
const numTables = view.getUint16(4);
let fvar = -1;
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16; // table directory: 16-byte records after the 12-byte header
if (readTag(view, rec) === 'fvar') {
fvar = view.getUint32(rec + 8); // record: tag, checksum, offset, length
break;
}
}
if (fvar < 0) return [];
// fvar header: version(4) axesArrayOffset(2) reserved(2) axisCount(2)
// axisSize(2) instanceCount(2) instanceSize(2)
const axesArrayOffset = view.getUint16(fvar + 4);
const axisCount = view.getUint16(fvar + 8);
const axisSize = view.getUint16(fvar + 10);
const base = fvar + axesArrayOffset;
const axes: FontAxis[] = [];
for (let i = 0; i < axisCount; i++) {
const a = base + i * axisSize; // axis record: tag(4) min(Fixed) default(Fixed) max(Fixed) flags(2) nameID(2)
axes.push({
tag: readTag(view, a),
min: readFixed(view, a + 4),
default: readFixed(view, a + 8),
max: readFixed(view, a + 12),
});
}
return axes;
} catch {
return [];
}
}
/** Whether a face carries variation axes (i.e. it's a variable font). */
export function isVariableFont(axes: FontAxis[] | undefined): boolean {
return (axes?.length ?? 0) > 0;
}
/**
* `FontFace` descriptor ranges for a variable font so the browser resolves a
* requested weight/width against the file's axes — one variable file then serves
* the whole weight range, driven by the Type panel's weight controls. Empty for
* a static font.
*
* Only `wght`→`weight` and `wdth`→`stretch` are mapped: those flow through the
* CSS font shorthand Vega builds for canvas/SVG text, so they actually take
* effect at render. Other axes (optical size, grade, custom) have no text-render
* hook in Vega and would only pin at the registered default, so we don't pretend
* to expose them. Declaring the `wdth` range also defaults the face to normal
* width (100%) instead of a variable font's possibly-condensed default instance.
*/
export function variableFontDescriptors(axes: FontAxis[] | undefined): {
weight?: string;
stretch?: string;
} {
const out: { weight?: string; stretch?: string } = {};
const wght = axes?.find((a) => a.tag === 'wght');
if (wght) out.weight = `${wght.min} ${wght.max}`;
const wdth = axes?.find((a) => a.tag === 'wdth');
if (wdth) out.stretch = `${wdth.min}% ${wdth.max}%`;
return out;
}