Skip to main content

curved-text

CI Docs

Draw text that follows an arbitrary curve in matplotlib.

Label a curve along its own path instead of in a legend, so the reader's eye never leaves the data to read a colour key. The figure below shows the difference: a legend on the left, and the same lines labelled along their paths on the right.

A legend on the left, the same lines labelled along their paths on the right

The label is recomputed every time the figure is drawn, so it keeps following the curve through layout changes, resizing, and interactive panning or zooming.

When it fits

  • The curve (x, y) must be ordered along its length, so each point comes after the one before it, and it must have at least two points.
  • The curve should be smooth relative to the text size. Label a smoothed or fitted trend line, not raw noisy samples. Each letter follows the direction of the curve under it, so where the curve turns back on itself within the width of a single letter, as noisy data does, the letters overlap.

Install

pip install curved-text

To work from a clone, install it in editable mode:

pip install -e .

Usage

import numpy as np
import matplotlib.pyplot as plt
from curved_text import curved_text

x = np.linspace(0, 2 * np.pi, 400)
y = np.sin(x)

fig, ax = plt.subplots()
ax.plot(x, y)
curved_text(ax, x, y, "text that follows the curve",
            pos=0.5, anchor="center", offset=6.0, color="C3")
plt.show()

A label following a sine wave

Placement

Three independent parameters control where the label sits:

  • pos sets where the label is anchored along the curve, as a fraction of the curve's length. 0.0 is the first point, 1.0 is the last.
  • anchor sets which part of the label lands at pos: "start", "center", or "end".
  • offset shifts the label off the curve, measured in typographic points. The shift is perpendicular to the curve. A positive value sits above a left-to-right curve.

The figure below sweeps pos from the first point to the last. The green dot marks the anchor point in each panel.

A label at five positions along a curve

If the label runs past either end of the curve, it is not cut off. The curve continues straight in the direction it had at that end, and the extra letters sit on that straight line.

The function and the class

The object form is also available:

from curved_text import CurvedText

CurvedText(x, y, "along the curve", ax, pos=0.2, anchor="start", offset=4.0)

Note the order of the axes argument. The CurvedText class takes it after x, y, text, matching matplotlib.text.Text. The curved_text function takes it first, matching matplotlib's axes-first helper functions.

Any extra keyword arguments (color, fontsize, alpha, fontfamily, usetex, ...) pass through to each per-character glyph and each math run.

Features

Mathtext

A $...$ run in the label is laid out by matplotlib's mathtext engine and then bent along the curve the same way plain text is. Every part of the expression follows the curve, down to the bars in fractions and the overlines in radicals, so fractions, radicals, and large brackets stay joined together at any curvature. Plain and math runs mix freely in one string:

curved_text(ax, x, y, r"flux $\propto \sqrt{D_{\mathrm{eff}}}\,(L/L_0)^2$",
            pos=0.5, anchor="center", offset=8.0)

A mathtext expression following a sine wave

Pass parse_math=False to treat dollar signs literally. Tall expressions compress vertically on the inside of tight bends, so choose the text size to suit the curvature.

Plain words and math runs in one label share a single baseline, so the math symbols sit level with the surrounding letters and a superscript lifts only the exponent, not the body -- the alignment is built in, not tuned:

curved_text(ax, x, y, r"mass $m$ and speed $c$ give $E = mc^2$")

Plain words and math runs on one shared baseline along a curve

LaTeX (usetex)

With matplotlib's text.usetex rcParam set, or usetex=True passed to curved_text, LaTeX typesets the label, so it matches the figure's other usetex text. A LaTeX installation is required, as for any usetex figure.

curved_text(ax, x, y, r"yield 50% at $\sqrt{\bar{\sigma}_G}$", usetex=True)

The figure below sets the rcParam and the serif font family, so the tick labels, the axis labels, and the labels along the curves share one LaTeX face. With the default sans-serif family, words in text mode are set in Computer Modern Sans while math stays in the serif Computer Modern.

Cooling curves labelled along their paths, with every text typeset by LaTeX

Plain text is typeset literally, one character at a time: %, #, &, and the other TeX markup characters print as themselves, and TeX commands work only inside $...$. This differs from matplotlib's own usetex text, where r"50\%" is needed for a percent sign. The first draw runs LaTeX once for each distinct character and math run, and once more to measure the font's ascender and descender lines, which every valign but "baseline" and the box casing use. This can take several seconds; later draws are cached.

