mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support
This commit is contained in:
@@ -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".
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
labelStyle: { fontFamily: value },
|
||||
}));
|
||||
const ROSTER_FONT_OPTIONS: SelectControlOption<string>[] = 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<Weight>[] = [
|
||||
{ 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,22 +76,58 @@ const weightValue = (v: unknown): Weight => {
|
||||
|
||||
export function TypeControls({ config }: { config: JsonObject }) {
|
||||
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 =
|
||||
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<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 (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Font</span>
|
||||
<div className={styles.control}>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Font family"
|
||||
heading="Apply font"
|
||||
options={FONT_OPTIONS}
|
||||
options={fontOptions}
|
||||
value={currentFont?.value}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={
|
||||
@@ -87,7 +142,46 @@ export function TypeControls({ config }: { config: JsonObject }) {
|
||||
}
|
||||
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>
|
||||
{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 title="Title">
|
||||
|
||||
@@ -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<IDBDatabase> {
|
||||
|
||||
@@ -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<IDBDatabase> | null = null;
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ export function readTextFile(file: File): Promise<string> {
|
||||
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
|
||||
* one place outside a component that touches the clipboard API. */
|
||||
export function copyText(text: string): Promise<void> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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).
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user