Source code for curved_text._core

"""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.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.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()


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: 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 _valign_datum(valign: str, prop: font_manager.FontProperties) -> 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
    font = font_manager.get_font(font_manager.findfont(prop))
    upm = font.units_per_EM
    # FreeType reports the ascender above the baseline (positive) and the
    # descender below it (negative); both keep their sign, so "center" is the
    # signed midpoint and "descender" returns a negative datum.
    ascender = font.ascender / upm * _text_to_path.FONT_SCALE
    descender = font.descender / upm * _text_to_path.FONT_SCALE
    if valign == "ascender":
        return ascender
    if valign == "descender":
        return descender
    return (ascender + 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)


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 mathtext
    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 mathtext 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:`_outline_units`) 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``. 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."""

    def _outline_units(self) -> tuple[np.ndarray, np.ndarray]:
        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]
        text = self.get_text()
        if not text.strip():  # whitespace advances the cursor but draws nothing
            outline = (np.empty((0, 2)), np.empty(0, dtype=Path.code_type))
        else:
            verts, codes = _text_to_path.get_text_path(prop, text, ismath=False)
            outline = (np.asarray(verts, float),
                       np.asarray(codes, dtype=Path.code_type))
        self._outline_cache = (key, outline)
        return outline


class _MathRun(_OutlineSegment):
    """One mathtext run, 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 _outline_units(self) -> tuple[np.ndarray, np.ndarray]:
        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)
        outline = (verts, codes)
        self._outline_cache = (key, outline)
        return outline


[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. ``text.usetex`` is not supported. Tall expressions compress vertically on the inside of tight bends, so choose label size relative to curvature accordingly. 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. 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 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, 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 (size # independent) and identical for every segment, so derive it once here # and hand it to each segment rather than re-deriving it per glyph. prop = self.get_fontproperties() datum = _valign_datum(self._valign, prop) # 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) # The glyph band's ink centre rides the "center" datum; the frame # rides the chosen ``valign`` datum, so shift the casing centreline by # the gap between the two so it covers the ink, not the bare datum. band_px = (_valign_datum("center", prop) - datum) * 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)