"""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 functools
import math
import re
from typing import TYPE_CHECKING, Any, NamedTuple
import matplotlib.artist as martist
import matplotlib.colors as mcolors
import matplotlib.font_manager as font_manager
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.texmanager import TexManager
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")
_VALIGN = ("baseline", "center", "ascender", "descender")
_BOX_KEYS = ("color", "pad", "alpha")
_CROWDING = ("none", "curvature")
# Crowding slack and cap for the ``"curvature"`` mode, both as fractions of the
# concave-edge shortening relative to the line height. ``_CROWD_SLACK`` is a
# deadband: the sidebearing whitespace already built into adjacent glyphs
# absorbs this much curvature before their ink visibly collides, so no gap is
# added below it and a gentle bend is left untouched. ``_MAX_CROWD`` caps the
# correction so even a very tight bend adds at most a bounded gap.
_CROWD_SLACK = 0.2
_MAX_CROWD = 0.6
# 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
# Points sampled along the curve to draw the box casing as a polyline. Enough
# to look smooth across a label-width span at any realistic curvature.
_BOX_SAMPLES = 64
# Shared converter from text to glyph outlines; it caches font faces internally.
_text_to_path = TextToPath()
# TeX source for a plain character under usetex. A plain glyph is one literal
# character, so characters TeX reads as markup are escaped, and characters the
# default OT1 encoding typesets as a different glyph ("<" as an inverted "!")
# are spelled out by name.
_CHAR_TO_TEX = {
"#": r"\#", "$": r"\$", "%": r"\%", "&": r"\&", "_": r"\_",
"{": r"\{", "}": r"\}", "\\": r"\textbackslash{}",
"~": r"\textasciitilde{}", "^": r"\textasciicircum{}",
"<": r"\textless{}", ">": r"\textgreater{}", "|": r"\textbar{}",
}
# TeX source for any whitespace character under usetex: an interword space
# between two 1sp (1/65536 pt) rules. matplotlib's DVI reader sizes the output
# from the glyphs and rules TeX sets, and glue alone sets neither, so a bare
# space would advance zero. TeX itself reads a tab as a space.
_TEX_SPACE = r"\rule{1sp}{1sp}\ \rule{1sp}{1sp}"
# TeX source whose height and depth stand for the ascender and descender lines
# under usetex. Parentheses reach the ascender line, and "g" and "y" reach the
# descender line where the parentheses stop short of it, as in typewriter fonts
# and Times. TeX sizes the box from the font's own metrics, so the lines follow
# whichever font the preamble, family, and size select, at the scale LaTeX
# draws it.
_TEX_LINE_PROBE = "()gy"
class _Run(NamedTuple):
is_math: bool
text: str
class _FontLines(NamedTuple):
"""Ascender and descender heights above the baseline, in 1/100-em layout
units. The descender lies below the baseline, so it is negative."""
ascender: float
descender: float
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: bool | str | dict) -> 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. Unknown dict keys raise, so a typo surfaces instead of vanishing.
"""
if not box:
return None
config: dict = {"color": "white", "pad": 1.1, "alpha": None}
if isinstance(box, str):
config["color"] = box
elif isinstance(box, dict):
unknown = set(box) - set(_BOX_KEYS)
if unknown:
raise ValueError(
f"box keys must be among {_BOX_KEYS}, got {sorted(unknown)}")
config.update(box)
return config
def _font_lines(prop: font_manager.FontProperties, *,
usetex: bool) -> _FontLines:
"""The ascender and descender lines of the font the text is drawn in.
Without usetex that is the matplotlib font ``prop`` names, whose lines are
the ascender and descender FreeType reports. Under usetex it is the TeX font
LaTeX sets the text in (:func:`_tex_font_lines`).
"""
if usetex:
return _tex_font_lines(prop.get_size_in_points())
font = font_manager.get_font(font_manager.findfont(prop))
units = _text_to_path.FONT_SCALE / font.units_per_EM
return _FontLines(font.ascender * units, font.descender * units)
def _tex_font_lines(size: float) -> _FontLines:
"""The ascender and descender lines of the TeX font LaTeX sets text in at
``size`` points: the height and depth TeX gives ``_TEX_LINE_PROBE``.
LaTeX chooses that font from the preamble, the ``font.family`` rcParam,
and the size (cmss8 at 8 pt, cmss17 at 30 pt), and TeX takes the box from
the font's metrics at the size it draws the font, so a font the preamble
loads scaled (``helvet`` with ``scaled=0.92``) yields lines scaled with its
glyphs. The measurement is matplotlib's own for usetex text, and after the
first draw it reads LaTeX's cached DVI file. matplotlib reports the height
including the depth, in points, so scaling by ``FONT_SCALE / size`` gives
the 1/100-em layout units the usetex outlines are brought to
(:func:`_layout_units`).
"""
_, height, depth = TexManager().get_text_width_height_descent(
_TEX_LINE_PROBE, size)
units = _text_to_path.FONT_SCALE / size
return _FontLines((height - depth) * units, -depth * units)
def _valign_datum(valign: str, lines: _FontLines) -> float:
"""Height above the baseline, in 1/100-em layout units, that rides the curve
for the given vertical alignment.
``"baseline"`` returns 0, so the text baseline follows the curve unshifted.
The others shift every segment by the same font-metric constant -- the curve
passes through the vertical centre, the ascender line, or the descender line
-- so plain glyphs and mathtext stay aligned and no per-glyph step is
introduced (the shift is identical for every glyph).
"""
if valign == "baseline":
return 0.0
if valign == "ascender":
return lines.ascender
if valign == "descender":
return lines.descender
return (lines.ascender + lines.descender) / 2.0 # "center"
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 curvature(self, s, span):
"""Signed turning rate (radians per pixel) over ``[s, s + span]``.
The tangent is sampled as the chord across each half of the span --
``[s, s + span/2]`` and ``[s + span/2, s + span]`` -- whose midpoints lie
``span/2`` apart. The wrapped angle between those two chords, divided by
that ``span/2`` separation, estimates the curvature at the glyph's own
length scale, the same scale :meth:`chord_angles` smooths rotation over;
on a circle of radius ``R`` it recovers ``1/R``. Positive turns left. A
straight stretch (or a degenerate ``span``) yields zero, so a straight
guide is left untouched by any curvature-driven adjustment.
"""
half = np.asarray(span, dtype=float) / 2.0
a0 = self.chord_angles(s, half)
a1 = self.chord_angles(np.asarray(s) + half, half)
turn = np.arctan2(np.sin(a1 - a0), np.cos(a1 - a0))
return np.where(half > 0.0, turn / np.where(half > 0.0, half, 1.0), 0.0)
def offset(self, distance: float) -> _CurveFrame:
"""The parallel (offset) curve at perpendicular ``distance`` pixels.
Each vertex is displaced along the local normal -- the bisector of its
two adjacent segment tangents at interior vertices, the single segment
tangent at the ends -- so the returned frame is the curve the label
actually rides when offset. Walking glyphs along it (rather than
projecting them off the base curve) keeps both the perpendicular
clearance and the on-screen letter spacing uniform, because the cursor
advances along the very curve the glyphs sit on. Positive ``distance``
is to the left of the direction of travel; ``distance`` of zero returns
this frame unchanged, so an un-offset label and a straight guide stay
numerically identical.
"""
if distance == 0.0:
return self
tx, ty = np.cos(self._rads), np.sin(self._rads)
vtx = np.empty_like(self._xf)
vty = np.empty_like(self._yf)
vtx[0], vty[0] = tx[0], ty[0]
vtx[-1], vty[-1] = tx[-1], ty[-1]
# Interior vertices ride the bisector of the adjacent unit tangents; a
# near-zero sum means the curve doubles back on itself, so fall back to
# the incoming segment tangent there rather than divide by zero.
mx, my = tx[:-1] + tx[1:], ty[:-1] + ty[1:]
mlen = np.hypot(mx, my)
safe = mlen > 1e-12
denom = np.where(safe, mlen, 1.0)
vtx[1:-1] = np.where(safe, mx / denom, tx[:-1])
vty[1:-1] = np.where(safe, my / denom, ty[:-1])
return _CurveFrame(self._xf - distance * vty, self._yf + distance * vtx)
def remap_arc(self, other: _CurveFrame, s):
"""Map arc length ``s`` on this curve to the corresponding arc length on
``other``, a curve sharing this one's vertices (its :meth:`offset`).
The point at fraction ``f`` of this curve's segment ``i`` maps to
fraction ``f`` of ``other``'s segment ``i``. ``pos`` and ``anchor`` are
given against the base curve, so this carries the anchor over to the
offset curve the label is laid along -- the label lands where the user
asked on the original curve while keeping even spacing on the offset
one. On a straight guide the two curves have equal-length segments, so
the map is the identity.
"""
s = np.asarray(s, dtype=float)
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)
return other._arc[i] + f * (other._arc[i + 1] - other._arc[i])
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)
def _tex_source(char: str) -> str:
"""The literal TeX source for one plain character under usetex."""
if char.isspace():
return _TEX_SPACE
return _CHAR_TO_TEX.get(char, char)
@functools.cache
def _tex_to_path(size: float) -> TextToPath:
"""A converter that runs LaTeX at ``size`` points and lays the outline out
in 1/100-em layout units, as the shared converter does.
matplotlib measures a usetex advance by running LaTeX at the label's own
size, and TeX fonts change design with size (cmss8 at 8 pt, cmss12 at
12 pt). The shared converter runs LaTeX at its fixed 100 pt ``FONT_SCALE``,
which selects a different design from the one measured. Laying the outline
out at the label size draws the measured design, and matplotlib's
measurement and the outline share one cached LaTeX run.
The converter loads usetex glyphs with FreeType hinting at ``FONT_SCALE``
points and ``DPI`` dots per inch. At the default 72 dpi a 10 pt glyph is
hinted on a 10-pixel em, which snaps an x-height of 0.44 em to 0.50 em.
Raising ``DPI`` so the em spans at least 100 pixels makes the hinting
negligible. FreeType takes the DPI as a whole number while matplotlib places
the glyphs at the exact value, so ``DPI`` is rounded up to a whole number,
and :func:`_layout_units` absorbs the rest.
"""
converter = TextToPath()
converter.FONT_SCALE = size
converter.DPI = math.ceil(
_text_to_path.DPI * _text_to_path.FONT_SCALE / size)
return converter
def _layout_units(converter: TextToPath) -> float:
"""1/100-em layout units per unit of ``converter``'s outline.
A converter lays out ``FONT_SCALE * DPI / 72`` pixels per em. That is 100
for the shared converter, so the factor is 1 there, and within 1.4% of 1
for a :func:`_tex_to_path` converter at any size up to 72 pt.
"""
em_px = converter.FONT_SCALE * converter.DPI / _text_to_path.DPI
return _text_to_path.FONT_SCALE / em_px
class _OutlineSegment(mtext.Text):
"""A curved-label segment drawn by mapping a baseline-relative glyph outline
through the curve frame.
One segment is either a plain character (:class:`_PlainGlyph`) or a math run
(:class:`_MathRun`). Both subclass :class:`~matplotlib.text.Text` so the
parent's cursor walk measures every segment the same way -- by window-extent
width -- and, crucially, both keep the text baseline as the shared datum
(``v = 0``), so plain glyphs and math runs sit on one baseline by construction.
Placement maps an outline point ``(u, v)`` -- arc length from the segment's
left edge and height above the baseline, both in 1/100-em layout units -- into
display pixels. ``v`` is shifted by the vertical-alignment datum
(:func:`_valign_datum`), so the chosen line (baseline, centre, ascender, or
descender) is what rides the curve. Because each glyph is placed by an
isometry, the perpendicular distance from the curve equals ``v`` exactly --
there is no per-glyph step, and plain and math are identical by construction.
Subclasses differ only in where the outline comes from
(:meth:`_build_outline`) and whether it is placed rigidly or bent along the
curve (:attr:`_bend`). Any perpendicular offset is already baked into the
frame (it is the parallel curve), so a segment rides it with no offset
handling of its own.
"""
#: Whether the outline bends along the curve (math runs) or is placed rigidly
#: as a single undistorted glyph (plain characters).
_bend = False
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._width_px = 0.0
self._datum = 0.0
self._outline_cache: tuple | None = None
def _set_placement(self, frame: _CurveFrame, s_left: float,
width_px: float, datum: float) -> None:
"""Receive this draw's frame, the arc length of the segment's left edge on
it (already the parallel curve when offset), the segment's flat advance
width in pixels (the chord for a rigid glyph's rotation), and the
vertical-alignment datum (height in 1/100-em layout units that rides the
curve), computed once by the container so every segment shares it."""
self._frame = frame
self._s_left = float(s_left)
self._width_px = float(width_px)
self._datum = float(datum)
@martist.allow_rasterization
def draw(self, renderer, *args, **kwargs) -> None:
if not self.get_visible() or self._frame is None:
return
path = self._placed_path(renderer)
if path is None:
return
# Honor path effects (e.g. a white withStroke halo to clear the line
# behind the label); the effect strokes the placed 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("curved_segment", self.get_gid())
renderer.draw_path(gc, path, IdentityTransform(), face)
renderer.close_group("curved_segment")
finally:
gc.restore()
self.stale = False
def _placed_path(self, renderer) -> Path | None:
"""This segment's outline mapped onto the curve, in display pixels."""
# draw() guards against a missing frame before calling this.
assert self._frame is not None
verts, codes = self._outline_units()
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
u = verts[:, 0] * px_per_unit
v = (verts[:, 1] - self._datum) * px_per_unit
if self._bend:
# Bend each outline point through the frame: arc length advances with
# ``u`` and the normal follows the local em-scale chord, so radicals
# and fraction bars stay connected through curvature. Long straight
# runs are pre-subdivided by ``_densify`` so they follow the curve.
s = self._s_left + u
x, y, _ = self._frame.points_and_angles(s)
angle = self._frame.chord_angles(s - em_px / 2.0, em_px)
placed = np.column_stack([x - v * np.sin(angle),
y + v * np.cos(angle)])
else:
# Place the glyph rigidly: one rotation, by the chord across its own
# advance, about its centre on the curve. A single isometry preserves
# the glyph's shape (no distortion) while the baseline datum still
# lands on the curve, so spacing and rotation match the bent runs.
w = self._width_px
cx, cy, _ = self._frame.points_and_angles(self._s_left + w / 2.0)
angle = float(self._frame.chord_angles(self._s_left, w))
cos, sin = np.cos(angle), np.sin(angle)
u_centred = u - w / 2.0
placed = np.column_stack([cx + u_centred * cos - v * sin,
cy + u_centred * sin + v * cos])
return Path(placed, codes)
def _outline_units(self) -> tuple[np.ndarray, np.ndarray]:
"""Outline ``(vertices, codes)`` in 1/100-em layout units, baseline at
``v = 0`` and left edge at ``u = 0``, cached on the text, font, and
usetex setting it is built from."""
prop = self.get_fontproperties()
text = self.get_text()
usetex = self.get_usetex()
key = (text, hash(prop), usetex)
if self._outline_cache is None or self._outline_cache[0] != key:
self._outline_cache = (key, self._build_outline(prop, text, usetex))
return self._outline_cache[1]
def _build_outline(self, prop: font_manager.FontProperties, text: str,
usetex: bool) -> tuple[np.ndarray, np.ndarray]:
"""Build the outline that :meth:`_outline_units` caches. Implemented by
subclasses."""
raise NotImplementedError
class _PlainGlyph(_OutlineSegment):
"""One plain character, drawn as a rigid (undistorted) glyph outline whose
baseline rides the curve. Inherits ``_bend = False`` from the base.
Under usetex the glyph's text is the character's literal TeX source
(:func:`_tex_source`). It is set once at construction, which is also when
matplotlib fixes the artist's usetex setting, so matplotlib's measurement,
figure layout, and the outline all read the same string."""
def __init__(self, char: str, **kwargs: Any) -> None:
super().__init__(char, **kwargs)
self._char = char
if self.get_usetex():
self.set_text(_tex_source(char))
def _build_outline(self, prop: font_manager.FontProperties, text: str,
usetex: bool) -> tuple[np.ndarray, np.ndarray]:
# Whitespace advances the cursor but draws nothing. Test the character,
# not ``text``: under usetex a space's text is TeX source, not whitespace.
if not self._char.strip():
return np.empty((0, 2)), np.empty(0, dtype=Path.code_type)
if usetex:
converter = _tex_to_path(prop.get_size_in_points())
verts, codes = converter.get_text_path(prop, text, ismath="TeX")
else:
converter = _text_to_path
verts, codes = converter.get_text_path(prop, text, ismath=False)
verts = np.asarray(verts, float) * _layout_units(converter)
return verts, np.asarray(codes, dtype=Path.code_type)
class _MathRun(_OutlineSegment):
"""One ``$...$`` math run, laid out by mathtext or, under usetex, by LaTeX,
and drawn by bending the expression's glyph outlines and rule boxes through
the curve so radicals, fractions, and sized delimiters stay connected at any
curvature. Its baseline is the same shared datum as the plain glyphs, so the
run's main symbols sit level with neighbouring characters."""
_bend = True
def _build_outline(self, prop: font_manager.FontProperties, text: str,
usetex: bool) -> tuple[np.ndarray, np.ndarray]:
# Lay out with the artist's own usetex setting, the one matplotlib
# measures the advance with. Under usetex LaTeX runs at the label size
# (see ``_tex_to_path``), and either way the output is brought to
# 1/100-em layout units.
if usetex:
converter = _tex_to_path(prop.get_size_in_points())
glyph_info, glyph_map, rects = converter.get_glyphs_tex(prop, text)
else:
converter = _text_to_path
glyph_info, glyph_map, rects = converter.get_glyphs_mathtext(prop, text)
units = _layout_units(converter)
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]) * units
pieces.append(_densify(placed, np.asarray(outline_codes)))
for rect_verts, rect_codes in rects:
pieces.append(_densify(np.asarray(rect_verts, float) * units,
np.asarray(rect_codes)))
if not pieces:
return np.empty((0, 2)), np.empty(0, dtype=Path.code_type)
return (np.concatenate([p[0] for p in pieces]),
np.concatenate([p[1] for p in pieces]))
[docs]
class CurvedText(mtext.Text):
"""A string drawn along an (x, y) curve, one segment at a time (each plain
character and each ``$...$`` run).
Every glyph -- plain character or mathtext run -- is laid out on one shared
text baseline and mapped onto the curve from its glyph outline: a plain
character rigidly (one rotation by the chord across its own advance, so its
shape is undistorted), a mathtext run by bending its outlines so radicals and
fractions stay connected. ``valign`` chooses which line of the text rides the
curve -- the vertical centre by default. Because both kinds share the baseline,
plain and math sit level by construction, and because each glyph is placed by
an isometry there is no per-glyph drift. The rotation 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 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. The label is
laid along the parallel (offset) curve at that distance, so the clearance
from the curve is uniform along the whole label and the letter spacing
stays even, even where the curvature is steep or asymmetric. Positive is
to the left of the direction of travel, which is visually above a
left-to-right curve. ``pos`` and ``anchor`` stay measured against the
original curve and are carried perpendicularly onto the offset curve, so
an offset label sits directly off the spot the same ``pos`` marks on the
bare curve.
``valign``
Which line of the text rides the curve: ``"center"`` (the default -- the
text straddles the curve), ``"baseline"`` (the baseline follows the curve,
so the body sits above it with descenders below), ``"ascender"``, or
``"descender"``. The choice is a single font-metric shift applied
identically to every glyph and to the mathtext runs, so it never
introduces a per-glyph step and keeps plain and math aligned. Combine with
``offset`` to lift the chosen line off the 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.
The run rides the same baseline as the surrounding plain glyphs, so its main
symbols sit level with them. Pass ``parse_math=False`` to treat dollar signs
literally. Tall expressions compress vertically on the inside of tight bends,
so choose label size relative to curvature accordingly.
LaTeX is used instead when ``text.usetex`` is set or ``usetex=True`` is
passed, so the label matches the figure's other usetex text. Math runs are
then typeset by LaTeX, and so is plain text, one literal character at a time:
characters that are TeX markup (such as ``%``, ``#``, and the backslash) are
escaped, so TeX commands work only inside ``$...$``. Plain text is limited to
characters the LaTeX preamble can typeset; the README shows how to declare
upright Greek letters there. The ``valign`` ascender and descender lines are
the height and depth TeX gives ``()gy`` in the font it sets the text in. The
first draw runs LaTeX once for each distinct character and math run, and
once more to measure those lines, which every ``valign`` but ``"baseline"``
and the ``box`` casing use; this can take seconds, and later draws
reuse matplotlib's cache. The usetex setting is fixed when the label is
constructed, as matplotlib fixes it for each glyph; pass ``usetex`` or set
the rcParam before creating the label. Under usetex the font family comes
from the ``font.family`` rcParam, as for matplotlib's own usetex text, and
the ``fontfamily`` keyword has no effect. Math runs are passed to LaTeX as
written, so they can run TeX commands, including ones that read local
files; do not pass untrusted text with usetex on.
Both plain glyphs and mathtext runs are rendered from their glyph outlines
rather than as hinted ``Text`` artists. On a rotated label this is what lets a
single baseline be pinned exactly; the only cost is the loss of pixel-grid
hinting, which is marginal on rotated text and matches how mathtext has always
rendered. Under usetex matplotlib loads the glyphs with light hinting, laid
out at a resolution where it is negligible.
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. The label is laid along
the parallel (offset) curve at that distance.
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``. The band has rounded ends, so it extends about
half its height past the first and last glyph.
crowding : {"none", "curvature"}, default "none"
How to space glyphs around bends. ``"none"`` advances each glyph by its
own width, so on the concave side of a tight bend the rotated glyph
boxes can overlap. ``"curvature"`` opens an even letterspacing gap that
grows with the local curvature and the glyph height, so the inside edges
stop colliding; the gap is the same between every pair of letters, and a
deadband leaves gentle bends and straight runs unchanged.
valign : {"center", "baseline", "ascender", "descender"}, default "center"
Which line of the text rides the curve. ``"center"`` straddles the text on
the curve (the default); ``"baseline"`` follows the text baseline so the
body sits above the curve; the others ride the ascender or descender line.
Each is a constant font-metric shift applied to the whole label.
**kwargs
Passed to each per-character glyph and each math run (for example
``color``, ``fontsize``, ``alpha``, ``fontfamily``, ``usetex``).
"""
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, crowding: str = "none",
valign: str = "center", **kwargs: Any) -> None:
if anchor not in _ANCHORS:
raise ValueError(f"anchor must be one of {_ANCHORS}, got {anchor!r}")
if crowding not in _CROWDING:
raise ValueError(
f"crowding must be one of {_CROWDING}, got {crowding!r}")
if valign not in _VALIGN:
raise ValueError(f"valign must be one of {_VALIGN}, got {valign!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)
self._crowding = crowding
self._valign = valign
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 = 1.1
self._box: mlines.Line2D | None = None
if box_config is not None:
self._box_pad = box_config["pad"]
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[_OutlineSegment] = []
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:
glyph = _PlainGlyph(ch, **kwargs)
axes.add_artist(glyph)
self._segments.append(glyph)
# 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)
def _hide_box(self) -> None:
# The casing is an axes-owned artist drawn independently, so when ``draw``
# bails out before positioning it the band must be hidden explicitly or
# the previous draw's geometry stays painted.
if self._box is not None:
self._box.set_visible(False)
[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()
def _advances(self, frame: _CurveFrame, widths: list[float],
heights: list[float], flat_start: float) -> list[float]:
"""Arc-length advance for each segment along ``frame``.
In the default ``"none"`` mode the advance is the flat glyph width, so
the layout is unchanged. In ``"curvature"`` mode each advance is widened
where the curve bends, to keep the concave edges of adjacent rigid glyph
boxes from overlapping on the inside of the bend. A box of height ``h``
whose center rides a curve of local curvature ``kappa`` has its concave
edge, a distance ``h/2`` toward the center of curvature, lose roughly
``(h/2)*|kappa|`` of arc length per unit advance to its neighbor; the
``crowd`` factor below is that fraction, clamped at ``_MAX_CROWD``.
Only the crowding past ``_CROWD_SLACK`` is corrected: a gentle bend eats
into the sidebearing whitespace already between the letters without
their ink colliding, so the gap stays zero there and the layout barely
changes until the letters are genuinely crowded. The clearance is then
added as a width-independent letterspacing gap, ``(crowd - slack) * h``
(scaled by the line height, a typographic constant), not as a multiple
of the glyph's own width: multiplying by the width would give wide
glyphs a proportionally larger trailing gap, which reads as uneven
tracking on an arc, whereas a constant gap keeps the spacing even where
the curvature is uniform. The center sits in the middle of its widened
slot, so the gap is split evenly before and after each glyph. The
curvature is sampled along the un-widened layout starting at
``flat_start``.
"""
if self._crowding == "none":
return list(widths)
advances = []
cursor = flat_start
for w, h in zip(widths, heights):
kappa = float(frame.curvature(cursor, w))
crowd = min(abs(kappa) * h / 2.0, _MAX_CROWD)
advances.append(w + max(0.0, crowd - _CROWD_SLACK) * h)
cursor += w
return advances
[docs]
def draw(self, renderer, *args, **kwargs) -> None:
if not self._segments or self.axes is None:
self._hide_box()
return
axes = self.axes
# Work in display pixels: project the curve and build its arc-length
# frame, then shift that frame perpendicularly to the parallel (offset)
# curve the label actually rides. Laying glyphs along the offset curve
# -- rather than projecting them off the base curve -- keeps the
# clearance from the curve and the on-screen letter spacing uniform at
# once, because the cursor advances along the curve the glyphs sit on.
# ``pos``/``anchor`` stay defined against the base curve and are carried
# onto the offset curve below, so the label lands where the user asked.
pts = axes.transData.transform(np.column_stack([self._cx, self._cy]))
offset_px = self._offset * renderer.points_to_pixels(1.0)
base = _CurveFrame(pts[:, 0], pts[:, 1])
frame = base.offset(offset_px)
if not np.isfinite(frame.length) or frame.length <= 0.0:
# A degenerate curve has no span to position the casing on; hide it
# rather than leave the previous draw's band stranded on screen.
self._hide_box()
return
inv = axes.transData.inverted()
# The vertical-alignment datum is a font-metric constant, identical for
# every segment, so derive it once here and hand it to each segment
# rather than re-deriving it per glyph. Every alignment but the baseline
# is a font line, and so is the box casing's centre, which follows the
# "center" line while the frame follows the chosen one. Measuring the
# lines runs LaTeX under usetex, so a baseline label without a casing
# never measures them. The font and the usetex setting are read from a
# segment, the artist that is drawn, so a setter called on this
# container after construction cannot measure one font and draw another.
first_segment = self._segments[0]
prop = first_segment.get_fontproperties()
datum = band = 0.0
if self._valign != "baseline" or self._box is not None:
lines = _font_lines(prop, usetex=first_segment.get_usetex())
datum = _valign_datum(self._valign, lines)
band = _valign_datum("center", lines) - datum
# Measure each segment's unrotated advance width and height. Segments
# render their own outlines and never set a Text rotation, so the window
# extent is always the unrotated box.
extents = [t.get_window_extent(renderer=renderer)
for t in self._segments]
widths = [e.width for e in extents]
heights = [e.height for e in extents]
# Anchor at ``pos`` of the base curve, carried onto the offset curve, so
# the user's placement reads against the curve they passed in. ``lead``
# is the fraction of the label that sits before the anchor point.
s0 = float(base.remap_arc(frame, self._pos * base.length))
lead = {"start": 0.0, "center": 0.5, "end": 1.0}[self._anchor]
# Per-glyph advances along the offset curve. In ``"curvature"`` mode
# these are widened on bends (see ``_advances``); the curve is sampled
# along the un-widened layout, anchored the same way, which is accurate
# enough since the widening shifts positions only slightly. The label
# then spans ``total`` pixels from the re-anchored cursor.
flat_total = float(sum(widths))
advances = self._advances(frame, widths, heights, s0 - lead * flat_total)
total = float(sum(advances))
cursor = s0 - lead * total
# The casing follows the 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). The label
# rides the frame on its ``valign`` datum, so the glyph band is centred a
# little off the frame; shift the casing centreline by that band offset so
# it sits over the ink rather than over the bare datum line.
if self._box is not None:
ppu = (renderer.points_to_pixels(prop.get_size_in_points())
/ _text_to_path.FONT_SCALE)
band_px = band * ppu
s_box = np.linspace(cursor, cursor + total, _BOX_SAMPLES)
bx, by, bang = frame.points_and_angles(s_box)
bx = bx - band_px * np.sin(bang)
by = by + band_px * np.cos(bang)
box_xy = inv.transform(np.column_stack([bx, by]))
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 segment maps
# its baseline-relative outline onto the curve when it draws: a plain
# glyph rigidly (one rotation by the chord across its own advance, so it
# stays undistorted), a math run by bending its outlines. Centering each
# segment's flat width in its (possibly widened) slot lets crowding space
# plain glyphs and math runs alike, and the shared baseline datum keeps
# them level.
for t, w, adv in zip(self._segments, widths, advances):
t._set_placement(frame, cursor + (adv - w) / 2.0, w, datum)
t.set_visible(True)
cursor += adv
[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, crowding: str = "none",
valign: str = "center", **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``, ``box``, ``crowding``, and ``valign``. 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, crowding=crowding, valign=valign, **kwargs)