Skip to content
Open
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
11 changes: 4 additions & 7 deletions lib/matplotlib/_text_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ def layout(string: str, font: FT2Font, *,
"""
Render *string* with *font*.

For each character in *string*, yield a LayoutItem instance. When such an instance
is yielded, the font's glyph is set to the corresponding character.
For each character in *string*, yield a LayoutItem instance. Callers that
need the outline must load the glyph themselves.

Parameters
----------
Expand All @@ -43,8 +43,5 @@ def layout(string: str, font: FT2Font, *,
------
LayoutItem
"""
for raqm_item in font._layout(string, LoadFlags.NO_HINTING,
features=features, language=language):
raqm_item.ft_object.load_glyph(raqm_item.glyph_index,
flags=LoadFlags.NO_HINTING)
yield raqm_item
yield from font._layout(string, LoadFlags.NO_HINTING,
features=features, language=language)
30 changes: 6 additions & 24 deletions lib/matplotlib/backends/backend_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
from matplotlib.dviread import Dvi
from matplotlib.font_manager import fontManager as _fontManager, get_font
from matplotlib.ft2font import LoadFlags, RenderMode
from matplotlib.ft2font import LoadFlags, RenderMode, _render_glyph_run
from matplotlib.mathtext import MathTextParser
from matplotlib.path import Path
from matplotlib.transforms import Bbox, BboxBase
Expand Down Expand Up @@ -174,29 +174,11 @@ def draw_path(self, gc, path, transform, rgbFace=None):

def _draw_text_glyphs_and_boxes(self, gc, x, y, angle, glyphs, boxes):
# y is downwards.
cos = math.cos(math.radians(angle))
sin = math.sin(math.radians(angle))
load_flags = get_hinting_flag()
for font, size, glyph_index, slant, extend, dx, dy in glyphs: # dy is upwards.
font.set_size(size, self.dpi)
font._set_transform(
(0x10000 * np.array([[cos, -sin], [sin, cos]])
@ [[extend, extend * slant], [0, 1]]).round().astype(int),
[round(0x40 * (x + dx * cos - dy * sin)),
# FreeType's y is upwards.
round(0x40 * (self.height - y + dx * sin + dy * cos))]
)
bitmap = font._render_glyph(
glyph_index, load_flags,
RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO)
buffer = bitmap.buffer
if not gc.get_antialiased():
buffer *= 0xff
# draw_text_image's y is downwards & the bitmap bottom side.
self._renderer.draw_text_image(
buffer,
bitmap.left, int(self.height) - bitmap.top + buffer.shape[0],
0, gc)
antialiased = gc.get_antialiased()
buffer, positions = _render_glyph_run(
list(glyphs), self.dpi, x, y, angle, self.height, get_hinting_flag(),
RenderMode.NORMAL if antialiased else RenderMode.MONO)
self._renderer.draw_text_images(buffer, positions, gc)

rgba = gc.get_rgb()
if len(rgba) == 3 or gc.get_forced_alpha():
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 @@ -158,6 +158,15 @@ def patched_get_sfnt_table(font, name):
"""
return None

def patched_get_font_height_metrics(font, fontsize, dpi):
"""
Replace ``_get_font_height_metrics`` with empty results.

It caches what it reads from the tables emptied out above, so patching
those alone would leave a font measured earlier with its real metrics.
"""
return None, None, 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 @@ -194,6 +203,8 @@ def patched_text_draw(self, renderer):

monkeypatch.setattr('matplotlib.ft2font.FT2Font.get_sfnt_table',
patched_get_sfnt_table)
monkeypatch.setattr('matplotlib.text._get_font_height_metrics',
patched_get_font_height_metrics)
monkeypatch.setattr('matplotlib.text._get_text_metrics_with_cache',
patched_get_text_metrics_with_cache)
monkeypatch.setattr('matplotlib.text.Text.draw', patched_text_draw)
20 changes: 20 additions & 0 deletions lib/matplotlib/tests/test_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,23 @@ def test_path_autosnap(fig_test, fig_ref):

axt.autoscale_view()
axr.autoscale_view()


def test_draw_text_images_bounds():
# Positions must describe bitmaps inside the buffer, as the renderer indexes
# into it without checking.
fig = plt.figure(figsize=(1, 1), dpi=40)
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
gc = renderer.new_gc()
buffer = np.zeros(16, dtype=np.uint8)

for positions in [[[1 << 20, 4, 4, 5, 20]], # Offset past the end.
[[0, 400, 400, 5, 39]], # Larger than the buffer.
[[0, -4, 4, 5, 20]]]: # Negative size.
with pytest.raises(ValueError, match='outside of buffer'):
renderer._renderer.draw_text_images(
buffer, np.array(positions, dtype=np.intp), gc)

renderer._renderer.draw_text_images(
buffer, np.array([[0, 2, 2, 1, 3]], dtype=np.intp), gc)
72 changes: 72 additions & 0 deletions lib/matplotlib/tests/test_ft2font.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,3 +1034,75 @@ def test__layout():
assert Path(item.ft_object.fname).name == 'DejaVuSans.ttf'
else:
assert Path(item.ft_object.fname).name == 'cmr10.ttf'


def test_render_glyph_cache():
# Reusing a cached outline must not change what is rendered.
ft = fm.get_font(fm.findfont('DejaVu Sans'))
ft.set_size(12, 100)
index = ft.get_char_index(ord('e'))
identity = [[0x10000, 0], [0, 0x10000]]

def render(delta=(0, 0)):
ft._set_transform(identity, list(delta))
return ft._render_glyph(index, ft2font.LoadFlags.DEFAULT,
ft2font.RenderMode.NORMAL)

first = render()
reference = first.buffer.copy()
# A whole-pixel shift reuses the outline and only moves the glyph.
shifted = render(delta=(0x40 * 3, 0x40 * 5))
assert np.array_equal(shifted.buffer, reference)
assert (shifted.left, shifted.top) == (first.left + 3, first.top + 5)
# A fractional shift must reach the rasterizer rather than be rounded away.
assert not np.array_equal(render(delta=(0x20, 0x20)).buffer, reference)
# The size is part of the key.
ft.set_size(24, 100)
assert render().buffer.shape != reference.shape


def test_layout_cache():
# A cached layout must match a fresh one, and the size is part of the key.
ft = fm.get_font(fm.findfont('DejaVu Sans'))
ft.set_size(12, 100)

def xs():
return [item.x for item in ft._layout('hello world',
ft2font.LoadFlags.DEFAULT)]

first = xs()
assert xs() == first
ft.set_size(24, 100)
assert xs() != first


def test_layout_cache_transform():
# The transform is part of the key, as it changes the shaped positions.
ft = fm.get_font(fm.findfont('DejaVu Sans'))
ft.set_size(12, 100)

def xs():
return [item.x for item in ft._layout('hello world',
ft2font.LoadFlags.DEFAULT)]

first = xs()
ft._set_transform([[0x20000, 0], [0, 0x10000]], [0, 0])
assert xs() != first


def test_layout_cache_fallback_size():
# Shaping reads the fallback faces, so resizing one must invalidate the cache.
ft = fm.get_font(fm.fontManager._find_fonts_by_props(
fm.FontProperties(family=['cmr10', 'DejaVu Sans'])))
ft.set_size(12, 100)

def items():
return ft._layout('AV \N{CIRCLED LATIN CAPITAL LETTER A} ffi',
ft2font.LoadFlags.DEFAULT)

first = [item.x for item in items()]
for item in items():
if item.ft_object is not ft:
item.ft_object.set_size(40, 100)
break
assert [item.x for item in items()] != first
12 changes: 11 additions & 1 deletion lib/matplotlib/tests/test_textpath.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import copy

from matplotlib.textpath import TextPath
import matplotlib.font_manager as fm
from matplotlib.textpath import TextPath, TextToPath


def test_glyphs_load_their_own_outline():
# Laying out no longer leaves a glyph in the font's slot, so each outline
# must be loaded where it is read, not taken from whatever was there.
font = fm.get_font(fm.findfont('DejaVu Sans'))
_, glyph_map, _ = TextToPath().get_glyphs_with_font(font, 'lM')
(l_verts, _), (m_verts, _) = glyph_map.values()
assert len(l_verts) != len(m_verts)


def test_copy():
Expand Down
48 changes: 32 additions & 16 deletions lib/matplotlib/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,36 @@ def _rotate_point(angle, x, y):
return (cos * x - sin * y, sin * x + cos * y)


def _get_font_height_metrics(font, fontsize, dpi,
_cache=weakref.WeakKeyDictionary()):
"""
Return the ascent, descent and line gap of *font*, in pixels, or
``(None, None, None)`` if it carries neither metrics table.

Keyed on the font rather than on font properties, which resolve to a font
via the rcParams, and held weakly so that caching never keeps a font alive.
"""
if (metrics := _cache.get(font)) is None:
metrics = _cache[font] = {}
if (key := (fontsize, dpi)) not in metrics:
possible = [
('OS/2', 'sTypoLineGap', 'sTypoAscender', 'sTypoDescender'),
('hhea', 'lineGap', 'ascent', 'descent')
]
metrics[key] = (None, None, None)
for table_name, linegap_key, ascent_key, descent_key in possible:
table = font.get_sfnt_table(table_name)
if table is None:
continue
# Rescale to font size/DPI if the metrics were available.
units_per_em = font.get_sfnt_table('head')['unitsPerEm']
scale = 1 / units_per_em * fontsize * dpi / 72
metrics[key] = (table[ascent_key] * scale, -table[descent_key] * scale,
table[linegap_key] * scale)
break
return metrics[key]


def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi):
"""Call ``renderer.get_text_width_height_descent``, caching the results."""

Expand Down Expand Up @@ -444,22 +474,8 @@ def _get_layout(self, renderer):
self._fontproperties)
if min_ascent is None:
font = get_font(fontManager._find_fonts_by_props(self._fontproperties))
possible = [
('OS/2', 'sTypoLineGap', 'sTypoAscender', 'sTypoDescender'),
('hhea', 'lineGap', 'ascent', 'descent')
]
for table_name, linegap_key, ascent_key, descent_key in possible:
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']
scale = 1 / units_per_em * fontsize * dpi / 72
line_gap = table[linegap_key] * scale
min_ascent = table[ascent_key] * scale
min_descent = -table[descent_key] * scale
break
min_ascent, min_descent, line_gap = _get_font_height_metrics(
font, self._fontproperties.get_size_in_points(), dpi)
if None in (min_ascent, min_descent):
# Fallback to font measurement.
_, h, min_descent = _get_text_metrics_with_cache(
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/textpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ def get_glyphs_with_font(self, font, s, glyph_map=None,
xpositions.append(item.x)
ypositions.append(item.y)
if glyph_repr not in glyph_map:
item.ft_object.load_glyph(item.glyph_index,
flags=LoadFlags.NO_HINTING)
glyph_map_new[glyph_repr] = item.ft_object.get_path()

sizes = [1.] * len(xpositions)
Expand Down
Loading
Loading