Skip to content

[Bug]: bbox computed incorrectly when using a superscript #21653

Closed

Description

@kc9jud

Bug summary

When including LaTeX with a superscript, the bbox is computed incorrectly.

Code for reproduction

import matplotlib.pyplot as plt

kw = dict(
    usetex=True,
    fontsize=40,
    bbox=dict(boxstyle='round,pad=0', fc='lightsteelblue', ec='none')
)

plt.axhline(y=0.7)
plt.annotate('A', xy=(0.3, 0.7), **kw)
plt.annotate(r'$A$', xy=(0.4, 0.7), **kw)
plt.annotate(r'$^{\tiny 1}A$', xy=(0.5, 0.7), **kw)
plt.annotate(r'$^{\tiny 1j}A$', xy=(0.65, 0.7), **kw)

plt.axhline(y=0.4)
plt.annotate('A', xy=(0.3, 0.4), va='bottom', **kw)
plt.annotate(r'$A$', xy=(0.4, 0.4), va='bottom', **kw)
plt.annotate(r'$^{1}A$', xy=(0.5, 0.4), va='bottom', **kw)
plt.annotate(r'$^{1j}A$', xy=(0.65, 0.4), va='bottom', **kw)

plt.show()

Actual outcome

Figure_1

Expected outcome

The bounding box should correctly surround the superscript.

Additional information

Related bugs: #7075 #14177

Operating system

Ubuntu

Matplotlib Version

3.5.0

Matplotlib Backend

No response

Python version

3.8.10

Jupyter version

No response

Installation

pip

