Fonts: carry uploaded faces through export/import + embed in SVG export

This commit is contained in:
2026-06-16 23:52:16 +03:00
parent 2ed9db3792
commit d8a7bcb7c1
16 changed files with 810 additions and 90 deletions
-21
View File
@@ -500,21 +500,6 @@ surfaces.
---
## Open divergences (spec ↔ code)
Surfaced by the full-docs consistency review — each needs a deliberate resolution,
not a silent drift:
- ~~**Storage-monitor scope.** Spec §02 frames the indicator as the **snippet** storage
budget specifically; the shipped estimate instead reported **whole-origin** usage/quota.~~
**Resolved** — rather than pick "snippet budget" vs. "whole-origin", the monitor was
redesigned into a **composition breakdown** (snippets · datasets · app) that drops the
unreliable browser quota entirely and shows real measured sizes. Spec §02 + §10 and
[arch 02 §6](architecture/02-persistence.md) updated; council resolution recorded in
[arch 10](architecture/10-interaction-and-feedback.md).
---
## Open items (carried from the exploration memos)
Deferred features whose reasoning lives in `docs/exploration/`; pulled here so the backlog
@@ -522,13 +507,7 @@ is in the maintained plan, not the archive.
**Chart theming** (`exploration/chart-theming-scope.md`):
- ~~**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; 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".
- **Color panel swatch reorder** — the remaining slice-4b control (reorder a materialized
+25 -4
View File
@@ -171,10 +171,17 @@ ships, it is an explicit per-font user action, never automatic.
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",
source of truth, and snippets use fonts with no theme to carry a field.
7. **Font export round-trip** ✅ (2026-06-16) — uploaded faces travel base64-encoded in the
§08 envelope (additive `fonts` array, decoded on import, **skipped** on family clash so a
self-backup doesn't pile up copies, rolled back with the rest on a failed import, registered
live so they render without reload), and the per-chart **SVG export embeds** the referenced
uploaded faces as `@font-face` data-URIs so an exported vector renders the right type off-app.
Shared base64 + `fontDataUri`/`primaryFamilyName`/`serializeFontAsset` machinery in
`core/font-asset.ts`; SVG embed + family-matching in `core/chart-export.ts`. Roster and
system stacks are never embedded (decoration with their own fallbacks; their bytes aren't in
the font library).
8. **Deferred** — Google Fonts opt-in tier; built-in expressive preset gallery ("Editorial",
"Terminal", "Sketch") showcasing the roster.
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
@@ -234,6 +241,20 @@ the app standardizes on v6.
## 6. Status log
- **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now
survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/
`deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in
`font-asset.ts`; the §08 envelope grew an additive `fonts` array (`export-envelope.ts`),
`normalizeImport` decodes it, and a new `dropClashingFonts` enforces the skip-on-clash merge
rule (`import-normalize.ts`). Service: `transfer.ts` exports all library fonts, and import
dedupes by family, commits fonts in the pre-snippet phase (rolled back with datasets/themes on
a failed snippet write), and registers the new faces on success. SVG: `referencedUploadedFonts`
- `embedFontsInSvg` in `chart-export.ts`, wired through the renderer's `toImageURL('svg')` with
the referenced faces resolved in LivePreview's `getImageUrl` (config captured per render).
Decision (font clash): **skip, not rename** — a font is identified by its family (the key in
config slots), so an existing same-named face satisfies the reference, and a self-backup
doesn't accrete "Font 2" copies; recorded in spec §08 → Name conflicts. Verified: typecheck,
lint, full tests. Remaining in §4: Google Fonts opt-in tier; preset gallery.
- **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`,
+20 -13
View File
@@ -6,13 +6,13 @@ Separately, a single chart can be exported on its own — its spec or its render
## Export
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_), every dataset (see _Datasets_), and every custom chart theme (see _Live Preview → Chart theme_), wrapped in an envelope carrying format metadata.
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_), every dataset (see _Datasets_), every custom chart theme (see _Live Preview → Chart theme_), and every uploaded font face (see _Live Preview → Chart theme → fonts_), wrapped in an envelope carrying format metadata.
- **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog).
- **Contents**: all snippets, datasets, and custom chart themes currently stored, plus envelope metadata.
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets or themes exist.
- **Contents**: all snippets, datasets, custom chart themes, and uploaded fonts currently stored, plus envelope metadata.
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets, themes, or fonts exist.
- **Filename**: `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets, 2 datasets and 1 theme" (the dataset and theme clauses are omitted when their counts are zero; singular/plural wording adapts to the counts).
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets, 2 datasets and 1 theme" (the dataset, theme, and font clauses are omitted when their counts are zero; singular/plural wording adapts to the counts).
### Export envelope shape
@@ -31,6 +31,9 @@ The downloaded file is a single JSON object: an envelope with a format `version`
],
"themes": [
/* full custom chart theme objects (see Data Model) */
],
"fonts": [
/* uploaded font records, bytes base64-encoded (see Data Model) */
]
}
```
@@ -39,6 +42,7 @@ The downloaded file is a single JSON object: an envelope with a format `version`
- `exportedAt` — ISO 8601 timestamp of the export.
- `exportedBy` — fixed identifier `"Astrolabe"`.
- `snippets` / `datasets` / `themes` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.) `themes` is additive: exports always write it, and importers treat it as optional, so pre-theme envelopes remain valid `"1.0"` files.
- `fonts` — the uploaded font faces, each a complete record with its bytes **base64-encoded** (JSON cannot carry binary). Without this a theme or snippet referencing an uploaded font would import on another machine with only the family name, falling back to a system font. Like `themes`, `fonts` is additive — exports always write it, importers treat it as optional, so older envelopes remain valid `"1.0"` files.
## Per-chart export
@@ -53,7 +57,7 @@ A single chart can be exported on its own, separately from the whole-workspace E
**Image** (available only when a chart is currently rendered — the actions are disabled, with an explanatory line, while the preview is empty or showing an error):
- **Download PNG** — a rasterized image of the chart as shown.
- **Download SVG** — a vector image of the chart as shown.
- **Download SVG** — a vector image of the chart as shown. When the chart uses an **uploaded** font (not a built-in roster or system family), that face is embedded into the SVG as a base64 `@font-face` rule, so the file renders the right type off-app instead of falling back to a system font; this happens automatically, with no option to configure.
- **Resolution** (PNG) — `1×` / `2×` / `3×`, default `1×`. These are multipliers **of the display's pixel density**, so `1×` already matches on-screen crispness on a high-DPI (Retina) display; higher values produce larger images for print or zoom. (SVG is resolution-independent and ignores this.)
- **Background** — `Theme` (default) / `White` / `None`. The chart itself renders on a transparent background (so on screen it shows the pane colour); export therefore fills it: _Theme_ matches the active theme's background, _White_ is always white, _None_ keeps it transparent. Applies to both PNG and SVG.
@@ -73,8 +77,8 @@ Import lets the user pick a JSON file from their device; its contents are normal
The importer recognizes several shapes so that both Astrolabe exports and looser snippet files work:
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; optional `datasets` and `themes` arrays are imported too.
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets or themes).
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; optional `datasets`, `themes`, and `fonts` arrays are imported too.
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets, themes, or fonts).
- **Single snippet object** — any other object is treated as one snippet.
- **Older / foreign snippet shapes** — snippets that do not match the current model are normalized onto it:
- Alternative field names are mapped: `content` → spec, `draft` → draft spec, `createdAt` → creation timestamp.
@@ -88,10 +92,11 @@ A snippet is treated as already in current Astrolabe format when it carries an I
- Imported snippets are **appended** to the existing library; nothing is overwritten or removed.
- **ID collisions** (an incoming snippet whose id already exists) are resolved by assigning the incoming snippet a fresh unique id; the original snippet keeps its id.
- Datasets and custom themes are imported **before** snippets so that snippet dataset references can resolve.
- Imported custom themes always receive fresh ids from the theme library; an envelope's theme ids never displace existing records.
- Datasets, custom themes, and uploaded fonts are imported **before** snippets so that snippet dataset references resolve and the whole import rolls back together if the snippet write fails.
- Imported custom themes (and fonts) always receive fresh ids from their library; an envelope's ids never displace existing records.
- An unusable font record (missing family or bytes, or bytes that aren't valid base64) is skipped; the rest of the import continues.
### Name conflicts (datasets and themes)
### Name conflicts
When an imported dataset's or custom theme's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see _Datasets_).
@@ -100,6 +105,8 @@ When an imported dataset's or custom theme's name already exists in the library,
- A dataset rename is propagated into the imported snippets that reference it; theme renames need no propagation (nothing references a theme by name).
- If a single dataset fails to import, it is skipped and the rest of the import continues.
**Fonts conflict differently — skip, not rename.** A font is identified by its family, which is the key embedded directly in a config's font slots, so a same-named face already in the library satisfies any incoming reference. When an imported font's family already exists, the **incoming face is skipped** and the existing one is kept (the references resolve to it) — rather than renamed to a copy. This also means re-importing your own backup adds no duplicate "Font 2" copies. Skipped fonts are listed in the import's warning toast.
### Storage limit handling
Snippet storage has an approximate 5 MB budget (see _Snippet Library_ storage monitor).
@@ -110,8 +117,8 @@ Snippet storage has an approximate 5 MB budget (see _Snippet Library_ storage mo
### Feedback
- **Success**: a toast reports how many snippets (and datasets and themes, when any) were imported, e.g. "Imported 4 snippets, 2 datasets and 1 theme".
- **Renames**: when datasets or themes were renamed, the success message is shown as a warning toast that also lists the renames.
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported — even if the file carries datasets or themes.
- **Success**: a toast reports how many snippets (and datasets, themes, and fonts, when any) were imported, e.g. "Imported 4 snippets, 2 datasets and 1 theme".
- **Renames / skips**: when datasets or themes were renamed, or fonts were skipped as already-present, the success message is shown as a warning toast that also lists the renames and skips.
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported — even if the file carries datasets, themes, or fonts.
- **Quota failure**: a clear error advising the user to delete snippets and retry.
- **Invalid file**: a non-JSON or unparseable file produces a clear error ("Failed to import. Please check that the file is valid JSON."); an unreadable file produces a read error. In all error cases the existing workspace is left unchanged.
+21
View File
@@ -100,6 +100,7 @@ Some preferences persist independently of _UserSettings_ so they can update freq
| Snippet store | All _Snippet_ records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see _Snippet Library_). |
| Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. |
| Theme store | All _CustomTheme_ records (G) | Local, separate store; records are small (a config object plus metadata). |
| Font store | All _FontAsset_ records (H) | Local, separate store; holds raw font-file bytes, so it is sized like the dataset tier (per-face cap ~10 MB). |
| Settings & preferences | _UserSettings_ plus the app/UI preferences in (D) | Local, small. |
Everything stays in the browser — no server or account is involved. All tiers survive reload and function offline. Because capacity is finite and per-browser, _Import & Export_ is the supported path for backup and for moving data between browsers or devices.
@@ -127,3 +128,23 @@ A **CustomTheme** is a user-named Vega-Lite config saved in the library and offe
| `modified` | ISO-timestamp string | When the theme was last changed. |
Selection is keyed by `id` (not name) so renaming a theme never invalidates the persisted `ui.chartTheme`. A persisted `custom:<id>` whose record no longer exists is not an error: charts render with the house style until the record appears (themes hydrate asynchronously), and deleting the actively-selected theme resets the selection to `astrolabe` explicitly. Custom themes travel in the _Import & Export_ envelope alongside snippets and datasets (spec §08).
## H. FontAsset
A **FontAsset** is a user-uploaded font face stored once and reused across themes and snippets. It is added and managed in the _Theme Builder → Type_ panel (see _Live Preview_), and referenced from a config's font slots by its `family` — exactly as a dataset is referenced by name. The raw bytes are registered as a live `FontFace` so a chart measures and renders the real face.
| Field | Type | Meaning |
| ---------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | number | Unique numeric identifier (store key). |
| `version` | number | Schema version of this record, for read-time migration (see _Schema versioning_ above). |
| `family` | string | Unique CSS family name — the key configs reference (case-insensitive uniqueness, like datasets/themes). |
| `data` | bytes | The raw font-file bytes (registered as a `FontFace`). In an export envelope these are **base64-encoded** (spec §08). |
| `format` | `woff2`/`woff`/`ttf`/`otf` | Container format, from the file extension. |
| `fileName` | string | Original file name, kept for display and provenance. |
| `source` | `file`/`google` | Provenance. Only `file` (an upload) ships today; `google` is reserved for a later keyless-catalog tier. |
| `axes` | array, optional | Variation axes for a variable font (parsed from `fvar`); drives the `FontFace` weight/width ranges. Absent for a static face. |
| `size` | number | Byte length of `data`. |
| `created` | ISO-timestamp string | When the font was first added. |
| `modified` | ISO-timestamp string | When the font was last changed (e.g. renamed). |
A font is identified by its `family`. No theme or snippet stores a font field: the faces a config uses are derived by scanning its font slots (the config is the source of truth), so a snippet can use a font with no theme to carry it. Fonts travel in the _Import & Export_ envelope alongside snippets, datasets, and themes; on import a family clash **skips** the incoming face rather than renaming it (spec §08 → Name conflicts).
+21 -1
View File
@@ -18,6 +18,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import {
@@ -30,6 +32,7 @@ import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
@@ -159,6 +162,11 @@ function PreviewSettings() {
export function LivePreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
// The config the live view was last rendered with — read at SVG export to find
// which uploaded fonts the chart references (a font may live only in the theme
// config, not the spec). Tracks `handleRef`: set together, valid whenever a
// handle is.
const configRef = useRef<Config | null>(null);
const generationRef = useRef(0);
// Serializes node mutations across overlapping renders: each render chains onto
// the previous one's promise so only one vega-embed ever touches the shared host
@@ -296,6 +304,7 @@ export function LivePreview() {
return;
}
handleRef.current = handle;
configRef.current = config;
setChartReady(true);
setError(null);
clearBusy();
@@ -354,7 +363,18 @@ export function LivePreview() {
): Promise<string | null> => {
const handle = handleRef.current;
if (!handle) return null;
return await handle.toImageURL(format, options);
// Embed the referenced uploaded faces into an SVG so it renders off-app
// (spec §08). Read fresh: a font can be uploaded after the last render, and
// configRef holds the config that render used (a font may be theme-only).
const embedFonts =
format === 'svg'
? referencedUploadedFonts(
selectShownText(useSnippetStore.getState()),
configRef.current,
useFontStore.getState().fonts,
)
: undefined;
return await handle.toImageURL(format, { ...options, embedFonts });
},
[],
);
+18 -5
View File
@@ -11,6 +11,8 @@ import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { collectFontFamilies } from '@core/custom-theme';
import { embedFontsInSvg } from '@core/chart-export';
import type { FontAsset } from '@core/font-asset';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
interface ImageExportOptions {
@@ -31,6 +33,14 @@ interface ImageExportOptions {
* it transparent. Default `null`.
*/
background?: string | null;
/**
* Uploaded font faces the chart references, embedded into the **SVG** as base64
* `@font-face` rules so it renders the right type off-app (spec §08; scope doc
* §4). Ignored for PNG (the raster already bakes in the glyphs). Omitted/empty
* leaves only the family name, which falls back to a system font elsewhere. The
* caller (which holds the font library) resolves which faces are referenced.
*/
embedFonts?: ReadonlyArray<FontAsset>;
}
export interface RenderHandle {
@@ -231,12 +241,15 @@ export async function renderSpec(
node.replaceChildren();
},
async toImageURL(format, options = {}) {
const { scale = 1, background = null } = options;
const { scale = 1, background = null, embedFonts = [] } = options;
if (format === 'svg') {
// Vector — resolution-independent, so dpr/scale don't apply. A background
// is added as a full-bleed rect rather than baked into the live view.
const svg = await result.view.toSVG();
return svgDataUrl(background ? withSvgBackground(svg, background) : svg);
// Vector — resolution-independent, so dpr/scale don't apply. The view
// writes only family names, so referenced uploaded faces are embedded as
// @font-face data-URIs; a background is added as a full-bleed rect. Both
// are injected into the serialized string, not the live view.
let svg = embedFontsInSvg(await result.view.toSVG(), embedFonts);
if (background) svg = withSvgBackground(svg, background);
return svgDataUrl(svg);
}
// Multiply the requested scale by the device pixel ratio so a "1×" export is
// as crisp as the chart on screen (the Retina fix — see ImageExportOptions).
+96 -3
View File
@@ -20,14 +20,23 @@ vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
};
});
// FontFace registration is a browser side effect; the service contract is just
// "register the imported faces once the import commits", so a spy suffices.
vi.mock('../infrastructure/font-faces', () => ({
registerFontAssets: vi.fn(),
}));
import { createCustomTheme } from '@core/custom-theme';
import { createDataset } from '@core/dataset';
import { createFontAsset, serializeFontAsset, type SerializedFontAsset } from '@core/font-asset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { registerFontAssets } from '../infrastructure/font-faces';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { exportWorkspace, importWorkspace } from './transfer';
@@ -36,6 +45,19 @@ const mockedDownload = vi.mocked(downloadJson);
const mockedRead = vi.mocked(readTextFile);
const mockedSave = vi.mocked(saveSnippet);
const mockedDelete = vi.mocked(deleteSnippet);
const mockedRegisterFonts = vi.mocked(registerFontAssets);
/** A serialized (base64) font record, as it appears in an export envelope. */
function fontRecord(family: string): SerializedFontAsset {
return serializeFontAsset(
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
}),
);
}
/** The most recent notification raised. */
function lastNote() {
@@ -54,6 +76,7 @@ beforeEach(() => {
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
useCustomThemeStore.getState().reset();
useFontStore.getState().reset();
useNotificationStore.getState().clear();
});
@@ -92,6 +115,27 @@ describe('exportWorkspace', () => {
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Exported 1 snippet, 1 dataset and 1 theme');
});
it('embeds uploaded font bytes (base64) in the envelope and counts them', () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
useFontStore.getState().add(
createFontAsset({
family: 'Brand',
data: new Uint8Array([5, 6, 7, 8]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
}),
);
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
const [, json] = mockedDownload.mock.calls[0];
const env = JSON.parse(json) as { fonts: SerializedFontAsset[] };
expect(env.fonts).toHaveLength(1);
expect(env.fonts[0].family).toBe('Brand');
expect(typeof env.fonts[0].data).toBe('string'); // base64, JSON-safe
expect(lastNote().message).toBe('Exported 1 snippet and 1 font');
});
});
describe('importWorkspace', () => {
@@ -191,6 +235,51 @@ describe('importWorkspace', () => {
expect(lastNote().message).toContain('Brand → Brand 2');
});
it('imports envelope fonts: added to the library and registered as live faces', async () => {
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
fonts: [fontRecord('Brand')],
}),
);
const fonts = useFontStore.getState().fonts;
expect(fonts.map((f) => f.family)).toEqual(['Brand']);
expect(new Uint8Array(fonts[0].data)).toEqual(new Uint8Array([1, 2, 3, 4]));
// The committed face is registered on document.fonts so it renders without reload.
expect(mockedRegisterFonts).toHaveBeenCalledTimes(1);
expect(mockedRegisterFonts.mock.calls[0][0].map((f) => f.family)).toEqual(['Brand']);
expect(lastNote().message).toBe('Imported 1 snippet and 1 font');
});
it('skips an incoming font whose family already exists, keeping the local one', async () => {
const existing = createFontAsset({
family: 'Brand',
data: new Uint8Array([9, 9, 9]).buffer,
format: 'woff2',
fileName: 'local.woff2',
});
useFontStore.getState().add(existing);
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
fonts: [fontRecord('Brand'), fontRecord('Display')],
}),
);
const fonts = useFontStore.getState().fonts;
expect(fonts.map((f) => f.family).sort()).toEqual(['Brand', 'Display']);
// The kept "Brand" is the local one (its bytes), not the incoming face.
expect(new Uint8Array(fonts.find((f) => f.family === 'Brand')!.data)).toEqual(
new Uint8Array([9, 9, 9]),
);
expect(lastNote()).toMatchObject({ kind: 'warning' });
expect(lastNote().message).toContain('Skipped fonts already in your library: Brand');
});
it('reassigns a colliding snippet id, keeping the existing one', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
@@ -262,8 +351,8 @@ describe('importWorkspace', () => {
expect(mockedDelete).toHaveBeenCalledTimes(1);
});
it('rolls back datasets and themes added to the store before the snippet write failed', async () => {
// The import contains a dataset, a theme, and a snippet; the snippet write fails.
it('rolls back datasets, themes, and fonts added to the store before the snippet write failed', async () => {
// The import carries a dataset, theme, font, and snippet; the snippet write fails.
mockedSave.mockRejectedValueOnce(new StorageQuotaError());
await importJson(
@@ -274,13 +363,17 @@ describe('importWorkspace', () => {
],
datasets: [{ id: 1, name: 'DS', data: [{ x: 1 }], format: 'json', source: 'inline' }],
themes: [{ id: 1, name: 'T', config: {} }],
fonts: [fontRecord('Brand')],
}),
);
// Neither snippets, datasets, nor themes should persist in the store.
// Snippets, datasets, themes, and fonts must all be rolled back from the store.
expect(useSnippetStore.getState().snippets).toHaveLength(0);
expect(useDatasetStore.getState().datasets).toHaveLength(0);
expect(useCustomThemeStore.getState().themes).toHaveLength(0);
expect(useFontStore.getState().fonts).toHaveLength(0);
// A rolled-back import never registers its faces.
expect(mockedRegisterFonts).not.toHaveBeenCalled();
});
it('surfaces a quota error as a clear actionable notification without a detail field', async () => {
+43 -7
View File
@@ -23,17 +23,20 @@ import { buildExportEnvelope, exportFilename, transferSummaryMessage } from '@co
import {
applyDatasetRenamesToSnippets,
dedupeIncomingNames,
dropClashingFonts,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
import { snippetSizeBytes } from '@core/snippet';
import { humanizeBytes } from '@core/storage-estimate';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { registerFontAssets } from '../infrastructure/font-faces';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** Practical snippet-storage budget (spec §02 storage monitor / §08). */
@@ -51,6 +54,7 @@ export function exportWorkspace(now: Date = new Date()): void {
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
const themes = useCustomThemeStore.getState().themes;
const fonts = useFontStore.getState().fonts;
if (snippets.length === 0) {
notify({
@@ -61,17 +65,22 @@ 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 });
// Uploaded font faces travel base64-encoded inside the envelope, so a theme or
// snippet referencing one renders on another machine instead of falling back to
// a system font (spec §08, scope doc §4).
const envelope = buildExportEnvelope(snippets, datasets, themes, fonts, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length),
message: transferSummaryMessage(
'Exported',
snippets.length,
datasets.length,
themes.length,
fonts.length,
),
});
}
@@ -110,6 +119,7 @@ export async function importWorkspace(file: File): Promise<void> {
snippets: normSnippets,
datasets: normDatasets,
themes: normThemes,
fonts: normFonts,
} = normalizeImport(parsed);
if (normSnippets.length === 0) {
@@ -139,6 +149,15 @@ export async function importWorkspace(file: File): Promise<void> {
normThemes,
);
// Fonts key on a unique family. A clash skips the incoming face (kept existing —
// a same-named face already satisfies the reference), so no propagation either,
// just reporting (spec §08 → Name conflicts; scope doc §4).
const existingFontFamilies = useFontStore.getState().fonts.map((f) => f.family);
const { records: newFonts, skipped: skippedFonts } = dropClashingFonts(
existingFontFamilies,
normFonts,
);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
@@ -150,6 +169,10 @@ export async function importWorkspace(file: File): Promise<void> {
useDatasetStore.getState().addDatasets(dedupedDatasets);
const themeIdsBefore = new Set(useCustomThemeStore.getState().themes.map((t) => t.id));
useCustomThemeStore.getState().addThemes(dedupedThemes);
// Fonts join the same pre-snippet commit so they roll back together on failure.
// Live `FontFace` registration is deferred to the success path below.
const fontIdsBefore = new Set(useFontStore.getState().fonts.map((f) => f.id));
useFontStore.getState().addFonts(newFonts);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
@@ -191,6 +214,10 @@ export async function importWorkspace(file: File): Promise<void> {
for (const t of addedThemes) {
useCustomThemeStore.getState().remove(t.id);
}
const addedFonts = useFontStore.getState().fonts.filter((f) => !fontIdsBefore.has(f.id));
for (const f of addedFonts) {
useFontStore.getState().remove(f.id);
}
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
// GOV.UK plain language — no codes, tell the user what to do next).
@@ -219,13 +246,19 @@ export async function importWorkspace(file: File): Promise<void> {
// All IDB writes succeeded — now make the snippets visible in the store.
useSnippetStore.getState().addSnippets(finalSnippets);
// Register the imported faces on document.fonts so charts using them render
// immediately (mirrors the upload path) — only now that the import has committed.
const addedFonts = useFontStore.getState().fonts.filter((f) => !fontIdsBefore.has(f.id));
registerFontAssets(addedFonts);
// Feedback (spec §08 → Feedback): one summary toast; a warning when records were
// renamed or storage is over budget, otherwise a success.
// renamed, fonts were skipped, or storage is over budget, otherwise a success.
const summary = transferSummaryMessage(
'Imported',
finalSnippets.length,
dedupedDatasets.length,
dedupedThemes.length,
newFonts.length,
);
const clauses: string[] = [];
const allRenames = [...renames, ...themeRenames];
@@ -234,6 +267,9 @@ export async function importWorkspace(file: File): Promise<void> {
`Renamed to avoid clashes: ${allRenames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (skippedFonts.length > 0) {
clauses.push(`Skipped fonts already in your library: ${skippedFonts.join(', ')}.`);
}
if (overage > 0) {
clauses.push(
`This puts snippet storage about ${humanizeBytes(overage)} over the ~5 MB budget; ` +
+87
View File
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
chartExportFilename,
embedFontsInSvg,
inlineReferencedDatasets,
MAX_BASENAME_LEN,
referencedDatasetNames,
referencedUploadedFonts,
snippetFileBasename,
} from './chart-export';
import { createFontAsset, fontFamilyStack, type FontAsset } from './font-asset';
import { DatasetNotFoundError, type ResolvableDataset } from './rendering';
describe('snippetFileBasename', () => {
@@ -130,3 +133,87 @@ describe('inlineReferencedDatasets', () => {
expect(() => inlineReferencedDatasets(spec, [sales])).toThrow(DatasetNotFoundError);
});
});
describe('referencedUploadedFonts', () => {
const font = (family: string): FontAsset =>
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
});
const brand = font('Brand');
const display = font('Display Face');
it('matches an uploaded face named in the spec (by its stacks primary family)', () => {
const spec = JSON.stringify({ config: { font: fontFamilyStack('Brand') }, mark: 'bar' });
expect(referencedUploadedFonts(spec, undefined, [brand, display])).toEqual([brand]);
});
it('matches a face named only in the chart config (theme-only font)', () => {
const config = { axis: { labelFont: fontFamilyStack('Display Face') } };
expect(referencedUploadedFonts('{}', config, [brand, display])).toEqual([display]);
});
it('matches case-insensitively', () => {
const config = { font: '"brand", sans-serif' };
expect(referencedUploadedFonts('{}', config, [brand])).toEqual([brand]);
});
it('ignores roster/system stacks that match no uploaded face', () => {
const config = { font: '"Inter", system-ui, sans-serif' };
expect(referencedUploadedFonts('{}', config, [brand])).toEqual([]);
});
it('returns [] for an empty library or an unparseable spec with no config font', () => {
expect(referencedUploadedFonts('{}', undefined, [])).toEqual([]);
expect(referencedUploadedFonts('not json', undefined, [brand])).toEqual([]);
});
});
describe('embedFontsInSvg', () => {
const font = (family: string): FontAsset =>
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
});
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="80"><g/></svg>';
it('returns the SVG unchanged when there are no fonts', () => {
expect(embedFontsInSvg(svg, [])).toBe(svg);
});
it('injects an @font-face style with a base64 data-URI src as the first child', () => {
const out = embedFontsInSvg(svg, [font('Brand')]);
expect(out).toContain('<style');
expect(out).toContain('@font-face');
expect(out).toContain('font-family:"Brand"');
expect(out).toContain('src:url(data:font/woff2;base64,');
expect(out).toContain('format("woff2")'); // CSS keyword, not the extension
// The style sits immediately after the opening <svg ...> tag.
expect(out).toMatch(/<svg\b[^>]*><style/);
});
it('uses the CSS format keyword for ttf/otf (truetype/opentype)', () => {
const ttf = createFontAsset({
family: 'Mono',
data: new Uint8Array([1]).buffer,
format: 'ttf',
fileName: 'mono.ttf',
});
expect(embedFontsInSvg(svg, [ttf])).toContain('format("truetype")');
});
it('emits a font-weight range for a variable face so weights resolve', () => {
const variable = createFontAsset({
family: 'Flex',
data: new Uint8Array([1]).buffer,
format: 'ttf',
fileName: 'flex.ttf',
axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }],
});
expect(embedFontsInSvg(svg, [variable])).toContain('font-weight:100 900');
});
});
+78
View File
@@ -12,6 +12,14 @@
* and stay in `infrastructure/file-transfer` and the chart renderer.
*/
import { collectFontFamilies } from './custom-theme';
import {
fontDataUri,
primaryFamilyName,
variableFontDescriptors,
type FontAsset,
type FontFormat,
} from './font-asset';
import { prepareSpecForRender, type ResolvableDataset } from './rendering';
import { extractDatasetRefs } from './spec-refs';
@@ -89,3 +97,73 @@ export function inlineReferencedDatasets(
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
return JSON.stringify(resolved, null, 2);
}
// --- SVG font embedding (spec §08; scope doc §4, item 7) --------------------
//
// `view.toSVG()` writes only the `font-family` name, so an exported SVG falls
// back to a system font wherever the user's uploaded face isn't installed. We
// embed the referenced uploaded faces as base64 `@font-face` rules so the SVG
// renders the right type anywhere. Only uploaded faces are embedded — the
// roster/system stacks are decoration with their own fallbacks, and we don't
// hold their bytes here.
/** CSS `format()` keyword for an `@font-face` src — distinct from the file extension. */
const CSS_FORMAT: Record<FontFormat, string> = {
woff2: 'woff2',
woff: 'woff',
ttf: 'truetype',
otf: 'opentype',
};
/**
* The uploaded fonts a chart actually references its spec + the active chart
* config, matched by primary family against the font library. Returns the
* matched `FontAsset`s (the bytes to embed); `[]` when none are referenced or
* the library is empty. Case-insensitive, like the naming helpers.
*/
export function referencedUploadedFonts(
specText: string,
config: unknown,
fonts: ReadonlyArray<FontAsset>,
): FontAsset[] {
if (fonts.length === 0) return [];
let spec: unknown;
try {
spec = JSON.parse(specText);
} catch {
spec = null; // an unparseable spec still has a config that may name a font
}
const stacks = new Set<string>([...collectFontFamilies(spec), ...collectFontFamilies(config)]);
const used = new Set<string>([...stacks].map((s) => primaryFamilyName(s).toLowerCase()));
return fonts.filter((f) => used.has(f.family.toLowerCase()));
}
/** Escape a family name for use inside a double-quoted CSS string. */
function cssQuote(family: string): string {
return family.replace(/[\\"]/g, '\\$&');
}
/** Build one `@font-face` rule embedding a face's bytes as a data-URI `src`. */
function fontFaceRule(font: FontAsset): string {
const desc = variableFontDescriptors(font.axes);
const weight = desc.weight ? `font-weight:${desc.weight};` : '';
const stretch = desc.stretch ? `font-stretch:${desc.stretch};` : '';
return (
`@font-face{font-family:"${cssQuote(font.family)}";${weight}${stretch}` +
`src:url(${fontDataUri(font)}) format("${CSS_FORMAT[font.format]}");}`
);
}
/**
* Inject a `<style>` of `@font-face` rules (base64 `src`) as the first child of
* the root `<svg>`, so the serialized chart carries the uploaded faces it uses.
* Returns `svg` unchanged when there are no fonts. The CSS is wrapped in CDATA
* (an SVG is XML; a family name could contain `&`/`<`), with the one sequence
* that can't appear in CDATA `]]>` split defensively.
*/
export function embedFontsInSvg(svg: string, fonts: ReadonlyArray<FontAsset>): string {
if (fonts.length === 0) return svg;
const css = fonts.map(fontFaceRule).join('').replace(/]]>/g, ']]]]><![CDATA[>');
const style = `<style type="text/css"><![CDATA[${css}]]></style>`;
return svg.replace(/(<svg\b[^>]*>)/, `$1${style}`);
}
+59 -26
View File
@@ -9,6 +9,7 @@ import {
transferSummaryMessage,
type ExportEnvelope,
} from './export-envelope';
import { createFontAsset, serializeFontAsset, type FontAsset } from './font-asset';
import { createSnippet, type Snippet } from './snippet';
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
@@ -39,6 +40,20 @@ function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
};
}
function makeFont(overrides: Partial<FontAsset> = {}): FontAsset {
return {
...createFontAsset({
family: 'Brand',
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
now: FIXED_NOW,
id: 1,
}),
...overrides,
};
}
describe('EXPORT_ENVELOPE_VERSION', () => {
it('is the spec-mandated "1.0"', () => {
expect(EXPORT_ENVELOPE_VERSION).toBe('1.0');
@@ -47,7 +62,7 @@ describe('EXPORT_ENVELOPE_VERSION', () => {
describe('buildExportEnvelope', () => {
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], [], { now: FIXED_NOW });
expect(env.version).toBe('1.0');
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
expect(env.exportedBy).toBe('Astrolabe');
@@ -57,7 +72,8 @@ describe('buildExportEnvelope', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const font = makeFont();
const env = buildExportEnvelope([snippet], [dataset], [theme], [font], { now: FIXED_NOW });
const expected: ExportEnvelope = {
version: '1.0',
exportedAt: '2026-06-03T12:00:00.000Z',
@@ -65,6 +81,7 @@ describe('buildExportEnvelope', () => {
snippets: [snippet],
datasets: [dataset],
themes: [theme],
fonts: [serializeFontAsset(font)],
};
expect(env).toEqual(expected);
});
@@ -73,17 +90,27 @@ describe('buildExportEnvelope', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const env = buildExportEnvelope([snippet], [dataset], [theme], [], { now: FIXED_NOW });
expect(env.snippets[0]).toBe(snippet);
expect(env.datasets[0]).toBe(dataset);
expect(env.themes[0]).toBe(theme);
});
it('serializes font bytes to base64 (the array is JSON-safe)', () => {
const font = makeFont();
const env = buildExportEnvelope([makeSnippet()], [], [], [font], { now: FIXED_NOW });
expect(typeof env.fonts[0].data).toBe('string');
// The whole envelope must survive a JSON round-trip without loss.
expect(() => JSON.stringify(env)).not.toThrow();
expect(env.fonts[0].family).toBe('Brand');
expect(env.fonts[0].size).toBe(4);
});
it("preserves each record's own version field", () => {
const snippet = makeSnippet({ version: 1 });
const dataset = makeDataset({ version: 1 });
const theme = makeTheme({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const env = buildExportEnvelope([snippet], [dataset], [theme], [], { now: FIXED_NOW });
expect(env.snippets[0].version).toBe(1);
expect(env.datasets[0].version).toBe(1);
expect(env.themes[0].version).toBe(1);
@@ -95,22 +122,26 @@ describe('buildExportEnvelope', () => {
const snippets = [makeSnippet()];
const datasets = [makeDataset()];
const themes = [makeTheme()];
const env = buildExportEnvelope(snippets, datasets, themes, { now: FIXED_NOW });
const fonts = [makeFont()];
const env = buildExportEnvelope(snippets, datasets, themes, fonts, { now: FIXED_NOW });
snippets.push(makeSnippet({ id: 's2' }));
datasets.push(makeDataset({ id: 2, name: 'D2' }));
themes.push(makeTheme({ id: 2, name: 'T2' }));
fonts.push(makeFont({ id: 2, family: 'Brand 2' }));
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(env.themes).toHaveLength(1);
expect(env.fonts).toHaveLength(1);
});
it('handles empty arrays', () => {
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], [], { now: FIXED_NOW });
expect(env.snippets).toEqual([]);
expect(env.datasets).toEqual([]);
expect(env.themes).toEqual([]);
expect(env.fonts).toEqual([]);
});
});
@@ -127,28 +158,30 @@ describe('exportFilename', () => {
describe('transferSummaryMessage (shared by export and import feedback)', () => {
it('reports snippet and dataset counts (plural)', () => {
expect(transferSummaryMessage('Exported', 4, 2, 0)).toBe('Exported 4 snippets and 2 datasets');
});
it('omits the dataset and theme clauses when their counts are zero', () => {
expect(transferSummaryMessage('Exported', 4, 0, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(transferSummaryMessage('Imported', 1, 1, 0)).toBe('Imported 1 snippet and 1 dataset');
});
it('pluralizes zero counts (and omits the other clauses)', () => {
expect(transferSummaryMessage('Imported', 0, 0, 0)).toBe('Imported 0 snippets');
});
it('reports all three counts with comma-and joining', () => {
expect(transferSummaryMessage('Exported', 4, 2, 1)).toBe(
'Exported 4 snippets, 2 datasets and 1 theme',
expect(transferSummaryMessage('Exported', 4, 2, 0, 0)).toBe(
'Exported 4 snippets and 2 datasets',
);
});
it('joins snippets and themes with "and" when there are no datasets', () => {
expect(transferSummaryMessage('Imported', 4, 0, 3)).toBe('Imported 4 snippets and 3 themes');
it('omits the dataset, theme, and font clauses when their counts are zero', () => {
expect(transferSummaryMessage('Exported', 4, 0, 0, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(transferSummaryMessage('Imported', 1, 1, 0, 0)).toBe('Imported 1 snippet and 1 dataset');
});
it('pluralizes zero counts (and omits the other clauses)', () => {
expect(transferSummaryMessage('Imported', 0, 0, 0, 0)).toBe('Imported 0 snippets');
});
it('reports all four counts with comma-and joining', () => {
expect(transferSummaryMessage('Exported', 4, 2, 1, 3)).toBe(
'Exported 4 snippets, 2 datasets, 1 theme and 3 fonts',
);
});
it('joins snippets and fonts with "and" when there are no datasets or themes', () => {
expect(transferSummaryMessage('Imported', 4, 0, 0, 1)).toBe('Imported 4 snippets and 1 font');
});
});
+18 -5
View File
@@ -14,6 +14,7 @@
import type { CustomTheme } from './custom-theme';
import type { Dataset } from './dataset';
import { serializeFontAsset, type FontAsset, type SerializedFontAsset } from './font-asset';
import type { Snippet } from './snippet';
/**
@@ -26,8 +27,8 @@ export const EXPORT_ENVELOPE_VERSION = '1.0';
/**
* The downloaded file's top-level shape (spec §08 Export envelope shape): format
* metadata plus the complete-record arrays. Each record keeps its own `version`
* field unchanged. `themes` is additive (always written, optional on read) so
* pre-theme envelopes and importers remain compatible without a format bump.
* field unchanged. `themes` and `fonts` are additive (always written, optional on
* read) so older envelopes and importers remain compatible without a format bump.
*/
export interface ExportEnvelope {
/** Export format version (currently `"1.0"`). */
@@ -42,6 +43,13 @@ export interface ExportEnvelope {
datasets: Dataset[];
/** All custom chart themes, as complete records (each including its record `version`). */
themes: CustomTheme[];
/**
* All uploaded font faces, base64-encoded so a referenced face survives the
* round-trip (without this the family name imports but renders as fallback
* spec §08, scope doc §4). The whole library travels, like datasets/themes: a
* workspace export is a backup, not a minimal bundle of what's referenced.
*/
fonts: SerializedFontAsset[];
}
/**
@@ -55,6 +63,7 @@ export function buildExportEnvelope(
snippets: ReadonlyArray<Snippet>,
datasets: ReadonlyArray<Dataset>,
themes: ReadonlyArray<CustomTheme>,
fonts: ReadonlyArray<FontAsset>,
opts: { now: Date },
): ExportEnvelope {
return {
@@ -64,6 +73,8 @@ export function buildExportEnvelope(
snippets: [...snippets],
datasets: [...datasets],
themes: [...themes],
// Encode each face's bytes (ArrayBuffer → base64) so the array is JSON-safe.
fonts: fonts.map(serializeFontAsset),
};
}
@@ -90,19 +101,21 @@ function countClause(count: number, noun: string): string {
/**
* Success-toast message reporting transfer counts (spec §08 Feedback) for both
* directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1
* snippet". The dataset and theme clauses are omitted entirely when their counts
* are zero; singular/plural wording adapts. One builder for export and import so
* a new record kind or wording change lands in both messages at once.
* snippet". The dataset, theme, and font clauses are omitted entirely when their
* counts are zero; singular/plural wording adapts. One builder for export and
* import so a new record kind or wording change lands in both messages at once.
*/
export function transferSummaryMessage(
verb: 'Exported' | 'Imported',
snippetCount: number,
datasetCount: number,
themeCount: number,
fontCount: number,
): string {
const clauses = [countClause(snippetCount, 'snippet')];
if (datasetCount > 0) clauses.push(countClause(datasetCount, 'dataset'));
if (themeCount > 0) clauses.push(countClause(themeCount, 'theme'));
if (fontCount > 0) clauses.push(countClause(fontCount, 'font'));
const last = clauses.pop()!;
return clauses.length === 0 ? `${verb} ${last}` : `${verb} ${clauses.join(', ')} and ${last}`;
}
+88
View File
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest';
import {
CURRENT_FONT_VERSION,
createFontAsset,
deserializeFontAsset,
detectFontFormat,
familyNameFromFileName,
type FontAsset,
type FontAxis,
fontDataUri,
fontFamilyStack,
isVariableFont,
parseFontAxes,
primaryFamilyName,
serializeFontAsset,
variableFontDescriptors,
} from './font-asset';
@@ -168,3 +173,86 @@ describe('variable-font helpers', () => {
expect(variableFontDescriptors(undefined)).toEqual({});
});
});
describe('primaryFamilyName', () => {
it('extracts the first comma-segment and strips quotes', () => {
expect(primaryFamilyName('"My Brand", sans-serif')).toBe('My Brand');
expect(primaryFamilyName("'Inter', system-ui, sans-serif")).toBe('Inter');
expect(primaryFamilyName('system-ui, -apple-system')).toBe('system-ui');
expect(primaryFamilyName('Brand')).toBe('Brand');
});
});
describe('fontDataUri', () => {
it('builds a base64 data URL with the format MIME type', () => {
const asset = createFontAsset({
family: 'Brand',
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
});
expect(fontDataUri(asset)).toBe(`data:font/woff2;base64,${btoa('\x01\x02\x03\x04')}`);
});
});
describe('serializeFontAsset / deserializeFontAsset', () => {
const NOW = new Date('2026-06-16T08:00:00.000Z');
function sample(overrides: Partial<FontAsset> = {}): FontAsset {
return {
...createFontAsset({
family: 'Brand',
data: new Uint8Array([10, 20, 30, 40, 50]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
now: NOW,
id: 7,
}),
...overrides,
};
}
it('round-trips a font through base64 without losing its bytes or metadata', () => {
const original = sample({ modified: '2026-06-16T09:00:00.000Z' });
const restored = deserializeFontAsset(serializeFontAsset(original));
expect(restored).not.toBeNull();
expect(new Uint8Array(restored!.data)).toEqual(new Uint8Array(original.data));
expect(restored!.family).toBe('Brand');
expect(restored!.format).toBe('woff2');
expect(restored!.fileName).toBe('brand.woff2');
expect(restored!.size).toBe(5);
expect(restored!.created).toBe(NOW.toISOString());
expect(restored!.modified).toBe('2026-06-16T09:00:00.000Z');
});
it('preserves variable-font axes through the round-trip', () => {
const axes: FontAxis[] = [{ tag: 'wght', min: 100, default: 400, max: 900 }];
const restored = deserializeFontAsset(serializeFontAsset(sample({ axes })));
expect(restored!.axes).toEqual(axes);
});
it('re-derives size from the decoded bytes (not a stale stored value)', () => {
const serialized = { ...serializeFontAsset(sample()), size: 999 };
expect(deserializeFontAsset(serialized)!.size).toBe(5);
});
it('returns null for a record missing family or data, or with bad base64', () => {
expect(deserializeFontAsset(null)).toBeNull();
expect(deserializeFontAsset({ data: 'AAEC' })).toBeNull();
expect(deserializeFontAsset({ family: 'X' })).toBeNull();
expect(deserializeFontAsset({ family: 'X', data: '' })).toBeNull();
expect(deserializeFontAsset({ family: 'X', data: '!!not-base64!!' })).toBeNull();
});
it('falls back to a derived format when the stored one is absent/unknown', () => {
const serialized = { ...serializeFontAsset(sample()), format: 'bogus' };
expect(deserializeFontAsset(serialized)!.format).toBe('woff2'); // from fileName
});
it('stores timestamps verbatim and never throws on a corrupt created (would abort import)', () => {
const serialized = { ...serializeFontAsset(sample()), created: 'not-a-date', modified: '' };
const restored = deserializeFontAsset(serialized);
expect(restored).not.toBeNull();
expect(restored!.created).toBe('not-a-date');
});
});
+122
View File
@@ -161,6 +161,128 @@ export function fontFamilyStack(family: string): string {
return `"${family}", sans-serif`;
}
/**
* The primary family of a CSS font stack the first comma-segment with any
* surrounding quotes stripped. `'"My Brand", sans-serif'` `My Brand`;
* `'system-ui, sans-serif'` `system-ui`. Used to match a config's font slot
* (which holds a whole stack see `fontFamilyStack`/`THEME_FONT_OPTIONS`) back
* to a `FontAsset.family`, so only uploaded faces (never the roster/system
* stacks) are embedded on export.
*/
export function primaryFamilyName(stack: string): string {
const first = stack.split(',')[0]?.trim() ?? '';
return first.replace(/^["']|["']$/g, '').trim();
}
// --- Serialization & data URIs (base64) ------------------------------------
//
// `btoa`/`atob` are platform globals (HTML/WHATWG, present in browsers and Node
// alike — same standing as `crypto.randomUUID`/`ArrayBuffer` used elsewhere in
// core), so font bytes serialize without reaching for a DOM API. The chunked
// `fromCharCode` keeps a multi-MB face under the argument-count limit.
/** Container-format → CSS `@font-face` MIME type, for an embedded `src` URL. */
const FONT_MIME: Record<FontFormat, string> = {
woff2: 'font/woff2',
woff: 'font/woff',
ttf: 'font/ttf',
otf: 'font/otf',
};
/** Base64-encode raw font bytes (32 KB chunks to stay under the spread limit). */
function bytesToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/** Decode base64 back to raw font bytes. Throws on malformed input (caller guards). */
function base64ToBytes(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
/** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */
export function fontDataUri(asset: FontAsset): string {
return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
}
/**
* A FontAsset as it travels in the §08 export envelope (JSON): identical to the
* stored record except `data` is base64 (an `ArrayBuffer` can't be JSON-encoded).
*/
export interface SerializedFontAsset extends Omit<FontAsset, 'data'> {
/** Base64-encoded font bytes. */
data: string;
}
/** Whether a value is one of the supported container formats. */
function isFontFormat(value: unknown): value is FontFormat {
return value === 'woff2' || value === 'woff' || value === 'ttf' || value === 'otf';
}
/** Encode a stored FontAsset for the export envelope (base64 its bytes). */
export function serializeFontAsset(font: FontAsset): SerializedFontAsset {
const { data, ...rest } = font;
return { ...rest, data: bytesToBase64(data) };
}
/**
* Decode one envelope font record back onto the current `FontAsset` shape, or
* `null` when it's unusable (missing/empty `family` or `data`, or `data` that
* isn't valid base64) the importer skips a `null` rather than abort. Like
* `normalizeCustomTheme` this is gap-filling, not foreign-shape mapping: fonts
* only travel inside Astrolabe envelopes. `size` is re-derived from the decoded
* bytes (authoritative), `version` stamped current, and `id` left provisional
* for the store's id authority to reassign.
*/
export function deserializeFontAsset(raw: unknown, opts: { now?: Date } = {}): FontAsset | null {
if (typeof raw !== 'object' || raw === null) return null;
const r = raw as Record<string, unknown>;
const family = typeof r.family === 'string' && r.family.trim() !== '' ? r.family : null;
const encoded = typeof r.data === 'string' && r.data !== '' ? r.data : null;
if (family === null || encoded === null) return null;
let data: ArrayBuffer;
try {
data = base64ToBytes(encoded);
} catch {
return null;
}
if (data.byteLength === 0) return null;
const fileName = typeof r.fileName === 'string' ? r.fileName : family;
const format = isFontFormat(r.format) ? r.format : (detectFontFormat(fileName) ?? 'ttf');
const axes = Array.isArray(r.axes) ? (r.axes as FontAxis[]) : undefined;
const created = typeof r.created === 'string' && r.created !== '' ? r.created : null;
const modified = typeof r.modified === 'string' && r.modified !== '' ? r.modified : null;
// Build the base for id/version/size/format/axes, then overlay the stored
// timestamps *verbatim* — never round-tripped through `new Date()`, so a corrupt
// non-ISO `created` can't throw and abort the import (mirrors normalizeCustomTheme).
const base = createFontAsset({
family,
data,
format,
fileName,
source: r.source === 'google' ? 'google' : 'file',
...(axes && axes.length > 0 ? { axes } : {}),
now: opts.now,
});
return {
...base,
created: created ?? base.created,
modified: modified ?? created ?? base.modified,
};
}
// --- Variable-font (fvar) parsing ------------------------------------------
const SFNT_TTF = 0x00010000; // TrueType outlines
+72 -1
View File
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
import { CURRENT_THEME_VERSION } from './custom-theme';
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
import { createFontAsset, serializeFontAsset, type FontAsset, type FontFormat } from './font-asset';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingNames,
dropClashingFonts,
normalizeImport,
reassignCollidingSnippetIds,
} from './import-normalize';
@@ -107,7 +109,7 @@ describe('normalizeImport — shape detection', () => {
});
it('returns empty for null / non-object / non-array junk', () => {
const empty = { snippets: [], datasets: [], themes: [] };
const empty = { snippets: [], datasets: [], themes: [], fonts: [] };
expect(normalizeImport(null)).toEqual(empty);
expect(normalizeImport(42)).toEqual(empty);
expect(normalizeImport('hello')).toEqual(empty);
@@ -373,6 +375,75 @@ describe('normalizeImport — custom themes', () => {
});
});
describe('normalizeImport — fonts', () => {
const serializedFont = (family: string) =>
serializeFontAsset(
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
now: FIXED_NOW,
}),
);
const envelope = (fonts: unknown[]) => ({
version: '1.0',
snippets: [currentSnippetRecord()],
fonts,
});
it('decodes envelope fonts back to FontAssets with their bytes intact', () => {
const { fonts } = normalizeImport(envelope([serializedFont('Brand')]), { now: FIXED_NOW });
expect(fonts).toHaveLength(1);
expect(fonts[0].family).toBe('Brand');
expect(new Uint8Array(fonts[0].data)).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(fonts[0].size).toBe(4);
});
it('drops unusable font records (no data / bad base64) without failing the import', () => {
const { snippets, fonts } = normalizeImport(
envelope([serializedFont('Good'), { family: 'NoBytes' }, { family: 'Bad', data: '%%%' }]),
{ now: FIXED_NOW },
);
expect(snippets).toHaveLength(1); // the import still succeeds
expect(fonts.map((f) => f.family)).toEqual(['Good']);
});
it('yields no fonts for a bare-array or single-snippet import (no envelope)', () => {
expect(normalizeImport([currentSnippetRecord()]).fonts).toEqual([]);
expect(normalizeImport(currentSnippetRecord()).fonts).toEqual([]);
});
});
describe('dropClashingFonts', () => {
const font = (family: string, format: FontFormat = 'woff2'): FontAsset =>
createFontAsset({
family,
data: new Uint8Array([1, 2, 3]).buffer,
format,
fileName: `${family}.${format}`,
});
it('keeps incoming fonts whose family is new', () => {
const { records, skipped } = dropClashingFonts(['Existing'], [font('Brand'), font('Display')]);
expect(records.map((f) => f.family)).toEqual(['Brand', 'Display']);
expect(skipped).toEqual([]);
});
it('skips an incoming font whose family already exists (case-insensitive), not rename', () => {
const { records, skipped } = dropClashingFonts(['Brand'], [font('brand'), font('New')]);
expect(records.map((f) => f.family)).toEqual(['New']);
expect(skipped).toEqual(['brand']);
});
it('dedupes within the incoming batch (first wins)', () => {
const { records, skipped } = dropClashingFonts([], [font('Brand'), font('Brand')]);
expect(records).toHaveLength(1);
expect(skipped).toEqual(['Brand']);
});
});
describe('dedupeIncomingNames', () => {
it('returns names unchanged when there are no collisions', () => {
const { records, renames } = dedupeIncomingNames(['Other'], [datasetRecord({ name: 'Sales' })]);
+42 -4
View File
@@ -16,6 +16,7 @@
import { CURRENT_THEME_VERSION, type CustomTheme } from './custom-theme';
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
import { deserializeFontAsset, type FontAsset } from './font-asset';
import type { DataFormat } from './format-detection';
import { makeUniqueName } from './naming';
import type { ColumnStats } from './profile';
@@ -32,6 +33,7 @@ export interface NormalizedImport {
snippets: Snippet[];
datasets: Dataset[];
themes: CustomTheme[];
fonts: FontAsset[];
}
/** A single rename applied during name dedupe (`from` original → `to` unique). */
@@ -237,21 +239,23 @@ function normalizeCustomTheme(raw: unknown, nowIso: string): CustomTheme {
* Detect the import shape and normalize every record onto the current model.
*
* - Envelope: object with a `version` AND a `snippets` array its snippets
* (+ optional `datasets` and `themes` arrays).
* - Bare array: a top-level array a list of snippets, no datasets/themes.
* - Single object: any other object one snippet, no datasets/themes.
* (+ optional `datasets`, `themes`, and `fonts` arrays).
* - Bare array: a top-level array a list of snippets, no datasets/themes/fonts.
* - Single object: any other object one snippet, no datasets/themes/fonts.
* - junk (null / non-object / non-array) empty.
*/
export function normalizeImport(
parsed: unknown,
opts: NormalizeImportOptions = {},
): NormalizedImport {
const nowIso = (opts.now ?? new Date()).toISOString();
const now = opts.now ?? new Date();
const nowIso = now.toISOString();
const makeId = opts.makeId ?? (() => crypto.randomUUID());
let rawSnippets: unknown[] = [];
let rawDatasets: unknown[] = [];
let rawThemes: unknown[] = [];
let rawFonts: unknown[] = [];
if (Array.isArray(parsed)) {
// Bare array of snippets.
@@ -262,6 +266,7 @@ export function normalizeImport(
rawSnippets = parsed.snippets as unknown[];
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
if (Array.isArray(parsed.fonts)) rawFonts = parsed.fonts;
} else {
// Single snippet object.
rawSnippets = [parsed];
@@ -273,6 +278,10 @@ export function normalizeImport(
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
themes: rawThemes.map((t) => normalizeCustomTheme(t, nowIso)),
// Unusable font records (no family / bad base64) decode to null and are dropped.
fonts: rawFonts
.map((f) => deserializeFontAsset(f, { now }))
.filter((f): f is FontAsset => f !== null),
};
}
@@ -305,6 +314,35 @@ export function dedupeIncomingNames<T extends { name: string }>(
return { records, renames };
}
/**
* Drop incoming fonts whose `family` already exists (case-insensitive) in the
* library or earlier in the same batch. The fonts merge rule (spec §08 Name
* conflicts; scope doc §4): unlike datasets/themes (renamed on clash), a clashing
* uploaded face is **skipped**, not renamed a font is identified by its family,
* which is the key embedded in config font slots, so a same-named existing face
* already satisfies any incoming reference. This also dedupes a self-backup
* restore (no "Font 2" copies pile up). Returns the kept records and the skipped
* family names (for reporting).
*/
export function dropClashingFonts(
existingFamilies: ReadonlyArray<string>,
incoming: ReadonlyArray<FontAsset>,
): { records: FontAsset[]; skipped: string[] } {
const seen = new Set(existingFamilies.map((n) => n.toLowerCase()));
const records: FontAsset[] = [];
const skipped: string[] = [];
for (const font of incoming) {
const key = font.family.toLowerCase();
if (seen.has(key)) {
skipped.push(font.family);
continue;
}
seen.add(key);
records.push(font);
}
return { records, skipped };
}
/**
* Reassign ids for incoming snippets whose id already exists (spec §08 "ID
* collisions"). The existing snippet keeps its id; the incoming one gets a fresh