Plain text is limited to characters the LaTeX preamble can typeset. A Greek letter in a math run ($\lambda$) is italic, as a variable should be. For an upright Greek letter in plain text, such as a name or a unit, declare it in your own preamble, one declaration per letter:

mpl.rcParams["text.latex.preamble"] = (
    r"\usepackage{upgreek}"
    r"\DeclareUnicodeCharacter{03BB}{\ensuremath{\uplambda}}"
)
curved_text(ax, x, y, r"plain λ, math $\lambda$", usetex=True)

The library leaves the preamble to you. It is one global setting for every usetex text in the figure, including the layout passes that measure the label.

A few details differ from the default renderer:

  • The usetex setting is fixed when the label is created, as matplotlib fixes it for each text. Set the rcParam or pass usetex=True before creating the label.
  • LaTeX takes the font family from the font.family rcParam, as for matplotlib's own usetex text, so the fontfamily keyword has no effect.
  • The valign lines come from the TeX font as LaTeX draws it. Under usetex, valign="ascender" meets the tops of the letters; without usetex it sits a little above them, on the line the font reserves for line spacing.
  • Math runs go to LaTeX as written, so they can run TeX commands, including ones that read local files. Do not pass untrusted text with usetex on.

Casing behind the label

Set box to draw a casing behind the label. The casing is a band that follows the curve under the letters, at the label's height. It keeps the label readable where it crosses the lines it labels. Because it is a single fill, it covers plain text and mathtext alike:

curved_text(ax, x, y, r"signal $s(t) = A\,e^{-t/\tau}$", box=True)

box accepts True, a colour string, or a dict with these keys:

Key Meaning Default
color Fill colour of the band white
pad Band height, as a multiple of the tallest glyph's height 1.1
alpha Opacity of the band 1.0

A label cleared from the lines it crosses by a white casing

For a lighter casing that hugs each letter instead, pass a white withStroke through matplotlib's path_effects. Path effects reach every letter and every mathtext run, as they do on any Text. Because each letter is stroked on its own, a wide stroke makes neighbouring letters blur together, so use box for solid coverage under plain text:

import matplotlib.patheffects as pe

curved_text(ax, x, y, r"signal $s(t) = A\,e^{-t/\tau}$",
            path_effects=[pe.withStroke(linewidth=4, foreground="white")])

The figure below shows the difference on the same plain-text label: a wide stroke per character on top, the single box fill on the bottom.

A wide per-character stroke leaves gaps; a box fill covers cleanly

Even spacing on tight bends

Letters are placed one after another by their own widths, so where the curve bends sharply they crowd together on the inside of the bend, because each rigid letter box fans into its neighbour there. Set crowding="curvature" to open an even letterspacing gap that grows with the local curvature, so the inside edges stop colliding:

curved_text(ax, x, y, "winding", crowding="curvature")

The gap is the same between every pair of letters, so the tracking stays even. It also has a deadband: a gentle bend or a straight run stays below it and is left unchanged, so only genuinely crowded text is spaced out. The default is crowding="none", which leaves the spacing as the letters' own widths set it.

The figure below shows both regimes. Top row: a sharp bend, where the correction visibly separates the letters. Bottom row: a gentle bend of the same letters, left untouched because it falls below the deadband.

A sharp bend with crowded letters spaced out, a gentle bend left unchanged

Choosing what rides the curve

By default the text straddles the curve (valign="center"). Use valign to pick a different line: "baseline" runs the text baseline along the curve so the body sits above it, while "ascender" and "descender" ride the top or bottom of the text -- handy for hanging a label below a curve or sitting it above one:

curved_text(ax, x, y, "Amplitude", valign="baseline")

The shift is a single font metric applied to the whole label, so it never disturbs the spacing or the alignment of plain text with mathtext. Combine it with offset to lift the chosen line clear of the curve.

The same word on a curve under each valign option

Works with seaborn, pandas, and other matplotlib-backed libraries

curved_text needs only a matplotlib.axes.Axes, so it works with any library that draws on matplotlib. seaborn's axes-level functions return an Axes, its figure-level functions expose one through .axes, and pandas DataFrame.plot returns an Axes as well. Pass that axes in directly:

import seaborn as sns