Activity

  1. anntzer commented on Nov 16, 2021

    @anntzer
    Contributor

    As it turns out, the previous metrics handling was, let's say, not optimal. (The main problem was the use of glyph height (=ascent+descent) and glyph descent, rather than ascent and descent, and some adjustments to descent were not propagated back to the height.)
    The following patch seems to resolve the issue here, although many test baselines also get slightly shifted :( (likely related to the "wiggly baseline" problem in nonhinted text alluded to elsewhere).

    diff --git i/lib/matplotlib/text.py w/lib/matplotlib/text.py
    index b2b2195837..02f34ddf7e 100644
    --- i/lib/matplotlib/text.py
    +++ w/lib/matplotlib/text.py
    @@ -62,23 +62,23 @@ def _get_textbox(text, renderer):
         # called within the _get_textbox. So, it would better to move this
         # function as a method with some refactoring of _get_layout method.
     
    -    projected_xs = []
    -    projected_ys = []
    +    projected_xys = []
     
         theta = np.deg2rad(text.get_rotation())
         tr = Affine2D().rotate(-theta)
     
    -    _, parts, d = text._get_layout(renderer)
    +    _, parts, _ = text._get_layout(renderer)
     
    -    for t, wh, x, y in parts:
    -        w, h = wh
    -
    -        xt1, yt1 = tr.transform((x, y))
    -        yt1 -= d
    -        xt2, yt2 = xt1 + w, yt1 + h
    -
    -        projected_xs.extend([xt1, xt2])
    -        projected_ys.extend([yt1, yt2])
    +    for i, (t, wad, x, y) in enumerate(parts):
    +        w, a, d = wad
    +        xt, yt = tr.transform((x, y))
    +        projected_xys.extend([
    +            (xt, yt + a),
    +            (xt, yt - d),
    +            (xt + w, yt + a),
    +            (xt + w, yt - d),
    +        ])
    +    projected_xs, projected_ys = zip(*projected_xys)
     
         xt_box, yt_box = min(projected_xs), min(projected_ys)
         w_box, h_box = max(projected_xs) - xt_box, max(projected_ys) - yt_box
    @@ -300,8 +300,7 @@ class Text(Artist):
             thisx, thisy = 0.0, 0.0
             lines = self.get_text().split("\n")  # Ensures lines is not empty.
     
    -        ws = []
    -        hs = []
    +        wads = []  # widths, ascents above baseline, descents below baseline.
             xs = []
             ys = []
     
    @@ -309,7 +308,8 @@ class Text(Artist):
             _, lp_h, lp_d = renderer.get_text_width_height_descent(
                 "lp", self._fontproperties,
                 ismath="TeX" if self.get_usetex() else False)
    -        min_dy = (lp_h - lp_d) * self._linespacing
    +        lp_a = lp_h - lp_d  # ascent, i.e. height above-the-baseline.
    +        min_dy = lp_a * self._linespacing
     
             for i, line in enumerate(lines):
                 clean_line, ismath = self._preprocess_math(line)
    @@ -318,26 +318,25 @@ class Text(Artist):
                         clean_line, self._fontproperties, ismath=ismath)
                 else:
                     w = h = d = 0
    -
    -            # For multiline text, increase the line spacing when the text
    -            # net-height (excluding baseline) is larger than that of a "l"
    -            # (e.g., use of superscripts), which seems what TeX does.
    -            h = max(h, lp_h)
    +            a = h - d
    +            # Pretend that the ascent of all lines is at least as large as "l",
    +            # to ensure good linespacing.  This seems similar to what TeX does.
    +            a = max(a, lp_a)
    +            # Pretend that the descent of all lines is at least as large as
    +            # "p", to ensure good linespacing.
                 d = max(d, lp_d)
    +            # Ideally, a should not be adjusted on the first line and d should
    +            # not be adjusted on the last line (they don't participate in
    +            # linespacing), but this would break all baseline images.
     
    -            ws.append(w)
    -            hs.append(h)
    +            baseline = a - thisy  # Last line metrics; needed later.
     
    -            # Metrics of the last line that are needed later:
    -            baseline = (h - d) - thisy
    -
    -            if i == 0:
    -                # position at baseline
    -                thisy = -(h - d)
    -            else:
    -                # put baseline a good distance from bottom of previous line
    -                thisy -= max(min_dy, (h - d) * self._linespacing)
    +            if i == 0:  # position at baseline
    +                thisy = -a
    +            else:  # put baseline a good distance from bottom of previous line
    +                thisy -= max(min_dy, a * self._linespacing)
     
    +            wads.append((w, a, d))
                 xs.append(thisx)  # == 0.
                 ys.append(thisy)
     
    @@ -347,6 +346,7 @@ class Text(Artist):
             descent = d
     
             # Bounding box definition:
    +        ws = [w for w, a, d in wads]
             width = max(ws)
             xmin = 0
             xmax = width
    @@ -440,7 +440,7 @@ class Text(Artist):
             # now rotate the positions around the first (x, y) position
             xys = M.transform(offset_layout) - (offsetx, offsety)
     
    -        ret = bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent
    +        ret = bbox, list(zip(lines, wads, *xys.T)), descent
             self._cached[key] = ret
             return ret
     
    @@ -709,7 +709,7 @@ class Text(Artist):
     
                 angle = self.get_rotation()
     
    -            for line, wh, x, y in info:
    +            for line, wad, x, y in info:
     
                     mtext = self if len(info) == 1 else None
                     x = x + posx
  2. kc9jud commented on Nov 17, 2021

    @kc9jud
    Author

    @anntzer That patch almost fixes my original problem as well...

    before:
    test1

    after:
    test2

    You can see that the superscript still isn't quite within the bbox, but for my current purpose it's good enough.


    edit to add: Here's the code for generating that new test case:

    plt.axhline(y=0.05)
    plt.annotate(r"$^6\mathrm{He}$ $0^+$" " \n " r"$n 0s_{1/2}$", xy=(0.01, 0.05), va='bottom', **kw)
  3. kc9jud commented on Nov 17, 2021

    @kc9jud
    Author

    It's possible that the slight (1-2px) issue is actually the same as #14177 (comment) ("wiggly baseline")

  4. anntzer commented on Nov 17, 2021

    @anntzer
    Contributor

    Actually this is likely something else, because it occurs even if you set a huge dpi. My uneducated guess is that the tfm metrics (used when parsing the dpi file) are in fact "incorrect", because they are designed to get proper glyph alignment (see https://tex.stackexchange.com/questions/526103/why-does-cmsy10-tfm-give-the-minus-sign-a-positive-depth for a similar case), rather than to correctly give rasterization bounding boxes. In other words, the metrics file claims that the glyph is shorter than it really is.

    If that is correct (to be checked...), the solution would be to not use the tfm files in dviread, and directly load the Type1 fonts and use their metrics. (That would also require a decent bit of surgery...)

  5. added this to the v3.11.0 milestone on Sep 5, 2025
  6. anntzer commented on Sep 5, 2025

    @anntzer
    Contributor

    Milestoning as 3.11 to try to get something along the lines of #21653 (comment) into the text-overhaul branch, although I can't guarantee any timing to look into it.

  7. QuLogic commented on Feb 6, 2026

    @QuLogic
    Member

    @anntzer do you have plans to come back to this soon?

  8. anntzer commented on Feb 6, 2026

    @anntzer
    Contributor

    The patch I gave above still works and is still (I believe) technically better (because it doesn't confuse height and ascent), with the same limitations as noted previously (the metrics are still slightly off because they are tfm metrics rather than real ones). I guess we can at least get that in for now, and always improve on that by switching to real metrics.
    Made a PR for that.

  9. QuLogic commented on May 15, 2026

    @QuLogic
    Member

    This looks like it's only off by a pixel at most now?

    Image
  10. tacaswell commented on May 15, 2026

    @tacaswell
    Member

    I'm going to make the call that this is close enough (from current main):

    Image
  11. moved this from Waiting for other PR to Done in Font and text overhaulon May 15, 2026
  12. QuLogic commented on May 15, 2026

    @QuLogic
    Member

    And at very large zoom, we can see that the rectangle is actually antialiased a bit, so while it looks 2 pixels off, it's maybe only within 1 pixel:

    Image
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions