Skip to content

Commit 97f4943

Browse files
committed
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.
1 parent d961462 commit 97f4943

6 files changed

Lines changed: 93 additions & 44 deletions

File tree

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: 49 additions & 19 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 (
@@ -433,16 +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-
linespacing = 1.2 if self._linespacing == 'normal' else self._linespacing
443-
min_dy = lp_a * linespacing
444-
445-
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:
446470
clean_line, ismath = self._preprocess_math(line)
447471
if clean_line:
448472
w, h, d = _get_text_metrics_with_cache(
@@ -452,18 +476,24 @@ def _get_layout(self, renderer):
452476
w = h = d = 0
453477

454478
a = h - d
455-
# To ensure good linespacing, pretend that the ascent (resp.
456-
# descent) of all lines is at least as large as "l" (resp. "p").
457-
a = max(a, lp_a)
458-
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
459492

460493
# Metrics of the last line that are needed later:
461494
baseline = a - thisy
462495

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

468498
wads.append((w, a, d))
469499
xs.append(thisx) # == 0.

0 commit comments

Comments
 (0)