Metadata-Version: 2.4
Name: curved-text
Version: 0.8.0
Summary: Draw text along an arbitrary curve in matplotlib, with arc-length positioning and a perpendicular offset.
Author-email: "Joseph J. Thiebes" <joseph@thiebes.org>
License-Expression: MIT
Project-URL: Homepage, https://github.com/thiebes/curved-text
Project-URL: Documentation, https://thiebes.github.io/curved-text/
Project-URL: Source, https://github.com/thiebes/curved-text
Project-URL: Issues, https://github.com/thiebes/curved-text/issues
Project-URL: Changelog, https://github.com/thiebes/curved-text/blob/main/CHANGELOG.md
Keywords: matplotlib,text,annotation,label,curve,visualization
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Framework :: Matplotlib
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Intended Audience :: Science/Research
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: matplotlib>=3.5
Requires-Dist: numpy>=1.20
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Requires-Dist: pandas>=1.3; extra == "test"
Provides-Extra: examples
Requires-Dist: seaborn>=0.11; extra == "examples"
Requires-Dist: pandas>=1.3; extra == "examples"
Provides-Extra: docs
Requires-Dist: sphinx>=7.0; extra == "docs"
Requires-Dist: myst-parser>=2.0; extra == "docs"
Requires-Dist: furo>=2024.1.29; extra == "docs"
Dynamic: license-file

# curved-text

