From d8a7bcb7c18552bb5664cfd0b53c75fd10939102 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Tue, 16 Jun 2026 23:52:16 +0300 Subject: [PATCH] Fonts: carry uploaded faces through export/import + embed in SVG export --- docs/IMPLEMENTATION-PLAN.md | 21 ---- docs/exploration/chart-theming-scope.md | 29 +++++- docs/spec/08-import-export.md | 33 ++++--- docs/spec/09-data-model.md | 21 ++++ src/app/components/LivePreview.tsx | 22 ++++- src/app/services/chart-renderer.ts | 23 ++++- src/app/services/transfer.test.ts | 99 ++++++++++++++++++- src/app/services/transfer.ts | 50 ++++++++-- src/core/chart-export.test.ts | 87 +++++++++++++++++ src/core/chart-export.ts | 78 +++++++++++++++ src/core/export-envelope.test.ts | 85 ++++++++++++----- src/core/export-envelope.ts | 23 ++++- src/core/font-asset.test.ts | 88 +++++++++++++++++ src/core/font-asset.ts | 122 ++++++++++++++++++++++++ src/core/import-normalize.test.ts | 73 +++++++++++++- src/core/import-normalize.ts | 46 ++++++++- 16 files changed, 810 insertions(+), 90 deletions(-) diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 973017c..3f821f3 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -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 diff --git a/docs/exploration/chart-theming-scope.md b/docs/exploration/chart-theming-scope.md index 9127582..7546eec 100644 --- a/docs/exploration/chart-theming-scope.md +++ b/docs/exploration/chart-theming-scope.md @@ -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`, diff --git a/docs/spec/08-import-export.md b/docs/spec/08-import-export.md index 2830f10..b7ecb71 100644 --- a/docs/spec/08-import-export.md +++ b/docs/spec/08-import-export.md @@ -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. diff --git a/docs/spec/09-data-model.md b/docs/spec/09-data-model.md index 8ebbea3..c49e638 100644 --- a/docs/spec/09-data-model.md +++ b/docs/spec/09-data-model.md @@ -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:` 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). diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx index c57c7e3..b8fde4b 100644 --- a/src/app/components/LivePreview.tsx +++ b/src/app/components/LivePreview.tsx @@ -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(null); const handleRef = useRef(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(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 => { 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 }); }, [], ); diff --git a/src/app/services/chart-renderer.ts b/src/app/services/chart-renderer.ts index 9b20a5a..ce95320 100644 --- a/src/app/services/chart-renderer.ts +++ b/src/app/services/chart-renderer.ts @@ -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; } 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). diff --git a/src/app/services/transfer.test.ts b/src/app/services/transfer.test.ts index 8338cda..407fb85 100644 --- a/src/app/services/transfer.test.ts +++ b/src/app/services/transfer.test.ts @@ -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 () => { diff --git a/src/app/services/transfer.ts b/src/app/services/transfer.ts index ff6066d..b70f52f 100644 --- a/src/app/services/transfer.ts +++ b/src/app/services/transfer.ts @@ -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 { 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 { 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 { 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 { 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 { // 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 { `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; ` + diff --git a/src/core/chart-export.test.ts b/src/core/chart-export.test.ts index e486350..f4e3058 100644 --- a/src/core/chart-export.test.ts +++ b/src/core/chart-export.test.ts @@ -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 stack’s 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 = ''; + + 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(' tag. + expect(out).toMatch(/]*>`; + return svg.replace(/(]*>)/, `$1${style}`); +} diff --git a/src/core/export-envelope.test.ts b/src/core/export-envelope.test.ts index b179111..27083c5 100644 --- a/src/core/export-envelope.test.ts +++ b/src/core/export-envelope.test.ts @@ -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 { }; } +function makeFont(overrides: Partial = {}): 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'); }); }); diff --git a/src/core/export-envelope.ts b/src/core/export-envelope.ts index 1b6e672..2de491b 100644 --- a/src/core/export-envelope.ts +++ b/src/core/export-envelope.ts @@ -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, datasets: ReadonlyArray, themes: ReadonlyArray, + fonts: ReadonlyArray, 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}`; } diff --git a/src/core/font-asset.test.ts b/src/core/font-asset.test.ts index 152c3c9..89bcd6b 100644 --- a/src/core/font-asset.test.ts +++ b/src/core/font-asset.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/core/font-asset.ts b/src/core/font-asset.ts index 1b6783b..07112c1 100644 --- a/src/core/font-asset.ts +++ b/src/core/font-asset.ts @@ -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 = { + 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 { + /** 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; + + 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 diff --git a/src/core/import-normalize.test.ts b/src/core/import-normalize.test.ts index c46fc24..3691355 100644 --- a/src/core/import-normalize.test.ts +++ b/src/core/import-normalize.test.ts @@ -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' })]); diff --git a/src/core/import-normalize.ts b/src/core/import-normalize.ts index 743ed87..14c2cd8 100644 --- a/src/core/import-normalize.ts +++ b/src/core/import-normalize.ts @@ -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( 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, + incoming: ReadonlyArray, +): { 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