From 3ab6a275b2724d798b597571512b5cc9a8f7690a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 10 Mar 2026 21:42:58 -0400 Subject: [PATCH 1/3] mathtext: Fetch x-height from font metrics This is minimally different from the `x` measurement, but technically more correct. We still do the measurement for fonts we don't ship, but that may change with Unicode Math fonts in the future. --- lib/matplotlib/_mathtext.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 3a60a31eb58d..a53595261fcf 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -445,11 +445,15 @@ def get_quad(self, fontname: str, fontsize: float, dpi: float) -> float: return metrics.advance def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float: - # Some fonts report the wrong x-height, while some don't store it, so - # we do a poor man's x-height. - metrics = self.get_metrics( - fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi) - return metrics.iceberg + consts = self.get_font_constants() + if consts.x_height is not None: + return consts.x_height * fontsize * dpi / 72 + else: + # Some fonts report the wrong x-height, while some don't store it, so + # we do a poor man's x-height. + metrics = self.get_metrics( + fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi) + return metrics.iceberg def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float: # This function used to grab underline thickness from the font @@ -1006,6 +1010,10 @@ class FontConstantsBase: # The size of a quad space in LaTeX, as a multiple of design size. quad: T.ClassVar[float | None] = None + # The size of x-height in font design units (i.e., divided by units-per-em). If not + # provided, then this will be measured from the font itself. + x_height: T.ClassVar[float | None] = None + class ComputerModernFontConstants(FontConstantsBase): # Previously, the x-height of Computer Modern was obtained from the font @@ -1034,6 +1042,7 @@ class ComputerModernFontConstants(FontConstantsBase): # size. axis_height = 262144 / 2**20 quad = 1048579 / 2**20 + x_height = _x_height / 2**20 class STIXFontConstants(FontConstantsBase): @@ -1041,10 +1050,11 @@ class STIXFontConstants(FontConstantsBase): delta = 0.05 delta_slanted = 0.3 delta_integral = 0.3 + _x_height = 450 + x_height = _x_height / 1000 # These values are extracted from the TeX table of STIXGeneral.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. - _x_height = 450 supdrop = 386 / _x_height subdrop = 50.0002 / _x_height sup1 = 413 / _x_height @@ -1068,10 +1078,11 @@ class STIXSansFontConstants(STIXFontConstants): class DejaVuSerifFontConstants(FontConstantsBase): + _x_height = 1063 + x_height = _x_height / 2048 # These values are extracted from the TeX table of DejaVuSerif.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. - _x_height = 1063 supdrop = 790.527 / _x_height subdrop = 102.4 / _x_height sup1 = 845.824 / _x_height @@ -1088,10 +1099,11 @@ class DejaVuSerifFontConstants(FontConstantsBase): class DejaVuSansFontConstants(FontConstantsBase): + _x_height = 1120 + x_height = _x_height / 2048 # These values are extracted from the TeX table of DejaVuSans.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. - _x_height = 1120 supdrop = 790.527 / _x_height subdrop = 102.4 / _x_height sup1 = 845.824 / _x_height From d961462910d4bc45535708affb7b2e6778f814d2 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 12 Mar 2026 14:01:08 -0400 Subject: [PATCH 2/3] text: Set line spacing to 'normal' by default This follows from CSS' default for line height. At the moment, the behaviour has not been changed, and still just falls back to 1.2 for 'normal'. --- lib/matplotlib/text.py | 25 +++++++++++++++++-------- lib/matplotlib/text.pyi | 5 +++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index f0dd963fe477..a9da4cd59108 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -241,7 +241,7 @@ def _reset_visual_defaults( self._bbox_patch = None # a FancyBboxPatch instance self._renderer = None if linespacing is None: - linespacing = 1.2 # Maybe use rcParam later. + linespacing = 'normal' # Maybe use rcParam later. self.set_linespacing(linespacing) self.set_rotation_mode(rotation_mode) self.set_antialiased(mpl._val_or_rc(antialiased, 'text.antialiased')) @@ -439,7 +439,8 @@ def _get_layout(self, renderer): ismath="TeX" if self.get_usetex() else False, dpi=self.get_figure(root=True).dpi) lp_a = lp_h - lp_d - min_dy = lp_a * self._linespacing + linespacing = 1.2 if self._linespacing == 'normal' else self._linespacing + min_dy = lp_a * linespacing for i, line in enumerate(lines): clean_line, ismath = self._preprocess_math(line) @@ -462,7 +463,7 @@ def _get_layout(self, renderer): 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) + thisy -= max(min_dy, a * linespacing) wads.append((w, a, d)) xs.append(thisx) # == 0. @@ -1122,18 +1123,26 @@ def set_multialignment(self, align): def set_linespacing(self, spacing): """ - Set the line spacing as a multiple of the font size. - - The default line spacing is 1.2. + Set the line spacing. Parameters ---------- - spacing : float (multiple of font size) + spacing : 'normal' or float, default: 'normal' + If 'normal', then the line spacing is automatically determined by font + metrics for each line individually. + + If a float, then line spacing will be fixed to this multiple of the font + size for every line. """ - _api.check_isinstance(Real, spacing=spacing) + if not cbook._str_equal(spacing, 'normal'): + _api.check_isinstance(Real, spacing=spacing) self._linespacing = spacing self.stale = True + def get_linespacing(self): + """Get the line spacing.""" + return self._linespacing + def set_fontfamily(self, fontname): """ Set the font family. Can be either a single string, or a list of diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index e89a03396d7e..15811462224a 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -34,7 +34,7 @@ class Text(Artist): multialignment: Literal["left", "center", "right"] | None = ..., fontproperties: str | Path | FontProperties | None = ..., rotation: float | Literal["vertical", "horizontal"] | None = ..., - linespacing: float | None = ..., + linespacing: Literal["normal"] | float | None = ..., rotation_mode: Literal["default", "anchor"] | None = ..., usetex: bool | None = ..., wrap: bool = ..., @@ -79,7 +79,8 @@ class Text(Artist): self, align: Literal["left", "center", "right"] ) -> None: ... def set_multialignment(self, align: Literal["left", "center", "right"]) -> None: ... - def set_linespacing(self, spacing: float) -> None: ... + def set_linespacing(self, spacing: Literal["normal"] | float) -> None: ... + def get_linespacing(self) -> Literal["normal"] | float: ... def set_fontfamily(self, fontname: str | Iterable[str]) -> None: ... def set_fontfeatures(self, features: Sequence[str] | None) -> None: ... def set_fontvariant(self, variant: Literal["normal", "small-caps"]) -> None: ... From 97f4943cf279c82a4dc64e40ec52dcb1bcd218fe Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 12 Mar 2026 19:57:29 -0400 Subject: [PATCH 3/3] text: Fetch line height metrics from the used font itself We follow the process from [the CSS Inline Layout module](https://www.w3.org/TR/css-inline-3/), specifically: 1. The default ascent and descent come from the `OS/2` font table, or failing that, the `hhea` table, with final fallback to the measurement we used to do. 2. If `linespacing` (cf line height in CSS) is normal, then we do as before and size each line based on the maximum ascent/descent of its contents. Additionally, apply the line gap from the font metrics as half-leading around each line. 3. If `linespacing` is a float, then scale it by font size of the first available font, and keep it fixed for each line. However, if we are drawing a single line, then we do not add the line gap around the line, to keep them a similar height as before. --- lib/matplotlib/testing/conftest.py | 11 +++++ lib/matplotlib/tests/test_axes.py | 8 ++-- lib/matplotlib/tests/test_legend.py | 32 +++++++------- lib/matplotlib/tests/test_polar.py | 2 +- lib/matplotlib/tests/test_text.py | 16 +++++-- lib/matplotlib/text.py | 68 +++++++++++++++++++++-------- 6 files changed, 93 insertions(+), 44 deletions(-) diff --git a/lib/matplotlib/testing/conftest.py b/lib/matplotlib/testing/conftest.py index 6f87d9826cc3..c60a38254aad 100644 --- a/lib/matplotlib/testing/conftest.py +++ b/lib/matplotlib/testing/conftest.py @@ -149,6 +149,15 @@ def text_placeholders(monkeypatch): """ from matplotlib.patches import Rectangle + def patched_get_sfnt_table(font, name): + """ + Replace ``FT2Font.get_sfnt_table`` with empty results. + + This forces ``Text._get_layout`` to fall back to + ``get_text_width_height_descent``, which produces results from the patch below. + """ + return None + def patched_get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): """ Replace ``_get_text_metrics_with_cache`` with fixed results. @@ -183,6 +192,8 @@ def patched_text_draw(self, renderer): facecolor=self.get_color(), edgecolor='none') rect.draw(renderer) + monkeypatch.setattr('matplotlib.ft2font.FT2Font.get_sfnt_table', + patched_get_sfnt_table) monkeypatch.setattr('matplotlib.text._get_text_metrics_with_cache', patched_get_text_metrics_with_cache) monkeypatch.setattr('matplotlib.text.Text.draw', patched_text_draw) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index deb74d4e7341..b18857d2f103 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -7753,7 +7753,7 @@ def test_titletwiny(): bbox_y0_title = title.get_window_extent(renderer).y0 # bottom of title bbox_y1_xlabel2 = xlabel2.get_window_extent(renderer).y1 # top of xlabel2 y_diff = bbox_y0_title - bbox_y1_xlabel2 - assert np.isclose(y_diff, 3) + assert y_diff >= 3 def test_titlesetpos(): @@ -8525,8 +8525,8 @@ def test_normal_axes(): # test the axis bboxes target = [ - [124.0, 76.89, 982.0, 32.0], - [86.89, 100.5, 52.0, 992.0], + [124.0, 75.56, 982.0, 33.33], + [86.89, 99.33, 52.0, 993.33], ] for nn, b in enumerate(bbaxis): targetbb = mtransforms.Bbox.from_bounds(*target[nn]) @@ -8546,7 +8546,7 @@ def test_normal_axes(): targetbb = mtransforms.Bbox.from_bounds(*target) assert_array_almost_equal(bbax.bounds, targetbb.bounds, decimal=2) - target = [86.89, 76.89, 1019.11, 1015.61] + target = [86.89, 75.56, 1019.11, 1017.11] targetbb = mtransforms.Bbox.from_bounds(*target) assert_array_almost_equal(bbtb.bounds, targetbb.bounds, decimal=2) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index ae14ef6cb423..5112aba843db 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -481,27 +481,27 @@ def test_figure_legend_outside(): todos += ['left ' + pos for pos in ['lower', 'center', 'upper']] todos += ['right ' + pos for pos in ['lower', 'center', 'upper']] - upperext = [20.722556, 26.722556, 790.333, 545.999] - lowerext = [20.722556, 70.056556, 790.333, 589.333] - leftext = [152.056556, 26.722556, 790.333, 589.333] - rightext = [20.722556, 26.722556, 658.999, 589.333] + upperext = [20.722556, 26.389222, 790.333, 545.16762] + lowerext = [20.722556, 70.723222, 790.333, 589.50162] + leftext = [152.056556, 26.389222, 790.333, 589.50162] + rightext = [20.722556, 26.389222, 658.999, 589.50162] axbb = [upperext, upperext, upperext, lowerext, lowerext, lowerext, leftext, leftext, leftext, rightext, rightext, rightext] - legbb = [[10., 555., 133., 590.], # upper left - [338.5, 555., 461.5, 590.], # upper center - [667, 555., 790., 590.], # upper right - [10., 10., 133., 45.], # lower left - [338.5, 10., 461.5, 45.], # lower center - [667., 10., 790., 45.], # lower right - [10., 10., 133., 45.], # left lower - [10., 282.5, 133., 317.5], # left center - [10., 555., 133., 590.], # left upper - [667, 10., 790., 45.], # right lower - [667., 282.5, 790., 317.5], # right center - [667., 555., 790., 590.]] # right upper + legbb = [[10., 554., 133., 590.], # upper left + [338.5, 554., 461.5, 590.], # upper center + [667, 554., 790., 590.], # upper right + [10., 10., 133., 46.], # lower left + [338.5, 10., 461.5, 46.], # lower center + [667., 10., 790., 46.], # lower right + [10., 10., 133., 46.], # left lower + [10., 282., 133., 318.], # left center + [10., 554., 133., 590.], # left upper + [667, 10., 790., 46.], # right lower + [667., 282., 790., 318.], # right center + [667., 554., 790., 590.]] # right upper for nn, todo in enumerate(todos): print(todo) diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 6b3e1b99bc8a..63d5c45308f1 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -332,7 +332,7 @@ def test_get_tightbbox_polar(): fig.canvas.draw() bb = ax.get_tightbbox(fig.canvas.get_renderer()) assert_allclose( - bb.extents, [108.27778, 28.7778, 539.7222, 451.2222], rtol=1e-03) + bb.extents, [108.27778, 29.1111, 539.7222, 450.8889], rtol=1e-03) @check_figures_equal() diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index dec91ce31979..0eed2f5aeb87 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -14,7 +14,7 @@ from matplotlib.backend_bases import MouseEvent from matplotlib.backends.backend_agg import RendererAgg from matplotlib.figure import Figure -from matplotlib.font_manager import FontProperties +from matplotlib.font_manager import FontProperties, fontManager, get_font import matplotlib.patches as mpatches import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec @@ -1061,8 +1061,16 @@ def test_text_annotation_get_window_extent(): _, _, d = renderer.get_text_width_height_descent( 'text', annotation._fontproperties, ismath=False) - _, _, lp_d = renderer.get_text_width_height_descent( - 'lp', annotation._fontproperties, ismath=False) + font = get_font(fontManager._find_fonts_by_props(annotation._fontproperties)) + for name, key in [('OS/2', 'sTypoDescender'), ('hhea', 'descent')]: + if (table := font.get_sfnt_table(name)) is not None: + units_per_em = font.get_sfnt_table('head')['unitsPerEm'] + fontsize = annotation._fontproperties.get_size_in_points() + lp_d = -table[key] / units_per_em * fontsize * figure.dpi / 72 + break + else: + _, _, lp_d = renderer.get_text_width_height_descent( + 'lp', annotation._fontproperties, ismath=False) below_line = max(d, lp_d) # These numbers are specific to the current implementation of Text @@ -1101,7 +1109,7 @@ def test_text_with_arrow_annotation_get_window_extent(): assert bbox.width == text_bbox.width + 50.0 # make sure the annotation text bounding box is same size # as the bounding box of the same string as a Text object - assert ann_txt_bbox.height == text_bbox.height + assert_almost_equal(ann_txt_bbox.height, text_bbox.height) assert ann_txt_bbox.width == text_bbox.width # compute the expected bounding box of arrow + text expected_bbox = mtransforms.Bbox.union([ann_txt_bbox, arrow_bbox]) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index a9da4cd59108..f6ea6673ff0f 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -15,7 +15,7 @@ import matplotlib as mpl from . import _api, artist, cbook, _docstring, colors as mcolors from .artist import Artist -from .font_manager import FontProperties +from .font_manager import FontProperties, fontManager, get_font from .patches import FancyArrowPatch, FancyBboxPatch, Rectangle from .textpath import TextPath, TextToPath # noqa # Logically located here from .transforms import ( @@ -433,16 +433,40 @@ def _get_layout(self, renderer): xs = [] ys = [] - # Full vertical extent of font, including ascenders and descenders: - _, lp_h, lp_d = _get_text_metrics_with_cache( - renderer, "lp", self._fontproperties, - ismath="TeX" if self.get_usetex() else False, - dpi=self.get_figure(root=True).dpi) - lp_a = lp_h - lp_d - linespacing = 1.2 if self._linespacing == 'normal' else self._linespacing - min_dy = lp_a * linespacing - - for i, line in enumerate(lines): + min_ascent = min_descent = line_gap = None + dpi = self.get_figure(root=True).dpi + # Determine full vertical extent of font, including ascenders and descenders: + if not self.get_usetex(): + font = get_font(fontManager._find_fonts_by_props(self._fontproperties)) + possible_metrics = [ + ('OS/2', 'sTypoLineGap', 'sTypoAscender', 'sTypoDescender'), + ('hhea', 'lineGap', 'ascent', 'descent') + ] + for table_name, linegap_key, ascent_key, descent_key in possible_metrics: + table = font.get_sfnt_table(table_name) + if table is None: + continue + # Rescale to font size/DPI if the metrics were available. + fontsize = self._fontproperties.get_size_in_points() + units_per_em = font.get_sfnt_table('head')['unitsPerEm'] + line_gap = table[linegap_key] / units_per_em * fontsize * dpi / 72 + min_ascent = table[ascent_key] / units_per_em * fontsize * dpi / 72 + min_descent = -table[descent_key] / units_per_em * fontsize * dpi / 72 + break + if None in (min_ascent, min_descent): + # Fallback to font measurement. + _, h, min_descent = _get_text_metrics_with_cache( + renderer, "lp", self._fontproperties, + ismath="TeX" if self.get_usetex() else False, + dpi=dpi) + min_ascent = h - min_descent + line_gap = 0 + + # Don't increase text height too much if it's not multiple lines. + if len(lines) == 1: + line_gap = 0 + + for line in lines: clean_line, ismath = self._preprocess_math(line) if clean_line: w, h, d = _get_text_metrics_with_cache( @@ -452,18 +476,24 @@ def _get_layout(self, renderer): w = h = d = 0 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) + + if self.get_usetex() or self._linespacing == 'normal': + # To ensure good linespacing, pretend that the ascent / descent of all + # lines is at least as large as the measured sizes. + a = max(a, min_ascent) + line_gap / 2 + d = max(d, min_descent) + line_gap / 2 + else: + # If using a fixed line spacing, then every line's spacing will be + # determined by the font metrics of the first available font. + line_height = self._linespacing * (min_ascent + min_descent) + leading = line_height - (a + d) + a += leading / 2 + d += leading / 2 # Metrics of the last line that are needed later: baseline = a - thisy - if i == 0: # position at baseline - thisy = -a - else: # put baseline a good distance from bottom of previous line - thisy -= max(min_dy, a * linespacing) + thisy -= a wads.append((w, a, d)) xs.append(thisx) # == 0.