Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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.
  • Loading branch information
QuLogic committed Mar 20, 2026
commit 97f4943cf279c82a4dc64e40ec52dcb1bcd218fe
11 changes: 11 additions & 0 deletions lib/matplotlib/testing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
8 changes: 4 additions & 4 deletions lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -8525,8 +8525,8 @@ def test_normal_axes():

# test the axis bboxes
>
[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])
Expand All @@ -8546,7 +8546,7 @@ def test_normal_axes():
targetbb = mtransforms.Bbox.from_bounds(*target)
assert_array_almost_equal(bbax.bounds, targetbb.bounds, decimal=2)

76.89, 1019.11, 1015.61]
75.56, 1019.11, 1017.11]
targetbb = mtransforms.Bbox.from_bounds(*target)
assert_array_almost_equal(bbtb.bounds, targetbb.bounds, decimal=2)

Expand Down
32 changes: 16 additions & 16 deletions lib/matplotlib/tests/test_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/tests/test_polar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 12 additions & 4 deletions lib/matplotlib/tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
68 changes: 49 additions & 19 deletions lib/matplotlib/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down
Loading