Chart fonts: self-hosted roster + document.fonts.load render gate

This commit is contained in:
2026-06-14 13:08:16 +03:00
parent df6a0b3845
commit b03c3b08ab
8 changed files with 353 additions and 10 deletions
+42
View File
@@ -10,6 +10,7 @@
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';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
interface ImageExportOptions {
@@ -145,6 +146,44 @@ function canvasObjectUrl(canvas: HTMLCanvasElement): Promise<string> {
});
}
/** Weights to preload per family — the roster ships 400/600 (700 for the mono). */
const FONT_LOAD_WEIGHTS = ['400', '600', '700'];
/**
* Cap on waiting for fonts before rendering anyway. A precached/cached face
* resolves near-instantly; this only bounds the first fetch of an uncached
* subset so a slow network never freezes the preview — it renders with fallback
* metrics and sharpens on a later re-render once the face is cached.
*/
const FONT_LOAD_TIMEOUT_MS = 3000;
/**
* Load the font faces a spec/config will use before rendering. Vega measures
* every text label via canvas `measureText` regardless of renderer (even the
* 'none' probe runs layout), so a face that finishes loading *after* embed
* leaves the whole chart laid out with fallback metrics.
*
* Font loading is a non-critical enhancement, and an individual face failing
* (offline, a 404, or a system family with no `@font-face`) is *expected* — the
* correct response is to render with fallback metrics, not to fail the chart. So
* we wait on `allSettled` (which never rejects; a rejected load is ignored by
* design) and bound a slow first fetch with the timeout. This is the sanctioned
* "safe to swallow" case from arch 02's fail-loud rule — the failure mode is
* graceful fallback, not a buried bug or lost data — kept explicit rather than
* hidden in a catch-all. No-op where the Font Loading API is absent (tests).
*/
async function ensureFontsLoaded(spec: VisualizationSpec, config: Config): Promise<void> {
if (typeof document === 'undefined' || !document.fonts?.load) return;
const families = new Set<string>([...collectFontFamilies(config), ...collectFontFamilies(spec)]);
if (families.size === 0) return;
const loads = [...families].flatMap((family) =>
FONT_LOAD_WEIGHTS.map((weight) => document.fonts.load(`${weight} 16px ${family}`)),
);
await Promise.race([
Promise.allSettled(loads),
new Promise((resolve) => setTimeout(resolve, FONT_LOAD_TIMEOUT_MS)),
]);
}
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
export async function renderSpec(
node: HTMLElement,
@@ -154,6 +193,9 @@ export async function renderSpec(
): Promise<RenderHandle> {
const renderer = options.renderer ?? 'svg';
// Load the chart's fonts before any layout pass measures text (see the helper).
await ensureFontsLoaded(spec, config);
// Canvas can't allocate past the browser's max dimension, and an oversized canvas
// fails *silently* (a blank/broken surface, sometimes a null 2d context). So for
// canvas we first run a headless ('none') layout pass — no canvas allocated — read