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; ` +