From c70390d2b9e992bfafd7d5c169277d2147f0b3ab Mon Sep 17 00:00:00 2001 From: Sergey Plotnikov Date: Fri, 26 Jun 2026 14:05:22 +0200 Subject: [PATCH] Embed custom fonts into vector exports Add an opt `fonts` (a list of font-file paths) that embeds the given fonts into Kaleido's output so figures render with the correct typeface without the font being installed system-wide. Plotly's SVG output only references fonts by name; it never embeds the font bytes. For PDF/EPS, Kaleido loads that SVG into an isolated document, so an uninstalled font silently falls back (e.g. Times New Roman). This change fixes that end to end: - font_tools.py: parse a .ttf/.otf/.woff file, read its family name from the sfnt `name` table, and build a base64 `@font-face` descriptor. .woff2 (Brotli) and .ttc collections are rejected with actionable errors. - fig_tools.py: accept `fonts` in LayoutOpts and package each path into the spec sent to the render scope. - render.js: load the fonts into the page before rendering (correct text metrics) and inline the `@font-face` (with base64 data) into the SVG so vector output is self-contained and survives the isolated used for PDF/EPS. - Rebuilt vendor/kaleido_scopes.js bundle. - Tests for font parsing, format hints, error paths, and coerce_for_js. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/js/src/plotly/parse.js | 1 + src/js/src/plotly/render.js | 91 +++++++++-- src/py/README.md | 6 +- src/py/kaleido/_utils/fig_tools.py | 13 +- src/py/kaleido/_utils/font_tools.py | 208 ++++++++++++++++++++++++ src/py/kaleido/vendor/kaleido_scopes.js | 92 +++++++++-- src/py/tests/test_font_tools.py | 160 ++++++++++++++++++ 7 files changed, 546 insertions(+), 25 deletions(-) create mode 100644 src/py/kaleido/_utils/font_tools.py create mode 100644 src/py/tests/test_font_tools.py diff --git a/src/js/src/plotly/parse.js b/src/js/src/plotly/parse.js index c5e7b944..be0c9844 100644 --- a/src/js/src/plotly/parse.js +++ b/src/js/src/plotly/parse.js @@ -60,6 +60,7 @@ function parse (body, _opts) { result.scale = isPositiveNumeric(opts.scale) ? Number(opts.scale) : cst.dflt.scale result.fid = isNonEmptyString(opts.fid) ? opts.fid : null result.encoded = !!opts.encoded + result.fonts = Array.isArray(opts.fonts) ? opts.fonts : [] if (isNonEmptyString(opts.format)) { if (cst.contentFormat[opts.format]) { diff --git a/src/js/src/plotly/render.js b/src/js/src/plotly/render.js index a190d52e..b2b74a39 100644 --- a/src/js/src/plotly/render.js +++ b/src/js/src/plotly/render.js @@ -38,6 +38,7 @@ function render (info, topojsonURL, stepper) { const figure = info.figure; const format = info.format; const encoded = info.encoded; + const fonts = info.fonts || []; // Build default config, and let figure.config override it const defaultConfig = { @@ -113,15 +114,23 @@ function render (info, topojsonURL, stepper) { return new Promise((resolve) => {resolve(done())}) } - let promise + if (semver.lt(Plotly.version, '1.11.0')) { + errorCode = 526 + errorMsg = `plotly.js version: ${Plotly.version}` + return new Promise((resolve) => {resolve(done())}) + } - if (semver.gte(Plotly.version, '1.30.0')) { - promise = Plotly - .toImage({ data: figure.data, layout: figure.layout, config: config }, imgOpts) - } else if (semver.gte(Plotly.version, '1.11.0')) { - const gd = document.createElement('div') + // Render the figure to an image. Wrapped in a function so it only runs + // *after* any custom fonts have loaded (see loadFonts below), ensuring text + // is measured with the correct font metrics. + const makeImage = () => { + if (semver.gte(Plotly.version, '1.30.0')) { + return Plotly + .toImage({ data: figure.data, layout: figure.layout, config: config }, imgOpts) + } - promise = Plotly + const gd = document.createElement('div') + return Plotly .newPlot(gd, figure.data, figure.layout, config) .then(() => Plotly.toImage(gd, imgOpts)) .then((imgData) => { @@ -148,16 +157,22 @@ function render (info, topojsonURL, stepper) { return imgData } }) - } else { - errorCode = 526 - errorMsg = `plotly.js version: ${Plotly.version}` - return new Promise((resolve) => {resolve(done())}) } + // Load any custom fonts into the page before rendering. This both fixes text + // measurement and lets the (now embedded) @font-face survive into vector + // output. No-op when no fonts were requested. + const promise = loadFonts(fonts).then(makeImage) + const img = document.getElementById("kaleido-image") const style = document.getElementById("head-style") let exportPromise = promise.then((imgData) => { + // For vector output, inline the fonts into the SVG itself so the result is + // self-contained and renders correctly without the font being installed. + if (fonts.length && (format === 'svg' || PRINT_TO_PDF)) { + imgData = injectFontsIntoSVG(imgData, fonts) + } result = imgData return done() }) @@ -218,4 +233,58 @@ function decodeSVG (imgData) { return window.decodeURIComponent(imgData.replace(cst.imgPrefix.svg, '')) } +/** + * Register custom fonts with the page and wait for them to be ready. + * + * @param {Array<{family: string, format: string, url: string}>} fonts + * @return {Promise} resolves once every font has loaded (failures are logged, + * not fatal, so rendering still proceeds with a fallback). + */ +function loadFonts (fonts) { + if (!fonts || !fonts.length || + typeof FontFace === 'undefined' || !document.fonts) { + return Promise.resolve() + } + + return Promise.all(fonts.map((font) => { + const face = new FontFace(font.family, `url(${font.url})`) + document.fonts.add(face) + return face.load().catch((err) => { + console.log(`kaleido: failed to load font '${font.family}': ${err}`) + }) + })).then(() => document.fonts.ready) +} + +/** + * Inline @font-face rules (with base64 font data) into an SVG so the vector + * output embeds the fonts and renders identically anywhere, with no system + * font installation required. + * + * @param {string} imgData : either a raw SVG string or a + * `data:image/svg+xml,...` URI (handled transparently). + * @param {Array<{family: string, format: string, url: string}>} fonts + * @return {string} imgData in the same form it was passed in. + */ +function injectFontsIntoSVG (imgData, fonts) { + if (!fonts || !fonts.length) return imgData + + const css = fonts.map((font) => + `@font-face { font-family: '${font.family}';` + + ` src: url(${font.url}) format('${font.format}'); }` + ).join('\n') + const styleEl = `` + + const isDataUri = imgData.indexOf('data:image/svg+xml,') === 0 + let svg = isDataUri + ? window.decodeURIComponent(imgData.replace(cst.imgPrefix.svg, '')) + : imgData + + // Insert the ` + + const isDataUri = imgData.indexOf('data:image/svg+xml,') === 0 + let svg = isDataUri + ? window.decodeURIComponent(imgData.replace(cst.imgPrefix.svg, '')) + : imgData + + // Insert the