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

This commit is contained in:
2026-06-16 23:52:16 +03:00
parent 2ed9db3792
commit d8a7bcb7c1
16 changed files with 810 additions and 90 deletions
+21 -1
View File
@@ -18,6 +18,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import {
@@ -30,6 +32,7 @@ import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
@@ -159,6 +162,11 @@ function PreviewSettings() {
export function LivePreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
// The config the live view was last rendered with — read at SVG export to find
// which uploaded fonts the chart references (a font may live only in the theme
// config, not the spec). Tracks `handleRef`: set together, valid whenever a
// handle is.
const configRef = useRef<Config | null>(null);
const generationRef = useRef(0);
// Serializes node mutations across overlapping renders: each render chains onto
// the previous one's promise so only one vega-embed ever touches the shared host
@@ -296,6 +304,7 @@ export function LivePreview() {
return;
}
handleRef.current = handle;
configRef.current = config;
setChartReady(true);
setError(null);
clearBusy();
@@ -354,7 +363,18 @@ export function LivePreview() {
): Promise<string | null> => {
const handle = handleRef.current;
if (!handle) return null;
return await handle.toImageURL(format, options);
// Embed the referenced uploaded faces into an SVG so it renders off-app
// (spec §08). Read fresh: a font can be uploaded after the last render, and
// configRef holds the config that render used (a font may be theme-only).
const embedFonts =
format === 'svg'
? referencedUploadedFonts(
selectShownText(useSnippetStore.getState()),
configRef.current,
useFontStore.getState().fonts,
)
: undefined;
return await handle.toImageURL(format, { ...options, embedFonts });
},
[],
);
+18 -5
View File
@@ -11,6 +11,8 @@ import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { collectFontFamilies } from '@core/custom-theme';
import { embedFontsInSvg } from '@core/chart-export';
import type { FontAsset } from '@core/font-asset';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
interface ImageExportOptions {
@@ -31,6 +33,14 @@ interface ImageExportOptions {
* it transparent. Default `null`.
*/
background?: string | null;
/**
* Uploaded font faces the chart references, embedded into the **SVG** as base64
* `@font-face` rules so it renders the right type off-app (spec §08; scope doc
* §4). Ignored for PNG (the raster already bakes in the glyphs). Omitted/empty
* leaves only the family name, which falls back to a system font elsewhere. The
* caller (which holds the font library) resolves which faces are referenced.
*/
embedFonts?: ReadonlyArray<FontAsset>;
}
export interface RenderHandle {
@@ -231,12 +241,15 @@ export async function renderSpec(
node.replaceChildren();
},
async toImageURL(format, options = {}) {
const { scale = 1, background = null } = options;
const { scale = 1, background = null, embedFonts = [] } = options;
if (format === 'svg') {
// Vector — resolution-independent, so dpr/scale don't apply. A background
// is added as a full-bleed rect rather than baked into the live view.
const svg = await result.view.toSVG();
return svgDataUrl(background ? withSvgBackground(svg, background) : svg);
// Vector — resolution-independent, so dpr/scale don't apply. The view
// writes only family names, so referenced uploaded faces are embedded as
// @font-face data-URIs; a background is added as a full-bleed rect. Both
// are injected into the serialized string, not the live view.
let svg = embedFontsInSvg(await result.view.toSVG(), embedFonts);
if (background) svg = withSvgBackground(svg, background);
return svgDataUrl(svg);
}
// Multiply the requested scale by the device pixel ratio so a "1×" export is
// as crisp as the chart on screen (the Retina fix — see ImageExportOptions).
+96 -3
View File
@@ -20,14 +20,23 @@ vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
};
});
// FontFace registration is a browser side effect; the service contract is just
// "register the imported faces once the import commits", so a spy suffices.
vi.mock('../infrastructure/font-faces', () => ({
registerFontAssets: vi.fn(),
}));
import { createCustomTheme } from '@core/custom-theme';
import { createDataset } from '@core/dataset';
import { createFontAsset, serializeFontAsset, type SerializedFontAsset } from '@core/font-asset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { registerFontAssets } from '../infrastructure/font-faces';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { exportWorkspace, importWorkspace } from './transfer';
@@ -36,6 +45,19 @@ const mockedDownload = vi.mocked(downloadJson);
const mockedRead = vi.mocked(readTextFile);
const mockedSave = vi.mocked(saveSnippet);
const mockedDelete = vi.mocked(deleteSnippet);
const mockedRegisterFonts = vi.mocked(registerFontAssets);
/** A serialized (base64) font record, as it appears in an export envelope. */
function fontRecord(family: string): SerializedFontAsset {
return serializeFontAsset(
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
}),
);
}
/** The most recent notification raised. */
function lastNote() {
@@ -54,6 +76,7 @@ beforeEach(() => {
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
useCustomThemeStore.getState().reset();
useFontStore.getState().reset();
useNotificationStore.getState().clear();
});
@@ -92,6 +115,27 @@ describe('exportWorkspace', () => {
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Exported 1 snippet, 1 dataset and 1 theme');
});
it('embeds uploaded font bytes (base64) in the envelope and counts them', () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
useFontStore.getState().add(
createFontAsset({
family: 'Brand',
data: new Uint8Array([5, 6, 7, 8]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
}),
);
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
const [, json] = mockedDownload.mock.calls[0];
const env = JSON.parse(json) as { fonts: SerializedFontAsset[] };
expect(env.fonts).toHaveLength(1);
expect(env.fonts[0].family).toBe('Brand');
expect(typeof env.fonts[0].data).toBe('string'); // base64, JSON-safe
expect(lastNote().message).toBe('Exported 1 snippet and 1 font');
});
});
describe('importWorkspace', () => {
@@ -191,6 +235,51 @@ describe('importWorkspace', () => {
expect(lastNote().message).toContain('Brand → Brand 2');
});
it('imports envelope fonts: added to the library and registered as live faces', async () => {
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
fonts: [fontRecord('Brand')],
}),
);
const fonts = useFontStore.getState().fonts;
expect(fonts.map((f) => f.family)).toEqual(['Brand']);
expect(new Uint8Array(fonts[0].data)).toEqual(new Uint8Array([1, 2, 3, 4]));
// The committed face is registered on document.fonts so it renders without reload.
expect(mockedRegisterFonts).toHaveBeenCalledTimes(1);
expect(mockedRegisterFonts.mock.calls[0][0].map((f) => f.family)).toEqual(['Brand']);
expect(lastNote().message).toBe('Imported 1 snippet and 1 font');
});
it('skips an incoming font whose family already exists, keeping the local one', async () => {
const existing = createFontAsset({
family: 'Brand',
data: new Uint8Array([9, 9, 9]).buffer,
format: 'woff2',
fileName: 'local.woff2',
});
useFontStore.getState().add(existing);
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
fonts: [fontRecord('Brand'), fontRecord('Display')],
}),
);
const fonts = useFontStore.getState().fonts;
expect(fonts.map((f) => f.family).sort()).toEqual(['Brand', 'Display']);
// The kept "Brand" is the local one (its bytes), not the incoming face.
expect(new Uint8Array(fonts.find((f) => f.family === 'Brand')!.data)).toEqual(
new Uint8Array([9, 9, 9]),
);
expect(lastNote()).toMatchObject({ kind: 'warning' });
expect(lastNote().message).toContain('Skipped fonts already in your library: Brand');
});
it('reassigns a colliding snippet id, keeping the existing one', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
@@ -262,8 +351,8 @@ describe('importWorkspace', () => {
expect(mockedDelete).toHaveBeenCalledTimes(1);
});
it('rolls back datasets and themes added to the store before the snippet write failed', async () => {
// The import contains a dataset, a theme, and a snippet; the snippet write fails.
it('rolls back datasets, themes, and fonts added to the store before the snippet write failed', async () => {
// The import carries a dataset, theme, font, and snippet; the snippet write fails.
mockedSave.mockRejectedValueOnce(new StorageQuotaError());
await importJson(
@@ -274,13 +363,17 @@ describe('importWorkspace', () => {
],
datasets: [{ id: 1, name: 'DS', data: [{ x: 1 }], format: 'json', source: 'inline' }],
themes: [{ id: 1, name: 'T', config: {} }],
fonts: [fontRecord('Brand')],
}),
);
// Neither snippets, datasets, nor themes should persist in the store.
// Snippets, datasets, themes, and fonts must all be rolled back from the store.
expect(useSnippetStore.getState().snippets).toHaveLength(0);
expect(useDatasetStore.getState().datasets).toHaveLength(0);
expect(useCustomThemeStore.getState().themes).toHaveLength(0);
expect(useFontStore.getState().fonts).toHaveLength(0);
// A rolled-back import never registers its faces.
expect(mockedRegisterFonts).not.toHaveBeenCalled();
});
it('surfaces a quota error as a clear actionable notification without a detail field', async () => {
+43 -7
View File
@@ -23,17 +23,20 @@ import { buildExportEnvelope, exportFilename, transferSummaryMessage } from '@co
import {
applyDatasetRenamesToSnippets,
dedupeIncomingNames,
dropClashingFonts,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
import { snippetSizeBytes } from '@core/snippet';
import { humanizeBytes } from '@core/storage-estimate';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { registerFontAssets } from '../infrastructure/font-faces';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** Practical snippet-storage budget (spec §02 storage monitor / §08). */
@@ -51,6 +54,7 @@ export function exportWorkspace(now: Date = new Date()): void {
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
const themes = useCustomThemeStore.getState().themes;
const fonts = useFontStore.getState().fonts;
if (snippets.length === 0) {
notify({
@@ -61,17 +65,22 @@ export function exportWorkspace(now: Date = new Date()): void {
return;
}
// TODO(fonts §08): the envelope does not yet carry user-uploaded FontAsset
// bytes, so a theme/snippet referencing an uploaded font imports on another
// machine with the fallback family. Embed the referenced faces (base64) here —
// best done with task #3's shared font-bytes→embeddable-string helper.
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
// Uploaded font faces travel base64-encoded inside the envelope, so a theme or
// snippet referencing one renders on another machine instead of falling back to
// a system font (spec §08, scope doc §4).
const envelope = buildExportEnvelope(snippets, datasets, themes, fonts, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length),
message: transferSummaryMessage(
'Exported',
snippets.length,
datasets.length,
themes.length,
fonts.length,
),
});
}
@@ -110,6 +119,7 @@ export async function importWorkspace(file: File): Promise<void> {
snippets: normSnippets,
datasets: normDatasets,
themes: normThemes,
fonts: normFonts,
} = normalizeImport(parsed);
if (normSnippets.length === 0) {
@@ -139,6 +149,15 @@ export async function importWorkspace(file: File): Promise<void> {
normThemes,
);
// Fonts key on a unique family. A clash skips the incoming face (kept existing —
// a same-named face already satisfies the reference), so no propagation either,
// just reporting (spec §08 → Name conflicts; scope doc §4).
const existingFontFamilies = useFontStore.getState().fonts.map((f) => f.family);
const { records: newFonts, skipped: skippedFonts } = dropClashingFonts(
existingFontFamilies,
normFonts,
);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
@@ -150,6 +169,10 @@ export async function importWorkspace(file: File): Promise<void> {
useDatasetStore.getState().addDatasets(dedupedDatasets);
const themeIdsBefore = new Set(useCustomThemeStore.getState().themes.map((t) => t.id));
useCustomThemeStore.getState().addThemes(dedupedThemes);
// Fonts join the same pre-snippet commit so they roll back together on failure.
// Live `FontFace` registration is deferred to the success path below.
const fontIdsBefore = new Set(useFontStore.getState().fonts.map((f) => f.id));
useFontStore.getState().addFonts(newFonts);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
@@ -191,6 +214,10 @@ export async function importWorkspace(file: File): Promise<void> {
for (const t of addedThemes) {
useCustomThemeStore.getState().remove(t.id);
}
const addedFonts = useFontStore.getState().fonts.filter((f) => !fontIdsBefore.has(f.id));
for (const f of addedFonts) {
useFontStore.getState().remove(f.id);
}
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
// GOV.UK plain language — no codes, tell the user what to do next).
@@ -219,13 +246,19 @@ export async function importWorkspace(file: File): Promise<void> {
// All IDB writes succeeded — now make the snippets visible in the store.
useSnippetStore.getState().addSnippets(finalSnippets);
// Register the imported faces on document.fonts so charts using them render
// immediately (mirrors the upload path) — only now that the import has committed.
const addedFonts = useFontStore.getState().fonts.filter((f) => !fontIdsBefore.has(f.id));
registerFontAssets(addedFonts);
// Feedback (spec §08 → Feedback): one summary toast; a warning when records were
// renamed or storage is over budget, otherwise a success.
// renamed, fonts were skipped, or storage is over budget, otherwise a success.
const summary = transferSummaryMessage(
'Imported',
finalSnippets.length,
dedupedDatasets.length,
dedupedThemes.length,
newFonts.length,
);
const clauses: string[] = [];
const allRenames = [...renames, ...themeRenames];
@@ -234,6 +267,9 @@ export async function importWorkspace(file: File): Promise<void> {
`Renamed to avoid clashes: ${allRenames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (skippedFonts.length > 0) {
clauses.push(`Skipped fonts already in your library: ${skippedFonts.join(', ')}.`);
}
if (overage > 0) {
clauses.push(
`This puts snippet storage about ${humanizeBytes(overage)} over the ~5 MB budget; ` +
+87
View File
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
chartExportFilename,
embedFontsInSvg,
inlineReferencedDatasets,
MAX_BASENAME_LEN,
referencedDatasetNames,
referencedUploadedFonts,
snippetFileBasename,
} from './chart-export';
import { createFontAsset, fontFamilyStack, type FontAsset } from './font-asset';
import { DatasetNotFoundError, type ResolvableDataset } from './rendering';
describe('snippetFileBasename', () => {
@@ -130,3 +133,87 @@ describe('inlineReferencedDatasets', () => {
expect(() => inlineReferencedDatasets(spec, [sales])).toThrow(DatasetNotFoundError);
});
});
describe('referencedUploadedFonts', () => {
const font = (family: string): FontAsset =>
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
});
const brand = font('Brand');
const display = font('Display Face');
it('matches an uploaded face named in the spec (by its stacks primary family)', () => {
const spec = JSON.stringify({ config: { font: fontFamilyStack('Brand') }, mark: 'bar' });
expect(referencedUploadedFonts(spec, undefined, [brand, display])).toEqual([brand]);
});
it('matches a face named only in the chart config (theme-only font)', () => {
const config = { axis: { labelFont: fontFamilyStack('Display Face') } };
expect(referencedUploadedFonts('{}', config, [brand, display])).toEqual([display]);
});
it('matches case-insensitively', () => {
const config = { font: '"brand", sans-serif' };
expect(referencedUploadedFonts('{}', config, [brand])).toEqual([brand]);
});
it('ignores roster/system stacks that match no uploaded face', () => {
const config = { font: '"Inter", system-ui, sans-serif' };
expect(referencedUploadedFonts('{}', config, [brand])).toEqual([]);
});
it('returns [] for an empty library or an unparseable spec with no config font', () => {
expect(referencedUploadedFonts('{}', undefined, [])).toEqual([]);
expect(referencedUploadedFonts('not json', undefined, [brand])).toEqual([]);
});
});
describe('embedFontsInSvg', () => {
const font = (family: string): FontAsset =>
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
});
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="80"><g/></svg>';
it('returns the SVG unchanged when there are no fonts', () => {
expect(embedFontsInSvg(svg, [])).toBe(svg);
});
it('injects an @font-face style with a base64 data-URI src as the first child', () => {
const out = embedFontsInSvg(svg, [font('Brand')]);
expect(out).toContain('<style');
expect(out).toContain('@font-face');
expect(out).toContain('font-family:"Brand"');
expect(out).toContain('src:url(data:font/woff2;base64,');
expect(out).toContain('format("woff2")'); // CSS keyword, not the extension
// The style sits immediately after the opening <svg ...> tag.
expect(out).toMatch(/<svg\b[^>]*><style/);
});
it('uses the CSS format keyword for ttf/otf (truetype/opentype)', () => {
const ttf = createFontAsset({
family: 'Mono',
data: new Uint8Array([1]).buffer,
format: 'ttf',
fileName: 'mono.ttf',
});
expect(embedFontsInSvg(svg, [ttf])).toContain('format("truetype")');
});
it('emits a font-weight range for a variable face so weights resolve', () => {
const variable = createFontAsset({
family: 'Flex',
data: new Uint8Array([1]).buffer,
format: 'ttf',
fileName: 'flex.ttf',
axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }],
});
expect(embedFontsInSvg(svg, [variable])).toContain('font-weight:100 900');
});
});
+78
View File
@@ -12,6 +12,14 @@
* and stay in `infrastructure/file-transfer` and the chart renderer.
*/
import { collectFontFamilies } from './custom-theme';
import {
fontDataUri,
primaryFamilyName,
variableFontDescriptors,
type FontAsset,
type FontFormat,
} from './font-asset';
import { prepareSpecForRender, type ResolvableDataset } from './rendering';
import { extractDatasetRefs } from './spec-refs';
@@ -89,3 +97,73 @@ export function inlineReferencedDatasets(
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
return JSON.stringify(resolved, null, 2);
}
// --- SVG font embedding (spec §08; scope doc §4, item 7) --------------------
//
// `view.toSVG()` writes only the `font-family` name, so an exported SVG falls
// back to a system font wherever the user's uploaded face isn't installed. We
// embed the referenced uploaded faces as base64 `@font-face` rules so the SVG
// renders the right type anywhere. Only uploaded faces are embedded — the
// roster/system stacks are decoration with their own fallbacks, and we don't
// hold their bytes here.
/** CSS `format()` keyword for an `@font-face` src — distinct from the file extension. */
const CSS_FORMAT: Record<FontFormat, string> = {
woff2: 'woff2',
woff: 'woff',
ttf: 'truetype',
otf: 'opentype',
};
/**
* The uploaded fonts a chart actually references — its spec + the active chart
* config, matched by primary family against the font library. Returns the
* matched `FontAsset`s (the bytes to embed); `[]` when none are referenced or
* the library is empty. Case-insensitive, like the naming helpers.
*/
export function referencedUploadedFonts(
specText: string,
config: unknown,
fonts: ReadonlyArray<FontAsset>,
): FontAsset[] {
if (fonts.length === 0) return [];
let spec: unknown;
try {
spec = JSON.parse(specText);
} catch {
spec = null; // an unparseable spec still has a config that may name a font
}
const stacks = new Set<string>([...collectFontFamilies(spec), ...collectFontFamilies(config)]);
const used = new Set<string>([...stacks].map((s) => primaryFamilyName(s).toLowerCase()));
return fonts.filter((f) => used.has(f.family.toLowerCase()));
}
/** Escape a family name for use inside a double-quoted CSS string. */
function cssQuote(family: string): string {
return family.replace(/[\\"]/g, '\\$&');
}
/** Build one `@font-face` rule embedding a face's bytes as a data-URI `src`. */
function fontFaceRule(font: FontAsset): string {
const desc = variableFontDescriptors(font.axes);
const weight = desc.weight ? `font-weight:${desc.weight};` : '';
const stretch = desc.stretch ? `font-stretch:${desc.stretch};` : '';
return (
`@font-face{font-family:"${cssQuote(font.family)}";${weight}${stretch}` +
`src:url(${fontDataUri(font)}) format("${CSS_FORMAT[font.format]}");}`
);
}
/**
* Inject a `<style>` of `@font-face` rules (base64 `src`) as the first child of
* the root `<svg>`, so the serialized chart carries the uploaded faces it uses.
* Returns `svg` unchanged when there are no fonts. The CSS is wrapped in CDATA
* (an SVG is XML; a family name could contain `&`/`<`), with the one sequence
* that can't appear in CDATA — `]]>` — split defensively.
*/
export function embedFontsInSvg(svg: string, fonts: ReadonlyArray<FontAsset>): string {
if (fonts.length === 0) return svg;
const css = fonts.map(fontFaceRule).join('').replace(/]]>/g, ']]]]><![CDATA[>');
const style = `<style type="text/css"><![CDATA[${css}]]></style>`;
return svg.replace(/(<svg\b[^>]*>)/, `$1${style}`);
}
+59 -26
View File
@@ -9,6 +9,7 @@ import {
transferSummaryMessage,
type ExportEnvelope,
} from './export-envelope';
import { createFontAsset, serializeFontAsset, type FontAsset } from './font-asset';
import { createSnippet, type Snippet } from './snippet';
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
@@ -39,6 +40,20 @@ function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
};
}
function makeFont(overrides: Partial<FontAsset> = {}): FontAsset {
return {
...createFontAsset({
family: 'Brand',
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
now: FIXED_NOW,
id: 1,
}),
...overrides,
};
}
describe('EXPORT_ENVELOPE_VERSION', () => {
it('is the spec-mandated "1.0"', () => {
expect(EXPORT_ENVELOPE_VERSION).toBe('1.0');
@@ -47,7 +62,7 @@ describe('EXPORT_ENVELOPE_VERSION', () => {
describe('buildExportEnvelope', () => {
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], [], { now: FIXED_NOW });
expect(env.version).toBe('1.0');
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
expect(env.exportedBy).toBe('Astrolabe');
@@ -57,7 +72,8 @@ describe('buildExportEnvelope', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const font = makeFont();
const env = buildExportEnvelope([snippet], [dataset], [theme], [font], { now: FIXED_NOW });
const expected: ExportEnvelope = {
version: '1.0',
exportedAt: '2026-06-03T12:00:00.000Z',
@@ -65,6 +81,7 @@ describe('buildExportEnvelope', () => {
snippets: [snippet],
datasets: [dataset],
themes: [theme],
fonts: [serializeFontAsset(font)],
};
expect(env).toEqual(expected);
});
@@ -73,17 +90,27 @@ describe('buildExportEnvelope', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const env = buildExportEnvelope([snippet], [dataset], [theme], [], { now: FIXED_NOW });
expect(env.snippets[0]).toBe(snippet);
expect(env.datasets[0]).toBe(dataset);
expect(env.themes[0]).toBe(theme);
});
it('serializes font bytes to base64 (the array is JSON-safe)', () => {
const font = makeFont();
const env = buildExportEnvelope([makeSnippet()], [], [], [font], { now: FIXED_NOW });
expect(typeof env.fonts[0].data).toBe('string');
// The whole envelope must survive a JSON round-trip without loss.
expect(() => JSON.stringify(env)).not.toThrow();
expect(env.fonts[0].family).toBe('Brand');
expect(env.fonts[0].size).toBe(4);
});
it("preserves each record's own version field", () => {
const snippet = makeSnippet({ version: 1 });
const dataset = makeDataset({ version: 1 });
const theme = makeTheme({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const env = buildExportEnvelope([snippet], [dataset], [theme], [], { now: FIXED_NOW });
expect(env.snippets[0].version).toBe(1);
expect(env.datasets[0].version).toBe(1);
expect(env.themes[0].version).toBe(1);
@@ -95,22 +122,26 @@ describe('buildExportEnvelope', () => {
const snippets = [makeSnippet()];
const datasets = [makeDataset()];
const themes = [makeTheme()];
const env = buildExportEnvelope(snippets, datasets, themes, { now: FIXED_NOW });
const fonts = [makeFont()];
const env = buildExportEnvelope(snippets, datasets, themes, fonts, { now: FIXED_NOW });
snippets.push(makeSnippet({ id: 's2' }));
datasets.push(makeDataset({ id: 2, name: 'D2' }));
themes.push(makeTheme({ id: 2, name: 'T2' }));
fonts.push(makeFont({ id: 2, family: 'Brand 2' }));
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(env.themes).toHaveLength(1);
expect(env.fonts).toHaveLength(1);
});
it('handles empty arrays', () => {
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], [], { now: FIXED_NOW });
expect(env.snippets).toEqual([]);
expect(env.datasets).toEqual([]);
expect(env.themes).toEqual([]);
expect(env.fonts).toEqual([]);
});
});
@@ -127,28 +158,30 @@ describe('exportFilename', () => {
describe('transferSummaryMessage (shared by export and import feedback)', () => {
it('reports snippet and dataset counts (plural)', () => {
expect(transferSummaryMessage('Exported', 4, 2, 0)).toBe('Exported 4 snippets and 2 datasets');
});
it('omits the dataset and theme clauses when their counts are zero', () => {
expect(transferSummaryMessage('Exported', 4, 0, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(transferSummaryMessage('Imported', 1, 1, 0)).toBe('Imported 1 snippet and 1 dataset');
});
it('pluralizes zero counts (and omits the other clauses)', () => {
expect(transferSummaryMessage('Imported', 0, 0, 0)).toBe('Imported 0 snippets');
});
it('reports all three counts with comma-and joining', () => {
expect(transferSummaryMessage('Exported', 4, 2, 1)).toBe(
'Exported 4 snippets, 2 datasets and 1 theme',
expect(transferSummaryMessage('Exported', 4, 2, 0, 0)).toBe(
'Exported 4 snippets and 2 datasets',
);
});
it('joins snippets and themes with "and" when there are no datasets', () => {
expect(transferSummaryMessage('Imported', 4, 0, 3)).toBe('Imported 4 snippets and 3 themes');
it('omits the dataset, theme, and font clauses when their counts are zero', () => {
expect(transferSummaryMessage('Exported', 4, 0, 0, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(transferSummaryMessage('Imported', 1, 1, 0, 0)).toBe('Imported 1 snippet and 1 dataset');
});
it('pluralizes zero counts (and omits the other clauses)', () => {
expect(transferSummaryMessage('Imported', 0, 0, 0, 0)).toBe('Imported 0 snippets');
});
it('reports all four counts with comma-and joining', () => {
expect(transferSummaryMessage('Exported', 4, 2, 1, 3)).toBe(
'Exported 4 snippets, 2 datasets, 1 theme and 3 fonts',
);
});
it('joins snippets and fonts with "and" when there are no datasets or themes', () => {
expect(transferSummaryMessage('Imported', 4, 0, 0, 1)).toBe('Imported 4 snippets and 1 font');
});
});
+18 -5
View File
@@ -14,6 +14,7 @@
import type { CustomTheme } from './custom-theme';
import type { Dataset } from './dataset';
import { serializeFontAsset, type FontAsset, type SerializedFontAsset } from './font-asset';
import type { Snippet } from './snippet';
/**
@@ -26,8 +27,8 @@ export const EXPORT_ENVELOPE_VERSION = '1.0';
/**
* The downloaded file's top-level shape (spec §08 → Export envelope shape): format
* metadata plus the complete-record arrays. Each record keeps its own `version`
* field unchanged. `themes` is additive (always written, optional on read) so
* pre-theme envelopes and importers remain compatible without a format bump.
* field unchanged. `themes` and `fonts` are additive (always written, optional on
* read) so older envelopes and importers remain compatible without a format bump.
*/
export interface ExportEnvelope {
/** Export format version (currently `"1.0"`). */
@@ -42,6 +43,13 @@ export interface ExportEnvelope {
datasets: Dataset[];
/** All custom chart themes, as complete records (each including its record `version`). */
themes: CustomTheme[];
/**
* All uploaded font faces, base64-encoded so a referenced face survives the
* round-trip (without this the family name imports but renders as fallback —
* spec §08, scope doc §4). The whole library travels, like datasets/themes: a
* workspace export is a backup, not a minimal bundle of what's referenced.
*/
fonts: SerializedFontAsset[];
}
/**
@@ -55,6 +63,7 @@ export function buildExportEnvelope(
snippets: ReadonlyArray<Snippet>,
datasets: ReadonlyArray<Dataset>,
themes: ReadonlyArray<CustomTheme>,
fonts: ReadonlyArray<FontAsset>,
opts: { now: Date },
): ExportEnvelope {
return {
@@ -64,6 +73,8 @@ export function buildExportEnvelope(
snippets: [...snippets],
datasets: [...datasets],
themes: [...themes],
// Encode each face's bytes (ArrayBuffer → base64) so the array is JSON-safe.
fonts: fonts.map(serializeFontAsset),
};
}
@@ -90,19 +101,21 @@ function countClause(count: number, noun: string): string {
/**
* Success-toast message reporting transfer counts (spec §08 → Feedback) for both
* directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1
* snippet". The dataset and theme clauses are omitted entirely when their counts
* are zero; singular/plural wording adapts. One builder for export and import so
* a new record kind or wording change lands in both messages at once.
* snippet". The dataset, theme, and font clauses are omitted entirely when their
* counts are zero; singular/plural wording adapts. One builder for export and
* import so a new record kind or wording change lands in both messages at once.
*/
export function transferSummaryMessage(
verb: 'Exported' | 'Imported',
snippetCount: number,
datasetCount: number,
themeCount: number,
fontCount: number,
): string {
const clauses = [countClause(snippetCount, 'snippet')];
if (datasetCount > 0) clauses.push(countClause(datasetCount, 'dataset'));
if (themeCount > 0) clauses.push(countClause(themeCount, 'theme'));
if (fontCount > 0) clauses.push(countClause(fontCount, 'font'));
const last = clauses.pop()!;
return clauses.length === 0 ? `${verb} ${last}` : `${verb} ${clauses.join(', ')} and ${last}`;
}
+88
View File
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest';
import {
CURRENT_FONT_VERSION,
createFontAsset,
deserializeFontAsset,
detectFontFormat,
familyNameFromFileName,
type FontAsset,
type FontAxis,
fontDataUri,
fontFamilyStack,
isVariableFont,
parseFontAxes,
primaryFamilyName,
serializeFontAsset,
variableFontDescriptors,
} from './font-asset';
@@ -168,3 +173,86 @@ describe('variable-font helpers', () => {
expect(variableFontDescriptors(undefined)).toEqual({});
});
});
describe('primaryFamilyName', () => {
it('extracts the first comma-segment and strips quotes', () => {
expect(primaryFamilyName('"My Brand", sans-serif')).toBe('My Brand');
expect(primaryFamilyName("'Inter', system-ui, sans-serif")).toBe('Inter');
expect(primaryFamilyName('system-ui, -apple-system')).toBe('system-ui');
expect(primaryFamilyName('Brand')).toBe('Brand');
});
});
describe('fontDataUri', () => {
it('builds a base64 data URL with the format MIME type', () => {
const asset = createFontAsset({
family: 'Brand',
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
});
expect(fontDataUri(asset)).toBe(`data:font/woff2;base64,${btoa('\x01\x02\x03\x04')}`);
});
});
describe('serializeFontAsset / deserializeFontAsset', () => {
const NOW = new Date('2026-06-16T08:00:00.000Z');
function sample(overrides: Partial<FontAsset> = {}): FontAsset {
return {
...createFontAsset({
family: 'Brand',
data: new Uint8Array([10, 20, 30, 40, 50]).buffer,
format: 'woff2',
fileName: 'brand.woff2',
now: NOW,
id: 7,
}),
...overrides,
};
}
it('round-trips a font through base64 without losing its bytes or metadata', () => {
const original = sample({ modified: '2026-06-16T09:00:00.000Z' });
const restored = deserializeFontAsset(serializeFontAsset(original));
expect(restored).not.toBeNull();
expect(new Uint8Array(restored!.data)).toEqual(new Uint8Array(original.data));
expect(restored!.family).toBe('Brand');
expect(restored!.format).toBe('woff2');
expect(restored!.fileName).toBe('brand.woff2');
expect(restored!.size).toBe(5);
expect(restored!.created).toBe(NOW.toISOString());
expect(restored!.modified).toBe('2026-06-16T09:00:00.000Z');
});
it('preserves variable-font axes through the round-trip', () => {
const axes: FontAxis[] = [{ tag: 'wght', min: 100, default: 400, max: 900 }];
const restored = deserializeFontAsset(serializeFontAsset(sample({ axes })));
expect(restored!.axes).toEqual(axes);
});
it('re-derives size from the decoded bytes (not a stale stored value)', () => {
const serialized = { ...serializeFontAsset(sample()), size: 999 };
expect(deserializeFontAsset(serialized)!.size).toBe(5);
});
it('returns null for a record missing family or data, or with bad base64', () => {
expect(deserializeFontAsset(null)).toBeNull();
expect(deserializeFontAsset({ data: 'AAEC' })).toBeNull();
expect(deserializeFontAsset({ family: 'X' })).toBeNull();
expect(deserializeFontAsset({ family: 'X', data: '' })).toBeNull();
expect(deserializeFontAsset({ family: 'X', data: '!!not-base64!!' })).toBeNull();
});
it('falls back to a derived format when the stored one is absent/unknown', () => {
const serialized = { ...serializeFontAsset(sample()), format: 'bogus' };
expect(deserializeFontAsset(serialized)!.format).toBe('woff2'); // from fileName
});
it('stores timestamps verbatim and never throws on a corrupt created (would abort import)', () => {
const serialized = { ...serializeFontAsset(sample()), created: 'not-a-date', modified: '' };
const restored = deserializeFontAsset(serialized);
expect(restored).not.toBeNull();
expect(restored!.created).toBe('not-a-date');
});
});
+122
View File
@@ -161,6 +161,128 @@ export function fontFamilyStack(family: string): string {
return `"${family}", sans-serif`;
}
/**
* The primary family of a CSS font stack — the first comma-segment with any
* surrounding quotes stripped. `'"My Brand", sans-serif'` → `My Brand`;
* `'system-ui, sans-serif'` → `system-ui`. Used to match a config's font slot
* (which holds a whole stack — see `fontFamilyStack`/`THEME_FONT_OPTIONS`) back
* to a `FontAsset.family`, so only uploaded faces (never the roster/system
* stacks) are embedded on export.
*/
export function primaryFamilyName(stack: string): string {
const first = stack.split(',')[0]?.trim() ?? '';
return first.replace(/^["']|["']$/g, '').trim();
}
// --- Serialization & data URIs (base64) ------------------------------------
//
// `btoa`/`atob` are platform globals (HTML/WHATWG, present in browsers and Node
// alike — same standing as `crypto.randomUUID`/`ArrayBuffer` used elsewhere in
// core), so font bytes serialize without reaching for a DOM API. The chunked
// `fromCharCode` keeps a multi-MB face under the argument-count limit.
/** Container-format → CSS `@font-face` MIME type, for an embedded `src` URL. */
const FONT_MIME: Record<FontFormat, string> = {
woff2: 'font/woff2',
woff: 'font/woff',
ttf: 'font/ttf',
otf: 'font/otf',
};
/** Base64-encode raw font bytes (32 KB chunks to stay under the spread limit). */
function bytesToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/** Decode base64 back to raw font bytes. Throws on malformed input (caller guards). */
function base64ToBytes(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
/** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */
export function fontDataUri(asset: FontAsset): string {
return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
}
/**
* A FontAsset as it travels in the §08 export envelope (JSON): identical to the
* stored record except `data` is base64 (an `ArrayBuffer` can't be JSON-encoded).
*/
export interface SerializedFontAsset extends Omit<FontAsset, 'data'> {
/** Base64-encoded font bytes. */
data: string;
}
/** Whether a value is one of the supported container formats. */
function isFontFormat(value: unknown): value is FontFormat {
return value === 'woff2' || value === 'woff' || value === 'ttf' || value === 'otf';
}
/** Encode a stored FontAsset for the export envelope (base64 its bytes). */
export function serializeFontAsset(font: FontAsset): SerializedFontAsset {
const { data, ...rest } = font;
return { ...rest, data: bytesToBase64(data) };
}
/**
* Decode one envelope font record back onto the current `FontAsset` shape, or
* `null` when it's unusable (missing/empty `family` or `data`, or `data` that
* isn't valid base64) — the importer skips a `null` rather than abort. Like
* `normalizeCustomTheme` this is gap-filling, not foreign-shape mapping: fonts
* only travel inside Astrolabe envelopes. `size` is re-derived from the decoded
* bytes (authoritative), `version` stamped current, and `id` left provisional
* for the store's id authority to reassign.
*/
export function deserializeFontAsset(raw: unknown, opts: { now?: Date } = {}): FontAsset | null {
if (typeof raw !== 'object' || raw === null) return null;
const r = raw as Record<string, unknown>;
const family = typeof r.family === 'string' && r.family.trim() !== '' ? r.family : null;
const encoded = typeof r.data === 'string' && r.data !== '' ? r.data : null;
if (family === null || encoded === null) return null;
let data: ArrayBuffer;
try {
data = base64ToBytes(encoded);
} catch {
return null;
}
if (data.byteLength === 0) return null;
const fileName = typeof r.fileName === 'string' ? r.fileName : family;
const format = isFontFormat(r.format) ? r.format : (detectFontFormat(fileName) ?? 'ttf');
const axes = Array.isArray(r.axes) ? (r.axes as FontAxis[]) : undefined;
const created = typeof r.created === 'string' && r.created !== '' ? r.created : null;
const modified = typeof r.modified === 'string' && r.modified !== '' ? r.modified : null;
// Build the base for id/version/size/format/axes, then overlay the stored
// timestamps *verbatim* — never round-tripped through `new Date()`, so a corrupt
// non-ISO `created` can't throw and abort the import (mirrors normalizeCustomTheme).
const base = createFontAsset({
family,
data,
format,
fileName,
source: r.source === 'google' ? 'google' : 'file',
...(axes && axes.length > 0 ? { axes } : {}),
now: opts.now,
});
return {
...base,
created: created ?? base.created,
modified: modified ?? created ?? base.modified,
};
}
// --- Variable-font (fvar) parsing ------------------------------------------
const SFNT_TTF = 0x00010000; // TrueType outlines
+72 -1
View File
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
import { CURRENT_THEME_VERSION } from './custom-theme';
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
import { createFontAsset, serializeFontAsset, type FontAsset, type FontFormat } from './font-asset';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingNames,
dropClashingFonts,
normalizeImport,
reassignCollidingSnippetIds,
} from './import-normalize';
@@ -107,7 +109,7 @@ describe('normalizeImport — shape detection', () => {
});
it('returns empty for null / non-object / non-array junk', () => {
const empty = { snippets: [], datasets: [], themes: [] };
const empty = { snippets: [], datasets: [], themes: [], fonts: [] };
expect(normalizeImport(null)).toEqual(empty);
expect(normalizeImport(42)).toEqual(empty);
expect(normalizeImport('hello')).toEqual(empty);
@@ -373,6 +375,75 @@ describe('normalizeImport — custom themes', () => {
});
});
describe('normalizeImport — fonts', () => {
const serializedFont = (family: string) =>
serializeFontAsset(
createFontAsset({
family,
data: new Uint8Array([1, 2, 3, 4]).buffer,
format: 'woff2',
fileName: `${family}.woff2`,
now: FIXED_NOW,
}),
);
const envelope = (fonts: unknown[]) => ({
version: '1.0',
snippets: [currentSnippetRecord()],
fonts,
});
it('decodes envelope fonts back to FontAssets with their bytes intact', () => {
const { fonts } = normalizeImport(envelope([serializedFont('Brand')]), { now: FIXED_NOW });
expect(fonts).toHaveLength(1);
expect(fonts[0].family).toBe('Brand');
expect(new Uint8Array(fonts[0].data)).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(fonts[0].size).toBe(4);
});
it('drops unusable font records (no data / bad base64) without failing the import', () => {
const { snippets, fonts } = normalizeImport(
envelope([serializedFont('Good'), { family: 'NoBytes' }, { family: 'Bad', data: '%%%' }]),
{ now: FIXED_NOW },
);
expect(snippets).toHaveLength(1); // the import still succeeds
expect(fonts.map((f) => f.family)).toEqual(['Good']);
});
it('yields no fonts for a bare-array or single-snippet import (no envelope)', () => {
expect(normalizeImport([currentSnippetRecord()]).fonts).toEqual([]);
expect(normalizeImport(currentSnippetRecord()).fonts).toEqual([]);
});
});
describe('dropClashingFonts', () => {
const font = (family: string, format: FontFormat = 'woff2'): FontAsset =>
createFontAsset({
family,
data: new Uint8Array([1, 2, 3]).buffer,
format,
fileName: `${family}.${format}`,
});
it('keeps incoming fonts whose family is new', () => {
const { records, skipped } = dropClashingFonts(['Existing'], [font('Brand'), font('Display')]);
expect(records.map((f) => f.family)).toEqual(['Brand', 'Display']);
expect(skipped).toEqual([]);
});
it('skips an incoming font whose family already exists (case-insensitive), not rename', () => {
const { records, skipped } = dropClashingFonts(['Brand'], [font('brand'), font('New')]);
expect(records.map((f) => f.family)).toEqual(['New']);
expect(skipped).toEqual(['brand']);
});
it('dedupes within the incoming batch (first wins)', () => {
const { records, skipped } = dropClashingFonts([], [font('Brand'), font('Brand')]);
expect(records).toHaveLength(1);
expect(skipped).toEqual(['Brand']);
});
});
describe('dedupeIncomingNames', () => {
it('returns names unchanged when there are no collisions', () => {
const { records, renames } = dedupeIncomingNames(['Other'], [datasetRecord({ name: 'Sales' })]);
+42 -4
View File
@@ -16,6 +16,7 @@
import { CURRENT_THEME_VERSION, type CustomTheme } from './custom-theme';
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
import { deserializeFontAsset, type FontAsset } from './font-asset';
import type { DataFormat } from './format-detection';
import { makeUniqueName } from './naming';
import type { ColumnStats } from './profile';
@@ -32,6 +33,7 @@ export interface NormalizedImport {
snippets: Snippet[];
datasets: Dataset[];
themes: CustomTheme[];
fonts: FontAsset[];
}
/** A single rename applied during name dedupe (`from` original → `to` unique). */
@@ -237,21 +239,23 @@ function normalizeCustomTheme(raw: unknown, nowIso: string): CustomTheme {
* Detect the import shape and normalize every record onto the current model.
*
* - Envelope: object with a `version` AND a `snippets` array → its snippets
* (+ optional `datasets` and `themes` arrays).
* - Bare array: a top-level array → a list of snippets, no datasets/themes.
* - Single object: any other object → one snippet, no datasets/themes.
* (+ optional `datasets`, `themes`, and `fonts` arrays).
* - Bare array: a top-level array → a list of snippets, no datasets/themes/fonts.
* - Single object: any other object → one snippet, no datasets/themes/fonts.
* - junk (null / non-object / non-array) → empty.
*/
export function normalizeImport(
parsed: unknown,
opts: NormalizeImportOptions = {},
): NormalizedImport {
const nowIso = (opts.now ?? new Date()).toISOString();
const now = opts.now ?? new Date();
const nowIso = now.toISOString();
const makeId = opts.makeId ?? (() => crypto.randomUUID());
let rawSnippets: unknown[] = [];
let rawDatasets: unknown[] = [];
let rawThemes: unknown[] = [];
let rawFonts: unknown[] = [];
if (Array.isArray(parsed)) {
// Bare array of snippets.
@@ -262,6 +266,7 @@ export function normalizeImport(
rawSnippets = parsed.snippets as unknown[];
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
if (Array.isArray(parsed.fonts)) rawFonts = parsed.fonts;
} else {
// Single snippet object.
rawSnippets = [parsed];
@@ -273,6 +278,10 @@ export function normalizeImport(
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
themes: rawThemes.map((t) => normalizeCustomTheme(t, nowIso)),
// Unusable font records (no family / bad base64) decode to null and are dropped.
fonts: rawFonts
.map((f) => deserializeFontAsset(f, { now }))
.filter((f): f is FontAsset => f !== null),
};
}
@@ -305,6 +314,35 @@ export function dedupeIncomingNames<T extends { name: string }>(
return { records, renames };
}
/**
* Drop incoming fonts whose `family` already exists (case-insensitive) — in the
* library or earlier in the same batch. The fonts merge rule (spec §08 → Name
* conflicts; scope doc §4): unlike datasets/themes (renamed on clash), a clashing
* uploaded face is **skipped**, not renamed — a font is identified by its family,
* which is the key embedded in config font slots, so a same-named existing face
* already satisfies any incoming reference. This also dedupes a self-backup →
* restore (no "Font 2" copies pile up). Returns the kept records and the skipped
* family names (for reporting).
*/
export function dropClashingFonts(
existingFamilies: ReadonlyArray<string>,
incoming: ReadonlyArray<FontAsset>,
): { records: FontAsset[]; skipped: string[] } {
const seen = new Set(existingFamilies.map((n) => n.toLowerCase()));
const records: FontAsset[] = [];
const skipped: string[] = [];
for (const font of incoming) {
const key = font.family.toLowerCase();
if (seen.has(key)) {
skipped.push(font.family);
continue;
}
seen.add(key);
records.push(font);
}
return { records, skipped };
}
/**
* Reassign ids for incoming snippets whose id already exists (spec §08 "ID
* collisions"). The existing snippet keeps its id; the incoming one gets a fresh