Repository navigation
[FEATURE]: Embed custom fonts into vector exports (no system install) #464
Description
Activity
- addedP3not needed for current cyclenot needed for current cycle
on Jul 23, 2026 @nul0m we still owe you a review on the PR. @camdecoster will try to take a look in the next week or two.
Thanks, our team would appreciate this a lot
Reacted by Robert ClausReviewing this issue and your PR, it seems like we need to make some decisions before moving forward. I agree with your solution at a high level: embed custom fonts such that PDFs/SVGs display properly on any system. Let's talk through the following points:
- It seems like some of these changes will need to be made in other repos (plotly.js and plotly.py). I think that we could move some (or most) of the SVG font injection code into plotly.js and then Kaleido could take advantage of that. We'll also need to update the signature of
write_imageto allow for a new param in the call to Kaleido. - Font utilities: I'm not a font expert and I'd like to avoid adding code that would require Kaleido maintainers to dig into how fonts are composed, byte sequences, etc. What do you think of using a package like fontTools to handle the font info lookup? There's a possibility that this wouldn't even be necessary if we inject the font in plotly.js.
- How should the fonts be referenced in the
fontsparam? Paths mean that we would need to read font info, which would require the custom font code or a library. If we use font metadata (family, weight, etc.), that could keep our font processing code lean.
I'm leaning toward adding the feature in plotly.js, then bubbling that up to Kaleido, but I'm curious what your thoughts are.
- It seems like some of these changes will need to be made in other repos (plotly.js and plotly.py). I think that we could move some (or most) of the SVG font injection code into plotly.js and then Kaleido could take advantage of that. We'll also need to update the signature of
Thanks @camdecoster — these are the right questions to settle before review, and one of them made me spot a real gap in my own PR. Taking them slightly out of order, because the third question turns out to answer the second.
First, a gap I should flag myself: weight and style
The current PR reads only the family name from the font file. The descriptor it builds is
{family, format, url}, it callsnew FontFace(family, url(...))with no descriptors, and the@font-faceit inlines carries nofont-weight/font-style.That means if you pass
OpenSans-Regular.ttfandOpenSans-Bold.ttf, both register as family "Open Sans" at weight 400 / normal — they collide, and any bold text gets synthesised from whichever face won rather than using the real bold outlines. Any figure using a bold or italic face in its chart text is affected. That needs fixing regardless of which repo the code ends up in.Q3: how should fonts be referenced? (paths and metadata)
I'd push back gently on framing these as alternatives. Metadata alone can't produce bytes to embed — if all Kaleido has is
family+weight, it's back to resolving a name against installed fonts, which is exactly the bug. A path (or the bytes) is necessary.But metadata is a valuable addition, and it's what fixes the weight/style gap above. So:
# simple: metadata read from the file fonts=["OpenSans-Regular.ttf"] # explicit: caller states the descriptors, nothing is parsed fonts=[ {"path": "OpenSans-Regular.ttf", "family": "Open Sans", "weight": 400}, {"path": "OpenSans-Bold.ttf", "family": "Open Sans", "weight": 700}, {"path": "OpenSans-Italic.ttf", "family": "Open Sans", "style": "italic"}, ]
The dict form maps 1:1 onto CSS
@font-facedescriptors, which is what both theFontFaceconstructor and the inlined rule want anyway.Q2: fontTools — agreed, and the above makes it optional
I'm happy to drop the hand-rolled sfnt parser; I share your reluctance to have Kaleido maintainers own font-internals code. Two ways to get there, and they compose:
- When the caller supplies metadata, parse nothing. With the dict form above there is no font introspection at all — Kaleido just base64s the file and passes the descriptors through. That covers the "I know my fonts" case with zero parsing code.
- Use fontTools for the convenience path (bare path, infer family/weight/ style).
TTFont(path)["name"]and theOS/2table give all three robustly, including the typographic-vs-basic family precedence I hand-rolled and the weight I currently ignore.
If a hard dependency is unwelcome, I'd suggest a lazy import behind an extra (
kaleido[fonts]) with a clear error pointing at either installing it or using the explicit dict form. Happy to go whichever way you prefer — including "metadata only, no inference at all", which needs no new dependency and no font code in Kaleido whatsoever. That may honestly be the cleanest v1.Q1: plotly.js vs Kaleido
I think you're right about the long-term shape: inlining
@font-faceinto the emitted SVG belongs in plotly.js, since plotly.js owns SVG serialisation, and a self-contained SVG is useful to browser users too, not just Kaleido. I'd support that and I'm willing to open the plotly.js PR.Two pieces can't move there, though, and they're the ones that make this work:
- Reading the font off disk. plotly.js runs in the page and can't read the user's filesystem. Whatever the API looks like, something Python-side has to turn
"OpenSans.ttf"into base64. That's Kaleido (or plotly.py). - Registering the font before layout. plotly.js measures text to place ticks, legends and margins. If the face isn't in
document.fontsbeforetoImage, it measures with fallback metrics and the geometry is wrong even when the glyphs later render correctly. In the PR that's theloadFonts(...).then(makeImage)ordering. Whoever owns the page has to do this — today that's Kaleido's render scope.
There's also an asymmetry in how badly the bug bites: a browser plotly.js user can already define
@font-faceon the host page and get correct output. Kaleido users can't, because the SVG is loaded into an<img>, which is an isolated document — so for PDF/EPS there is no workaround at all today.So my suggestion: land the additive
fontsoption in Kaleido now (it changes no existing behaviour and is inert when unused), and treat the plotly.js SVG-inlining as a follow-up I'm happy to drive. The public API doesn't change when that lands — Kaleido would simply stop doing the injection itself and let plotly.js emit it, keeping the file-reading and the pre-layout font registration where they have to live.If you'd rather not carry even the injection step in the meantime, I can cut the PR down to just the file→descriptor plumbing plus page registration and leave vector self-containment entirely to the plotly.js work — that would fix raster and text-metrics immediately, though PDF/EPS would have to wait.
Let me know which shape you'd like and I'll rework the PR accordingly — happy to split it into smaller pieces if that's easier to review.
FYI, it will be easier for me to come to a decision if we talk to each other directly rather than through an LLM.
@camdecoster sure, i thought a detailed and structured answer would be easier to read, but i respect your preference. to be short:
- plotly.py and plotly.js repos:
write_imageis needed, agreed, i missed that part. on plotly.js - i like the idea, injectFontsIntoSVG belongs there. tho, i see two things to agree on (or did you have it in mind?):- reading the font file into base64 has to be python, and it can live in plotly.py rather than kaleido, which would keep font code out of kaleido entirely; and
- registering the font before plotly.js measures text is in kaleido's
render.jstoday — it can move to plotly.js, but only if plotly.js can take the font data, so it's not a pure relocation, right?
fonttoolspackage: sure, sounds reasonable. i just didn't know how strict you are on adding extra 3rd-party dependencies; and if the caller passes family/weight/style there is nothing to parse anyway- paths vs metadata: well, this was the whole point of the original bug request, metadata alone gives no bytes to embed, so if the font is not installed on the system a path is still needed somewhere; i'd treat metadata as additive - it's also what fixes a bug in my current PR: i only read the family name, so regular and bold both land as one face at weight 400 and bold gets synthesized, hence the suggested format
fonts=[ {"path": "OpenSans-Bold.ttf", "family": "Open Sans", "weight": 700}, ... ]
- plotly.py and plotly.js repos:
It still feels like you're copy/pasting LLM text, so I'm not prioritizing this work. In my opinion, I think we should update plotly.js to inject the font before moving forward with changes in Kaleido. If you're interested in doing that (and you'll write in your own words), then I'm open to coming up with a solution. Before creating any PR, let's iterate in a plotly.js issue.
It still feels like you're copy/pasting LLM text, so I'm not prioritizing this work.
well, it's your feelings, i can't do much about those. feel free to feel whatever you want. i spent 2 hours on that last comment reworking whatever was originally llm-ed. those were mostly my words and (what is more important) only my thoughts.
In my opinion, I think we should update plotly.js to inject the font before moving forward with changes in Kaleido. If you're interested in doing that (and you'll write in your own words), then I'm open to coming up with a solution. Before creating any PR, let's iterate in a plotly.js issue.
It's not about interest, I need that to work, so of course I will continue trying to contribute. Should I create that plotly.js issue?
UPD: i went on and checked the existing issues in plotly.js repo and #4885 looks related. should we discuss it there or you prefer a new one?
Thanks for looking into that. Go ahead and create a new issue and reference that one that you found. Let's hash out the plan there and then you can start work if you're interested.
Reacted by sergey plotnikov@camdecoster created plotly/plotly.js#8056
Reacted by Cameron DeCoster
Problem
Plotly's SVG output references fonts by name only (e.g.
font-family: 'My Font'); it never embeds the font bytes. When Kaleido renders that SVG to a raster or vector format, the font name is resolved against fonts available to the headless browser/OS. If the requested font is not installed system-wide, it silently falls back to a default (e.g. Times New Roman).For PDF/EPS this is unavoidable today even with workarounds, because Kaleido loads the SVG into an
<img>element and prints the page — an<img>-loaded SVG is an isolated document, so it can't see any@font-facedefined on the host page.The practical consequence: a chart authored with a brand/custom font renders correctly on the author's machine (where the font happens to be installed) but differently on CI, in containers, or on a colleague's machine.
Reproduction (current behavior)
Pick any font file (
.ttf/.otf/.woff) whose family is not installed system-wide on your machine and set the two variables below. A distinctive display font such as Pacifico is a safe default on most systems:Inspect which font the PDF actually embedded:
Observed: the embedded font is a system fallback (e.g. a Times/serif face), not
FAMILY. The same chart looks different on a machine whereFAMILYis installed — output is not reproducible across environments. (If you instead seeFAMILYembedded, that font is installed on your machine — pick a different one.)Desired behavior
A way to tell Kaleido to embed specific font files into the output so figures render with the intended typeface everywhere, without requiring a system font install — e.g.:
After which the PDF embeds the actual
FAMILYglyphs (a subset such asAAAAAA+Pacifico-Regular) and the SVG carries an inlined base64@font-face, making vector output self-contained.A draft implementation is in #463.