From 67b4202058dd8e76d7376765a054ae1d89cb27b4 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 12 May 2025 20:08:56 +0200 Subject: [PATCH 01/16] Replace FT2Image by plain numpy arrays. --- .../deprecations/30044-AL.rst | 12 ++++ lib/matplotlib/_mathtext.py | 14 +++-- lib/matplotlib/ft2font.pyi | 2 +- lib/matplotlib/tests/test_ft2font.py | 5 +- src/ft2font.cpp | 59 ++++++------------- src/ft2font.h | 13 ++-- src/ft2font_wrapper.cpp | 46 +++++++++------ 7 files changed, 78 insertions(+), 73 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30044-AL.rst diff --git a/doc/api/next_api_changes/deprecations/30044-AL.rst b/doc/api/next_api_changes/deprecations/30044-AL.rst new file mode 100644 index 000000000000..e004d5f2730f --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30044-AL.rst @@ -0,0 +1,12 @@ +``FT2Image`` +~~~~~~~~~~~~ +... is deprecated. Use 2D uint8 ndarrays instead. In particular: + +- The ``FT2Image`` constructor took ``width, height`` as separate parameters + but the ndarray constructor takes ``(height, width)`` as single tuple + parameter. +- `.FT2Font.draw_glyph_to_bitmap` now (also) takes 2D uint8 arrays as input. +- ``FT2Image.draw_rect_filled`` should be replaced by directly setting pixel + values to black. +- The ``image`` attribute of the object returned by ``MathTextParser("agg").parse`` + is now a 2D uint8 array. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 3739a517978b..a528a65ca3cb 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -9,6 +9,7 @@ import enum import functools import logging +import math import os import re import types @@ -19,6 +20,7 @@ from typing import NamedTuple import numpy as np +from numpy.typing import NDArray from pyparsing import ( Empty, Forward, Literal, Group, NotAny, OneOrMore, Optional, ParseBaseException, ParseException, ParseExpression, ParseFatalException, @@ -30,7 +32,7 @@ from ._mathtext_data import ( latex_to_bakoma, stix_glyph_fixes, stix_virtual_fonts, tex2uni) from .font_manager import FontProperties, findfont, get_font -from .ft2font import FT2Font, FT2Image, Kerning, LoadFlags +from .ft2font import FT2Font, Kerning, LoadFlags if T.TYPE_CHECKING: @@ -99,7 +101,7 @@ class RasterParse(NamedTuple): The offsets are always zero. width, height, depth : float The global metrics. - image : FT2Image + image : 2D array of uint8 A raster image. """ ox: float @@ -107,7 +109,7 @@ class RasterParse(NamedTuple): width: float height: float depth: float - image: FT2Image + image: NDArray[np.uint8] RasterParse.__module__ = "matplotlib.mathtext" @@ -148,7 +150,7 @@ def to_raster(self, *, antialiased: bool) -> RasterParse: w = xmax - xmin h = ymax - ymin - self.box.depth d = ymax - ymin - self.box.height - image = FT2Image(int(np.ceil(w)), int(np.ceil(h + max(d, 0)))) + image = np.zeros((math.ceil(h + max(d, 0)), math.ceil(w)), np.uint8) # Ideally, we could just use self.glyphs and self.rects here, shifting # their coordinates by (-xmin, -ymin), but this yields slightly @@ -167,7 +169,9 @@ def to_raster(self, *, antialiased: bool) -> RasterParse: y = int(center - (height + 1) / 2) else: y = int(y1) - image.draw_rect_filled(int(x1), y, int(np.ceil(x2)), y + height) + x1 = math.floor(x1) + x2 = math.ceil(x2) + image[y:y+height+1, x1:x2+1] = 0xff return RasterParse(0, 0, w, h + d, d, image) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index b12710afd801..a413cd3c1a76 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -198,7 +198,7 @@ class FT2Font(Buffer): def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( - self, image: FT2Image, x: int, y: int, glyph: Glyph, antialiased: bool = ... + self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, antialiased: bool = ... ) -> None: ... def draw_glyphs_to_bitmap(self, antialiased: bool = ...) -> None: ... def get_bitmap_offset(self) -> tuple[int, int]: ... diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index a9f2a56658aa..8b448e17b7fd 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -18,7 +18,8 @@ def test_ft2image_draw_rect_filled(): width = 23 height = 42 for x0, y0, x1, y1 in itertools.product([1, 100], [2, 200], [4, 400], [8, 800]): - im = ft2font.FT2Image(width, height) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + im = ft2font.FT2Image(width, height) im.draw_rect_filled(x0, y0, x1, y1) a = np.asarray(im) assert a.dtype == np.uint8 @@ -823,7 +824,7 @@ def test_ft2font_drawing(): np.testing.assert_array_equal(image, expected) font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) glyph = font.load_char(ord('M')) - image = ft2font.FT2Image(expected.shape[1], expected.shape[0]) + image = np.zeros(expected.shape, np.uint8) font.draw_glyph_to_bitmap(image, -1, 1, glyph, antialiased=False) np.testing.assert_array_equal(image, expected) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 94c554cf9f63..b2c2c0fa9bd1 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -63,51 +63,23 @@ void throw_ft_error(std::string message, FT_Error error) { throw std::runtime_error(os.str()); } -FT2Image::FT2Image() : m_buffer(nullptr), m_width(0), m_height(0) -{ -} - FT2Image::FT2Image(unsigned long width, unsigned long height) - : m_buffer(nullptr), m_width(0), m_height(0) + : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height) { - resize(width, height); } FT2Image::~FT2Image() { - delete[] m_buffer; + free(m_buffer); } -void FT2Image::resize(long width, long height) +void draw_bitmap( + py::array_t im, FT_Bitmap *bitmap, FT_Int x, FT_Int y) { - if (width <= 0) { - width = 1; - } - if (height <= 0) { - height = 1; - } - size_t numBytes = width * height; - - if ((unsigned long)width != m_width || (unsigned long)height != m_height) { - if (numBytes > m_width * m_height) { - delete[] m_buffer; - m_buffer = nullptr; - m_buffer = new unsigned char[numBytes]; - } + auto buf = im.mutable_data(0); - m_width = (unsigned long)width; - m_height = (unsigned long)height; - } - - if (numBytes && m_buffer) { - memset(m_buffer, 0, numBytes); - } -} - -void FT2Image::draw_bitmap(FT_Bitmap *bitmap, FT_Int x, FT_Int y) -{ - FT_Int image_width = (FT_Int)m_width; - FT_Int image_height = (FT_Int)m_height; + FT_Int image_width = (FT_Int)im.shape(1); + FT_Int image_height = (FT_Int)im.shape(0); FT_Int char_width = bitmap->width; FT_Int char_height = bitmap->rows; @@ -121,14 +93,14 @@ void FT2Image::draw_bitmap(FT_Bitmap *bitmap, FT_Int x, FT_Int y) if (bitmap->pixel_mode == FT_PIXEL_MODE_GRAY) { for (FT_Int i = y1; i < y2; ++i) { - unsigned char *dst = m_buffer + (i * image_width + x1); + unsigned char *dst = buf + (i * image_width + x1); unsigned char *src = bitmap->buffer + (((i - y_offset) * bitmap->pitch) + x_start); for (FT_Int j = x1; j < x2; ++j, ++dst, ++src) *dst |= *src; } } else if (bitmap->pixel_mode == FT_PIXEL_MODE_MONO) { for (FT_Int i = y1; i < y2; ++i) { - unsigned char *dst = m_buffer + (i * image_width + x1); + unsigned char *dst = buf + (i * image_width + x1); unsigned char *src = bitmap->buffer + ((i - y_offset) * bitmap->pitch); for (FT_Int j = x1; j < x2; ++j, ++dst) { int x = (j - x1 + x_start); @@ -259,7 +231,7 @@ FT2Font::FT2Font(FT_Open_Args &open_args, long hinting_factor_, std::vector &fallback_list, FT2Font::WarnFunc warn, bool warn_if_used) - : ft_glyph_warn(warn), warn_if_used(warn_if_used), image(), face(nullptr), + : ft_glyph_warn(warn), warn_if_used(warn_if_used), image({1, 1}), face(nullptr), hinting_factor(hinting_factor_), // set default kerning factor to 0, i.e., no kerning manipulation kerning_factor(0) @@ -676,7 +648,8 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased) long width = (bbox.xMax - bbox.xMin) / 64 + 2; long height = (bbox.yMax - bbox.yMin) / 64 + 2; - image.resize(width, height); + image = py::array_t{{height, width}}; + std::memset(image.mutable_data(0), 0, image.nbytes()); for (auto & glyph : glyphs) { FT_Error error = FT_Glyph_To_Bitmap( @@ -692,11 +665,13 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased) FT_Int x = (FT_Int)(bitmap->left - (bbox.xMin * (1. / 64.))); FT_Int y = (FT_Int)((bbox.yMax * (1. / 64.)) - bitmap->top + 1); - image.draw_bitmap(&bitmap->bitmap, x, y); + draw_bitmap(image, &bitmap->bitmap, x, y); } } -void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, bool antialiased) +void FT2Font::draw_glyph_to_bitmap( + py::array_t im, + int x, int y, size_t glyphInd, bool antialiased) { FT_Vector sub_offset; sub_offset.x = 0; // int((xd - (double)x) * 64.0); @@ -718,7 +693,7 @@ void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyphs[glyphInd]; - im.draw_bitmap(&bitmap->bitmap, x + bitmap->left, y); + draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y); } void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer, diff --git a/src/ft2font.h b/src/ft2font.h index cb38e337157a..209581d8f362 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -22,6 +22,10 @@ extern "C" { #include FT_TRUETYPE_TABLES_H } +#include +#include +namespace py = pybind11; + /* By definition, FT_FIXED as 2 16bit values stored in a single long. */ @@ -32,7 +36,6 @@ extern "C" { class FT2Image { public: - FT2Image(); FT2Image(unsigned long width, unsigned long height); virtual ~FT2Image(); @@ -101,7 +104,9 @@ class FT2Font void get_bitmap_offset(long *x, long *y); long get_descent(); void draw_glyphs_to_bitmap(bool antialiased); - void draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, bool antialiased); + void draw_glyph_to_bitmap( + py::array_t im, + int x, int y, size_t glyphInd, bool antialiased); void get_glyph_name(unsigned int glyph_number, std::string &buffer, bool fallback); long get_name_index(char *name); FT_UInt get_char_index(FT_ULong charcode, bool fallback); @@ -113,7 +118,7 @@ class FT2Font return face; } - FT2Image &get_image() + py::array_t &get_image() { return image; } @@ -141,7 +146,7 @@ class FT2Font private: WarnFunc ft_glyph_warn; bool warn_if_used; - FT2Image image; + py::array_t image; FT_Face face; FT_Vector pen; /* untransformed origin */ std::vector glyphs; diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 18f26ad4e76b..ca2db6aa0e5b 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -968,7 +968,7 @@ const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = R"""( Parameters ---------- - image : FT2Image + image : 2d array of uint8 The image buffer on which to draw the glyph. x, y : int The pixel location at which to draw the glyph. @@ -983,14 +983,16 @@ const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = R"""( )"""; static void -PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, FT2Image &image, +PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, py::buffer &image, double_or_ vxd, double_or_ vyd, PyGlyph *glyph, bool antialiased = true) { auto xd = _double_to_("x", vxd); auto yd = _double_to_("y", vyd); - self->x->draw_glyph_to_bitmap(image, xd, yd, glyph->glyphInd, antialiased); + self->x->draw_glyph_to_bitmap( + py::array_t{image}, + xd, yd, glyph->glyphInd, antialiased); } const char *PyFT2Font_get_glyph_name__doc__ = R"""( @@ -1440,12 +1442,7 @@ const char *PyFT2Font_get_image__doc__ = R"""( static py::array PyFT2Font_get_image(PyFT2Font *self) { - FT2Image &im = self->x->get_image(); - py::ssize_t dims[] = { - static_cast(im.get_height()), - static_cast(im.get_width()) - }; - return py::array_t(dims, im.get_buffer()); + return self->x->get_image(); } const char *PyFT2Font__get_type1_encoding_vector__doc__ = R"""( @@ -1565,6 +1562,10 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Image__doc__) .def(py::init( [](double_or_ width, double_or_ height) { + auto warn = + py::module_::import("matplotlib._api").attr("warn_deprecated"); + warn("since"_a="3.11", "name"_a="FT2Image", "obj_type"_a="class", + "alternative"_a="a 2D uint8 ndarray"); return new FT2Image( _double_to_("width", width), _double_to_("height", height) @@ -1604,8 +1605,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def_property_readonly("bbox", &PyGlyph_get_bbox, "The control box of the glyph."); - py::class_(m, "FT2Font", py::is_final(), py::buffer_protocol(), - PyFT2Font__doc__) + auto cls = py::class_(m, "FT2Font", py::is_final(), py::buffer_protocol(), + PyFT2Font__doc__) .def(py::init(&PyFT2Font_init), "filename"_a, "hinting_factor"_a=8, py::kw_only(), "_fallback_list"_a=py::none(), "_kerning_factor"_a=0, @@ -1639,10 +1640,20 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_descent", &PyFT2Font_get_descent, PyFT2Font_get_descent__doc__) .def("draw_glyphs_to_bitmap", &PyFT2Font_draw_glyphs_to_bitmap, py::kw_only(), "antialiased"_a=true, - PyFT2Font_draw_glyphs_to_bitmap__doc__) - .def("draw_glyph_to_bitmap", &PyFT2Font_draw_glyph_to_bitmap, - "image"_a, "x"_a, "y"_a, "glyph"_a, py::kw_only(), "antialiased"_a=true, - PyFT2Font_draw_glyph_to_bitmap__doc__) + PyFT2Font_draw_glyphs_to_bitmap__doc__); + // The generated docstring uses an unqualified "Buffer" as type hint, + // which causes an error in sphinx. This is fixed as of pybind11 + // master (since #5566) which now uses "collections.abc.Buffer"; + // restore the signature once that version is released. + { + py::options options{}; + options.disable_function_signatures(); + cls + .def("draw_glyph_to_bitmap", &PyFT2Font_draw_glyph_to_bitmap, + "image"_a, "x"_a, "y"_a, "glyph"_a, py::kw_only(), "antialiased"_a=true, + PyFT2Font_draw_glyph_to_bitmap__doc__); + } + cls .def("get_glyph_name", &PyFT2Font_get_glyph_name, "index"_a, PyFT2Font_get_glyph_name__doc__) .def("get_charmap", &PyFT2Font_get_charmap, PyFT2Font_get_charmap__doc__) @@ -1760,10 +1771,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "The original filename for this object.") .def_buffer([](PyFT2Font &self) -> py::buffer_info { - FT2Image &im = self.x->get_image(); - 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); + return self.x->get_image().request(); }); m.attr("__freetype_version__") = version_string; From ead211025d96599bf058e2c5d27971b0013e1980 Mon Sep 17 00:00:00 2001 From: Oscar Gustafsson Date: Sun, 18 May 2025 15:36:07 +0200 Subject: [PATCH 02/16] Deprecate point_at_t and document that a BezierSegment can be called --- doc/api/bezier_api.rst | 1 + doc/api/next_api_changes/deprecations/30070-OG.rst | 4 ++++ lib/matplotlib/bezier.py | 8 ++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30070-OG.rst diff --git a/doc/api/bezier_api.rst b/doc/api/bezier_api.rst index b3764ad04b5a..45019153fa63 100644 --- a/doc/api/bezier_api.rst +++ b/doc/api/bezier_api.rst @@ -5,4 +5,5 @@ .. automodule:: matplotlib.bezier :members: :undoc-members: + :special-members: __call__ :show-inheritance: diff --git a/doc/api/next_api_changes/deprecations/30070-OG.rst b/doc/api/next_api_changes/deprecations/30070-OG.rst new file mode 100644 index 000000000000..98786bcfa1d2 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30070-OG.rst @@ -0,0 +1,4 @@ +``BezierSegment.point_at_t`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. Instead, it is possible to call the BezierSegment with an argument. diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 42a6b478d729..b9b67c9a72d6 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -190,6 +190,9 @@ class BezierSegment: """ A d-dimensional Bézier segment. + A BezierSegment can be called with an argument, either a scalar or an array-like + object, to evaluate the curve at that/those location(s). + Parameters ---------- control_points : (N, d) array @@ -223,6 +226,8 @@ def __call__(self, t): return (np.power.outer(1 - t, self._orders[::-1]) * np.power.outer(t, self._orders)) @ self._px + @_api.deprecated( + "3.11", alternative="Call the BezierSegment object with an argument.") def point_at_t(self, t): """ Evaluate the curve at a single point, returning a tuple of *d* floats. @@ -336,10 +341,9 @@ def split_bezier_intersecting_with_closedpath( """ bz = BezierSegment(bezier) - bezier_point_at_t = bz.point_at_t t0, t1 = find_bezier_t_intersecting_with_closedpath( - bezier_point_at_t, inside_closedpath, tolerance=tolerance) + lambda t: tuple(bz(t)), inside_closedpath, tolerance=tolerance) _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.) return _left, _right From c4e54cfc303c203e4acac728c17cedbea22fd2ef Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 20 May 2025 11:51:03 +0200 Subject: [PATCH 03/16] Parse FontBBox in type1font. ... instead of having to go through ft2font in createType1Descriptor just to extract the font bbox, ascender and descender. FontBBox is gauranteed to exist in the type1 font definition by the standard; its parsing as a size-4 array matches freetype's behavior (see ps_parser_load_field); and using bbox entries as ascender and descender also matches freetype's behavior (T1_Face_Init directly assigns `root->ascender = (FT_Short)(root->bbox.yMax)` and likewise for the descender; see also the docs for ascender and descender in FT_FaceRec). --- .../next_api_changes/deprecations/30088-AL.rst | 4 ++++ lib/matplotlib/_type1font.py | 10 ++++++++++ lib/matplotlib/backends/backend_pdf.py | 15 +++++++-------- 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30088-AL.rst diff --git a/doc/api/next_api_changes/deprecations/30088-AL.rst b/doc/api/next_api_changes/deprecations/30088-AL.rst new file mode 100644 index 000000000000..ae1338da7f85 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30088-AL.rst @@ -0,0 +1,4 @@ +*fontfile* parameter of ``PdfFile.createType1Descriptor`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This parameter is deprecated; all relevant pieces of information are now +directly extracted from the *t1font* argument. diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index b3e08f52c035..032b6a42ea63 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -579,6 +579,16 @@ def _parse(self): extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' '(ultra)?light|extra|condensed))+$') prop['FamilyName'] = re.sub(extras, '', prop['FullName']) + + # Parse FontBBox + toks = [*_tokenize(prop['FontBBox'].encode('ascii'), True)] + if ([tok.kind for tok in toks] + != ['delimiter', 'number', 'number', 'number', 'number', 'delimiter'] + or toks[-1].raw != toks[0].opposite()): + raise RuntimeError( + f"FontBBox should be a size-4 array, was {prop['FontBBox']}") + prop['FontBBox'] = [tok.value() for tok in toks[1:-1]] + # Decrypt the encrypted parts ndiscard = prop.get('lenIV', 4) cs = prop['CharStrings'] diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 73cf8bc19be2..073ca05bc172 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -1034,14 +1034,15 @@ def _embedTeXFont(self, fontinfo): fontinfo.effects.get('extend', 1.0)) fontdesc = self._type1Descriptors.get((fontinfo.fontfile, effects)) if fontdesc is None: - fontdesc = self.createType1Descriptor(t1font, fontinfo.fontfile) + fontdesc = self.createType1Descriptor(t1font) self._type1Descriptors[(fontinfo.fontfile, effects)] = fontdesc fontdict['FontDescriptor'] = fontdesc self.writeObject(fontdictObject, fontdict) return fontdictObject - def createType1Descriptor(self, t1font, fontfile): + @_api.delete_parameter("3.11", "fontfile") + def createType1Descriptor(self, t1font, fontfile=None): # Create and write the font descriptor and the font file # of a Type-1 font fontdescObject = self.reserveObject('font descriptor') @@ -1076,16 +1077,14 @@ def createType1Descriptor(self, t1font, fontfile): if 0: flags |= 1 << 18 - ft2font = get_font(fontfile) - descriptor = { 'Type': Name('FontDescriptor'), 'FontName': Name(t1font.prop['FontName']), 'Flags': flags, - 'FontBBox': ft2font.bbox, + 'FontBBox': t1font.prop['FontBBox'], 'ItalicAngle': italic_angle, - 'Ascent': ft2font.ascender, - 'Descent': ft2font.descender, + 'Ascent': t1font.prop['FontBBox'][3], + 'Descent': t1font.prop['FontBBox'][1], 'CapHeight': 1000, # TODO: find this out 'XHeight': 500, # TODO: this one too 'FontFile': fontfileObject, @@ -1093,7 +1092,7 @@ def createType1Descriptor(self, t1font, fontfile): 'StemV': 50, # TODO # (see also revision 3874; but not all TeX distros have AFM files!) # 'FontWeight': a number where 400 = Regular, 700 = Bold - } + } self.writeObject(fontdescObject, descriptor) From d77efae515271c2792c5135817b980d495b3034f Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Tue, 20 May 2025 10:09:17 -0700 Subject: [PATCH 04/16] FIX: fix submerged margins algorithm being applied twice --- lib/matplotlib/_constrained_layout.py | 8 ++++++-- .../tests/test_constrainedlayout.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 5623e12a3c41..f5f23581bd9d 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -521,11 +521,13 @@ def match_submerged_margins(layoutgrids, fig): See test_constrained_layout::test_constrained_layout12 for an example. """ + axsdone = [] for sfig in fig.subfigs: - match_submerged_margins(layoutgrids, sfig) + axsdone += match_submerged_margins(layoutgrids, sfig) axs = [a for a in fig.get_axes() - if a.get_subplotspec() is not None and a.get_in_layout()] + if (a.get_subplotspec() is not None and a.get_in_layout() and + a not in axsdone)] for ax1 in axs: ss1 = ax1.get_subplotspec() @@ -592,6 +594,8 @@ def match_submerged_margins(layoutgrids, fig): for i in ss1.rowspan[:-1]: lg1.edit_margin_min('bottom', maxsubb, cell=i) + return axs + def get_cb_parent_spans(cbax): """ diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index 7c7dd43a3115..df2dbd6f43bd 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -721,3 +721,23 @@ def test_layout_leak(): gc.collect() assert not any(isinstance(obj, mpl._layoutgrid.LayoutGrid) for obj in gc.get_objects()) + + +def test_submerged_subfig(): + """ + Test that the submerged margin logic does not get called multiple times + on same axes if it is already in a subfigure + """ + fig = plt.figure(figsize=(4, 5), layout='constrained') + figures = fig.subfigures(3, 1) + axs = [] + for f in figures.flatten(): + gs = f.add_gridspec(2, 2) + for i in range(2): + axs += [f.add_subplot(gs[i, 0])] + axs[-1].plot() + f.add_subplot(gs[:, 1]).plot() + fig.draw_without_rendering() + for ax in axs[1:]: + assert np.allclose(ax.get_position().bounds[-1], + axs[0].get_position().bounds[-1], atol=1e-6) From bb5bfb88b641e8266bf442ea498d05df3f8bcdc6 Mon Sep 17 00:00:00 2001 From: Marie Date: Fri, 23 May 2025 10:38:54 +0200 Subject: [PATCH 05/16] remove point troubling regex --- galleries/plot_types/basic/scatter_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galleries/plot_types/basic/scatter_plot.py b/galleries/plot_types/basic/scatter_plot.py index 07fa943b724f..738af15440db 100644 --- a/galleries/plot_types/basic/scatter_plot.py +++ b/galleries/plot_types/basic/scatter_plot.py @@ -2,7 +2,7 @@ ============= scatter(x, y) ============= -A scatter plot of y vs. x with varying marker size and/or color. +A scatter plot of y versus x with varying marker size and/or color. See `~matplotlib.axes.Axes.scatter`. """ From 731f45460b5fd50b1490f028701400e2b460388d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 22 May 2025 04:04:16 -0400 Subject: [PATCH 06/16] Fix OffsetBox custom picker As with the custom picker, `Artist.contains` returns a boolean and a dictionary in a tuple. This non-empty tuple is always true, so the custom picker would always return True for any non-scroll event. It would also lose the related dictionary. --- lib/matplotlib/offsetbox.py | 4 +++- lib/matplotlib/tests/test_offsetbox.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 6a3a122fc3e7..1e07125cdc2a 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -1504,7 +1504,9 @@ def __init__(self, ref_artist, use_blit=False): @staticmethod def _picker(artist, mouseevent): # A custom picker to prevent dragging on mouse scroll events - return (artist.contains(mouseevent) and mouseevent.name != "scroll_event"), {} + if mouseevent.name == "scroll_event": + return False, {} + return artist.contains(mouseevent) # A property, not an attribute, to maintain picklability. canvas = property(lambda self: self.ref_artist.get_figure(root=True).canvas) diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py index f18fa7c777d1..d9791ff5bc20 100644 --- a/lib/matplotlib/tests/test_offsetbox.py +++ b/lib/matplotlib/tests/test_offsetbox.py @@ -460,3 +460,13 @@ def test_draggable_in_subfigure(): fig.canvas.draw() # Texts are non-pickable until the first draw. MouseEvent("button_press_event", fig.canvas, 1, 1)._process() assert ann._draggable.got_artist + # Stop dragging the annotation. + MouseEvent("button_release_event", fig.canvas, 1, 1)._process() + assert not ann._draggable.got_artist + # A scroll event should not initiate a drag. + MouseEvent("scroll_event", fig.canvas, 1, 1)._process() + assert not ann._draggable.got_artist + # An event outside the annotation should not initiate a drag. + bbox = ann.get_window_extent() + MouseEvent("button_press_event", fig.canvas, bbox.x1+2, bbox.y1+2)._process() + assert not ann._draggable.got_artist From 0b2c33e8ecd432767833886307b83a6ffe2a472e Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 23 May 2025 12:49:10 +0200 Subject: [PATCH 07/16] Fix tight-bbox computation of HostAxes. --- lib/mpl_toolkits/axes_grid1/parasite_axes.py | 3 +++ .../axes_grid1/tests/test_axes_grid1.py | 23 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py index f7bc2df6d7e0..fbc6e8141272 100644 --- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py +++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py @@ -25,6 +25,9 @@ def clear(self): self._parent_axes.callbacks._connect_picklable( "ylim_changed", self._sync_lims) + def get_axes_locator(self): + return self._parent_axes.get_axes_locator() + def pick(self, mouseevent): # This most likely goes to Artist.pick (depending on axes_class given # to the factory), which only handles pick events registered on the diff --git a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py index 496ce74d72c0..26f0aaa37de0 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -9,7 +9,7 @@ from matplotlib.backend_bases import MouseEvent from matplotlib.colors import LogNorm from matplotlib.patches import Circle, Ellipse -from matplotlib.transforms import Bbox, TransformedBbox +from matplotlib.transforms import Affine2D, Bbox, TransformedBbox from matplotlib.testing.decorators import ( check_figures_equal, image_comparison, remove_ticks_and_titles) @@ -26,6 +26,7 @@ from mpl_toolkits.axes_grid1.axes_rgb import RGBAxes from mpl_toolkits.axes_grid1.inset_locator import ( zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch) +from mpl_toolkits.axes_grid1.parasite_axes import HostAxes import mpl_toolkits.axes_grid1.mpl_axes import pytest @@ -467,6 +468,26 @@ def test_gettightbbox(): [-17.7, -13.9, 7.2, 5.4]) +def test_gettightbbox_parasite(): + fig = plt.figure() + + y0 = 0.3 + horiz = [Size.Scaled(1.0)] + vert = [Size.Scaled(1.0)] + ax0_div = Divider(fig, [0.1, y0, 0.8, 0.2], horiz, vert) + ax1_div = Divider(fig, [0.1, 0.5, 0.8, 0.4], horiz, vert) + + ax0 = fig.add_subplot( + xticks=[], yticks=[], axes_locator=ax0_div.new_locator(nx=0, ny=0)) + ax1 = fig.add_subplot( + axes_class=HostAxes, axes_locator=ax1_div.new_locator(nx=0, ny=0)) + aux_ax = ax1.get_aux_axes(Affine2D()) + + fig.canvas.draw() + rdr = fig.canvas.get_renderer() + assert rdr.get_canvas_width_height()[1] * y0 / fig.dpi == fig.get_tightbbox(rdr).y0 + + @pytest.mark.parametrize("click_on", ["big", "small"]) @pytest.mark.parametrize("big_on_axes,small_on_axes", [ ("gca", "gca"), From 04d26b243c1f11b1a8fb433a0c9ef8a71728e507 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sat, 24 May 2025 12:46:24 +0200 Subject: [PATCH 08/16] Simplify/improve error reporting from ft2font. Provide a simple macro to call a FreeType function and throw an exception if an error is returned, while also including the source file and line location for the error. For example, trying `FT2Font(open("pyproject.toml", "rb"))` now raises "FT_Open_Face (ft2font.cpp line 220) failed with error 0x2: unknown file format" instead of "Can not load face (unknown file format; error code 0x2)" --- src/ft2font.cpp | 108 ++++++++++++------------------------------------ src/ft2font.h | 31 ++++++++++++-- 2 files changed, 55 insertions(+), 84 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index b2c2c0fa9bd1..bdfa2873ca80 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -43,26 +43,6 @@ FT_Library _ft2Library; -// FreeType error codes; loaded as per fterror.h. -static char const* ft_error_string(FT_Error error) { -#undef __FTERRORS_H__ -#define FT_ERROR_START_LIST switch (error) { -#define FT_ERRORDEF( e, v, s ) case v: return s; -#define FT_ERROR_END_LIST default: return NULL; } -#include FT_ERRORS_H -} - -void throw_ft_error(std::string message, FT_Error error) { - char const* s = ft_error_string(error); - std::ostringstream os(""); - if (s) { - os << message << " (" << s << "; error code 0x" << std::hex << error << ")"; - } else { // Should not occur, but don't add another error from failed lookup. - os << message << " (error code 0x" << std::hex << error << ")"; - } - throw std::runtime_error(os.str()); -} - FT2Image::FT2Image(unsigned long width, unsigned long height) : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height) { @@ -237,26 +217,16 @@ FT2Font::FT2Font(FT_Open_Args &open_args, kerning_factor(0) { clear(); - - FT_Error error = FT_Open_Face(_ft2Library, &open_args, 0, &face); - if (error) { - throw_ft_error("Can not load face", error); - } - - // set a default fontsize 12 pt at 72dpi - error = FT_Set_Char_Size(face, 12 * 64, 0, 72 * (unsigned int)hinting_factor, 72); - if (error) { - FT_Done_Face(face); - throw_ft_error("Could not set the fontsize", error); - } - + FT_CHECK(FT_Open_Face, _ft2Library, &open_args, 0, &face); if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } - - FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; - FT_Set_Transform(face, &transform, nullptr); - + try { + set_size(12., 72.); // Set a default fontsize 12 pt at 72dpi. + } catch (...) { + FT_Done_Face(face); + throw; + } // Set fallbacks std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); } @@ -293,11 +263,9 @@ void FT2Font::clear() void FT2Font::set_size(double ptsize, double dpi) { - FT_Error error = FT_Set_Char_Size( + FT_CHECK( + FT_Set_Char_Size, face, (FT_F26Dot6)(ptsize * 64), 0, (FT_UInt)(dpi * hinting_factor), (FT_UInt)dpi); - if (error) { - throw_ft_error("Could not set the fontsize", error); - } FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; FT_Set_Transform(face, &transform, nullptr); @@ -311,17 +279,12 @@ void FT2Font::set_charmap(int i) if (i >= face->num_charmaps) { throw std::runtime_error("i exceeds the available number of char maps"); } - FT_CharMap charmap = face->charmaps[i]; - if (FT_Error error = FT_Set_Charmap(face, charmap)) { - throw_ft_error("Could not set the charmap", error); - } + FT_CHECK(FT_Set_Charmap, face, face->charmaps[i]); } void FT2Font::select_charmap(unsigned long i) { - if (FT_Error error = FT_Select_Charmap(face, (FT_Encoding)i)) { - throw_ft_error("Could not set the charmap", error); - } + FT_CHECK(FT_Select_Charmap, face, (FT_Encoding)i); } int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, @@ -477,10 +440,10 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool if (!was_found) { ft_glyph_warn(charcode, glyph_seen_fonts); if (charcode_error) { - throw_ft_error("Could not load charcode", charcode_error); + THROW_FT_ERROR("charcode loading", charcode_error); } else if (glyph_error) { - throw_ft_error("Could not load charcode", glyph_error); + THROW_FT_ERROR("charcode loading", glyph_error); } } else if (ft_object_with_glyph->warn_if_used) { ft_glyph_warn(charcode, glyph_seen_fonts); @@ -494,13 +457,9 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool glyph_seen_fonts.insert((face != nullptr)?face->family_name: nullptr); ft_glyph_warn((FT_ULong)charcode, glyph_seen_fonts); } - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load charcode", error); - } + FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } + FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph); glyphs.push_back(thisGlyph); } } @@ -600,13 +559,9 @@ void FT2Font::load_glyph(FT_UInt glyph_index, void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags) { - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load glyph", error); - } + FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } + FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph); glyphs.push_back(thisGlyph); } @@ -651,13 +606,10 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased) image = py::array_t{{height, width}}; std::memset(image.mutable_data(0), 0, image.nbytes()); - for (auto & glyph : glyphs) { - FT_Error error = FT_Glyph_To_Bitmap( + for (auto & glyph: glyphs) { + FT_CHECK( + FT_Glyph_To_Bitmap, &glyph, antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, nullptr, 1); - if (error) { - throw_ft_error("Could not convert glyph to bitmap", error); - } - FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyph; // now, draw to our target surface (convert position) @@ -681,16 +633,12 @@ void FT2Font::draw_glyph_to_bitmap( throw std::runtime_error("glyph num is out of range"); } - FT_Error error = FT_Glyph_To_Bitmap( - &glyphs[glyphInd], - antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, - &sub_offset, // additional translation - 1 // destroy image - ); - if (error) { - throw_ft_error("Could not convert glyph to bitmap", error); - } - + FT_CHECK( + FT_Glyph_To_Bitmap, + &glyphs[glyphInd], + antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, + &sub_offset, // additional translation + 1); // destroy image FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyphs[glyphInd]; draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y); @@ -715,9 +663,7 @@ void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer, throw std::runtime_error("Failed to convert glyph to standard name"); } } else { - if (FT_Error error = FT_Get_Glyph_Name(face, glyph_number, buffer.data(), buffer.size())) { - throw_ft_error("Could not get glyph names", error); - } + FT_CHECK(FT_Get_Glyph_Name, face, glyph_number, buffer.data(), buffer.size()); auto len = buffer.find('\0'); if (len != buffer.npos) { buffer.resize(len); diff --git a/src/ft2font.h b/src/ft2font.h index 209581d8f362..e1ebdb934329 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -6,6 +6,7 @@ #ifndef MPL_FT2FONT_H #define MPL_FT2FONT_H +#include #include #include #include @@ -26,12 +27,36 @@ extern "C" { #include namespace py = pybind11; -/* - By definition, FT_FIXED as 2 16bit values stored in a single long. - */ +// By definition, FT_FIXED as 2 16bit values stored in a single long. #define FIXED_MAJOR(val) (signed short)((val & 0xffff0000) >> 16) #define FIXED_MINOR(val) (unsigned short)(val & 0xffff) +// Error handling (error codes are loaded as described in fterror.h). +inline char const* ft_error_string(FT_Error error) { +#undef __FTERRORS_H__ +#define FT_ERROR_START_LIST switch (error) { +#define FT_ERRORDEF( e, v, s ) case v: return s; +#define FT_ERROR_END_LIST default: return NULL; } +#include FT_ERRORS_H +} + +// No more than 16 hex digits + "0x" + null byte for a 64-bit int error. +#define THROW_FT_ERROR(name, err) { \ + char buf[20] = {0}; \ + sprintf(buf, "%#04x", err); \ + throw std::runtime_error{ \ + name " (" \ + + std::filesystem::path(__FILE__).filename().string() \ + + " line " + std::to_string(__LINE__) + ") failed with error " \ + + std::string{buf} + ": " + std::string{ft_error_string(err)}}; \ +} (void)0 + +#define FT_CHECK(func, ...) { \ + if (auto const& error_ = func(__VA_ARGS__)) { \ + THROW_FT_ERROR(#func, error_); \ + } \ +} (void)0 + // the FreeType string rendered into a width, height buffer class FT2Image { From 05b0a4eee551783df1df7651a21ed77db68e8214 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Mon, 26 May 2025 08:24:21 +0100 Subject: [PATCH 09/16] DOC: expand petroff10 example to include 6- and 8- styles --- galleries/examples/style_sheets/petroff10.py | 32 ++++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/galleries/examples/style_sheets/petroff10.py b/galleries/examples/style_sheets/petroff10.py index f6293fd40a6b..5683a4df296c 100644 --- a/galleries/examples/style_sheets/petroff10.py +++ b/galleries/examples/style_sheets/petroff10.py @@ -1,13 +1,13 @@ """ -===================== -Petroff10 style sheet -===================== +==================== +Petroff style sheets +==================== -This example demonstrates the "petroff10" style, which implements the 10-color -sequence developed by Matthew A. Petroff [1]_ for accessible data visualization. -The style balances aesthetics with accessibility considerations, making it -suitable for various types of plots while ensuring readability and distinction -between data series. +This example demonstrates the "petroffN" styles, which implement the 6-, 8- and +10-color sequences developed by Matthew A. Petroff [1]_ for accessible data +visualization. The styles balance aesthetics with accessibility considerations, +making them suitable for various types of plots while ensuring readability and +distinction between data series. .. [1] https://arxiv.org/abs/2107.02270 @@ -35,9 +35,15 @@ def image_and_patch_example(ax): c = plt.Circle((5, 5), radius=5, label='patch') ax.add_patch(c) -plt.style.use('petroff10') -fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(12, 5)) -fig.suptitle("'petroff10' style sheet") -colored_lines_example(ax1) -image_and_patch_example(ax2) + +fig = plt.figure(figsize=(6.4, 9.6), layout='compressed') +sfigs = fig.subfigures(nrows=3) + +for style, sfig in zip(['petroff6', 'petroff8', 'petroff10'], sfigs): + sfig.suptitle(f"'{style}' style sheet") + with plt.style.context(style): + ax1, ax2 = sfig.subplots(ncols=2) + colored_lines_example(ax1) + image_and_patch_example(ax2) + plt.show() From 46255ae65bf61d0defae7729ec826189c7016189 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 23 May 2025 14:08:57 +0200 Subject: [PATCH 10/16] Use fix-cm instead of type1cm. ... to fix unicode glyph sizing issues for svg & patheffects. --- doc/install/dependencies.rst | 3 ++- lib/matplotlib/mpl-data/matplotlibrc | 2 +- .../mpl-data/stylelib/classic.mplstyle | 4 ++-- lib/matplotlib/tests/test_backend_svg.py | 2 +- lib/matplotlib/tests/test_usetex.py | 7 +++++++ lib/matplotlib/texmanager.py | 19 ++++++++++++------- 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 712846771cc6..4b006d9016e2 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -443,7 +443,8 @@ will often automatically include these collections. | | `lm `_, | | | `txfonts `_ | +-----------------------------+--------------------------------------------------+ -| collection-latex | `geometry `_, | +| collection-latex | `fix-cm `_, | +| | `geometry `_, | | | `hyperref `_, | | | `latex `_, | | | latex-bin, | diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index acb131c82e6c..780dcd377041 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -339,7 +339,7 @@ # become quite long. # The following packages are always loaded with usetex, # so beware of package collisions: - # geometry, inputenc, type1cm. + # color, fix-cm, geometry, graphicx, textcomp. # PostScript (PSNFSS) font packages may also be # loaded, depending on your font settings. diff --git a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle index 6cba66076ac7..92624503f99e 100644 --- a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle +++ b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle @@ -122,8 +122,8 @@ text.latex.preamble : # IMPROPER USE OF THIS FEATURE WILL LEAD TO LATEX FAILURE # Note that it has to be put on a single line, which may # become quite long. # The following packages are always loaded with usetex, so - # beware of package collisions: color, geometry, graphicx, - # type1cm, textcomp. + # beware of package collisions: + # color, fix-cm, geometry, graphicx, textcomp. # Adobe Postscript (PSSNFS) font packages may also be # loaded, depending on your font settings. diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index d2d4042870a1..2c64b7c24b3e 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -216,7 +216,7 @@ def test_unicode_won(): tree = xml.etree.ElementTree.fromstring(buf) ns = 'http://www.w3.org/2000/svg' - won_id = 'SFSS3583-8e' + won_id = 'SFSS1728-8e' assert len(tree.findall(f'.//{{{ns}}}path[@d][@id="{won_id}"]')) == 1 assert f'#{won_id}' in tree.find(f'.//{{{ns}}}use').attrib.values() diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py index c7658c4f42ac..0b6d6d5e5535 100644 --- a/lib/matplotlib/tests/test_usetex.py +++ b/lib/matplotlib/tests/test_usetex.py @@ -185,3 +185,10 @@ def test_rotation(): # 'My' checks full height letters plus descenders. ax.text(x, y, f"$\\mathrm{{My {text[ha]}{text[va]} {angle}}}$", rotation=angle, horizontalalignment=ha, verticalalignment=va) + + +def test_unicode_sizing(): + tp = mpl.textpath.TextToPath() + scale1 = tp.get_glyphs_tex(mpl.font_manager.FontProperties(), "W")[0][0][3] + scale2 = tp.get_glyphs_tex(mpl.font_manager.FontProperties(), r"\textwon")[0][0][3] + assert scale1 == scale2 diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 94fc94e9e840..020a26e31cbe 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -67,6 +67,13 @@ class TexManager: _grey_arrayd = {} _font_families = ('serif', 'sans-serif', 'cursive', 'monospace') + # Check for the cm-super package (which registers unicode computer modern + # support just by being installed) without actually loading any package + # (because we already load the incompatible fix-cm). + _check_cmsuper_installed = ( + r'\IfFileExists{type1ec.sty}{}{\PackageError{matplotlib-support}{' + r'Missing cm-super package, required by Matplotlib}{}}' + ) _font_preambles = { 'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}', 'bookman': r'\renewcommand{\rmdefault}{pbk}', @@ -80,13 +87,10 @@ class TexManager: 'helvetica': r'\usepackage{helvet}', 'avant garde': r'\usepackage{avant}', 'courier': r'\usepackage{courier}', - # Loading the type1ec package ensures that cm-super is installed, which - # is necessary for Unicode computer modern. (It also allows the use of - # computer modern at arbitrary sizes, but that's just a side effect.) - 'monospace': r'\usepackage{type1ec}', - 'computer modern roman': r'\usepackage{type1ec}', - 'computer modern sans serif': r'\usepackage{type1ec}', - 'computer modern typewriter': r'\usepackage{type1ec}', + 'monospace': _check_cmsuper_installed, + 'computer modern roman': _check_cmsuper_installed, + 'computer modern sans serif': _check_cmsuper_installed, + 'computer modern typewriter': _check_cmsuper_installed, } _font_types = { 'new century schoolbook': 'serif', @@ -200,6 +204,7 @@ def _get_tex_source(cls, tex, fontsize): font_preamble, fontcmd = cls._get_font_preamble_and_command() baselineskip = 1.25 * fontsize return "\n".join([ + r"\RequirePackage{fix-cm}", r"\documentclass{article}", r"% Pass-through \mathdefault, which is used in non-usetex mode", r"% to use the default text font but was historically suppressed", From b18407b13a89e7b13c238bf7b13a06222fc18626 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 17:20:42 +0200 Subject: [PATCH 11/16] Bump scientific-python/circleci-artifacts-redirector-action (#30113) Bumps the actions group with 1 update: [scientific-python/circleci-artifacts-redirector-action](https://github.com/scientific-python/circleci-artifacts-redirector-action). Updates `scientific-python/circleci-artifacts-redirector-action` from 1.0.0 to 1.1.0 - [Release notes](https://github.com/scientific-python/circleci-artifacts-redirector-action/releases) - [Commits](https://github.com/scientific-python/circleci-artifacts-redirector-action/compare/4e13a10d89177f4bfc8007a7064bdbeda848d8d1...7eafdb60666f57706a5525a2f5eb76224dc8779b) --- updated-dependencies: - dependency-name: scientific-python/circleci-artifacts-redirector-action dependency-version: 1.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/circleci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index f0ae304882e7..d61db3f14345 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: GitHub Action step uses: - scientific-python/circleci-artifacts-redirector-action@4e13a10d89177f4bfc8007a7064bdbeda848d8d1 # v1.0.0 + scientific-python/circleci-artifacts-redirector-action@7eafdb60666f57706a5525a2f5eb76224dc8779b # v1.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} From 42f1905ce52625034f6b029a7e3e4737f3cf13b3 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 29 May 2025 17:24:16 +0200 Subject: [PATCH 12/16] Fix _is_tensorflow_array. (#30114) The previous implementation was clearly wrong (the isinstance check would raise TypeError as the second argument would be a bool), but the tests didn't catch that because the bug led to _is_tensorflow_array returning False, then _unpack_to_numpy returning the original input, and then assert_array_equal implicitly converting `result` by calling `__array__` on it. Fix the test by explicitly checking that `result` is indeed a numpy array, and also fix _is_tensorflow_array with more restrictive exception catching (also applied to _is_torch_array, _is_jax_array, and _is_pandas_dataframe, while we're at it). --- lib/matplotlib/cbook.py | 78 +++++++++++++++--------------- lib/matplotlib/tests/test_cbook.py | 3 ++ 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 10048f1be782..3100cc4da81d 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -2331,42 +2331,56 @@ def _picklable_class_constructor(mixin_class, fmt, attr_name, base_class): def _is_torch_array(x): - """Check if 'x' is a PyTorch Tensor.""" + """Return whether *x* is a PyTorch Tensor.""" try: - # we're intentionally not attempting to import torch. If somebody - # has created a torch array, torch should already be in sys.modules - return isinstance(x, sys.modules['torch'].Tensor) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False + # We're intentionally not attempting to import torch. If somebody + # has created a torch array, torch should already be in sys.modules. + tp = sys.modules.get("torch").Tensor + except AttributeError: + return False # Module not imported or a nonstandard module with no Tensor attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) def _is_jax_array(x): - """Check if 'x' is a JAX Array.""" + """Return whether *x* is a JAX Array.""" try: - # we're intentionally not attempting to import jax. If somebody - # has created a jax array, jax should already be in sys.modules - return isinstance(x, sys.modules['jax'].Array) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False + # We're intentionally not attempting to import jax. If somebody + # has created a jax array, jax should already be in sys.modules. + tp = sys.modules.get("jax").Array + except AttributeError: + return False # Module not imported or a nonstandard module with no Array attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) + + +def _is_pandas_dataframe(x): + """Check if *x* is a Pandas DataFrame.""" + try: + # We're intentionally not attempting to import Pandas. If somebody + # has created a Pandas DataFrame, Pandas should already be in sys.modules. + tp = sys.modules.get("pandas").DataFrame + except AttributeError: + return False # Module not imported or a nonstandard module with no Array attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) def _is_tensorflow_array(x): - """Check if 'x' is a TensorFlow Tensor or Variable.""" + """Return whether *x* is a TensorFlow Tensor or Variable.""" try: - # we're intentionally not attempting to import TensorFlow. If somebody - # has created a TensorFlow array, TensorFlow should already be in sys.modules - # we use `is_tensor` to not depend on the class structure of TensorFlow - # arrays, as `tf.Variables` are not instances of `tf.Tensor` - # (they both convert the same way) - return isinstance(x, sys.modules['tensorflow'].is_tensor(x)) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions + # We're intentionally not attempting to import TensorFlow. If somebody + # has created a TensorFlow array, TensorFlow should already be in + # sys.modules we use `is_tensor` to not depend on the class structure + # of TensorFlow arrays, as `tf.Variables` are not instances of + # `tf.Tensor` (they both convert the same way). + is_tensor = sys.modules.get("tensorflow").is_tensor + except AttributeError: return False + try: + return is_tensor(x) + except Exception: + return False # Just in case it's a very nonstandard module. def _unpack_to_numpy(x): @@ -2421,15 +2435,3 @@ def _auto_format_str(fmt, value): return fmt % (value,) except (TypeError, ValueError): return fmt.format(value) - - -def _is_pandas_dataframe(x): - """Check if 'x' is a Pandas DataFrame.""" - try: - # we're intentionally not attempting to import Pandas. If somebody - # has created a Pandas DataFrame, Pandas should already be in sys.modules - return isinstance(x, sys.modules['pandas'].DataFrame) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 7cb057cf4723..9b97d8e7e231 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -1000,6 +1000,7 @@ def __array__(self): torch_tensor = torch.Tensor(data) result = cbook._unpack_to_numpy(torch_tensor) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. @@ -1028,6 +1029,7 @@ def __array__(self): jax_array = jax.Array(data) result = cbook._unpack_to_numpy(jax_array) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. @@ -1057,6 +1059,7 @@ def __array__(self): tf_tensor = tensorflow.Tensor(data) result = cbook._unpack_to_numpy(tf_tensor) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. From ea5d85f67d4aeda04a94e3842217f89130e62cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 29 May 2025 18:01:29 +0300 Subject: [PATCH 13/16] Add some types to _mathtext.py Mypy 1.16.0 flags errors here: lib/matplotlib/_mathtext.py:2531: error: "Node" has no attribute "width" [attr-defined] lib/matplotlib/_mathtext.py:2608: error: List item 0 has incompatible type "Kern"; expected "Hlist | Vlist" [list-item] The check for the attribute _metrics is equivalent to checking for an instance of Char, since only Char and its subclasses set self._metrics. Mypy infers an unnecessarily tight type list[Hlist | Vlist] for spaced_nucleus so we give it a more general one. --- lib/matplotlib/_mathtext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index a528a65ca3cb..19ddbb6d0883 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2524,10 +2524,10 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: if len(new_children): # remove last kern if (isinstance(new_children[-1], Kern) and - hasattr(new_children[-2], '_metrics')): + isinstance(new_children[-2], Char)): new_children = new_children[:-1] last_char = new_children[-1] - if hasattr(last_char, '_metrics'): + if isinstance(last_char, Char): last_char.width = last_char._metrics.advance # create new Hlist without kerning nucleus = Hlist(new_children, do_kern=False) @@ -2603,7 +2603,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: # Do we need to add a space after the nucleus? # To find out, check the flag set by operatorname - spaced_nucleus = [nucleus, x] + spaced_nucleus: list[Node] = [nucleus, x] if self._in_subscript_or_superscript: spaced_nucleus += [self._make_space(self._space_widths[r'\,'])] self._in_subscript_or_superscript = False From 335e6b417100422f5d04631c8e27158e6a3a320e Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sat, 31 May 2025 13:20:56 +0200 Subject: [PATCH 14/16] Fix FT_CHECK compat with cibuildwheel. (#30123) --- src/ft2font.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ft2font.h b/src/ft2font.h index e1ebdb934329..8db0239ed4fd 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -6,7 +6,6 @@ #ifndef MPL_FT2FONT_H #define MPL_FT2FONT_H -#include #include #include #include @@ -42,11 +41,12 @@ inline char const* ft_error_string(FT_Error error) { // No more than 16 hex digits + "0x" + null byte for a 64-bit int error. #define THROW_FT_ERROR(name, err) { \ + std::string path{__FILE__}; \ char buf[20] = {0}; \ - sprintf(buf, "%#04x", err); \ + snprintf(buf, sizeof buf, "%#04x", err); \ throw std::runtime_error{ \ name " (" \ - + std::filesystem::path(__FILE__).filename().string() \ + + path.substr(path.find_last_of("/\\") + 1) \ + " line " + std::to_string(__LINE__) + ") failed with error " \ + std::string{buf} + ": " + std::string{ft_error_string(err)}}; \ } (void)0 From 2a82d0c3a462f0c34d115785c30408b48d94ee3f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 31 May 2025 08:39:30 -0400 Subject: [PATCH 15/16] Clean up AFM code (#30121) Since AFM is now private, we can delete unused methods without deprecation. Additionally, add `AFM.get_glyph_name` so that the PostScript mathtext code doesn't need to special case AFM files. --- lib/matplotlib/_afm.py | 145 +++++++------------------- lib/matplotlib/backends/backend_ps.py | 7 +- lib/matplotlib/tests/test_afm.py | 8 ++ lib/matplotlib/tests/test_text.py | 7 -- 4 files changed, 49 insertions(+), 118 deletions(-) diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py index 558efe16392f..9094206c2d7c 100644 --- a/lib/matplotlib/_afm.py +++ b/lib/matplotlib/_afm.py @@ -1,5 +1,5 @@ """ -A python interface to Adobe Font Metrics Files. +A Python interface to Adobe Font Metrics Files. Although a number of other Python implementations exist, and may be more complete than this, it was decided not to go with them because they were @@ -16,19 +16,11 @@ >>> from pathlib import Path >>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') >>> ->>> from matplotlib.afm import AFM +>>> from matplotlib._afm import AFM >>> with afm_path.open('rb') as fh: ... afm = AFM(fh) ->>> afm.string_width_height('What the heck?') -(6220.0, 694) >>> afm.get_fontname() 'Times-Roman' ->>> afm.get_kern_dist('A', 'f') -0 ->>> afm.get_kern_dist('A', 'y') --92.0 ->>> afm.get_bbox_char('!') -[130, -9, 238, 676] As in the Adobe Font Metrics File Format Specification, all dimensions are given in units of 1/1000 of the scale factor (point size) of the font @@ -87,20 +79,23 @@ def _to_bool(s): def _parse_header(fh): """ - Read the font metrics header (up to the char metrics) and returns - a dictionary mapping *key* to *val*. *val* will be converted to the - appropriate python type as necessary; e.g.: + Read the font metrics header (up to the char metrics). - * 'False'->False - * '0'->0 - * '-168 -218 1000 898'-> [-168, -218, 1000, 898] + Returns + ------- + dict + A dictionary mapping *key* to *val*. Dictionary keys are: - Dictionary keys are + StartFontMetrics, FontName, FullName, FamilyName, Weight, ItalicAngle, + IsFixedPitch, FontBBox, UnderlinePosition, UnderlineThickness, Version, + Notice, EncodingScheme, CapHeight, XHeight, Ascender, Descender, + StartCharMetrics - StartFontMetrics, FontName, FullName, FamilyName, Weight, - ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, - UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, - XHeight, Ascender, Descender, StartCharMetrics + *val* will be converted to the appropriate Python type as necessary, e.g.,: + + * 'False' -> False + * '0' -> 0 + * '-168 -218 1000 898' -> [-168, -218, 1000, 898] """ header_converters = { b'StartFontMetrics': _to_float, @@ -185,11 +180,9 @@ def _parse_header(fh): def _parse_char_metrics(fh): """ - Parse the given filehandle for character metrics information and return - the information as dicts. + Parse the given filehandle for character metrics information. - It is assumed that the file cursor is on the line behind - 'StartCharMetrics'. + It is assumed that the file cursor is on the line behind 'StartCharMetrics'. Returns ------- @@ -239,14 +232,15 @@ def _parse_char_metrics(fh): def _parse_kern_pairs(fh): """ - Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and - values are the kern pair value. For example, a kern pairs line like - ``KPX A y -50`` - - will be represented as:: + Return a kern pairs dictionary. - d[ ('A', 'y') ] = -50 + Returns + ------- + dict + Keys are (*char1*, *char2*) tuples and values are the kern pair value. For + example, a kern pairs line like ``KPX A y -50`` will be represented as:: + d['A', 'y'] = -50 """ line = next(fh) @@ -279,8 +273,7 @@ def _parse_kern_pairs(fh): def _parse_composites(fh): """ - Parse the given filehandle for composites information return them as a - dict. + Parse the given filehandle for composites information. It is assumed that the file cursor is on the line behind 'StartComposites'. @@ -363,36 +356,6 @@ def __init__(self, fh): self._metrics, self._metrics_by_name = _parse_char_metrics(fh) self._kern, self._composite = _parse_optional(fh) - def get_bbox_char(self, c, isord=False): - if not isord: - c = ord(c) - return self._metrics[c].bbox - - def string_width_height(self, s): - """ - Return the string width (including kerning) and string height - as a (*w*, *h*) tuple. - """ - if not len(s): - return 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - for c in s: - if c == '\n': - continue - wx, name, bbox = self._metrics[ord(c)] - - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return total_width, maxy - miny - def get_str_bbox_and_descent(self, s): """Return the string bounding box and the maximal descent.""" if not len(s): @@ -423,45 +386,29 @@ def get_str_bbox_and_descent(self, s): return left, miny, total_width, maxy - miny, -miny - def get_str_bbox(self, s): - """Return the string bounding box.""" - return self.get_str_bbox_and_descent(s)[:4] - - def get_name_char(self, c, isord=False): - """Get the name of the character, i.e., ';' is 'semicolon'.""" - if not isord: - c = ord(c) - return self._metrics[c].name + def get_glyph_name(self, glyph_ind): # For consistency with FT2Font. + """Get the name of the glyph, i.e., ord(';') is 'semicolon'.""" + return self._metrics[glyph_ind].name - def get_width_char(self, c, isord=False): + def get_char_index(self, c): # For consistency with FT2Font. """ - Get the width of the character from the character metric WX field. + Return the glyph index corresponding to a character code point. + + Note, for AFM fonts, we treat the glyph index the same as the codepoint. """ - if not isord: - c = ord(c) + return c + + def get_width_char(self, c): + """Get the width of the character code from the character metric WX field.""" return self._metrics[c].width def get_width_from_char_name(self, name): """Get the width of the character from a type1 character name.""" return self._metrics_by_name[name].width - def get_height_char(self, c, isord=False): - """Get the bounding box (ink) height of character *c* (space is 0).""" - if not isord: - c = ord(c) - return self._metrics[c].bbox[-1] - - def get_kern_dist(self, c1, c2): - """ - Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. - """ - name1, name2 = self.get_name_char(c1), self.get_name_char(c2) - return self.get_kern_dist_from_name(name1, name2) - def get_kern_dist_from_name(self, name1, name2): """ - Return the kerning pair distance (possibly 0) for chars - *name1* and *name2*. + Return the kerning pair distance (possibly 0) for chars *name1* and *name2*. """ return self._kern.get((name1, name2), 0) @@ -493,7 +440,7 @@ def get_familyname(self): return re.sub(extras, '', name) @property - def family_name(self): + def family_name(self): # For consistency with FT2Font. """The font family name, e.g., 'Times'.""" return self.get_familyname() @@ -516,17 +463,3 @@ def get_xheight(self): def get_underline_thickness(self): """Return the underline thickness as float.""" return self._header[b'UnderlineThickness'] - - def get_horizontal_stem_width(self): - """ - Return the standard horizontal stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdHW', None) - - def get_vertical_stem_width(self): - """ - Return the standard vertical stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdVW', None) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index c1f4348016bb..f6b8455a15a7 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -24,7 +24,6 @@ import matplotlib as mpl from matplotlib import _api, cbook, _path, _text_helpers -from matplotlib._afm import AFM from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.cbook import is_writable_file_like, file_requires_unicode @@ -787,7 +786,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): width = font.get_width_from_char_name(name) except KeyError: name = 'question' - width = font.get_width_char('?') + width = font.get_width_char(ord('?')) kern = font.get_kern_dist_from_name(last_name, name) last_name = name thisx += kern * scale @@ -835,9 +834,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): lastfont = font.postscript_name, fontsize self._pswriter.write( f"/{font.postscript_name} {fontsize} selectfont\n") - glyph_name = ( - font.get_name_char(chr(num)) if isinstance(font, AFM) else - font.get_glyph_name(font.get_char_index(num))) + glyph_name = font.get_glyph_name(font.get_char_index(num)) self._pswriter.write( f"{ox:g} {oy:g} moveto\n" f"/{glyph_name} glyphshow\n") diff --git a/lib/matplotlib/tests/test_afm.py b/lib/matplotlib/tests/test_afm.py index e5c6a83937cd..80cf8ac60feb 100644 --- a/lib/matplotlib/tests/test_afm.py +++ b/lib/matplotlib/tests/test_afm.py @@ -135,3 +135,11 @@ def test_malformed_header(afm_data, caplog): _afm._parse_header(fh) assert len(caplog.records) == 1 + + +def test_afm_kerning(): + fn = fm.findfont("Helvetica", fontext="afm") + with open(fn, 'rb') as fh: + afm = _afm.AFM(fh) + assert afm.get_kern_dist_from_name('A', 'V') == -70.0 + assert afm.get_kern_dist_from_name('V', 'A') == -80.0 diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 79a9e2d66c46..407d7a96be4d 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -208,13 +208,6 @@ def test_antialiasing(): mpl.rcParams['text.antialiased'] = False # Should not affect existing text. -def test_afm_kerning(): - fn = mpl.font_manager.findfont("Helvetica", fontext="afm") - with open(fn, 'rb') as fh: - afm = mpl._afm.AFM(fh) - assert afm.string_width_height('VAVAVAVAVAVA') == (7174.0, 718) - - @image_comparison(['text_contains.png']) def test_contains(): fig = plt.figure() From 6083ecd880c950a705ba74107ff18b447e2c53dc Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 2 Jun 2025 10:02:01 +0100 Subject: [PATCH 16/16] Remove apply_theta_transforms argument (#30004) * Remove apply_theta_transforms argument * Improve formatting * Rename xxxxxx-DS.rst to 30004-DS.rst * Delete extra line --- .../next_api_changes/removals/30004-DS.rst | 10 ++++ .../next_api_changes/removals/xxxxxx-DS.rst | 4 -- .../axisartist/demo_axis_direction.py | 2 +- .../axisartist/demo_curvelinear_grid.py | 3 +- .../examples/axisartist/demo_floating_axes.py | 5 +- .../examples/axisartist/demo_floating_axis.py | 3 +- .../examples/axisartist/simple_axis_pad.py | 3 +- lib/matplotlib/projections/polar.py | 55 +++---------------- lib/matplotlib/projections/polar.pyi | 3 - lib/matplotlib/tests/test_transforms.py | 3 +- lib/matplotlib/text.py | 4 +- .../axisartist/tests/test_floating_axes.py | 4 +- .../tests/test_grid_helper_curvelinear.py | 6 +- 13 files changed, 29 insertions(+), 76 deletions(-) create mode 100644 doc/api/next_api_changes/removals/30004-DS.rst delete mode 100644 doc/api/next_api_changes/removals/xxxxxx-DS.rst diff --git a/doc/api/next_api_changes/removals/30004-DS.rst b/doc/api/next_api_changes/removals/30004-DS.rst new file mode 100644 index 000000000000..f5fdf214366c --- /dev/null +++ b/doc/api/next_api_changes/removals/30004-DS.rst @@ -0,0 +1,10 @@ +``apply_theta_transforms`` option in ``PolarTransform`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Applying theta transforms in `~matplotlib.projections.polar.PolarTransform` and +`~matplotlib.projections.polar.InvertedPolarTransform` has been removed, and +the ``apply_theta_transforms`` keyword argument removed from both classes. + +If you need to retain the behaviour where theta values +are transformed, chain the ``PolarTransform`` with a `~matplotlib.transforms.Affine2D` +transform that performs the theta shift and/or sign shift. diff --git a/doc/api/next_api_changes/removals/xxxxxx-DS.rst b/doc/api/next_api_changes/removals/xxxxxx-DS.rst deleted file mode 100644 index 8ae7919afa31..000000000000 --- a/doc/api/next_api_changes/removals/xxxxxx-DS.rst +++ /dev/null @@ -1,4 +0,0 @@ -``backend_ps.get_bbox_header`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -... is removed, as it is considered an internal helper. diff --git a/galleries/examples/axisartist/demo_axis_direction.py b/galleries/examples/axisartist/demo_axis_direction.py index 8c57b6c5a351..9540599c6a7b 100644 --- a/galleries/examples/axisartist/demo_axis_direction.py +++ b/galleries/examples/axisartist/demo_axis_direction.py @@ -22,7 +22,7 @@ def setup_axes(fig, rect): grid_helper = GridHelperCurveLinear( ( Affine2D().scale(np.pi/180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False) + PolarAxes.PolarTransform() ), extreme_finder=angle_helper.ExtremeFinderCycle( 20, 20, diff --git a/galleries/examples/axisartist/demo_curvelinear_grid.py b/galleries/examples/axisartist/demo_curvelinear_grid.py index 40853dee12cb..fb1fbdd011ce 100644 --- a/galleries/examples/axisartist/demo_curvelinear_grid.py +++ b/galleries/examples/axisartist/demo_curvelinear_grid.py @@ -54,8 +54,7 @@ def curvelinear_test2(fig): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = Affine2D().scale(np.pi/180, 1) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi/180, 1) + PolarAxes.PolarTransform() # Polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes # (min, max of the coordinate within the view). diff --git a/galleries/examples/axisartist/demo_floating_axes.py b/galleries/examples/axisartist/demo_floating_axes.py index 632f6d237aa6..add03e266d3e 100644 --- a/galleries/examples/axisartist/demo_floating_axes.py +++ b/galleries/examples/axisartist/demo_floating_axes.py @@ -54,7 +54,7 @@ def setup_axes2(fig, rect): With custom locator and formatter. Note that the extreme values are swapped. """ - tr = PolarAxes.PolarTransform(apply_theta_transforms=False) + tr = PolarAxes.PolarTransform() pi = np.pi angle_ticks = [(0, r"$0$"), @@ -99,8 +99,7 @@ def setup_axes3(fig, rect): # scale degree to radians tr_scale = Affine2D().scale(np.pi/180., 1.) - tr = tr_rotate + tr_scale + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = tr_rotate + tr_scale + PolarAxes.PolarTransform() grid_locator1 = angle_helper.LocatorHMS(4) tick_formatter1 = angle_helper.FormatterHMS() diff --git a/galleries/examples/axisartist/demo_floating_axis.py b/galleries/examples/axisartist/demo_floating_axis.py index 5296b682367b..0894bf8f4ce1 100644 --- a/galleries/examples/axisartist/demo_floating_axis.py +++ b/galleries/examples/axisartist/demo_floating_axis.py @@ -22,8 +22,7 @@ def curvelinear_test2(fig): """Polar projection, but in a rectangular box.""" # see demo_curvelinear_grid.py for details - tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, diff --git a/galleries/examples/axisartist/simple_axis_pad.py b/galleries/examples/axisartist/simple_axis_pad.py index 95f30ce1ffbc..f40a1aa9f273 100644 --- a/galleries/examples/axisartist/simple_axis_pad.py +++ b/galleries/examples/axisartist/simple_axis_pad.py @@ -21,8 +21,7 @@ def setup_axes(fig, rect): """Polar projection, but in a rectangular box.""" # see demo_curvelinear_grid.py for details - tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform() extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, lon_cycle=360, diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 71224fb3affe..948b3a6e704f 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -15,20 +15,6 @@ from matplotlib.spines import Spine -def _apply_theta_transforms_warn(): - _api.warn_deprecated( - "3.9", - message=( - "Passing `apply_theta_transforms=True` (the default) " - "is deprecated since Matplotlib %(since)s. " - "Support for this will be removed in Matplotlib in %(removal)s. " - "To prevent this warning, set `apply_theta_transforms=False`, " - "and make sure to shift theta values before being passed to " - "this transform." - ) - ) - - class PolarTransform(mtransforms.Transform): r""" The base polar transform. @@ -48,8 +34,7 @@ class PolarTransform(mtransforms.Transform): input_dims = output_dims = 2 - def __init__(self, axis=None, use_rmin=True, *, - apply_theta_transforms=True, scale_transform=None): + def __init__(self, axis=None, use_rmin=True, *, scale_transform=None): """ Parameters ---------- @@ -64,15 +49,12 @@ def __init__(self, axis=None, use_rmin=True, *, super().__init__() self._axis = axis self._use_rmin = use_rmin - self._apply_theta_transforms = apply_theta_transforms self._scale_transform = scale_transform - if apply_theta_transforms: - _apply_theta_transforms_warn() __str__ = mtransforms._make_str_method( "_axis", - use_rmin="_use_rmin", - apply_theta_transforms="_apply_theta_transforms") + use_rmin="_use_rmin" + ) def _get_rorigin(self): # Get lower r limit after being scaled by the radial scale transform @@ -82,11 +64,6 @@ def _get_rorigin(self): def transform_non_affine(self, values): # docstring inherited theta, r = np.transpose(values) - # PolarAxes does not use the theta transforms here, but apply them for - # backwards-compatibility if not being used by it. - if self._apply_theta_transforms and self._axis is not None: - theta *= self._axis.get_theta_direction() - theta += self._axis.get_theta_offset() if self._use_rmin and self._axis is not None: r = (r - self._get_rorigin()) * self._axis.get_rsign() r = np.where(r >= 0, r, np.nan) @@ -148,10 +125,7 @@ def transform_path_non_affine(self, path): def inverted(self): # docstring inherited - return PolarAxes.InvertedPolarTransform( - self._axis, self._use_rmin, - apply_theta_transforms=self._apply_theta_transforms - ) + return PolarAxes.InvertedPolarTransform(self._axis, self._use_rmin) class PolarAffine(mtransforms.Affine2DBase): @@ -209,8 +183,7 @@ class InvertedPolarTransform(mtransforms.Transform): """ input_dims = output_dims = 2 - def __init__(self, axis=None, use_rmin=True, - *, apply_theta_transforms=True): + def __init__(self, axis=None, use_rmin=True): """ Parameters ---------- @@ -225,26 +198,16 @@ def __init__(self, axis=None, use_rmin=True, super().__init__() self._axis = axis self._use_rmin = use_rmin - self._apply_theta_transforms = apply_theta_transforms - if apply_theta_transforms: - _apply_theta_transforms_warn() __str__ = mtransforms._make_str_method( "_axis", - use_rmin="_use_rmin", - apply_theta_transforms="_apply_theta_transforms") + use_rmin="_use_rmin") def transform_non_affine(self, values): # docstring inherited x, y = values.T r = np.hypot(x, y) theta = (np.arctan2(y, x) + 2 * np.pi) % (2 * np.pi) - # PolarAxes does not use the theta transforms here, but apply them for - # backwards-compatibility if not being used by it. - if self._apply_theta_transforms and self._axis is not None: - theta -= self._axis.get_theta_offset() - theta *= self._axis.get_theta_direction() - theta %= 2 * np.pi if self._use_rmin and self._axis is not None: r += self._axis.get_rorigin() r *= self._axis.get_rsign() @@ -252,10 +215,7 @@ def transform_non_affine(self, values): def inverted(self): # docstring inherited - return PolarAxes.PolarTransform( - self._axis, self._use_rmin, - apply_theta_transforms=self._apply_theta_transforms - ) + return PolarAxes.PolarTransform(self._axis, self._use_rmin) class ThetaFormatter(mticker.Formatter): @@ -895,7 +855,6 @@ def _set_lim_and_transforms(self): # data. This one is aware of rmin self.transProjection = self.PolarTransform( self, - apply_theta_transforms=False, scale_transform=self.transScale ) # Add dependency on rorigin. diff --git a/lib/matplotlib/projections/polar.pyi b/lib/matplotlib/projections/polar.pyi index de1cbc293900..fc1d508579b5 100644 --- a/lib/matplotlib/projections/polar.pyi +++ b/lib/matplotlib/projections/polar.pyi @@ -18,7 +18,6 @@ class PolarTransform(mtransforms.Transform): axis: PolarAxes | None = ..., use_rmin: bool = ..., *, - apply_theta_transforms: bool = ..., scale_transform: mtransforms.Transform | None = ..., ) -> None: ... def inverted(self) -> InvertedPolarTransform: ... @@ -35,8 +34,6 @@ class InvertedPolarTransform(mtransforms.Transform): self, axis: PolarAxes | None = ..., use_rmin: bool = ..., - *, - apply_theta_transforms: bool = ..., ) -> None: ... def inverted(self) -> PolarTransform: ... diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 99647e99bbde..b4db34db5a91 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -891,8 +891,7 @@ def test_str_transform(): Affine2D().scale(1.0))), PolarTransform( PolarAxes(0.125,0.1;0.775x0.8), - use_rmin=True, - apply_theta_transforms=False)), + use_rmin=True)), CompositeGenericTransform( CompositeGenericTransform( PolarAffine( diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 3b0de58814d9..acde4fb179a2 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -1553,9 +1553,7 @@ def _get_xy_transform(self, renderer, coords): return self.axes.transData elif coords == 'polar': from matplotlib.projections import PolarAxes - tr = PolarAxes.PolarTransform(apply_theta_transforms=False) - trans = tr + self.axes.transData - return trans + return PolarAxes.PolarTransform() + self.axes.transData try: bbox_name, unit = coords.split() diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index 362384221bdd..feb667af013e 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -26,7 +26,7 @@ def test_curvelinear3(): fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + - mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + mprojections.PolarAxes.PolarTransform()) grid_helper = GridHelperCurveLinear( tr, extremes=(0, 360, 10, 3), @@ -75,7 +75,7 @@ def test_curvelinear4(): fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + - mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + mprojections.PolarAxes.PolarTransform()) grid_helper = GridHelperCurveLinear( tr, extremes=(120, 30, 10, 0), diff --git a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py index 1b266044bdd0..7d6554782fe6 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -82,8 +82,7 @@ def test_polar_box(): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = (Affine2D().scale(np.pi / 180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False)) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() # polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes @@ -145,8 +144,7 @@ def test_axis_direction(): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = (Affine2D().scale(np.pi / 180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False)) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() # polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes