From 60f231068d7aeb96a58f83b4e1ad779ae36087bd Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 6 Feb 2026 13:45:57 +0100 Subject: [PATCH] Fix confusion between text height and ascent in metrics calculations. The text height includes both the ascender and the descender, but the logic in _get_layout is that multiline texts should be treated as having an ascent at least as large as "l" and a descent at least as large as "p" (not a height at least as large as "lp" and a descent at least as large as "p") to prevent lines from bumping into each other (see changes to test_text/test_multiline, where the topmost superscript was close to bumping into the "p" descender previously). --- lib/matplotlib/offsetbox.py | 13 +++---- lib/matplotlib/text.py | 72 ++++++++++++++++++------------------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 9b9c7a69f35f..dc11c4205f5d 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -799,23 +799,24 @@ def get_bbox(self, renderer): ismath="TeX" if self._text.get_usetex() else False, dpi=self.get_figure(root=True).dpi) - bbox, info, yd = self._text._get_layout(renderer) + bbox, info = self._text._get_layout(renderer) + _last_line, (_last_width, _last_ascent, last_descent), _last_xy = info[-1] w, h = bbox.size self._baseline_transform.clear() if len(info) > 1 and self._multilinebaseline: yd_new = 0.5 * h - 0.5 * (h_ - d_) - self._baseline_transform.translate(0, yd - yd_new) - yd = yd_new + self._baseline_transform.translate(0, last_descent - yd_new) + last_descent = yd_new else: # single line - h_d = max(h_ - d_, h - yd) - h = h_d + yd + h_d = max(h_ - d_, h - last_descent) + h = h_d + last_descent ha = self._text.get_horizontalalignment() x0 = {"left": 0, "center": -w / 2, "right": -w}[ha] - return Bbox.from_bounds(x0, -yd, w, h) + return Bbox.from_bounds(x0, -last_descent, w, h) def draw(self, renderer): # docstring inherited diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 51eb8fa8cd15..6238401f83dd 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -37,23 +37,22 @@ def _get_textbox(text, renderer): # called within the _get_textbox. So, it would be 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 t, (w, a, d), xy in parts: + xt, yt = tr.transform(xy) + 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 @@ -434,15 +433,18 @@ def update_from(self, other): def _get_layout(self, renderer): """ - Return the extent (bbox) of the text together with - multiple-alignment information. Note that it returns an extent - of a rotated text when necessary. + Return + + - the (rotated) text bbox, and + - a list of ``(line, (width, ascent, descent), xy)`` tuples for each line. """ thisx, thisy = 0.0, 0.0 lines = self._get_wrapped_text().split("\n") # Ensures lines is not empty. - ws = [] - hs = [] + # Reminder: The ascent (a) goes from the baseline to the top and the + # descent (d) from the baseline to the bottom; both are (typically) + # nonnegative. The height h is the sum, h = a + d. + wads = [] # (width, ascents, descents) xs = [] ys = [] @@ -451,7 +453,8 @@ def _get_layout(self, renderer): renderer, "lp", self._fontproperties, ismath="TeX" if self.get_usetex() else False, dpi=self.get_figure(root=True).dpi) - min_dy = (lp_h - lp_d) * self._linespacing + lp_a = lp_h - lp_d + min_dy = lp_a * self._linespacing for i, line in enumerate(lines): clean_line, ismath = self._preprocess_math(line) @@ -462,25 +465,21 @@ def _get_layout(self, renderer): 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 + # To ensure good linespacing, pretend that the ascent (resp. + # descent) of all lines is at least as large as "l" (resp. "p"). + a = max(a, lp_a) d = max(d, lp_d) - ws.append(w) - hs.append(h) - # Metrics of the last line that are needed later: - baseline = (h - d) - thisy + baseline = a - 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) @@ -490,6 +489,7 @@ def _get_layout(self, renderer): descent = d # Bounding box definition: + ws = [w for w, a, d in wads] width = max(ws) xmin = 0 xmax = width @@ -587,7 +587,7 @@ def _get_layout(self, renderer): # now rotate the positions around the first (x, y) position xys = M.transform(offset_layout) - (offsetx, offsety) - return bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent + return bbox, list(zip(lines, wads, xys)) def set_bbox(self, rectprops): """ @@ -840,7 +840,7 @@ def draw(self, renderer): renderer.open_group('text', self.get_gid()) with self._cm_set(text=self._get_wrapped_text()): - bbox, info, descent = self._get_layout(renderer) + bbox, info = self._get_layout(renderer) trans = self.get_transform() # don't use self.get_position here, which refers to text @@ -876,7 +876,7 @@ def draw(self, renderer): 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 @@ -1064,7 +1064,7 @@ def get_window_extent(self, renderer=None, dpi=None): "want to call 'figure.draw_without_rendering()' first.") with cbook._setattr_cm(fig, dpi=dpi): - bbox, info, descent = self._get_layout(self._renderer) + bbox, _ = self._get_layout(self._renderer) x, y = self.get_unitless_position() x, y = self.get_transform().transform((x, y)) bbox = bbox.translated(x, y)