From 931bcf3f034172ddd22e905e5a6cf4ed515f25e6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 04:00:55 -0500 Subject: [PATCH 1/4] Change RendererAgg.draw_text to use FT2Font._render_glyph This makes it more similar to `draw_mathtext`, and is necessary for colour fonts, where the previously-cached bitmap would have been downgraded to black&white strokes by FreeType when copied. It should also reduce the necessary code paths with the other text drawing methods as well. --- lib/matplotlib/backends/backend_agg.py | 26 +++++++++++++++++++------- src/ft2font_wrapper.cpp | 10 ---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index d96a27b544af..409348c1cb66 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -229,13 +229,25 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) font = self._prepare_font(prop) - font.set_text(s, angle, flags=get_hinting_flag(), - features=mtext.get_fontfeatures() if mtext is not None else None, - language=mtext.get_language() if mtext is not None else None) - for bitmap in font._render_glyphs( - x, self.height - y, - RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO, - ): + cos = math.cos(math.radians(angle)) + sin = math.sin(math.radians(angle)) + load_flags = get_hinting_flag() + items = font._layout( + s, flags=load_flags, + features=mtext.get_fontfeatures() if mtext is not None else None, + language=mtext.get_language() if mtext is not None else None) + for item in items: + hf = item.ft_object._hinting_factor + item.ft_object._set_transform( + [[round(0x10000 * cos / hf), round(0x10000 * -sin)], + [round(0x10000 * sin / hf), round(0x10000 * cos)]], + [round(0x40 * (x + item.x * cos - item.y * sin)), + # FreeType's y is upwards. + round(0x40 * (self.height - y + item.x * sin + item.y * cos))] + ) + bitmap = item.ft_object._render_glyph( + item.glyph_index, load_flags, + RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) buffer = bitmap.buffer if not gc.get_antialiased(): buffer *= 0xff diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 7a3d4cfe9479..72aec2a437ab 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1933,16 +1933,6 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) FT_CHECK(FT_Render_Glyph, face->glyph, render_mode); return PyPositionedBitmap{face->glyph}; }) - .def("_render_glyphs", - [](PyFT2Font *self, double x, double y, FT_Render_Mode render_mode) { - auto origin = FT_Vector{std::lround(x * 64), std::lround(y * 64)}; - auto pbs = std::vector{}; - for (auto &g: self->get_glyphs()) { - FT_CHECK(FT_Glyph_To_Bitmap, &g, render_mode, &origin, 1); - pbs.emplace_back(reinterpret_cast(g)); - } - return pbs; - }) ; m.attr("__freetype_version__") = version_string; From 6ec8fc85ad778a5a472c4c6767f40a99edf7bef2 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 04:08:40 -0500 Subject: [PATCH 2/4] Deduplicate RendererAgg.draw_{mathtext,text} All the glyph drawing loop is the same, other than a flexible size in the former. --- lib/matplotlib/backends/backend_agg.py | 50 +++++++++++--------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 409348c1cb66..efdc84fa5fd8 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -172,14 +172,12 @@ def draw_path(self, gc, path, transform, rgbFace=None): raise OverflowError(msg) from None - def draw_mathtext(self, gc, x, y, s, prop, angle): - """Draw mathtext using :mod:`matplotlib.mathtext`.""" + def _draw_text_glyphs(self, gc, x, y, angle, glyphs): # y is downwards. - parse = self.mathtext_parser.parse( - s, self.dpi, prop, antialiased=gc.get_antialiased()) cos = math.cos(math.radians(angle)) sin = math.sin(math.radians(angle)) - for font, size, _char, glyph_index, dx, dy in parse.glyphs: # dy is upwards. + load_flags = get_hinting_flag() + for font, size, glyph_index, dx, dy in glyphs: # dy is upwards. font.set_size(size, self.dpi) hf = font._hinting_factor font._set_transform( @@ -190,9 +188,9 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): round(0x40 * (self.height - y + dx * sin + dy * cos))] ) bitmap = font._render_glyph( - glyph_index, get_hinting_flag(), + glyph_index, load_flags, RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) - buffer = np.asarray(bitmap.buffer) + buffer = bitmap.buffer if not gc.get_antialiased(): buffer *= 0xff # draw_text_image's y is downwards & the bitmap bottom side. @@ -200,6 +198,15 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): buffer, bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], 0, gc) + + def draw_mathtext(self, gc, x, y, s, prop, angle): + """Draw mathtext using :mod:`matplotlib.mathtext`.""" + parse = self.mathtext_parser.parse( + s, self.dpi, prop, antialiased=gc.get_antialiased()) + self._draw_text_glyphs( + gc, x, y, angle, + ((font, size, glyph_index, dx, dy) + for font, size, _char, glyph_index, dx, dy in parse.glyphs)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): rgba = rgba[:3] + (gc.get_alpha(),) @@ -229,32 +236,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) font = self._prepare_font(prop) - cos = math.cos(math.radians(angle)) - sin = math.sin(math.radians(angle)) - load_flags = get_hinting_flag() items = font._layout( - s, flags=load_flags, + s, flags=get_hinting_flag(), features=mtext.get_fontfeatures() if mtext is not None else None, language=mtext.get_language() if mtext is not None else None) - for item in items: - hf = item.ft_object._hinting_factor - item.ft_object._set_transform( - [[round(0x10000 * cos / hf), round(0x10000 * -sin)], - [round(0x10000 * sin / hf), round(0x10000 * cos)]], - [round(0x40 * (x + item.x * cos - item.y * sin)), - # FreeType's y is upwards. - round(0x40 * (self.height - y + item.x * sin + item.y * cos))] - ) - bitmap = item.ft_object._render_glyph( - item.glyph_index, load_flags, - RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) - buffer = bitmap.buffer - if not gc.get_antialiased(): - buffer *= 0xff - self._renderer.draw_text_image( - buffer, - bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], - 0, gc) + size = prop.get_size_in_points() + self._draw_text_glyphs( + gc, x, y, angle, + ((item.ft_object, size, item.glyph_index, item.x, item.y) + for item in items)) def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited From 9a185c6f40730ba8a2dccb133f2755b4c5aa7da6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 05:20:54 -0500 Subject: [PATCH 3/4] Deduplicate RendererAgg.draw_tex with draw_{mathtext,text} All the glyph drawing loop is the same, other than a slight transform difference with slant/extend in the former. --- lib/matplotlib/backends/backend_agg.py | 49 +++++++------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index efdc84fa5fd8..3cf7f071bf33 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -177,12 +177,13 @@ def _draw_text_glyphs(self, gc, x, y, angle, glyphs): cos = math.cos(math.radians(angle)) sin = math.sin(math.radians(angle)) load_flags = get_hinting_flag() - for font, size, glyph_index, dx, dy in glyphs: # dy is upwards. + for font, size, glyph_index, slant, extend, dx, dy in glyphs: # dy is upwards. font.set_size(size, self.dpi) hf = font._hinting_factor font._set_transform( - [[round(0x10000 * cos / hf), round(0x10000 * -sin)], - [round(0x10000 * sin / hf), round(0x10000 * cos)]], + (0x10000 * np.array([[cos, -sin], [sin, cos]]) + @ [[extend, extend * slant], [0, 1]] + @ [[1 / hf, 0], [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))] @@ -205,7 +206,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): s, self.dpi, prop, antialiased=gc.get_antialiased()) self._draw_text_glyphs( gc, x, y, angle, - ((font, size, glyph_index, dx, dy) + ((font, size, glyph_index, 0, 1, dx, dy) for font, size, _char, glyph_index, dx, dy in parse.glyphs)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): @@ -243,7 +244,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): size = prop.get_size_in_points() self._draw_text_glyphs( gc, x, y, angle, - ((item.ft_object, size, item.glyph_index, item.x, item.y) + ((item.ft_object, size, item.glyph_index, 0, 1, item.x, item.y) for item in items)) def get_text_width_height_descent(self, s, prop, ismath): @@ -287,38 +288,12 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): with Dvi(dvifile, self.dpi) as dvi: page, = dvi - cos = math.cos(math.radians(angle)) - sin = math.sin(math.radians(angle)) - - for text in page.text: - hf = mpl.rcParams["text.hinting_factor"] - # Resolving text.index will implicitly call get_font(), which - # resets the font transform, so it has to be done before explicitly - # setting the font transform below. - index = text.index - font = get_font(text.font_path) - font.set_size(text.font_size, self.dpi) - slant = text.font_effects.get("slant", 0) - extend = text.font_effects.get("extend", 1) - font._set_transform( - (0x10000 * np.array([[cos, -sin], [sin, cos]]) - @ [[extend, extend * slant], [0, 1]] - @ [[1 / hf, 0], [0, 1]]).round().astype(int), - [round(0x40 * (x + text.x * cos - text.y * sin)), - # FreeType's y is upwards. - round(0x40 * (self.height - y + text.x * sin + text.y * cos))] - ) - bitmap = font._render_glyph( - index, get_hinting_flag(), - RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) - buffer = np.asarray(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) + self._draw_text_glyphs( + gc, x, y, angle, + ((get_font(text.font_path), text.font_size, text.index, + text.font_effects.get('slant', 0), text.font_effects.get('extend', 1), + text.x, text.y) + for text in page.text)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): From 754bdab5f174513fa00afad716c47299c31960d0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 05:43:27 -0500 Subject: [PATCH 4/4] Also deduplicate box rendering in RendererAgg.draw_{mathtext,tex} --- lib/matplotlib/backends/backend_agg.py | 60 ++++++++------------------ 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 3cf7f071bf33..f0006a2d7dbe 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -172,7 +172,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): raise OverflowError(msg) from None - def _draw_text_glyphs(self, gc, x, y, angle, glyphs): + 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)) @@ -200,21 +200,13 @@ def _draw_text_glyphs(self, gc, x, y, angle, glyphs): bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], 0, gc) - def draw_mathtext(self, gc, x, y, s, prop, angle): - """Draw mathtext using :mod:`matplotlib.mathtext`.""" - parse = self.mathtext_parser.parse( - s, self.dpi, prop, antialiased=gc.get_antialiased()) - self._draw_text_glyphs( - gc, x, y, angle, - ((font, size, glyph_index, 0, 1, dx, dy) - for font, size, _char, glyph_index, dx, dy in parse.glyphs)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): rgba = rgba[:3] + (gc.get_alpha(),) gc1 = self.new_gc() gc1.set_linewidth(0) gc1.set_snap(gc.get_snap()) - for dx, dy, w, h in parse.rects: # dy is upwards. + for dx, dy, w, h in boxes: # dy is upwards. if gc1.get_snap() in [None, True]: # Prevent thin bars from disappearing by growing symmetrically. if w < 1: @@ -232,6 +224,16 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): rgba) gc1.restore() + def draw_mathtext(self, gc, x, y, s, prop, angle): + """Draw mathtext using :mod:`matplotlib.mathtext`.""" + parse = self.mathtext_parser.parse( + s, self.dpi, prop, antialiased=gc.get_antialiased()) + self._draw_text_glyphs_and_boxes( + gc, x, y, angle, + ((font, size, glyph_index, 0, 1, dx, dy) + for font, size, _char, glyph_index, dx, dy in parse.glyphs), + parse.rects) + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited if ismath: @@ -242,10 +244,11 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): features=mtext.get_fontfeatures() if mtext is not None else None, language=mtext.get_language() if mtext is not None else None) size = prop.get_size_in_points() - self._draw_text_glyphs( + self._draw_text_glyphs_and_boxes( gc, x, y, angle, ((item.ft_object, size, item.glyph_index, 0, 1, item.x, item.y) - for item in items)) + for item in items), + []) def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited @@ -288,40 +291,13 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): with Dvi(dvifile, self.dpi) as dvi: page, = dvi - self._draw_text_glyphs( + self._draw_text_glyphs_and_boxes( gc, x, y, angle, ((get_font(text.font_path), text.font_size, text.index, text.font_effects.get('slant', 0), text.font_effects.get('extend', 1), text.x, text.y) - for text in page.text)) - - rgba = gc.get_rgb() - if len(rgba) == 3 or gc.get_forced_alpha(): - rgba = rgba[:3] + (gc.get_alpha(),) - gc1 = self.new_gc() - gc1.set_linewidth(0) - gc1.set_snap(gc.get_snap()) - for box in page.boxes: - bx = box.x - by = box.y - bw = box.width - bh = box.height - if gc1.get_snap() in [None, True]: - # Prevent thin bars from disappearing by growing symmetrically. - if bw < 1: - bx -= (1 - bw) / 2 - bw = 1 - if bh < 1: - by -= (1 - bh) / 2 - bh = 1 - path = Path._create_closed([ - (bx, by), (bx + bw, by), (bx + bw, by + bh), (bx, by + bh)]) - self._renderer.draw_path( - gc1, path, - mpl.transforms.Affine2D() - .rotate_deg(angle).translate(x, self.height - y), - rgba) - gc1.restore() + for text in page.text), + ((box.x, box.y, box.width, box.height) for box in page.boxes)) def get_canvas_width_height(self): # docstring inherited