ax = sns.lineplot(data=df, x="x", y="y")
curved_text(ax, df["x"], df["y"], "along the curve",
            pos=0.5, anchor="center", offset=6.0)

A label drawn on a seaborn axes

How it works

Each character (and each $...$ run) is a child artist drawn from its glyph outline and placed so a single shared text baseline follows the curve. A plain character is turned rigidly to follow the line: instead of reading the curve's slope at a single point, the package draws a straight line across the width of that one letter and tips the letter to match. Averaging over the letter's width this way keeps neighbouring letters smooth even when the curve is drawn from only a few points, and sharing one baseline keeps plain text and mathtext level.

All of this is measured in display space, that is, in pixels on the final figure after every scaling step. So the spacing and the perpendicular offset stay correct at any DPI and figure size. The layout is redone on every draw, which is why the label keeps up with resizing and with interactive pan or zoom.

matplotlib-label-lines labels one or many lines inline at a chosen or automatically picked point, each label rotated to the local slope. It is the quickest way to replace a legend across a set of lines. curved-text solves the adjacent problem: making a single string (plain or mathtext) follow the curve character by character, with arc-length placement and a perpendicular offset, redone on every draw. Reach for label-lines to drop legend labels onto several lines, and for curved-text to make text ride a path. In the ggplot2 world, geomtextpath covers similar text-on-path ground.

For matplotlib users coming from LaTeX: this is matplotlib's version of TikZ's text along path decoration (from decorations.text). The mapping is close. pos and anchor do the work of text align and the indents, offset does the work of raise, and overrunning text rides the straight tangent extension instead of being cut off at the path's end.

Documentation

Full documentation, including the API reference and the design notes, is at thiebes.github.io/curved-text. More worked examples are in the example gallery.

Contributing

Bug reports, feature requests, and pull requests are welcome. See CONTRIBUTING.md for the development install, how to run the tests, linting, and the rest of the workflow. By participating you agree to the Code of Conduct.

Development and AI use

Generative AI tools were used to develop and maintain this package. The author (the sole maintainer) directed the design and reviewed, tested, and approved every change before committing it.

  • Design and direction. The architecture and the design decisions were the maintainer's: the chord-across-advance placement, the _CurveFrame display-space geometry, tangent-extension overflow, and the choice to support mathtext by bending glyph outlines along the curve. The mathtext feature was iterated against real problems found in use, including the baseline-alignment fix and the move from a per-character stroke to a single box casing once the per-letter halo was found to leave gaps. AI assisted in drafting the design documents and the implementation against these decisions.
  • Code. The implementation in src/curved_text/ was drafted with substantial AI assistance against that design, then reviewed and revised by the maintainer. Correctness, cross-version compatibility (the test suite runs against a matplotlib floor and a pre-release), packaging, and the release process were the maintainer's responsibility.
  • Tests. The test suite in tests/ was drafted with AI assistance and reviewed by the maintainer, and is run in CI on every change.
  • Documentation. The README, API reference, and example gallery prose were drafted with AI assistance and edited by the maintainer.
  • How AI was used. Primarily conversational and agentic drafting with Anthropic's Claude. The maintainer provided the problem framing, the design decisions, and the final review.

No AI-generated content was committed without human review.

License

MIT

Release files for curved-text 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for curved-text 0.6.0
File Size Uploaded
curved_text-0.6.0.tar.gz 44.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for curved-text 0.6.0
File Interpreter ABI Platform
curved_text-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 69.3 kB

Release files / curved_text-0.6.0.tar.gz

Download URL curved_text-0.6.0.tar.gz
Size 44.7 kB
Tags Source
SHA-256 checksum
How to use checksums
6c9aa2cc4ae2e4864fc36d1f271b4702d67b480207abe2c4d48f1b5b3bb393d5
BLAKE2b-256 checksum
How to use checksums
a886a407b402459fa28aa847cbf8506267816c63b6dbc2f873bd0ebef40c30f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / curved_text-0.6.0-py3-none-any.whl

Download URL curved_text-0.6.0-py3-none-any.whl
Size 24.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c775a20d8cc45cee814bc4e667e30f5df0bac9677b68657d372d9ecb9f9bd1dd
BLAKE2b-256 checksum
How to use checksums
16394c0afa5b138affc61644dc1c1a0f0868f014f6e424c1f627e46f34d73ddc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page