Skip to content

Commit 1f064dc

Browse files
authored
Merge pull request #31291 from QuLogic/font-heights
text: Use font metrics to determine line heights
2 parents f99a345 + 97f4943 commit 1f064dc

File tree

8 files changed

+130
-59
lines changed

8 files changed

+130
-59
lines changed

lib/matplotlib/_mathtext.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -445,11 +445,15 @@ def get_quad(self, fontname: str, fontsize: float, dpi: float) -> float:
445445
return metrics.advance
446446

447447
def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float:
448-
# Some fonts report the wrong x-height, while some don't store it, so
449-
# we do a poor man's x-height.
450-
metrics = self.get_metrics(
451-
fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
452-
return metrics.iceberg
448+
consts = self.get_font_constants()
449+
if consts.x_height is not None:
450+
return consts.x_height * fontsize * dpi / 72
451+
else:
452+
# Some fonts report the wrong x-height, while some don't store it, so
453+
# we do a poor man's x-height.
454+
metrics = self.get_metrics(
455+
fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
456+
return metrics.iceberg
453457

454458
def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
455459
# This function used to grab underline thickness from the font
@@ -1006,6 +1010,10 @@ class FontConstantsBase:
10061010
# The size of a quad space in LaTeX, as a multiple of design size.
10071011
quad: T.ClassVar[float | None] = None
10081012

1013+
# The size of x-height in font design units (i.e., divided by units-per-em). If not
1014+
# provided, then this will be measured from the font itself.
1015+
x_height: T.ClassVar[float | None] = None
1016+
10091017

10101018
class ComputerModernFontConstants(FontConstantsBase):
10111019
# Previously, the x-height of Computer Modern was obtained from the font
@@ -1034,17 +1042,19 @@ class ComputerModernFontConstants(FontConstantsBase):
10341042
# size.
10351043
axis_height = 262144 / 2**20
10361044
quad = 1048579 / 2**20
1045+
x_height = _x_height / 2**20
10371046

10381047

10391048
class STIXFontConstants(FontConstantsBase):
10401049
script_space = 0.1
10411050
delta = 0.05
10421051
delta_slanted = 0.3
10431052
delta_integral = 0.3
1053+
_x_height = 450
1054+
x_height = _x_height / 1000
10441055
# These values are extracted from the TeX table of STIXGeneral.ttf using FontForge,
10451056
# and then divided by design xheight, since we multiply these values by the scaled
10461057
# xheight later.
1047-
_x_height = 450
10481058
supdrop = 386 / _x_height
10491059
subdrop = 50.0002 / _x_height
10501060
sup1 = 413 / _x_height
@@ -1068,10 +1078,11 @@ class STIXSansFontConstants(STIXFontConstants):
10681078

10691079

10701080
class DejaVuSerifFontConstants(FontConstantsBase):
1081+
_x_height = 1063
1082+
x_height = _x_height / 2048
10711083
# These values are extracted from the TeX table of DejaVuSerif.ttf using FontForge,
10721084
# and then divided by design xheight, since we multiply these values by the scaled
10731085
# xheight later.
1074-
_x_height = 1063
10751086
supdrop = 790.527 / _x_height
10761087
subdrop = 102.4 / _x_height
10771088
sup1 = 845.824 / _x_height
@@ -1088,10 +1099,11 @@ class DejaVuSerifFontConstants(FontConstantsBase):
10881099

10891100

10901101
class DejaVuSansFontConstants(FontConstantsBase):
1102+
_x_height = 1120
1103+
x_height = _x_height / 2048
10911104
# These values are extracted from the TeX table of DejaVuSans.ttf using FontForge,
10921105
# and then divided by design xheight, since we multiply these values by the scaled
10931106
# xheight later.
1094-
_x_height = 1120
10951107
supdrop = 790.527 / _x_height
10961108
subdrop = 102.4 / _x_height
10971109
sup1 = 845.824 / _x_height

lib/matplotlib/testing/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ def text_placeholders(monkeypatch):
149149
"""
150150
from matplotlib.patches import Rectangle
151151

152+
def patched_get_sfnt_table(font, name):
153+
"""
154+
Replace ``FT2Font.get_sfnt_table`` with empty results.
155+
156+
This forces ``Text._get_layout`` to fall back to
157+
``get_text_width_height_descent``, which produces results from the patch below.
158+
"""
159+
return None
160+
152161
def patched_get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi):
153162
"""
154163
Replace ``_get_text_metrics_with_cache`` with fixed results.
@@ -183,6 +192,8 @@ def patched_text_draw(self, renderer):
183192
facecolor=self.get_color(), edgecolor='none')
184193
rect.draw(renderer)
185194

195+
monkeypatch.setattr('matplotlib.ft2font.FT2Font.get_sfnt_table',
196+
patched_get_sfnt_table)
186197
monkeypatch.setattr('matplotlib.text._get_text_metrics_with_cache',
187198
patched_get_text_metrics_with_cache)
188199
monkeypatch.setattr('matplotlib.text.Text.draw', patched_text_draw)

lib/matplotlib/tests/test_axes.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7753,7 +7753,7 @@ def test_titletwiny():
77537753
bbox_y0_title = title.get_window_extent(renderer).y0 # bottom of title
77547754
bbox_y1_xlabel2 = xlabel2.get_window_extent(renderer).y1 # top of xlabel2
77557755
y_diff = bbox_y0_title - bbox_y1_xlabel2
7756-
assert np.isclose(y_diff, 3)
7756+
assert y_diff >= 3
77577757

77587758

77597759
def test_titlesetpos():
@@ -8525,8 +8525,8 @@ def test_normal_axes():
85258525

85268526
# test the axis bboxes
85278527
target = [
8528-
[124.0, 76.89, 982.0, 32.0],
8529-
[86.89, 100.5, 52.0, 992.0],
8528+
[124.0, 75.56, 982.0, 33.33],
8529+
[86.89, 99.33, 52.0, 993.33],
85308530
]
85318531
for nn, b in enumerate(bbaxis):
85328532
targetbb = mtransforms.Bbox.from_bounds(*target[nn])
@@ -8546,7 +8546,7 @@ def test_normal_axes():
85468546
targetbb = mtransforms.Bbox.from_bounds(*target)
85478547
assert_array_almost_equal(bbax.bounds, targetbb.bounds, decimal=2)
85488548

8549-
target = [86.89, 76.89, 1019.11, 1015.61]
8549+
target = [86.89, 75.56, 1019.11, 1017.11]
85508550
targetbb = mtransforms.Bbox.from_bounds(*target)
85518551
assert_array_almost_equal(bbtb.bounds, targetbb.bounds, decimal=2)
85528552

lib/matplotlib/tests/test_legend.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -481,27 +481,27 @@ def test_figure_legend_outside():
481481
todos += ['left ' + pos for pos in ['lower', 'center', 'upper']]
482482
todos += ['right ' + pos for pos in ['lower', 'center', 'upper']]
483483

484-
upperext = [20.722556, 26.722556, 790.333, 545.999]
485-
lowerext = [20.722556, 70.056556, 790.333, 589.333]
486-
leftext = [152.056556, 26.722556, 790.333, 589.333]
487-
rightext = [20.722556, 26.722556, 658.999, 589.333]
484+
upperext = [20.722556, 26.389222, 790.333, 545.16762]
485+
lowerext = [20.722556, 70.723222, 790.333, 589.50162]
486+
leftext = [152.056556, 26.389222, 790.333, 589.50162]
487+
rightext = [20.722556, 26.389222, 658.999, 589.50162]
488488
axbb = [upperext, upperext, upperext,
489489
lowerext, lowerext, lowerext,
490490
leftext, leftext, leftext,
491491
rightext, rightext, rightext]
492492

493-
legbb = [[10., 555., 133., 590.], # upper left
494-
[338.5, 555., 461.5, 590.], # upper center
495-
[667, 555., 790., 590.], # upper right
496-
[10., 10., 133., 45.], # lower left
497-
[338.5, 10., 461.5, 45.], # lower center
498-
[667., 10., 790., 45.], # lower right
499-
[10., 10., 133., 45.], # left lower
500-
[10., 282.5, 133., 317.5], # left center
501-
[10., 555., 133., 590.], # left upper
502-
[667, 10., 790., 45.], # right lower
503-
[667., 282.5, 790., 317.5], # right center
504-
[667., 555., 790., 590.]] # right upper
493+
legbb = [[10., 554., 133., 590.], # upper left
494+
[338.5, 554., 461.5, 590.], # upper center
495+
[667, 554., 790., 590.], # upper right
496+
[10., 10., 133., 46.], # lower left
497+
[338.5, 10., 461.5, 46.], # lower center
498+
[667., 10., 790., 46.], # lower right
499+
[10., 10., 133., 46.], # left lower
500+
[10., 282., 133., 318.], # left center
501+
[10., 554., 133., 590.], # left upper
502+
[667, 10., 790., 46.], # right lower
503+
[667., 282., 790., 318.], # right center
504+
[667., 554., 790., 590.]] # right upper
505505

506506
for nn, todo in enumerate(todos):
507507
print(todo)

lib/matplotlib/tests/test_polar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ def test_get_tightbbox_polar():
332332
fig.canvas.draw()
333333
bb = ax.get_tightbbox(fig.canvas.get_renderer())
334334
assert_allclose(
335-
bb.extents, [108.27778, 28.7778, 539.7222, 451.2222], rtol=1e-03)
335+
bb.extents, [108.27778, 29.1111, 539.7222, 450.8889], rtol=1e-03)
336336

