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