Repository navigation
[FEATURE]: Embed custom fonts into vector exports (no system install) #464
Description
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-face defined 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:
curl -L -o font.ttf \
https://github.com/google/fonts/raw/main/ofl/pacifico/Pacifico-Regular.ttfimport asyncio
import kaleido
import plotly.graph_objects as go
FONT_PATH = "font.ttf" # a font NOT installed on your system
FAMILY = "Pacifico" # its family name
fig = go.Figure(go.Scatter(y=[1, 4, 2, 7, 5], mode="lines+markers"))
fig.update_layout(width=700, height=500, font_family=FAMILY,
title_text=f"{FAMILY} test")
async def main():
async with kaleido.Kaleido(n=1, timeout=90) as k:
await k.write_fig(fig, path="chart.pdf", opts={"format": "pdf"})
asyncio.run(main())Inspect which font the PDF actually embedded:
from pypdf import PdfReader
fonts = set()
def visit(t, cm, tm, fd, sz):
if fd and "/BaseFont" in fd: fonts.add(str(fd["/BaseFont"]))
for p in PdfReader("chart.pdf").pages:
p.extract_text(visitor_text=visit)
print(fonts)Observed: the embedded font is a system fallback (e.g. a Times/serif face), not FAMILY. The same chart looks different on a machine where FAMILY is installed — output is not reproducible across environments. (If you instead see FAMILY embedded, 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.:
await k.write_fig(fig, path="chart.pdf",
opts={"format": "pdf", "fonts": [FONT_PATH]})After which the PDF embeds the actual FAMILY glyphs (a subset such as AAAAAA+Pacifico-Regular) and the SVG carries an inlined base64 @font-face, making vector output self-contained.
A draft implementation is in #463.
Activity
robertclaus commentedon Aug 4, 2026
@nul0m we still owe you a review on the PR. @camdecoster will try to take a look in the next week or two.
nul0m commentedon Aug 5, 2026
Thanks, our team would appreciate this a lot
camdecoster commentedon Aug 20, 2026
Reviewing 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:
write_imageto allow for a new param in the call to Kaleido.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.
nul0m commentedon Aug 20, 2026
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:
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:
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:
"OpenSans.ttf"into base64. That's Kaleido (or plotly.py).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.
camdecoster commentedon Aug 20, 2026
FYI, it will be easier for me to come to a decision if we talk to each other directly rather than through an LLM.
nul0m commentedon Aug 21, 2026
@camdecoster sure, i thought a detailed and structured answer would be easier to read, but i respect your preference. to be short:
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?):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 anywayfonts=[ {"path": "OpenSans-Bold.ttf", "family": "Open Sans", "weight": 700}, ... ]camdecoster commentedon Aug 27, 2026
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.
nul0m commentedon Sep 3, 2026
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.
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?
camdecoster commentedon Sep 17, 2026
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.
nul0m commentedon Sep 18, 2026
@camdecoster created plotly/plotly.js#8056