From e980d97c622febcd60d042ab2003969b962aba22 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 16 May 2025 08:29:24 +0200 Subject: [PATCH] Drop the FT2Font intermediate buffer. Directly render FT glyphs to the Agg buffer. In particular, this naturally provides, with no extra work, subpixel positioning of glyphs (which could also have been implemented in the old framework, but would have required careful tracking of subpixel offets). Note that all baseline images should be regenerated. The new APIs added to FT2Font are also up to bikeshedding (but they are all private). --- lib/matplotlib/backends/backend_agg.py | 77 ++++++++++++++++---------- src/ft2font.h | 2 + src/ft2font_wrapper.cpp | 54 +++++++++++++++++- 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index b435ae565ce4..0653a875e2a8 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -70,7 +70,7 @@ def __init__(self, width, height, dpi): self._filter_renderers = [] self._update_methods() - self.mathtext_parser = MathTextParser('agg') + self.mathtext_parser = MathTextParser('path') self.bbox = Bbox.from_bounds(0, 0, self.width, self.height) @@ -172,36 +172,58 @@ def draw_path(self, gc, path, transform, rgbFace=None): def draw_mathtext(self, gc, x, y, s, prop, angle): """Draw mathtext using :mod:`matplotlib.mathtext`.""" - ox, oy, width, height, descent, font_image = \ - self.mathtext_parser.parse(s, self.dpi, prop, - antialiased=gc.get_antialiased()) - - xd = descent * sin(radians(angle)) - yd = descent * cos(radians(angle)) - x = round(x + ox + xd) - y = round(y - oy + yd) - self._renderer.draw_text_image(font_image, x, y + 1, angle, gc) + # y is downwards. + parse = self.mathtext_parser.parse( + s, self.dpi, prop, antialiased=gc.get_antialiased()) + c = cos(radians(angle)) + s = sin(radians(angle)) + for font, size, char, dx, dy in parse.glyphs: # dy is upwards. + font.set_size(size, self.dpi) + bitmap = font._render_glyph( + font.get_char_index(char), + # The "y" parameter is upwards (per FreeType). + x + dx * c - dy * s, self.height - y + dx * s + dy * c, angle, + get_hinting_flag()) + # draw_text_image's y is downwards & the bitmap bottom side. + self._renderer.draw_text_image( + bitmap["buffer"], + bitmap["left"], + int(self.height) - bitmap["top"] + bitmap["buffer"].shape[0], + 0, gc) + if not angle: + for dx, dy, w, h in parse.rects: # dy is upwards & the rect top side. + self._renderer.draw_text_image( + np.full((round(h), round(w)), np.uint8(0xff)), + round(x + dx), round(y - dy - h), + 0, gc) + else: + 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) + for dx, dy, w, h in parse.rects: # dy is upwards & the rect top side. + path = Path._create_closed( + [(dx, dy), (dx + w, dy), (dx + w, dy + h), (dx, dy + h)]) + self._renderer.draw_path( + gc1, path, + mpl.transforms.Affine2D() + .rotate_deg(angle).translate(x, self.height - y), + rgba) + gc1.restore() def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) font = self._prepare_font(prop) - # We pass '0' for angle here, since it will be rotated (in raster - # space) in the following call to draw_text_image). - font.set_text(s, 0, flags=get_hinting_flag()) - font.draw_glyphs_to_bitmap( - antialiased=gc.get_antialiased()) - d = font.get_descent() / 64.0 - # The descent needs to be adjusted for the angle. - xo, yo = font.get_bitmap_offset() - xo /= 64.0 - yo /= 64.0 - xd = d * sin(radians(angle)) - yd = d * cos(radians(angle)) - x = round(x + xo + xd) - y = round(y + yo + yd) - self._renderer.draw_text_image(font, x, y + 1, angle, gc) + font.set_text(s, angle, flags=get_hinting_flag()) + for bitmap in font._render_glyphs(x, self.height - y): + self._renderer.draw_text_image( + bitmap["buffer"], + bitmap["left"], + int(self.height) - bitmap["top"] + bitmap["buffer"].shape[0], + 0, gc) def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited @@ -211,9 +233,8 @@ def get_text_width_height_descent(self, s, prop, ismath): return super().get_text_width_height_descent(s, prop, ismath) if ismath: - ox, oy, width, height, descent, font_image = \ - self.mathtext_parser.parse(s, self.dpi, prop) - return width, height, descent + parse = self.mathtext_parser.parse(s, self.dpi, prop) + return parse.width, parse.height, parse.depth font = self._prepare_font(prop) font.set_text(s, 0.0, flags=get_hinting_flag()) diff --git a/src/ft2font.h b/src/ft2font.h index 209581d8f362..b34f5fb7bbc9 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -149,7 +149,9 @@ class FT2Font py::array_t image; FT_Face face; FT_Vector pen; /* untransformed origin */ + public: std::vector glyphs; + private: std::vector fallbacks; std::unordered_map glyph_to_font; std::unordered_map char_to_font; diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index ca2db6aa0e5b..2c98a829a33f 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1772,7 +1772,59 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def_buffer([](PyFT2Font &self) -> py::buffer_info { return self.x->get_image().request(); - }); + }) + + // TODO: Return a nicer structure than dicts. + // NOTE: The lifetime of the buffers is limited and could get invalidated... + // TODO: Real antialiasing flag. + // TODO: throw_ft_error. + // x, y are upwards here + .def("_render_glyph", [](PyFT2Font *self, FT_UInt idx, + double x, double y, double angle, + LoadFlags flags) { + auto face = self->x->get_face(); + auto hf = self->x->get_hinting_factor(); + auto c = std::cos(angle * M_PI / 180) * 0x10000L, + s = std::sin(angle * M_PI / 180) * 0x10000L; + auto matrix = FT_Matrix{ + std::lround(c / hf), std::lround(-s), std::lround(s / hf), std::lround(c)}; + auto delta = FT_Vector{std::lround(x * 64), std::lround(y * 64)}; + FT_Set_Transform(face, &matrix, &delta); + if (auto error = FT_Load_Glyph(face, idx, static_cast(flags))) { + throw std::runtime_error("Could not load glyph"); + } + if (auto error = FT_Render_Glyph(face->glyph, FT_RENDER_MODE_NORMAL)) { + throw std::runtime_error("Could not convert glyph to bitmap"); + } + py::dict d; + d["left"] = face->glyph->bitmap_left; + d["top"] = face->glyph->bitmap_top; + d["buffer"] = py::array_t{ + {face->glyph->bitmap.rows, face->glyph->bitmap.width}, + {face->glyph->bitmap.pitch, 1}, + face->glyph->bitmap.buffer}; + return d; + }) + .def("_render_glyphs", [](PyFT2Font *self, double x, double y) { + auto origin = FT_Vector{std::lround(x * 64), std::lround(y * 64)}; + py::list gs; + for (auto &g: self->x->glyphs) { + if (auto error = FT_Glyph_To_Bitmap(&g, FT_RENDER_MODE_NORMAL, &origin, 1)) { + throw std::runtime_error("Could not convert glyph to bitmap"); + } + auto bg = reinterpret_cast(g); + py::dict d; + d["left"] = bg->left; + d["top"] = bg->top; + d["buffer"] = py::array_t{ + {bg->bitmap.rows, bg->bitmap.width}, + {bg->bitmap.pitch, 1}, + bg->bitmap.buffer}; + gs.append(d); + } + return gs; + }) + ; m.attr("__freetype_version__") = version_string; m.attr("__freetype_build_type__") = FREETYPE_BUILD_TYPE;