Skip to content

[FEATURE]: Embed custom fonts into vector exports (no system install) #464

Description

@nul0m

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.ttf
import 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

  1. self-assigned this
    on Jul 2, 2026
  2. robertclaus commented on Aug 4, 2026

    @robertclaus

    @nul0m we still owe you a review on the PR. @camdecoster will try to take a look in the next week or two.

  3. nul0m commented on Aug 5, 2026

    @nul0m
    Author

    Thanks, our team would appreciate this a lot

  4. camdecoster commented on Aug 20, 2026

    @camdecoster
    Contributor

    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:

    • 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_image to 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 fonts param? 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.

  5. nul0m commented on Aug 20, 2026

    @nul0m
    Author

    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 calls new FontFace(family, url(...)) with no descriptors, and the @font-face it inlines carries no font-weight/font-style.

    That means if you pass OpenSans-Regular.ttf and OpenSans-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-face descriptors, which is what both the FontFace constructor 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:

    1. 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.
    2. Use fontTools for the convenience path (bare path, infer family/weight/ style). TTFont(path)["name"] and the OS/2 table 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-face into 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.fonts before toImage, it measures with fallback metrics and the geometry is wrong even when the glyphs later render correctly. In the PR that's the loadFonts(...).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-face on 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 fonts option 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.

  6. camdecoster commented on Aug 20, 2026

    @camdecoster
    Contributor

    FYI, it will be easier for me to come to a decision if we talk to each other directly rather than through an LLM.

  7. nul0m commented on Aug 21, 2026

    @nul0m
    Author

    @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_image is 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.js today — it can move to plotly.js, but only if plotly.js can take the font data, so it's not a pure relocation, right?
    • fonttools package: 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}, ... ]
  8. camdecoster commented on Aug 27, 2026

    @camdecoster
    Contributor

    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.

  9. nul0m commented on Sep 3, 2026

    @nul0m
    Author

    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?

  10. camdecoster commented on Sep 17, 2026

    @camdecoster
    Contributor

    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.

  11. nul0m commented on Sep 18, 2026

    @nul0m
    Author
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions