Skip to content

Embed custom fonts into vector exports#463

Open
nul0m wants to merge 1 commit into
plotly:masterfrom
nul0m:feature/embed-custom-fonts
Open

Embed custom fonts into vector exports#463
nul0m wants to merge 1 commit into
plotly:masterfrom
nul0m:feature/embed-custom-fonts

Conversation

@nul0m

@nul0m nul0m commented Jun 26, 2026

Copy link
Copy Markdown

Fixes #464

Summary

Adds a fonts image option (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.

await k.write_fig(fig, path="chart.pdf",
                  opts={"format": "pdf", "fonts": ["MyFont.ttf"]})

Why

Plotly's SVG output references fonts by name only — it never embeds the font bytes. For PDF/EPS, Kaleido loads that SVG into an isolated <img> document, so a font that isn't installed silently falls back (e.g. Times New Roman on Windows). Charts then look different depending on which machine renders them. See #464 for a full reproduction.

What changed

  • _utils/font_tools.py (new): parses a .ttf/.otf/.woff file, reads its family name straight from the sfnt name table (preferring the typographic family, nameID 16), and builds a base64 @font-face descriptor. .woff2 (needs Brotli) and .ttc collections are rejected with actionable errors.
  • _utils/fig_tools.py: fonts added to LayoutOpts; each path is packaged into the spec sent to the render scope.
  • src/js/src/plotly/render.js: loads the fonts into the page before rendering (so text is measured with correct metrics), and inlines the @font-face (with base64 data) into the SVG so vector output is self-contained and survives the isolated <img> used for PDF/EPS.
  • Rebuilt vendor/kaleido_scopes.js.
  • tests/test_font_tools.py (new): family extraction (ttf/otf/woff), typographic-vs-basic precedence, format hints, coerce_for_js packaging, and error paths (missing file, woff2, ttc, unrecognized, missing name table).

Testing steps

Automated

cd src/py
uv run pytest tests/test_font_tools.py        # 11 new tests
uv run pytest                                  # full suite (no regressions)
uv run ruff check .                            # lint (project uses select = ["ALL"])

Manual end-to-end (before/after reproduction of #464)

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

If you're unsure of the family name, let Kaleido read it from the file:

python -c "from kaleido._utils import font_tools; \
  print(font_tools.font_face_from_path('font.ttf')['family'])"   # -> e.g. 'Pacifico'

Render the same figure twice — once without the opt (current behavior) and once with it (the fix):

import asyncio, kaleido
import plotly.graph_objects as go

FONT_PATH = "font.ttf"   # a font NOT installed on your system
FAMILY    = "Pacifico"   # its family name (see the one-liner above)

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} embedding test")

async def main():
    async with kaleido.Kaleido(n=1, timeout=90) as k:
        await k.write_fig(fig, path="before.pdf", opts={"format": "pdf"})
        await k.write_fig(fig, path="after.pdf",
                          opts={"format": "pdf", "fonts": [FONT_PATH]})
        await k.write_fig(fig, path="after.svg",
                          opts={"format": "svg", "fonts": [FONT_PATH]})

asyncio.run(main())

Verify:

from pypdf import PdfReader

def pdf_fonts(path):
    found = set()
    def visit(t, cm, tm, fd, sz):
        if fd and "/BaseFont" in fd: found.add(str(fd["/BaseFont"]))
    for pg in PdfReader(path).pages:
        pg.extract_text(visitor_text=visit)
    return sorted(found)

print("before:", pdf_fonts("before.pdf"))   # fallback, e.g. ['/AAAAAA+Times-Roman']
print("after :", pdf_fonts("after.pdf"))     # embedded, e.g. ['/AAAAAA+Pacifico-Regular']
print("svg   :", "@font-face" in open("after.svg").read())   # -> True (base64 @font-face)

Expected result:

  • before.pdf embeds a system fallback face (e.g. Times-Roman) — FAMILY is absent: this is the [FEATURE]: Embed custom fonts into vector exports (no system install) #464 bug.
  • after.pdf embeds a FAMILY subset, and after.svg contains an inlined base64 @font-face — the fix.
  • If before.pdf already embeds FAMILY, that font is installed on your machine — pick a different font so the contrast is meaningful.

Notes / open questions

  • Only uncompressed sfnt (.ttf/.otf) and .woff v1 are parsed; .woff2 requires a Brotli decoder and is rejected — happy to add support if a dependency is acceptable.
  • The family name is read from the font file, so the family the user references in font_family is matched automatically.

🤖 Generated with Claude Code

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 <img>
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 <img> 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) <noreply@anthropic.com>
@nul0m nul0m marked this pull request as ready for review June 26, 2026 12:45
@camdecoster

Copy link
Copy Markdown
Contributor

Thanks for the PR! Before we review, could you please create an issue/feature request that this fixes? In that issue, could you please include reproduction steps? In this PR, could you please add testing steps?

@nul0m

nul0m commented Jun 30, 2026

Copy link
Copy Markdown
Author

Thanks @camdecoster! Done on both:

  • Opened a feature request with a full reproduction (showing the current name-only fallback to a system font): [FEATURE]: Embed custom fonts into vector exports (no system install) #464. This PR now links it via Fixes #464.
  • Added a Testing steps section to the PR description — both the automated tests (pytest tests/test_font_tools.py + full suite + ruff) and a parametrized manual before/after that verifies the SVG carries an inlined base64 @font-face and the PDF embeds the chosen font as a subset rather than a system fallback. The font is a variable, so you can point it at any family that isn't installed on your machine.

Happy to adjust anything before review.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants