From abbfe881f941244c415b73e1294115a531b42534 Mon Sep 17 00:00:00 2001 From: Ahmed Mohamed <68402624+ahmed5145@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:53:41 -0500 Subject: [PATCH] ENH: Parse \limits and \nolimits in mathtext Adds support for the \limits and \nolimits commands, which force the sub- and superscripts of the preceding operator to be placed above/below it or to its side, respectively. Previously these commands raised a ParseFatalException (Unknown symbol). Closes #28051 --- .../next_whats_new/mathtext_limits.rst | 12 +++++++++++ galleries/users_explain/text/mathtext.py | 9 +++++++++ lib/matplotlib/_mathtext.py | 20 ++++++++++++++++--- lib/matplotlib/tests/test_mathtext.py | 17 ++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 doc/release/next_whats_new/mathtext_limits.rst diff --git a/doc/release/next_whats_new/mathtext_limits.rst b/doc/release/next_whats_new/mathtext_limits.rst new file mode 100644 index 000000000000..a9870959822d --- /dev/null +++ b/doc/release/next_whats_new/mathtext_limits.rst @@ -0,0 +1,12 @@ +``\limits`` and ``\nolimits`` in mathtext +----------------------------------------- +Mathtext now parses the ``\limits`` and ``\nolimits`` commands. They control +whether the sub- and superscripts of the preceding operator are placed directly +above and below it, or to its side: + +- ``\limits`` forces over/under placement, e.g. ``$\int\limits_a^b$`` renders + the bounds above and below the integral sign. +- ``\nolimits`` forces side placement, e.g. ``$\sum\nolimits_i^n$`` renders the + bounds to the right of the summation sign. + +Previously these commands raised a parse error. diff --git a/galleries/users_explain/text/mathtext.py b/galleries/users_explain/text/mathtext.py index 4a4f80c12695..81679872c5c2 100644 --- a/galleries/users_explain/text/mathtext.py +++ b/galleries/users_explain/text/mathtext.py @@ -101,6 +101,15 @@ # # \sum_{i=0}^\infty x_i # +# You can override the default placement with ``\limits`` (force under/over) and +# ``\nolimits`` (force to the side) directly after the operator:: +# +# r'$\int\limits_0^\infty x_i \quad \sum\nolimits_{i=0}^\infty x_i$' +# +# .. math:: +# +# \int\limits_0^\infty x_i \quad \sum\nolimits_{i=0}^\infty x_i +# # Fractions, binomials, and stacked numbers # ----------------------------------------- # Fractions, binomials, and stacked numbers can be created with the diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 319d6f065389..f8e1deb0c3d4 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2267,12 +2267,18 @@ def csnames(group: str, names: Iterable[str]) -> Regex: content=Group(OneOrMore(p.token)) + ZeroOrMore(Literal("\\\\").suppress()))("parts")) + # \limits and \nolimits force (resp. forbid) the sub/superscripts of the + # preceding operator to be placed above/below rather than to the side. + p.limits = Regex(r"\\(?:no)?limits(?![A-Za-z])")("limits") + p.subsuper = ( (Optional(p.placeable)("nucleus") + + Optional(p.limits) + OneOrMore(one_of(["_", "^"]) - p.placeable)("subsuper") + Regex("'*")("apostrophes")) | Regex("'+")("apostrophes") - | (p.named_placeable("nucleus") + Regex("'*")("apostrophes")) + | (p.named_placeable("nucleus") + + Optional(p.limits) + Regex("'*")("apostrophes")) ) p.simple = p.space | p.customspace | p.font | p.subsuper @@ -2627,6 +2633,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: nucleus = toks.get("nucleus", Hbox(0)) subsuper = toks.get("subsuper", []) napostrophes = len(toks.get("apostrophes", [])) + limits = toks.get("limits") if not subsuper and not napostrophes: return nucleus @@ -2662,8 +2669,15 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: super.kern() super.hpack() - # Handle over/under symbols, such as sum or prod - if self.is_overunder(nucleus): + # Handle over/under symbols, such as sum or prod. \limits and + # \nolimits override the default placement for the nucleus. + if limits == r"\limits": + overunder = True + elif limits == r"\nolimits": + overunder = False + else: + overunder = self.is_overunder(nucleus) + if overunder: vlist = [] shift = 0. width = nucleus.width diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 1e1bf793cdae..30cbcdd88e26 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -292,6 +292,23 @@ def test_short_long_accents(fig_test, fig_ref): 0, .5, "$" + "".join(fr"\{l} a" for l in corresponding_long_accs) + "$") +@check_figures_equal() +def test_limits(fig_test, fig_ref): + # \limits forces over/under placement, matching the default for \sum; + # \nolimits forces side placement, matching the default for \int. + fig_test.text(0.5, 0.5, r"$\sum\limits_a^b x \quad \int\nolimits_a^b x$") + fig_ref.text(0.5, 0.5, r"$\sum_a^b x \quad \int_a^b x$") + + +def test_limits_parse(): + # \limits and \nolimits should parse without error, including with no + # following sub/superscript (gh-28051). + parser = mathtext.MathTextParser("agg") + for expr in [r"$\int\limits_a^b$", r"$\sum\nolimits_i x$", + r"$\lim\limits_{x\to 0} f$", r"$\int\limits$"]: + parser.parse(expr) + + def test_fontinfo(): fontpath = mpl.font_manager.findfont("DejaVu Sans") font = mpl.ft2font.FT2Font(fontpath)