diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index fce0dba..8f09e02 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -514,9 +514,13 @@ is in the maintained plan, not the archive. **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. -- **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. - **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch". diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index da16082..03cfccd 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -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 `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). + 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 one, and on component unmount. - **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` (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 The builder's panels — Color, Type, Layout, Axes & grid, Legend diff --git a/docs/exploration/chart-theming-scope.md b/docs/exploration/chart-theming-scope.md index 13eb9e9..9127582 100644 --- a/docs/exploration/chart-theming-scope.md +++ b/docs/exploration/chart-theming-scope.md @@ -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) 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). -6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field - carries `{ family, source: 'file' }`. -7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in - expressive preset gallery ("Editorial", "Terminal", "Sketch") showcasing the roster. +6. **User font upload** ✅ (2026-06-16) — `FontAsset` (`core/font-asset.ts`) + the `fonts` + store @ DB v3, registered as a `FontFace` at startup so the render gate resolves user + faces like the roster (full entity-store stack: adapter/migration, `FontStore`, + `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 it without a second mechanism). @@ -224,6 +234,14 @@ the app standardizes on v6. ## 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 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 diff --git a/src/app/components/ThemeFields.module.css b/src/app/components/ThemeFields.module.css index 017a633..a8d8bfe 100644 --- a/src/app/components/ThemeFields.module.css +++ b/src/app/components/ThemeFields.module.css @@ -75,3 +75,53 @@ font-size: 10px; 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; +} diff --git a/src/app/components/TypeControls.tsx b/src/app/components/TypeControls.tsx index 4290f09..0194dd4 100644 --- a/src/app/components/TypeControls.tsx +++ b/src/app/components/TypeControls.tsx @@ -8,10 +8,17 @@ * 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 { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset'; import type { JsonObject } from '@core/spec-config'; +import { humanizeBytes } from '@core/storage-estimate'; 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 { useFontStore } from '../stores/FontStore'; +import { Button } from './Button'; import { SelectControl, type SelectControlOption } from './SelectControl'; import { ControlSection, NumberRow, SelectRow } from './ThemeFields'; import styles from './ThemeFields.module.css'; @@ -24,24 +31,36 @@ const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize']; const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight']; /** - * Font options, each labelled in its own family so the dropdown previews the - * typeface (the type analogue of the color dropdowns' swatches). The roster - * (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate. + * The built-in roster, each labelled in its own family so the dropdown previews + * the typeface (the type analogue of the color dropdowns' swatches). Loaded + * 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[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({ - value, - label, - labelStyle: { fontFamily: value }, -})); +const ROSTER_FONT_OPTIONS: SelectControlOption[] = THEME_FONT_OPTIONS.map( + ({ value, label }) => ({ value, label, labelStyle: { fontFamily: value } }), +); -// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel. -type Weight = '' | '400' | '500' | '600' | '700' | 'custom'; +/** A variable font's badge text — its weight range when present, else just "Variable". */ +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 +// 100–900 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[] = [ { value: '', label: 'Theme default' }, + { value: '100', label: 'Thin' }, + { value: '200', label: 'Extra Light' }, + { value: '300', label: 'Light' }, { value: '400', label: 'Normal' }, { value: '500', label: 'Medium' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }, + { value: '800', label: 'Extra Bold' }, + { value: '900', label: 'Black' }, ]; const weightValue = (v: unknown): Weight => { if (v === undefined) return ''; @@ -57,37 +76,112 @@ const weightValue = (v: unknown): Weight => { export function TypeControls({ config }: { config: JsonObject }) { const set = useConfigSetter(); + const fonts = useFontStore((s) => s.fonts); + const fileRef = useRef(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[]>(() => { + 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 = - 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) => set(path, w === '' ? undefined : Number(w)); + const onPickFiles = (e: React.ChangeEvent) => { + 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 (
Font - useCustomThemeStore.getState().applyDraftFont(family)} - triggerContent={ - <> - - {currentFont?.label ?? 'Apply font…'} - - - - } - triggerTitle="Write one font family into every font slot of the config" - /> +
+ useCustomThemeStore.getState().applyDraftFont(family)} + triggerContent={ + <> + + {currentFont?.label ?? 'Apply font…'} + + + + } + triggerTitle="Write one font family into every font slot of the config" + /> + + +
+ {fonts.length > 0 && ( +
    + {fonts.map((f) => ( +
  • + + + {f.family} + + {isVariableFont(f.axes) && ( + {variableBadge(f.axes)} + )} + + {humanizeBytes(f.size)} + +
  • + ))} +
+ )}
diff --git a/src/app/infrastructure/db.test.ts b/src/app/infrastructure/db.test.ts index 78100ad..1b80b09 100644 --- a/src/app/infrastructure/db.test.ts +++ b/src/app/infrastructure/db.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IDBFactory } from 'fake-indexeddb'; import { DATASETS_STORE, + FONTS_STORE, SNIPPETS_STORE, THEMES_STORE, _resetDbForTests, @@ -16,7 +17,7 @@ import { put, } 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. */ function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise { diff --git a/src/app/infrastructure/db.ts b/src/app/infrastructure/db.ts index 6ff4ea6..1a06d5b 100644 --- a/src/app/infrastructure/db.ts +++ b/src/app/infrastructure/db.ts @@ -11,16 +11,18 @@ const DB_NAME = 'astrolabe'; /** * Store-layout version. Bump only when the set of object stores / indexes * 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 DATASETS_STORE = 'datasets'; export const THEMES_STORE = 'themes'; +export const FONTS_STORE = 'fonts'; /** 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 | null = null; diff --git a/src/app/infrastructure/file-transfer.ts b/src/app/infrastructure/file-transfer.ts index eb2c2aa..03512de 100644 --- a/src/app/infrastructure/file-transfer.ts +++ b/src/app/infrastructure/file-transfer.ts @@ -35,6 +35,11 @@ export function readTextFile(file: File): Promise { return file.text(); } +/** Read a picked file's raw bytes (rejects on an unreadable file) — for font uploads. */ +export function readBinaryFile(file: File): Promise { + return file.arrayBuffer(); +} + /** Copy `text` to the clipboard (rejects when the browser blocks access). The * one place outside a component that touches the clipboard API. */ export function copyText(text: string): Promise { diff --git a/src/app/infrastructure/font-faces.ts b/src/app/infrastructure/font-faces.ts new file mode 100644 index 0000000..9a57888 --- /dev/null +++ b/src/app/infrastructure/font-faces.ts @@ -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(); + +/** 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): 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); + } +} diff --git a/src/app/infrastructure/font-migrations.test.ts b/src/app/infrastructure/font-migrations.test.ts new file mode 100644 index 0000000..6727802 --- /dev/null +++ b/src/app/infrastructure/font-migrations.test.ts @@ -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).futureField).toBe('kept'); + }); +}); diff --git a/src/app/infrastructure/font-migrations.ts b/src/app/infrastructure/font-migrations.ts new file mode 100644 index 0000000..be0d35b --- /dev/null +++ b/src/app/infrastructure/font-migrations.ts @@ -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 = ['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) }; + 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(), + }; +} diff --git a/src/app/infrastructure/font-store.ts b/src/app/infrastructure/font-store.ts new file mode 100644 index 0000000..0840d74 --- /dev/null +++ b/src/app/infrastructure/font-store.ts @@ -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 { + const records = await getAll(FONTS_STORE); + return records.map(migrateFontAsset); +} + +/** Persist a font asset at the current schema version. Propagates failures. */ +export async function saveFont(font: FontAsset): Promise { + await put(FONTS_STORE, { ...font, version: CURRENT_FONT_VERSION }); +} + +/** Permanently remove a font asset by id. */ +export async function deleteFont(id: number): Promise { + await del(FONTS_STORE, id); +} diff --git a/src/app/orchestration/font-persistence.ts b/src/app/orchestration/font-persistence.ts new file mode 100644 index 0000000..3baf807 --- /dev/null +++ b/src/app/orchestration/font-persistence.ts @@ -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)); + } + }); +} diff --git a/src/app/orchestration/startup.ts b/src/app/orchestration/startup.ts index d4ecba3..c4f0fb2 100644 --- a/src/app/orchestration/startup.ts +++ b/src/app/orchestration/startup.ts @@ -11,17 +11,22 @@ import type { Snippet } from '@core/snippet'; import type { Dataset } from '@core/dataset'; import type { CustomTheme } from '@core/custom-theme'; +import type { FontAsset } from '@core/font-asset'; import { loadSnippets } from '../infrastructure/snippet-store'; import { loadDatasets } from '../infrastructure/dataset-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 { notify } from '../stores/NotificationStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore'; +import { useFontStore } from '../stores/FontStore'; import { wireSnippetPersistence } from './snippet-persistence'; import { wireDatasetPersistence } from './dataset-persistence'; import { wireThemePersistence } from './theme-persistence'; +import { wireFontPersistence } from './font-persistence'; import { startRouting } from '../modals/UrlStateSync'; import { startEventRouter } from './EventRouter'; @@ -61,15 +66,31 @@ export async function initApp(): Promise { 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); useDatasetStore.getState().hydrate(datasets); 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 // — otherwise it would redundantly re-save every record on each startup. wireSnippetPersistence(); wireDatasetPersistence(); wireThemePersistence(); + wireFontPersistence(); // Routing starts AFTER hydrate so the on-load hash restore can resolve snippet // / dataset ids against the loaded stores (spec §01E, docs/architecture/04). diff --git a/src/app/services/fonts.ts b/src/app/services/fonts.ts new file mode 100644 index 0000000..bc44406 --- /dev/null +++ b/src/app/services/fonts.ts @@ -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 { + 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, + }); +} diff --git a/src/app/services/transfer.ts b/src/app/services/transfer.ts index 8ff58c7..da6a110 100644 --- a/src/app/services/transfer.ts +++ b/src/app/services/transfer.ts @@ -60,6 +60,10 @@ export function exportWorkspace(now: Date = new Date()): void { 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 }); downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2)); diff --git a/src/app/stores/FontStore.test.ts b/src/app/stores/FontStore.test.ts new file mode 100644 index 0000000..2dc1a58 --- /dev/null +++ b/src/app/stores/FontStore.test.ts @@ -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'); + }); +}); diff --git a/src/app/stores/FontStore.ts b/src/app/stores/FontStore.ts new file mode 100644 index 0000000..ad8b15b --- /dev/null +++ b/src/app/stores/FontStore.ts @@ -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): number { + return fonts.reduce((max, f) => Math.max(max, f.id), 0) + 1; +} + +export const useFontStore = create((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: [] }), +})); diff --git a/src/core/font-asset.test.ts b/src/core/font-asset.test.ts new file mode 100644 index 0000000..152c3c9 --- /dev/null +++ b/src/core/font-asset.test.ts @@ -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({}); + }); +}); diff --git a/src/core/font-asset.ts b/src/core/font-asset.ts new file mode 100644 index 0000000..1b6783b --- /dev/null +++ b/src/core/font-asset.ts @@ -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 = { + 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; +}