mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user