GeezerVison is showing github.com — [Bug]: bbox computed incorrectly when using a superscript · Issue #21653 · matplotlib/matplotlib
[Bug]: bbox computed incorrectly when using a superscript #21653DescriptionBug 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

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 Activityanntzer commented on Nov 16, 2021 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 + posxkc9jud commented on Nov 17, 2021 @anntzer That patch almost fixes my original problem as well...
before:

after:

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)kc9jud commented on Nov 17, 2021 It's possible that the slight (1-2px) issue is actually the same as #14177 (comment) ("wiggly baseline") anntzer commented on Nov 17, 2021 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...) anntzer commented on Sep 5, 2025 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. QuLogic commented on Feb 6, 2026 @anntzer do you have plans to come back to this soon? anntzer commented on Feb 6, 2026 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. QuLogic commented on May 15, 2026 This looks like it's only off by a pixel at most now?
 tacaswell commented on May 15, 2026 I'm going to make the call that this is close enough (from current main):
 QuLogic commented on May 15, 2026 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:

MetadataMetadataStatusShow more project fields
DevelopmentNo branches or pull requestsIssue actions
|