"""Draw text along an arbitrary curve in a matplotlib Axes."""
# Developed with AI assistance under maintainer review; see the
# "Development and AI use" section of the README.
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, NamedTuple
import matplotlib.artist as martist
import matplotlib.colors as mcolors
import matplotlib.lines as mlines
import matplotlib.text as mtext
import numpy as np
from matplotlib.patheffects import PathEffectRenderer
from matplotlib.path import Path
from matplotlib.textpath import TextToPath
from matplotlib.transforms import IdentityTransform
if TYPE_CHECKING:
from matplotlib.axes import Axes
from numpy.typing import ArrayLike
__all__ = ["CurvedText", "curved_text"]
_ANCHORS = ("start", "center", "end")
# Unescaped mathtext delimiter, mirroring matplotlib's own escape rule.
_MATH_DELIMITER = re.compile(r"(?<!\\)\$")
# Longest straight outline segment, in mathtext layout units (1/100 em), that
# the bend map will not subdivide. Short chords keep bent rule boxes (fraction
# bars, radical overlines) smooth at any curvature where text is readable.
_MAX_SEGMENT_UNITS = 5.0
# Shared converter from text to glyph outlines; it caches font faces internally.
_text_to_path = TextToPath()
class _Run(NamedTuple):
is_math: bool
text: str
def _split_runs(text: str) -> list[_Run]:
"""Split ``text`` into plain runs and ``$...$`` mathtext runs.
Mirrors matplotlib's parsing rules: a string with an odd number of
unescaped dollar signs is literal text, and ``\\$`` in plain text renders
as a dollar sign. Math runs keep their delimiters so they re-parse as
written; empty plain runs between adjacent math runs are dropped.
"""
delimiters = [m.start() for m in _MATH_DELIMITER.finditer(text)]
if len(delimiters) % 2:
delimiters = []
runs = []
cursor = 0
for opening, closing in zip(delimiters[::2], delimiters[1::2]):
if opening > cursor:
runs.append(_Run(False, text[cursor:opening].replace(r"\$", "$")))
runs.append(_Run(True, text[opening:closing + 1]))
cursor = closing + 1
if cursor < len(text) or not runs:
runs.append(_Run(False, text[cursor:].replace(r"\$", "$")))
return runs
def _box_config(box) -> dict | None:
"""Normalize the ``box`` argument to a settings dict, or None when off.
``box`` may be a bool, a color string, or a dict of ``color`` / ``pad`` /
``alpha`` overrides. ``pad`` scales the band height relative to the tallest
glyph.
"""
if not box:
return None
config = {"color": "white", "pad": 1.1, "alpha": None}
if isinstance(box, str):
config["color"] = box
elif isinstance(box, dict):
config.update(box)
return config
class _CurveFrame:
"""Display-space curve geometry: cumulative arc length with an elementwise
point-and-tangent lookup.
Arc lengths past either end of the curve clip into the terminal segments,
so lookups there extrapolate along the end tangents and an overrunning
label rides a straight extension instead of being clipped.
"""
def __init__(self, xf: np.ndarray, yf: np.ndarray) -> None:
self._xf = xf
self._yf = yf
self._arc = np.insert(
np.cumsum(np.hypot(np.diff(xf), np.diff(yf))), 0, 0.0)
self._rads = np.arctan2(np.diff(yf), np.diff(xf))
@property
def length(self) -> float:
return float(self._arc[-1])
def points_and_angles(self, s):
"""Map arc length ``s`` (scalar or array, pixels) to the position on
the curve and the local segment-tangent angle, elementwise."""
i = np.clip(np.searchsorted(self._arc, s) - 1, 0, len(self._arc) - 2)
d = self._arc[i + 1] - self._arc[i]
f = np.where(d != 0.0, (s - self._arc[i]) / np.where(d != 0.0, d, 1.0),
0.0)
x = self._xf[i] + f * (self._xf[i + 1] - self._xf[i])
y = self._yf[i] + f * (self._yf[i + 1] - self._yf[i])
return x, y, self._rads[i]
def chord_angles(self, s, span):
"""Angle of the chord across ``[s, s + span]``, elementwise.
This is the rotation a glyph of advance ``span`` takes: it follows the
local tangent but averages over the glyph's own width, so it stays
smooth across the vertices of a coarsely sampled polyline instead of
snapping to each segment's angle. Degenerate chords (zero ``span`` or a
zero-length stretch of curve) fall back to the segment tangent.
"""
xl, yl, rad = self.points_and_angles(s)
xr, yr, _ = self.points_and_angles(np.asarray(s) + span)
return np.where((xr == xl) & (yr == yl), rad,
np.arctan2(yr - yl, xr - xl))
def _densify(verts: np.ndarray, codes: np.ndarray,
max_du: float = _MAX_SEGMENT_UNITS) -> tuple[np.ndarray, np.ndarray]:
"""Subdivide straight LINETO segments longer than ``max_du`` along x.
The bend map displaces vertices but keeps segments straight between them,
so a long horizontal segment (a fraction bar, a radical overline) would
cut a chord across the curve. Bezier control points pass through: font
outline segments are short, and mapping their control points directly is
the standard path-bending approximation.
"""
out_verts: list[np.ndarray] = []
out_codes: list[int] = []
prev = None
for vert, code in zip(verts, codes):
if code == Path.LINETO and prev is not None:
n_extra = int(abs(vert[0] - prev[0]) // max_du)
if n_extra:
fractions = np.linspace(0.0, 1.0, n_extra + 2)[1:, None]
out_verts.extend(prev + (vert - prev) * fractions)
out_codes.extend([int(Path.LINETO)] * (n_extra + 1))
prev = vert
continue
out_verts.append(vert)
out_codes.append(code)
if code != Path.CLOSEPOLY:
prev = vert
return np.asarray(out_verts), np.asarray(out_codes, dtype=Path.code_type)
class _MathRun(mtext.Text):
"""One mathtext run of a curved label, drawn by bending the expression's
glyph outlines through the curve frame.
Inheriting :class:`~matplotlib.text.Text` keeps keyword handling and
measurement identical to the sibling per-character artists: the parent
advances its cursor by window-extent width for every segment alike. Only
rendering differs. At draw time the mathtext layout is mapped through
``(u, v) -> curve(u) + (v - datum) * normal(u)``, where ``u`` is arc
length from the run's left edge and ``v`` is height above the baseline,
with the datum at the layout box center so the run rides the curve exactly
where ``va="center"`` would put it. Normals follow em-scale chords,
matching the chord rotation of plain characters across coarse polyline
vertices.
"""
def __init__(self, text: str, **kwargs: Any) -> None:
super().__init__(0.0, 0.0, text, **kwargs)
self._frame: _CurveFrame | None = None
self._s_left = 0.0
self._offset_px = (0.0, 0.0)
self._outline_cache: tuple | None = None
def _set_frame(self, frame: _CurveFrame, s_left: float,
offset_px: tuple[float, float]) -> None:
"""Receive this draw's frame: the run starts at arc length ``s_left``
and shifts by ``offset_px`` along the label's chord normal."""
self._frame = frame
self._s_left = float(s_left)
self._offset_px = offset_px
@martist.allow_rasterization
def draw(self, renderer, *args, **kwargs) -> None:
if not self.get_visible() or self._frame is None:
return
path = self._bent_path(renderer)
if path is None:
return
# Honor path effects (e.g. a white withStroke halo to clear the line
# behind the label) the same way the per-character Text glyphs do; the
# effect strokes the bent outline, so the clearing follows the curve.
path_effects = self.get_path_effects()
if path_effects:
renderer = PathEffectRenderer(path_effects, renderer)
gc = renderer.new_gc()
try:
if self.get_clip_on():
gc.set_clip_rectangle(self.get_clip_box())
gc.set_clip_path(self.get_clip_path())
gc.set_linewidth(0.0)
gc.set_url(self.get_url())
face = mcolors.to_rgba(self.get_color(), self.get_alpha())
renderer.open_group("mathrun", self.get_gid())
renderer.draw_path(gc, path, IdentityTransform(), face)
renderer.close_group("mathrun")
finally:
gc.restore()
self.stale = False
def _bent_path(self, renderer) -> Path | None:
"""The expression's outline bent along the frame, in display pixels."""
# draw() guards against a missing frame before calling this, so the
# frame is set here by contract.
assert self._frame is not None
verts, codes, datum = self._expression_outline()
if len(verts) == 0:
return None
em_px = renderer.points_to_pixels(self.get_fontsize())
px_per_unit = em_px / _text_to_path.FONT_SCALE
s = self._s_left + verts[:, 0] * px_per_unit
v = (verts[:, 1] - datum) * px_per_unit
x, y, _ = self._frame.points_and_angles(s)
angle = self._frame.chord_angles(s - em_px / 2.0, em_px)
ox, oy = self._offset_px
bent = np.column_stack([x - v * np.sin(angle) + ox,
y + v * np.cos(angle) + oy])
return Path(bent, codes)
def _expression_outline(self) -> tuple[np.ndarray, np.ndarray, float]:
"""Glyph outlines and rule boxes of the laid-out expression as one
(vertices, codes) pair in layout units (1/100 em), plus the vertical
datum above the baseline. Cached until text or font changes."""
prop = self.get_fontproperties()
key = (self.get_text(), hash(prop))
if self._outline_cache is not None and self._outline_cache[0] == key:
return self._outline_cache[1]
glyph_info, glyph_map, rects = _text_to_path.get_glyphs_mathtext(
prop, self.get_text())
pieces = []
for glyph_id, x_pen, y_pen, scale in glyph_info:
outline_verts, outline_codes = glyph_map[glyph_id]
if len(outline_verts) == 0: # whitespace glyphs have no outline
continue
placed = np.asarray(outline_verts, float) * scale + [x_pen, y_pen]
pieces.append(_densify(placed, np.asarray(outline_codes)))
for rect_verts, rect_codes in rects:
pieces.append(_densify(np.asarray(rect_verts, float),
np.asarray(rect_codes)))
if pieces:
verts = np.concatenate([p[0] for p in pieces])
codes = np.concatenate([p[1] for p in pieces])
else:
verts = np.empty((0, 2))
codes = np.empty(0, dtype=Path.code_type)
# Ride the curve on the surrounding text's x-height line, so the main
# symbols sit level with neighbouring plain characters. Centering on the
# run's own box instead would let a superscript or tall delimiter inflate
# the box and drop the body below the rest of the label.
size = prop.get_size_in_points()
_, x_height, _ = _text_to_path.get_text_width_height_descent(
"x", prop, ismath=False)
datum = (x_height / 2.0) * _text_to_path.FONT_SCALE / size
self._outline_cache = (key, (verts, codes, datum))
return verts, codes, datum
[docs]
class CurvedText(mtext.Text):
"""A string drawn along an (x, y) curve, one character at a time.
Each character is an independent :class:`matplotlib.text.Text`, centered
(``ha="center"``, ``va="center"``) on its own arc-length midpoint, placed in
display coordinates and rotated to the chord across its own advance -- the
direction from where the glyph starts on the curve to where it ends. The
chord follows the local tangent but averages over the glyph's own width, so
rotation stays smooth across the vertices of a coarsely sampled polyline
instead of snapping to each segment's angle. The layout
is recomputed on every draw, so the label keeps following the curve through
figure layout, resizing, and interactive panning or zooming.
Placement has three independent controls:
``pos``
Where the label is anchored along the curve, as a fraction of the curve's
arc length: ``0.0`` is the first point, ``1.0`` is the last.
``anchor``
Which part of the label lands at ``pos``: ``"start"``, ``"center"``, or
``"end"``.
``offset``
A perpendicular shift off the curve, in typographic points, along the
normal of the label's chord (the line from its first to its last glyph).
Positive is to the left of the direction of travel, which is visually
above a left-to-right curve.
A label that overruns either end of the curve -- because of ``pos`` and
``anchor`` -- is not clipped. The curve is extended along its end tangent and
the overrunning glyphs are placed on that straight extension.
Set ``box`` to draw a casing behind the label -- a band that follows the
curve at the label's height, drawn under the glyphs -- so the label stays
legible where it crosses the lines it labels. For a lighter, glyph-hugging
casing instead, pass a white ``withStroke`` through ``path_effects``; a wide
stroke there merges adjacent per-character glyphs, so ``box`` is the way to
get solid coverage under plain text.
Mathtext is supported: each ``$...$`` run in ``text`` is laid out by
matplotlib's mathtext engine and bent continuously along the curve --
every glyph outline and rule box is mapped through the curve's arc-length
frame, so radicals, fractions, and sized delimiters stay connected at any
curvature. Pass ``parse_math=False`` to treat dollar signs literally.
``text.usetex`` is not supported. Tall expressions compress vertically on
the inside of tight bends, so choose label size relative to curvature
accordingly.
Parameters
----------
x, y : array-like
The curve in data coordinates: 1-D, equal length, at least two points,
finite, and ordered along the curve.
text : str
The string to draw. May contain mathtext runs (``$...$``).
axes : matplotlib.axes.Axes
The axes to draw into.
pos : float, default 0.5
Arc-length fraction in ``[0, 1]`` for the anchor point.
anchor : {"start", "center", "end"}, default "center"
Which part of the label sits at ``pos``.
offset : float, default 0.0
Perpendicular offset off the curve, in points.
box : bool, str, or dict, default False
A casing drawn behind the label to clear the lines it crosses. ``True``
draws a white band; a color string sets its color; a dict accepts
``color``, ``pad`` (band height relative to the tallest glyph, default
``1.1``), and ``alpha``.
**kwargs
Passed to each per-character :class:`~matplotlib.text.Text` and each
mathtext run (for example ``color``, ``fontsize``, ``alpha``,
``fontfamily``).
"""
def __init__(self, x: ArrayLike, y: ArrayLike, text: str, axes: Axes, *,
pos: float = 0.5, anchor: str = "center", offset: float = 0.0,
box: bool | str | dict = False, **kwargs: Any) -> None:
if anchor not in _ANCHORS:
raise ValueError(f"anchor must be one of {_ANCHORS}, got {anchor!r}")
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
if x.ndim != 1 or x.shape != y.shape or x.size < 2:
raise ValueError("x and y must be 1-D arrays of equal length >= 2")
if not (np.isfinite(x).all() and np.isfinite(y).all()):
raise ValueError("x and y must contain only finite values")
super().__init__(float(x[0]), float(y[0]), " ", **kwargs)
self._cx = x
self._cy = y
self._pos = float(pos)
self._anchor = anchor
self._offset = float(offset)
axes.add_artist(self)
# Optional casing behind the label: a fat line following the curve at
# the label's height. Its geometry is set in ``draw`` (on the container),
# so it must draw after the container and before the glyphs; ``set_zorder``
# below places it between them.
box_config = _box_config(box)
self._box_pad = box_config["pad"] if box_config else 0.0
self._box: mlines.Line2D | None = None
if box_config is not None:
self._box = mlines.Line2D([], [], color=box_config["color"],
alpha=box_config["alpha"],
solid_capstyle="round",
solid_joinstyle="round")
axes.add_line(self._box)
self._segments: list[mtext.Text] = []
runs = (_split_runs(text) if self.get_parse_math()
else [_Run(False, text)])
for run in runs:
if run.is_math:
segment = _MathRun(run.text, **kwargs)
axes.add_artist(segment)
self._segments.append(segment)
continue
for ch in run.text:
t = mtext.Text(0.0, 0.0, " " if ch == " " else ch, **kwargs)
t.set_horizontalalignment("center")
t.set_verticalalignment("center")
axes.add_artist(t)
self._segments.append(t)
# Apply the layered zorders now that the casing and glyphs exist: the
# container draws first (it positions them), then the casing, then the
# glyphs on top.
self.set_zorder(self.get_zorder())
[docs]
def set_zorder(self, zorder) -> None:
# Glyphs sit one level above the container; the casing sits between, so
# it clears the data lines but stays under the glyphs. ``super().__init__``
# may set the zorder before these attributes exist, so guard against
# running during base-class construction.
super().set_zorder(zorder)
box = getattr(self, "_box", None)
if box is not None:
box.set_zorder(self.get_zorder() + 0.5)
for t in getattr(self, "_segments", ()):
t.set_zorder(self.get_zorder() + 1)
[docs]
def remove(self) -> None:
# The glyphs and casing are independent artists on the axes; remove them
# with the container so removal does not leave them behind as orphans.
for t in self._segments:
t.remove()
self._segments = []
if self._box is not None:
self._box.remove()
self._box = None
super().remove()
[docs]
def draw(self, renderer, *args, **kwargs) -> None:
if not self._segments:
return
axes = self.axes
if axes is None:
return
# Work in display pixels: project the curve and build its arc-length
# frame.
pts = axes.transData.transform(np.column_stack([self._cx, self._cy]))
frame = _CurveFrame(pts[:, 0], pts[:, 1])
if not np.isfinite(frame.length) or frame.length <= 0.0:
return
inv = axes.transData.inverted()
# Reset any rotation left by the previous draw before measuring, so each
# width is the unrotated advance, not the wider rotated bounding box.
for t in self._segments:
t.set_rotation(0)
extents = [t.get_window_extent(renderer=renderer)
for t in self._segments]
widths = [e.width for e in extents]
total = float(sum(widths))
s0 = self._pos * frame.length
if self._anchor == "center":
cursor = s0 - total / 2.0
elif self._anchor == "end":
cursor = s0 - total
else:
cursor = s0
# Offset the whole label along the normal of its chord (first to last
# glyph); the frame extrapolates past the curve ends along their tangents.
x0, y0, _ = frame.points_and_angles(cursor)
x1, y1, _ = frame.points_and_angles(cursor + total)
dx, dy = x1 - x0, y1 - y0
norm = float(np.hypot(dx, dy))
nx, ny = (-dy / norm, dx / norm) if norm else (0.0, 1.0)
scale = self._offset * renderer.points_to_pixels(1.0)
ox, oy = nx * scale, ny * scale
# The casing follows the offset curve across the label's whole span at
# the tallest glyph's height, so it clears the lines behind plain and
# math segments alike (a single fill, immune to the per-character
# cannibalization a wide ``path_effects`` stroke would cause).
if self._box is not None:
s_box = np.linspace(cursor, cursor + total, 64)
bx, by, _ = frame.points_and_angles(s_box)
box_xy = inv.transform(np.column_stack([bx + ox, by + oy]))
height = max(e.height for e in extents)
self._box.set_data(box_xy[:, 0], box_xy[:, 1])
self._box.set_linewidth(
self._box_pad * height / renderer.points_to_pixels(1.0))
self._box.set_visible(True)
# ``cursor`` walks the label's left edge along the arc. Each character
# is centered on its own midpoint and rotated to the chord across its
# own advance, which smooths the segment-wise tangent of a coarse
# polyline at exactly the glyph's own length scale; a math run instead
# receives the frame and bends its outlines through it when it draws.
for t, w in zip(self._segments, widths):
if isinstance(t, _MathRun):
t._set_frame(frame, cursor, (ox, oy))
else:
px, py, _ = frame.points_and_angles(cursor + w / 2.0)
rot = frame.chord_angles(cursor, w)
gx, gy = inv.transform((px + ox, py + oy))
t.set_position((float(gx), float(gy)))
t.set_rotation(np.degrees(rot))
t.set_visible(True)
cursor += w
[docs]
def curved_text(ax: Axes, x: ArrayLike, y: ArrayLike, text: str, *,
pos: float = 0.5, anchor: str = "center", offset: float = 0.0,
box: bool | str | dict = False, **kwargs: Any) -> CurvedText:
"""Draw ``text`` along the curve ``(x, y)`` on ``ax`` and return the artist.
Thin convenience wrapper around :class:`CurvedText`; see it for the meaning of
``pos``, ``anchor``, ``offset``, and ``box``. The axes is the first argument
here, matching matplotlib's axes-first helper functions, whereas
:class:`CurvedText` takes it after ``x, y, text`` to match
:class:`matplotlib.text.Text`.
"""
return CurvedText(x, y, text, ax, pos=pos, anchor=anchor, offset=offset,
box=box, **kwargs)