From 9d7d7b42ab5f241499f69cadab99e2cd506173c9 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 | 110 ++++++++++++++---------- lib/matplotlib/font_manager.py | 6 +- lib/matplotlib/ft2font.pyi | 8 ++ lib/matplotlib/tests/test_axes.py | 2 +- lib/matplotlib/text.py | 1 + src/ft2font.cpp | 11 +++ src/ft2font.h | 7 ++ src/ft2font_wrapper.cpp | 112 ++++++++++++++++++++++++- 8 files changed, 212 insertions(+), 45 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 43d40d1c0c68..e5dd81a1110c 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -22,7 +22,7 @@ """ from contextlib import nullcontext -from math import radians, cos, sin +import math import numpy as np from PIL import features @@ -32,7 +32,7 @@ from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.font_manager import fontManager as _fontManager, get_font -from matplotlib.ft2font import LoadFlags +from matplotlib.ft2font import LoadFlags, RenderMode from matplotlib.mathtext import MathTextParser from matplotlib.path import Path from matplotlib.transforms import Bbox, BboxBase @@ -71,7 +71,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) @@ -173,48 +173,75 @@ 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()) + 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. + 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)]], + [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, 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) + 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 & the rect top side. + if gc1.get_snap() in [None, True]: + # Prevent thin bars from disappearing by growing symmetrically. + if w < 1: + dx -= (1 - w) / 2 + w = 1 + if h < 1: + dy -= (1 - h) / 2 + h = 1 + 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.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) - 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 - - rad = radians(angle) - xd = d * sin(rad) - yd = d * cos(rad) - # Rotating the offset vector ensures text rotates around the anchor point. - # Without this, rotated text offsets incorrectly, causing a horizontal shift. - # Applying the 2D rotation matrix. - rotated_xo = xo * cos(rad) - yo * sin(rad) - rotated_yo = xo * sin(rad) + yo * cos(rad) - # Subtract rotated_yo to account for the inverted y-axis in computer graphics, - # compared to the mathematical convention. - x = round(x + rotated_xo + xd) - y = round(y - rotated_yo + yd) - - self._renderer.draw_text_image(font, x, y + 1, angle, gc) + for bitmap in font._render_glyphs( + x, self.height - y, + 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) def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited @@ -224,9 +251,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()) @@ -248,8 +274,8 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): Z = np.array(Z * 255.0, np.uint8) w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX") - xd = d * sin(radians(angle)) - yd = d * cos(radians(angle)) + xd = d * math.sin(math.radians(angle)) + yd = d * math.cos(math.radians(angle)) x = round(x + xd) y = round(y + yd) self._renderer.draw_text_image(Z, x, y, angle, gc) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index d789c2eb425c..e7bef5f29f46 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1712,7 +1712,7 @@ def get_font(font_filepaths, hinting_factor=None): hinting_factor = mpl._val_or_rc(hinting_factor, 'text.hinting_factor') - return _get_font( + font = _get_font( # must be a tuple to be cached paths, hinting_factor, @@ -1721,6 +1721,10 @@ def get_font(font_filepaths, hinting_factor=None): thread_id=threading.get_ident(), enable_last_resort=mpl.rcParams['font.enable_last_resort'], ) + # Ensure the transform is always consistent. + font._set_transform([[round(0x10000 / font._hinting_factor), 0], [0, 0x10000]], + [0, 0]) + return font def _load_fontmanager(*, try_read_cache=True): diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 71bd89f81561..c8cd14bbe042 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -70,6 +70,14 @@ class LoadFlags(Flag): TARGET_LCD = cast(int, ...) TARGET_LCD_V = cast(int, ...) +class RenderMode(Enum): + NORMAL = cast(int, ...) + LIGHT = cast(int, ...) + MONO = cast(int, ...) + LCD = cast(int, ...) + LCD_V = cast(int, ...) + SDF = cast(int, ...) + class StyleFlags(Flag): NORMAL = cast(int, ...) ITALIC = cast(int, ...) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 0c87cbc8649b..d767a3604dee 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6446,7 +6446,7 @@ def test_pie_linewidth_0(): plt.axis('equal') -@image_comparison(['pie_center_radius.png'], style='mpl20', tol=0.01) +@image_comparison(['pie_center_radius.png'], style='mpl20', tol=0.011) def test_pie_center_radius(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 53e05a44ea69..905054261cb0 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -871,6 +871,7 @@ def draw(self, renderer): gc.set_alpha(self.get_alpha()) gc.set_url(self._url) gc.set_antialiased(self._antialiased) + gc.set_snap(self.get_snap()) self._set_gc_clip(gc) angle = self.get_rotation() diff --git a/src/ft2font.cpp b/src/ft2font.cpp index b70f3a29d469..dc9397dd75f0 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -283,6 +283,17 @@ void FT2Font::set_size(double ptsize, double dpi) } } +void FT2Font::_set_transform( + std::array, 2> matrix, std::array delta) +{ + FT_Matrix m = {matrix[0][0], matrix[0][1], matrix[1][0], matrix[1][1]}; + FT_Vector d = {delta[0], delta[1]}; + FT_Set_Transform(face, &m, &d); + for (auto & fallback : fallbacks) { + fallback->_set_transform(matrix, delta); + } +} + void FT2Font::set_charmap(int i) { if (i >= face->num_charmaps) { diff --git a/src/ft2font.h b/src/ft2font.h index 68d31bac9a41..3facec0fb244 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -19,6 +19,7 @@ extern "C" { #include +#include FT_BITMAP_H #include FT_FREETYPE_H #include FT_GLYPH_H #include FT_OUTLINE_H @@ -111,6 +112,8 @@ class FT2Font void close(); void clear(); void set_size(double ptsize, double dpi); + void _set_transform( + std::array, 2> matrix, std::array delta); void set_charmap(int i); void select_charmap(unsigned long i); std::vector layout(std::u32string_view text, FT_Int32 flags, @@ -155,6 +158,10 @@ class FT2Font { return image; } + std::vector &get_glyphs() + { + return glyphs; + } FT_Glyph const &get_last_glyph() const { return glyphs.back(); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index d5cf07e7762d..084feb299a63 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -204,6 +204,25 @@ P11X_DECLARE_ENUM( {"TARGET_LCD_V", LoadFlags::TARGET_LCD_V}, ); +const char *RenderMode__doc__ = R"""( + Render modes. + + For more information, see `the FreeType documentation + `_. + + .. versionadded:: 3.10 +)"""; + +P11X_DECLARE_ENUM( + "RenderMode", "Enum", + {"NORMAL", FT_RENDER_MODE_NORMAL}, + {"LIGHT", FT_RENDER_MODE_LIGHT}, + {"MONO", FT_RENDER_MODE_MONO}, + {"LCD", FT_RENDER_MODE_LCD}, + {"LCD_V", FT_RENDER_MODE_LCD_V}, + {"SDF", FT_RENDER_MODE_SDF}, +); + const char *StyleFlags__doc__ = R"""( Flags returned by `FT2Font.style_flags`. @@ -265,6 +284,45 @@ PyFT2Image_draw_rect_filled(FT2Image *self, self->draw_rect_filled(x0, y0, x1, y1); } +/********************************************************************** + * Positioned Bitmap; owns the FT_Bitmap! + * */ + +struct PyPositionedBitmap { + FT_Int left, top; + bool owning; + FT_Bitmap bitmap; + + PyPositionedBitmap(FT_GlyphSlot slot) : + left{slot->bitmap_left}, top{slot->bitmap_top}, owning{true} + { + FT_Bitmap_Init(&bitmap); + FT_CHECK(FT_Bitmap_Convert, _ft2Library, &slot->bitmap, &bitmap, 1); + } + + PyPositionedBitmap(FT_BitmapGlyph bg) : + left{bg->left}, top{bg->top}, owning{true} + { + FT_Bitmap_Init(&bitmap); + FT_CHECK(FT_Bitmap_Convert, _ft2Library, &bg->bitmap, &bitmap, 1); + } + + PyPositionedBitmap(PyPositionedBitmap& other) = delete; // Non-copyable. + + PyPositionedBitmap(PyPositionedBitmap&& other) : + left{other.left}, top{other.top}, owning{true}, bitmap{other.bitmap} + { + other.owning = false; // Prevent double deletion. + } + + ~PyPositionedBitmap() + { + if (owning) { + FT_Bitmap_Done(_ft2Library, &bitmap); + } + } +}; + /********************************************************************** * Glyph * */ @@ -545,6 +603,22 @@ const char *PyFT2Font_set_size__doc__ = R"""( The DPI used for rendering the text. )"""; +const char *PyFT2Font__set_transform__doc__ = R"""( + Set the transform of the text. + + This is a low-level function, where *matrix* and *delta* are directly in + 16.16 and 26.6 formats respectively. Refer to the FreeType docs of + FT_Set_Transform for further description. + + Note, every call to `.font_manager.get_font` will reset the transform to the default + to ensure consistency across cache accesses. + + Parameters + ---------- + matrix : (2, 2) array of int + delta : (2,) array of int +)"""; + const char *PyFT2Font_set_charmap__doc__ = R"""( Make the i-th charmap current. @@ -1565,6 +1639,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) p11x::bind_enums(m); p11x::enums["Kerning"].attr("__doc__") = Kerning__doc__; p11x::enums["LoadFlags"].attr("__doc__") = LoadFlags__doc__; + p11x::enums["RenderMode"].attr("__doc__") = RenderMode__doc__; p11x::enums["FaceFlags"].attr("__doc__") = FaceFlags__doc__; p11x::enums["StyleFlags"].attr("__doc__") = StyleFlags__doc__; @@ -1591,6 +1666,17 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) return py::buffer_info(self.get_buffer(), shape, strides); }); + py::class_(m, "_PositionedBitmap", py::is_final()) + .def_readonly("left", &PyPositionedBitmap::left) + .def_readonly("top", &PyPositionedBitmap::top) + .def_property_readonly( + "buffer", [](PyPositionedBitmap &self) -> py::array { + return {{self.bitmap.rows, self.bitmap.width}, + {self.bitmap.pitch, 1}, + self.bitmap.buffer}; + }) + ; + py::class_(m, "Glyph", py::is_final(), PyGlyph__doc__) .def(py::init<>([]() -> PyGlyph { // Glyph is not useful from Python, so mark it as not constructible. @@ -1651,6 +1737,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("clear", &PyFT2Font::clear, PyFT2Font_clear__doc__) .def("set_size", &PyFT2Font::set_size, "ptsize"_a, "dpi"_a, PyFT2Font_set_size__doc__) + .def("_set_transform", &PyFT2Font::_set_transform, "matrix"_a, "delta"_a, + PyFT2Font__set_transform__doc__) .def("set_charmap", &PyFT2Font::set_charmap, "i"_a, PyFT2Font_set_charmap__doc__) .def("select_charmap", &PyFT2Font::select_charmap, "i"_a, @@ -1813,10 +1901,32 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def_property_readonly( "fname", &PyFT2Font_fname, "The original filename for this object.") + .def_property_readonly( + "_hinting_factor", &PyFT2Font::get_hinting_factor, + "The hinting factor.") .def_buffer([](PyFT2Font &self) -> py::buffer_info { return self.get_image().request(); - }); + }) + + .def("_render_glyph", + [](PyFT2Font *self, FT_UInt idx, LoadFlags flags, FT_Render_Mode render_mode) { + auto face = self->get_face(); + FT_CHECK(FT_Load_Glyph, face, idx, static_cast(flags)); + 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; m.attr("__freetype_build_type__") = FREETYPE_BUILD_TYPE;