mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Fonts: carry uploaded faces through export/import + embed in SVG export
This commit is contained in:
@@ -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; ` +
|
||||
|
||||
Reference in New Issue
Block a user