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
+78
View File
@@ -12,6 +12,14 @@
* and stay in `infrastructure/file-transfer` and the chart renderer.
*/
import { collectFontFamilies } from './custom-theme';
import {
fontDataUri,
primaryFamilyName,
variableFontDescriptors,
type FontAsset,
type FontFormat,
} from './font-asset';
import { prepareSpecForRender, type ResolvableDataset } from './rendering';
import { extractDatasetRefs } from './spec-refs';
@@ -89,3 +97,73 @@ export function inlineReferencedDatasets(
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
return JSON.stringify(resolved, null, 2);
}
// --- SVG font embedding (spec §08; scope doc §4, item 7) --------------------
//
// `view.toSVG()` writes only the `font-family` name, so an exported SVG falls
// back to a system font wherever the user's uploaded face isn't installed. We
// embed the referenced uploaded faces as base64 `@font-face` rules so the SVG
// renders the right type anywhere. Only uploaded faces are embedded — the
// roster/system stacks are decoration with their own fallbacks, and we don't
// hold their bytes here.
/** CSS `format()` keyword for an `@font-face` src — distinct from the file extension. */
const CSS_FORMAT: Record<FontFormat, string> = {
woff2: 'woff2',
woff: 'woff',
ttf: 'truetype',
otf: 'opentype',
};
/**
* The uploaded fonts a chart actually references — its spec + the active chart
* config, matched by primary family against the font library. Returns the
* matched `FontAsset`s (the bytes to embed); `[]` when none are referenced or
* the library is empty. Case-insensitive, like the naming helpers.
*/
export function referencedUploadedFonts(
specText: string,
config: unknown,
fonts: ReadonlyArray<FontAsset>,
): FontAsset[] {
if (fonts.length === 0) return [];
let spec: unknown;
try {
spec = JSON.parse(specText);
} catch {
spec = null; // an unparseable spec still has a config that may name a font
}
const stacks = new Set<string>([...collectFontFamilies(spec), ...collectFontFamilies(config)]);
const used = new Set<string>([...stacks].map((s) => primaryFamilyName(s).toLowerCase()));
return fonts.filter((f) => used.has(f.family.toLowerCase()));
}
/** Escape a family name for use inside a double-quoted CSS string. */
function cssQuote(family: string): string {
return family.replace(/[\\"]/g, '\\$&');
}
/** Build one `@font-face` rule embedding a face's bytes as a data-URI `src`. */
function fontFaceRule(font: FontAsset): string {
const desc = variableFontDescriptors(font.axes);
const weight = desc.weight ? `font-weight:${desc.weight};` : '';
const stretch = desc.stretch ? `font-stretch:${desc.stretch};` : '';
return (
`@font-face{font-family:"${cssQuote(font.family)}";${weight}${stretch}` +
`src:url(${fontDataUri(font)}) format("${CSS_FORMAT[font.format]}");}`
);
}
/**
* Inject a `<style>` of `@font-face` rules (base64 `src`) as the first child of
* the root `<svg>`, so the serialized chart carries the uploaded faces it uses.
* Returns `svg` unchanged when there are no fonts. The CSS is wrapped in CDATA
* (an SVG is XML; a family name could contain `&`/`<`), with the one sequence
* that can't appear in CDATA — `]]>` — split defensively.
*/
export function embedFontsInSvg(svg: string, fonts: ReadonlyArray<FontAsset>): string {
if (fonts.length === 0) return svg;
const css = fonts.map(fontFaceRule).join('').replace(/]]>/g, ']]]]><![CDATA[>');
const style = `<style type="text/css"><![CDATA[${css}]]></style>`;
return svg.replace(/(<svg\b[^>]*>)/, `$1${style}`);
}