Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support

This commit is contained in:
2026-06-16 21:41:04 +03:00
parent 713f396c5c
commit 5d3aba608a
20 changed files with 1191 additions and 40 deletions
+117
View File
@@ -0,0 +1,117 @@
/**
* Font upload service (scope doc §4 → User font upload) — orchestration over the
* pure core helpers, the FontStore, and the browser seams.
*
* The deterministic work (format detection, family derivation, uniqueness) lives
* in `@core/font-asset` + `@core/naming`; this layer reads the file bytes through
* the infrastructure adapter, validates, commits to the store (the persistence
* subscriber writes through to IndexedDB), registers the live `FontFace`, and
* reports one summary toast. Deleting a font unregisters its face and removes the
* record (the subscriber deletes it from IndexedDB).
*/
import {
createFontAsset,
detectFontFormat,
familyNameFromFileName,
MAX_FONT_BYTES,
parseFontAxes,
} from '@core/font-asset';
import { makeUniqueName } from '@core/naming';
import { humanizeBytes } from '@core/storage-estimate';
import { readBinaryFile } from '../infrastructure/file-transfer';
import { registerFontAsset, unregisterFont } from '../infrastructure/font-faces';
import { notify } from '../stores/NotificationStore';
import { useFontStore } from '../stores/FontStore';
/** Why a picked file was skipped, for the summary toast. */
interface Skip {
fileName: string;
reason: string;
}
/**
* Add one or more picked font files to the library. Each file is validated
* independently (unsupported format / over the size cap are skipped, not fatal),
* given a unique family name, stored, and registered. Reports how many were added
* and why any were skipped — never throws on a bad file.
*/
export async function uploadFontFiles(files: FileList | File[]): Promise<void> {
const list = Array.from(files);
if (list.length === 0) return;
const added: string[] = [];
const skipped: Skip[] = [];
for (const file of list) {
const format = detectFontFormat(file.name);
if (!format) {
skipped.push({ fileName: file.name, reason: 'unsupported format' });
continue;
}
let data: ArrayBuffer;
try {
data = await readBinaryFile(file);
} catch {
skipped.push({ fileName: file.name, reason: 'could not be read' });
continue;
}
if (data.byteLength > MAX_FONT_BYTES) {
skipped.push({
fileName: file.name,
reason: `larger than ${humanizeBytes(MAX_FONT_BYTES)}`,
});
continue;
}
// Unique within the library (and within this batch — the store grew on the
// previous iteration, so re-read its families each time).
const family = makeUniqueName(
familyNameFromFileName(file.name),
useFontStore.getState().fonts.map((f) => f.family),
);
// Detect variation axes (variable fonts) so the face registers with the
// right weight/width ranges — uncompressed ttf/otf only; others stay static.
const axes = parseFontAxes(data, format);
const stored = useFontStore
.getState()
.add(createFontAsset({ family, data, format, fileName: file.name, axes }));
registerFontAsset(stored);
added.push(family);
}
reportUpload(added, skipped);
}
/** Remove a font: unregister its live face, then drop the record. */
export function removeFont(id: number): void {
unregisterFont(id);
useFontStore.getState().remove(id);
}
/** One summary toast covering what was added and what was skipped. */
function reportUpload(added: string[], skipped: Skip[]): void {
const skips = skipped.length
? `Skipped ${skipped.map((s) => `${s.fileName} (${s.reason})`).join(', ')}.`
: '';
if (added.length === 0) {
notify({
kind: skipped.length ? 'error' : 'info',
title: skipped.length ? "Couldn't add the font" : 'No fonts added',
message:
skips || 'Choose a .woff2, .woff, .ttf, or .otf file to add a font to your chart themes.',
});
return;
}
const noun = added.length === 1 ? 'font' : 'fonts';
const message = `Added ${added.join(', ')}. ${skips}`.trim();
notify({
kind: skipped.length ? 'warning' : 'success',
title: `Added ${added.length} ${noun}`,
message,
});
}
+4
View File
@@ -60,6 +60,10 @@ 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 });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));