From 66fdae0d34aac9eaa6fdf5f80aca4d40cab251dd 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 cb38e337157a..e52ac94dcd62 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -144,7 +144,9 @@ class FT2Font FT2Image 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 18f26ad4e76b..e6f3f7d57323 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1764,7 +1764,59 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) std::vector shape { im.get_height(), im.get_width() }; std::vector strides { im.get_width(), 1 }; return py::buffer_info(im.get_buffer(), shape, strides); - }); + }) + + // 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;