337337

338338
@check_figures_equal()

lib/matplotlib/tests/test_text.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from matplotlib.backend_bases import MouseEvent
1515
from matplotlib.backends.backend_agg import RendererAgg
1616
from matplotlib.figure import Figure
17-
from matplotlib.font_manager import FontProperties
17+
from matplotlib.font_manager import FontProperties, fontManager, get_font
1818
import matplotlib.patches as mpatches
1919
import matplotlib.pyplot as plt
2020
from matplotlib.gridspec import GridSpec
@@ -1061,8 +1061,16 @@ def test_text_annotation_get_window_extent():
10611061

10621062
_, _, d = renderer.get_text_width_height_descent(
10631063
'text', annotation._fontproperties, ismath=False)
1064-
_, _, lp_d = renderer.get_text_width_height_descent(
1065-
'lp', annotation._fontproperties, ismath=False)
1064+
font = get_font(fontManager._find_fonts_by_props(annotation._fontproperties))
1065+
for name, key in [('OS/2', 'sTypoDescender'), ('hhea', 'descent')]:
1066+
if (table := font.get_sfnt_table(name)) is not None:
1067+
units_per_em = font.get_sfnt_table('head')['unitsPerEm']
1068+
fontsize = annotation._fontproperties.get_size_in_points()
1069+
lp_d = -table[key] / units_per_em * fontsize * figure.dpi / 72
1070+
break
1071+
else:
1072+
_, _, lp_d = renderer.get_text_width_height_descent(
1073+
'lp', annotation._fontproperties, ismath=False)
10661074
below_line = max(d, lp_d)
10671075

