Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 20 additions & 8 deletions lib/matplotlib/_mathtext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1034,17 +1042,19 @@ class ComputerModernFontConstants(FontConstantsBase):
# size.
axis_height = 262144 / 2**20
quad = 1048579 / 2**20
x_height = _x_height / 2**20


class STIXFontConstants(FontConstantsBase):
script_space = 0.1
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
Expand All @@ -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
Expand All @@ -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
Expand Down
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
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])
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)

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)

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
87 changes: 63 additions & 24 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 @@ -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'))
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/matplotlib/text.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ...,
Expand Down Expand Up @@ -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: ...
Expand Down
Loading