[![CI](https://github.com/thiebes/curved-text/actions/workflows/ci.yml/badge.svg)](https://github.com/thiebes/curved-text/actions/workflows/ci.yml)
[![Docs](https://github.com/thiebes/curved-text/actions/workflows/docs.yml/badge.svg)](https://thiebes.github.io/curved-text/)
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20725006.svg)](https://doi.org/10.5281/zenodo.20725006)

Draw text that follows an arbitrary curve in [matplotlib](https://matplotlib.org/).

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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/01_direct_labeling.png)

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

```bash
pip install curved-text
```

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

```bash
pip install -e .
```

## Usage

```python
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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/02_sine_hello.png)

### Placement

Three independent parameters control where along the curve the label sits;
`valign`, below, sets which line of the text rides it:

- `pos` sets where the label is anchored along the curve, as a fraction of the
  curve's length. `0.0` is the start of the curve, `1.0` is its end.
- `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 ring marks
the anchor point in each panel.

![A label at five positions along a curve](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/03_pos_sweep.png)

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.

A curve with NaN, infinite, or masked points has gaps, as `plot` draws it, and
so does one with points the axes cannot show: zero or below on a log axis, and
zero, one, or beyond on a logit axis, whatever their `nonpositive` setting.
`pos` then counts only the drawn length, and the label rides the
stretch that holds its anchor, continuing straight past that stretch's ends as
it does past the ends of a curve, over a gap if there is one; it never
continues onto another stretch. Lengths are measured on screen, so zooming can
move the anchor onto another stretch. To run a label along a curve with gaps,
or along scattered points, pass `curved_text` a smooth curve that follows
their overall shape, such as a fit or a running mean, and leave it unplotted:
the label never draws the curve it follows.

The figure below shows a label riding one stretch of three curves with gaps:
NaN points in (a), masked points in (b), and zeros on a log axis in (c).

![A label riding one stretch of curves with NaN, masked, and log-axis gaps](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/26_gaps.png)

A label is clipped to the axes, as lines are, so letters that run past the
edge of the axes are cut there. matplotlib's own `ax.text` is not clipped by
default. Pass `clip_on=False` to draw the whole label wherever its curve goes;
a tight bounding box (`bbox_inches="tight"`) and constrained layout then make
room for it.

### Dates, categories, and units

The curve takes the same data `plot` does: dates (`datetime64`, `datetime`,
pandas dates), category strings, and values with units. The axes convert them
as they convert the plotted line, so the label stays on it.

```python
days = np.arange("2024-01-01", "2025-01-01", dtype="datetime64[D]")
# Day length at 40° N, from the sun's declination on each day of the year.
n = np.arange(days.size)
declination = np.radians(23.44) * np.sin(2 * np.pi * (n - 80) / 365)
hours = 24 / np.pi * np.arccos(-np.tan(np.radians(40)) * np.tan(declination))

fig, ax = plt.subplots()
ax.plot(days, hours)
curved_text(ax, days, hours, "day length at 40° N", pos=0.3, offset=6.0)
```

A label created before anything is plotted sets up the axis for its data, as
`plot` does. Category strings not yet on an axis are added to it, as ticks.
Data an axis cannot convert, such as strings on a date axis, raises
matplotlib's `ConversionError` when the label is made.

### The function and the class

The object form is also available:

```python
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](https://matplotlib.org/stable/users/explain/text/mathtext.html)
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:

```python
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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/10_mathtext.png)

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:

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

![Plain words and math runs on one shared baseline along a curve](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/15_mixed_alignment.png)

### 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.

```python
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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/16_usetex.png)

Plain text is typeset literally: `%`, `#`, `&`, 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. Each run of plain text is typeset in one LaTeX pass and kerned as
TeX sets it, and each space is one interword space, without the extra space TeX
adds after a period. A run that holds a character TeX builds from several
pieces, such as an accented letter in the default encoding, or a character that
can print nothing, such as a soft hyphen, is typeset one character at a time
instead, without kerning. The first draw runs LaTeX once for each distinct
plain run and math run, as matplotlib does for each usetex string, and up to
twice more for the font's line heights; later draws are cached. A figure with
many short new labels therefore runs LaTeX many times on its first draw.

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:

```python
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.
- Set the LaTeX preamble and the font family and font lists before the figure
  is drawn. If they change after a draw, redrawing the same figure keeps the
  old measurements of math runs and of text typeset one character at a time,
  because matplotlib caches them per figure without the preamble, while plain
  runs follow the change, so one label can mix two fonts. matplotlib's own
  usetex text also keeps the old measurements. Create the figure again after
  changing them.
- 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:

```python
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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/11_box.png)

For a lighter casing that hugs each letter instead (a halo), pass a white
`withStroke` through matplotlib's
[`path_effects`](https://matplotlib.org/stable/users/explain/artists/patheffects_guide.html).
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:

```python
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 in (a), the single `box` fill in (b). A thin halo has its
own use: over thin lines and dense figures it hides the lines only where they
touch the letters, as the gallery's
[halo-or-box figure](https://github.com/thiebes/curved-text/tree/main/examples#a-halo-or-a-box)
shows.

![A wide per-character stroke leaves gaps; a box fill covers cleanly](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/12_box_vs_stroke.png)

### Even spacing on tight bends

Letters are placed one after another as ordinary text spaces them, by their
widths and kerning, 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:

```python
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 ordinary text sets it.

The figure below shows both regimes. In (a) and (b), a sharp bend, where the
correction visibly separates the letters. In (c) and (d), 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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/13_crowding.png)

### 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:

```python
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](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/14_valign.png)

### 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:

```python
import seaborn as sns

palette = {"NASDAQ Composite": "#003f7f", "S&P 500": "#b06306"}
ax = sns.lineplot(data=df, x="date", y="level", hue="series",
                  palette=palette, legend=False)
for name, line in df.groupby("series"):
    curved_text(ax, line["date"], line["level"], name, color=palette[name],
                pos=0.6, anchor="center", offset=8.0)
```

The gallery figure below labels two stock market indexes with the same calls,
in seaborn's `whitegrid` theme, with each label in place of a legend entry. It
also passes `curved_text` a smoothed copy of each line, which keeps the letters
of a label on monthly data from tilting into each other
([#61](https://github.com/thiebes/curved-text/issues/61)).

![Two stock market indexes drawn by seaborn, each labelled along its line](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/09_seaborn_pandas.png)

## 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.

## Related

[matplotlib-label-lines](https://github.com/cphyc/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](https://github.com/AllanCameron/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](https://thiebes.github.io/curved-text/). More
worked examples are in the
[example gallery](https://thiebes.github.io/curved-text/gallery.html).

## Contributing

Bug reports, feature requests, and pull requests are welcome. See
[CONTRIBUTING.md](https://github.com/thiebes/curved-text/blob/main/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](https://github.com/thiebes/curved-text/blob/main/CODE_OF_CONDUCT.md).

## 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