10681076
# These numbers are specific to the current implementation of Text
@@ -1101,7 +1109,7 @@ def test_text_with_arrow_annotation_get_window_extent():
11011109
assert bbox.width == text_bbox.width + 50.0
11021110
# make sure the annotation text bounding box is same size
11031111
# as the bounding box of the same string as a Text object
1104-
assert ann_txt_bbox.height == text_bbox.height
1112+
assert_almost_equal(ann_txt_bbox.height, text_bbox.height)
11051113
assert ann_txt_bbox.width == text_bbox.width
11061114
# compute the expected bounding box of arrow + text
11071115
expected_bbox = mtransforms.Bbox.union([ann_txt_bbox, arrow_bbox])

lib/matplotlib/text.py

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import matplotlib as mpl
1616
from . import _api, artist, cbook, _docstring, colors as mcolors
1717
from .artist import Artist
18-
from .font_manager import FontProperties
18+
from .font_manager import FontProperties, fontManager, get_font
1919
from .patches import FancyArrowPatch, FancyBboxPatch, Rectangle
2020
from .textpath import TextPath, TextToPath # noqa # Logically located here
2121
from .transforms import (
@@ -241,7 +241,7 @@ def _reset_visual_defaults(
241241
self._bbox_patch = None # a FancyBboxPatch instance
242242
self._renderer = None
243243
if linespacing is None:
244-
linespacing = 1.2 # Maybe use rcParam later.
244+
linespacing = 'normal' # Maybe use rcParam later.
245245
self.set_linespacing(linespacing)
246246
self.set_rotation_mode(rotation_mode)
247247
self.set_antialiased(mpl._val_or_rc(antialiased, 'text.antialiased'))
@@ -433,15 +433,40 @@ def _get_layout(self, renderer):
433433
xs = []
434434
ys = []
435435

436-
# Full vertical extent of font, including ascenders and descenders:
437-
_, lp_h, lp_d = _get_text_metrics_with_cache(
438-
renderer, "lp", self._fontproperties,
439-
ismath="TeX" if self.get_usetex() else False,
440-
dpi=self.get_figure(root=True).dpi)
441-
lp_a = lp_h - lp_d
442-
min_dy = lp_a * self._linespacing
443-
444-
for i, line in enumerate(lines):
436+
min_ascent = min_descent = line_gap = None
437+
dpi = self.get_figure(root=True).dpi
438+
# Determine full vertical extent of font, including ascenders and descenders:
439+
if not self.get_usetex():
440+
font = get_font(fontManager._find_fonts_by_props(self._fontproperties))
441+
possible_metrics = [
442+
('OS/2', 'sTypoLineGap', 'sTypoAscender', 'sTypoDescender'),
443+
('hhea', 'lineGap', 'ascent', 'descent')
444+
]
445+
for table_name, linegap_key, ascent_key, descent_key in possible_metrics:
446+
table = font.get_sfnt_table(table_name)
447+
if table is None:
448+
continue
449+
# Rescale to font size/DPI if the metrics were available.
450+
fontsize = self._fontproperties.get_size_in_points()
451+
units_per_em = font.get_sfnt_table('head')['unitsPerEm']
452+
line_gap = table[linegap_key] / units_per_em * fontsize * dpi / 72
453+
min_ascent = table[ascent_key] / units_per_em * fontsize * dpi / 72
454+
min_descent = -table[descent_key] / units_per_em * fontsize * dpi / 72
455+
break
456+
if None in (min_ascent, min_descent):
457+
# Fallback to font measurement.
458+
_, h, min_descent = _get_text_metrics_with_cache(
459+
renderer, "lp", self._fontproperties,
460+
ismath="TeX" if self.get_usetex() else False,
461+
dpi=dpi)
462+
min_ascent = h - min_descent
463+
line_gap = 0
464+
465+
# Don't increase text height too much if it's not multiple lines.
466+
if len(lines) == 1:
467+
line_gap = 0
468+
469+
for line in lines:
445470
clean_line, ismath = self._preprocess_math(line)
446471
if clean_line:
447472
w, h, d = _get_text_metrics_with_cache(
@@ -451,18 +476,24 @@ def _get_layout(self, renderer):
451476
w = h = d = 0
452477

453478
a = h - d
454-
# To ensure good linespacing, pretend that the ascent (resp.
455-
# descent) of all lines is at least as large as "l" (resp. "p").
456-
a = max(a, lp_a)
457-
d = max(d, lp_d)
479+
480+
if self.get_usetex() or self._linespacing == 'normal':
481+
# To ensure good linespacing, pretend that the ascent / descent of all
482+
# lines is at least as large as the measured sizes.
483+
a = max(a, min_ascent) + line_gap / 2
484+
d = max(d, min_descent) + line_gap / 2
485+
else:
486+
# If using a fixed line spacing, then every line's spacing will be
487+
# determined by the font metrics of the first available font.
488+
line_height = self._linespacing * (min_ascent + min_descent)
489+
leading = line_height - (a + d)
490+
a += leading / 2
491+
d += leading / 2
458492

459493
# Metrics of the last line that are needed later:
460494
baseline = a - thisy
461495

462-
if i == 0: # position at baseline
463-
thisy = -a
464-
else: # put baseline a good distance from bottom of previous line
465-
thisy -= max(min_dy, a * self._linespacing)
496+
thisy -= a
466497

467498
wads.append((w, a, d))
468499
xs.append(thisx) # == 0.
@@ -1122,18 +1153,26 @@ def set_multialignment(self, align):
11221153

11231154
def set_linespacing(self, spacing):
11241155
"""
1125-
Set the line spacing as a multiple of the font size.
1126-
1127-
The default line spacing is 1.2.
1156+
Set the line spacing.
11281157
11291158
Parameters
11301159
----------
1131-
spacing : float (multiple of font size)
1160+
spacing : 'normal' or float, default: 'normal'
1161+
If 'normal', then the line spacing is automatically determined by font
1162+
metrics for each line individually.
1163+
1164+
If a float, then line spacing will be fixed to this multiple of the font
1165+
size for every line.
11321166
"""
1133-
_api.check_isinstance(Real, spacing=spacing)
1167+
if not cbook._str_equal(spacing, 'normal'):
1168+
_api.check_isinstance(Real, spacing=spacing)
11341169
self._linespacing = spacing
11351170
self.stale = True
11361171

1172+
def get_linespacing(self):
1173+
"""Get the line spacing."""
1174+
return self._linespacing
1175+
11371176
def set_fontfamily(self, fontname):
11381177
"""
11391178
Set the font family. Can be either a single string, or a list of

lib/matplotlib/text.pyi

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class Text(Artist):
3434
multialignment: Literal["left", "center", "right"] | None = ...,
3535
fontproperties: str | Path | FontProperties | None = ...,
3636
rotation: float | Literal["vertical", "horizontal"] | None = ...,
37-
linespacing: float | None = ...,
37+
linespacing: Literal["normal"] | float | None = ...,
3838
rotation_mode: Literal["default", "anchor"] | None = ...,
3939
usetex: bool | None = ...,
4040
wrap: bool = ...,
@@ -79,7 +79,8 @@ class Text(Artist):
7979
self, align: Literal["left", "center", "right"]
8080
) -> None: ...
8181
def set_multialignment(self, align: Literal["left", "center", "right"]) -> None: ...
82-
def set_linespacing(self, spacing: float) -> None: ...
82+
def set_linespacing(self, spacing: Literal["normal"] | float) -> None: ...
83+
def get_linespacing(self) -> Literal["normal"] | float: ...
8384
def set_fontfamily(self, fontname: str | Iterable[str]) -> None: ...
8485
def set_fontfeatures(self, features: Sequence[str] | None) -> None: ...
8586
def set_fontvariant(self, variant: Literal["normal", "small-caps"]) -> None: ...

0 commit comments

Comments
 (0)