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