mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Fonts: carry uploaded faces through export/import + embed in SVG export
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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; ` +
|
||||
|
||||
@@ -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 = '<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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user