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 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 f0dd963fe477..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 ( @@ -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')) @@ -433,15 +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 - min_dy = lp_a * self._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( @@ -451,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 * self._linespacing) + thisy -= a wads.append((w, a, d)) xs.append(thisx) # == 0. @@ -1122,18 +1153,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: ...