From 8caff885c195fd65588c5dae6c4feaf5e57c44f7 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sat, 3 May 2025 03:04:33 -0400 Subject: [PATCH 001/108] Remove ttconv backwards-compatibility code --- lib/matplotlib/backends/backend_pdf.py | 38 +++++---------------- src/_path.h | 47 ++++++++++---------------- 2 files changed, 25 insertions(+), 60 deletions(-) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 8db640d888b1..53bf10c90b48 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -617,40 +617,18 @@ def _get_pdf_charprocs(font_path, glyph_ids): procs = {} for glyph_id in glyph_ids: g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) - # NOTE: We should be using round(), but instead use - # "(x+.5).astype(int)" to keep backcompat with the old ttconv code - # (this is different for negative x's). - d1 = (np.array([g.horiAdvance, 0, *g.bbox]) * conv + .5).astype(int) + d1 = [ + round(g.horiAdvance * conv), 0, + # Round bbox corners *outwards*, so that they indeed bound the glyph. + math.floor(g.bbox[0] * conv), math.floor(g.bbox[1] * conv), + math.ceil(g.bbox[2] * conv), math.ceil(g.bbox[3] * conv), + ] v, c = font.get_path() - v = (v * 64).astype(int) # Back to TrueType's internal units (1/64's). - # Backcompat with old ttconv code: control points between two quads are - # omitted if they are exactly at the midpoint between the control of - # the quad before and the quad after, but ttconv used to interpolate - # *after* conversion to PS units, causing floating point errors. Here - # we reproduce ttconv's logic, detecting these "implicit" points and - # re-interpolating them. Note that occasionally (e.g. with DejaVu Sans - # glyph "0") a point detected as "implicit" is actually explicit, and - # will thus be shifted by 1. - quads, = np.nonzero(c == 3) - quads_on = quads[1::2] - quads_mid_on = np.array( - sorted({*quads_on} & {*(quads - 1)} & {*(quads + 1)}), int) - implicit = quads_mid_on[ - (v[quads_mid_on] # As above, use astype(int), not // division - == ((v[quads_mid_on - 1] + v[quads_mid_on + 1]) / 2).astype(int)) - .all(axis=1)] - if (font.postscript_name, glyph_id) in [ - ("DejaVuSerif-Italic", 77), # j - ("DejaVuSerif-Italic", 135), # \AA - ]: - v[:, 0] -= 1 # Hard-coded backcompat (FreeType shifts glyph by 1). - v = (v * conv + .5).astype(int) # As above re: truncation vs rounding. - v[implicit] = (( # Fix implicit points; again, truncate. - (v[implicit - 1] + v[implicit + 1]) / 2).astype(int)) + v = (v * 64 * conv).round() # Back to TrueType's internal units (1/64's). procs[font.get_glyph_name(glyph_id)] = ( " ".join(map(str, d1)).encode("ascii") + b" d1\n" + _path.convert_to_string( - Path(v, c), None, None, False, None, -1, + Path(v, c), None, None, False, None, 0, # no code for quad Beziers triggers auto-conversion to cubics. [b"m", b"l", b"", b"c", b"h"], True) + b"f") diff --git a/src/_path.h b/src/_path.h index c03703776760..1b54426c7e81 100644 --- a/src/_path.h +++ b/src/_path.h @@ -1066,38 +1066,25 @@ void quad2cubic(double x0, double y0, void __add_number(double val, char format_code, int precision, std::string& buffer) { - if (precision == -1) { - // Special-case for compat with old ttconv code, which *truncated* - // values with a cast to int instead of rounding them as printf - // would do. The only point where non-integer values arise is from - // quad2cubic conversion (as we already perform a first truncation - // on Python's side), which can introduce additional floating point - // error (by adding 2/3 delta-x and then 1/3 delta-x), so compensate by - // first rounding to the closest 1/3 and then truncating. - char str[255]; - PyOS_snprintf(str, 255, "%d", (int)(round(val * 3)) / 3); - buffer += str; - } else { - char *str = PyOS_double_to_string( - val, format_code, precision, Py_DTSF_ADD_DOT_0, nullptr); - // Delete trailing zeros and decimal point - char *c = str + strlen(str) - 1; // Start at last character. - // Rewind through all the zeros and, if present, the trailing decimal - // point. Py_DTSF_ADD_DOT_0 ensures we won't go past the start of str. - while (*c == '0') { - --c; - } - if (*c == '.') { - --c; - } - try { - buffer.append(str, c + 1); - } catch (std::bad_alloc& e) { - PyMem_Free(str); - throw e; - } + char *str = PyOS_double_to_string( + val, format_code, precision, Py_DTSF_ADD_DOT_0, nullptr); + // Delete trailing zeros and decimal point + char *c = str + strlen(str) - 1; // Start at last character. + // Rewind through all the zeros and, if present, the trailing decimal + // point. Py_DTSF_ADD_DOT_0 ensures we won't go past the start of str. + while (*c == '0') { + --c; + } + if (*c == '.') { + --c; + } + try { + buffer.append(str, c + 1); + } catch (std::bad_alloc& e) { PyMem_Free(str); + throw e; } + PyMem_Free(str); } From c44db77b9fb1318934767cfa01397ba6b81e30f7 Mon Sep 17 00:00:00 2001 From: Wiliam Date: Tue, 26 Nov 2024 19:24:26 +0100 Subject: [PATCH 002/108] Fix center of rotation with rotation_mode='anchor' --- lib/matplotlib/backends/backend_agg.py | 18 ++++++++++++++---- .../test_text/rotation_anchor.png | Bin 0 -> 15484 bytes lib/matplotlib/tests/test_text.py | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/test_text/rotation_anchor.png diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index b435ae565ce4..f25b89e2b053 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -197,10 +197,20 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): xo, yo = font.get_bitmap_offset() xo /= 64.0 yo /= 64.0 - xd = d * sin(radians(angle)) - yd = d * cos(radians(angle)) - x = round(x + xo + xd) - y = round(y + yo + yd) + + 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) def get_text_width_height_descent(self, s, prop, ismath): diff --git a/lib/matplotlib/tests/baseline_images/test_text/rotation_anchor.png b/lib/matplotlib/tests/baseline_images/test_text/rotation_anchor.png new file mode 100644 index 0000000000000000000000000000000000000000..3dad1f9a19f754558bcaae58122536d4544bd710 GIT binary patch literal 15484 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

e-(#m58!y>EXEBxmOCkFyGfXFjdMYq~`*< z6WjNf@2x3a@_dzxJM|ldFxN`-l9a5g*FRV2tC>occ?1-)J1k=bCX*Nx>iNx|t@h3E zMW#dpd8n0*tlw0nn9;ut4mTu$fyfHz&=fnKJa~nay?DO0X%tV`42tke49HXI|INX{ znodkW@SCZbnSkYt7)JhDIr)z&XGS2D(Nlm(JlRy{-L--6Pj z1|{`}cok227M%XmqTGaVbsY~}u9aQY`u?8ot>+@5&H1k~OPUt8EP?t=jF=THtRK<6 z>^LJC{J*C@PEP6Hdi|q@w$53!p+o_BG*xE+I!gpe`XG?5(dyW=JqaBYBt_QS;R!<> z?{hw=b5m^8=KEDXG4(0gsr9@K))9%Wa3d+yU+YP98% z2Ral7yEdXgbM0N~;^3ahh12xSl+e)C20CmAZJidu02hwf!X78mM-%xJ4oCg8C_O2B zO946W_0g}XK5T0*-Esfox2d72S>SfG+#X|UdiG|#fWbKCT0RU5Yj>mGBiGqb0Cj(l z+IlOPh>#^nIf{{OptSTXrD2_qJLBSV{?DI?;Tqv%0ngmhW%JJ3^9vb~%xhfjD5Q5g z4$cf$erCj5Fogg&j$VD1cNcIlxmSaY0p5)Q&0MG`Am8ddP~I#!-%Jm|gBA7S71>~8 z-!$^t1~#;C9f%^nZ>`pyogZ;w?&Jb@O7!&=!!7G`+kE zH0|lJYfVtNxOY5;S3ua_Xa~Gv6-B5arr@+{am35 zms~L*fEEY)r)#Z= z>}S=TjfQg)&WUY$j*OWF3d{eYp+s`Ud8HPwR&%M{DW9cbImr5H^X-OJSc`Snmvp_^ z+HiF?ZWrIZlZ;$f{a!(j`lKI8$#k+Fznj5FPms)%*rD!4^_QD%-8nUbDTkLfZtqe& z+%8Tx$19}I7lBzg+k_xXNJzkGpt;zcIB9N8y0W9)V|?s)urTU8ryS`i)+J4eZh70- z1fCL2=x&Q-v^Ra4i(idOFzfa^KV0DIZhluSLio6wra)zfbx4W?s~4qX)#KP?MH@ATUqM;WYv54?EJDPNz`W8 zBvD3C#p$5<_3HlNV42g};k%k6Hmwv*S)Wxie&D^1Go|$YVt)%$jI6ia)pimmyBl-u zle@kZ(e{#(1HYb&Bpo`u!P~yEjURXG2O}bw4W-70z`Ww&2>5OY9()I~2{>yeFq;Vq zqC&GM45g@}m5JJ!`FgDWgf7{<_u-9utlBZ=@Pl0!RL8|vP?-eK@0{#zH CCYEdf literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 407d7a96be4d..906484c8a526 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -301,6 +301,22 @@ def test_alignment(): ax.set_yticks([]) +@image_comparison(baseline_images=['rotation_anchor.png'], style='mpl20', + remove_text=True) +def test_rotation_mode_anchor(): + fig, ax = plt.subplots() + + ax.plot([0, 1], lw=0) + ax.axvline(.5, linewidth=.5, color='.5') + ax.axhline(.5, linewidth=.5, color='.5') + + N = 4 + for r in range(N): + ax.text(.5, .5, 'pP', color=f'C{r}', size=100, + rotation=r/N*360, rotation_mode='anchor', + verticalalignment='center_baseline') + + @image_comparison(['axes_titles.png']) def test_axes_titles(): # Related to issue #3327 From 41cc933f33a87fe7f2b69fe0ffe9a82e60c1389b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 12 Jun 2025 18:42:01 -0400 Subject: [PATCH 003/108] Remove fallback code for glyph indices Glyph indices are specific to each font. It does not make sense to fall back based on glyph index to another font. This could only really be populated by calling `FT2Font.set_text`, but even that was fragile. If a fallback font was used for a character with the same glyph index as a previous character in the main font, then these lookups could be overwritten to the fallback instead of the main font, with a completely different character! Fortunately, nothing actually uses or requires a fallback through glyph indices. --- src/ft2font.cpp | 65 +++++------------------------------------ src/ft2font.h | 8 ++--- src/ft2font_wrapper.cpp | 12 +++----- 3 files changed, 14 insertions(+), 71 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index bdfa2873ca80..9726e96233ab 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -253,7 +253,6 @@ void FT2Font::clear() } glyphs.clear(); - glyph_to_font.clear(); char_to_font.clear(); for (auto & fallback : fallbacks) { @@ -287,35 +286,13 @@ void FT2Font::select_charmap(unsigned long i) FT_CHECK(FT_Select_Charmap, face, (FT_Encoding)i); } -int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, - bool fallback = false) -{ - if (fallback && glyph_to_font.find(left) != glyph_to_font.end() && - glyph_to_font.find(right) != glyph_to_font.end()) { - FT2Font *left_ft_object = glyph_to_font[left]; - FT2Font *right_ft_object = glyph_to_font[right]; - if (left_ft_object != right_ft_object) { - // we do not know how to do kerning between different fonts - return 0; - } - // if left_ft_object is the same as right_ft_object, - // do the exact same thing which set_text does. - return right_ft_object->get_kerning(left, right, mode, false); - } - else - { - FT_Vector delta; - return get_kerning(left, right, mode, delta); - } -} - -int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, - FT_Vector &delta) +int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode) { if (!FT_HAS_KERNING(face)) { return 0; } + FT_Vector delta; if (!FT_Get_Kerning(face, left, right, mode, &delta)) { return (int)(delta.x) / (hinting_factor << kerning_factor); } else { @@ -364,7 +341,7 @@ void FT2Font::set_text( std::set glyph_seen_fonts; FT2Font *ft_object_with_glyph = this; bool was_found = load_char_with_fallback(ft_object_with_glyph, glyph_index, glyphs, - char_to_font, glyph_to_font, codepoint, flags, + char_to_font, codepoint, flags, charcode_error, glyph_error, glyph_seen_fonts, false); if (!was_found) { ft_glyph_warn((FT_ULong)codepoint, glyph_seen_fonts); @@ -372,8 +349,7 @@ void FT2Font::set_text( // come back to top-most font ft_object_with_glyph = this; char_to_font[codepoint] = ft_object_with_glyph; - glyph_to_font[glyph_index] = ft_object_with_glyph; - ft_object_with_glyph->load_glyph(glyph_index, flags, ft_object_with_glyph, false); + ft_object_with_glyph->load_glyph(glyph_index, flags); } else if (ft_object_with_glyph->warn_if_used) { ft_glyph_warn((FT_ULong)codepoint, glyph_seen_fonts); } @@ -383,8 +359,7 @@ void FT2Font::set_text( ft_object_with_glyph->has_kerning() && // if the font knows how to kern previous && glyph_index // and we really have 2 glyphs ) { - FT_Vector delta; - pen.x += ft_object_with_glyph->get_kerning(previous, glyph_index, FT_KERNING_DEFAULT, delta); + pen.x += ft_object_with_glyph->get_kerning(previous, glyph_index, FT_KERNING_DEFAULT); } // extract glyph image and store it in our table @@ -434,7 +409,7 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool FT_Error charcode_error, glyph_error; FT2Font *ft_object_with_glyph = this; bool was_found = load_char_with_fallback(ft_object_with_glyph, final_glyph_index, - glyphs, char_to_font, glyph_to_font, + glyphs, char_to_font, charcode, flags, charcode_error, glyph_error, glyph_seen_fonts, true); if (!was_found) { @@ -493,7 +468,6 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, FT_UInt &final_glyph_index, std::vector &parent_glyphs, std::unordered_map &parent_char_to_font, - std::unordered_map &parent_glyph_to_font, long charcode, FT_Int32 flags, FT_Error &charcode_error, @@ -523,7 +497,6 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, // need to store this for anytime a character is loaded from a parent // FT2Font object or to generate a mapping of individual characters to fonts ft_object_with_glyph = this; - parent_glyph_to_font[final_glyph_index] = this; parent_char_to_font[charcode] = this; parent_glyphs.push_back(thisGlyph); return true; @@ -532,7 +505,7 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, for (auto & fallback : fallbacks) { bool was_found = fallback->load_char_with_fallback( ft_object_with_glyph, final_glyph_index, parent_glyphs, - parent_char_to_font, parent_glyph_to_font, charcode, flags, + parent_char_to_font, charcode, flags, charcode_error, glyph_error, glyph_seen_fonts, override); if (was_found) { return true; @@ -542,21 +515,6 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, } } -void FT2Font::load_glyph(FT_UInt glyph_index, - FT_Int32 flags, - FT2Font *&ft_object, - bool fallback = false) -{ - // cache is only for parent FT2Font - if (fallback && glyph_to_font.find(glyph_index) != glyph_to_font.end()) { - ft_object = glyph_to_font[glyph_index]; - } else { - ft_object = this; - } - - ft_object->load_glyph(glyph_index, flags); -} - void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags) { FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); @@ -644,15 +602,8 @@ void FT2Font::draw_glyph_to_bitmap( draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y); } -void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer, - bool fallback = false) +void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer) { - if (fallback && glyph_to_font.find(glyph_number) != glyph_to_font.end()) { - // cache is only for parent FT2Font - FT2Font *ft_object = glyph_to_font[glyph_number]; - ft_object->get_glyph_name(glyph_number, buffer, false); - return; - } if (!FT_HAS_GLYPH_NAMES(face)) { /* Note that this generated name must match the name that is generated by ttconv in ttfont_CharStrings_getname. */ diff --git a/src/ft2font.h b/src/ft2font.h index 8db0239ed4fd..262ff395ac5d 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -108,22 +108,19 @@ class FT2Font void select_charmap(unsigned long i); void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags, std::vector &xys); - int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, bool fallback); - int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, FT_Vector &delta); + int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode); void set_kerning_factor(int factor); void load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool fallback); bool load_char_with_fallback(FT2Font *&ft_object_with_glyph, FT_UInt &final_glyph_index, std::vector &parent_glyphs, std::unordered_map &parent_char_to_font, - std::unordered_map &parent_glyph_to_font, long charcode, FT_Int32 flags, FT_Error &charcode_error, FT_Error &glyph_error, std::set &glyph_seen_fonts, bool override); - void load_glyph(FT_UInt glyph_index, FT_Int32 flags, FT2Font *&ft_object, bool fallback); void load_glyph(FT_UInt glyph_index, FT_Int32 flags); void get_width_height(long *width, long *height); void get_bitmap_offset(long *x, long *y); @@ -132,7 +129,7 @@ class FT2Font 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); + void get_glyph_name(unsigned int glyph_number, std::string &buffer); long get_name_index(char *name); FT_UInt get_char_index(FT_ULong charcode, bool fallback); void get_path(std::vector &vertices, std::vector &codes); @@ -176,7 +173,6 @@ class FT2Font FT_Vector pen; /* untransformed origin */ std::vector glyphs; std::vector fallbacks; - std::unordered_map glyph_to_font; std::unordered_map char_to_font; FT_BBox bbox; FT_Pos advance; diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index ca2db6aa0e5b..cb816efff9a9 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -618,7 +618,6 @@ static int PyFT2Font_get_kerning(PyFT2Font *self, FT_UInt left, FT_UInt right, std::variant mode_or_int) { - bool fallback = true; FT_Kerning_Mode mode; if (auto value = std::get_if(&mode_or_int)) { @@ -636,7 +635,7 @@ PyFT2Font_get_kerning(PyFT2Font *self, FT_UInt left, FT_UInt right, throw py::type_error("mode must be Kerning or int"); } - return self->x->get_kerning(left, right, mode, fallback); + return self->x->get_kerning(left, right, mode); } const char *PyFT2Font_get_fontmap__doc__ = R"""( @@ -834,8 +833,6 @@ static PyGlyph * PyFT2Font_load_glyph(PyFT2Font *self, FT_UInt glyph_index, std::variant flags_or_int = LoadFlags::FORCE_AUTOHINT) { - bool fallback = true; - FT2Font *ft_object = nullptr; LoadFlags flags; if (auto value = std::get_if(&flags_or_int)) { @@ -853,9 +850,9 @@ PyFT2Font_load_glyph(PyFT2Font *self, FT_UInt glyph_index, throw py::type_error("flags must be LoadFlags or int"); } - self->x->load_glyph(glyph_index, static_cast(flags), ft_object, fallback); + self->x->load_glyph(glyph_index, static_cast(flags)); - return PyGlyph_from_FT2Font(ft_object); + return PyGlyph_from_FT2Font(self->x); } const char *PyFT2Font_get_width_height__doc__ = R"""( @@ -1022,10 +1019,9 @@ static py::str PyFT2Font_get_glyph_name(PyFT2Font *self, unsigned int glyph_number) { std::string buffer; - bool fallback = true; buffer.resize(128); - self->x->get_glyph_name(glyph_number, buffer, fallback); + self->x->get_glyph_name(glyph_number, buffer); return buffer; } From 389373eca101a613ffb7f88271d5eb9c10712005 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 30 Jun 2025 17:44:09 -0400 Subject: [PATCH 004/108] ci: Preload existing test images from text-overhaul-figures branch This allows checking that there are no _new_ failures, without committing the new figures to the repo until the branch is complete. --- .appveyor.yml | 20 ++++++++++++++++++++ .github/workflows/tests.yml | 19 +++++++++++++++++++ azure-pipelines.yml | 19 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/.appveyor.yml b/.appveyor.yml index c3fcb0ea9591..3e3a3b884d18 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -60,6 +60,26 @@ install: - micromamba env create -f environment.yml python=%PYTHON_VERSION% pywin32 - micromamba activate mpl-dev +before_test: + - git config --global user.name 'Matplotlib' + - git config --global user.email 'nobody@matplotlib.org' + - git fetch https://github.com/QuLogic/matplotlib.git text-overhaul-figures:text-overhaul-figures + - git merge --no-commit text-overhaul-figures || true + # If there are any conflicts in baseline images, then pick "ours", + # which should be the updated images in the PR. + - ps: | + $conflicts = git diff --name-only --diff-filter=U ` + lib/matplotlib/tests/baseline_images ` + lib/mpl_toolkits/*/tests/baseline_images + if ($conflicts) { + git checkout --ours -- $conflicts + git add -- $conflicts + } + git status + # If committing fails, there were conflicts other than the baseline images, + # which should not be allowed to happen, and should fail the build. + - git commit -m "Preload test images from branch text-overhaul-figures" + test_script: # Now build the thing.. - set LINK=/LIBPATH:%cd%\lib diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85ace93445b6..53d47346c6eb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -95,6 +95,25 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Preload test images + run: | + git config --global user.name 'Matplotlib' + git config --global user.email 'nobody@matplotlib.org' + git fetch https://github.com/QuLogic/matplotlib.git text-overhaul-figures:text-overhaul-figures + git merge --no-commit text-overhaul-figures || true + # If there are any conflicts in baseline images, then pick "ours", + # which should be the updated images in the PR. + conflicts=$(git diff --name-only --diff-filter=U \ + lib/matplotlib/tests/baseline_images \ + lib/mpl_toolkits/*/tests/baseline_images) + if [ -n "${conflicts}" ]; then + git checkout --ours -- "${conflicts}" + git add -- "${conflicts}" + fi + # If committing fails, there were conflicts other than the baseline images, + # which should not be allowed to happen, and should fail the build. + git commit -m 'Preload test images from branch text-overhaul-figures' + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/azure-pipelines.yml b/azure-pipelines.yml index d68a9d36f0d3..a5a0e965e97b 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -68,6 +68,25 @@ stages: architecture: 'x64' displayName: 'Use Python $(python.version)' + - bash: | + git config --global user.name 'Matplotlib' + git config --global user.email 'nobody@matplotlib.org' + git fetch https://github.com/QuLogic/matplotlib.git text-overhaul-figures:text-overhaul-figures + git merge --no-commit text-overhaul-figures || true + # If there are any conflicts in baseline images, then pick "ours", + # which should be the updated images in the PR. + conflicts=$(git diff --name-only --diff-filter=U \ + lib/matplotlib/tests/baseline_images \ + lib/mpl_toolkits/*/tests/baseline_images) + if [ -n "${conflicts}" ]; then + git checkout --ours -- "${conflicts}" + git add -- "${conflicts}" + fi + # If committing fails, there were conflicts other than the baseline images, + # which should not be allowed to happen, and should fail the build. + git commit -m 'Preload test images from branch text-overhaul-figures' + displayName: Preload test images + - bash: | choco install ninja displayName: 'Install dependencies' From a01860688d2d1a01c1f808983c15a170aa90f099 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 3 Jun 2025 00:32:10 -0400 Subject: [PATCH 005/108] Add typing to AFM parser Also, check some expected conditions at parse time instead of somewhere during use of the data. --- lib/matplotlib/_afm.py | 231 ++++++++++++++------------ lib/matplotlib/backends/backend_ps.py | 2 +- lib/matplotlib/tests/test_afm.py | 49 +++--- 3 files changed, 154 insertions(+), 128 deletions(-) diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py index 9094206c2d7c..352d3c42247e 100644 --- a/lib/matplotlib/_afm.py +++ b/lib/matplotlib/_afm.py @@ -27,9 +27,10 @@ being used. """ -from collections import namedtuple +import inspect import logging import re +from typing import BinaryIO, NamedTuple, TypedDict from ._mathtext_data import uni2type1 @@ -37,7 +38,7 @@ _log = logging.getLogger(__name__) -def _to_int(x): +def _to_int(x: bytes | str) -> int: # Some AFM files have floats where we are expecting ints -- there is # probably a better way to handle this (support floats, round rather than # truncate). But I don't know what the best approach is now and this @@ -46,7 +47,7 @@ def _to_int(x): return int(float(x)) -def _to_float(x): +def _to_float(x: bytes | str) -> float: # Some AFM files use "," instead of "." as decimal separator -- this # shouldn't be ambiguous (unless someone is wicked enough to use "," as # thousands separator...). @@ -57,27 +58,56 @@ def _to_float(x): return float(x.replace(',', '.')) -def _to_str(x): +def _to_str(x: bytes) -> str: return x.decode('utf8') -def _to_list_of_ints(s): +def _to_list_of_ints(s: bytes) -> list[int]: s = s.replace(b',', b' ') return [_to_int(val) for val in s.split()] -def _to_list_of_floats(s): +def _to_list_of_floats(s: bytes | str) -> list[float]: return [_to_float(val) for val in s.split()] -def _to_bool(s): +def _to_bool(s: bytes) -> bool: if s.lower().strip() in (b'false', b'0', b'no'): return False else: return True -def _parse_header(fh): +class FontMetricsHeader(TypedDict, total=False): + StartFontMetrics: float + FontName: str + FullName: str + FamilyName: str + Weight: str + ItalicAngle: float + IsFixedPitch: bool + FontBBox: list[int] + UnderlinePosition: float + UnderlineThickness: float + Version: str + # Some AFM files have non-ASCII characters (which are not allowed by the spec). + # Given that there is actually no public API to even access this field, just return + # it as straight bytes. + Notice: bytes + EncodingScheme: str + CapHeight: float # Is the second version a mistake, or + Capheight: float # do some AFM files contain 'Capheight'? -JKS + XHeight: float + Ascender: float + Descender: float + StdHW: float + StdVW: float + StartCharMetrics: int + CharacterSet: str + Characters: int + + +def _parse_header(fh: BinaryIO) -> FontMetricsHeader: """ Read the font metrics header (up to the char metrics). @@ -98,34 +128,15 @@ def _parse_header(fh): * '-168 -218 1000 898' -> [-168, -218, 1000, 898] """ header_converters = { - b'StartFontMetrics': _to_float, - b'FontName': _to_str, - b'FullName': _to_str, - b'FamilyName': _to_str, - b'Weight': _to_str, - b'ItalicAngle': _to_float, - b'IsFixedPitch': _to_bool, - b'FontBBox': _to_list_of_ints, - b'UnderlinePosition': _to_float, - b'UnderlineThickness': _to_float, - b'Version': _to_str, - # Some AFM files have non-ASCII characters (which are not allowed by - # the spec). Given that there is actually no public API to even access - # this field, just return it as straight bytes. - b'Notice': lambda x: x, - b'EncodingScheme': _to_str, - b'CapHeight': _to_float, # Is the second version a mistake, or - b'Capheight': _to_float, # do some AFM files contain 'Capheight'? -JKS - b'XHeight': _to_float, - b'Ascender': _to_float, - b'Descender': _to_float, - b'StdHW': _to_float, - b'StdVW': _to_float, - b'StartCharMetrics': _to_int, - b'CharacterSet': _to_str, - b'Characters': _to_int, + bool: _to_bool, + bytes: lambda x: x, + float: _to_float, + int: _to_int, + list[int]: _to_list_of_ints, + str: _to_str, } - d = {} + header_value_types = inspect.get_annotations(FontMetricsHeader) + d: FontMetricsHeader = {} first_line = True for line in fh: line = line.rstrip() @@ -147,14 +158,16 @@ def _parse_header(fh): else: val = b'' try: - converter = header_converters[key] - except KeyError: + key_str = _to_str(key) + value_type = header_value_types[key_str] + except (KeyError, UnicodeDecodeError): _log.error("Found an unknown keyword in AFM header (was %r)", key) continue try: - d[key] = converter(val) + converter = header_converters[value_type] + d[key_str] = converter(val) # type: ignore[literal-required] except ValueError: - _log.error('Value error parsing header in AFM: %s, %s', key, val) + _log.error('Value error parsing header in AFM: %r, %r', key, val) continue if key == b'StartCharMetrics': break @@ -163,8 +176,8 @@ def _parse_header(fh): return d -CharMetrics = namedtuple('CharMetrics', 'width, name, bbox') -CharMetrics.__doc__ = """ +class CharMetrics(NamedTuple): + """ Represents the character metrics of a single character. Notes @@ -172,13 +185,20 @@ def _parse_header(fh): The fields do currently only describe a subset of character metrics information defined in the AFM standard. """ + + width: float + name: str + bbox: tuple[int, int, int, int] + + CharMetrics.width.__doc__ = """The character width (WX).""" CharMetrics.name.__doc__ = """The character name (N).""" CharMetrics.bbox.__doc__ = """ The bbox of the character (B) as a tuple (*llx*, *lly*, *urx*, *ury*).""" -def _parse_char_metrics(fh): +def _parse_char_metrics(fh: BinaryIO) -> tuple[dict[int, CharMetrics], + dict[str, CharMetrics]]: """ Parse the given filehandle for character metrics information. @@ -198,12 +218,12 @@ def _parse_char_metrics(fh): """ required_keys = {'C', 'WX', 'N', 'B'} - ascii_d = {} - name_d = {} - for line in fh: + ascii_d: dict[int, CharMetrics] = {} + name_d: dict[str, CharMetrics] = {} + for bline in fh: # We are defensively letting values be utf8. The spec requires # ascii, but there are non-compliant fonts in circulation - line = _to_str(line.rstrip()) # Convert from byte-literal + line = _to_str(bline.rstrip()) if line.startswith('EndCharMetrics'): return ascii_d, name_d # Split the metric line into a dictionary, keyed by metric identifiers @@ -214,8 +234,9 @@ def _parse_char_metrics(fh): num = _to_int(vals['C']) wx = _to_float(vals['WX']) name = vals['N'] - bbox = _to_list_of_floats(vals['B']) - bbox = list(map(int, bbox)) + bbox = tuple(map(int, _to_list_of_floats(vals['B']))) + if len(bbox) != 4: + raise RuntimeError(f'Bad parse: bbox has {len(bbox)} elements, should be 4') metrics = CharMetrics(wx, name, bbox) # Workaround: If the character name is 'Euro', give it the # corresponding character code, according to WinAnsiEncoding (see PDF @@ -230,7 +251,7 @@ def _parse_char_metrics(fh): raise RuntimeError('Bad parse') -def _parse_kern_pairs(fh): +def _parse_kern_pairs(fh: BinaryIO) -> dict[tuple[str, str], float]: """ Return a kern pairs dictionary. @@ -242,12 +263,11 @@ def _parse_kern_pairs(fh): d['A', 'y'] = -50 """ - line = next(fh) if not line.startswith(b'StartKernPairs'): - raise RuntimeError('Bad start of kern pairs data: %s' % line) + raise RuntimeError(f'Bad start of kern pairs data: {line!r}') - d = {} + d: dict[tuple[str, str], float] = {} for line in fh: line = line.rstrip() if not line: @@ -257,21 +277,26 @@ def _parse_kern_pairs(fh): return d vals = line.split() if len(vals) != 4 or vals[0] != b'KPX': - raise RuntimeError('Bad kern pairs line: %s' % line) + raise RuntimeError(f'Bad kern pairs line: {line!r}') c1, c2, val = _to_str(vals[1]), _to_str(vals[2]), _to_float(vals[3]) d[(c1, c2)] = val raise RuntimeError('Bad kern pairs parse') -CompositePart = namedtuple('CompositePart', 'name, dx, dy') -CompositePart.__doc__ = """ - Represents the information on a composite element of a composite char.""" +class CompositePart(NamedTuple): + """Represents the information on a composite element of a composite char.""" + + name: bytes + dx: float + dy: float + + CompositePart.name.__doc__ = """Name of the part, e.g. 'acute'.""" CompositePart.dx.__doc__ = """x-displacement of the part from the origin.""" CompositePart.dy.__doc__ = """y-displacement of the part from the origin.""" -def _parse_composites(fh): +def _parse_composites(fh: BinaryIO) -> dict[bytes, list[CompositePart]]: """ Parse the given filehandle for composites information. @@ -292,11 +317,11 @@ def _parse_composites(fh): will be represented as:: - composites['Aacute'] = [CompositePart(name='A', dx=0, dy=0), - CompositePart(name='acute', dx=160, dy=170)] + composites[b'Aacute'] = [CompositePart(name=b'A', dx=0, dy=0), + CompositePart(name=b'acute', dx=160, dy=170)] """ - composites = {} + composites: dict[bytes, list[CompositePart]] = {} for line in fh: line = line.rstrip() if not line: @@ -306,6 +331,9 @@ def _parse_composites(fh): vals = line.split(b';') cc = vals[0].split() name, _num_parts = cc[1], _to_int(cc[2]) + if len(vals) != _num_parts + 2: # First element is 'CC', last is empty. + raise RuntimeError(f'Bad composites parse: expected {_num_parts} parts, ' + f'but got {len(vals) - 2}') pccParts = [] for s in vals[1:-1]: pcc = s.split() @@ -316,7 +344,8 @@ def _parse_composites(fh): raise RuntimeError('Bad composites parse') -def _parse_optional(fh): +def _parse_optional(fh: BinaryIO) -> tuple[dict[tuple[str, str], float], + dict[bytes, list[CompositePart]]]: """ Parse the optional fields for kern pair data and composites. @@ -329,44 +358,38 @@ def _parse_optional(fh): A dict containing composite information. May be empty. See `._parse_composites`. """ - optional = { - b'StartKernData': _parse_kern_pairs, - b'StartComposites': _parse_composites, - } - - d = {b'StartKernData': {}, - b'StartComposites': {}} + kern_data: dict[tuple[str, str], float] = {} + composites: dict[bytes, list[CompositePart]] = {} for line in fh: line = line.rstrip() if not line: continue - key = line.split()[0] - - if key in optional: - d[key] = optional[key](fh) + match line.split()[0]: + case b'StartKernData': + kern_data = _parse_kern_pairs(fh) + case b'StartComposites': + composites = _parse_composites(fh) - return d[b'StartKernData'], d[b'StartComposites'] + return kern_data, composites class AFM: - def __init__(self, fh): + def __init__(self, fh: BinaryIO): """Parse the AFM file in file object *fh*.""" self._header = _parse_header(fh) self._metrics, self._metrics_by_name = _parse_char_metrics(fh) self._kern, self._composite = _parse_optional(fh) - def get_str_bbox_and_descent(self, s): + def get_str_bbox_and_descent(self, s: str) -> tuple[int, int, float, int, int]: """Return the string bounding box and the maximal descent.""" if not len(s): return 0, 0, 0, 0, 0 - total_width = 0 - namelast = None - miny = 1e9 + total_width = 0.0 + namelast = '' + miny = 1_000_000_000 maxy = 0 left = 0 - if not isinstance(s, str): - s = _to_str(s) for c in s: if c == '\n': continue @@ -386,11 +409,11 @@ def get_str_bbox_and_descent(self, s): return left, miny, total_width, maxy - miny, -miny - def get_glyph_name(self, glyph_ind): # For consistency with FT2Font. + def get_glyph_name(self, glyph_ind: int) -> str: # For consistency with FT2Font. """Get the name of the glyph, i.e., ord(';') is 'semicolon'.""" return self._metrics[glyph_ind].name - def get_char_index(self, c): # For consistency with FT2Font. + def get_char_index(self, c: int) -> int: # For consistency with FT2Font. """ Return the glyph index corresponding to a character code point. @@ -398,38 +421,38 @@ def get_char_index(self, c): # For consistency with FT2Font. """ return c - def get_width_char(self, c): + def get_width_char(self, c: int) -> float: """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): + def get_width_from_char_name(self, name: str) -> float: """Get the width of the character from a type1 character name.""" return self._metrics_by_name[name].width - def get_kern_dist_from_name(self, name1, name2): + def get_kern_dist_from_name(self, name1: str, name2: str) -> float: """ Return the kerning pair distance (possibly 0) for chars *name1* and *name2*. """ return self._kern.get((name1, name2), 0) - def get_fontname(self): + def get_fontname(self) -> str: """Return the font name, e.g., 'Times-Roman'.""" - return self._header[b'FontName'] + return self._header['FontName'] @property - def postscript_name(self): # For consistency with FT2Font. + def postscript_name(self) -> str: # For consistency with FT2Font. return self.get_fontname() - def get_fullname(self): + def get_fullname(self) -> str: """Return the font full name, e.g., 'Times-Roman'.""" - name = self._header.get(b'FullName') + name = self._header.get('FullName') if name is None: # use FontName as a substitute - name = self._header[b'FontName'] + name = self._header['FontName'] return name - def get_familyname(self): + def get_familyname(self) -> str: """Return the font family name, e.g., 'Times'.""" - name = self._header.get(b'FamilyName') + name = self._header.get('FamilyName') if name is not None: return name @@ -440,26 +463,26 @@ def get_familyname(self): return re.sub(extras, '', name) @property - def family_name(self): # For consistency with FT2Font. + def family_name(self) -> str: # For consistency with FT2Font. """The font family name, e.g., 'Times'.""" return self.get_familyname() - def get_weight(self): + def get_weight(self) -> str: """Return the font weight, e.g., 'Bold' or 'Roman'.""" - return self._header[b'Weight'] + return self._header['Weight'] - def get_angle(self): + def get_angle(self) -> float: """Return the fontangle as float.""" - return self._header[b'ItalicAngle'] + return self._header['ItalicAngle'] - def get_capheight(self): + def get_capheight(self) -> float: """Return the cap height as float.""" - return self._header[b'CapHeight'] + return self._header['CapHeight'] - def get_xheight(self): + def get_xheight(self) -> float: """Return the xheight as float.""" - return self._header[b'XHeight'] + return self._header['XHeight'] - def get_underline_thickness(self): + def get_underline_thickness(self) -> float: """Return the underline thickness as float.""" - return self._header[b'UnderlineThickness'] + return self._header['UnderlineThickness'] diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index ea5868387918..368564a1518d 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -779,7 +779,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): .decode("ascii")) scale = 0.001 * prop.get_size_in_points() thisx = 0 - last_name = None # kerns returns 0 for None. + last_name = '' # kerns returns 0 for ''. for c in s: name = uni2type1.get(ord(c), f"uni{ord(c):04X}") try: diff --git a/lib/matplotlib/tests/test_afm.py b/lib/matplotlib/tests/test_afm.py index 80cf8ac60feb..bc1d587baf6b 100644 --- a/lib/matplotlib/tests/test_afm.py +++ b/lib/matplotlib/tests/test_afm.py @@ -47,20 +47,20 @@ def test_parse_header(): fh = BytesIO(AFM_TEST_DATA) header = _afm._parse_header(fh) assert header == { - b'StartFontMetrics': 2.0, - b'FontName': 'MyFont-Bold', - b'EncodingScheme': 'FontSpecific', - b'FullName': 'My Font Bold', - b'FamilyName': 'Test Fonts', - b'Weight': 'Bold', - b'ItalicAngle': 0.0, - b'IsFixedPitch': False, - b'UnderlinePosition': -100, - b'UnderlineThickness': 56.789, - b'Version': '001.000', - b'Notice': b'Copyright \xa9 2017 No one.', - b'FontBBox': [0, -321, 1234, 369], - b'StartCharMetrics': 3, + 'StartFontMetrics': 2.0, + 'FontName': 'MyFont-Bold', + 'EncodingScheme': 'FontSpecific', + 'FullName': 'My Font Bold', + 'FamilyName': 'Test Fonts', + 'Weight': 'Bold', + 'ItalicAngle': 0.0, + 'IsFixedPitch': False, + 'UnderlinePosition': -100, + 'UnderlineThickness': 56.789, + 'Version': '001.000', + 'Notice': b'Copyright \xa9 2017 No one.', + 'FontBBox': [0, -321, 1234, 369], + 'StartCharMetrics': 3, } @@ -69,20 +69,23 @@ def test_parse_char_metrics(): _afm._parse_header(fh) # position metrics = _afm._parse_char_metrics(fh) assert metrics == ( - {0: (250.0, 'space', [0, 0, 0, 0]), - 42: (1141.0, 'foo', [40, 60, 800, 360]), - 99: (583.0, 'bar', [40, -10, 543, 210]), - }, - {'space': (250.0, 'space', [0, 0, 0, 0]), - 'foo': (1141.0, 'foo', [40, 60, 800, 360]), - 'bar': (583.0, 'bar', [40, -10, 543, 210]), - }) + { + 0: _afm.CharMetrics(250.0, 'space', (0, 0, 0, 0)), + 42: _afm.CharMetrics(1141.0, 'foo', (40, 60, 800, 360)), + 99: _afm.CharMetrics(583.0, 'bar', (40, -10, 543, 210)), + }, + { + 'space': _afm.CharMetrics(250.0, 'space', (0, 0, 0, 0)), + 'foo': _afm.CharMetrics(1141.0, 'foo', (40, 60, 800, 360)), + 'bar': _afm.CharMetrics(583.0, 'bar', (40, -10, 543, 210)), + } + ) def test_get_familyname_guessed(): fh = BytesIO(AFM_TEST_DATA) font = _afm.AFM(fh) - del font._header[b'FamilyName'] # remove FamilyName, so we have to guess + del font._header['FamilyName'] # remove FamilyName, so we have to guess assert font.get_familyname() == 'My Font' From aff20cf00c41e61ee7b72fdd803a8b389530e076 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 8 Jul 2025 03:02:36 -0400 Subject: [PATCH 006/108] ci: Fix image preload with multiple conflicts --- .github/workflows/tests.yml | 4 ++-- azure-pipelines.yml | 4 ++-- lib/matplotlib/mlab.py | 3 --- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 53d47346c6eb..7c27ec84f86a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,8 +107,8 @@ jobs: lib/matplotlib/tests/baseline_images \ lib/mpl_toolkits/*/tests/baseline_images) if [ -n "${conflicts}" ]; then - git checkout --ours -- "${conflicts}" - git add -- "${conflicts}" + git checkout --ours -- ${conflicts} + git add -- ${conflicts} fi # If committing fails, there were conflicts other than the baseline images, # which should not be allowed to happen, and should fail the build. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a5a0e965e97b..eef71162f9cb 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -79,8 +79,8 @@ stages: lib/matplotlib/tests/baseline_images \ lib/mpl_toolkits/*/tests/baseline_images) if [ -n "${conflicts}" ]; then - git checkout --ours -- "${conflicts}" - git add -- "${conflicts}" + git checkout --ours -- ${conflicts} + git add -- ${conflicts} fi # If committing fails, there were conflicts other than the baseline images, # which should not be allowed to happen, and should fail the build. diff --git a/lib/matplotlib/mlab.py b/lib/matplotlib/mlab.py index b4b4c3f96828..de890935c23b 100644 --- a/lib/matplotlib/mlab.py +++ b/lib/matplotlib/mlab.py @@ -219,9 +219,6 @@ def _stride_windows(x, n, noverlap=0): raise ValueError(f'n ({n}) and noverlap ({noverlap}) must be positive integers ' f'with n < noverlap and n <= x.size ({x.size})') - if n == 1 and noverlap == 0: - return x[np.newaxis] - step = n - noverlap shape = (n, (x.shape[-1]-noverlap)//step) strides = (x.strides[0], step*x.strides[0]) From 7b4d725306b2d5907a023c14a30474a04280f804 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 28 Mar 2025 00:09:04 -0400 Subject: [PATCH 007/108] Remove kerning_factor from tests --- .../mpl-data/stylelib/_classic_test_patch.mplstyle | 2 -- lib/matplotlib/tests/test_artist.py | 3 --- lib/matplotlib/tests/test_legend.py | 3 --- lib/matplotlib/tests/test_text.py | 5 ----- lib/mpl_toolkits/axisartist/tests/test_axis_artist.py | 9 --------- lib/mpl_toolkits/axisartist/tests/test_axislines.py | 6 ------ lib/mpl_toolkits/axisartist/tests/test_floating_axes.py | 3 --- .../axisartist/tests/test_grid_helper_curvelinear.py | 5 +---- 8 files changed, 1 insertion(+), 35 deletions(-) diff --git a/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle b/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle index abd972925871..478ff5e415f9 100644 --- a/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle +++ b/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle @@ -1,8 +1,6 @@ # This patch should go on top of the "classic" style and exists solely to avoid # changing baseline images. -text.kerning_factor : 6 - ytick.alignment: center_baseline hatch.color: edge diff --git a/lib/matplotlib/tests/test_artist.py b/lib/matplotlib/tests/test_artist.py index 1367701ffe3e..d891609d4eb3 100644 --- a/lib/matplotlib/tests/test_artist.py +++ b/lib/matplotlib/tests/test_artist.py @@ -217,9 +217,6 @@ def test_remove(): @image_comparison(["default_edges.png"], remove_text=True, style='default') def test_default_edges(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig, [[ax1, ax2], [ax3, ax4]] = plt.subplots(2, 2) ax1.plot(np.arange(10), np.arange(10), 'x', diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 9b100037cc41..7ef31bf64a53 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -258,9 +258,6 @@ def test_legend_expand(): @image_comparison(['hatching'], remove_text=True, style='default') def test_hatching(): # Remove legend texts when this image is regenerated. - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig, ax = plt.subplots() # Patches diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 7e1a50df8a2f..911de1d4a59a 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -139,9 +139,6 @@ def test_multiline(): @image_comparison(['multiline2'], style='mpl20') def test_multiline2(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig, ax = plt.subplots() ax.set_xlim(0, 1.4) @@ -694,8 +691,6 @@ def test_annotation_units(fig_test, fig_ref): @image_comparison(['large_subscript_title.png'], style='mpl20') def test_large_subscript_title(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 plt.rcParams['axes.titley'] = None fig, axs = plt.subplots(1, 2, figsize=(9, 2.5), constrained_layout=True) diff --git a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py index d44a61b6dd4a..7caf4fc21683 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py @@ -26,9 +26,6 @@ def test_ticks(): @image_comparison(['axis_artist_labelbase.png'], style='default') def test_labelbase(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig, ax = plt.subplots() ax.plot([0.5], [0.5], "o") @@ -43,9 +40,6 @@ def test_labelbase(): @image_comparison(['axis_artist_ticklabels.png'], style='default') def test_ticklabels(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig, ax = plt.subplots() ax.xaxis.set_visible(False) @@ -78,9 +72,6 @@ def test_ticklabels(): @image_comparison(['axis_artist.png'], style='default') def test_axis_artist(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig, ax = plt.subplots() ax.xaxis.set_visible(False) diff --git a/lib/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py index a1485d4f436b..c371d6453932 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_axislines.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py @@ -9,9 +9,6 @@ @image_comparison(['SubplotZero.png'], style='default') def test_SubplotZero(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig = plt.figure() ax = SubplotZero(fig, 1, 1, 1) @@ -30,9 +27,6 @@ def test_SubplotZero(): @image_comparison(['Subplot.png'], style='default') def test_Subplot(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig = plt.figure() ax = Subplot(fig, 1, 1, 1) diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index feb667af013e..3dd4309d199e 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -69,9 +69,6 @@ def test_curvelinear3(): # remove when image is regenerated. @image_comparison(['curvelinear4.png'], style='default', tol=0.9) def test_curvelinear4(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + 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 7d6554782fe6..f58a42471680 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -135,11 +135,8 @@ def test_polar_box(): ax1.grid(True) -# Remove tol & kerning_factor when this test image is regenerated. -@image_comparison(['axis_direction.png'], style='default', tol=0.13) +@image_comparison(['axis_direction.png'], style='default', tol=0.04) def test_axis_direction(): - plt.rcParams['text.kerning_factor'] = 6 - fig = plt.figure(figsize=(5, 5)) # PolarAxes.PolarTransform takes radian. However, we want our coordinate From 8255ae206b273524657a4c81c8c06162a31a27e0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 9 Apr 2025 05:03:23 -0400 Subject: [PATCH 008/108] Set text hinting to defaults Namely, `text.hinting` is now `default` instead of `force_autohint` (or `none` for classic tests) and `text.hinting_factor` is now 1, not 8. --- lib/matplotlib/mpl-data/matplotlibrc | 4 ++-- .../mpl-data/stylelib/_classic_test_patch.mplstyle | 3 +++ lib/matplotlib/testing/__init__.py | 11 +++++++++-- lib/matplotlib/tests/test_axes.py | 6 +++--- lib/matplotlib/tests/test_figure.py | 2 +- lib/matplotlib/tests/test_legend.py | 8 ++++---- lib/matplotlib/tests/test_polar.py | 2 +- lib/matplotlib/tests/test_text.py | 6 +++--- 8 files changed, 26 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index ec649560ba3b..e1f66cce0c36 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -301,9 +301,9 @@ ## ("native" is a synonym.) ## - force_autohint: Use FreeType's auto-hinter. ("auto" is a synonym.) ## - no_hinting: Disable hinting. ("none" is a synonym.) -#text.hinting: force_autohint +#text.hinting: default -#text.hinting_factor: 8 # Specifies the amount of softness for hinting in the +#text.hinting_factor: 1 # Specifies the amount of softness for hinting in the # horizontal direction. A value of 1 will hint to full # pixels. A value of 2 will hint to half pixels etc. #text.kerning_factor: 0 # Specifies the scaling factor for kerning values. This diff --git a/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle b/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle index 478ff5e415f9..3dc92f832b20 100644 --- a/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle +++ b/lib/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle @@ -4,3 +4,6 @@ ytick.alignment: center_baseline hatch.color: edge + +text.hinting: default +text.hinting_factor: 1 diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index d6affb1b039f..453cc631c0ea 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -19,8 +19,15 @@ def set_font_settings_for_testing(): mpl.rcParams['font.family'] = 'DejaVu Sans' - mpl.rcParams['text.hinting'] = 'none' - mpl.rcParams['text.hinting_factor'] = 8 + # We've changed the default for ourselves here, but for backwards-compatibility, use + # the old setting if not called in our own tests (which would set + # `_called_from_pytest` from our `conftest.py`). + if getattr(mpl, '_called_from_pytest', False): + mpl.rcParams['text.hinting'] = 'default' + mpl.rcParams['text.hinting_factor'] = 1 + else: + mpl.rcParams['text.hinting'] = 'none' + mpl.rcParams['text.hinting_factor'] = 8 def set_reproducibility_for_testing(): diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index c96173e340f7..60d507e2b999 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -8272,8 +8272,8 @@ def test_normal_axes(): # test the axis bboxes target = [ - [123.375, 75.88888888888886, 983.25, 33.0], - [85.51388888888889, 99.99999999999997, 53.375, 993.0] + [124.0, 76.89, 982.0, 32.0], + [86.89, 100.5, 52.0, 992.0], ] for nn, b in enumerate(bbaxis): targetbb = mtransforms.Bbox.from_bounds(*target[nn]) @@ -8293,7 +8293,7 @@ def test_normal_axes(): targetbb = mtransforms.Bbox.from_bounds(*target) assert_array_almost_equal(bbax.bounds, targetbb.bounds, decimal=2) - target = [85.5138, 75.88888, 1021.11, 1017.11] + target = [86.89, 76.89, 1019.11, 1015.61] targetbb = mtransforms.Bbox.from_bounds(*target) assert_array_almost_equal(bbtb.bounds, targetbb.bounds, decimal=2) diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index c5890a2963b3..cad77e2d00d7 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -814,7 +814,7 @@ def test_tightbbox(): ax.set_xlim(0, 1) t = ax.text(1., 0.5, 'This dangles over end') renderer = fig.canvas.get_renderer() - x1Nom0 = 9.035 # inches + x1Nom0 = 8.9375 # inches assert abs(t.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 assert abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 assert abs(fig.get_tightbbox(renderer).x1 - x1Nom0) < 0.05 diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 7ef31bf64a53..9c43217cabb5 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -481,10 +481,10 @@ def test_figure_legend_outside(): todos += ['left ' + pos for pos in ['lower', 'center', 'upper']] todos += ['right ' + pos for pos in ['lower', 'center', 'upper']] - upperext = [20.347556, 27.722556, 790.583, 545.499] - lowerext = [20.347556, 71.056556, 790.583, 588.833] - leftext = [151.681556, 27.722556, 790.583, 588.833] - rightext = [20.347556, 27.722556, 659.249, 588.833] + upperext = [20.722556, 26.722556, 790.333, 545.999] + lowerext = [20.722556, 70.056556, 790.333, 589.333] + leftext = [152.056556, 26.722556, 790.333, 589.333] + rightext = [20.722556, 26.722556, 658.999, 589.333] axbb = [upperext, upperext, upperext, lowerext, lowerext, lowerext, leftext, leftext, leftext, diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index c0bf72b89eb0..83368f819242 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -328,7 +328,7 @@ def test_get_tightbbox_polar(): fig.canvas.draw() bb = ax.get_tightbbox(fig.canvas.get_renderer()) assert_allclose( - bb.extents, [107.7778, 29.2778, 539.7847, 450.7222], rtol=1e-03) + bb.extents, [108.27778, 28.7778, 539.7222, 451.2222], rtol=1e-03) @check_figures_equal() diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 911de1d4a59a..9b894a650bcf 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -720,14 +720,14 @@ def test_wrap(x, rotation, halign): s = 'This is a very long text that should be wrapped multiple times.' text = subfig.text(x, 0.7, s, wrap=True, rotation=rotation, ha=halign) fig.canvas.draw() - assert text._get_wrapped_text() == ('This is a very long\n' - 'text that should be\n' + assert text._get_wrapped_text() == ('This is a very long text\n' + 'that should be\n' 'wrapped multiple\n' 'times.') def test_mathwrap(): - fig = plt.figure(figsize=(6, 4)) + fig = plt.figure(figsize=(5, 4)) s = r'This is a very $\overline{\mathrm{long}}$ line of Mathtext.' text = fig.text(0, 0.5, s, size=40, wrap=True) fig.canvas.draw() From 89c054dc80e61425f0e07f192a62801cf6a22cc1 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 27 Mar 2025 04:32:15 -0400 Subject: [PATCH 009/108] Update FreeType to 2.13.3 --- .pre-commit-config.yaml | 4 +- extern/meson.build | 13 +- lib/matplotlib/__init__.py | 2 +- lib/matplotlib/tests/test_bbox_tight.py | 2 +- lib/matplotlib/tests/test_figure.py | 5 +- lib/matplotlib/tests/test_ft2font.py | 6 +- lib/matplotlib/tests/test_mathtext.py | 16 +- lib/matplotlib/tests/test_text.py | 4 +- subprojects/freetype-2.13.3.wrap | 13 + subprojects/freetype-2.6.1.wrap | 10 - .../freetype-2.6.1-meson/LICENSE.build | 19 - .../builds/unix/ftconfig.h.in | 498 ---------- .../include/freetype/config/ftoption.h.in | 886 ------------------ .../freetype-2.6.1-meson/meson.build | 193 ---- .../freetype-2.6.1-meson/src/gzip/zconf.h | 284 ------ ...d655f1696da774b5cdd4c5effb312153232f.patch | 36 + 16 files changed, 80 insertions(+), 1911 deletions(-) create mode 100644 subprojects/freetype-2.13.3.wrap delete mode 100644 subprojects/freetype-2.6.1.wrap delete mode 100644 subprojects/packagefiles/freetype-2.6.1-meson/LICENSE.build delete mode 100644 subprojects/packagefiles/freetype-2.6.1-meson/builds/unix/ftconfig.h.in delete mode 100644 subprojects/packagefiles/freetype-2.6.1-meson/include/freetype/config/ftoption.h.in delete mode 100644 subprojects/packagefiles/freetype-2.6.1-meson/meson.build delete mode 100644 subprojects/packagefiles/freetype-2.6.1-meson/src/gzip/zconf.h create mode 100644 subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86a9a0f45440..595d69f65b4a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,13 +20,13 @@ repos: - id: check-docstring-first exclude: lib/matplotlib/typing.py # docstring used for attribute flagged by check - id: end-of-file-fixer - exclude_types: [svg] + exclude_types: [diff, svg] - id: mixed-line-ending - id: name-tests-test args: ["--pytest-test-first"] - id: no-commit-to-branch # Default is master and main. - id: trailing-whitespace - exclude_types: [svg] + exclude_types: [diff, svg] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 hooks: diff --git a/extern/meson.build b/extern/meson.build index 5463183a9099..7f7c2511c3d5 100644 --- a/extern/meson.build +++ b/extern/meson.build @@ -13,11 +13,20 @@ else # must match the value in `lib/matplotlib.__init__.py`. Also update the docs # in `docs/devel/dependencies.rst`. Bump the cache key in # `.circleci/config.yml` when changing requirements. - LOCAL_FREETYPE_VERSION = '2.6.1' + LOCAL_FREETYPE_VERSION = '2.13.3' freetype_proj = subproject( f'freetype-@LOCAL_FREETYPE_VERSION@', - default_options: ['default_library=static']) + default_options: [ + 'default_library=static', + 'brotli=disabled', + 'bzip2=disabled', + 'harfbuzz=disabled', + 'mmap=auto', + 'png=disabled', + 'tests=disabled', + 'zlib=internal', + ]) freetype_dep = freetype_proj.get_variable('freetype_dep') endif diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index e98e8ea07502..008d4de77a3b 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1329,7 +1329,7 @@ def _val_or_rc(val, *rc_names): def _init_tests(): # The version of FreeType to install locally for running the tests. This must match # the value in `meson.build`. - LOCAL_FREETYPE_VERSION = '2.6.1' + LOCAL_FREETYPE_VERSION = '2.13.3' from matplotlib import ft2font if (ft2font.__freetype_version__ != LOCAL_FREETYPE_VERSION or diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 431ca70bf7ea..2ae94abcd7b2 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -47,7 +47,7 @@ def test_bbox_inches_tight(text_placeholders): @image_comparison(['bbox_inches_tight_suptile_legend'], savefig_kwarg={'bbox_inches': 'tight'}, - tol=0 if platform.machine() == 'x86_64' else 0.02) + tol=0 if platform.machine() == 'x86_64' else 0.022) def test_bbox_inches_tight_suptile_legend(): plt.plot(np.arange(10), label='a straight line') plt.legend(bbox_to_anchor=(0.9, 1), loc='upper left') diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index cad77e2d00d7..900e184c6741 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -814,7 +814,7 @@ def test_tightbbox(): ax.set_xlim(0, 1) t = ax.text(1., 0.5, 'This dangles over end') renderer = fig.canvas.get_renderer() - x1Nom0 = 8.9375 # inches + x1Nom0 = 8.9875 # inches assert abs(t.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 assert abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 assert abs(fig.get_tightbbox(renderer).x1 - x1Nom0) < 0.05 @@ -1376,7 +1376,8 @@ def test_subfigure_dpi(): @image_comparison(['test_subfigure_ss.png'], style='mpl20', - savefig_kwarg={'facecolor': 'teal'}, tol=0.02) + savefig_kwarg={'facecolor': 'teal'}, + tol=0.022) def test_subfigure_ss(): # test assigning the subfigure via subplotspec np.random.seed(19680801) diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 8b448e17b7fd..0dc0667d0e84 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -708,10 +708,10 @@ def test_ft2font_get_sfnt_table(font_name, header): @pytest.mark.parametrize('left, right, unscaled, unfitted, default', [ # These are all the same class. - ('A', 'A', 57, 248, 256), ('A', 'À', 57, 248, 256), ('A', 'Á', 57, 248, 256), - ('A', 'Â', 57, 248, 256), ('A', 'Ã', 57, 248, 256), ('A', 'Ä', 57, 248, 256), + ('A', 'A', 57, 247, 256), ('A', 'À', 57, 247, 256), ('A', 'Á', 57, 247, 256), + ('A', 'Â', 57, 247, 256), ('A', 'Ã', 57, 247, 256), ('A', 'Ä', 57, 247, 256), # And a few other random ones. - ('D', 'A', -36, -156, -128), ('T', '.', -243, -1056, -1024), + ('D', 'A', -36, -156, -128), ('T', '.', -243, -1055, -1024), ('X', 'C', -149, -647, -640), ('-', 'J', 114, 495, 512), ]) def test_ft2font_get_kerning(left, right, unscaled, unfitted, default): diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 39c28dc9228c..4fc04a627dd5 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -568,14 +568,14 @@ def test_box_repr(): _mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING), fontsize=12, dpi=100)) assert s == textwrap.dedent("""\ - Hlist[ + Hlist[ Hlist[], - Hlist[ - Hlist[ - Vlist[ - HCentered[ + Hlist[ + Hlist[ + Vlist[ + HCentered[ Glue, - Hlist[ + Hlist[ `1`, k2.36, ], @@ -584,9 +584,9 @@ def test_box_repr(): Vbox, Hrule, Vbox, - HCentered[ + HCentered[ Glue, - Hlist[ + Hlist[ `2`, k2.02, ], diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 9b894a650bcf..9d943fa9df13 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -720,8 +720,8 @@ def test_wrap(x, rotation, halign): s = 'This is a very long text that should be wrapped multiple times.' text = subfig.text(x, 0.7, s, wrap=True, rotation=rotation, ha=halign) fig.canvas.draw() - assert text._get_wrapped_text() == ('This is a very long text\n' - 'that should be\n' + assert text._get_wrapped_text() == ('This is a very long\n' + 'text that should be\n' 'wrapped multiple\n' 'times.') diff --git a/subprojects/freetype-2.13.3.wrap b/subprojects/freetype-2.13.3.wrap new file mode 100644 index 000000000000..68f688a35861 --- /dev/null +++ b/subprojects/freetype-2.13.3.wrap @@ -0,0 +1,13 @@ +[wrap-file] +directory = freetype-2.13.3 +source_url = https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz +source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.13.3/freetype-2.13.3.tar.xz +source_filename = freetype-2.13.3.tar.xz +source_hash = 0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289 + +# https://gitlab.freedesktop.org/freetype/freetype/-/commit/34aed655f1696da774b5cdd4c5effb312153232f +diff_files = freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch + +[provide] +freetype2 = freetype_dep +freetype = freetype_dep diff --git a/subprojects/freetype-2.6.1.wrap b/subprojects/freetype-2.6.1.wrap deleted file mode 100644 index 763362b84df0..000000000000 --- a/subprojects/freetype-2.6.1.wrap +++ /dev/null @@ -1,10 +0,0 @@ -[wrap-file] -source_url = https://download.savannah.gnu.org/releases/freetype/freetype-old/freetype-2.6.1.tar.gz -source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.6.1/freetype-2.6.1.tar.gz -source_filename = freetype-2.6.1.tar.gz -source_hash = 0a3c7dfbda6da1e8fce29232e8e96d987ababbbf71ebc8c75659e4132c367014 - -patch_directory = freetype-2.6.1-meson - -[provide] -freetype-2.6.1 = freetype_dep diff --git a/subprojects/packagefiles/freetype-2.6.1-meson/LICENSE.build b/subprojects/packagefiles/freetype-2.6.1-meson/LICENSE.build deleted file mode 100644 index ec288041f388..000000000000 --- a/subprojects/packagefiles/freetype-2.6.1-meson/LICENSE.build +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2018 The Meson development team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/subprojects/packagefiles/freetype-2.6.1-meson/builds/unix/ftconfig.h.in b/subprojects/packagefiles/freetype-2.6.1-meson/builds/unix/ftconfig.h.in deleted file mode 100644 index 400f3a2a5bf2..000000000000 --- a/subprojects/packagefiles/freetype-2.6.1-meson/builds/unix/ftconfig.h.in +++ /dev/null @@ -1,498 +0,0 @@ -/***************************************************************************/ -/* */ -/* ftconfig.in */ -/* */ -/* UNIX-specific configuration file (specification only). */ -/* */ -/* Copyright 1996-2015 by */ -/* David Turner, Robert Wilhelm, and Werner Lemberg. */ -/* */ -/* This file is part of the FreeType project, and may only be used, */ -/* modified, and distributed under the terms of the FreeType project */ -/* license, LICENSE.TXT. By continuing to use, modify, or distribute */ -/* this file you indicate that you have read the license and */ -/* understand and accept it fully. */ -/* */ -/***************************************************************************/ - - - /*************************************************************************/ - /* */ - /* This header file contains a number of macro definitions that are used */ - /* by the rest of the engine. Most of the macros here are automatically */ - /* determined at compile time, and you should not need to change it to */ - /* port FreeType, except to compile the library with a non-ANSI */ - /* compiler. */ - /* */ - /* Note however that if some specific modifications are needed, we */ - /* advise you to place a modified copy in your build directory. */ - /* */ - /* The build directory is usually `builds/', and contains */ - /* system-specific files that are always included first when building */ - /* the library. */ - /* */ - /*************************************************************************/ - -/* MESON: based on unix/ftconfig.in with but meson-friendly configuration defines */ - -#ifndef FTCONFIG_H_ -#define FTCONFIG_H_ - -#include -#include FT_CONFIG_OPTIONS_H -#include FT_CONFIG_STANDARD_LIBRARY_H - - -FT_BEGIN_HEADER - - - /*************************************************************************/ - /* */ - /* PLATFORM-SPECIFIC CONFIGURATION MACROS */ - /* */ - /* These macros can be toggled to suit a specific system. The current */ - /* ones are defaults used to compile FreeType in an ANSI C environment */ - /* (16bit compilers are also supported). Copy this file to your own */ - /* `builds/' directory, and edit it to port the engine. */ - /* */ - /*************************************************************************/ - - -#define HAVE_UNISTD_H @HAVE_UNISTD_H@ -#define HAVE_FCNTL_H @HAVE_FCNTL_H@ -#define HAVE_STDINT_H @HAVE_STDINT_H@ - - - /* There are systems (like the Texas Instruments 'C54x) where a `char' */ - /* has 16 bits. ANSI C says that sizeof(char) is always 1. Since an */ - /* `int' has 16 bits also for this system, sizeof(int) gives 1 which */ - /* is probably unexpected. */ - /* */ - /* `CHAR_BIT' (defined in limits.h) gives the number of bits in a */ - /* `char' type. */ - -#ifndef FT_CHAR_BIT -#define FT_CHAR_BIT CHAR_BIT -#endif - - -#undef FT_USE_AUTOCONF_SIZEOF_TYPES -#ifdef FT_USE_AUTOCONF_SIZEOF_TYPES - -#undef SIZEOF_INT -#undef SIZEOF_LONG -#define FT_SIZEOF_INT SIZEOF_INT -#define FT_SIZEOF_LONG SIZEOF_LONG - -#else /* !FT_USE_AUTOCONF_SIZEOF_TYPES */ - - /* Following cpp computation of the bit length of int and long */ - /* is copied from default include/freetype/config/ftconfig.h. */ - /* If any improvement is required for this file, it should be */ - /* applied to the original header file for the builders that */ - /* do not use configure script. */ - - /* The size of an `int' type. */ -#if FT_UINT_MAX == 0xFFFFUL -#define FT_SIZEOF_INT (16 / FT_CHAR_BIT) -#elif FT_UINT_MAX == 0xFFFFFFFFUL -#define FT_SIZEOF_INT (32 / FT_CHAR_BIT) -#elif FT_UINT_MAX > 0xFFFFFFFFUL && FT_UINT_MAX == 0xFFFFFFFFFFFFFFFFUL -#define FT_SIZEOF_INT (64 / FT_CHAR_BIT) -#else -#error "Unsupported size of `int' type!" -#endif - - /* The size of a `long' type. A five-byte `long' (as used e.g. on the */ - /* DM642) is recognized but avoided. */ -#if FT_ULONG_MAX == 0xFFFFFFFFUL -#define FT_SIZEOF_LONG (32 / FT_CHAR_BIT) -#elif FT_ULONG_MAX > 0xFFFFFFFFUL && FT_ULONG_MAX == 0xFFFFFFFFFFUL -#define FT_SIZEOF_LONG (32 / FT_CHAR_BIT) -#elif FT_ULONG_MAX > 0xFFFFFFFFUL && FT_ULONG_MAX == 0xFFFFFFFFFFFFFFFFUL -#define FT_SIZEOF_LONG (64 / FT_CHAR_BIT) -#else -#error "Unsupported size of `long' type!" -#endif - -#endif /* !FT_USE_AUTOCONF_SIZEOF_TYPES */ - - - /* FT_UNUSED is a macro used to indicate that a given parameter is not */ - /* used -- this is only used to get rid of unpleasant compiler warnings */ -#ifndef FT_UNUSED -#define FT_UNUSED( arg ) ( (arg) = (arg) ) -#endif - - - /*************************************************************************/ - /* */ - /* AUTOMATIC CONFIGURATION MACROS */ - /* */ - /* These macros are computed from the ones defined above. Don't touch */ - /* their definition, unless you know precisely what you are doing. No */ - /* porter should need to mess with them. */ - /* */ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* Mac support */ - /* */ - /* This is the only necessary change, so it is defined here instead */ - /* providing a new configuration file. */ - /* */ -#if defined( __APPLE__ ) || ( defined( __MWERKS__ ) && defined( macintosh ) ) - /* no Carbon frameworks for 64bit 10.4.x */ - /* AvailabilityMacros.h is available since Mac OS X 10.2, */ - /* so guess the system version by maximum errno before inclusion */ -#include -#ifdef ECANCELED /* defined since 10.2 */ -#include "AvailabilityMacros.h" -#endif -#if defined( __LP64__ ) && \ - ( MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_4 ) -/undef FT_MACINTOSH -#endif - -#elif defined( __SC__ ) || defined( __MRC__ ) - /* Classic MacOS compilers */ -#include "ConditionalMacros.h" -#if TARGET_OS_MAC -#define FT_MACINTOSH 1 -#endif - -#endif - - - /* Fix compiler warning with sgi compiler */ -#if defined( __sgi ) && !defined( __GNUC__ ) -#if defined( _COMPILER_VERSION ) && ( _COMPILER_VERSION >= 730 ) -#pragma set woff 3505 -#endif -#endif - - - /*************************************************************************/ - /* */ - /*

*/ - /* basic_types */ - /* */ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* */ - /* FT_Int16 */ - /* */ - /* */ - /* A typedef for a 16bit signed integer type. */ - /* */ - typedef signed short FT_Int16; - - - /*************************************************************************/ - /* */ - /* */ - /* FT_UInt16 */ - /* */ - /* */ - /* A typedef for a 16bit unsigned integer type. */ - /* */ - typedef unsigned short FT_UInt16; - - /* */ - - - /* this #if 0 ... #endif clause is for documentation purposes */ -#if 0 - - /*************************************************************************/ - /* */ - /* */ - /* FT_Int32 */ - /* */ - /* */ - /* A typedef for a 32bit signed integer type. The size depends on */ - /* the configuration. */ - /* */ - typedef signed XXX FT_Int32; - - - /*************************************************************************/ - /* */ - /* */ - /* FT_UInt32 */ - /* */ - /* A typedef for a 32bit unsigned integer type. The size depends on */ - /* the configuration. */ - /* */ - typedef unsigned XXX FT_UInt32; - - - /*************************************************************************/ - /* */ - /* */ - /* FT_Int64 */ - /* */ - /* A typedef for a 64bit signed integer type. The size depends on */ - /* the configuration. Only defined if there is real 64bit support; */ - /* otherwise, it gets emulated with a structure (if necessary). */ - /* */ - typedef signed XXX FT_Int64; - - - /*************************************************************************/ - /* */ - /* */ - /* FT_UInt64 */ - /* */ - /* A typedef for a 64bit unsigned integer type. The size depends on */ - /* the configuration. Only defined if there is real 64bit support; */ - /* otherwise, it gets emulated with a structure (if necessary). */ - /* */ - typedef unsigned XXX FT_UInt64; - - /* */ - -#endif - -#if FT_SIZEOF_INT == 4 - - typedef signed int FT_Int32; - typedef unsigned int FT_UInt32; - -#elif FT_SIZEOF_LONG == 4 - - typedef signed long FT_Int32; - typedef unsigned long FT_UInt32; - -#else -#error "no 32bit type found -- please check your configuration files" -#endif - - - /* look up an integer type that is at least 32 bits */ -#if FT_SIZEOF_INT >= 4 - - typedef int FT_Fast; - typedef unsigned int FT_UFast; - -#elif FT_SIZEOF_LONG >= 4 - - typedef long FT_Fast; - typedef unsigned long FT_UFast; - -#endif - - - /* determine whether we have a 64-bit int type for platforms without */ - /* Autoconf */ -#if FT_SIZEOF_LONG == 8 - - /* FT_LONG64 must be defined if a 64-bit type is available */ -#define FT_LONG64 -#define FT_INT64 long -#define FT_UINT64 unsigned long - - /*************************************************************************/ - /* */ - /* A 64-bit data type may create compilation problems if you compile */ - /* in strict ANSI mode. To avoid them, we disable other 64-bit data */ - /* types if __STDC__ is defined. You can however ignore this rule */ - /* by defining the FT_CONFIG_OPTION_FORCE_INT64 configuration macro. */ - /* */ -#elif !defined( __STDC__ ) || defined( FT_CONFIG_OPTION_FORCE_INT64 ) - -#if defined( _MSC_VER ) && _MSC_VER >= 900 /* Visual C++ (and Intel C++) */ - - /* this compiler provides the __int64 type */ -#define FT_LONG64 -#define FT_INT64 __int64 -#define FT_UINT64 unsigned __int64 - -#elif defined( __BORLANDC__ ) /* Borland C++ */ - - /* XXXX: We should probably check the value of __BORLANDC__ in order */ - /* to test the compiler version. */ - - /* this compiler provides the __int64 type */ -#define FT_LONG64 -#define FT_INT64 __int64 -#define FT_UINT64 unsigned __int64 - -#elif defined( __WATCOMC__ ) /* Watcom C++ */ - - /* Watcom doesn't provide 64-bit data types */ - -#elif defined( __MWERKS__ ) /* Metrowerks CodeWarrior */ - -#define FT_LONG64 -#define FT_INT64 long long int -#define FT_UINT64 unsigned long long int - -#elif defined( __GNUC__ ) - - /* GCC provides the `long long' type */ -#define FT_LONG64 -#define FT_INT64 long long int -#define FT_UINT64 unsigned long long int - -#endif /* _MSC_VER */ - -#endif /* FT_SIZEOF_LONG == 8 */ - -#ifdef FT_LONG64 - typedef FT_INT64 FT_Int64; - typedef FT_UINT64 FT_UInt64; -#endif - - - /*************************************************************************/ - /* */ - /* miscellaneous */ - /* */ - /*************************************************************************/ - - -#define FT_BEGIN_STMNT do { -#define FT_END_STMNT } while ( 0 ) -#define FT_DUMMY_STMNT FT_BEGIN_STMNT FT_END_STMNT - - - /* typeof condition taken from gnulib's `intprops.h' header file */ -#if ( __GNUC__ >= 2 || \ - defined( __IBM__TYPEOF__ ) || \ - ( __SUNPRO_C >= 0x5110 && !__STDC__ ) ) -#define FT_TYPEOF( type ) (__typeof__ (type)) -#else -#define FT_TYPEOF( type ) /* empty */ -#endif - - -#ifdef FT_MAKE_OPTION_SINGLE_OBJECT - -#define FT_LOCAL( x ) static x -#define FT_LOCAL_DEF( x ) static x - -#else - -#ifdef __cplusplus -#define FT_LOCAL( x ) extern "C" x -#define FT_LOCAL_DEF( x ) extern "C" x -#else -#define FT_LOCAL( x ) extern x -#define FT_LOCAL_DEF( x ) x -#endif - -#endif /* FT_MAKE_OPTION_SINGLE_OBJECT */ - -#define FT_LOCAL_ARRAY( x ) extern const x -#define FT_LOCAL_ARRAY_DEF( x ) const x - - -#ifndef FT_BASE - -#ifdef __cplusplus -#define FT_BASE( x ) extern "C" x -#else -#define FT_BASE( x ) extern x -#endif - -#endif /* !FT_BASE */ - - -#ifndef FT_BASE_DEF - -#ifdef __cplusplus -#define FT_BASE_DEF( x ) x -#else -#define FT_BASE_DEF( x ) x -#endif - -#endif /* !FT_BASE_DEF */ - - -#ifndef FT_EXPORT - -#ifdef __cplusplus -#define FT_EXPORT( x ) extern "C" x -#else -#define FT_EXPORT( x ) extern x -#endif - -#endif /* !FT_EXPORT */ - - -#ifndef FT_EXPORT_DEF - -#ifdef __cplusplus -#define FT_EXPORT_DEF( x ) extern "C" x -#else -#define FT_EXPORT_DEF( x ) extern x -#endif - -#endif /* !FT_EXPORT_DEF */ - - -#ifndef FT_EXPORT_VAR - -#ifdef __cplusplus -#define FT_EXPORT_VAR( x ) extern "C" x -#else -#define FT_EXPORT_VAR( x ) extern x -#endif - -#endif /* !FT_EXPORT_VAR */ - - /* The following macros are needed to compile the library with a */ - /* C++ compiler and with 16bit compilers. */ - /* */ - - /* This is special. Within C++, you must specify `extern "C"' for */ - /* functions which are used via function pointers, and you also */ - /* must do that for structures which contain function pointers to */ - /* assure C linkage -- it's not possible to have (local) anonymous */ - /* functions which are accessed by (global) function pointers. */ - /* */ - /* */ - /* FT_CALLBACK_DEF is used to _define_ a callback function. */ - /* */ - /* FT_CALLBACK_TABLE is used to _declare_ a constant variable that */ - /* contains pointers to callback functions. */ - /* */ - /* FT_CALLBACK_TABLE_DEF is used to _define_ a constant variable */ - /* that contains pointers to callback functions. */ - /* */ - /* */ - /* Some 16bit compilers have to redefine these macros to insert */ - /* the infamous `_cdecl' or `__fastcall' declarations. */ - /* */ -#ifndef FT_CALLBACK_DEF -#ifdef __cplusplus -#define FT_CALLBACK_DEF( x ) extern "C" x -#else -#define FT_CALLBACK_DEF( x ) static x -#endif -#endif /* FT_CALLBACK_DEF */ - -#ifndef FT_CALLBACK_TABLE -#ifdef __cplusplus -#define FT_CALLBACK_TABLE extern "C" -#define FT_CALLBACK_TABLE_DEF extern "C" -#else -#define FT_CALLBACK_TABLE extern -#define FT_CALLBACK_TABLE_DEF /* nothing */ -#endif -#endif /* FT_CALLBACK_TABLE */ - - -FT_END_HEADER - - -#endif /* FTCONFIG_H_ */ - - -/* END */ diff --git a/subprojects/packagefiles/freetype-2.6.1-meson/include/freetype/config/ftoption.h.in b/subprojects/packagefiles/freetype-2.6.1-meson/include/freetype/config/ftoption.h.in deleted file mode 100644 index 5df84c706800..000000000000 --- a/subprojects/packagefiles/freetype-2.6.1-meson/include/freetype/config/ftoption.h.in +++ /dev/null @@ -1,886 +0,0 @@ -/***************************************************************************/ -/* */ -/* ftoption.h */ -/* */ -/* User-selectable configuration macros (specification only). */ -/* */ -/* Copyright 1996-2015 by */ -/* David Turner, Robert Wilhelm, and Werner Lemberg. */ -/* */ -/* This file is part of the FreeType project, and may only be used, */ -/* modified, and distributed under the terms of the FreeType project */ -/* license, LICENSE.TXT. By continuing to use, modify, or distribute */ -/* this file you indicate that you have read the license and */ -/* understand and accept it fully. */ -/* */ -/***************************************************************************/ - - -#ifndef FTOPTION_H_ -#define FTOPTION_H_ - - -#include - - -FT_BEGIN_HEADER - - /*************************************************************************/ - /* */ - /* USER-SELECTABLE CONFIGURATION MACROS */ - /* */ - /* This file contains the default configuration macro definitions for */ - /* a standard build of the FreeType library. There are three ways to */ - /* use this file to build project-specific versions of the library: */ - /* */ - /* - You can modify this file by hand, but this is not recommended in */ - /* cases where you would like to build several versions of the */ - /* library from a single source directory. */ - /* */ - /* - You can put a copy of this file in your build directory, more */ - /* precisely in `$BUILD/freetype/config/ftoption.h', where `$BUILD' */ - /* is the name of a directory that is included _before_ the FreeType */ - /* include path during compilation. */ - /* */ - /* The default FreeType Makefiles and Jamfiles use the build */ - /* directory `builds/' by default, but you can easily change */ - /* that for your own projects. */ - /* */ - /* - Copy the file to `$BUILD/ft2build.h' and modify it */ - /* slightly to pre-define the macro FT_CONFIG_OPTIONS_H used to */ - /* locate this file during the build. For example, */ - /* */ - /* #define FT_CONFIG_OPTIONS_H */ - /* #include */ - /* */ - /* will use `$BUILD/myftoptions.h' instead of this file for macro */ - /* definitions. */ - /* */ - /* Note also that you can similarly pre-define the macro */ - /* FT_CONFIG_MODULES_H used to locate the file listing of the modules */ - /* that are statically linked to the library at compile time. By */ - /* default, this file is . */ - /* */ - /* We highly recommend using the third method whenever possible. */ - /* */ - /*************************************************************************/ - - - /*************************************************************************/ - /*************************************************************************/ - /**** ****/ - /**** G E N E R A L F R E E T Y P E 2 C O N F I G U R A T I O N ****/ - /**** ****/ - /*************************************************************************/ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* Uncomment the line below if you want to activate sub-pixel rendering */ - /* (a.k.a. LCD rendering, or ClearType) in this build of the library. */ - /* */ - /* Note that this feature is covered by several Microsoft patents */ - /* and should not be activated in any default build of the library. */ - /* */ - /* This macro has no impact on the FreeType API, only on its */ - /* _implementation_. For example, using FT_RENDER_MODE_LCD when calling */ - /* FT_Render_Glyph still generates a bitmap that is 3 times wider than */ - /* the original size in case this macro isn't defined; however, each */ - /* triplet of subpixels has R=G=B. */ - /* */ - /* This is done to allow FreeType clients to run unmodified, forcing */ - /* them to display normal gray-level anti-aliased glyphs. */ - /* */ -/* #define FT_CONFIG_OPTION_SUBPIXEL_RENDERING */ - - - /*************************************************************************/ - /* */ - /* Many compilers provide a non-ANSI 64-bit data type that can be used */ - /* by FreeType to speed up some computations. However, this will create */ - /* some problems when compiling the library in strict ANSI mode. */ - /* */ - /* For this reason, the use of 64-bit integers is normally disabled when */ - /* the __STDC__ macro is defined. You can however disable this by */ - /* defining the macro FT_CONFIG_OPTION_FORCE_INT64 here. */ - /* */ - /* For most compilers, this will only create compilation warnings when */ - /* building the library. */ - /* */ - /* ObNote: The compiler-specific 64-bit integers are detected in the */ - /* file `ftconfig.h' either statically or through the */ - /* `configure' script on supported platforms. */ - /* */ -#undef FT_CONFIG_OPTION_FORCE_INT64 - - - /*************************************************************************/ - /* */ - /* If this macro is defined, do not try to use an assembler version of */ - /* performance-critical functions (e.g. FT_MulFix). You should only do */ - /* that to verify that the assembler function works properly, or to */ - /* execute benchmark tests of the various implementations. */ -/* #define FT_CONFIG_OPTION_NO_ASSEMBLER */ - - - /*************************************************************************/ - /* */ - /* If this macro is defined, try to use an inlined assembler version of */ - /* the `FT_MulFix' function, which is a `hotspot' when loading and */ - /* hinting glyphs, and which should be executed as fast as possible. */ - /* */ - /* Note that if your compiler or CPU is not supported, this will default */ - /* to the standard and portable implementation found in `ftcalc.c'. */ - /* */ -#define FT_CONFIG_OPTION_INLINE_MULFIX - - - /*************************************************************************/ - /* */ - /* LZW-compressed file support. */ - /* */ - /* FreeType now handles font files that have been compressed with the */ - /* `compress' program. This is mostly used to parse many of the PCF */ - /* files that come with various X11 distributions. The implementation */ - /* uses NetBSD's `zopen' to partially uncompress the file on the fly */ - /* (see src/lzw/ftgzip.c). */ - /* */ - /* Define this macro if you want to enable this `feature'. */ - /* */ -#define FT_CONFIG_OPTION_USE_LZW - - - /*************************************************************************/ - /* */ - /* Gzip-compressed file support. */ - /* */ - /* FreeType now handles font files that have been compressed with the */ - /* `gzip' program. This is mostly used to parse many of the PCF files */ - /* that come with XFree86. The implementation uses `zlib' to */ - /* partially uncompress the file on the fly (see src/gzip/ftgzip.c). */ - /* */ - /* Define this macro if you want to enable this `feature'. See also */ - /* the macro FT_CONFIG_OPTION_SYSTEM_ZLIB below. */ - /* */ -#define FT_CONFIG_OPTION_USE_ZLIB - - - /*************************************************************************/ - /* */ - /* ZLib library selection */ - /* */ - /* This macro is only used when FT_CONFIG_OPTION_USE_ZLIB is defined. */ - /* It allows FreeType's `ftgzip' component to link to the system's */ - /* installation of the ZLib library. This is useful on systems like */ - /* Unix or VMS where it generally is already available. */ - /* */ - /* If you let it undefined, the component will use its own copy */ - /* of the zlib sources instead. These have been modified to be */ - /* included directly within the component and *not* export external */ - /* function names. This allows you to link any program with FreeType */ - /* _and_ ZLib without linking conflicts. */ - /* */ - /* Do not #undef this macro here since the build system might define */ - /* it for certain configurations only. */ - /* */ -#mesondefine FT_CONFIG_OPTION_SYSTEM_ZLIB - - - /*************************************************************************/ - /* */ - /* Bzip2-compressed file support. */ - /* */ - /* FreeType now handles font files that have been compressed with the */ - /* `bzip2' program. This is mostly used to parse many of the PCF */ - /* files that come with XFree86. The implementation uses `libbz2' to */ - /* partially uncompress the file on the fly (see src/bzip2/ftbzip2.c). */ - /* Contrary to gzip, bzip2 currently is not included and need to use */ - /* the system available bzip2 implementation. */ - /* */ - /* Define this macro if you want to enable this `feature'. */ - /* */ -#mesondefine FT_CONFIG_OPTION_USE_BZIP2 - - - /*************************************************************************/ - /* */ - /* Define to disable the use of file stream functions and types, FILE, */ - /* fopen() etc. Enables the use of smaller system libraries on embedded */ - /* systems that have multiple system libraries, some with or without */ - /* file stream support, in the cases where file stream support is not */ - /* necessary such as memory loading of font files. */ - /* */ -/* #define FT_CONFIG_OPTION_DISABLE_STREAM_SUPPORT */ - - - /*************************************************************************/ - /* */ - /* PNG bitmap support. */ - /* */ - /* FreeType now handles loading color bitmap glyphs in the PNG format. */ - /* This requires help from the external libpng library. Uncompressed */ - /* color bitmaps do not need any external libraries and will be */ - /* supported regardless of this configuration. */ - /* */ - /* Define this macro if you want to enable this `feature'. */ - /* */ -#mesondefine FT_CONFIG_OPTION_USE_PNG - - - /*************************************************************************/ - /* */ - /* HarfBuzz support. */ - /* */ - /* FreeType uses the HarfBuzz library to improve auto-hinting of */ - /* OpenType fonts. If available, many glyphs not directly addressable */ - /* by a font's character map will be hinted also. */ - /* */ - /* Define this macro if you want to enable this `feature'. */ - /* */ -#mesondefine FT_CONFIG_OPTION_USE_HARFBUZZ - - - /*************************************************************************/ - /* */ - /* DLL export compilation */ - /* */ - /* When compiling FreeType as a DLL, some systems/compilers need a */ - /* special keyword in front OR after the return type of function */ - /* declarations. */ - /* */ - /* Two macros are used within the FreeType source code to define */ - /* exported library functions: FT_EXPORT and FT_EXPORT_DEF. */ - /* */ - /* FT_EXPORT( return_type ) */ - /* */ - /* is used in a function declaration, as in */ - /* */ - /* FT_EXPORT( FT_Error ) */ - /* FT_Init_FreeType( FT_Library* alibrary ); */ - /* */ - /* */ - /* FT_EXPORT_DEF( return_type ) */ - /* */ - /* is used in a function definition, as in */ - /* */ - /* FT_EXPORT_DEF( FT_Error ) */ - /* FT_Init_FreeType( FT_Library* alibrary ) */ - /* { */ - /* ... some code ... */ - /* return FT_Err_Ok; */ - /* } */ - /* */ - /* You can provide your own implementation of FT_EXPORT and */ - /* FT_EXPORT_DEF here if you want. If you leave them undefined, they */ - /* will be later automatically defined as `extern return_type' to */ - /* allow normal compilation. */ - /* */ - /* Do not #undef these macros here since the build system might define */ - /* them for certain configurations only. */ - /* */ -/* #define FT_EXPORT(x) extern x */ -/* #define FT_EXPORT_DEF(x) x */ - - - /*************************************************************************/ - /* */ - /* Glyph Postscript Names handling */ - /* */ - /* By default, FreeType 2 is compiled with the `psnames' module. This */ - /* module is in charge of converting a glyph name string into a */ - /* Unicode value, or return a Macintosh standard glyph name for the */ - /* use with the TrueType `post' table. */ - /* */ - /* Undefine this macro if you do not want `psnames' compiled in your */ - /* build of FreeType. This has the following effects: */ - /* */ - /* - The TrueType driver will provide its own set of glyph names, */ - /* if you build it to support postscript names in the TrueType */ - /* `post' table. */ - /* */ - /* - The Type 1 driver will not be able to synthesize a Unicode */ - /* charmap out of the glyphs found in the fonts. */ - /* */ - /* You would normally undefine this configuration macro when building */ - /* a version of FreeType that doesn't contain a Type 1 or CFF driver. */ - /* */ -#define FT_CONFIG_OPTION_POSTSCRIPT_NAMES - - - /*************************************************************************/ - /* */ - /* Postscript Names to Unicode Values support */ - /* */ - /* By default, FreeType 2 is built with the `PSNames' module compiled */ - /* in. Among other things, the module is used to convert a glyph name */ - /* into a Unicode value. This is especially useful in order to */ - /* synthesize on the fly a Unicode charmap from the CFF/Type 1 driver */ - /* through a big table named the `Adobe Glyph List' (AGL). */ - /* */ - /* Undefine this macro if you do not want the Adobe Glyph List */ - /* compiled in your `PSNames' module. The Type 1 driver will not be */ - /* able to synthesize a Unicode charmap out of the glyphs found in the */ - /* fonts. */ - /* */ -#define FT_CONFIG_OPTION_ADOBE_GLYPH_LIST - - - /*************************************************************************/ - /* */ - /* Support for Mac fonts */ - /* */ - /* Define this macro if you want support for outline fonts in Mac */ - /* format (mac dfont, mac resource, macbinary containing a mac */ - /* resource) on non-Mac platforms. */ - /* */ - /* Note that the `FOND' resource isn't checked. */ - /* */ -#define FT_CONFIG_OPTION_MAC_FONTS - - - /*************************************************************************/ - /* */ - /* Guessing methods to access embedded resource forks */ - /* */ - /* Enable extra Mac fonts support on non-Mac platforms (e.g. */ - /* GNU/Linux). */ - /* */ - /* Resource forks which include fonts data are stored sometimes in */ - /* locations which users or developers don't expected. In some cases, */ - /* resource forks start with some offset from the head of a file. In */ - /* other cases, the actual resource fork is stored in file different */ - /* from what the user specifies. If this option is activated, */ - /* FreeType tries to guess whether such offsets or different file */ - /* names must be used. */ - /* */ - /* Note that normal, direct access of resource forks is controlled via */ - /* the FT_CONFIG_OPTION_MAC_FONTS option. */ - /* */ -#ifdef FT_CONFIG_OPTION_MAC_FONTS -#define FT_CONFIG_OPTION_GUESSING_EMBEDDED_RFORK -#endif - - - /*************************************************************************/ - /* */ - /* Allow the use of FT_Incremental_Interface to load typefaces that */ - /* contain no glyph data, but supply it via a callback function. */ - /* This is required by clients supporting document formats which */ - /* supply font data incrementally as the document is parsed, such */ - /* as the Ghostscript interpreter for the PostScript language. */ - /* */ -#define FT_CONFIG_OPTION_INCREMENTAL - - - /*************************************************************************/ - /* */ - /* The size in bytes of the render pool used by the scan-line converter */ - /* to do all of its work. */ - /* */ -#define FT_RENDER_POOL_SIZE 16384L - - - /*************************************************************************/ - /* */ - /* FT_MAX_MODULES */ - /* */ - /* The maximum number of modules that can be registered in a single */ - /* FreeType library object. 32 is the default. */ - /* */ -#define FT_MAX_MODULES 32 - - - /*************************************************************************/ - /* */ - /* Debug level */ - /* */ - /* FreeType can be compiled in debug or trace mode. In debug mode, */ - /* errors are reported through the `ftdebug' component. In trace */ - /* mode, additional messages are sent to the standard output during */ - /* execution. */ - /* */ - /* Define FT_DEBUG_LEVEL_ERROR to build the library in debug mode. */ - /* Define FT_DEBUG_LEVEL_TRACE to build it in trace mode. */ - /* */ - /* Don't define any of these macros to compile in `release' mode! */ - /* */ - /* Do not #undef these macros here since the build system might define */ - /* them for certain configurations only. */ - /* */ -/* #define FT_DEBUG_LEVEL_ERROR */ -/* #define FT_DEBUG_LEVEL_TRACE */ - - - /*************************************************************************/ - /* */ - /* Autofitter debugging */ - /* */ - /* If FT_DEBUG_AUTOFIT is defined, FreeType provides some means to */ - /* control the autofitter behaviour for debugging purposes with global */ - /* boolean variables (consequently, you should *never* enable this */ - /* while compiling in `release' mode): */ - /* */ - /* _af_debug_disable_horz_hints */ - /* _af_debug_disable_vert_hints */ - /* _af_debug_disable_blue_hints */ - /* */ - /* Additionally, the following functions provide dumps of various */ - /* internal autofit structures to stdout (using `printf'): */ - /* */ - /* af_glyph_hints_dump_points */ - /* af_glyph_hints_dump_segments */ - /* af_glyph_hints_dump_edges */ - /* af_glyph_hints_get_num_segments */ - /* af_glyph_hints_get_segment_offset */ - /* */ - /* As an argument, they use another global variable: */ - /* */ - /* _af_debug_hints */ - /* */ - /* Please have a look at the `ftgrid' demo program to see how those */ - /* variables and macros should be used. */ - /* */ - /* Do not #undef these macros here since the build system might define */ - /* them for certain configurations only. */ - /* */ -/* #define FT_DEBUG_AUTOFIT */ - - - /*************************************************************************/ - /* */ - /* Memory Debugging */ - /* */ - /* FreeType now comes with an integrated memory debugger that is */ - /* capable of detecting simple errors like memory leaks or double */ - /* deletes. To compile it within your build of the library, you */ - /* should define FT_DEBUG_MEMORY here. */ - /* */ - /* Note that the memory debugger is only activated at runtime when */ - /* when the _environment_ variable `FT2_DEBUG_MEMORY' is defined also! */ - /* */ - /* Do not #undef this macro here since the build system might define */ - /* it for certain configurations only. */ - /* */ -/* #define FT_DEBUG_MEMORY */ - - - /*************************************************************************/ - /* */ - /* Module errors */ - /* */ - /* If this macro is set (which is _not_ the default), the higher byte */ - /* of an error code gives the module in which the error has occurred, */ - /* while the lower byte is the real error code. */ - /* */ - /* Setting this macro makes sense for debugging purposes only, since */ - /* it would break source compatibility of certain programs that use */ - /* FreeType 2. */ - /* */ - /* More details can be found in the files ftmoderr.h and fterrors.h. */ - /* */ -#undef FT_CONFIG_OPTION_USE_MODULE_ERRORS - - - /*************************************************************************/ - /* */ - /* Position Independent Code */ - /* */ - /* If this macro is set (which is _not_ the default), FreeType2 will */ - /* avoid creating constants that require address fixups. Instead the */ - /* constants will be moved into a struct and additional intialization */ - /* code will be used. */ - /* */ - /* Setting this macro is needed for systems that prohibit address */ - /* fixups, such as BREW. */ - /* */ -#mesondefine FT_CONFIG_OPTION_PIC - - - /*************************************************************************/ - /*************************************************************************/ - /**** ****/ - /**** S F N T D R I V E R C O N F I G U R A T I O N ****/ - /**** ****/ - /*************************************************************************/ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_EMBEDDED_BITMAPS if you want to support */ - /* embedded bitmaps in all formats using the SFNT module (namely */ - /* TrueType & OpenType). */ - /* */ -#define TT_CONFIG_OPTION_EMBEDDED_BITMAPS - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_POSTSCRIPT_NAMES if you want to be able to */ - /* load and enumerate the glyph Postscript names in a TrueType or */ - /* OpenType file. */ - /* */ - /* Note that when you do not compile the `PSNames' module by undefining */ - /* the above FT_CONFIG_OPTION_POSTSCRIPT_NAMES, the `sfnt' module will */ - /* contain additional code used to read the PS Names table from a font. */ - /* */ - /* (By default, the module uses `PSNames' to extract glyph names.) */ - /* */ -#define TT_CONFIG_OPTION_POSTSCRIPT_NAMES - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_SFNT_NAMES if your applications need to */ - /* access the internal name table in a SFNT-based format like TrueType */ - /* or OpenType. The name table contains various strings used to */ - /* describe the font, like family name, copyright, version, etc. It */ - /* does not contain any glyph name though. */ - /* */ - /* Accessing SFNT names is done through the functions declared in */ - /* `ftsnames.h'. */ - /* */ -#define TT_CONFIG_OPTION_SFNT_NAMES - - - /*************************************************************************/ - /* */ - /* TrueType CMap support */ - /* */ - /* Here you can fine-tune which TrueType CMap table format shall be */ - /* supported. */ -#define TT_CONFIG_CMAP_FORMAT_0 -#define TT_CONFIG_CMAP_FORMAT_2 -#define TT_CONFIG_CMAP_FORMAT_4 -#define TT_CONFIG_CMAP_FORMAT_6 -#define TT_CONFIG_CMAP_FORMAT_8 -#define TT_CONFIG_CMAP_FORMAT_10 -#define TT_CONFIG_CMAP_FORMAT_12 -#define TT_CONFIG_CMAP_FORMAT_13 -#define TT_CONFIG_CMAP_FORMAT_14 - - - /*************************************************************************/ - /*************************************************************************/ - /**** ****/ - /**** T R U E T Y P E D R I V E R C O N F I G U R A T I O N ****/ - /**** ****/ - /*************************************************************************/ - /*************************************************************************/ - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_BYTECODE_INTERPRETER if you want to compile */ - /* a bytecode interpreter in the TrueType driver. */ - /* */ - /* By undefining this, you will only compile the code necessary to load */ - /* TrueType glyphs without hinting. */ - /* */ - /* Do not #undef this macro here, since the build system might */ - /* define it for certain configurations only. */ - /* */ -#define TT_CONFIG_OPTION_BYTECODE_INTERPRETER - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_SUBPIXEL_HINTING if you want to compile */ - /* EXPERIMENTAL subpixel hinting support into the TrueType driver. This */ - /* replaces the native TrueType hinting mechanism when anything but */ - /* FT_RENDER_MODE_MONO is requested. */ - /* */ - /* Enabling this causes the TrueType driver to ignore instructions under */ - /* certain conditions. This is done in accordance with the guide here, */ - /* with some minor differences: */ - /* */ - /* http://www.microsoft.com/typography/cleartype/truetypecleartype.aspx */ - /* */ - /* By undefining this, you only compile the code necessary to hint */ - /* TrueType glyphs with native TT hinting. */ - /* */ - /* This option requires TT_CONFIG_OPTION_BYTECODE_INTERPRETER to be */ - /* defined. */ - /* */ -/* #define TT_CONFIG_OPTION_SUBPIXEL_HINTING */ - - - /*************************************************************************/ - /* */ - /* If you define TT_CONFIG_OPTION_UNPATENTED_HINTING, a special version */ - /* of the TrueType bytecode interpreter is used that doesn't implement */ - /* any of the patented opcodes and algorithms. The patents related to */ - /* TrueType hinting have expired worldwide since May 2010; this option */ - /* is now deprecated. */ - /* */ - /* Note that the TT_CONFIG_OPTION_UNPATENTED_HINTING macro is *ignored* */ - /* if you define TT_CONFIG_OPTION_BYTECODE_INTERPRETER; in other words, */ - /* either define TT_CONFIG_OPTION_BYTECODE_INTERPRETER or */ - /* TT_CONFIG_OPTION_UNPATENTED_HINTING but not both at the same time. */ - /* */ - /* This macro is only useful for a small number of font files (mostly */ - /* for Asian scripts) that require bytecode interpretation to properly */ - /* load glyphs. For all other fonts, this produces unpleasant results, */ - /* thus the unpatented interpreter is never used to load glyphs from */ - /* TrueType fonts unless one of the following two options is used. */ - /* */ - /* - The unpatented interpreter is explicitly activated by the user */ - /* through the FT_PARAM_TAG_UNPATENTED_HINTING parameter tag */ - /* when opening the FT_Face. */ - /* */ - /* - FreeType detects that the FT_Face corresponds to one of the */ - /* `trick' fonts (e.g., `Mingliu') it knows about. The font engine */ - /* contains a hard-coded list of font names and other matching */ - /* parameters (see function `tt_face_init' in file */ - /* `src/truetype/ttobjs.c'). */ - /* */ - /* Here a sample code snippet for using FT_PARAM_TAG_UNPATENTED_HINTING. */ - /* */ - /* { */ - /* FT_Parameter parameter; */ - /* FT_Open_Args open_args; */ - /* */ - /* */ - /* parameter.tag = FT_PARAM_TAG_UNPATENTED_HINTING; */ - /* */ - /* open_args.flags = FT_OPEN_PATHNAME | FT_OPEN_PARAMS; */ - /* open_args.pathname = my_font_pathname; */ - /* open_args.num_params = 1; */ - /* open_args.params = ¶meter; */ - /* */ - /* error = FT_Open_Face( library, &open_args, index, &face ); */ - /* ... */ - /* } */ - /* */ -/* #define TT_CONFIG_OPTION_UNPATENTED_HINTING */ - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_COMPONENT_OFFSET_SCALED to compile the */ - /* TrueType glyph loader to use Apple's definition of how to handle */ - /* component offsets in composite glyphs. */ - /* */ - /* Apple and MS disagree on the default behavior of component offsets */ - /* in composites. Apple says that they should be scaled by the scaling */ - /* factors in the transformation matrix (roughly, it's more complex) */ - /* while MS says they should not. OpenType defines two bits in the */ - /* composite flags array which can be used to disambiguate, but old */ - /* fonts will not have them. */ - /* */ - /* http://www.microsoft.com/typography/otspec/glyf.htm */ - /* https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6glyf.html */ - /* */ -#undef TT_CONFIG_OPTION_COMPONENT_OFFSET_SCALED - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_GX_VAR_SUPPORT if you want to include */ - /* support for Apple's distortable font technology (fvar, gvar, cvar, */ - /* and avar tables). This has many similarities to Type 1 Multiple */ - /* Masters support. */ - /* */ -#define TT_CONFIG_OPTION_GX_VAR_SUPPORT - - - /*************************************************************************/ - /* */ - /* Define TT_CONFIG_OPTION_BDF if you want to include support for */ - /* an embedded `BDF ' table within SFNT-based bitmap formats. */ - /* */ -#define TT_CONFIG_OPTION_BDF - - - /*************************************************************************/ - /*************************************************************************/ - /**** ****/ - /**** T Y P E 1 D R I V E R C O N F I G U R A T I O N ****/ - /**** ****/ - /*************************************************************************/ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* T1_MAX_DICT_DEPTH is the maximum depth of nest dictionaries and */ - /* arrays in the Type 1 stream (see t1load.c). A minimum of 4 is */ - /* required. */ - /* */ -#define T1_MAX_DICT_DEPTH 5 - - - /*************************************************************************/ - /* */ - /* T1_MAX_SUBRS_CALLS details the maximum number of nested sub-routine */ - /* calls during glyph loading. */ - /* */ -#define T1_MAX_SUBRS_CALLS 16 - - - /*************************************************************************/ - /* */ - /* T1_MAX_CHARSTRING_OPERANDS is the charstring stack's capacity. A */ - /* minimum of 16 is required. */ - /* */ - /* The Chinese font MingTiEG-Medium (CNS 11643 character set) needs 256. */ - /* */ -#define T1_MAX_CHARSTRINGS_OPERANDS 256 - - - /*************************************************************************/ - /* */ - /* Define this configuration macro if you want to prevent the */ - /* compilation of `t1afm', which is in charge of reading Type 1 AFM */ - /* files into an existing face. Note that if set, the T1 driver will be */ - /* unable to produce kerning distances. */ - /* */ -#undef T1_CONFIG_OPTION_NO_AFM - - - /*************************************************************************/ - /* */ - /* Define this configuration macro if you want to prevent the */ - /* compilation of the Multiple Masters font support in the Type 1 */ - /* driver. */ - /* */ -#undef T1_CONFIG_OPTION_NO_MM_SUPPORT - - - /*************************************************************************/ - /*************************************************************************/ - /**** ****/ - /**** C F F D R I V E R C O N F I G U R A T I O N ****/ - /**** ****/ - /*************************************************************************/ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* Using CFF_CONFIG_OPTION_DARKENING_PARAMETER_{X,Y}{1,2,3,4} it is */ - /* possible to set up the default values of the four control points that */ - /* define the stem darkening behaviour of the (new) CFF engine. For */ - /* more details please read the documentation of the */ - /* `darkening-parameters' property of the cff driver module (file */ - /* `ftcffdrv.h'), which allows the control at run-time. */ - /* */ - /* Do *not* undefine these macros! */ - /* */ -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_X1 500 -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y1 400 - -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_X2 1000 -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y2 275 - -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_X3 1667 -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y3 275 - -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_X4 2333 -#define CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y4 0 - - - /*************************************************************************/ - /* */ - /* CFF_CONFIG_OPTION_OLD_ENGINE controls whether the pre-Adobe CFF */ - /* engine gets compiled into FreeType. If defined, it is possible to */ - /* switch between the two engines using the `hinting-engine' property of */ - /* the cff driver module. */ - /* */ -/* #define CFF_CONFIG_OPTION_OLD_ENGINE */ - - - /*************************************************************************/ - /*************************************************************************/ - /**** ****/ - /**** A U T O F I T M O D U L E C O N F I G U R A T I O N ****/ - /**** ****/ - /*************************************************************************/ - /*************************************************************************/ - - - /*************************************************************************/ - /* */ - /* Compile autofit module with CJK (Chinese, Japanese, Korean) script */ - /* support. */ - /* */ -#define AF_CONFIG_OPTION_CJK - - /*************************************************************************/ - /* */ - /* Compile autofit module with Indic script support. */ - /* */ -#define AF_CONFIG_OPTION_INDIC - - /*************************************************************************/ - /* */ - /* Compile autofit module with warp hinting. The idea of the warping */ - /* code is to slightly scale and shift a glyph within a single dimension */ - /* so that as much of its segments are aligned (more or less) on the */ - /* grid. To find out the optimal scaling and shifting value, various */ - /* parameter combinations are tried and scored. */ - /* */ - /* This experimental option is active only if the rendering mode is */ - /* FT_RENDER_MODE_LIGHT; you can switch warping on and off with the */ - /* `warping' property of the auto-hinter (see file `ftautoh.h' for more */ - /* information; by default it is switched off). */ - /* */ -#define AF_CONFIG_OPTION_USE_WARPER - - /* */ - - - /* - * This macro is obsolete. Support has been removed in FreeType - * version 2.5. - */ -/* #define FT_CONFIG_OPTION_OLD_INTERNALS */ - - - /* - * This macro is defined if either unpatented or native TrueType - * hinting is requested by the definitions above. - */ -#ifdef TT_CONFIG_OPTION_BYTECODE_INTERPRETER -#define TT_USE_BYTECODE_INTERPRETER -#undef TT_CONFIG_OPTION_UNPATENTED_HINTING -#elif defined TT_CONFIG_OPTION_UNPATENTED_HINTING -#define TT_USE_BYTECODE_INTERPRETER -#endif - - - /* - * Check CFF darkening parameters. The checks are the same as in function - * `cff_property_set' in file `cffdrivr.c'. - */ -#if CFF_CONFIG_OPTION_DARKENING_PARAMETER_X1 < 0 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X2 < 0 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X3 < 0 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X4 < 0 || \ - \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y1 < 0 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y2 < 0 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y3 < 0 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y4 < 0 || \ - \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X1 > \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X2 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X2 > \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X3 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X3 > \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_X4 || \ - \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y1 > 500 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y2 > 500 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y3 > 500 || \ - CFF_CONFIG_OPTION_DARKENING_PARAMETER_Y4 > 500 -#error "Invalid CFF darkening parameters!" -#endif - -FT_END_HEADER - - -#endif /* FTOPTION_H_ */ - - -/* END */ diff --git a/subprojects/packagefiles/freetype-2.6.1-meson/meson.build b/subprojects/packagefiles/freetype-2.6.1-meson/meson.build deleted file mode 100644 index 9a5180ef7586..000000000000 --- a/subprojects/packagefiles/freetype-2.6.1-meson/meson.build +++ /dev/null @@ -1,193 +0,0 @@ -project('freetype2', 'c', - version: '2.6.1', - license: '(FTL OR GPL-2.0-or-later) AND BSD-3-Clause AND MIT AND MIT-Modern-Variant AND Zlib', - license_files: [ - 'docs/LICENSE.TXT', - 'docs/FTL.TXT', - 'docs/GPLv2.TXT', - ], - meson_version: '>=1.1.0') - -# NOTE about FreeType versions -# There are 3 versions numbers associated with each releases: -# - official release number (eg. 2.6.1) - accessible via -# FREETYPE_{MAJOR,MINOR,PATCH} macros from FT_FREETYPE_H -# - libtool-specific version number, this is what is returned by -# freetype-config --version / pkg-config --modversion (eg. 22.1.16) -# - the platform-specific shared object version number (eg. 6.16.1) -# See https://git.savannah.gnu.org/cgit/freetype/freetype2.git/tree/docs/VERSIONS.TXT -# for more information -release_version = meson.project_version() -libtool_version = '18.1.12' -so_version = '6.12.1' -so_soversion = '6' - -pkgmod = import('pkgconfig') - -cc = meson.get_compiler('c') - -base_sources = [ - 'src/autofit/autofit.c', - 'src/base/ftbase.c', - 'src/base/ftbbox.c', - 'src/base/ftbdf.c', - 'src/base/ftbitmap.c', - 'src/base/ftcid.c', - 'src/base/ftfntfmt.c', - 'src/base/ftfstype.c', - 'src/base/ftgasp.c', - 'src/base/ftglyph.c', - 'src/base/ftgxval.c', - 'src/base/ftinit.c', - 'src/base/ftlcdfil.c', - 'src/base/ftmm.c', - 'src/base/ftotval.c', - 'src/base/ftpatent.c', - 'src/base/ftpfr.c', - 'src/base/ftstroke.c', - 'src/base/ftsynth.c', - 'src/base/ftsystem.c', - 'src/base/fttype1.c', - 'src/base/ftwinfnt.c', - 'src/bdf/bdf.c', - 'src/bzip2/ftbzip2.c', - 'src/cache/ftcache.c', - 'src/cff/cff.c', - 'src/cid/type1cid.c', - 'src/gzip/ftgzip.c', - 'src/lzw/ftlzw.c', - 'src/pcf/pcf.c', - 'src/pfr/pfr.c', - 'src/psaux/psaux.c', - 'src/pshinter/pshinter.c', - 'src/psnames/psnames.c', - 'src/raster/raster.c', - 'src/sfnt/sfnt.c', - 'src/smooth/smooth.c', - 'src/truetype/truetype.c', - 'src/type1/type1.c', - 'src/type42/type42.c', - 'src/winfonts/winfnt.c', -] - -ft2build_h = [ - 'include/ft2build.h', -] - -ft_headers = [ - 'include/freetype/freetype.h', - 'include/freetype/ftadvanc.h', - 'include/freetype/ftautoh.h', - 'include/freetype/ftbbox.h', - 'include/freetype/ftbdf.h', - 'include/freetype/ftbitmap.h', - 'include/freetype/ftbzip2.h', - 'include/freetype/ftcache.h', - 'include/freetype/ftcffdrv.h', - 'include/freetype/ftchapters.h', - 'include/freetype/ftcid.h', - 'include/freetype/fterrdef.h', - 'include/freetype/fterrors.h', - 'include/freetype/ftfntfmt.h', - 'include/freetype/ftgasp.h', - 'include/freetype/ftglyph.h', - 'include/freetype/ftgxval.h', - 'include/freetype/ftgzip.h', - 'include/freetype/ftimage.h', - 'include/freetype/ftincrem.h', - 'include/freetype/ftlcdfil.h', - 'include/freetype/ftlist.h', - 'include/freetype/ftlzw.h', - 'include/freetype/ftmac.h', - 'include/freetype/ftmm.h', - 'include/freetype/ftmodapi.h', - 'include/freetype/ftmoderr.h', - 'include/freetype/ftotval.h', - 'include/freetype/ftoutln.h', - 'include/freetype/ftpfr.h', - 'include/freetype/ftrender.h', - 'include/freetype/ftsizes.h', - 'include/freetype/ftsnames.h', - 'include/freetype/ftstroke.h', - 'include/freetype/ftsynth.h', - 'include/freetype/ftsystem.h', - 'include/freetype/fttrigon.h', - 'include/freetype/ftttdrv.h', - 'include/freetype/fttypes.h', - 'include/freetype/ftwinfnt.h', - 'include/freetype/t1tables.h', - 'include/freetype/ttnameid.h', - 'include/freetype/tttables.h', - 'include/freetype/tttags.h', - 'include/freetype/ttunpat.h', -] - -ft_config_headers = [ - 'include/freetype/config/ftconfig.h', - 'include/freetype/config/ftheader.h', - 'include/freetype/config/ftmodule.h', - 'include/freetype/config/ftoption.h', - 'include/freetype/config/ftstdlib.h', -] - -if host_machine.system() == 'windows' - base_sources += [ - 'builds/windows/ftdebug.c', - ] -else - base_sources += [ - 'src/base/ftdebug.c', - ] -endif - -c_args = [ - '-DFT2_BUILD_LIBRARY', - '-DFT_CONFIG_CONFIG_H=', - '-DFT_CONFIG_OPTIONS_H=' -] - -check_headers = [] - -if ['linux', 'darwin', 'cygwin'].contains(host_machine.system()) - check_headers += [ - ['unistd.h'], - ['fcntl.h'], - ['stdint.h'], - ] - ftconfig_h_in = files('builds/unix/ftconfig.h.in') -else - ftconfig_h_in = files('include/freetype/config/ftconfig.h') -endif - -conf = configuration_data() -deps = [] -incbase = include_directories(['include']) - -foreach check : check_headers - name = check[0] - - if cc.has_header(name) - conf.set('HAVE_@0@'.format(name.to_upper().underscorify()), 1) - endif -endforeach - -configure_file(input: ftconfig_h_in, - output: 'ftconfig.h', - configuration: conf) - -ft_config_headers += [configure_file(input: 'include/freetype/config/ftoption.h.in', - output: 'ftoption.h', - configuration: conf)] - -libfreetype = static_library('freetype', base_sources, - include_directories: incbase, - dependencies: deps, - c_args: c_args, - gnu_symbol_visibility: 'inlineshidden', -) - -freetype_dep = declare_dependency( - link_with: libfreetype, - include_directories : incbase, - dependencies: deps, -) diff --git a/subprojects/packagefiles/freetype-2.6.1-meson/src/gzip/zconf.h b/subprojects/packagefiles/freetype-2.6.1-meson/src/gzip/zconf.h deleted file mode 100644 index d88a82a2eec8..000000000000 --- a/subprojects/packagefiles/freetype-2.6.1-meson/src/gzip/zconf.h +++ /dev/null @@ -1,284 +0,0 @@ -/* zconf.h -- configuration of the zlib compression library - * Copyright (C) 1995-2002 Jean-loup Gailly. - * For conditions of distribution and use, see copyright notice in zlib.h - */ - -/* @(#) $Id$ */ - -#ifndef _ZCONF_H -#define _ZCONF_H - -/* - * If you *really* need a unique prefix for all types and library functions, - * compile with -DZ_PREFIX. The "standard" zlib should be compiled without it. - */ -#ifdef Z_PREFIX -# define deflateInit_ z_deflateInit_ -# define deflate z_deflate -# define deflateEnd z_deflateEnd -# define inflateInit_ z_inflateInit_ -# define inflate z_inflate -# define inflateEnd z_inflateEnd -# define deflateInit2_ z_deflateInit2_ -# define deflateSetDictionary z_deflateSetDictionary -# define deflateCopy z_deflateCopy -# define deflateReset z_deflateReset -# define deflateParams z_deflateParams -# define inflateInit2_ z_inflateInit2_ -# define inflateSetDictionary z_inflateSetDictionary -# define inflateSync z_inflateSync -# define inflateSyncPoint z_inflateSyncPoint -# define inflateReset z_inflateReset -# define compress z_compress -# define compress2 z_compress2 -# define uncompress z_uncompress -# define adler32 z_adler32 -# define crc32 z_crc32 -# define get_crc_table z_get_crc_table - -# define Byte z_Byte -# define uInt z_uInt -# define uLong z_uLong -# define Bytef z_Bytef -# define charf z_charf -# define intf z_intf -# define uIntf z_uIntf -# define uLongf z_uLongf -# define voidpf z_voidpf -# define voidp z_voidp -#endif - -#if (defined(_WIN32) || defined(__WIN32__)) && !defined(WIN32) -# define WIN32 -#endif -#if defined(__GNUC__) || defined(WIN32) || defined(__386__) || defined(i386) -# ifndef __32BIT__ -# define __32BIT__ -# endif -#endif -#if defined(__MSDOS__) && !defined(MSDOS) -# define MSDOS -#endif - -/* WinCE doesn't have errno.h */ -#ifdef _WIN32_WCE -# define NO_ERRNO_H -#endif - - -/* - * Compile with -DMAXSEG_64K if the alloc function cannot allocate more - * than 64k bytes at a time (needed on systems with 16-bit int). - */ -#if defined(MSDOS) && !defined(__32BIT__) -# define MAXSEG_64K -#endif -#ifdef MSDOS -# define UNALIGNED_OK -#endif - -#if (defined(MSDOS) || defined(_WINDOWS) || defined(WIN32)) && !defined(STDC) -# define STDC -#endif -#if defined(__STDC__) || defined(__cplusplus) || defined(__OS2__) -# ifndef STDC -# define STDC -# endif -#endif - -#ifndef STDC -# ifndef const /* cannot use !defined(STDC) && !defined(const) on Mac */ -# define const -# endif -#endif - -/* Some Mac compilers merge all .h files incorrectly: */ -#if defined(__MWERKS__) || defined(applec) ||defined(THINK_C) ||defined(__SC__) -# define NO_DUMMY_DECL -#endif - -/* Old Borland C and LCC incorrectly complains about missing returns: */ -#if defined(__BORLANDC__) && (__BORLANDC__ < 0x500) -# define NEED_DUMMY_RETURN -#endif - -#if defined(__LCC__) -# define NEED_DUMMY_RETURN -#endif - -/* Maximum value for memLevel in deflateInit2 */ -#ifndef MAX_MEM_LEVEL -# ifdef MAXSEG_64K -# define MAX_MEM_LEVEL 8 -# else -# define MAX_MEM_LEVEL 9 -# endif -#endif - -/* Maximum value for windowBits in deflateInit2 and inflateInit2. - * WARNING: reducing MAX_WBITS makes minigzip unable to extract .gz files - * created by gzip. (Files created by minigzip can still be extracted by - * gzip.) - */ -#ifndef MAX_WBITS -# define MAX_WBITS 15 /* 32K LZ77 window */ -#endif - -/* The memory requirements for deflate are (in bytes): - (1 << (windowBits+2)) + (1 << (memLevel+9)) - that is: 128K for windowBits=15 + 128K for memLevel = 8 (default values) - plus a few kilobytes for small objects. For example, if you want to reduce - the default memory requirements from 256K to 128K, compile with - make CFLAGS="-O -DMAX_WBITS=14 -DMAX_MEM_LEVEL=7" - Of course this will generally degrade compression (there's no free lunch). - - The memory requirements for inflate are (in bytes) 1 << windowBits - that is, 32K for windowBits=15 (default value) plus a few kilobytes - for small objects. -*/ - - /* Type declarations */ - -#ifndef OF /* function prototypes */ -# ifdef STDC -# define OF(args) args -# else -# define OF(args) () -# endif -#endif - -/* The following definitions for FAR are needed only for MSDOS mixed - * model programming (small or medium model with some far allocations). - * This was tested only with MSC; for other MSDOS compilers you may have - * to define NO_MEMCPY in zutil.h. If you don't need the mixed model, - * just define FAR to be empty. - */ -#if (defined(M_I86SM) || defined(M_I86MM)) && !defined(__32BIT__) - /* MSC small or medium model */ -# define SMALL_MEDIUM -# ifdef _MSC_VER -# define FAR _far -# else -# define FAR far -# endif -#endif -#if defined(__BORLANDC__) && (defined(__SMALL__) || defined(__MEDIUM__)) -# ifndef __32BIT__ -# define SMALL_MEDIUM -# define FAR _far -# endif -#endif - -/* Compile with -DZLIB_DLL for Windows DLL support */ -#if defined(ZLIB_DLL) -# if defined(_WINDOWS) || defined(WINDOWS) -# ifdef FAR -# undef FAR -# endif -# include -# define ZEXPORT(x) x WINAPI -# ifdef WIN32 -# define ZEXPORTVA(x) x WINAPIV -# else -# define ZEXPORTVA(x) x FAR _cdecl _export -# endif -# endif -# if defined (__BORLANDC__) -# if (__BORLANDC__ >= 0x0500) && defined (WIN32) -# include -# define ZEXPORT(x) x __declspec(dllexport) WINAPI -# define ZEXPORTRVA(x) x __declspec(dllexport) WINAPIV -# else -# if defined (_Windows) && defined (__DLL__) -# define ZEXPORT(x) x _export -# define ZEXPORTVA(x) x _export -# endif -# endif -# endif -#endif - - -#ifndef ZEXPORT -# define ZEXPORT(x) static x -#endif -#ifndef ZEXPORTVA -# define ZEXPORTVA(x) static x -#endif -#ifndef ZEXTERN -# define ZEXTERN(x) static x -#endif -#ifndef ZEXTERNDEF -# define ZEXTERNDEF(x) static x -#endif - -#ifndef FAR -# define FAR -#endif - -#if !defined(__MACTYPES__) -typedef unsigned char Byte; /* 8 bits */ -#endif -typedef unsigned int uInt; /* 16 bits or more */ -typedef unsigned long uLong; /* 32 bits or more */ - -#ifdef SMALL_MEDIUM - /* Borland C/C++ and some old MSC versions ignore FAR inside typedef */ -# define Bytef Byte FAR -#else - typedef Byte FAR Bytef; -#endif -typedef char FAR charf; -typedef int FAR intf; -typedef uInt FAR uIntf; -typedef uLong FAR uLongf; - -#ifdef STDC - typedef void FAR *voidpf; - typedef void *voidp; -#else - typedef Byte FAR *voidpf; - typedef Byte *voidp; -#endif - -#ifdef HAVE_UNISTD_H -# include /* for off_t */ -# include /* for SEEK_* and off_t */ -# define z_off_t off_t -#endif -#ifndef SEEK_SET -# define SEEK_SET 0 /* Seek from beginning of file. */ -# define SEEK_CUR 1 /* Seek from current position. */ -# define SEEK_END 2 /* Set file pointer to EOF plus "offset" */ -#endif -#ifndef z_off_t -# define z_off_t long -#endif - -/* MVS linker does not support external names larger than 8 bytes */ -#if defined(__MVS__) -# pragma map(deflateInit_,"DEIN") -# pragma map(deflateInit2_,"DEIN2") -# pragma map(deflateEnd,"DEEND") -# pragma map(inflateInit_,"ININ") -# pragma map(inflateInit2_,"ININ2") -# pragma map(inflateEnd,"INEND") -# pragma map(inflateSync,"INSY") -# pragma map(inflateSetDictionary,"INSEDI") -# pragma map(inflate_blocks,"INBL") -# pragma map(inflate_blocks_new,"INBLNE") -# pragma map(inflate_blocks_free,"INBLFR") -# pragma map(inflate_blocks_reset,"INBLRE") -# pragma map(inflate_codes_free,"INCOFR") -# pragma map(inflate_codes,"INCO") -# pragma map(inflate_fast,"INFA") -# pragma map(inflate_flush,"INFLU") -# pragma map(inflate_mask,"INMA") -# pragma map(inflate_set_dictionary,"INSEDI2") -# pragma map(inflate_copyright,"INCOPY") -# pragma map(inflate_trees_bits,"INTRBI") -# pragma map(inflate_trees_dynamic,"INTRDY") -# pragma map(inflate_trees_fixed,"INTRFI") -# pragma map(inflate_trees_free,"INTRFR") -#endif - -#endif /* _ZCONF_H */ diff --git a/subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch b/subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch new file mode 100644 index 000000000000..c00baa702f65 --- /dev/null +++ b/subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch @@ -0,0 +1,36 @@ +From 34aed655f1696da774b5cdd4c5effb312153232f Mon Sep 17 00:00:00 2001 +From: Benoit Pierre +Date: Sat, 12 Oct 2024 10:49:46 +0000 +Subject: [PATCH] * meson.build: Fix `bzip2` option handling. + +--- + meson.build | 11 ++++++++--- + 1 file changed, 8 insertions(+), 3 deletions(-) + +diff --git a/meson.build b/meson.build +index 72b7f9900..2e8d5355e 100644 +--- a/meson.build ++++ b/meson.build +@@ -320,11 +320,16 @@ else + endif + + # BZip2 support. +-bzip2_dep = dependency('bzip2', required: false) ++bzip2_dep = dependency( ++ 'bzip2', ++ required: get_option('bzip2').disabled() ? get_option('bzip2') : false, ++) + if not bzip2_dep.found() +- bzip2_dep = cc.find_library('bz2', ++ bzip2_dep = cc.find_library( ++ 'bz2', + has_headers: ['bzlib.h'], +- required: get_option('bzip2')) ++ required: get_option('bzip2'), ++ ) + endif + + if bzip2_dep.found() +-- +GitLab + From 7787153a524475e60350ca2d87b2caf00905c6aa Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 23 Apr 2025 05:36:31 -0400 Subject: [PATCH 010/108] Bump minimum meson-python to 0.13.2 --- pyproject.toml | 4 ++-- requirements/testing/minver.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b580feff930e..18d99e3111e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ requires-python = ">=3.11" [project.optional-dependencies] # Should be a copy of the build dependencies below. dev = [ - "meson-python>=0.13.1,!=0.17.*", + "meson-python>=0.13.2,!=0.17.*", "pybind11>=2.13.2,!=2.13.3", "setuptools_scm>=7", # Not required by us but setuptools_scm without a version, cso _if_ @@ -72,7 +72,7 @@ build-backend = "mesonpy" requires = [ # meson-python 0.17.x breaks symlinks in sdists. You can remove this pin if # you really need it and aren't using an sdist. - "meson-python>=0.13.1,!=0.17.*", + "meson-python>=0.13.2,!=0.17.*", "pybind11>=2.13.2,!=2.13.3", "setuptools_scm>=7", ] diff --git a/requirements/testing/minver.txt b/requirements/testing/minver.txt index ee55f6c7b1bf..3b6aea9e7ca3 100644 --- a/requirements/testing/minver.txt +++ b/requirements/testing/minver.txt @@ -5,7 +5,7 @@ cycler==0.10 fonttools==4.22.0 importlib-resources==3.2.0 kiwisolver==1.3.2 -meson-python==0.13.1 +meson-python==0.13.2 meson==1.1.0 numpy==1.25.0 packaging==20.0 From 972a82173fba27b00696fbcb5cc4a20caacb6bd0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 2 Jul 2025 17:36:02 -0400 Subject: [PATCH 011/108] ci: Purge Strawberry Perl from Windows builders We don't use Perl, and it includes a completely busted version of `patch`. --- .github/workflows/cibuildwheel.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index fececb0dfc40..dc05ec5483fd 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -140,6 +140,10 @@ jobs: name: cibw-sdist path: dist/ + - name: Purge Strawberry Perl + if: startsWith(matrix.os, 'windows-') + run: Remove-Item -Recurse C:\Strawberry + - name: Build wheels for CPython 3.14 uses: pypa/cibuildwheel@5f22145df44122af0f5a201f93cf0207171beca7 # v3.0.0 with: From 5fc955939dd074beea4554b6e2b668061fb46073 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 16 Jul 2025 04:42:42 -0400 Subject: [PATCH 012/108] Don't set a default size for FT2Font In the interest of handling non-scalable fonts and reducing font initialization, drop the default size from the `FT2Font` constructor. Non-scalable fonts are sometimes used for bitmap-backed emoji fonts. When we start supporting collection fonts (`.ttc`), then setting a size is a waste, as we will just need to read the count of fonts within. The renderer method `Renderer.draw_text` always sets a size immediately after creating the font object, so this doesn't affect anything in most cases. Only the direct `FT2Font` tests need changes. --- doc/api/next_api_changes/behavior/30318-ES.rst | 9 +++++++++ lib/matplotlib/tests/test_ft2font.py | 7 ++++++- src/ft2font.cpp | 6 ------ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/30318-ES.rst diff --git a/doc/api/next_api_changes/behavior/30318-ES.rst b/doc/api/next_api_changes/behavior/30318-ES.rst new file mode 100644 index 000000000000..805901dcb21d --- /dev/null +++ b/doc/api/next_api_changes/behavior/30318-ES.rst @@ -0,0 +1,9 @@ +FT2Font no longer sets a default size +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In the interest of handling non-scalable fonts and reducing font initialization, the +`.FT2Font` constructor no longer sets a default size. Non-scalable fonts are sometimes +used for bitmap-backed emoji fonts. + +If metrics are important (i.e., if you are loading character glyphs, or setting a text +string), then explicitly call `.FT2Font.set_size` beforehand. diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 0dc0667d0e84..b39df1f52996 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -188,8 +188,8 @@ def test_ft2font_clear(): def test_ft2font_set_size(): file = fm.findfont('DejaVu Sans') - # Default is 12pt @ 72 dpi. font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=1) + font.set_size(12, 72) font.set_text('ABabCDcd') orig = font.get_width_height() font.set_size(24, 72) @@ -757,6 +757,7 @@ def test_ft2font_get_kerning(left, right, unscaled, unfitted, default): def test_ft2font_set_text(): file = fm.findfont('DejaVu Sans') font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font.set_size(12, 72) xys = font.set_text('') np.testing.assert_array_equal(xys, np.empty((0, 2))) assert font.get_width_height() == (0, 0) @@ -778,6 +779,7 @@ def test_ft2font_set_text(): def test_ft2font_loading(): file = fm.findfont('DejaVu Sans') font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font.set_size(12, 72) for glyph in [font.load_char(ord('M')), font.load_glyph(font.get_char_index(ord('M')))]: assert glyph is not None @@ -818,11 +820,13 @@ def test_ft2font_drawing(): expected *= 255 file = fm.findfont('DejaVu Sans') font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font.set_size(12, 72) font.set_text('M') font.draw_glyphs_to_bitmap(antialiased=False) image = font.get_image() np.testing.assert_array_equal(image, expected) font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font.set_size(12, 72) glyph = font.load_char(ord('M')) image = np.zeros(expected.shape, np.uint8) font.draw_glyph_to_bitmap(image, -1, 1, glyph, antialiased=False) @@ -832,6 +836,7 @@ def test_ft2font_drawing(): def test_ft2font_get_path(): file = fm.findfont('DejaVu Sans') font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font.set_size(12, 72) vertices, codes = font.get_path() assert vertices.shape == (0, 2) assert codes.shape == (0, ) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index ca8881d98c50..1d03ecf10b56 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -221,12 +221,6 @@ FT2Font::FT2Font(FT_Open_Args &open_args, if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } - 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)); } From 7a628e5b752da6e5b549a5180e3e1ebb4f142b9d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 18 Jul 2025 02:25:58 -0400 Subject: [PATCH 013/108] Deprecate font_manager.is_opentype_cff_font According to the docs, it was used for PostScript and PDF which "cannot subset those fonts". However, that is no longer true, and there are no users of this function. --- doc/api/next_api_changes/deprecations/30329-ES.rst | 4 ++++ lib/matplotlib/font_manager.py | 2 +- lib/matplotlib/tests/test_font_manager.py | 6 ++++-- 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30329-ES.rst diff --git a/doc/api/next_api_changes/deprecations/30329-ES.rst b/doc/api/next_api_changes/deprecations/30329-ES.rst new file mode 100644 index 000000000000..8d5060c4821b --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30329-ES.rst @@ -0,0 +1,4 @@ +``font_manager.is_opentype_cff_font`` is deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There is no replacement. diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index ab6b495631de..79e088b85998 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1539,7 +1539,7 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default, return _cached_realpath(result) -@lru_cache +@_api.deprecated("3.11") def is_opentype_cff_font(filename): """ Return whether the given font is a Postscript Compact Font Format Font diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 24421b8e30b3..b15647644e04 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -67,12 +67,14 @@ def test_json_serialization(tmp_path): def test_otf(): fname = '/usr/share/fonts/opentype/freefont/FreeMono.otf' if Path(fname).exists(): - assert is_opentype_cff_font(fname) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + assert is_opentype_cff_font(fname) for f in fontManager.ttflist: if 'otf' in f.fname: with open(f.fname, 'rb') as fd: res = fd.read(4) == b'OTTO' - assert res == is_opentype_cff_font(f.fname) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + assert res == is_opentype_cff_font(f.fname) @pytest.mark.skipif(sys.platform == "win32" or not has_fclist, From 42c108a850fb88e78b9a3d990d826ce536a0d6e0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 16 Jul 2025 20:43:18 -0400 Subject: [PATCH 014/108] Deprecate setting text kerning factor to any non-None value This factor existed only to preserve test images, but as of #29816, it is set to 0 (i.e., disabled and providing default behaviour). In the future, with libraqm, it will have no effect no matter its setting (because we won't be applying kerning ourselves at all.) --- .../deprecations/30322-ES.rst | 7 +++++ doc/users/prev_whats_new/whats_new_3.2.0.rst | 29 ++++++------------- lib/matplotlib/__init__.py | 2 ++ lib/matplotlib/ft2font.pyi | 2 +- lib/matplotlib/mpl-data/matplotlibrc | 7 ++--- lib/matplotlib/rcsetup.py | 2 +- lib/matplotlib/tests/test_ft2font.py | 20 ++++++++----- src/ft2font_wrapper.cpp | 20 +++++++------ 8 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30322-ES.rst diff --git a/doc/api/next_api_changes/deprecations/30322-ES.rst b/doc/api/next_api_changes/deprecations/30322-ES.rst new file mode 100644 index 000000000000..b9c4964e58c8 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30322-ES.rst @@ -0,0 +1,7 @@ +Font kerning factor is deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Due to internal changes to support complex text rendering, the kerning factor on fonts is +no longer used. Setting the ``text.kerning_factor`` rcParam (which existed only for +backwards-compatibility) to any value other than None is deprecated, and the rcParam will +be removed in the future. diff --git a/doc/users/prev_whats_new/whats_new_3.2.0.rst b/doc/users/prev_whats_new/whats_new_3.2.0.rst index 12d7fab3af90..3519245642a8 100644 --- a/doc/users/prev_whats_new/whats_new_3.2.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.2.0.rst @@ -52,26 +52,15 @@ triangle meshes. Kerning adjustments now use correct values ------------------------------------------ -Due to an error in how kerning adjustments were applied, previous versions of -Matplotlib would under-correct kerning. This version will now correctly apply -kerning (for fonts supported by FreeType). To restore the old behavior (e.g., -for test images), you may set :rc:`text.kerning_factor` to 6 (instead of 0). -Other values have undefined behavior. - -.. plot:: - - import matplotlib.pyplot as plt - - # Use old kerning values: - plt.rcParams['text.kerning_factor'] = 6 - fig, ax = plt.subplots() - ax.text(0.0, 0.05, 'BRAVO\nAWKWARD\nVAT\nW.Test', fontsize=56) - ax.set_title('Before (text.kerning_factor = 6)') - -Note how the spacing between characters is uniform between their bounding boxes -(above). With corrected kerning (below), slanted characters (e.g., AV or VA) -will be spaced closer together, as well as various other character pairs, -depending on font support (e.g., T and e, or the period after the W). +Due to an error in how kerning adjustments were applied, previous versions of Matplotlib +would under-correct kerning. This version will now correctly apply kerning (for fonts +supported by FreeType). To restore the old behavior (e.g., for test images), you may set +the ``text.kerning_factor`` rcParam to 6 (instead of 0). Other values have undefined +behavior. + +With corrected kerning (below), slanted characters (e.g., AV or VA) will be spaced closer +together, as well as various other character pairs, depending on font support (e.g., T +and e, or the period after the W). .. plot:: diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 008d4de77a3b..e343e60b5fa1 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -751,6 +751,8 @@ def __setitem__(self, key, val): f"a list of valid parameters)") from err except ValueError as ve: raise ValueError(f"Key {key}: {ve}") from None + if key == "text.kerning_factor" and cval is not None: + _api.warn_deprecated("3.11", name="text.kerning_factor", obj_type="rcParam") self._set(key, cval) def __getitem__(self, key): diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index a413cd3c1a76..5257893b380a 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -191,7 +191,7 @@ class FT2Font(Buffer): hinting_factor: int = ..., *, _fallback_list: list[FT2Font] | None = ..., - _kerning_factor: int = ... + _kerning_factor: int | None = ... ) -> None: ... if sys.version_info[:2] >= (3, 12): def __buffer__(self, flags: int) -> memoryview: ... diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 0ab5cfadf291..fb96656c5303 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -306,10 +306,9 @@ #text.hinting_factor: 1 # Specifies the amount of softness for hinting in the # horizontal direction. A value of 1 will hint to full # pixels. A value of 2 will hint to half pixels etc. -#text.kerning_factor: 0 # Specifies the scaling factor for kerning values. This - # is provided solely to allow old test images to remain - # unchanged. Set to 6 to obtain previous behavior. - # Values other than 0 or 6 have no defined meaning. +#text.kerning_factor: None # Specifies the scaling factor for kerning values. Values + # other than 0, 6, or None have no defined meaning. + # This setting is deprecated. #text.antialiased: True # If True (default), the text will be antialiased. # This only affects raster outputs. #text.parse_math: True # Use mathtext if there is an even number of unescaped diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 80d25659888e..b4224d169815 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1042,7 +1042,7 @@ def _convert_validator_spec(key, conv): "text.hinting": ["default", "no_autohint", "force_autohint", "no_hinting", "auto", "native", "either", "none"], "text.hinting_factor": validate_int, - "text.kerning_factor": validate_int, + "text.kerning_factor": validate_int_or_None, "text.antialiased": validate_bool, "text.parse_math": validate_bool, diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index b39df1f52996..5dd96ce9cafe 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -168,6 +168,12 @@ def test_ft2font_invalid_args(tmp_path): # kerning_factor argument. with pytest.raises(TypeError, match='incompatible constructor arguments'): ft2font.FT2Font(file, _kerning_factor=1.3) + with pytest.warns(mpl.MatplotlibDeprecationWarning, + match='text.kerning_factor rcParam was deprecated .+ 3.11'): + mpl.rcParams['text.kerning_factor'] = 0 + with pytest.warns(mpl.MatplotlibDeprecationWarning, + match='_kerning_factor parameter was deprecated .+ 3.11'): + ft2font.FT2Font(file, _kerning_factor=123) def test_ft2font_clear(): @@ -188,7 +194,7 @@ def test_ft2font_clear(): def test_ft2font_set_size(): file = fm.findfont('DejaVu Sans') - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=1) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(12, 72) font.set_text('ABabCDcd') orig = font.get_width_height() @@ -717,7 +723,7 @@ def test_ft2font_get_sfnt_table(font_name, header): def test_ft2font_get_kerning(left, right, unscaled, unfitted, default): file = fm.findfont('DejaVu Sans') # With unscaled, these settings should produce exact values found in FontForge. - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(100, 100) assert font.get_kerning(font.get_char_index(ord(left)), font.get_char_index(ord(right)), @@ -756,7 +762,7 @@ def test_ft2font_get_kerning(left, right, unscaled, unfitted, default): def test_ft2font_set_text(): file = fm.findfont('DejaVu Sans') - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(12, 72) xys = font.set_text('') np.testing.assert_array_equal(xys, np.empty((0, 2))) @@ -778,7 +784,7 @@ def test_ft2font_set_text(): def test_ft2font_loading(): file = fm.findfont('DejaVu Sans') - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(12, 72) for glyph in [font.load_char(ord('M')), font.load_glyph(font.get_char_index(ord('M')))]: @@ -819,13 +825,13 @@ def test_ft2font_drawing(): ]) expected *= 255 file = fm.findfont('DejaVu Sans') - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(12, 72) font.set_text('M') font.draw_glyphs_to_bitmap(antialiased=False) image = font.get_image() np.testing.assert_array_equal(image, expected) - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(12, 72) glyph = font.load_char(ord('M')) image = np.zeros(expected.shape, np.uint8) @@ -835,7 +841,7 @@ def test_ft2font_drawing(): def test_ft2font_get_path(): file = fm.findfont('DejaVu Sans') - font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + font = ft2font.FT2Font(file, hinting_factor=1) font.set_size(12, 72) vertices, codes = font.get_path() assert vertices.shape == (0, 2) diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index cb816efff9a9..5ba4bec36874 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -432,12 +432,6 @@ const char *PyFT2Font_init__doc__ = R"""( .. warning:: This API is both private and provisional: do not use it directly. - _kerning_factor : int, optional - Used to adjust the degree of kerning. - - .. warning:: - This API is private: do not use it directly. - _warn_if_used : bool, optional Used to trigger missing glyph warnings. @@ -448,11 +442,19 @@ const char *PyFT2Font_init__doc__ = R"""( static PyFT2Font * PyFT2Font_init(py::object filename, long hinting_factor = 8, std::optional> fallback_list = std::nullopt, - int kerning_factor = 0, bool warn_if_used = false) + std::optional kerning_factor = std::nullopt, + bool warn_if_used = false) { if (hinting_factor <= 0) { throw py::value_error("hinting_factor must be greater than 0"); } + if (kerning_factor) { + auto api = py::module_::import("matplotlib._api"); + auto warn = api.attr("warn_deprecated"); + warn("since"_a="3.11", "name"_a="_kerning_factor", "obj_type"_a="parameter"); + } else { + kerning_factor = 0; + } PyFT2Font *self = new PyFT2Font(); self->x = nullptr; @@ -500,7 +502,7 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, self->x = new FT2Font(open_args, hinting_factor, fallback_fonts, ft_glyph_warn, warn_if_used); - self->x->set_kerning_factor(kerning_factor); + self->x->set_kerning_factor(*kerning_factor); return self; } @@ -1605,7 +1607,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) 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, + "_fallback_list"_a=py::none(), "_kerning_factor"_a=py::none(), "_warn_if_used"_a=false, PyFT2Font_init__doc__) .def("clear", &PyFT2Font_clear, PyFT2Font_clear__doc__) From 3a0a7734466db95809e72d7912d7a075b0ee4d12 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 3 Jun 2025 18:59:00 -0400 Subject: [PATCH 015/108] TYP: Make glyph indices distinct from character codes Previously, these were both typed as `int`, which means you could mix and match them erroneously. While the character code can't be made a distinct type (because it's used for `chr`/`ord`), typing glyph indices as a distinct type means these can't be fully swapped. Unfortunately, you can still go back to the base type, so glyph indices still work as character codes. But this is still sufficient to catch errors such as the wrong call to `FT2Font.get_kerning` in `_mathtext.py`. --- .../next_api_changes/development/30143-ES.rst | 7 +++++ lib/matplotlib/_afm.py | 19 +++++++------ lib/matplotlib/_mathtext.py | 28 +++++++++---------- lib/matplotlib/_mathtext_data.py | 18 +++++++----- lib/matplotlib/_text_helpers.py | 4 +-- lib/matplotlib/dviread.pyi | 7 +++-- lib/matplotlib/ft2font.pyi | 22 +++++++++------ lib/matplotlib/tests/test_ft2font.py | 5 ++-- src/ft2font_wrapper.cpp | 3 ++ 9 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 doc/api/next_api_changes/development/30143-ES.rst diff --git a/doc/api/next_api_changes/development/30143-ES.rst b/doc/api/next_api_changes/development/30143-ES.rst new file mode 100644 index 000000000000..2d79ad6bbe9d --- /dev/null +++ b/doc/api/next_api_changes/development/30143-ES.rst @@ -0,0 +1,7 @@ +Glyph indices now typed distinctly from character codes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, character codes and glyph indices were both typed as `int`, which means you +could mix and match them erroneously. While the character code can't be made a distinct +type (because it's used for `chr`/`ord`), typing glyph indices as a distinct type means +these can't be fully swapped. diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py index 352d3c42247e..3d7f7a44baca 100644 --- a/lib/matplotlib/_afm.py +++ b/lib/matplotlib/_afm.py @@ -30,9 +30,10 @@ import inspect import logging import re -from typing import BinaryIO, NamedTuple, TypedDict +from typing import BinaryIO, NamedTuple, TypedDict, cast from ._mathtext_data import uni2type1 +from .ft2font import CharacterCodeType, GlyphIndexType _log = logging.getLogger(__name__) @@ -197,7 +198,7 @@ class CharMetrics(NamedTuple): The bbox of the character (B) as a tuple (*llx*, *lly*, *urx*, *ury*).""" -def _parse_char_metrics(fh: BinaryIO) -> tuple[dict[int, CharMetrics], +def _parse_char_metrics(fh: BinaryIO) -> tuple[dict[CharacterCodeType, CharMetrics], dict[str, CharMetrics]]: """ Parse the given filehandle for character metrics information. @@ -218,7 +219,7 @@ def _parse_char_metrics(fh: BinaryIO) -> tuple[dict[int, CharMetrics], """ required_keys = {'C', 'WX', 'N', 'B'} - ascii_d: dict[int, CharMetrics] = {} + ascii_d: dict[CharacterCodeType, CharMetrics] = {} name_d: dict[str, CharMetrics] = {} for bline in fh: # We are defensively letting values be utf8. The spec requires @@ -409,19 +410,21 @@ def get_str_bbox_and_descent(self, s: str) -> tuple[int, int, float, int, int]: return left, miny, total_width, maxy - miny, -miny - def get_glyph_name(self, glyph_ind: int) -> str: # For consistency with FT2Font. + def get_glyph_name(self, # For consistency with FT2Font. + glyph_ind: GlyphIndexType) -> str: """Get the name of the glyph, i.e., ord(';') is 'semicolon'.""" - return self._metrics[glyph_ind].name + return self._metrics[cast(CharacterCodeType, glyph_ind)].name - def get_char_index(self, c: int) -> int: # For consistency with FT2Font. + def get_char_index(self, # For consistency with FT2Font. + c: CharacterCodeType) -> GlyphIndexType: """ Return the glyph index corresponding to a character code point. Note, for AFM fonts, we treat the glyph index the same as the codepoint. """ - return c + return cast(GlyphIndexType, c) - def get_width_char(self, c: int) -> float: + def get_width_char(self, c: CharacterCodeType) -> float: """Get the width of the character code from the character metric WX field.""" return self._metrics[c].width diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 19ddbb6d0883..afaa9ade6018 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -37,7 +37,8 @@ if T.TYPE_CHECKING: from collections.abc import Iterable - from .ft2font import Glyph + from .ft2font import CharacterCodeType, Glyph + ParserElement.enable_packrat() _log = logging.getLogger("matplotlib.mathtext") @@ -47,7 +48,7 @@ # FONTS -def get_unicode_index(symbol: str) -> int: # Publicly exported. +def get_unicode_index(symbol: str) -> CharacterCodeType: # Publicly exported. r""" Return the integer index (from the Unicode table) of *symbol*. @@ -85,7 +86,7 @@ class VectorParse(NamedTuple): width: float height: float depth: float - glyphs: list[tuple[FT2Font, float, int, float, float]] + glyphs: list[tuple[FT2Font, float, CharacterCodeType, float, float]] rects: list[tuple[float, float, float, float]] VectorParse.__module__ = "matplotlib.mathtext" @@ -212,7 +213,7 @@ class FontInfo(NamedTuple): fontsize: float postscript_name: str metrics: FontMetrics - num: int + num: CharacterCodeType glyph: Glyph offset: float @@ -365,7 +366,7 @@ def _get_offset(self, font: FT2Font, glyph: Glyph, fontsize: float, return 0. def _get_glyph(self, fontname: str, font_class: str, - sym: str) -> tuple[FT2Font, int, bool]: + sym: str) -> tuple[FT2Font, CharacterCodeType, bool]: raise NotImplementedError # The return value of _get_info is cached per-instance. @@ -459,7 +460,7 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag _slanted_symbols = set(r"\int \oint".split()) def _get_glyph(self, fontname: str, font_class: str, - sym: str) -> tuple[FT2Font, int, bool]: + sym: str) -> tuple[FT2Font, CharacterCodeType, bool]: font = None if fontname in self.fontmap and sym in latex_to_bakoma: basename, num = latex_to_bakoma[sym] @@ -551,7 +552,7 @@ class UnicodeFonts(TruetypeFonts): # Some glyphs are not present in the `cmr10` font, and must be brought in # from `cmsy10`. Map the Unicode indices of those glyphs to the indices at # which they are found in `cmsy10`. - _cmr10_substitutions = { + _cmr10_substitutions: dict[CharacterCodeType, CharacterCodeType] = { 0x00D7: 0x00A3, # Multiplication sign. 0x2212: 0x00A1, # Minus sign. } @@ -594,11 +595,11 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag _slanted_symbols = set(r"\int \oint".split()) def _map_virtual_font(self, fontname: str, font_class: str, - uniindex: int) -> tuple[str, int]: + uniindex: CharacterCodeType) -> tuple[str, CharacterCodeType]: return fontname, uniindex def _get_glyph(self, fontname: str, font_class: str, - sym: str) -> tuple[FT2Font, int, bool]: + sym: str) -> tuple[FT2Font, CharacterCodeType, bool]: try: uniindex = get_unicode_index(sym) found_symbol = True @@ -607,8 +608,7 @@ def _get_glyph(self, fontname: str, font_class: str, found_symbol = False _log.warning("No TeX to Unicode mapping for %a.", sym) - fontname, uniindex = self._map_virtual_font( - fontname, font_class, uniindex) + fontname, uniindex = self._map_virtual_font(fontname, font_class, uniindex) new_fontname = fontname @@ -693,7 +693,7 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag self.fontmap[name] = fullpath def _get_glyph(self, fontname: str, font_class: str, - sym: str) -> tuple[FT2Font, int, bool]: + sym: str) -> tuple[FT2Font, CharacterCodeType, bool]: # Override prime symbol to use Bakoma. if sym == r'\prime': return self.bakoma._get_glyph(fontname, font_class, sym) @@ -783,7 +783,7 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag self.fontmap[name] = fullpath def _map_virtual_font(self, fontname: str, font_class: str, - uniindex: int) -> tuple[str, int]: + uniindex: CharacterCodeType) -> tuple[str, CharacterCodeType]: # Handle these "fonts" that are actually embedded in # other fonts. font_mapping = stix_virtual_fonts.get(fontname) @@ -1170,7 +1170,7 @@ def __init__(self, elements: T.Sequence[Node]): self.glue_sign = 0 # 0: normal, -1: shrinking, 1: stretching self.glue_order = 0 # The order of infinity (0 - 3) for the glue - def __repr__(self): + def __repr__(self) -> str: return "{}[{}]".format( super().__repr__(), self.width, self.height, diff --git a/lib/matplotlib/_mathtext_data.py b/lib/matplotlib/_mathtext_data.py index 5819ee743044..0451791e9f26 100644 --- a/lib/matplotlib/_mathtext_data.py +++ b/lib/matplotlib/_mathtext_data.py @@ -3,9 +3,12 @@ """ from __future__ import annotations -from typing import overload +from typing import TypeAlias, overload -latex_to_bakoma = { +from .ft2font import CharacterCodeType + + +latex_to_bakoma: dict[str, tuple[str, CharacterCodeType]] = { '\\__sqrt__' : ('cmex10', 0x70), '\\bigcap' : ('cmex10', 0x5c), '\\bigcup' : ('cmex10', 0x5b), @@ -241,7 +244,7 @@ # Automatically generated. -type12uni = { +type12uni: dict[str, CharacterCodeType] = { 'aring' : 229, 'quotedblright' : 8221, 'V' : 86, @@ -475,7 +478,7 @@ # for key in sd: # print("{0:24} : {1: dict[str, float]: ... @property - def index(self) -> int: ... # type: ignore[override] + def index(self) -> GlyphIndexType: ... # type: ignore[override] @property - def glyph_name_or_index(self) -> int | str: ... + def glyph_name_or_index(self) -> GlyphIndexType | str: ... class Dvi: file: io.BufferedReader diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 5257893b380a..a4ddc84358c1 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -1,6 +1,6 @@ from enum import Enum, Flag import sys -from typing import BinaryIO, Literal, TypedDict, final, overload, cast +from typing import BinaryIO, Literal, NewType, TypeAlias, TypedDict, cast, final, overload from typing_extensions import Buffer # < Py 3.12 import numpy as np @@ -9,6 +9,12 @@ from numpy.typing import NDArray __freetype_build_type__: str __freetype_version__: str +# We can't change the type hints for standard library chr/ord, so character codes are a +# simple type alias. +CharacterCodeType: TypeAlias = int +# But glyph indices are internal, so use a distinct type hint. +GlyphIndexType = NewType('GlyphIndexType', int) + class FaceFlags(Flag): SCALABLE = cast(int, ...) FIXED_SIZES = cast(int, ...) @@ -202,13 +208,13 @@ class FT2Font(Buffer): ) -> None: ... def draw_glyphs_to_bitmap(self, antialiased: bool = ...) -> None: ... def get_bitmap_offset(self) -> tuple[int, int]: ... - def get_char_index(self, codepoint: int) -> int: ... - def get_charmap(self) -> dict[int, int]: ... + def get_char_index(self, codepoint: CharacterCodeType) -> GlyphIndexType: ... + def get_charmap(self) -> dict[CharacterCodeType, GlyphIndexType]: ... def get_descent(self) -> int: ... - def get_glyph_name(self, index: int) -> str: ... + def get_glyph_name(self, index: GlyphIndexType) -> str: ... def get_image(self) -> NDArray[np.uint8]: ... - def get_kerning(self, left: int, right: int, mode: Kerning) -> int: ... - def get_name_index(self, name: str) -> int: ... + def get_kerning(self, left: GlyphIndexType, right: GlyphIndexType, mode: Kerning) -> int: ... + def get_name_index(self, name: str) -> GlyphIndexType: ... def get_num_glyphs(self) -> int: ... def get_path(self) -> tuple[NDArray[np.float64], NDArray[np.int8]]: ... def get_ps_font_info( @@ -230,8 +236,8 @@ class FT2Font(Buffer): @overload def get_sfnt_table(self, name: Literal["pclt"]) -> _SfntPcltDict | None: ... def get_width_height(self) -> tuple[int, int]: ... - def load_char(self, charcode: int, flags: LoadFlags = ...) -> Glyph: ... - def load_glyph(self, glyphindex: int, flags: LoadFlags = ...) -> Glyph: ... + def load_char(self, charcode: CharacterCodeType, flags: LoadFlags = ...) -> Glyph: ... + def load_glyph(self, glyphindex: GlyphIndexType, flags: LoadFlags = ...) -> Glyph: ... def select_charmap(self, i: int) -> None: ... def set_charmap(self, i: int) -> None: ... def set_size(self, ptsize: float, dpi: float) -> None: ... diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 5dd96ce9cafe..6b405287e5d7 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -1,6 +1,7 @@ import itertools import io from pathlib import Path +from typing import cast import numpy as np import pytest @@ -241,7 +242,7 @@ def enc(name): assert unic == after # This is just a random sample from FontForge. - glyph_names = { + glyph_names = cast(dict[str, ft2font.GlyphIndexType], { 'non-existent-glyph-name': 0, 'plusminus': 115, 'Racute': 278, @@ -253,7 +254,7 @@ def enc(name): 'uni2A02': 4464, 'u1D305': 5410, 'u1F0A1': 5784, - } + }) for name, index in glyph_names.items(): assert font.get_name_index(name) == index if name == 'non-existent-glyph-name': diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 5ba4bec36874..31202f018e42 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1774,5 +1774,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) m.attr("__freetype_version__") = version_string; m.attr("__freetype_build_type__") = FREETYPE_BUILD_TYPE; + auto py_int = py::module_::import("builtins").attr("int"); + m.attr("CharacterCodeType") = py_int; + m.attr("GlyphIndexType") = py_int; m.def("__getattr__", ft2font__getattr__); } From c6e690489637b25e8bd883cc9299751462a1da96 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Jun 2025 23:18:52 -0400 Subject: [PATCH 016/108] Fix kerning of mathtext The `FontInfo.num` value returned by `TruetypeFonts._get_info` is a character code, but `FT2Font.get_kerning` takes *glyph indices*, meaning that kerning was likely off in most cases. --- lib/matplotlib/_mathtext.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index afaa9ade6018..78f8913cd65a 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -426,7 +426,9 @@ def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float, info1 = self._get_info(font1, fontclass1, sym1, fontsize1, dpi) info2 = self._get_info(font2, fontclass2, sym2, fontsize2, dpi) font = info1.font - return font.get_kerning(info1.num, info2.num, Kerning.DEFAULT) / 64 + return font.get_kerning(font.get_char_index(info1.num), + font.get_char_index(info2.num), + Kerning.DEFAULT) / 64 return super().get_kern(font1, fontclass1, sym1, fontsize1, font2, fontclass2, sym2, fontsize2, dpi) From 733cd7d99a3534be25dd46cd2c05b93749ec1b39 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 24 Jul 2025 15:46:13 -0400 Subject: [PATCH 017/108] Update test images for previous changes --- .../test_mathtext/mathtext_cm_21.svg | 1476 +++++++++-------- .../test_mathtext/mathtext_cm_23.png | Bin 3144 -> 2823 bytes .../test_mathtext/mathtext_cm_23.svg | 599 +++---- .../test_mathtext/mathtext_dejavusans_21.svg | 907 +++++----- .../test_mathtext/mathtext_dejavusans_23.png | Bin 3122 -> 2822 bytes .../test_mathtext/mathtext_dejavusans_23.svg | 537 +++--- .../test_mathtext/mathtext_dejavusans_27.svg | 383 +++-- .../test_mathtext/mathtext_dejavusans_46.svg | 229 +-- .../test_mathtext/mathtext_dejavusans_49.svg | 211 +-- .../test_mathtext/mathtext_dejavusans_60.svg | 418 ++--- .../test_mathtext/mathtext_dejavuserif_21.svg | 1020 ++++++------ .../test_mathtext/mathtext_dejavuserif_23.png | Bin 3125 -> 2853 bytes .../test_mathtext/mathtext_dejavuserif_23.svg | 559 ++++--- .../test_mathtext/mathtext_dejavuserif_60.svg | 444 ++--- .../test_mathtext/mathtext_stix_21.svg | 1096 ++++++------ .../test_mathtext/mathtext_stix_23.png | Bin 3135 -> 2826 bytes .../test_mathtext/mathtext_stix_23.svg | 573 ++++--- .../test_mathtext/mathtext_stixsans_21.svg | 904 +++++----- .../test_mathtext/mathtext_stixsans_23.png | Bin 3099 -> 2808 bytes .../test_mathtext/mathtext_stixsans_23.svg | 539 +++--- 20 files changed, 5180 insertions(+), 4715 deletions(-) diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_21.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_21.svg index 6967f80a1186..a7195c665c14 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_21.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_21.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:36.846948 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,721 +26,752 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_23.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_23.png index 0317cb99e1c00d2d126a11b341ffe67100702976..c2076843da4aa5e8c399a405e432f818e61b17b9 100644 GIT binary patch literal 2823 zcmc&$XH-+!77nP)0E4J>3_(Yo#|RJUMJWSPq=b%02@wzw=^>Cnq^MxUpa?RwFgSEb zLJ=t;L`RAaA_yr6NDHAwB0Uf(bNKUqzhCdIb?-fA?Yr-}yL|iG`+<{#wS<_W7z6^5 zu(@R60)YrifW6(1BH+1t_vHt$TtQo2LAyo9>CiRhX8CKpO8BSk?OjpkBz7WZ&Ev}K*3jmiKK{;5o9TGN8{h9qrsHh+fOKF{*AbmD zaj@5T%nkK%n|RRbG<(T6gg(b|LOiN9j z+gxUwgfeS9PjyGTd3gykND>fri&T+=HKU_G5$g(istsEOgYm2UtJM|rO1`C@43N?Kal z{BS6lG3aPY#N+XrvsG191B*@Lq}o~yL_8-uVYOF3k*7LDOQvaQX-UIir2c-VFIe{D zgxNw&+ssR1HHpMj!i-LSIu0oo65@TbR?hC>xHvmM?Z#p-O|QZ}zsSmx?(FDLF*ZhY z$twh)XFe$C>gtAkEHM20<3}cw=uMqL)cL2CITn%b{2*CXTiYMJ7n7@h@}wCY4i^Zx zWZ=>Z3a{0!u&~es2Q@@lh>D7y$1eTRzBwfx8yj0$QE}-Tl~0)IEBeL{p2@k9s4>1``=2=5SPiJRm!Q?$Mn5?30kt zDk?6nP1_Z4mxfwnx#_1@w`Pkn5+YEjdK)-gX*Y3wfnk;TzNcrk3PDSvKE3AU<<;f$ zj2!Pl@|kyL_4i+ec59xaZP)j~R%d#(UCZn>|K(7yWPkxqSY>X_}_PPEJ z1fzLp3U=9_jcTz6WrB?5kKxD<($jUQR4OdCva-^bdh55hg(3&r0P@#2HWvQOFj0i5 z0z_WBRua@l__vKB6zXPXb}OQNE2!lQZ?ao7Ffj1^&COa?fr5g(npU@YMR8%*w6QML)xL|(U)+19%GD(Ym8qFT3=hcf?z$#k7%{w zGKqsDku+jalHmOUG$0*d5Z!0YzdqIR0M>2gOVhOCa5%OO4$MwD~Y;JCD(pq{vd5^mB_4A*Wk7R!}Fu0Pr zw!PZNEzR*A0J)`)j@I0|C8Q9Lm77~>y1V%{^kc|indkZjX6)U&cMbS0!R}oG0TzqF zRF{-kym|8m)#3v#F)^`$>G#jhgwS^mNk~YXRZ(#Y4<{Zwc5FJ1LL`O0D=$i_0;s1h z{U!eJ@neR&2ck5X5FbP*7#kZeaJhq=93(F`Y;Khs^ec_Gr< zJKy6(NNDI_U=AXVDX@9=%dcu`_MV=ef~B{TMCO_Ye>|DCHp_Rf@o5?Owi@8?{}QAC zkX%K3yDu_nt8@&@mS8X#TicnZn?{JB#&=eF&iQs*;o*5YHpRuoH$p>KtCSQU-M?Q; zI=*1+rF7(7^B`nNW}$u_WcbYzhb6gq%gt9to$@TSe{KF@Ron9; zTAi(KZT0GsFA8%zU5NX-R7L&HqqtwbT*l)?WCRo?Zsgv+eLE{FYuW?h z;*uG+I9$5Bo0QQCFc`Csh}3?_rH2_AtPi=`jec)MV$o<7eSH=iYh`tJpP|U@0$Y%p zVJfZ(mb@K(=9gax9?0a`@USp?89X1oGHtUs(&PwAbC6!?+IVvdRb&jPy!4MhPL!6G zhVXe`&c`lf4G#~y0fTC4)PQm%T~}64PE}Ra4rnAL8l$bP?}6Un?&9(ypc`LFqakq{ zU!8$J$E2h{Bj}4k#&cu<0Ju-BVf&rTp}?S+<%@%>=k!!>Fg%d%k&$HB%_TEkJ-w=i z2G>aH`g0~3NiHWO*w)q6B{4X&v(-Q@X}Y}>^=*6mCOPVdgC)yj4@7-bOtz+_PQ6dP zcMrE%um`B^!&K)ZIH>Dr%9o(7bcM3^_OTCz7g!x#U58l`RF1|9{1BVnE0R2GoUm5m zS%2+8%i4h5(ZhTuNEdy>kq#(SDwP}8JQHAE}Dqq8&j$&({eN=o4_uCBd+&3h*% zCMt^TqND3_jpFNV?Ci8bWdCI1zao8oBVgDGA?@&C9VuDaAX2;FMuUH+*dT+UyI|BM z@9OOA7eQIn(~h39kdl%L0F7CDyUQ95e{3BNn0o)D93Rw!Tn)e5Et7YTe*k>go2woC zv656@k80-2c=-5TCi{ll+S$e9goXMv+j&QYUmq$1*mN{nPR+~Dmr+(W9qSPiK3pvH zng%@Arqk&qr>ybme3z7z4uS|ouH=xoYz4quxkI5#OiTme=86sIw<@Nl$!$3z2i1IA z4n~=IHAfY%tgN6XUJI?SuZIZM<^=%Rx)|Q*vM(3_uts1Gs#(Gc|9_^T|7s}Pmh2GD VBU01<;(}=nVq@uGK{CI3`ybYCRIUI3 literal 3144 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 diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_23.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_23.svg index 9d57faac5f18..09dd81f56563 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_23.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_23.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:33.031781 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,297 +26,317 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + - - - + - + - - + - - - - + - - - - - - - - - - - - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_21.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_21.svg index 90f9b2cec969..b6236288603d 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_21.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_21.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:40.551077 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,437 +26,467 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_23.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_23.png index d6802f84bfda1b1eb62f0b43e1655f44b1b690b2..5a615d92e166a98248600a46f67c6770f06c47e8 100644 GIT binary patch literal 2822 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 H&N2T6^r6IS diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_23.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_23.svg index 77ded780c3f1..4d7fdcc30954 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_23.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_23.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:40.658346 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,268 +26,288 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + - - - + - - + - - + - - + - + - - - - - - - - - - - - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_27.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_27.svg index 7a7b7ec42c25..e73b1c5e872b 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_27.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_27.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:39.585869 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,190 +26,202 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - - + - - - + - - - + - - - - - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_46.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_46.svg index 0846b552246a..ca0439485b08 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_46.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_46.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:40.642097 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,115 +26,121 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - - - + - - - - - - +M 1381 2969 +Q 1594 3256 1914 3420 +Q 2234 3584 2584 3584 +Q 3122 3584 3439 3221 +Q 3756 2859 3756 2241 +Q 3756 1734 3570 1259 +Q 3384 784 3041 416 +Q 2816 172 2522 40 +Q 2228 -91 1906 -91 +Q 1566 -91 1316 65 +Q 1066 222 909 531 +L 806 0 +L 231 0 +L 1178 4863 +L 1753 4863 +L 1381 2969 +z +" transform="scale(0.015625)"/> + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_49.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_49.svg index 24db824fd37c..8287a2338258 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_49.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_49.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:40.896681 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,103 +26,109 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - - - + + - + - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_60.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_60.svg index 189491319c10..0bbef213526f 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_60.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_60.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:39.508765 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,209 +26,220 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + + - + - - - + - - + - - - - - - - - - - - - - +M 1959 2075 +Q 2384 2075 2632 2365 +Q 2881 2656 2881 3163 +Q 2881 3666 2632 3958 +Q 2384 4250 1959 4250 +Q 1534 4250 1286 3958 +Q 1038 3666 1038 3163 +Q 1038 2656 1286 2365 +Q 1534 2075 1959 2075 +z +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_21.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_21.svg index e0721c9e47a4..3e7c6dc1c42c 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_21.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_21.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:40.642033 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,493 +26,524 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_23.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_23.png index b405dd438309c2f44abc7e9ee3bbf115dd84c202..c8bae48383aa5503ea433fd5f57972daf62d20ef 100644 GIT binary patch literal 2853 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 F@L%p)Jz)R< literal 3125 zcmd5;`9D-`8$ZNEwkSP}HL?`tiLs1jh_UyWvJ<04sE3KjHZt~oPgEFDqQpG1jxfoV zD5jAiGb4sELS$)VeNXS_{R7_L-uM2TbMA9K*E#pO?(4d~-|L%t!Ol|X5cCiL07BMQ zNP7U_76R{W`5@qREvaD;oOpswtsVKm5yj`70zUHxTDb)SfXJ2KCs&nmhi9Y(mwk5LzANFQ@JB3JXJM&E@6X7cX86Bzo+RNnZJyuPt<4!Ktc z?|p^akM?J@NqR;7==r^>>Bdef##d12XoHUb7yyj5W1A)JNEU#RdYPqxjc~>+SO83@?@q6{)yzZqIN%(nwQNGw0ExZ=Z*5Hcrp<-*auo$hcp+6vQ4r zE|F>B|D}PSw6~@9Wn!W(a^ogDq&23u{R{btWzgoz74}Bt42yO3=i;~xw#mIlFYqg{ zwY{C2lM_Ruc_btz{xzndt}aL<5|4~#Hj-*<&%ktbnQs#X_(Vkdb~%&T>+2!sA8KBw z{h(oV5xeUS(#Fx!TD~6>#{GLMYzv3Ux$=oLR@M(TRM@2;iw~39dt@#tOmB0T>b#$9663K{MIL#%mQ?%6DcNhF+ z&*eYgfj%p#tDhHy#E(#^*l#-@jw{&P+bbMDeq2ma@}%-TYg02br88&jqW6ByogM0% zz1?4Kp5E=PY3=U*C1Gy<*7tAUZh{+48!U!96rAxq6+HJ*yvAAWSo(EZU9AUELBx3H zA0Q_of#;z?SZ#NA_q*OqDc%^5j;Qha-nck!E_u-B+e1biKA?YaFh4eyi_l&*0xz-a z@9!4{G<`oxKh*Ng8hoTZ;EOK5+&ye^u7*HB{q{*iW2m~FNOS|l zoxw}DiQWsNHQXkM{heUcHoIs$j#r@jW@B2)yOouHQZ{i8LLoE|2reH>3{(^pc6WF4 z^70fw5PzDSG-gT5;dbhP92c*CD)>8nC3Bm9cvV-Fe4)) zHH)%LtE;P99lMqt!4emRhkY*w%g={aXTiDTj*pMWH?_4H zk{31*`|cbSWn~L|1WR&ggI(llW)_cHaxLZN(ZmD<)VO%s+NJ|JB?e(x@V-1H+Y@m3 zxNp{@M^G+#85tR7t&*muz0NI$;nX8S$;48qt)E|2+varok)Q3(rlxU*>%(SY`6*Qg<)U)RV&i0QB|LrHe5DJbCoMek z`7;-=x3h*?oM;vS@qo}>|2#UXprUdxH8u5t5mslK$qf22ZSK($*y&6A@!h(`S)Tc< zvJ&g5aj8krz2+EL;L&}ul9HWu*ZLk)<}aYpS+8Dc=HI_Rm8r{kldEV60F`zn(BJ^y zA-p*bXMF6m`_s14i_lC691d62(CBNuvv9%9jlcS{{uPp%OYP(OddjjSnyzDLm~lkK zPF_*5Ypx?fT+R7%c(?=@3~(#hULYqox2!dweB#)@w*tZ+EHMEmCnr^Pb)=`KjI?kv zRVW9z;Ov|I}5h4g&zl%VSLeQXsR$pT5-eW-0Vq zPnvjDZEde-7=5;R$P2g23$!q^45H9l&`i9sh>*}Zx!{b4vP?!#L zgiO`3l?8r39FH2in0kDCz%g!B$>Fhk$-P;@4C}S9Y7^I^lmT(AdYb%nl z(2nJBuk3!gn(tiQ^TMfmcw{7j!C=HtsiUKid;PE}X6`8kMa8-s6lHaV9`eYB{!~6R6 zG9pouw7+LySBl7NZPkD2R_{aGXl!b_I`uY@+StrspdWnjfoW@(y?!mFQzV4teD49* zO`jbo%%0%fcR|O}F_sn<5X8=k+2WT*0T8DtSy>QZsGYy3r-!|;5Dzw$AIv`T_;_k1 zKJw5cZr>;^J>7^zDsy)MGgt3BRQ~ue;9M;Z9=yD~Em6N#@04(n1FqZO>N?Sy=s(%v z30T?Kq)k#$#d^m<*m9cyDP+HV&)1>W_V#(8b0|97LD;U6;wy+l>G0@d931W;&CMge z$%TLAnqOR$4igs5C6M;qK_)fa1yVXM=UB={cn@mTjr(((yzS~zQd9f;tyWtors#vR zwY4?APn^rj$|_}MZZ5rR>Oo*z{6P_$+qYlM=_)az*8Yl^uP8)5A*T$?u8z2=A0ucz z)W7p0zo1|%qtc!8(O?-|e`Tb`^R=^$jm_pd53f-sAI_@8>i)p2sjsifVyFR_=IrMV zm3Hr2o`h{LY($<=R))Y_!oqZ;x0iH5g(iRIj4&jA%A%mQ+o%3_)D_}3H^UQ>lf5{u z*@!LG=$#*yJF5d4=p_*1Y&JUvERw5N6+lX6HIr`AzkZdsE74D6v;DezdOSIisTdVd z_=OB?v5Icx6cn(CjOJT%gFag5@F`w@dg~oA7mK6Cpj>1ZR8ZyvK}DvBK*$;y8R^~K zf`KhFjm2U~brdF~aQA`AviW6c^cKI@-3{?bEVhg?pHp0%vADQc^plh&cJcCmL?=`K h|4(%OSDJW7 - - + + + + + + 2025-07-24T15:42:40.725227 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,278 +26,298 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + - - - + - + - + - - - + - - + - - - - - - - - - - - - - - - - - - - - - +M 3022 2063 +Q 3016 2534 2758 2815 +Q 2500 3097 2075 3097 +Q 1594 3097 1305 2825 +Q 1016 2553 972 2059 +L 3022 2063 +z +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_60.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_60.svg index a4fb4be582a4..cd7dfc34183b 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_60.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_60.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:42.172241 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,223 +26,234 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + - + - - - + - - + - + - - - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_21.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_21.svg index 4623754e2963..045cc829e0cf 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_21.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_21.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:38.874726 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,531 +26,562 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_23.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_23.png index 1923648b80a3d4e1b4098614faa2b8b4fd08c3ad..d0233a4ee7a0136ab93656d81e101a97dfb49807 100644 GIT binary patch literal 2826 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 V?H43i=Rn&AF*iots5ZQv@E=cIIIsW! literal 3135 zcmdT{`9D-`8$XuFR#_rLi0aAKBKsOM8B_)pB7>BOkfmWXL-8=y;X#CHlu&6bX%LO2 zY)=#(Wfvk#!we?IFnI6Y&-=ssAH1LUe9k%dIp^N4<$GP%_axZaBE^JZ!VmP1vc3&reccCDwD z*|EMKAA>U#y;RjylxasY60@>8gD1*Wj^KSJ?|Wblv@dUL_tpl^HginhoMdv=JG(d0 z*&vf%rAv)N=;3HV0tfvy~8>Ph4DF)ze2wSy}mr zs2J#Z7W?Er=*m2)y-!>`T_R2Z^f*%nmy;>IKpXzt&3TxZ-ThtB?VQKk3pa!*8wC@5ePC(fC z)2B}zot^M==Q?Y3x{X<)iHV6PZx6qrYt2qfDEaHkSz20hO$R1}n!Kyb5!;mp_vV>Q za%M!J)XPm&=xHyXf@td9MHi^)Gs&p^9<8Wo7cy1e9FI4BGfZcpZcMeM1R0Wci-^=8 z-`dtBkx0Op(XH2HYi1hjbd?=S@)kstay1eX60EGP%`acxZ*%S(?SXkjghBL9ya=Qp zG9#Lso7?mC>n<9an4B#7thgAPf0OER=X;CLD>Myew!N+blauq*hE;lg5Xd))l-AVL zbgsO%E7XwWj@w}@O@7c1o!j>dYZ4qBeA2I9qe#b<;wY~l1kq?TLQ)dk)^?8t9A1t^ zBM|%wQQSMDVFPYc{mC`eYioC%HKg;+q7?c{Q0+AKx1`o285Mo~!P$Hj6_xd0eW*JN zgQvQ3PVsqH$;zBSBmDf-9m}pJ6ci|77AHT%FaOAPe?K33ZJW#K`1tXGWua_UxebLd zQPpkwp|=n@79uGQ9TN6%tqIr<1y3~b5btDn9_j7v{VO;3r$T9IsU##JE30a4ZGBDa z5r4EXXF5Y+R$4|z1X4YITsp;41q{`S+P)_)9x+S8@JIhzUA<7tEG=(P6NoWqH16Pq z#KiW;G%Hc5R7PFp%sz2dHMPHhY(jhYX1BB;goK3Bb8-$CM666xZ$BkQsH>|V(bsqO z?#ekNB6B>`AY_K%SeliXC`_gucW=<>e5eWJk&*c$WTq?q`SW8}8u2tJ?OgWpgbw#8 z6ow+z9}ZRB?Cg4Unl^J(7$GVq z)#I<57aJRE6%bI?UfX(29D03wM3+67Ao<61ydpkGvHt4up*Ox6P}YRUnx}I<7ItPeigAt5@{;-vL9e40;jY&Ps{TFDzu9zWI55 zL8q>NWhI#HeD>_0KH4y%9^+G938#dOJ7BsQHV#>)NCH()~>LZ=j70CZX~D0 z@Wx$BC4pgKRHWO*i$LAQks6{=`-5E~hYbvd{8B-T^C(d$lqobaB&6y2z|VXMO+veHsM+FIab1*RSv?TPv%y_4ROBS=r_1u2tSCz|@4X z01WX(|Ix#T!?|(3U->L~()O^E=fJ6gDUo5driM$QqS242aT{T){k}E1I za;9N*ssqM^OTS{jvH|Q&lzh^gI+C60$rR z(tSunL)3*Led65`Z@z7b)A-U-P!VTqF_KQH!OGJ^4G*NW-61P_)Qn69zYUsvzf0aQ zw1d9->Xin0Jlx0M9}yI^6#GrP^ySNn2$uK4H=3NBUO-Dn$L@z3R~~%)Xful6T=aU( znk7f^LIH1oNS6;JTNZA1$Hc_!OFzYq2=xy|UGh7)M-b8CN91AR{`hxPEQJ~#1mf9) z%ndNRF#1-~-rnB1;oe*1*h5X%3hz&_OOB4%wOvbHu$WA4%iJQ-62jPE~K!2{$pHQUqcqa8l?B)$T}L=y#VR@v}-X1?v1UH8N_CGN+wJS?`@FoI9llcBH;-_08`GhX z=ez;H`6nJnGR%X6wWPbU`0==KYTF>Gj)<@W5 znDMh=VLJGoEzFmJfho=`8F}rRvLnSshaunE+8U%U58Pp(AuR&jtCw$zgX!q#IKK`4 zQ9C>2ubXciAptaOEpw~qx;UXwd%L_(!(gzU-roBS4WZGGf%mj;kmUf z?d>8EkCJ7fJ=p8e@4BL!Hx^bn9t_Ge+N5p6JEjpQ2ZJ5f()!0O3a|=mSJ%950|cUB zd&AG##wM67ct%tp#(1SeWr|@9umSSo-<_QqA+w)3qmAmbi*$O%vuB6dPWySR?CjE} zfBZ=JFu62z4lzB^9P?Lx{sR7S%Sd3OX8F67L@@Qv+pCN^-~-~;Z(K^r$PkAvQrAG0 zm5gbgD08LW%97f@e|bbwYDru9UYcRLT6X83)z!L#i+8*7^a3r&EO%lNp{hf`EU-S5%yAX=!QlIJy3c$zWK5n*Iw*b7v+0q|@PG2!bDEupCp|M(hUER7f;U zB;z-P8o4V;?dwZRCtX}!CqoUz6%~muE|#Q@z^QjVJfIR=(u2RRTbw`N6 z(|Z=!z!3m|V@%;T*P8uxr&?v)TZ%8_9bNuN;FSR!ax4wRF{5^BPfJ_d)B1Y35)`@Z z{huP9mCj`xQwinV1As=>9opiuy4Qb}miP4a@sVufr>C!9zj4E@X6H^Of!qEchB=h* he?&|Fi#1P2Z4VcyJD7HQfj - - + + + + + + 2025-07-24T15:42:38.959357 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,284 +26,304 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + - - - + - - + - - + - - - + - - - - - - - - - - - - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_21.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_21.svg index d61317816ad6..c3dd8722b044 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_21.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_21.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:38.040182 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,435 +26,466 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_23.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_23.png index a86119004e62ecf23497cd90cf563911bc3679d9..6f816c2ee723ac761b49e407d6a8eea1e088d8df 100644 GIT binary patch literal 2808 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 CAyDc7 literal 3099 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 B_&ERo diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_23.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_23.svg index 4e129aa6c87d..50bdb38d37b1 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_23.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_23.svg @@ -1,12 +1,23 @@ - - + + + + + + 2025-07-24T15:42:38.119948 + image/svg+xml + + + Matplotlib v3.11.0.dev1119+gc6e6904896.d20250724, https://matplotlib.org/ + + + + + - + @@ -15,269 +26,289 @@ L 378 54 L 378 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - + - - - + - + - - + - - - + - - + - - - - - - - - - - - - - - - - - - - - +" transform="scale(0.015625)"/> + + + + + + + + + + + + + + + + + + + + + + + + + From fa62956b9e6b66fe6637e82bcde06e1bc04c6bb7 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 16 Jul 2025 05:05:01 -0400 Subject: [PATCH 018/108] Split font opening/closing out of FT2Font constructor/destructor This makes it easier to do later refactors. --- src/ft2font.cpp | 25 ++++++++++++++++++------- src/ft2font.h | 5 +++-- src/ft2font_wrapper.cpp | 4 ++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 1d03ecf10b56..cc8e6f26caff 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -207,9 +206,7 @@ FT2Font::get_path(std::vector &vertices, std::vector &cod codes.push_back(CLOSEPOLY); } -FT2Font::FT2Font(FT_Open_Args &open_args, - long hinting_factor_, - std::vector &fallback_list, +FT2Font::FT2Font(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({1, 1}), face(nullptr), hinting_factor(hinting_factor_), @@ -217,22 +214,36 @@ FT2Font::FT2Font(FT_Open_Args &open_args, kerning_factor(0) { clear(); + // Set fallbacks + std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); +} + +FT2Font::~FT2Font() +{ + close(); +} + +void FT2Font::open(FT_Open_Args &open_args) +{ FT_CHECK(FT_Open_Face, _ft2Library, &open_args, 0, &face); if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } - // Set fallbacks - std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); } -FT2Font::~FT2Font() +void FT2Font::close() { + // This should be idempotent, in case a user manually calls close before the + // destructor does. + for (auto & glyph : glyphs) { FT_Done_Glyph(glyph); } + glyphs.clear(); if (face) { FT_Done_Face(face); + face = nullptr; } } diff --git a/src/ft2font.h b/src/ft2font.h index 0881693e7557..a4443a0cd74d 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -99,10 +99,11 @@ class FT2Font typedef void (*WarnFunc)(FT_ULong charcode, std::set family_names); public: - FT2Font(FT_Open_Args &open_args, long hinting_factor, - std::vector &fallback_list, + FT2Font(long hinting_factor, std::vector &fallback_list, WarnFunc warn, bool warn_if_used); virtual ~FT2Font(); + void open(FT_Open_Args &open_args); + void close(); void clear(); void set_size(double ptsize, double dpi); void set_charmap(int i); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 31202f018e42..3678370b4c3a 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -499,10 +499,10 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, self->stream.close = nullptr; } - self->x = new FT2Font(open_args, hinting_factor, fallback_fonts, ft_glyph_warn, + self->x = new FT2Font(hinting_factor, fallback_fonts, ft_glyph_warn, warn_if_used); - self->x->set_kerning_factor(*kerning_factor); + self->x->open(open_args); return self; } From db17bafa6111ff27640fdb3b567be9f8ceb05859 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 17 Jul 2025 01:15:38 -0400 Subject: [PATCH 019/108] Make PyFT2Font a subclass of FT2Font --- src/ft2font.cpp | 26 ++-- src/ft2font.h | 14 +-- src/ft2font_wrapper.cpp | 272 ++++++++++++++-------------------------- 3 files changed, 115 insertions(+), 197 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index cc8e6f26caff..ebb7d5204d80 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -207,15 +207,13 @@ FT2Font::get_path(std::vector &vertices, std::vector &cod } FT2Font::FT2Font(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({1, 1}), face(nullptr), + bool warn_if_used) + : warn_if_used(warn_if_used), image({1, 1}), face(nullptr), fallbacks(fallback_list), hinting_factor(hinting_factor_), // set default kerning factor to 0, i.e., no kerning manipulation kerning_factor(0) { clear(); - // Set fallbacks - std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); } FT2Font::~FT2Font() @@ -234,7 +232,8 @@ void FT2Font::open(FT_Open_Args &open_args) void FT2Font::close() { // This should be idempotent, in case a user manually calls close before the - // destructor does. + // destructor does. Note for example, that PyFT2Font _does_ call this before the + // base destructor to ensure internal pointers are cleared early enough. for (auto & glyph : glyphs) { FT_Done_Glyph(glyph); @@ -544,10 +543,9 @@ FT_UInt FT2Font::get_char_index(FT_ULong charcode, bool fallback = false) return FT_Get_Char_Index(ft_object->get_face(), charcode); } -void FT2Font::get_width_height(long *width, long *height) +std::tuple FT2Font::get_width_height() { - *width = advance; - *height = bbox.yMax - bbox.yMin; + return {advance, bbox.yMax - bbox.yMin}; } long FT2Font::get_descent() @@ -555,10 +553,9 @@ long FT2Font::get_descent() return -bbox.yMin; } -void FT2Font::get_bitmap_offset(long *x, long *y) +std::tuple FT2Font::get_bitmap_offset() { - *x = bbox.xMin; - *y = 0; + return {bbox.xMin, 0}; } void FT2Font::draw_glyphs_to_bitmap(bool antialiased) @@ -607,8 +604,11 @@ void FT2Font::draw_glyph_to_bitmap( draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y); } -void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer) +std::string FT2Font::get_glyph_name(unsigned int glyph_number) { + std::string buffer; + buffer.resize(128); + if (!FT_HAS_GLYPH_NAMES(face)) { /* Note that this generated name must match the name that is generated by ttconv in ttfont_CharStrings_getname. */ @@ -625,6 +625,8 @@ void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer) buffer.resize(len); } } + + return buffer; } long FT2Font::get_name_index(char *name) diff --git a/src/ft2font.h b/src/ft2font.h index a4443a0cd74d..80bc490f4bad 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -96,11 +97,9 @@ extern FT_Library _ft2Library; class FT2Font { - typedef void (*WarnFunc)(FT_ULong charcode, std::set family_names); - public: FT2Font(long hinting_factor, std::vector &fallback_list, - WarnFunc warn, bool warn_if_used); + bool warn_if_used); virtual ~FT2Font(); void open(FT_Open_Args &open_args); void close(); @@ -124,14 +123,14 @@ class FT2Font std::set &glyph_seen_fonts, bool override); void load_glyph(FT_UInt glyph_index, FT_Int32 flags); - void get_width_height(long *width, long *height); - void get_bitmap_offset(long *x, long *y); + std::tuple get_width_height(); + std::tuple get_bitmap_offset(); long get_descent(); void draw_glyphs_to_bitmap(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); + std::string get_glyph_name(unsigned int glyph_number); long get_name_index(char *name); FT_UInt get_char_index(FT_ULong charcode, bool fallback); void get_path(std::vector &vertices, std::vector &codes); @@ -167,8 +166,9 @@ class FT2Font return FT_HAS_KERNING(face); } + protected: + virtual void ft_glyph_warn(FT_ULong charcode, std::set family_names) = 0; private: - WarnFunc ft_glyph_warn; bool warn_if_used; py::array_t image; FT_Face face; diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 3678370b4c3a..99023836b001 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -331,16 +331,35 @@ PyGlyph_get_bbox(PyGlyph *self) * FT2Font * */ -struct PyFT2Font +class PyFT2Font final : public FT2Font { - FT2Font *x; + public: + using FT2Font::FT2Font; + py::object py_file; FT_StreamRec stream; py::list fallbacks; ~PyFT2Font() { - delete this->x; + // Because destructors are called from subclass up to base class, we need to + // explicitly close the font here. Otherwise, the instance attributes here will + // be destroyed before the font itself, but those are used in the close callback. + close(); + } + + void ft_glyph_warn(FT_ULong charcode, std::set family_names) + { + std::set::iterator it = family_names.begin(); + std::stringstream ss; + ss<<*it; + while(++it != family_names.end()){ + ss<<", "<<*it; + } + + auto text_helpers = py::module_::import("matplotlib._text_helpers"); + auto warn_on_missing_glyph = text_helpers.attr("warn_on_missing_glyph"); + warn_on_missing_glyph(charcode, ss.str()); } }; @@ -402,21 +421,6 @@ close_file_callback(FT_Stream stream) PyErr_Restore(type, value, traceback); } -static void -ft_glyph_warn(FT_ULong charcode, std::set family_names) -{ - std::set::iterator it = family_names.begin(); - std::stringstream ss; - ss<<*it; - while(++it != family_names.end()){ - ss<<", "<<*it; - } - - auto text_helpers = py::module_::import("matplotlib._text_helpers"); - auto warn_on_missing_glyph = text_helpers.attr("warn_on_missing_glyph"); - warn_on_missing_glyph(charcode, ss.str()); -} - const char *PyFT2Font_init__doc__ = R"""( Parameters ---------- @@ -456,8 +460,23 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, kerning_factor = 0; } - PyFT2Font *self = new PyFT2Font(); - self->x = nullptr; + std::vector fallback_fonts; + if (fallback_list) { + // go through fallbacks to add them to our lists + std::copy(fallback_list->begin(), fallback_list->end(), + std::back_inserter(fallback_fonts)); + } + + auto self = new PyFT2Font(hinting_factor, fallback_fonts, warn_if_used); + self->set_kerning_factor(*kerning_factor); + + if (fallback_list) { + // go through fallbacks to add them to our lists + for (auto item : *fallback_list) { + self->fallbacks.append(item); + } + } + memset(&self->stream, 0, sizeof(FT_StreamRec)); self->stream.base = nullptr; self->stream.size = 0x7fffffff; // Unknown size. @@ -469,18 +488,6 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, open_args.flags = FT_OPEN_STREAM; open_args.stream = &self->stream; - std::vector fallback_fonts; - if (fallback_list) { - // go through fallbacks to add them to our lists - for (auto item : *fallback_list) { - self->fallbacks.append(item); - // Also (locally) cache the underlying FT2Font objects. As long as - // the Python objects are kept alive, these pointer are good. - FT2Font *fback = item->x; - fallback_fonts.push_back(fback); - } - } - if (py::isinstance(filename) || py::isinstance(filename)) { self->py_file = py::module_::import("io").attr("open")(filename, "rb"); self->stream.close = &close_file_callback; @@ -499,10 +506,7 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, self->stream.close = nullptr; } - self->x = new FT2Font(hinting_factor, fallback_fonts, ft_glyph_warn, - warn_if_used); - self->x->set_kerning_factor(*kerning_factor); - self->x->open(open_args); + self->open(open_args); return self; } @@ -520,12 +524,6 @@ PyFT2Font_fname(PyFT2Font *self) const char *PyFT2Font_clear__doc__ = "Clear all the glyphs, reset for a new call to `.set_text`."; -static void -PyFT2Font_clear(PyFT2Font *self) -{ - self->x->clear(); -} - const char *PyFT2Font_set_size__doc__ = R"""( Set the size of the text. @@ -537,12 +535,6 @@ const char *PyFT2Font_set_size__doc__ = R"""( The DPI used for rendering the text. )"""; -static void -PyFT2Font_set_size(PyFT2Font *self, double ptsize, double dpi) -{ - self->x->set_size(ptsize, dpi); -} - const char *PyFT2Font_set_charmap__doc__ = R"""( Make the i-th charmap current. @@ -561,12 +553,6 @@ const char *PyFT2Font_set_charmap__doc__ = R"""( .get_charmap )"""; -static void -PyFT2Font_set_charmap(PyFT2Font *self, int i) -{ - self->x->set_charmap(i); -} - const char *PyFT2Font_select_charmap__doc__ = R"""( Select a charmap by its FT_Encoding number. @@ -585,12 +571,6 @@ const char *PyFT2Font_select_charmap__doc__ = R"""( .get_charmap )"""; -static void -PyFT2Font_select_charmap(PyFT2Font *self, unsigned long i) -{ - self->x->select_charmap(i); -} - const char *PyFT2Font_get_kerning__doc__ = R"""( Get the kerning between two glyphs. @@ -637,7 +617,7 @@ PyFT2Font_get_kerning(PyFT2Font *self, FT_UInt left, FT_UInt right, throw py::type_error("mode must be Kerning or int"); } - return self->x->get_kerning(left, right, mode); + return self->get_kerning(left, right, mode); } const char *PyFT2Font_get_fontmap__doc__ = R"""( @@ -671,7 +651,7 @@ PyFT2Font_get_fontmap(PyFT2Font *self, std::u32string text) py::object target_font; int index; - if (self->x->get_char_fallback_index(code, index)) { + if (self->get_char_fallback_index(code, index)) { if (index >= 0) { target_font = self->fallbacks[index]; } else { @@ -733,7 +713,7 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 throw py::type_error("flags must be LoadFlags or int"); } - self->x->set_text(text, angle, static_cast(flags), xys); + self->set_text(text, angle, static_cast(flags), xys); py::ssize_t dims[] = { static_cast(xys.size()) / 2, 2 }; py::array_t result(dims); @@ -745,12 +725,6 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 const char *PyFT2Font_get_num_glyphs__doc__ = "Return the number of loaded glyphs."; -static size_t -PyFT2Font_get_num_glyphs(PyFT2Font *self) -{ - return self->x->get_num_glyphs(); -} - const char *PyFT2Font_load_char__doc__ = R"""( Load character in current fontfile and set glyph. @@ -800,7 +774,7 @@ PyFT2Font_load_char(PyFT2Font *self, long charcode, throw py::type_error("flags must be LoadFlags or int"); } - self->x->load_char(charcode, static_cast(flags), ft_object, fallback); + self->load_char(charcode, static_cast(flags), ft_object, fallback); return PyGlyph_from_FT2Font(ft_object); } @@ -852,9 +826,9 @@ PyFT2Font_load_glyph(PyFT2Font *self, FT_UInt glyph_index, throw py::type_error("flags must be LoadFlags or int"); } - self->x->load_glyph(glyph_index, static_cast(flags)); + self->load_glyph(glyph_index, static_cast(flags)); - return PyGlyph_from_FT2Font(self->x); + return PyGlyph_from_FT2Font(self); } const char *PyFT2Font_get_width_height__doc__ = R"""( @@ -874,16 +848,6 @@ const char *PyFT2Font_get_width_height__doc__ = R"""( .get_descent )"""; -static py::tuple -PyFT2Font_get_width_height(PyFT2Font *self) -{ - long width, height; - - self->x->get_width_height(&width, &height); - - return py::make_tuple(width, height); -} - const char *PyFT2Font_get_bitmap_offset__doc__ = R"""( Get the (x, y) offset for the bitmap if ink hangs left or below (0, 0). @@ -901,16 +865,6 @@ const char *PyFT2Font_get_bitmap_offset__doc__ = R"""( .get_descent )"""; -static py::tuple -PyFT2Font_get_bitmap_offset(PyFT2Font *self) -{ - long x, y; - - self->x->get_bitmap_offset(&x, &y); - - return py::make_tuple(x, y); -} - const char *PyFT2Font_get_descent__doc__ = R"""( Get the descent of the current string set by `.set_text`. @@ -928,12 +882,6 @@ const char *PyFT2Font_get_descent__doc__ = R"""( .get_width_height )"""; -static long -PyFT2Font_get_descent(PyFT2Font *self) -{ - return self->x->get_descent(); -} - const char *PyFT2Font_draw_glyphs_to_bitmap__doc__ = R"""( Draw the glyphs that were loaded by `.set_text` to the bitmap. @@ -949,12 +897,6 @@ const char *PyFT2Font_draw_glyphs_to_bitmap__doc__ = R"""( .draw_glyph_to_bitmap )"""; -static void -PyFT2Font_draw_glyphs_to_bitmap(PyFT2Font *self, bool antialiased = true) -{ - self->x->draw_glyphs_to_bitmap(antialiased); -} - const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = R"""( Draw a single glyph to the bitmap at pixel locations x, y. @@ -989,7 +931,7 @@ PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, py::buffer &image, auto xd = _double_to_("x", vxd); auto yd = _double_to_("y", vyd); - self->x->draw_glyph_to_bitmap( + self->draw_glyph_to_bitmap( py::array_t{image}, xd, yd, glyph->glyphInd, antialiased); } @@ -1017,16 +959,6 @@ const char *PyFT2Font_get_glyph_name__doc__ = R"""( .get_name_index )"""; -static py::str -PyFT2Font_get_glyph_name(PyFT2Font *self, unsigned int glyph_number) -{ - std::string buffer; - - buffer.resize(128); - self->x->get_glyph_name(glyph_number, buffer); - return buffer; -} - const char *PyFT2Font_get_charmap__doc__ = R"""( Return a mapping of character codes to glyph indices in the font. @@ -1045,10 +977,10 @@ PyFT2Font_get_charmap(PyFT2Font *self) { py::dict charmap; FT_UInt index; - FT_ULong code = FT_Get_First_Char(self->x->get_face(), &index); + FT_ULong code = FT_Get_First_Char(self->get_face(), &index); while (index != 0) { charmap[py::cast(code)] = py::cast(index); - code = FT_Get_Next_Char(self->x->get_face(), code, &index); + code = FT_Get_Next_Char(self->get_face(), code, &index); } return charmap; } @@ -1060,6 +992,8 @@ const char *PyFT2Font_get_char_index__doc__ = R"""( ---------- codepoint : int A character code point in the current charmap (which defaults to Unicode.) + _fallback : bool + Whether to enable fallback fonts while searching for a character. Returns ------- @@ -1074,14 +1008,6 @@ const char *PyFT2Font_get_char_index__doc__ = R"""( .get_name_index )"""; -static FT_UInt -PyFT2Font_get_char_index(PyFT2Font *self, FT_ULong ccode) -{ - bool fallback = true; - - return self->x->get_char_index(ccode, fallback); -} - const char *PyFT2Font_get_sfnt__doc__ = R"""( Load the entire SFNT names table. @@ -1098,17 +1024,17 @@ const char *PyFT2Font_get_sfnt__doc__ = R"""( static py::dict PyFT2Font_get_sfnt(PyFT2Font *self) { - if (!(self->x->get_face()->face_flags & FT_FACE_FLAG_SFNT)) { + if (!(self->get_face()->face_flags & FT_FACE_FLAG_SFNT)) { throw py::value_error("No SFNT name table"); } - size_t count = FT_Get_Sfnt_Name_Count(self->x->get_face()); + size_t count = FT_Get_Sfnt_Name_Count(self->get_face()); py::dict names; for (FT_UInt j = 0; j < count; ++j) { FT_SfntName sfnt; - FT_Error error = FT_Get_Sfnt_Name(self->x->get_face(), j, &sfnt); + FT_Error error = FT_Get_Sfnt_Name(self->get_face(), j, &sfnt); if (error) { throw py::value_error("Could not get SFNT name"); @@ -1143,12 +1069,6 @@ const char *PyFT2Font_get_name_index__doc__ = R"""( .get_glyph_name )"""; -static long -PyFT2Font_get_name_index(PyFT2Font *self, char *glyphname) -{ - return self->x->get_name_index(glyphname); -} - const char *PyFT2Font_get_ps_font_info__doc__ = R"""( Return the information in the PS Font Info structure. @@ -1173,7 +1093,7 @@ PyFT2Font_get_ps_font_info(PyFT2Font *self) { PS_FontInfoRec fontinfo; - FT_Error error = FT_Get_PS_Font_Info(self->x->get_face(), &fontinfo); + FT_Error error = FT_Get_PS_Font_Info(self->get_face(), &fontinfo); if (error) { throw py::value_error("Could not get PS font info"); } @@ -1225,7 +1145,7 @@ PyFT2Font_get_sfnt_table(PyFT2Font *self, std::string tagname) return std::nullopt; } - void *table = FT_Get_Sfnt_Table(self->x->get_face(), tag); + void *table = FT_Get_Sfnt_Table(self->get_face(), tag); if (!table) { return std::nullopt; } @@ -1408,7 +1328,7 @@ PyFT2Font_get_path(PyFT2Font *self) std::vector vertices; std::vector codes; - self->x->get_path(vertices, codes); + self->get_path(vertices, codes); py::ssize_t length = codes.size(); py::ssize_t vertices_dims[2] = { length, 2 }; @@ -1437,12 +1357,6 @@ const char *PyFT2Font_get_image__doc__ = R"""( .get_path )"""; -static py::array -PyFT2Font_get_image(PyFT2Font *self) -{ - return self->x->get_image(); -} - const char *PyFT2Font__get_type1_encoding_vector__doc__ = R"""( Return a list mapping CharString indices of a Type 1 font to FreeType glyph indices. @@ -1454,7 +1368,7 @@ const char *PyFT2Font__get_type1_encoding_vector__doc__ = R"""( static std::array PyFT2Font__get_type1_encoding_vector(PyFT2Font *self) { - auto face = self->x->get_face(); + auto face = self->get_face(); auto indices = std::array{}; for (auto i = 0u; i < indices.size(); ++i) { auto len = FT_Get_PS_Font_Value(face, PS_DICT_ENCODING_ENTRY, i, nullptr, 0); @@ -1610,12 +1524,12 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "_fallback_list"_a=py::none(), "_kerning_factor"_a=py::none(), "_warn_if_used"_a=false, PyFT2Font_init__doc__) - .def("clear", &PyFT2Font_clear, PyFT2Font_clear__doc__) - .def("set_size", &PyFT2Font_set_size, "ptsize"_a, "dpi"_a, + .def("clear", &PyFT2Font::clear, PyFT2Font_clear__doc__) + .def("set_size", &PyFT2Font::set_size, "ptsize"_a, "dpi"_a, PyFT2Font_set_size__doc__) - .def("set_charmap", &PyFT2Font_set_charmap, "i"_a, + .def("set_charmap", &PyFT2Font::set_charmap, "i"_a, PyFT2Font_set_charmap__doc__) - .def("select_charmap", &PyFT2Font_select_charmap, "i"_a, + .def("select_charmap", &PyFT2Font::select_charmap, "i"_a, PyFT2Font_select_charmap__doc__) .def("get_kerning", &PyFT2Font_get_kerning, "left"_a, "right"_a, "mode"_a, PyFT2Font_get_kerning__doc__) @@ -1624,19 +1538,20 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Font_set_text__doc__) .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, PyFT2Font_get_fontmap__doc__) - .def("get_num_glyphs", &PyFT2Font_get_num_glyphs, PyFT2Font_get_num_glyphs__doc__) + .def("get_num_glyphs", &PyFT2Font::get_num_glyphs, + PyFT2Font_get_num_glyphs__doc__) .def("load_char", &PyFT2Font_load_char, "charcode"_a, "flags"_a=LoadFlags::FORCE_AUTOHINT, PyFT2Font_load_char__doc__) .def("load_glyph", &PyFT2Font_load_glyph, "glyph_index"_a, "flags"_a=LoadFlags::FORCE_AUTOHINT, PyFT2Font_load_glyph__doc__) - .def("get_width_height", &PyFT2Font_get_width_height, + .def("get_width_height", &PyFT2Font::get_width_height, PyFT2Font_get_width_height__doc__) - .def("get_bitmap_offset", &PyFT2Font_get_bitmap_offset, + .def("get_bitmap_offset", &PyFT2Font::get_bitmap_offset, PyFT2Font_get_bitmap_offset__doc__) - .def("get_descent", &PyFT2Font_get_descent, PyFT2Font_get_descent__doc__) - .def("draw_glyphs_to_bitmap", &PyFT2Font_draw_glyphs_to_bitmap, + .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__); // The generated docstring uses an unqualified "Buffer" as type hint, @@ -1652,26 +1567,27 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Font_draw_glyph_to_bitmap__doc__); } cls - .def("get_glyph_name", &PyFT2Font_get_glyph_name, "index"_a, + .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__) - .def("get_char_index", &PyFT2Font_get_char_index, "codepoint"_a, + .def("get_char_index", &PyFT2Font::get_char_index, + "codepoint"_a, py::kw_only(), "_fallback"_a=true, PyFT2Font_get_char_index__doc__) .def("get_sfnt", &PyFT2Font_get_sfnt, PyFT2Font_get_sfnt__doc__) - .def("get_name_index", &PyFT2Font_get_name_index, "name"_a, + .def("get_name_index", &PyFT2Font::get_name_index, "name"_a, PyFT2Font_get_name_index__doc__) .def("get_ps_font_info", &PyFT2Font_get_ps_font_info, PyFT2Font_get_ps_font_info__doc__) .def("get_sfnt_table", &PyFT2Font_get_sfnt_table, "name"_a, PyFT2Font_get_sfnt_table__doc__) .def("get_path", &PyFT2Font_get_path, PyFT2Font_get_path__doc__) - .def("get_image", &PyFT2Font_get_image, PyFT2Font_get_image__doc__) + .def("get_image", &PyFT2Font::get_image, PyFT2Font_get_image__doc__) .def("_get_type1_encoding_vector", &PyFT2Font__get_type1_encoding_vector, PyFT2Font__get_type1_encoding_vector__doc__) .def_property_readonly( "postscript_name", [](PyFT2Font *self) { - if (const char *name = FT_Get_Postscript_Name(self->x->get_face())) { + if (const char *name = FT_Get_Postscript_Name(self->get_face())) { return name; } else { return "UNAVAILABLE"; @@ -1679,11 +1595,11 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) }, "PostScript name of the font.") .def_property_readonly( "num_faces", [](PyFT2Font *self) { - return self->x->get_face()->num_faces; + return self->get_face()->num_faces; }, "Number of faces in file.") .def_property_readonly( "family_name", [](PyFT2Font *self) { - if (const char *name = self->x->get_face()->family_name) { + if (const char *name = self->get_face()->family_name) { return name; } else { return "UNAVAILABLE"; @@ -1691,7 +1607,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) }, "Face family name.") .def_property_readonly( "style_name", [](PyFT2Font *self) { - if (const char *name = self->x->get_face()->style_name) { + if (const char *name = self->get_face()->style_name) { return name; } else { return "UNAVAILABLE"; @@ -1699,77 +1615,77 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) }, "Style name.") .def_property_readonly( "face_flags", [](PyFT2Font *self) { - return static_cast(self->x->get_face()->face_flags); + return static_cast(self->get_face()->face_flags); }, "Face flags; see `.FaceFlags`.") .def_property_readonly( "style_flags", [](PyFT2Font *self) { - return static_cast(self->x->get_face()->style_flags & 0xffff); + return static_cast(self->get_face()->style_flags & 0xffff); }, "Style flags; see `.StyleFlags`.") .def_property_readonly( "num_named_instances", [](PyFT2Font *self) { - return (self->x->get_face()->style_flags & 0x7fff0000) >> 16; + return (self->get_face()->style_flags & 0x7fff0000) >> 16; }, "Number of named instances in the face.") .def_property_readonly( "num_glyphs", [](PyFT2Font *self) { - return self->x->get_face()->num_glyphs; + return self->get_face()->num_glyphs; }, "Number of glyphs in the face.") .def_property_readonly( "num_fixed_sizes", [](PyFT2Font *self) { - return self->x->get_face()->num_fixed_sizes; + return self->get_face()->num_fixed_sizes; }, "Number of bitmap in the face.") .def_property_readonly( "num_charmaps", [](PyFT2Font *self) { - return self->x->get_face()->num_charmaps; + return self->get_face()->num_charmaps; }, "Number of charmaps in the face.") .def_property_readonly( "scalable", [](PyFT2Font *self) { - return bool(FT_IS_SCALABLE(self->x->get_face())); + return bool(FT_IS_SCALABLE(self->get_face())); }, "Whether face is scalable; attributes after this one " "are only defined for scalable faces.") .def_property_readonly( "units_per_EM", [](PyFT2Font *self) { - return self->x->get_face()->units_per_EM; + return self->get_face()->units_per_EM; }, "Number of font units covered by the EM.") .def_property_readonly( "bbox", [](PyFT2Font *self) { - FT_BBox bbox = self->x->get_face()->bbox; + FT_BBox bbox = self->get_face()->bbox; return py::make_tuple(bbox.xMin, bbox.yMin, bbox.xMax, bbox.yMax); }, "Face global bounding box (xmin, ymin, xmax, ymax).") .def_property_readonly( "ascender", [](PyFT2Font *self) { - return self->x->get_face()->ascender; + return self->get_face()->ascender; }, "Ascender in 26.6 units.") .def_property_readonly( "descender", [](PyFT2Font *self) { - return self->x->get_face()->descender; + return self->get_face()->descender; }, "Descender in 26.6 units.") .def_property_readonly( "height", [](PyFT2Font *self) { - return self->x->get_face()->height; + return self->get_face()->height; }, "Height in 26.6 units; used to compute a default line spacing " "(baseline-to-baseline distance).") .def_property_readonly( "max_advance_width", [](PyFT2Font *self) { - return self->x->get_face()->max_advance_width; + return self->get_face()->max_advance_width; }, "Maximum horizontal cursor advance for all glyphs.") .def_property_readonly( "max_advance_height", [](PyFT2Font *self) { - return self->x->get_face()->max_advance_height; + return self->get_face()->max_advance_height; }, "Maximum vertical cursor advance for all glyphs.") .def_property_readonly( "underline_position", [](PyFT2Font *self) { - return self->x->get_face()->underline_position; + return self->get_face()->underline_position; }, "Vertical position of the underline bar.") .def_property_readonly( "underline_thickness", [](PyFT2Font *self) { - return self->x->get_face()->underline_thickness; + return self->get_face()->underline_thickness; }, "Thickness of the underline bar.") .def_property_readonly( "fname", &PyFT2Font_fname, "The original filename for this object.") .def_buffer([](PyFT2Font &self) -> py::buffer_info { - return self.x->get_image().request(); + return self.get_image().request(); }); m.attr("__freetype_version__") = version_string; From ad32f0dd9aa8d7c3974ff6d25e2dbabb4f986cc6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 21 Aug 2025 17:18:13 -0400 Subject: [PATCH 020/108] DOC: Fix missing references on text-overhaul branch I thought this was because this branch was missing d2d969ef9d01297728c15c0fdfa957852201834b, but it was actually a change introduced in #30324 as a separate (but similar-looking) issue. --- doc/missing-references.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/missing-references.json b/doc/missing-references.json index 1a3693c990e5..e89439b90483 100644 --- a/doc/missing-references.json +++ b/doc/missing-references.json @@ -126,10 +126,12 @@ "doc/docstring of matplotlib.ft2font.PyCapsule.set_text:1" ], "numpy.typing.NDArray": [ + "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.get_image:1", "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.set_text:1" ], "numpy.uint8": [ - ":1" + ":1", + "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.get_image:1" ] }, "py:obj": { From 584b1fd01dfe4c60e465220e2aef14966f5e9911 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 26 Aug 2025 04:18:02 -0400 Subject: [PATCH 021/108] Remove version from FreeType wrap file Latest Meson (1.9.0 at this time) is now erroring because of the Harfbuzz contains a differently-named wrap file that provides `freetype2`. This may be a bug upstream, but we really don't need the version in the filename. --- extern/meson.build | 8 +------- lib/matplotlib/__init__.py | 2 +- subprojects/{freetype-2.13.3.wrap => freetype2.wrap} | 4 ++++ 3 files changed, 6 insertions(+), 8 deletions(-) rename subprojects/{freetype-2.13.3.wrap => freetype2.wrap} (69%) diff --git a/extern/meson.build b/extern/meson.build index 7f7c2511c3d5..f4e14530369d 100644 --- a/extern/meson.build +++ b/extern/meson.build @@ -9,14 +9,8 @@ subdir('agg24-svn') if get_option('system-freetype') freetype_dep = dependency('freetype2', version: '>=9.11.3') else - # This is the version of FreeType to use when building a local version. It - # must match the value in `lib/matplotlib.__init__.py`. Also update the docs - # in `docs/devel/dependencies.rst`. Bump the cache key in - # `.circleci/config.yml` when changing requirements. - LOCAL_FREETYPE_VERSION = '2.13.3' - freetype_proj = subproject( - f'freetype-@LOCAL_FREETYPE_VERSION@', + 'freetype2', default_options: [ 'default_library=static', 'brotli=disabled', diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 651936dc19c2..dc6b703e942a 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1333,7 +1333,7 @@ def _val_or_rc(val, *rc_names): def _init_tests(): # The version of FreeType to install locally for running the tests. This must match - # the value in `meson.build`. + # the value in `subprojects/freetype2.wrap`. LOCAL_FREETYPE_VERSION = '2.13.3' from matplotlib import ft2font diff --git a/subprojects/freetype-2.13.3.wrap b/subprojects/freetype2.wrap similarity index 69% rename from subprojects/freetype-2.13.3.wrap rename to subprojects/freetype2.wrap index 68f688a35861..e1d0fb112ca9 100644 --- a/subprojects/freetype-2.13.3.wrap +++ b/subprojects/freetype2.wrap @@ -1,3 +1,7 @@ +# This is the version of FreeType to use when building a local version. It +# must match the value in `lib/matplotlib.__init__.py`. Also update the docs +# in `docs/devel/dependencies.rst`. Bump the cache key in +# `.circleci/config.yml` when changing requirements. [wrap-file] directory = freetype-2.13.3 source_url = https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz From 0635d3ab2445d2e8ea1a16a3882835b5b950e7a6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 18 Dec 2024 22:04:51 -0500 Subject: [PATCH 022/108] Add libraqm and its dependencies to build Also add some missing license entries in more places. --- LICENSE/LICENSE_FREETYPE | 46 ++++ LICENSE/LICENSE_HARFBUZZ | 42 ++++ LICENSE/LICENSE_LIBRAQM | 22 ++ LICENSE/LICENSE_SHEENBIDI | 202 ++++++++++++++++++ doc/project/license.rst | 32 +++ extern/meson.build | 29 +++ lib/matplotlib/ft2font.pyi | 1 + meson.build | 8 +- meson.options | 2 + src/ft2font.h | 2 + src/ft2font_wrapper.cpp | 1 + src/meson.build | 2 +- subprojects/harfbuzz.wrap | 13 ++ subprojects/libraqm-0.10.3.wrap | 8 + .../harfbuzz-11.2.0-bundle-freetype.patch | 36 ++++ .../libraqm-0.10.2-bundle-freetype.patch | 11 + subprojects/packagefiles/libraqm-203.patch | 27 +++ subprojects/sheenbidi.wrap | 5 + 18 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 LICENSE/LICENSE_FREETYPE create mode 100644 LICENSE/LICENSE_HARFBUZZ create mode 100644 LICENSE/LICENSE_LIBRAQM create mode 100755 LICENSE/LICENSE_SHEENBIDI create mode 100644 subprojects/harfbuzz.wrap create mode 100644 subprojects/libraqm-0.10.3.wrap create mode 100644 subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch create mode 100644 subprojects/packagefiles/libraqm-0.10.2-bundle-freetype.patch create mode 100644 subprojects/packagefiles/libraqm-203.patch create mode 100644 subprojects/sheenbidi.wrap diff --git a/LICENSE/LICENSE_FREETYPE b/LICENSE/LICENSE_FREETYPE new file mode 100644 index 000000000000..8b9ce9e2e6e3 --- /dev/null +++ b/LICENSE/LICENSE_FREETYPE @@ -0,0 +1,46 @@ +FREETYPE LICENSES +----------------- + +The FreeType 2 font engine is copyrighted work and cannot be used +legally without a software license. In order to make this project +usable to a vast majority of developers, we distribute it under two +mutually exclusive open-source licenses. + +This means that *you* must choose *one* of the two licenses described +below, then obey all its terms and conditions when using FreeType 2 in +any of your projects or products. + + - The FreeType License, found in the file `docs/FTL.TXT`, which is + similar to the original BSD license *with* an advertising clause + that forces you to explicitly cite the FreeType project in your + product's documentation. All details are in the license file. + This license is suited to products which don't use the GNU General + Public License. + + Note that this license is compatible to the GNU General Public + License version 3, but not version 2. + + - The GNU General Public License version 2, found in + `docs/GPLv2.TXT` (any later version can be used also), for + programs which already use the GPL. Note that the FTL is + incompatible with GPLv2 due to its advertisement clause. + +The contributed BDF and PCF drivers come with a license similar to +that of the X Window System. It is compatible to the above two +licenses (see files `src/bdf/README` and `src/pcf/README`). The same +holds for the source code files `src/base/fthash.c` and +`include/freetype/internal/fthash.h`; they were part of the BDF driver +in earlier FreeType versions. + +The gzip module uses the zlib license (see `src/gzip/zlib.h`) which +too is compatible to the above two licenses. + +The files `src/autofit/ft-hb.c` and `src/autofit/ft-hb.h` contain code +taken almost verbatim from the HarfBuzz file `hb-ft.cc`, which uses +the 'Old MIT' license, compatible to the above two licenses. + +The MD5 checksum support (only used for debugging in development +builds) is in the public domain. + + +--- end of LICENSE.TXT --- diff --git a/LICENSE/LICENSE_HARFBUZZ b/LICENSE/LICENSE_HARFBUZZ new file mode 100644 index 000000000000..1dd917e9f2e7 --- /dev/null +++ b/LICENSE/LICENSE_HARFBUZZ @@ -0,0 +1,42 @@ +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/LICENSE/LICENSE_LIBRAQM b/LICENSE/LICENSE_LIBRAQM new file mode 100644 index 000000000000..97e2489b7798 --- /dev/null +++ b/LICENSE/LICENSE_LIBRAQM @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright © 2015 Information Technology Authority (ITA) +Copyright © 2016-2023 Khaled Hosny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE/LICENSE_SHEENBIDI b/LICENSE/LICENSE_SHEENBIDI new file mode 100755 index 000000000000..d64569567334 --- /dev/null +++ b/LICENSE/LICENSE_SHEENBIDI @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/doc/project/license.rst b/doc/project/license.rst index eba9ef23cf62..2cad3f25b95d 100644 --- a/doc/project/license.rst +++ b/doc/project/license.rst @@ -71,6 +71,38 @@ Bundled software .. literalinclude:: ../../LICENSE/LICENSE_QT4_EDITOR :language: none +Rendering software +------------------ + +.. dropdown:: Agg + :class-container: sdd + + .. literalinclude:: ../../extern/agg24-svn/src/copying + :language: none + +.. dropdown:: FreeType + :class-container: sdd + + .. literalinclude:: ../../LICENSE/LICENSE_FREETYPE + :language: none + +.. dropdown:: Harfbuzz + :class-container: sdd + + .. literalinclude:: ../../LICENSE/LICENSE_HARFBUZZ + :language: none + +.. dropdown:: libraqm + :class-container: sdd + + .. literalinclude:: ../../LICENSE/LICENSE_LIBRAQM + :language: none + +.. dropdown:: SheenBidi + :class-container: sdd + + .. literalinclude:: ../../LICENSE/LICENSE_SHEENBIDI + :language: none .. _licenses-cmaps-styles: diff --git a/extern/meson.build b/extern/meson.build index f4e14530369d..2723baa47505 100644 --- a/extern/meson.build +++ b/extern/meson.build @@ -24,6 +24,35 @@ else freetype_dep = freetype_proj.get_variable('freetype_dep') endif +if get_option('system-libraqm') + libraqm_dep = dependency('raqm', version: '>=0.10.3') +else + subproject('harfbuzz', + default_options: [ + 'default_library=static', + 'cairo=disabled', + 'coretext=disabled', + 'directwrite=disabled', + 'fontations=disabled', + 'freetype=enabled', + 'gdi=disabled', + 'glib=disabled', + 'gobject=disabled', + 'harfruzz=disabled', + 'icu=disabled', + 'tests=disabled', + ] + ) + subproject('sheenbidi', default_options: ['default_library=static']) + libraqm_proj = subproject('libraqm-0.10.3', + default_options: [ + 'default_library=static', + 'sheenbidi=true', + ] + ) + libraqm_dep = libraqm_proj.get_variable('libraqm_dep') +endif + if get_option('system-qhull') qhull_dep = dependency('qhull_r', version: '>=8.0.2', required: false) if not qhull_dep.found() diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index a4ddc84358c1..55c076bb68b6 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -8,6 +8,7 @@ from numpy.typing import NDArray __freetype_build_type__: str __freetype_version__: str +__libraqm_version__: str # We can't change the type hints for standard library chr/ord, so character codes are a # simple type alias. diff --git a/meson.build b/meson.build index 54249473fe8e..239ae7827b73 100644 --- a/meson.build +++ b/meson.build @@ -7,18 +7,24 @@ project( '-m', 'setuptools_scm', check: true).stdout().strip(), # qt_editor backend is MIT # ResizeObserver at end of lib/matplotlib/backends/web_backend/js/mpl.js is CC0 - # Carlogo, STIX and Computer Modern is OFL + # Carlogo, STIX, Computer Modern, and Last Resort are OFL # DejaVu is Bitstream Vera and Public Domain license: 'PSF-2.0 AND MIT AND CC0-1.0 AND OFL-1.1 AND Bitstream-Vera AND Public-Domain', license_files: [ 'LICENSE/LICENSE', + 'extern/agg24-svn/src/copying', 'LICENSE/LICENSE_AMSFONTS', 'LICENSE/LICENSE_BAKOMA', 'LICENSE/LICENSE_CARLOGO', 'LICENSE/LICENSE_COLORBREWER', 'LICENSE/LICENSE_COURIERTEN', + 'LICENSE/LICENSE_FREETYPE', + 'LICENSE/LICENSE_HARFBUZZ', 'LICENSE/LICENSE_JSXTOOLS_RESIZE_OBSERVER', + 'LICENSE/LICENSE_LAST_RESORT_FONT', + 'LICENSE/LICENSE_LIBRAQM', 'LICENSE/LICENSE_QT4_EDITOR', + 'LICENSE/LICENSE_SHEENBIDI', 'LICENSE/LICENSE_SOLARIZED', 'LICENSE/LICENSE_STIX', 'LICENSE/LICENSE_YORICK', diff --git a/meson.options b/meson.options index d21cbedb9bb9..7e03ff405f85 100644 --- a/meson.options +++ b/meson.options @@ -7,6 +7,8 @@ # FreeType on AIX. option('system-freetype', type: 'boolean', value: false, description: 'Build against system version of FreeType') +option('system-libraqm', type: 'boolean', value: false, + description: 'Build against system version of libraqm') option('system-qhull', type: 'boolean', value: false, description: 'Build against system version of Qhull') diff --git a/src/ft2font.h b/src/ft2font.h index 80bc490f4bad..ffaf511ab9ca 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -26,6 +26,8 @@ extern "C" { #include FT_TRUETYPE_TABLES_H } +#include + namespace py = pybind11; // By definition, FT_FIXED as 2 16bit values stored in a single long. diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 99023836b001..65fcb4b7e013 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1690,6 +1690,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) m.attr("__freetype_version__") = version_string; m.attr("__freetype_build_type__") = FREETYPE_BUILD_TYPE; + m.attr("__libraqm_version__") = raqm_version_string(); auto py_int = py::module_::import("builtins").attr("int"); m.attr("CharacterCodeType") = py_int; m.attr("GlyphIndexType") = py_int; diff --git a/src/meson.build b/src/meson.build index d479a8b84aa2..8b52bf739c03 100644 --- a/src/meson.build +++ b/src/meson.build @@ -53,7 +53,7 @@ extension_data = { 'ft2font_wrapper.cpp', ), 'dependencies': [ - freetype_dep, pybind11_dep, agg_dep.partial_dependency(includes: true), + freetype_dep, libraqm_dep, pybind11_dep, agg_dep.partial_dependency(includes: true), ], 'cpp_args': [ '-DFREETYPE_BUILD_TYPE="@0@"'.format( diff --git a/subprojects/harfbuzz.wrap b/subprojects/harfbuzz.wrap new file mode 100644 index 000000000000..cc5e227f0ca2 --- /dev/null +++ b/subprojects/harfbuzz.wrap @@ -0,0 +1,13 @@ +[wrap-file] +directory = harfbuzz-11.2.1 +source_url = https://github.com/harfbuzz/harfbuzz/releases/download/11.2.1/harfbuzz-11.2.1.tar.xz +source_filename = harfbuzz-11.2.1.tar.xz +source_hash = 093714c8548a285094685f0bdc999e202d666b59eeb3df2ff921ab68b8336a49 +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/harfbuzz_11.2.1-1/harfbuzz-11.2.1.tar.xz +wrapdb_version = 11.2.1-1 + +# This patch allows using our bundled FreeType. +diff_files = harfbuzz-11.2.0-bundle-freetype.patch + +[provide] +dependency_names = harfbuzz, harfbuzz-cairo, harfbuzz-gobject, harfbuzz-icu, harfbuzz-subset diff --git a/subprojects/libraqm-0.10.3.wrap b/subprojects/libraqm-0.10.3.wrap new file mode 100644 index 000000000000..87061a231cba --- /dev/null +++ b/subprojects/libraqm-0.10.3.wrap @@ -0,0 +1,8 @@ +[wrap-file] +source_url = https://github.com/HOST-Oman/libraqm/archive/v0.10.3/libraqm-0.10.3.tar.gz +source_filename = libraqm-0.10.3.tar.gz +source_hash = fe1fe28b32f97ef97b325ca5d2defb0704da1ef048372ec20e85e1f587e20965 + +# First patch allows using our bundled FreeType. +# Second patch is for use as a subproject https://github.com/HOST-Oman/libraqm/pull/203 +diff_files = libraqm-0.10.2-bundle-freetype.patch, libraqm-203.patch diff --git a/subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch b/subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch new file mode 100644 index 000000000000..fa7be0b54afd --- /dev/null +++ b/subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch @@ -0,0 +1,36 @@ +diff -uPNr harfbuzz-11.2.0.orig/meson.build harfbuzz-11.2.0/meson.build +--- harfbuzz-11.2.0.orig/meson.build 2025-04-28 08:56:32.000000000 -0400 ++++ harfbuzz-11.2.0/meson.build 2025-05-03 03:25:39.602646412 -0400 +@@ -115,31 +115,7 @@ + # Sadly, FreeType's versioning schemes are different between pkg-config and CMake + + # Try pkg-config name +- freetype_dep = dependency('freetype2', +- version: freetype_min_version, +- method: 'pkg-config', +- required: false, +- allow_fallback: false) +- if not freetype_dep.found() +- # Try cmake name +- freetype_dep = dependency('Freetype', +- version: freetype_min_version_actual, +- method: 'cmake', +- required: false, +- allow_fallback: false) +- # Subproject fallback +- if not freetype_dep.found() +- freetype_proj = subproject('freetype2', +- version: freetype_min_version_actual, +- required: get_option('freetype'), +- default_options: ['harfbuzz=disabled']) +- if freetype_proj.found() +- freetype_dep = freetype_proj.get_variable('freetype_dep') +- else +- freetype_dep = dependency('', required: false) +- endif +- endif +- endif ++ freetype_dep = dependency('freetype2', version: freetype_min_version) + endif + + glib_dep = dependency('glib-2.0', version: glib_min_version, required: get_option('glib')) diff --git a/subprojects/packagefiles/libraqm-0.10.2-bundle-freetype.patch b/subprojects/packagefiles/libraqm-0.10.2-bundle-freetype.patch new file mode 100644 index 000000000000..5e9a6b7f9ed5 --- /dev/null +++ b/subprojects/packagefiles/libraqm-0.10.2-bundle-freetype.patch @@ -0,0 +1,11 @@ +--- a/meson.build 2025-03-26 03:32:12.444735795 -0400 ++++ b/meson.build 2025-03-26 03:32:16.117435140 -0400 +@@ -45,8 +45,7 @@ + if not freetype.found() + freetype = dependency( + 'freetype2', + version: '>= @0@'.format(freetype_version[0]), +- method: 'pkg-config', + fallback: ['freetype2', 'freetype_dep'], + default_options: [ + 'png=disabled', diff --git a/subprojects/packagefiles/libraqm-203.patch b/subprojects/packagefiles/libraqm-203.patch new file mode 100644 index 000000000000..6628fec1d111 --- /dev/null +++ b/subprojects/packagefiles/libraqm-203.patch @@ -0,0 +1,27 @@ +From 8cedfc989998bb2cf23c2c1b40802effad72b0ed Mon Sep 17 00:00:00 2001 +From: Elliott Sales de Andrade +Date: Thu, 7 Aug 2025 18:07:15 -0400 +Subject: [PATCH] Add dependency override for use as a subproject + +--- + src/meson.build | 7 +++++++ + 1 file changed, 7 insertions(+) + +diff --git a/src/meson.build b/src/meson.build +index 0a32f832..ca7c13d1 100644 +--- a/src/meson.build ++++ b/src/meson.build +@@ -42,6 +42,13 @@ libraqm = library( + install: true, + ) + ++libraqm_dep = declare_dependency( ++ include_directories: include_directories('.'), ++ link_with: libraqm, ++) ++ ++meson.override_dependency(meson.project_name(), libraqm_dep) ++ + libraqm_test = static_library( + 'raqm-test', + 'raqm.c', diff --git a/subprojects/sheenbidi.wrap b/subprojects/sheenbidi.wrap new file mode 100644 index 000000000000..c58277d47499 --- /dev/null +++ b/subprojects/sheenbidi.wrap @@ -0,0 +1,5 @@ +[wrap-file] +directory = SheenBidi-2.9.0 +source_url = https://github.com/Tehreer/SheenBidi/archive/refs/tags/v2.9.0/sheenbidi-2.9.0.tar.gz +source_filename = sheenbidi-2.9.0.tar.gz +source_hash = e90ae142c6fc8b94366f3526f84b349a2c10137f87093db402fe51f6eace6d13 From b0ded3aadda70932fa2130df61ed9629cd7d54e4 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 26 Feb 2025 09:27:53 -0500 Subject: [PATCH 023/108] Implement text shaping with libraqm --- lib/matplotlib/_text_helpers.py | 17 ----- lib/matplotlib/tests/test_ft2font.py | 6 +- lib/matplotlib/tests/test_text.py | 24 +++---- src/ft2font.cpp | 97 ++++++++++++++++++---------- 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index 1a9b4e4c989c..a874c8f4bf81 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -25,23 +25,6 @@ def warn_on_missing_glyph(codepoint, fontnames): f"({chr(codepoint).encode('ascii', 'namereplace').decode('ascii')}) " f"missing from font(s) {fontnames}.") - block = ("Hebrew" if 0x0590 <= codepoint <= 0x05ff else - "Arabic" if 0x0600 <= codepoint <= 0x06ff else - "Devanagari" if 0x0900 <= codepoint <= 0x097f else - "Bengali" if 0x0980 <= codepoint <= 0x09ff else - "Gurmukhi" if 0x0a00 <= codepoint <= 0x0a7f else - "Gujarati" if 0x0a80 <= codepoint <= 0x0aff else - "Oriya" if 0x0b00 <= codepoint <= 0x0b7f else - "Tamil" if 0x0b80 <= codepoint <= 0x0bff else - "Telugu" if 0x0c00 <= codepoint <= 0x0c7f else - "Kannada" if 0x0c80 <= codepoint <= 0x0cff else - "Malayalam" if 0x0d00 <= codepoint <= 0x0d7f else - "Sinhala" if 0x0d80 <= codepoint <= 0x0dff else - None) - if block: - _api.warn_external( - f"Matplotlib currently does not support {block} natively.") - def layout(string, font, *, kern_mode=Kerning.DEFAULT): """ diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 6b405287e5d7..70e611e17bcc 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -775,9 +775,9 @@ def test_ft2font_set_text(): xys = font.set_text('AADAT.XC-J') np.testing.assert_array_equal( xys, - [(0, 0), (512, 0), (1024, 0), (1600, 0), (2112, 0), (2496, 0), (2688, 0), - (3200, 0), (3712, 0), (4032, 0)]) - assert font.get_width_height() == (4288, 768) + [(0, 0), (533, 0), (1045, 0), (1608, 0), (2060, 0), (2417, 0), (2609, 0), + (3065, 0), (3577, 0), (3940, 0)]) + assert font.get_width_height() == (4196, 768) assert font.get_num_glyphs() == 10 assert font.get_descent() == 192 assert font.get_bitmap_offset() == (6, 0) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 9d943fa9df13..bdf7ce72a2df 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -113,6 +113,18 @@ def find_matplotlib_font(**kw): ax.set_yticks([]) +@image_comparison(['complex.png']) +def test_complex_shaping(): + # Raqm is Arabic for writing; note that because Arabic is RTL, the characters here + # may seem to be in a different order than expected, but libraqm will order them + # correctly for us. + text = ( + 'Arabic: \N{Arabic Letter REH}\N{Arabic FATHA}\N{Arabic Letter QAF}' + '\N{Arabic SUKUN}\N{Arabic Letter MEEM}') + fig = plt.figure(figsize=(3, 1)) + fig.text(0.5, 0.5, text, size=32, ha='center', va='center') + + @image_comparison(['multiline']) def test_multiline(): plt.figure() @@ -826,18 +838,6 @@ def test_pdf_kerning(): plt.figtext(0.1, 0.5, "ATATATATATATATATATA", size=30) -def test_unsupported_script(recwarn): - fig = plt.figure() - t = fig.text(.5, .5, "\N{BENGALI DIGIT ZERO}") - fig.canvas.draw() - assert all(isinstance(warn.message, UserWarning) for warn in recwarn) - assert ( - [warn.message.args for warn in recwarn] == - [(r"Glyph 2534 (\N{BENGALI DIGIT ZERO}) missing from font(s) " - + f"{t.get_fontname()}.",), - (r"Matplotlib currently does not support Bengali natively.",)]) - - # See gh-26152 for more information on this xfail @pytest.mark.xfail(pyparsing_version.release == (3, 1, 0), reason="Error messages are incorrect with pyparsing 3.1.0") diff --git a/src/ft2font.cpp b/src/ft2font.cpp index ebb7d5204d80..22199a0fd19b 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -227,6 +227,11 @@ void FT2Font::open(FT_Open_Args &open_args) if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } + + // This allows us to get back to our data if we need it, though it makes a pointer + // loop, so don't set a free-function for it. + face->generic.data = this; + face->generic.finalizer = nullptr; } void FT2Font::close() @@ -333,48 +338,69 @@ void FT2Font::set_text( bbox.xMin = bbox.yMin = 32000; bbox.xMax = bbox.yMax = -32000; - FT_UInt previous = 0; - FT2Font *previous_ft_object = nullptr; + auto rq = raqm_create(); + if (!rq) { + throw std::runtime_error("failed to compute text layout"); + } + [[maybe_unused]] auto const& rq_cleanup = + std::unique_ptr, decltype(&raqm_destroy)>( + rq, raqm_destroy); + + if (!raqm_set_text(rq, reinterpret_cast(text.data()), + text.size())) + { + throw std::runtime_error("failed to set text for layout"); + } + if (!raqm_set_freetype_face(rq, face)) { + throw std::runtime_error("failed to set text face for layout"); + } + if (!raqm_set_freetype_load_flags(rq, flags)) { + throw std::runtime_error("failed to set text flags for layout"); + } + if (!raqm_layout(rq)) { + throw std::runtime_error("failed to layout text"); + } - for (auto codepoint : text) { - FT_UInt glyph_index = 0; - FT_BBox glyph_bbox; - FT_Pos last_advance; + std::set glyph_seen_fonts; + glyph_seen_fonts.insert(face->family_name); - FT_Error charcode_error, glyph_error; - std::set glyph_seen_fonts; - FT2Font *ft_object_with_glyph = this; - bool was_found = load_char_with_fallback(ft_object_with_glyph, glyph_index, glyphs, - char_to_font, codepoint, flags, - charcode_error, glyph_error, glyph_seen_fonts, false); - if (!was_found) { - ft_glyph_warn((FT_ULong)codepoint, glyph_seen_fonts); - // render missing glyph tofu - // come back to top-most font - ft_object_with_glyph = this; - char_to_font[codepoint] = ft_object_with_glyph; - ft_object_with_glyph->load_glyph(glyph_index, flags); - } else if (ft_object_with_glyph->warn_if_used) { - ft_glyph_warn((FT_ULong)codepoint, glyph_seen_fonts); - } + size_t num_glyphs = 0; + auto const& rq_glyphs = raqm_get_glyphs(rq, &num_glyphs); - // retrieve kerning distance and move pen position - if ((ft_object_with_glyph == previous_ft_object) && // if both fonts are the same - ft_object_with_glyph->has_kerning() && // if the font knows how to kern - previous && glyph_index // and we really have 2 glyphs - ) { - pen.x += ft_object_with_glyph->get_kerning(previous, glyph_index, FT_KERNING_DEFAULT); + for (size_t i = 0; i < num_glyphs; i++) { + auto const& rglyph = rq_glyphs[i]; + + // Warn for missing glyphs. + if (rglyph.index == 0) { + ft_glyph_warn(text[rglyph.cluster], glyph_seen_fonts); + continue; + } + FT2Font *wrapped_font = static_cast(rglyph.ftface->generic.data); + if (wrapped_font->warn_if_used) { + ft_glyph_warn(text[rglyph.cluster], glyph_seen_fonts); } // extract glyph image and store it in our table - FT_Glyph &thisGlyph = glyphs[glyphs.size() - 1]; + FT_Error error; + error = FT_Load_Glyph(rglyph.ftface, rglyph.index, flags); + if (error) { + throw std::runtime_error("failed to load glyph"); + } + FT_Glyph thisGlyph; + error = FT_Get_Glyph(rglyph.ftface->glyph, &thisGlyph); + if (error) { + throw std::runtime_error("failed to get glyph"); + } + + pen.x += rglyph.x_offset; + pen.y += rglyph.y_offset; - last_advance = ft_object_with_glyph->get_face()->glyph->advance.x; FT_Glyph_Transform(thisGlyph, nullptr, &pen); FT_Glyph_Transform(thisGlyph, &matrix, nullptr); xys.push_back(pen.x); xys.push_back(pen.y); + FT_BBox glyph_bbox; FT_Glyph_Get_CBox(thisGlyph, FT_GLYPH_BBOX_SUBPIXELS, &glyph_bbox); bbox.xMin = std::min(bbox.xMin, glyph_bbox.xMin); @@ -382,11 +408,14 @@ void FT2Font::set_text( bbox.yMin = std::min(bbox.yMin, glyph_bbox.yMin); bbox.yMax = std::max(bbox.yMax, glyph_bbox.yMax); - pen.x += last_advance; - - previous = glyph_index; - previous_ft_object = ft_object_with_glyph; + if ((flags & FT_LOAD_NO_HINTING) != 0) { + pen.x += rglyph.x_advance - rglyph.x_offset; + } else { + pen.x += hinting_factor * rglyph.x_advance - rglyph.x_offset; + } + pen.y += rglyph.y_advance - rglyph.y_offset; + glyphs.push_back(thisGlyph); } FT_Vector_Transform(&pen, &matrix); From 98135232085af3c5585e4d7a077318f3d511306d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 3 Apr 2025 04:09:06 -0400 Subject: [PATCH 024/108] Implement font fallback for libraqm --- lib/matplotlib/tests/test_text.py | 12 +++++- src/ft2font.cpp | 63 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index bdf7ce72a2df..8dba63eeef32 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -121,8 +121,16 @@ def test_complex_shaping(): text = ( 'Arabic: \N{Arabic Letter REH}\N{Arabic FATHA}\N{Arabic Letter QAF}' '\N{Arabic SUKUN}\N{Arabic Letter MEEM}') - fig = plt.figure(figsize=(3, 1)) - fig.text(0.5, 0.5, text, size=32, ha='center', va='center') + math_signs = '\N{N-ary Product}\N{N-ary Coproduct}\N{N-ary summation}\N{Integral}' + text = math_signs + text + math_signs + fig = plt.figure(figsize=(6, 2)) + fig.text(0.5, 0.75, text, size=32, ha='center', va='center') + # Also check fallback behaviour: + # - English should use cmr10 + # - Math signs should use DejaVu Sans Display (and thus be larger than the rest) + # - Arabic should use DejaVu Sans + fig.text(0.5, 0.25, text, size=32, ha='center', va='center', + family=['cmr10', 'DejaVu Sans Display', 'DejaVu Sans']) @image_comparison(['multiline']) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 22199a0fd19b..890fc61974b0 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -361,9 +362,71 @@ void FT2Font::set_text( throw std::runtime_error("failed to layout text"); } + std::vector> face_substitutions; std::set glyph_seen_fonts; glyph_seen_fonts.insert(face->family_name); + // Attempt to use fallback fonts if necessary. + for (auto const& fallback : fallbacks) { + size_t num_glyphs = 0; + auto const& rq_glyphs = raqm_get_glyphs(rq, &num_glyphs); + bool new_fallback_used = false; + + // Sort clusters (n.b. std::map is ordered), as RTL text will be returned in + // display, not source, order. + std::map cluster_missing; + for (size_t i = 0; i < num_glyphs; i++) { + auto const& rglyph = rq_glyphs[i]; + + // Sometimes multiple glyphs are necessary for a single cluster; if any are + // not found, we want to "poison" the whole set and keep them missing. + cluster_missing[rglyph.cluster] |= (rglyph.index == 0); + } + + for (auto it = cluster_missing.cbegin(); it != cluster_missing.cend(); ) { + auto [cluster, missing] = *it; + ++it; // Early change so we can access the next cluster below. + if (missing) { + auto next = (it != cluster_missing.cend()) ? it->first : text.size(); + for (auto i = cluster; i < next; i++) { + face_substitutions.emplace_back(i, fallback->face); + } + new_fallback_used = true; + } + } + + if (!new_fallback_used) { + // If we never used a fallback, then we're good to go with the existing + // layout we have already made. + break; + } + + // If a fallback was used, then re-attempt the layout with the new fonts. + if (!fallback->warn_if_used) { + glyph_seen_fonts.insert(fallback->face->family_name); + } + + raqm_clear_contents(rq); + if (!raqm_set_text(rq, + reinterpret_cast(text.data()), + text.size())) + { + throw std::runtime_error("failed to set text for layout"); + } + if (!raqm_set_freetype_face(rq, face)) { + throw std::runtime_error("failed to set text face for layout"); + } + for (auto [cluster, fallback] : face_substitutions) { + raqm_set_freetype_face_range(rq, fallback, cluster, 1); + } + if (!raqm_set_freetype_load_flags(rq, flags)) { + throw std::runtime_error("failed to set text flags for layout"); + } + if (!raqm_layout(rq)) { + throw std::runtime_error("failed to layout text"); + } + } + size_t num_glyphs = 0; auto const& rq_glyphs = raqm_get_glyphs(rq, &num_glyphs); From b36b97f3da663f65584203c8c1051e653e882338 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 8 Aug 2025 03:05:02 -0400 Subject: [PATCH 025/108] DOC: Add What's New entry for complex text layout --- doc/release/next_whats_new/libraqm.rst | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 doc/release/next_whats_new/libraqm.rst diff --git a/doc/release/next_whats_new/libraqm.rst b/doc/release/next_whats_new/libraqm.rst new file mode 100644 index 000000000000..8312f2f9432c --- /dev/null +++ b/doc/release/next_whats_new/libraqm.rst @@ -0,0 +1,42 @@ +Complex text layout with libraqm +-------------------------------- + +Text support has been extended to include complex text layout. This support includes: + +1. Languages that require advanced layout, such as Arabic or Hebrew. +2. Text that mixes left-to-right and right-to-left languages. + + .. plot:: + :show-source-link: False + + text = 'Here is some رَقْم in اَلْعَرَبِيَّةُ' + fig = plt.figure(figsize=(6, 1)) + fig.text(0.5, 0.5, text, size=32, ha='center', va='center') + +3. Ligatures that combine several adjacent characters for improved legibility. + + .. plot:: + :show-source-link: False + + text = 'f\N{Hair Space}f\N{Hair Space}i \N{Rightwards Arrow} ffi' + fig = plt.figure(figsize=(3, 1)) + fig.text(0.5, 0.5, text, size=32, ha='center', va='center') + +4. Combining multiple or double-width diacritics. + + .. plot:: + :show-source-link: False + + text = ( + 'a\N{Combining Circumflex Accent}\N{Combining Double Tilde}' + 'c\N{Combining Diaeresis}') + text = ' + '.join( + c if c in 'ac' else f'\N{Dotted Circle}{c}' + for c in text) + f' \N{Rightwards Arrow} {text}' + fig = plt.figure(figsize=(6, 1)) + fig.text(0.5, 0.5, text, size=32, ha='center', va='center', + # Builtin DejaVu Sans doesn't support multiple diacritics. + family=['Noto Sans', 'DejaVu Sans']) + +Note, all advanced features require corresponding font support, and may require +additional fonts over the builtin DejaVu Sans. From 04c8eefb7e42029e0194d84f3cb30fc5e31df061 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 26 Aug 2025 00:10:47 -0400 Subject: [PATCH 026/108] ci: Ignore coverage data from subprojects and generated files We only care about our own source files, so anything in `subprojects` or the `build` directory can be ignored, thus fixing the bugginess with Harfbuzz headers. --- .github/workflows/tests.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 343d497a4696..b2ba198589ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -397,13 +397,14 @@ jobs: if [[ "${{ runner.os }}" != 'macOS' ]]; then LCOV_IGNORE_ERRORS=',' # do not ignore any lcov errors by default if [[ "${{ matrix.os }}" = ubuntu-24.04 ]]; then - # filter mismatch and unused-entity errors detected by lcov 2.x - LCOV_IGNORE_ERRORS='mismatch,unused' + # filter mismatch errors detected by lcov 2.x + LCOV_IGNORE_ERRORS='mismatch' fi lcov --rc lcov_branch_coverage=1 --ignore-errors $LCOV_IGNORE_ERRORS \ - --capture --directory . --output-file coverage.info + --capture --directory . --exclude $PWD/subprojects --exclude $PWD/build \ + --output-file coverage.info lcov --rc lcov_branch_coverage=1 --ignore-errors $LCOV_IGNORE_ERRORS \ - --output-file coverage.info --extract coverage.info $PWD/src/'*' $PWD/lib/'*' + --output-file coverage.info --extract coverage.info $PWD/src/'*' lcov --rc lcov_branch_coverage=1 --ignore-errors $LCOV_IGNORE_ERRORS \ --list coverage.info find . -name '*.gc*' -delete From 258de53f55f0306400d7271a4c302cd37ab530de Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 30 Aug 2025 06:38:17 -0400 Subject: [PATCH 027/108] pdf: Simplify Type 3 font character encoding For a Type 3 font, its encoding is entirely defined by its `Encoding` dictionary (which we create), so there's no reason to use a specific encoding like `cp1252`. Instead, switch to Latin-1, which corresponds exactly to the first 256 character codes in Unicode, and can be mapped directly with `ord`. --- lib/matplotlib/backends/backend_pdf.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index ff351e301176..6682deffb00e 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -1180,13 +1180,11 @@ def embedTTFType3(font, characters, descriptor): 'Widths': widthsObject } - from encodings import cp1252 - # Make the "Widths" array def get_char_width(charcode): - s = ord(cp1252.decoding_table[charcode]) width = font.load_char( - s, flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING).horiAdvance + charcode, + flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING).horiAdvance return cvt(width) with warnings.catch_warnings(): # Ignore 'Required glyph missing from current font' warning @@ -2331,9 +2329,13 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): self.draw_path(boxgc, path, mytrans, gc._rgb) def encode_string(self, s, fonttype): - if fonttype in (1, 3): - return s.encode('cp1252', 'replace') - return s.encode('utf-16be', 'replace') + match fonttype: + case 1: + return s.encode('cp1252', 'replace') + case 3: + return s.encode('latin-1', 'replace') + case _: + return s.encode('utf-16be', 'replace') def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited From f630b483e86d703afa37f5eaa25c88b8c753c639 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Jun 2025 15:49:39 -0400 Subject: [PATCH 028/108] Use glyph indices for font tracking in vector formats With libraqm, string layout produces glyph indices, not character codes, and font features may even produce different glyphs for the same character code (e.g., by picking a different Stylistic Set). Thus we cannot rely on character codes as unique items within a font, and must move toward glyph indices everywhere. --- .../next_api_changes/behavior/30335-ES.rst | 15 +++ lib/matplotlib/_mathtext.py | 15 ++- lib/matplotlib/_text_helpers.py | 16 +-- lib/matplotlib/backends/_backend_pdf_ps.py | 45 ++++--- lib/matplotlib/backends/backend_cairo.py | 10 +- lib/matplotlib/backends/backend_pdf.py | 111 +++++++++--------- lib/matplotlib/backends/backend_ps.py | 56 ++++----- lib/matplotlib/backends/backend_svg.py | 31 +++-- lib/matplotlib/dviread.pyi | 4 +- lib/matplotlib/tests/test_backend_pdf.py | 6 +- lib/matplotlib/tests/test_backend_svg.py | 2 +- lib/matplotlib/textpath.py | 53 ++++----- 12 files changed, 191 insertions(+), 173 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/30335-ES.rst diff --git a/doc/api/next_api_changes/behavior/30335-ES.rst b/doc/api/next_api_changes/behavior/30335-ES.rst new file mode 100644 index 000000000000..26b059401e19 --- /dev/null +++ b/doc/api/next_api_changes/behavior/30335-ES.rst @@ -0,0 +1,15 @@ +``mathtext.VectorParse`` now includes glyph indices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For a *path*-outputting `.MathTextParser`, in the return value of +`~.MathTextParser.parse`, (a `.VectorParse`), the *glyphs* field is now a list +containing tuples of: + +- font: `.FT2Font` +- fontsize: `float` +- character code: `int` +- glyph index: `int` +- x: `float` +- y: `float` + +Specifically, the glyph index was added after the character code. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 234e5a238436..b85cdffd6d88 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -38,7 +38,7 @@ if T.TYPE_CHECKING: from collections.abc import Iterable - from .ft2font import CharacterCodeType, Glyph + from .ft2font import CharacterCodeType, Glyph, GlyphIndexType ParserElement.enable_packrat() @@ -87,7 +87,7 @@ class VectorParse(NamedTuple): width: float height: float depth: float - glyphs: list[tuple[FT2Font, float, CharacterCodeType, float, float]] + glyphs: list[tuple[FT2Font, float, CharacterCodeType, GlyphIndexType, float, float]] rects: list[tuple[float, float, float, float]] VectorParse.__module__ = "matplotlib.mathtext" @@ -132,7 +132,8 @@ def __init__(self, box: Box): def to_vector(self) -> VectorParse: w, h, d = map( np.ceil, [self.box.width, self.box.height, self.box.depth]) - gs = [(info.font, info.fontsize, info.num, ox, h - oy + info.offset) + gs = [(info.font, info.fontsize, info.num, info.glyph_index, + ox, h - oy + info.offset) for ox, oy, info in self.glyphs] rs = [(x1, h - y2, x2 - x1, y2 - y1) for x1, y1, x2, y2 in self.rects] @@ -215,6 +216,7 @@ class FontInfo(NamedTuple): postscript_name: str metrics: FontMetrics num: CharacterCodeType + glyph_index: GlyphIndexType glyph: Glyph offset: float @@ -375,7 +377,8 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, dpi: float) -> FontInfo: font, num, slanted = self._get_glyph(fontname, font_class, sym) font.set_size(fontsize, dpi) - glyph = font.load_char(num, flags=self.load_glyph_flags) + glyph_index = font.get_char_index(num) + glyph = font.load_glyph(glyph_index, flags=self.load_glyph_flags) xmin, ymin, xmax, ymax = (val / 64 for val in glyph.bbox) offset = self._get_offset(font, glyph, fontsize, dpi) @@ -398,6 +401,7 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, postscript_name=font.postscript_name, metrics=metrics, num=num, + glyph_index=glyph_index, glyph=glyph, offset=offset ) @@ -427,8 +431,7 @@ def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float, info1 = self._get_info(font1, fontclass1, sym1, fontsize1, dpi) info2 = self._get_info(font2, fontclass2, sym2, fontsize2, dpi) font = info1.font - return font.get_kerning(font.get_char_index(info1.num), - font.get_char_index(info2.num), + return font.get_kerning(info1.glyph_index, info2.glyph_index, Kerning.DEFAULT) / 64 return super().get_kern(font1, fontclass1, sym1, fontsize1, font2, fontclass2, sym2, fontsize2, dpi) diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index a874c8f4bf81..b9471c2c7e39 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -14,7 +14,7 @@ class LayoutItem: ft_object: FT2Font char: str - glyph_idx: GlyphIndexType + glyph_index: GlyphIndexType x: float prev_kern: float @@ -47,19 +47,19 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT): LayoutItem """ x = 0 - prev_glyph_idx = None + prev_glyph_index = None char_to_font = font._get_fontmap(string) base_font = font for char in string: # This has done the fallback logic font = char_to_font.get(char, base_font) - glyph_idx = font.get_char_index(ord(char)) + glyph_index = font.get_char_index(ord(char)) kern = ( - base_font.get_kerning(prev_glyph_idx, glyph_idx, kern_mode) / 64 - if prev_glyph_idx is not None else 0. + base_font.get_kerning(prev_glyph_index, glyph_index, kern_mode) / 64 + if prev_glyph_index is not None else 0. ) x += kern - glyph = font.load_glyph(glyph_idx, flags=LoadFlags.NO_HINTING) - yield LayoutItem(font, char, glyph_idx, x, kern) + glyph = font.load_glyph(glyph_index, flags=LoadFlags.NO_HINTING) + yield LayoutItem(font, char, glyph_index, x, kern) x += glyph.linearHoriAdvance / 65536 - prev_glyph_idx = glyph_idx + prev_glyph_index = glyph_index diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index a2a878d54156..75f0a05ae0dc 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -2,9 +2,12 @@ Common functionality between the PDF and PS backends. """ +from __future__ import annotations + from io import BytesIO import functools import logging +import typing from fontTools import subset @@ -14,24 +17,29 @@ from ..backend_bases import RendererBase +if typing.TYPE_CHECKING: + from .ft2font import FT2Font, GlyphIndexType + from fontTools.ttLib import TTFont + + @functools.lru_cache(50) def _cached_get_afm_from_fname(fname): with open(fname, "rb") as fh: return AFM(fh) -def get_glyphs_subset(fontfile, characters): +def get_glyphs_subset(fontfile: str, glyphs: set[GlyphIndexType]) -> TTFont: """ - Subset a TTF font + Subset a TTF font. - Reads the named fontfile and restricts the font to the characters. + Reads the named fontfile and restricts the font to the glyphs. Parameters ---------- fontfile : str Path to the font file - characters : str - Continuous set of characters to include in subset + glyphs : set[GlyphIndexType] + Set of glyph indices to include in subset. Returns ------- @@ -39,8 +47,8 @@ def get_glyphs_subset(fontfile, characters): An open font object representing the subset, which needs to be closed by the caller. """ - - options = subset.Options(glyph_names=True, recommended_glyphs=True) + options = subset.Options(glyph_names=True, recommended_glyphs=True, + retain_gids=True) # Prevent subsetting extra tables. options.drop_tables += [ @@ -71,7 +79,7 @@ def get_glyphs_subset(fontfile, characters): font = subset.load_font(fontfile, options) subsetter = subset.Subsetter(options=options) - subsetter.populate(text=characters) + subsetter.populate(gids=glyphs) subsetter.subset(font) return font @@ -97,24 +105,25 @@ def font_as_file(font): class CharacterTracker: """ - Helper for font subsetting by the pdf and ps backends. + Helper for font subsetting by the PDF and PS backends. - Maintains a mapping of font paths to the set of character codepoints that - are being used from that font. + Maintains a mapping of font paths to the set of glyphs that are being used from that + font. """ - def __init__(self): - self.used = {} + def __init__(self) -> None: + self.used: dict[str, set[GlyphIndexType]] = {} - def track(self, font, s): + def track(self, font: FT2Font, s: str) -> None: """Record that string *s* is being typeset using font *font*.""" char_to_font = font._get_fontmap(s) for _c, _f in char_to_font.items(): - self.used.setdefault(_f.fname, set()).add(ord(_c)) + glyph_index = _f.get_char_index(ord(_c)) + self.used.setdefault(_f.fname, set()).add(glyph_index) - def track_glyph(self, font, glyph): - """Record that codepoint *glyph* is being typeset using font *font*.""" - self.used.setdefault(font.fname, set()).add(glyph) + def track_glyph(self, font: FT2Font, glyph_index: GlyphIndexType) -> None: + """Record that glyph index *glyph_index* is being typeset using font *font*.""" + self.used.setdefault(font.fname, set()).add(glyph_index) class RendererPDFPSBase(RendererBase): diff --git a/lib/matplotlib/backends/backend_cairo.py b/lib/matplotlib/backends/backend_cairo.py index 7409cd35b394..e20ec3fc2313 100644 --- a/lib/matplotlib/backends/backend_cairo.py +++ b/lib/matplotlib/backends/backend_cairo.py @@ -8,6 +8,7 @@ import functools import gzip +import itertools import math import numpy as np @@ -248,13 +249,12 @@ def _draw_mathtext(self, gc, x, y, s, prop, angle): if angle: ctx.rotate(np.deg2rad(-angle)) - for font, fontsize, idx, ox, oy in glyphs: + for (font, fontsize), font_glyphs in itertools.groupby( + glyphs, key=lambda info: (info[0], info[1])): ctx.new_path() - ctx.move_to(ox, -oy) - ctx.select_font_face( - *_cairo_font_args_from_font_prop(ttfFontProperty(font))) + ctx.select_font_face(*_cairo_font_args_from_font_prop(ttfFontProperty(font))) ctx.set_font_size(self.points_to_pixels(fontsize)) - ctx.show_text(chr(idx)) + ctx.show_glyphs([(idx, ox, -oy) for _, _, idx, ox, oy in font_glyphs]) for ox, oy, w, h in rects: ctx.new_path() diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 6682deffb00e..7f1905f96f12 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -19,6 +19,7 @@ import sys import time import types +import typing import warnings import zlib @@ -35,7 +36,8 @@ from matplotlib.figure import Figure from matplotlib.font_manager import get_font, fontManager as _fontManager from matplotlib._afm import AFM -from matplotlib.ft2font import FT2Font, FaceFlags, Kerning, LoadFlags, StyleFlags +from matplotlib.ft2font import ( + FT2Font, FaceFlags, GlyphIndexType, Kerning, LoadFlags, StyleFlags) from matplotlib.transforms import Affine2D, BboxBase from matplotlib.path import Path from matplotlib.dates import UTC @@ -611,12 +613,12 @@ def _flush(self): self.compressobj = None -def _get_pdf_charprocs(font_path, glyph_ids): +def _get_pdf_charprocs(font_path, glyph_indices): font = get_font(font_path, hinting_factor=1) conv = 1000 / font.units_per_EM # Conversion to PS units (1/1000's). procs = {} - for glyph_id in glyph_ids: - g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) + for glyph_index in glyph_indices: + g = font.load_glyph(glyph_index, LoadFlags.NO_SCALE) d1 = [ round(g.horiAdvance * conv), 0, # Round bbox corners *outwards*, so that they indeed bound the glyph. @@ -625,7 +627,7 @@ def _get_pdf_charprocs(font_path, glyph_ids): ] v, c = font.get_path() v = (v * 64 * conv).round() # Back to TrueType's internal units (1/64's). - procs[font.get_glyph_name(glyph_id)] = ( + procs[font.get_glyph_name(glyph_index)] = ( " ".join(map(str, d1)).encode("ascii") + b" d1\n" + _path.convert_to_string( Path(v, c), None, None, False, None, 0, @@ -960,9 +962,9 @@ def writeFonts(self): else: # a normal TrueType font _log.debug('Writing TrueType font.') - chars = self._character_tracker.used.get(filename) - if chars: - fonts[Fx] = self.embedTTF(filename, chars) + glyphs = self._character_tracker.used.get(filename) + if glyphs: + fonts[Fx] = self.embedTTF(filename, glyphs) self.writeObject(self.fontObject, fonts) def _write_afm_font(self, filename): @@ -1136,9 +1138,8 @@ def _get_xobject_glyph_name(self, filename, glyph_name): end end""" - def embedTTF(self, filename, characters): + def embedTTF(self, filename, glyphs): """Embed the TTF font from the named file into the document.""" - font = get_font(filename) fonttype = mpl.rcParams['pdf.fonttype'] @@ -1153,7 +1154,7 @@ def cvt(length, upe=font.units_per_EM, nearest=True): else: return math.ceil(value) - def embedTTFType3(font, characters, descriptor): + def embedTTFType3(font, glyphs, descriptor): """The Type 3-specific part of embedding a Truetype font""" widthsObject = self.reserveObject('font widths') fontdescObject = self.reserveObject('font descriptor') @@ -1198,15 +1199,13 @@ def get_char_width(charcode): # Make the "Differences" array, sort the ccodes < 255 from # the multi-byte ccodes, and build the whole set of glyph ids # that we need from this font. - glyph_ids = [] differences = [] multi_byte_chars = set() - for c in characters: - ccode = c - gind = font.get_char_index(ccode) - glyph_ids.append(gind) + charmap = {gind: ccode for ccode, gind in font.get_charmap().items()} + for gind in glyphs: glyph_name = font.get_glyph_name(gind) - if ccode <= 255: + ccode = charmap.get(gind) + if ccode is not None and ccode <= 255: differences.append((ccode, glyph_name)) else: multi_byte_chars.add(glyph_name) @@ -1220,7 +1219,7 @@ def get_char_width(charcode): last_c = c # Make the charprocs array. - rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) + rawcharprocs = _get_pdf_charprocs(filename, glyphs) charprocs = {} for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] @@ -1257,7 +1256,7 @@ def get_char_width(charcode): return fontdictObject - def embedTTFType42(font, characters, descriptor): + def embedTTFType42(font, glyphs, descriptor): """The Type 42-specific part of embedding a Truetype font""" fontdescObject = self.reserveObject('font descriptor') cidFontDictObject = self.reserveObject('CID font dictionary') @@ -1267,9 +1266,8 @@ def embedTTFType42(font, characters, descriptor): wObject = self.reserveObject('Type 0 widths') toUnicodeMapObject = self.reserveObject('ToUnicode map') - subset_str = "".join(chr(c) for c in characters) - _log.debug("SUBSET %s characters: %s", filename, subset_str) - with _backend_pdf_ps.get_glyphs_subset(filename, subset_str) as subset: + _log.debug("SUBSET %s characters: %s", filename, glyphs) + with _backend_pdf_ps.get_glyphs_subset(filename, glyphs) as subset: fontdata = _backend_pdf_ps.font_as_file(subset) _log.debug( "SUBSET %s %d -> %d", filename, @@ -1317,11 +1315,11 @@ def embedTTFType42(font, characters, descriptor): cid_to_gid_map = ['\0'] * 65536 widths = [] max_ccode = 0 - for c in characters: - ccode = c - gind = font.get_char_index(ccode) - glyph = font.load_char(ccode, - flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) + charmap = {gind: ccode for ccode, gind in font.get_charmap().items()} + for gind in glyphs: + glyph = font.load_glyph(gind, + flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) + ccode = charmap[gind] widths.append((ccode, cvt(glyph.horiAdvance))) if ccode < 65536: cid_to_gid_map[ccode] = chr(gind) @@ -1359,14 +1357,13 @@ def embedTTFType42(font, characters, descriptor): (len(unicode_groups), b"\n".join(unicode_bfrange))) # Add XObjects for unsupported chars - glyph_ids = [] - for ccode in characters: - if not _font_supports_glyph(fonttype, ccode): - gind = full_font.get_char_index(ccode) - glyph_ids.append(gind) + glyph_indices = [ + glyph_index for glyph_index in glyphs + if not _font_supports_glyph(fonttype, charmap[glyph_index]) + ] bbox = [cvt(x, nearest=False) for x in full_font.bbox] - rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) + rawcharprocs = _get_pdf_charprocs(filename, glyph_indices) for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] charprocDict = {'Type': Name('XObject'), @@ -1448,9 +1445,9 @@ def embedTTFType42(font, characters, descriptor): } if fonttype == 3: - return embedTTFType3(font, characters, descriptor) + return embedTTFType3(font, glyphs, descriptor) elif fonttype == 42: - return embedTTFType42(font, characters, descriptor) + return embedTTFType42(font, glyphs, descriptor) def alphaState(self, alpha): """Return name of an ExtGState that sets alpha to the given value.""" @@ -2214,13 +2211,13 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): unsupported_chars = [] self.file.output(Op.begin_text) - for font, fontsize, num, ox, oy in glyphs: - self.file._character_tracker.track_glyph(font, num) + for font, fontsize, ccode, glyph_index, ox, oy in glyphs: + self.file._character_tracker.track_glyph(font, glyph_index) fontname = font.fname - if not _font_supports_glyph(fonttype, num): + if not _font_supports_glyph(fonttype, ccode): # Unsupported chars (i.e. multibyte in Type 3 or beyond BMP in # Type 42) must be emitted separately (below). - unsupported_chars.append((font, fontsize, ox, oy, num)) + unsupported_chars.append((font, fontsize, ox, oy, glyph_index)) else: self._setup_textpos(ox, oy, 0, oldx, oldy) oldx, oldy = ox, oy @@ -2228,13 +2225,12 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): self.file.output(self.file.fontName(fontname), fontsize, Op.selectfont) prev_font = fontname, fontsize - self.file.output(self.encode_string(chr(num), fonttype), + self.file.output(self.encode_string(chr(ccode), fonttype), Op.show) self.file.output(Op.end_text) - for font, fontsize, ox, oy, num in unsupported_chars: - self._draw_xobject_glyph( - font, fontsize, font.get_char_index(num), ox, oy) + for font, fontsize, ox, oy, glyph_index in unsupported_chars: + self._draw_xobject_glyph(font, fontsize, glyph_index, ox, oy) # Draw any horizontal lines in the math layout for ox, oy, width, height in rects: @@ -2266,13 +2262,17 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): # one single-character string, but later it may have longer # strings interspersed with kern amounts. oldfont, seq = None, [] - for x1, y1, dvifont, glyph, width in page.text: - if dvifont != oldfont: - pdfname = self.file.dviFontName(dvifont) - seq += [['font', pdfname, dvifont.size]] - oldfont = dvifont - seq += [['text', x1, y1, [bytes([glyph])], x1+width]] - self.file._character_tracker.track(dvifont, chr(glyph)) + for text in page.text: + if text.font != oldfont: + pdfname = self.file.dviFontName(text.font) + seq += [['font', pdfname, text.font.size]] + oldfont = text.font + seq += [['text', text.x, text.y, [bytes([text.glyph])], text.x+text.width]] + # TODO: This should use glyph indices, not character codes, but will be + # fixed soon. + self.file._character_tracker.track_glyph(text.font, + typing.cast('GlyphIndexType', + text.glyph)) # Find consecutive text strings with constant y coordinate and # combine into a sequence of strings and kerns, or just one @@ -2401,7 +2401,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): singlebyte_chunks[-1][2].append(item.char) prev_was_multibyte = False else: - multibyte_glyphs.append((item.ft_object, item.x, item.glyph_idx)) + multibyte_glyphs.append((item.ft_object, item.x, item.glyph_index)) prev_was_multibyte = True # Do the rotation and global translation as a single matrix # concatenation up front @@ -2411,7 +2411,6 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): -math.sin(a), math.cos(a), x, y, Op.concat_matrix) # Emit all the 1-byte characters in a BT/ET group. - self.file.output(Op.begin_text) prev_start_x = 0 for ft_object, start_x, kerns_or_chars in singlebyte_chunks: @@ -2428,15 +2427,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): prev_start_x = start_x self.file.output(Op.end_text) # Then emit all the multibyte characters, one at a time. - for ft_object, start_x, glyph_idx in multibyte_glyphs: + for ft_object, start_x, glyph_index in multibyte_glyphs: self._draw_xobject_glyph( - ft_object, fontsize, glyph_idx, start_x, 0 + ft_object, fontsize, glyph_index, start_x, 0 ) self.file.output(Op.grestore) - def _draw_xobject_glyph(self, font, fontsize, glyph_idx, x, y): + def _draw_xobject_glyph(self, font, fontsize, glyph_index, x, y): """Draw a multibyte character from a Type 3 font as an XObject.""" - glyph_name = font.get_glyph_name(glyph_idx) + glyph_name = font.get_glyph_name(glyph_index) name = self.file._get_xobject_glyph_name(font.fname, glyph_name) self.file.output( Op.gsave, diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 368564a1518d..060a40c08e8b 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -88,16 +88,16 @@ def _move_path_to_path_or_stream(src, dst): shutil.move(src, dst, copy_function=shutil.copyfile) -def _font_to_ps_type3(font_path, chars): +def _font_to_ps_type3(font_path, glyph_indices): """ - Subset *chars* from the font at *font_path* into a Type 3 font. + Subset *glyphs_indices* from the font at *font_path* into a Type 3 font. Parameters ---------- font_path : path-like Path to the font to be subsetted. - chars : str - The characters to include in the subsetted font. + glyph_indices : set[int] + The glyphs to include in the subsetted font. Returns ------- @@ -106,7 +106,6 @@ def _font_to_ps_type3(font_path, chars): verbatim into a PostScript file. """ font = get_font(font_path, hinting_factor=1) - glyph_ids = [font.get_char_index(c) for c in chars] preamble = """\ %!PS-Adobe-3.0 Resource-Font @@ -123,9 +122,9 @@ def _font_to_ps_type3(font_path, chars): """.format(font_name=font.postscript_name, inv_units_per_em=1 / font.units_per_EM, bbox=" ".join(map(str, font.bbox)), - encoding=" ".join(f"/{font.get_glyph_name(glyph_id)}" - for glyph_id in glyph_ids), - num_glyphs=len(glyph_ids) + 1) + encoding=" ".join(f"/{font.get_glyph_name(glyph_index)}" + for glyph_index in glyph_indices), + num_glyphs=len(glyph_indices) + 1) postamble = """ end readonly def @@ -146,12 +145,12 @@ def _font_to_ps_type3(font_path, chars): """ entries = [] - for glyph_id in glyph_ids: - g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) + for glyph_index in glyph_indices: + g = font.load_glyph(glyph_index, LoadFlags.NO_SCALE) v, c = font.get_path() entries.append( "/%(name)s{%(bbox)s sc\n" % { - "name": font.get_glyph_name(glyph_id), + "name": font.get_glyph_name(glyph_index), "bbox": " ".join(map(str, [g.horiAdvance, 0, *g.bbox])), } + _path.convert_to_string( @@ -169,21 +168,20 @@ def _font_to_ps_type3(font_path, chars): return preamble + "\n".join(entries) + postamble -def _font_to_ps_type42(font_path, chars, fh): +def _font_to_ps_type42(font_path, glyph_indices, fh): """ - Subset *chars* from the font at *font_path* into a Type 42 font at *fh*. + Subset *glyph_indices* from the font at *font_path* into a Type 42 font at *fh*. Parameters ---------- font_path : path-like Path to the font to be subsetted. - chars : str - The characters to include in the subsetted font. + glyph_indices : set[int] + The glyphs to include in the subsetted font. fh : file-like Where to write the font. """ - subset_str = ''.join(chr(c) for c in chars) - _log.debug("SUBSET %s characters: %s", font_path, subset_str) + _log.debug("SUBSET %s characters: %s", font_path, glyph_indices) try: kw = {} # fix this once we support loading more fonts from a collection @@ -191,7 +189,7 @@ def _font_to_ps_type42(font_path, chars, fh): if font_path.endswith('.ttc'): kw['fontNumber'] = 0 with (fontTools.ttLib.TTFont(font_path, **kw) as font, - _backend_pdf_ps.get_glyphs_subset(font_path, subset_str) as subset): + _backend_pdf_ps.get_glyphs_subset(font_path, glyph_indices) as subset): fontdata = _backend_pdf_ps.font_as_file(subset).getvalue() _log.debug( "SUBSET %s %d -> %d", font_path, os.stat(font_path).st_size, @@ -775,8 +773,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if mpl.rcParams['ps.useafm']: font = self._get_font_afm(prop) - ps_name = (font.postscript_name.encode("ascii", "replace") - .decode("ascii")) + ps_name = font.postscript_name.encode("ascii", "replace").decode("ascii") scale = 0.001 * prop.get_size_in_points() thisx = 0 last_name = '' # kerns returns 0 for ''. @@ -799,7 +796,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): for item in _text_helpers.layout(s, font): ps_name = (item.ft_object.postscript_name .encode("ascii", "replace").decode("ascii")) - glyph_name = item.ft_object.get_glyph_name(item.glyph_idx) + glyph_name = item.ft_object.get_glyph_name(item.glyph_index) stream.append((ps_name, item.x, glyph_name)) self.set_color(*gc.get_rgb()) @@ -828,13 +825,13 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): f"{x:g} {y:g} translate\n" f"{angle:g} rotate\n") lastfont = None - for font, fontsize, num, ox, oy in glyphs: - self._character_tracker.track_glyph(font, num) + for font, fontsize, ccode, glyph_index, ox, oy in glyphs: + self._character_tracker.track_glyph(font, glyph_index) if (font.postscript_name, fontsize) != lastfont: lastfont = font.postscript_name, fontsize self._pswriter.write( f"/{font.postscript_name} {fontsize} selectfont\n") - glyph_name = font.get_glyph_name(font.get_char_index(num)) + glyph_name = font.get_glyph_name(glyph_index) self._pswriter.write( f"{ox:g} {oy:g} moveto\n" f"/{glyph_name} glyphshow\n") @@ -1072,19 +1069,18 @@ def print_figure_impl(fh): print("mpldict begin", file=fh) print("\n".join(_psDefs), file=fh) if not mpl.rcParams['ps.useafm']: - for font_path, chars \ - in ps_renderer._character_tracker.used.items(): - if not chars: + for font_path, glyphs in ps_renderer._character_tracker.used.items(): + if not glyphs: continue fonttype = mpl.rcParams['ps.fonttype'] # Can't use more than 255 chars from a single Type 3 font. - if len(chars) > 255: + if len(glyphs) > 255: fonttype = 42 fh.flush() if fonttype == 3: - fh.write(_font_to_ps_type3(font_path, chars)) + fh.write(_font_to_ps_type3(font_path, glyphs)) else: # Type 42 only. - _font_to_ps_type42(font_path, chars, fh) + _font_to_ps_type42(font_path, glyphs, fh) print("end", file=fh) print("%%EndProlog", file=fh) diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 0cb6430ec823..7b94a2b9ba2b 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -1023,19 +1023,19 @@ def _update_glyph_map_defs(self, glyph_map_new): writer = self.writer if glyph_map_new: writer.start('defs') - for char_id, (vertices, codes) in glyph_map_new.items(): - char_id = self._adjust_char_id(char_id) + for glyph_repr, (vertices, codes) in glyph_map_new.items(): + glyph_repr = self._adjust_glyph_repr(glyph_repr) # x64 to go back to FreeType's internal (integral) units. path_data = self._convert_path( Path(vertices * 64, codes), simplify=False) writer.element( - 'path', id=char_id, d=path_data, + 'path', id=glyph_repr, d=path_data, transform=_generate_transform([('scale', (1 / 64,))])) writer.end('defs') self._glyph_map.update(glyph_map_new) - def _adjust_char_id(self, char_id): - return char_id.replace("%20", "_") + def _adjust_glyph_repr(self, glyph_repr): + return glyph_repr.replace("%20", "_") def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): # docstring inherited @@ -1067,19 +1067,18 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): if not ismath: font = text2path._get_font(prop) - _glyphs = text2path.get_glyphs_with_font( + glyph_info, glyph_map_new, rects = text2path.get_glyphs_with_font( font, s, glyph_map=glyph_map, return_new_glyphs_only=True) - glyph_info, glyph_map_new, rects = _glyphs self._update_glyph_map_defs(glyph_map_new) - for glyph_id, xposition, yposition, scale in glyph_info: + for glyph_repr, xposition, yposition, scale in glyph_info: writer.element( 'use', transform=_generate_transform([ ('translate', (xposition, yposition)), ('scale', (scale,)), ]), - attrib={'xlink:href': f'#{glyph_id}'}) + attrib={'xlink:href': f'#{glyph_repr}'}) else: if ismath == "TeX": @@ -1091,15 +1090,15 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): glyph_info, glyph_map_new, rects = _glyphs self._update_glyph_map_defs(glyph_map_new) - for char_id, xposition, yposition, scale in glyph_info: - char_id = self._adjust_char_id(char_id) + for glyph_repr, xposition, yposition, scale in glyph_info: + glyph_repr = self._adjust_glyph_repr(glyph_repr) writer.element( 'use', transform=_generate_transform([ ('translate', (xposition, yposition)), ('scale', (scale,)), ]), - attrib={'xlink:href': f'#{char_id}'}) + attrib={'xlink:href': f'#{glyph_repr}'}) for verts, codes in rects: path = Path(verts, codes) @@ -1223,7 +1222,7 @@ def _get_all_quoted_names(prop): # Sort the characters by font, and output one tspan for each. spans = {} - for font, fontsize, thetext, new_x, new_y in glyphs: + for font, fontsize, ccode, glyph_index, new_x, new_y in glyphs: entry = fm.ttfFontProperty(font) font_style = {} # Separate font style in its separate attributes @@ -1238,9 +1237,9 @@ def _get_all_quoted_names(prop): if entry.stretch != 'normal': font_style['font-stretch'] = entry.stretch style = _generate_css({**font_style, **color_style}) - if thetext == 32: - thetext = 0xa0 # non-breaking space - spans.setdefault(style, []).append((new_x, -new_y, thetext)) + if ccode == 32: + ccode = 0xa0 # non-breaking space + spans.setdefault(style, []).append((new_x, -new_y, ccode)) for style, chars in spans.items(): chars.sort() # Sort by increasing x position diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 6ddc463295a9..c1e911a88355 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -8,7 +8,7 @@ from collections.abc import Generator from typing import NamedTuple from typing import Self -from .ft2font import GlyphIndexType +from .ft2font import CharacterCodeType, GlyphIndexType class _dvistate(Enum): @@ -35,7 +35,7 @@ class Text(NamedTuple): x: int y: int font: DviFont - glyph: int + glyph: CharacterCodeType width: int @property def font_path(self) -> Path: ... diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index f126fb543e78..2dc22fd9170e 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -361,13 +361,13 @@ def test_glyphs_subset(): # non-subsetted FT2Font nosubfont = FT2Font(fpath) nosubfont.set_text(chars) + nosubcmap = nosubfont.get_charmap() # subsetted FT2Font - with get_glyphs_subset(fpath, chars) as subset: + glyph_indices = {nosubcmap[ord(c)] for c in chars} + with get_glyphs_subset(fpath, glyph_indices) as subset: subfont = FT2Font(font_as_file(subset)) subfont.set_text(chars) - - nosubcmap = nosubfont.get_charmap() subcmap = subfont.get_charmap() # all unique chars must be available in subsetted font diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 2c64b7c24b3e..e865dbbe92da 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 = 'SFSS1728-8e' + won_id = 'SFSS1728-232' 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/textpath.py b/lib/matplotlib/textpath.py index b57597ded363..626568ba134e 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -39,11 +39,9 @@ def _get_font(self, prop): def _get_hinting_flag(self): return LoadFlags.NO_HINTING - def _get_char_id(self, font, ccode): - """ - Return a unique id for the given font and character-code set. - """ - return urllib.parse.quote(f"{font.postscript_name}-{ccode:x}") + def _get_glyph_repr(self, font, glyph): + """Return a unique id for the given font and glyph index.""" + return urllib.parse.quote(f"{font.postscript_name}-{glyph:x}") def get_text_width_height_descent(self, s, prop, ismath): fontsize = prop.get_size_in_points() @@ -114,8 +112,8 @@ def get_text_path(self, prop, s, ismath=False): glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) verts, codes = [], [] - for glyph_id, xposition, yposition, scale in glyph_info: - verts1, codes1 = glyph_map[glyph_id] + for glyph_repr, xposition, yposition, scale in glyph_info: + verts1, codes1 = glyph_map[glyph_repr] verts.extend(verts1 * scale + [xposition, yposition]) codes.extend(codes1) for verts1, codes1 in rects: @@ -144,20 +142,20 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, glyph_map_new = glyph_map xpositions = [] - glyph_ids = [] + glyph_reprs = [] for item in _text_helpers.layout(s, font): - char_id = self._get_char_id(item.ft_object, ord(item.char)) - glyph_ids.append(char_id) + glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index) + glyph_reprs.append(glyph_repr) xpositions.append(item.x) - if char_id not in glyph_map: - glyph_map_new[char_id] = item.ft_object.get_path() + if glyph_repr not in glyph_map: + glyph_map_new[glyph_repr] = item.ft_object.get_path() ypositions = [0] * len(xpositions) sizes = [1.] * len(xpositions) rects = [] - return (list(zip(glyph_ids, xpositions, ypositions, sizes)), + return (list(zip(glyph_reprs, xpositions, ypositions, sizes)), glyph_map_new, rects) def get_glyphs_mathtext(self, prop, s, glyph_map=None, @@ -182,20 +180,20 @@ def get_glyphs_mathtext(self, prop, s, glyph_map=None, xpositions = [] ypositions = [] - glyph_ids = [] + glyph_reprs = [] sizes = [] - for font, fontsize, ccode, ox, oy in glyphs: - char_id = self._get_char_id(font, ccode) - if char_id not in glyph_map: + for font, fontsize, ccode, glyph_index, ox, oy in glyphs: + glyph_repr = self._get_glyph_repr(font, glyph_index) + if glyph_repr not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) - font.load_char(ccode, flags=LoadFlags.NO_HINTING) - glyph_map_new[char_id] = font.get_path() + font.load_glyph(glyph_index, flags=LoadFlags.NO_HINTING) + glyph_map_new[glyph_repr] = font.get_path() xpositions.append(ox) ypositions.append(oy) - glyph_ids.append(char_id) + glyph_reprs.append(glyph_repr) size = fontsize / self.FONT_SCALE sizes.append(size) @@ -208,7 +206,7 @@ def get_glyphs_mathtext(self, prop, s, glyph_map=None, Path.CLOSEPOLY] myrects.append((vert1, code1)) - return (list(zip(glyph_ids, xpositions, ypositions, sizes)), + return (list(zip(glyph_reprs, xpositions, ypositions, sizes)), glyph_map_new, myrects) def get_glyphs_tex(self, prop, s, glyph_map=None, @@ -228,21 +226,20 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, else: glyph_map_new = glyph_map - glyph_ids, xpositions, ypositions, sizes = [], [], [], [] + glyph_reprs, xpositions, ypositions, sizes = [], [], [], [] # Gather font information and do some setup for combining # characters into strings. - t1_encodings = {} for text in page.text: font = get_font(text.font_path) - char_id = self._get_char_id(font, text.glyph) - if char_id not in glyph_map: + glyph_repr = self._get_glyph_repr(font, text.index) + if glyph_repr not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) font.load_glyph(text.index, flags=LoadFlags.TARGET_LIGHT) - glyph_map_new[char_id] = font.get_path() + glyph_map_new[glyph_repr] = font.get_path() - glyph_ids.append(char_id) + glyph_reprs.append(glyph_repr) xpositions.append(text.x) ypositions.append(text.y) sizes.append(text.font_size / self.FONT_SCALE) @@ -257,7 +254,7 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, Path.CLOSEPOLY] myrects.append((vert1, code1)) - return (list(zip(glyph_ids, xpositions, ypositions, sizes)), + return (list(zip(glyph_reprs, xpositions, ypositions, sizes)), glyph_map_new, myrects) From f192c8794f410e3dc7c052477c0e0a359182d980 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 05:08:35 -0400 Subject: [PATCH 029/108] pdf/ps: Track full character map in CharacterTracker By tracking both character codes and glyph indices, we can handle producing multiple font subsets if needed by a file format. --- lib/matplotlib/backends/_backend_pdf_ps.py | 108 ++++++++++++++++++--- lib/matplotlib/backends/backend_pdf.py | 52 +++++----- lib/matplotlib/backends/backend_ps.py | 13 +-- 3 files changed, 123 insertions(+), 50 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 75f0a05ae0dc..1fdcccbab61a 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -18,7 +18,7 @@ if typing.TYPE_CHECKING: - from .ft2font import FT2Font, GlyphIndexType + from .ft2font import CharacterCodeType, FT2Font, GlyphIndexType from fontTools.ttLib import TTFont @@ -107,23 +107,103 @@ class CharacterTracker: """ Helper for font subsetting by the PDF and PS backends. - Maintains a mapping of font paths to the set of glyphs that are being used from that - font. - """ + Maintains a mapping of font paths to the set of characters and glyphs that are being + used from that font. + + Attributes + ---------- + subset_size : int + The size at which characters are grouped into subsets. + used : dict[tuple[str, int], dict[CharacterCodeType, GlyphIndexType]] + A dictionary of font files to character maps. + + The key is a font filename and subset within that font. - def __init__(self) -> None: - self.used: dict[str, set[GlyphIndexType]] = {} + The value is a dictionary mapping a character code to a glyph index. Note this + mapping is the inverse of FreeType, which maps glyph indices to character codes. - def track(self, font: FT2Font, s: str) -> None: - """Record that string *s* is being typeset using font *font*.""" + If *subset_size* is not set, then there will only be one subset per font + filename. + """ + + def __init__(self, subset_size: int = 0): + """ + Parameters + ---------- + subset_size : int, optional + The maximum size that is supported for an embedded font. If provided, then + characters will be grouped into these sized subsets. + """ + self.used: dict[tuple[str, int], dict[CharacterCodeType, GlyphIndexType]] = {} + self.subset_size = subset_size + + def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: + """ + Record that string *s* is being typeset using font *font*. + + Parameters + ---------- + font : FT2Font + A font that is being used for the provided string. + s : str + The string that should be marked as tracked by the provided font. + + Returns + ------- + list[tuple[int, CharacterCodeType]] + A list of subset and character code pairs corresponding to the input string. + If a *subset_size* is specified on this instance, then the character code + will correspond with the given subset (and not necessarily the string as a + whole). If *subset_size* is not specified, then the subset will always be 0 + and the character codes will be returned from the string unchanged. + """ + font_glyphs = [] char_to_font = font._get_fontmap(s) for _c, _f in char_to_font.items(): - glyph_index = _f.get_char_index(ord(_c)) - self.used.setdefault(_f.fname, set()).add(glyph_index) - - def track_glyph(self, font: FT2Font, glyph_index: GlyphIndexType) -> None: - """Record that glyph index *glyph_index* is being typeset using font *font*.""" - self.used.setdefault(font.fname, set()).add(glyph_index) + charcode = ord(_c) + glyph_index = _f.get_char_index(charcode) + if self.subset_size != 0: + subset = charcode // self.subset_size + subset_charcode = charcode % self.subset_size + else: + subset = 0 + subset_charcode = charcode + self.used.setdefault((_f.fname, subset), {})[subset_charcode] = glyph_index + font_glyphs.append((subset, subset_charcode)) + return font_glyphs + + def track_glyph( + self, font: FT2Font, charcode: CharacterCodeType, + glyph: GlyphIndexType) -> tuple[int, CharacterCodeType]: + """ + Record character code *charcode* at glyph index *glyph* as using font *font*. + + Parameters + ---------- + font : FT2Font + A font that is being used for the provided string. + charcode : CharacterCodeType + The character code to record. + glyph : GlyphIndexType + The corresponding glyph index to record. + + Returns + ------- + subset : int + The subset in which the returned character code resides. If *subset_size* + was not specified on this instance, then this is always 0. + subset_charcode : CharacterCodeType + The character code within the above subset. If *subset_size* was not + specified on this instance, then this is just *charcode* unmodified. + """ + if self.subset_size != 0: + subset = charcode // self.subset_size + subset_charcode = charcode % self.subset_size + else: + subset = 0 + subset_charcode = charcode + self.used.setdefault((font.fname, subset), {})[subset_charcode] = glyph + return (subset, subset_charcode) class RendererPDFPSBase(RendererBase): diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 7f1905f96f12..153e03639d84 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -19,7 +19,6 @@ import sys import time import types -import typing import warnings import zlib @@ -36,8 +35,7 @@ from matplotlib.figure import Figure from matplotlib.font_manager import get_font, fontManager as _fontManager from matplotlib._afm import AFM -from matplotlib.ft2font import ( - FT2Font, FaceFlags, GlyphIndexType, Kerning, LoadFlags, StyleFlags) +from matplotlib.ft2font import FT2Font, FaceFlags, Kerning, LoadFlags, StyleFlags from matplotlib.transforms import Affine2D, BboxBase from matplotlib.path import Path from matplotlib.dates import UTC @@ -962,9 +960,9 @@ def writeFonts(self): else: # a normal TrueType font _log.debug('Writing TrueType font.') - glyphs = self._character_tracker.used.get(filename) - if glyphs: - fonts[Fx] = self.embedTTF(filename, glyphs) + charmap = self._character_tracker.used.get((filename, 0)) + if charmap: + fonts[Fx] = self.embedTTF(filename, charmap) self.writeObject(self.fontObject, fonts) def _write_afm_font(self, filename): @@ -1006,8 +1004,9 @@ def _embedTeXFont(self, dvifont): # Reduce the font to only the glyphs used in the document, get the encoding # for that subset, and compute various properties based on the encoding. - chars = frozenset(self._character_tracker.used[dvifont.fname]) - t1font = t1font.subset(chars, self._get_subset_prefix(chars)) + charmap = self._character_tracker.used[(dvifont.fname, 0)] + chars = frozenset(charmap.keys()) + t1font = t1font.subset(chars, self._get_subset_prefix(charmap.values())) fontdict['BaseFont'] = Name(t1font.prop['FontName']) # createType1Descriptor writes the font data as a side effect fontdict['FontDescriptor'] = self.createType1Descriptor(t1font) @@ -1138,7 +1137,7 @@ def _get_xobject_glyph_name(self, filename, glyph_name): end end""" - def embedTTF(self, filename, glyphs): + def embedTTF(self, filename, charmap): """Embed the TTF font from the named file into the document.""" font = get_font(filename) fonttype = mpl.rcParams['pdf.fonttype'] @@ -1154,7 +1153,7 @@ def cvt(length, upe=font.units_per_EM, nearest=True): else: return math.ceil(value) - def embedTTFType3(font, glyphs, descriptor): + def embedTTFType3(font, charmap, descriptor): """The Type 3-specific part of embedding a Truetype font""" widthsObject = self.reserveObject('font widths') fontdescObject = self.reserveObject('font descriptor') @@ -1201,10 +1200,8 @@ def get_char_width(charcode): # that we need from this font. differences = [] multi_byte_chars = set() - charmap = {gind: ccode for ccode, gind in font.get_charmap().items()} - for gind in glyphs: + for ccode, gind in charmap.items(): glyph_name = font.get_glyph_name(gind) - ccode = charmap.get(gind) if ccode is not None and ccode <= 255: differences.append((ccode, glyph_name)) else: @@ -1219,7 +1216,7 @@ def get_char_width(charcode): last_c = c # Make the charprocs array. - rawcharprocs = _get_pdf_charprocs(filename, glyphs) + rawcharprocs = _get_pdf_charprocs(filename, charmap.values()) charprocs = {} for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] @@ -1256,7 +1253,7 @@ def get_char_width(charcode): return fontdictObject - def embedTTFType42(font, glyphs, descriptor): + def embedTTFType42(font, charmap, descriptor): """The Type 42-specific part of embedding a Truetype font""" fontdescObject = self.reserveObject('font descriptor') cidFontDictObject = self.reserveObject('CID font dictionary') @@ -1266,8 +1263,9 @@ def embedTTFType42(font, glyphs, descriptor): wObject = self.reserveObject('Type 0 widths') toUnicodeMapObject = self.reserveObject('ToUnicode map') - _log.debug("SUBSET %s characters: %s", filename, glyphs) - with _backend_pdf_ps.get_glyphs_subset(filename, glyphs) as subset: + _log.debug("SUBSET %s characters: %s", filename, charmap) + with _backend_pdf_ps.get_glyphs_subset(filename, + charmap.values()) as subset: fontdata = _backend_pdf_ps.font_as_file(subset) _log.debug( "SUBSET %s %d -> %d", filename, @@ -1315,11 +1313,9 @@ def embedTTFType42(font, glyphs, descriptor): cid_to_gid_map = ['\0'] * 65536 widths = [] max_ccode = 0 - charmap = {gind: ccode for ccode, gind in font.get_charmap().items()} - for gind in glyphs: + for ccode, gind in charmap.items(): glyph = font.load_glyph(gind, flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) - ccode = charmap[gind] widths.append((ccode, cvt(glyph.horiAdvance))) if ccode < 65536: cid_to_gid_map[ccode] = chr(gind) @@ -1358,8 +1354,8 @@ def embedTTFType42(font, glyphs, descriptor): # Add XObjects for unsupported chars glyph_indices = [ - glyph_index for glyph_index in glyphs - if not _font_supports_glyph(fonttype, charmap[glyph_index]) + glyph_index for ccode, glyph_index in charmap.items() + if not _font_supports_glyph(fonttype, ccode) ] bbox = [cvt(x, nearest=False) for x in full_font.bbox] @@ -1445,9 +1441,9 @@ def embedTTFType42(font, glyphs, descriptor): } if fonttype == 3: - return embedTTFType3(font, glyphs, descriptor) + return embedTTFType3(font, charmap, descriptor) elif fonttype == 42: - return embedTTFType42(font, glyphs, descriptor) + return embedTTFType42(font, charmap, descriptor) def alphaState(self, alpha): """Return name of an ExtGState that sets alpha to the given value.""" @@ -2212,7 +2208,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): self.file.output(Op.begin_text) for font, fontsize, ccode, glyph_index, ox, oy in glyphs: - self.file._character_tracker.track_glyph(font, glyph_index) + self.file._character_tracker.track_glyph(font, ccode, glyph_index) fontname = font.fname if not _font_supports_glyph(fonttype, ccode): # Unsupported chars (i.e. multibyte in Type 3 or beyond BMP in @@ -2268,11 +2264,7 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): seq += [['font', pdfname, text.font.size]] oldfont = text.font seq += [['text', text.x, text.y, [bytes([text.glyph])], text.x+text.width]] - # TODO: This should use glyph indices, not character codes, but will be - # fixed soon. - self.file._character_tracker.track_glyph(text.font, - typing.cast('GlyphIndexType', - text.glyph)) + self.file._character_tracker.track_glyph(text.font, text.glyph, text.index) # Find consecutive text strings with constant y coordinate and # combine into a sequence of strings and kerns, or just one diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 060a40c08e8b..b0180de20f9f 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -826,7 +826,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): f"{angle:g} rotate\n") lastfont = None for font, fontsize, ccode, glyph_index, ox, oy in glyphs: - self._character_tracker.track_glyph(font, glyph_index) + self._character_tracker.track_glyph(font, ccode, glyph_index) if (font.postscript_name, fontsize) != lastfont: lastfont = font.postscript_name, fontsize self._pswriter.write( @@ -1069,18 +1069,19 @@ def print_figure_impl(fh): print("mpldict begin", file=fh) print("\n".join(_psDefs), file=fh) if not mpl.rcParams['ps.useafm']: - for font_path, glyphs in ps_renderer._character_tracker.used.items(): - if not glyphs: + for (font, subset_index), charmap in \ + ps_renderer._character_tracker.used.items(): + if not charmap: continue fonttype = mpl.rcParams['ps.fonttype'] # Can't use more than 255 chars from a single Type 3 font. - if len(glyphs) > 255: + if len(charmap) > 255: fonttype = 42 fh.flush() if fonttype == 3: - fh.write(_font_to_ps_type3(font_path, glyphs)) + fh.write(_font_to_ps_type3(font, charmap.values())) else: # Type 42 only. - _font_to_ps_type42(font_path, glyphs, fh) + _font_to_ps_type42(font, charmap.values(), fh) print("end", file=fh) print("%%EndProlog", file=fh) From 3e97c0d649550d805c25c71bf49f5f456fec284f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 29 Aug 2025 04:16:48 -0400 Subject: [PATCH 030/108] pdf: Merge loops for single byte text chunk output Currently, we split text into single byte chunks and multi-byte glyphs, then iterate through single byte chunks for output and multi-byte glyphs for output. Instead, output the single byte chunks as we finish them, then do the multi-byte glyphs at the end. --- lib/matplotlib/backends/backend_pdf.py | 65 +++++++++++++------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 153e03639d84..560d85db682d 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2376,25 +2376,14 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # the regular text show command (TJ) with appropriate kerning between # chunks, whereas multibyte characters use the XObject command (Do). else: - # List of (ft_object, start_x, [prev_kern, char, char, ...]), - # w/o zero kerns. - singlebyte_chunks = [] - # List of (ft_object, start_x, glyph_index). - multibyte_glyphs = [] - prev_was_multibyte = True - prev_font = font - for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED): - if _font_supports_glyph(fonttype, ord(item.char)): - if prev_was_multibyte or item.ft_object != prev_font: - singlebyte_chunks.append((item.ft_object, item.x, [])) - prev_font = item.ft_object - if item.prev_kern: - singlebyte_chunks[-1][2].append(item.prev_kern) - singlebyte_chunks[-1][2].append(item.char) - prev_was_multibyte = False - else: - multibyte_glyphs.append((item.ft_object, item.x, item.glyph_index)) - prev_was_multibyte = True + def output_singlebyte_chunk(kerns_or_chars): + self.file.output( + # See pdf spec "Text space details" for the 1000/fontsize + # (aka. 1000/T_fs) factor. + [(-1000 * next(group) / fontsize) if tp == float # a kern + else self.encode_string("".join(group), fonttype) + for tp, group in itertools.groupby(kerns_or_chars, type)], + Op.showkern) # Do the rotation and global translation as a single matrix # concatenation up front self.file.output(Op.gsave) @@ -2402,21 +2391,33 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): self.file.output(math.cos(a), math.sin(a), -math.sin(a), math.cos(a), x, y, Op.concat_matrix) + # List of [prev_kern, char, char, ...] w/o zero kerns. + singlebyte_chunk = [] + # List of (ft_object, start_x, glyph_index). + multibyte_glyphs = [] + prev_font = None + prev_start_x = 0 # Emit all the 1-byte characters in a BT/ET group. self.file.output(Op.begin_text) - prev_start_x = 0 - for ft_object, start_x, kerns_or_chars in singlebyte_chunks: - ft_name = self.file.fontName(ft_object.fname) - self.file.output(ft_name, fontsize, Op.selectfont) - self._setup_textpos(start_x, 0, 0, prev_start_x, 0, 0) - self.file.output( - # See pdf spec "Text space details" for the 1000/fontsize - # (aka. 1000/T_fs) factor. - [-1000 * next(group) / fontsize if tp == float # a kern - else self.encode_string("".join(group), fonttype) - for tp, group in itertools.groupby(kerns_or_chars, type)], - Op.showkern) - prev_start_x = start_x + for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED): + if _font_supports_glyph(fonttype, ord(item.char)): + if item.ft_object != prev_font: + if singlebyte_chunk: + output_singlebyte_chunk(singlebyte_chunk) + ft_name = self.file.fontName(item.ft_object.fname) + self.file.output(ft_name, fontsize, Op.selectfont) + self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0) + singlebyte_chunk = [] + prev_font = item.ft_object + prev_start_x = item.x + if item.prev_kern: + singlebyte_chunk.append(item.prev_kern) + singlebyte_chunk.append(item.char) + else: + prev_font = None + multibyte_glyphs.append((item.ft_object, item.x, item.glyph_index)) + if singlebyte_chunk: + output_singlebyte_chunk(singlebyte_chunk) self.file.output(Op.end_text) # Then emit all the multibyte characters, one at a time. for ft_object, start_x, glyph_index in multibyte_glyphs: From b2364882c3776a7531e00b50c0e8957219fce32b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 17 Sep 2025 22:39:24 -0400 Subject: [PATCH 031/108] MNT: Ignore differing stub for GlyphIndexType This may be an upstream bug [1], but until that is determined, ignore the error to get CI working. [1] https://github.com/python/mypy/issues/19877 --- ci/mypy-stubtest-allowlist.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 46ec06e0a9f1..c8e8e60bc3c9 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -49,3 +49,7 @@ matplotlib\.figure\.FigureBase\.get_figure # getitem method only exists for 3.10 deprecation backcompatability matplotlib\.inset\.InsetIndicator\.__getitem__ + +# Avoid a regression in NewType handling for stubtest +# https://github.com/python/mypy/issues/19877 +matplotlib\.ft2font\.GlyphIndexType\.__init__ From 18ffa029b95d747ece48ec0a53c1340f84858b74 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 25 Jul 2025 01:20:05 -0400 Subject: [PATCH 032/108] Add os.PathLike support to FT2Font constructor, and FontManager Since we pass the filename to `io.open`, we can accept everything it can. Also, fix the return value of `FT2Font.fname`, which could be `bytes` if that was initially provided. --- lib/matplotlib/font_manager.py | 6 ++--- lib/matplotlib/font_manager.pyi | 14 +++++----- lib/matplotlib/ft2font.pyi | 5 ++-- lib/matplotlib/tests/test_font_manager.py | 33 ++++++++++++++++++----- lib/matplotlib/tests/test_ft2font.py | 22 +++++++++++++++ src/ft2font_wrapper.cpp | 13 +++++---- 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 79e088b85998..47339d4491dd 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1611,10 +1611,10 @@ def get_font(font_filepaths, hinting_factor=None): Parameters ---------- - font_filepaths : Iterable[str, Path, bytes], str, Path, bytes + font_filepaths : Iterable[str, bytes, os.PathLike], str, bytes, os.PathLike Relative or absolute paths to the font files to be used. - If a single string, bytes, or `pathlib.Path`, then it will be treated + If a single string, bytes, or `os.PathLike`, then it will be treated as a list with that entry only. If more than one filepath is passed, then the returned FT2Font object @@ -1626,7 +1626,7 @@ def get_font(font_filepaths, hinting_factor=None): `.ft2font.FT2Font` """ - if isinstance(font_filepaths, (str, Path, bytes)): + if isinstance(font_filepaths, (str, bytes, os.PathLike)): paths = (_cached_realpath(font_filepaths),) else: paths = tuple(_cached_realpath(fname) for fname in font_filepaths) diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index e865f67384cd..f5e3910e5f63 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -24,7 +24,7 @@ def list_fonts(directory: str, extensions: Iterable[str]) -> list[str]: ... def win32FontDirectory() -> str: ... def _get_fontconfig_fonts() -> list[Path]: ... def findSystemFonts( - fontpaths: Iterable[str | os.PathLike | Path] | None = ..., fontext: str = ... + fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ... ) -> list[str]: ... @dataclass class FontEntry: @@ -50,7 +50,7 @@ class FontProperties: weight: int | str | None = ..., stretch: int | str | None = ..., size: float | str | None = ..., - fname: str | os.PathLike | Path | None = ..., + fname: str | os.PathLike | None = ..., math_fontfamily: str | None = ..., ) -> None: ... def __hash__(self) -> int: ... @@ -72,7 +72,7 @@ class FontProperties: def set_weight(self, weight: int | str | None) -> None: ... def set_stretch(self, stretch: int | str | None) -> None: ... def set_size(self, size: float | str | None) -> None: ... - def set_file(self, file: str | os.PathLike | Path | None) -> None: ... + def set_file(self, file: str | os.PathLike | None) -> None: ... def set_fontconfig_pattern(self, pattern: str) -> None: ... def get_math_fontfamily(self) -> str: ... def set_math_fontfamily(self, fontfamily: str | None) -> None: ... @@ -83,8 +83,8 @@ class FontProperties: set_slant = set_style get_size_in_points = get_size -def json_dump(data: FontManager, filename: str | Path | os.PathLike) -> None: ... -def json_load(filename: str | Path | os.PathLike) -> FontManager: ... +def json_dump(data: FontManager, filename: str | os.PathLike) -> None: ... +def json_load(filename: str | os.PathLike) -> FontManager: ... class FontManager: __version__: str @@ -93,7 +93,7 @@ class FontManager: afmlist: list[FontEntry] ttflist: list[FontEntry] def __init__(self, size: float | None = ..., weight: str = ...) -> None: ... - def addfont(self, path: str | Path | os.PathLike) -> None: ... + def addfont(self, path: str | os.PathLike) -> None: ... @property def defaultFont(self) -> dict[str, str]: ... def get_default_weight(self) -> str: ... @@ -120,7 +120,7 @@ class FontManager: def is_opentype_cff_font(filename: str) -> bool: ... def get_font( - font_filepaths: Iterable[str | Path | bytes] | str | Path | bytes, + font_filepaths: Iterable[str | bytes | os.PathLike] | str | bytes | os.PathLike, hinting_factor: int | None = ..., ) -> ft2font.FT2Font: ... diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 55c076bb68b6..98b4b1f7cc4d 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -1,4 +1,5 @@ from enum import Enum, Flag +from os import PathLike import sys from typing import BinaryIO, Literal, NewType, TypeAlias, TypedDict, cast, final, overload from typing_extensions import Buffer # < Py 3.12 @@ -194,7 +195,7 @@ class _SfntPcltDict(TypedDict): class FT2Font(Buffer): def __init__( self, - filename: str | BinaryIO, + filename: str | bytes | PathLike | BinaryIO, hinting_factor: int = ..., *, _fallback_list: list[FT2Font] | None = ..., @@ -256,7 +257,7 @@ class FT2Font(Buffer): @property def family_name(self) -> str: ... @property - def fname(self) -> str: ... + def fname(self) -> str | bytes: ... @property def height(self) -> int: ... @property diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index b15647644e04..d51eb8d9837f 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -1,4 +1,4 @@ -from io import BytesIO, StringIO +from io import BytesIO import gc import multiprocessing import os @@ -137,6 +137,32 @@ def test_find_noto(): fig.savefig(BytesIO(), format=fmt) +def test_find_valid(): + class PathLikeClass: + def __init__(self, filename): + self.filename = filename + + def __fspath__(self): + return self.filename + + file_str = findfont('DejaVu Sans') + file_bytes = os.fsencode(file_str) + + font = get_font(file_str) + assert font.fname == file_str + font = get_font(file_bytes) + assert font.fname == file_bytes + font = get_font(PathLikeClass(file_str)) + assert font.fname == file_str + font = get_font(PathLikeClass(file_bytes)) + assert font.fname == file_bytes + + # Note, fallbacks are not currently accessible. + font = get_font([file_str, file_bytes, + PathLikeClass(file_str), PathLikeClass(file_bytes)]) + assert font.fname == file_str + + def test_find_invalid(tmp_path): with pytest.raises(FileNotFoundError): @@ -148,11 +174,6 @@ def test_find_invalid(tmp_path): with pytest.raises(FileNotFoundError): get_font(bytes(tmp_path / 'non-existent-font-name.ttf')) - # Not really public, but get_font doesn't expose non-filename constructor. - from matplotlib.ft2font import FT2Font - with pytest.raises(TypeError, match='font file or a binary-mode file'): - FT2Font(StringIO()) # type: ignore[arg-type] - @pytest.mark.skipif(sys.platform != 'linux' or not has_fclist, reason='only Linux with fontconfig installed') diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 70e611e17bcc..e78a3894076a 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -1,5 +1,6 @@ import itertools import io +import os from pathlib import Path from typing import cast @@ -134,6 +135,27 @@ def test_ft2font_stix_bold_attrs(): assert font.bbox == (4, -355, 1185, 2095) +def test_ft2font_valid_args(): + class PathLikeClass: + def __init__(self, filename): + self.filename = filename + + def __fspath__(self): + return self.filename + + file_str = fm.findfont('DejaVu Sans') + file_bytes = os.fsencode(file_str) + + font = ft2font.FT2Font(file_str) + assert font.fname == file_str + font = ft2font.FT2Font(file_bytes) + assert font.fname == file_bytes + font = ft2font.FT2Font(PathLikeClass(file_str)) + assert font.fname == file_str + font = ft2font.FT2Font(PathLikeClass(file_bytes)) + assert font.fname == file_bytes + + def test_ft2font_invalid_args(tmp_path): # filename argument. with pytest.raises(TypeError, match='to a font file or a binary-mode file object'): diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 65fcb4b7e013..3471203311b3 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -424,7 +424,7 @@ close_file_callback(FT_Stream stream) const char *PyFT2Font_init__doc__ = R"""( Parameters ---------- - filename : str or file-like + filename : str, bytes, os.PathLike, or io.BinaryIO The source of the font data in a format (ttf or ttc) that FreeType can read. hinting_factor : int, optional @@ -488,7 +488,10 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, open_args.flags = FT_OPEN_STREAM; open_args.stream = &self->stream; - if (py::isinstance(filename) || py::isinstance(filename)) { + auto PathLike = py::module_::import("os").attr("PathLike"); + if (py::isinstance(filename) || py::isinstance(filename) || + py::isinstance(filename, PathLike)) + { self->py_file = py::module_::import("io").attr("open")(filename, "rb"); self->stream.close = &close_file_callback; } else { @@ -511,13 +514,13 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, return self; } -static py::str +static py::object PyFT2Font_fname(PyFT2Font *self) { - if (self->stream.close) { // Called passed a filename to the constructor. + if (self->stream.close) { // User passed a filename to the constructor. return self->py_file.attr("name"); } else { - return py::cast(self->py_file); + return self->py_file; } } From 7ce8eae7264dcef06278d675afd5b23b86c8c93b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 22 Mar 2025 05:15:06 -0400 Subject: [PATCH 033/108] Add language parameter to Text objects --- doc/release/next_whats_new/text_language.rst | 37 +++++++++++++ lib/matplotlib/_text_helpers.py | 7 ++- lib/matplotlib/backends/backend_agg.py | 3 +- lib/matplotlib/backends/backend_pdf.py | 6 ++- lib/matplotlib/backends/backend_ps.py | 3 +- lib/matplotlib/ft2font.pyi | 7 ++- lib/matplotlib/mpl-data/matplotlibrc | 5 ++ lib/matplotlib/rcsetup.py | 1 + lib/matplotlib/tests/test_ft2font.py | 31 +++++++++++ lib/matplotlib/tests/test_text.py | 57 ++++++++++++++++++++ lib/matplotlib/text.py | 38 +++++++++++++ lib/matplotlib/text.pyi | 4 +- lib/matplotlib/textpath.py | 12 +++-- lib/matplotlib/textpath.pyi | 5 +- src/ft2font.cpp | 24 ++++++++- src/ft2font.h | 6 ++- src/ft2font_wrapper.cpp | 22 ++++++-- 17 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 doc/release/next_whats_new/text_language.rst diff --git a/doc/release/next_whats_new/text_language.rst b/doc/release/next_whats_new/text_language.rst new file mode 100644 index 000000000000..1d4668587b43 --- /dev/null +++ b/doc/release/next_whats_new/text_language.rst @@ -0,0 +1,37 @@ +Specifying text language +------------------------ + +OpenType fonts may support language systems which can be used to select different +typographic conventions, e.g., localized variants of letters that share a single Unicode +code point, or different default font features. The text API now supports setting a +language to be used and may be set/get with: + +- `matplotlib.text.Text.set_language` / `matplotlib.text.Text.get_language` +- Any API that creates a `.Text` object by passing the *language* argument (e.g., + ``plt.xlabel(..., language=...)``) + +The language of the text must be in a format accepted by libraqm, namely `a BCP47 +language code `_. If None or +unset, then no particular language will be implied, and default font settings will be +used. + +For example, Matplotlib's default font ``DejaVu Sans`` supports language-specific glyphs +in the Serbian and Macedonian languages in the Cyrillic alphabet (vs Russian), +or the Sámi family of languages in the Latin alphabet (vs English). + +.. plot:: + :include-source: + + fig = plt.figure(figsize=(7, 3)) + + char = '\U00000431' + fig.text(0.5, 0.8, f'\\U{ord(char):08x}', fontsize=40, horizontalalignment='center') + fig.text(0, 0.6, f'Serbian: {char}', fontsize=40, language='sr') + fig.text(1, 0.6, f'Russian: {char}', fontsize=40, language='ru', + horizontalalignment='right') + + char = '\U0000014a' + fig.text(0.5, 0.3, f'\\U{ord(char):08x}', fontsize=40, horizontalalignment='center') + fig.text(0, 0.1, f'Inari Sámi: {char}', fontsize=40, language='smn') + fig.text(1, 0.1, f'English: {char}', fontsize=40, language='en', + horizontalalignment='right') diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index b9471c2c7e39..fa5d36bc99c8 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -26,7 +26,7 @@ def warn_on_missing_glyph(codepoint, fontnames): f"missing from font(s) {fontnames}.") -def layout(string, font, *, kern_mode=Kerning.DEFAULT): +def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None): """ Render *string* with *font*. @@ -41,6 +41,9 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT): The font. kern_mode : Kerning A FreeType kerning mode. + language : str, optional + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. Yields ------ @@ -48,7 +51,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT): """ x = 0 prev_glyph_index = None - char_to_font = font._get_fontmap(string) + char_to_font = font._get_fontmap(string) # TODO: Pass in language. base_font = font for char in string: # This has done the fallback logic diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index feb4b0c8be01..2da422a88e84 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -190,7 +190,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): 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, 0, flags=get_hinting_flag(), + 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 diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index aaacc9589391..ebbc70eb68c8 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2338,6 +2338,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): return self.draw_mathtext(gc, x, y, s, prop, angle) fontsize = prop.get_size_in_points() + language = mtext.get_language() if mtext is not None else None if mpl.rcParams['pdf.use14corefonts']: font = self._get_font_afm(prop) @@ -2348,7 +2349,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): fonttype = mpl.rcParams['pdf.fonttype'] if gc.get_url() is not None: - font.set_text(s) + font.set_text(s, language=language) width, height = font.get_width_height() self.file._annotations[-1][1].append(_get_link_annotation( gc, x, y, width / 64, height / 64, angle)) @@ -2398,7 +2399,8 @@ def output_singlebyte_chunk(kerns_or_chars): prev_start_x = 0 # Emit all the 1-byte characters in a BT/ET group. self.file.output(Op.begin_text) - for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED): + for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED, + language=language): if _font_supports_glyph(fonttype, ord(item.char)): if item.ft_object != prev_font: if singlebyte_chunk: diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index b0180de20f9f..14518a38c4ef 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -791,9 +791,10 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): thisx += width * scale else: + language = mtext.get_language() if mtext is not None else None font = self._get_font_ttf(prop) self._character_tracker.track(font, s) - for item in _text_helpers.layout(s, font): + for item in _text_helpers.layout(s, font, language=language): ps_name = (item.ft_object.postscript_name .encode("ascii", "replace").decode("ascii")) glyph_name = item.ft_object.get_glyph_name(item.glyph_index) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 55c076bb68b6..91d8d6a38818 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -243,7 +243,12 @@ class FT2Font(Buffer): def set_charmap(self, i: int) -> None: ... def set_size(self, ptsize: float, dpi: float) -> None: ... def set_text( - self, string: str, angle: float = ..., flags: LoadFlags = ... + self, + string: str, + angle: float = ..., + flags: LoadFlags = ..., + *, + language: str | list[tuple[str, int, int]] | None = ..., ) -> NDArray[np.float64]: ... @property def ascender(self) -> int: ... diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 223eed396535..66a2569ca6f7 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -292,6 +292,11 @@ ## for more information on text properties #text.color: black +## The language of the text in a format accepted by libraqm, namely `a BCP47 language +## code `_. If None, then no +## particular language will be implied, and default font settings will be used. +#text.language: None + ## FreeType hinting flag ("foo" corresponds to FT_LOAD_FOO); may be one of the ## following (Proprietary Matplotlib-specific synonyms are given in parentheses, ## but their use is discouraged): diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index b4224d169815..586365dcf3f2 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1045,6 +1045,7 @@ def _convert_validator_spec(key, conv): "text.kerning_factor": validate_int_or_None, "text.antialiased": validate_bool, "text.parse_math": validate_bool, + "text.language": validate_string_or_None, "mathtext.cal": validate_font_properties, "mathtext.rm": validate_font_properties, diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 70e611e17bcc..c464dddc051f 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -783,6 +783,37 @@ def test_ft2font_set_text(): assert font.get_bitmap_offset() == (6, 0) +@pytest.mark.parametrize( + 'input', + [ + [1, 2, 3], + [(1, 2)], + [('en', 'foo', 2)], + [('en', 1, 'foo')], + ], + ids=[ + 'nontuple', + 'wrong length', + 'wrong start type', + 'wrong end type', + ], +) +def test_ft2font_language_invalid(input): + file = fm.findfont('DejaVu Sans') + font = ft2font.FT2Font(file, hinting_factor=1) + with pytest.raises(TypeError): + font.set_text('foo', language=input) + + +def test_ft2font_language(): + # This is just a smoke test. + file = fm.findfont('DejaVu Sans') + font = ft2font.FT2Font(file, hinting_factor=1) + font.set_text('foo') + font.set_text('foo', language='en') + font.set_text('foo', language=[('en', 1, 2)]) + + def test_ft2font_loading(): file = fm.findfont('DejaVu Sans') font = ft2font.FT2Font(file, hinting_factor=1) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 8dba63eeef32..4d8a7a59c731 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -1202,3 +1202,60 @@ def test_ytick_rotation_mode(): tick.set_rotation(angle) plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01) + + +@pytest.mark.parametrize( + 'input, match', + [ + ([1, 2, 3], 'must be list of tuple'), + ([(1, 2)], 'must be list of tuple'), + ([('en', 'foo', 2)], 'start location must be int'), + ([('en', 1, 'foo')], 'end location must be int'), + ], +) +def test_text_language_invalid(input, match): + with pytest.raises(TypeError, match=match): + Text(0, 0, 'foo', language=input) + + +@image_comparison(baseline_images=['language.png'], remove_text=False, style='mpl20') +def test_text_language(): + fig = plt.figure(figsize=(5, 3)) + + t = fig.text(0, 0.8, 'Default', fontsize=32) + assert t.get_language() is None + t = fig.text(0, 0.55, 'Lang A', fontsize=32) + assert t.get_language() is None + t = fig.text(0, 0.3, 'Lang B', fontsize=32) + assert t.get_language() is None + t = fig.text(0, 0.05, 'Mixed', fontsize=32) + assert t.get_language() is None + + # DejaVu Sans supports language-specific glyphs in the Serbian and Macedonian + # languages in the Cyrillic alphabet. + cyrillic = '\U00000431' + t = fig.text(0.4, 0.8, cyrillic, fontsize=32) + assert t.get_language() is None + t = fig.text(0.4, 0.55, cyrillic, fontsize=32, language='sr') + assert t.get_language() == 'sr' + t = fig.text(0.4, 0.3, cyrillic, fontsize=32) + t.set_language('ru') + assert t.get_language() == 'ru' + t = fig.text(0.4, 0.05, cyrillic * 4, fontsize=32, + language=[('ru', 0, 1), ('sr', 1, 2), ('ru', 2, 3), ('sr', 3, 4)]) + assert t.get_language() == (('ru', 0, 1), ('sr', 1, 2), ('ru', 2, 3), ('sr', 3, 4)) + + # Or the Sámi family of languages in the Latin alphabet. + latin = '\U0000014a' + t = fig.text(0.7, 0.8, latin, fontsize=32) + assert t.get_language() is None + with plt.rc_context({'text.language': 'en'}): + t = fig.text(0.7, 0.55, latin, fontsize=32) + assert t.get_language() == 'en' + t = fig.text(0.7, 0.3, latin, fontsize=32, language='smn') + assert t.get_language() == 'smn' + # Tuples are not documented, but we'll allow it. + t = fig.text(0.7, 0.05, latin * 4, fontsize=32) + t.set_language((('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4))) + assert t.get_language() == ( + ('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4)) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index acde4fb179a2..4d80f9874941 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -2,6 +2,7 @@ Classes for including text in a figure. """ +from collections.abc import Sequence import functools import logging import math @@ -136,6 +137,7 @@ def __init__(self, super().__init__() self._x, self._y = x, y self._text = '' + self.set_language(None) self._reset_visual_defaults( text=text, color=color, @@ -1422,6 +1424,42 @@ def _va_for_angle(self, angle): return 'baseline' if anchor_at_left else 'top' return 'top' if anchor_at_left else 'baseline' + def get_language(self): + """Return the language this Text is in.""" + return self._language + + def set_language(self, language): + """ + Set the language of the text. + + Parameters + ---------- + language : str or None + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. + + If None, then defaults to :rc:`text.language`. + """ + _api.check_isinstance((Sequence, str, None), language=language) + language = mpl._val_or_rc(language, 'text.language') + + if not cbook.is_scalar_or_string(language): + language = tuple(language) + for val in language: + if not isinstance(val, tuple) or len(val) != 3: + raise TypeError('language must be list of tuple, not {language!r}') + sublang, start, end = val + if not isinstance(sublang, str): + raise TypeError( + 'sub-language specification must be str, not {sublang!r}') + if not isinstance(start, int): + raise TypeError('start location must be int, not {start!r}') + if not isinstance(end, int): + raise TypeError('end location must be int, not {end!r}') + + self._language = language + self.stale = True + class OffsetFrom: """Callable helper class for working with `Annotation`.""" diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index 41c7b761ae32..eb3c076b1c5c 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -14,7 +14,7 @@ from .transforms import ( Transform, ) -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from typing import Any, Literal from .typing import ColorType, CoordsType @@ -108,6 +108,8 @@ class Text(Artist): def set_antialiased(self, antialiased: bool) -> None: ... def _ha_for_angle(self, angle: Any) -> Literal['center', 'right', 'left'] | None: ... def _va_for_angle(self, angle: Any) -> Literal['center', 'top', 'baseline'] | None: ... + def get_language(self) -> str | tuple[tuple[str, int, int], ...] | None: ... + def set_language(self, language: str | Sequence[tuple[str, int, int]] | None) -> None: ... class OffsetFrom: def __init__( diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index 6a24197d3e43..6f6f4daa4cfa 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -67,7 +67,7 @@ def get_text_width_height_descent(self, s, prop, ismath): d /= 64.0 return w * scale, h * scale, d * scale - def get_text_path(self, prop, s, ismath=False): + def get_text_path(self, prop, s, ismath=False, *, language=None): """ Convert text *s* to path (a tuple of vertices and codes for matplotlib.path.Path). @@ -80,6 +80,9 @@ def get_text_path(self, prop, s, ismath=False): The text to be converted. ismath : {False, True, "TeX"} If True, use mathtext parser. If "TeX", use tex for rendering. + language : str, optional + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. Returns ------- @@ -107,7 +110,8 @@ def get_text_path(self, prop, s, ismath=False): glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s) elif not ismath: font = self._get_font(prop) - glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s) + glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s, + language=language) else: glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) @@ -128,7 +132,7 @@ def get_text_path(self, prop, s, ismath=False): return verts, codes def get_glyphs_with_font(self, font, s, glyph_map=None, - return_new_glyphs_only=False): + return_new_glyphs_only=False, *, language=None): """ Convert string *s* to vertices and codes using the provided ttf font. """ @@ -143,7 +147,7 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, xpositions = [] glyph_reprs = [] - for item in _text_helpers.layout(s, font): + for item in _text_helpers.layout(s, font, language=language): glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index) glyph_reprs.append(glyph_repr) xpositions.append(item.x) diff --git a/lib/matplotlib/textpath.pyi b/lib/matplotlib/textpath.pyi index 34d4e92ac47e..b83b337aa541 100644 --- a/lib/matplotlib/textpath.pyi +++ b/lib/matplotlib/textpath.pyi @@ -16,7 +16,8 @@ class TextToPath: self, s: str, prop: FontProperties, ismath: bool | Literal["TeX"] ) -> tuple[float, float, float]: ... def get_text_path( - self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ... + self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ..., *, + language: str | list[tuple[str, int, int]] | None = ..., ) -> list[np.ndarray]: ... def get_glyphs_with_font( self, @@ -24,6 +25,8 @@ class TextToPath: s: str, glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ..., return_new_glyphs_only: bool = ..., + *, + language: str | list[tuple[str, int, int]] | None = ..., ) -> tuple[ list[tuple[str, float, float, float]], dict[str, tuple[np.ndarray, np.ndarray]], diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 890fc61974b0..e3352e901c2b 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -319,7 +319,9 @@ void FT2Font::set_kerning_factor(int factor) } void FT2Font::set_text( - std::u32string_view text, double angle, FT_Int32 flags, std::vector &xys) + std::u32string_view text, double angle, FT_Int32 flags, + LanguageType languages, + std::vector &xys) { FT_Matrix matrix; /* transformation matrix */ @@ -358,6 +360,16 @@ void FT2Font::set_text( if (!raqm_set_freetype_load_flags(rq, flags)) { throw std::runtime_error("failed to set text flags for layout"); } + if (languages) { + for (auto & [lang_str, start, end] : *languages) { + if (!raqm_set_language(rq, lang_str.c_str(), start, end - start)) { + throw std::runtime_error( + "failed to set language between {} and {} characters "_s + "to {!r} for layout"_s.format( + start, end, lang_str)); + } + } + } if (!raqm_layout(rq)) { throw std::runtime_error("failed to layout text"); } @@ -422,6 +434,16 @@ void FT2Font::set_text( if (!raqm_set_freetype_load_flags(rq, flags)) { throw std::runtime_error("failed to set text flags for layout"); } + if (languages) { + for (auto & [lang_str, start, end] : *languages) { + if (!raqm_set_language(rq, lang_str.c_str(), start, end - start)) { + throw std::runtime_error( + "failed to set language between {} and {} characters "_s + "to {!r} for layout"_s.format( + start, end, lang_str)); + } + } + } if (!raqm_layout(rq)) { throw std::runtime_error("failed to layout text"); } diff --git a/src/ft2font.h b/src/ft2font.h index ffaf511ab9ca..b468804b4830 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -100,6 +101,9 @@ extern FT_Library _ft2Library; class FT2Font { public: + using LanguageRange = std::tuple; + using LanguageType = std::optional>; + FT2Font(long hinting_factor, std::vector &fallback_list, bool warn_if_used); virtual ~FT2Font(); @@ -110,7 +114,7 @@ class FT2Font void set_charmap(int i); void select_charmap(unsigned long i); void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags, - std::vector &xys); + LanguageType languages, std::vector &xys); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode); void set_kerning_factor(int factor); void load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool fallback); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 65fcb4b7e013..186bf7864dc2 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -693,7 +693,8 @@ const char *PyFT2Font_set_text__doc__ = R"""( static py::array_t PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0, - std::variant flags_or_int = LoadFlags::FORCE_AUTOHINT) + std::variant flags_or_int = LoadFlags::FORCE_AUTOHINT, + std::variant languages_or_str = nullptr) { std::vector xys; LoadFlags flags; @@ -713,7 +714,21 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 throw py::type_error("flags must be LoadFlags or int"); } - self->set_text(text, angle, static_cast(flags), xys); + FT2Font::LanguageType languages; + if (auto value = std::get_if(&languages_or_str)) { + languages = std::move(*value); + } else if (auto value = std::get_if(&languages_or_str)) { + languages = std::vector{ + FT2Font::LanguageRange{*value, 0, text.size()} + }; + } else { + // NOTE: this can never happen as pybind11 would have checked the type in the + // Python wrapper before calling this function, but we need to keep the + // std::get_if instead of std::get for macOS 10.12 compatibility. + throw py::type_error("languages must be str or list of tuple"); + } + + self->set_text(text, angle, static_cast(flags), languages, xys); py::ssize_t dims[] = { static_cast(xys.size()) / 2, 2 }; py::array_t result(dims); @@ -1534,7 +1549,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_kerning", &PyFT2Font_get_kerning, "left"_a, "right"_a, "mode"_a, PyFT2Font_get_kerning__doc__) .def("set_text", &PyFT2Font_set_text, - "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, + "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(), + "language"_a=nullptr, PyFT2Font_set_text__doc__) .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, PyFT2Font_get_fontmap__doc__) From b35e5cd9ded818b83a9feeb43a41606cf56219a0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 23 May 2025 07:24:32 -0400 Subject: [PATCH 034/108] ft2font: Split layouting from set_text The former may be used even on PS/PDF backend where nothing is rendered. --- src/ft2font.cpp | 52 ++++++++++++++++++++++++++++--------------------- src/ft2font.h | 3 +++ 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index e3352e901c2b..6f3db040f17d 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -318,29 +318,13 @@ void FT2Font::set_kerning_factor(int factor) } } -void FT2Font::set_text( - std::u32string_view text, double angle, FT_Int32 flags, +std::vector FT2Font::layout( + std::u32string_view text, FT_Int32 flags, LanguageType languages, - std::vector &xys) + std::set& glyph_seen_fonts) { - FT_Matrix matrix; /* transformation matrix */ - - angle = angle * (2 * M_PI / 360.0); - - // this computes width and height in subpixels so we have to multiply by 64 - double cosangle = cos(angle) * 0x10000L; - double sinangle = sin(angle) * 0x10000L; - - matrix.xx = (FT_Fixed)cosangle; - matrix.xy = (FT_Fixed)-sinangle; - matrix.yx = (FT_Fixed)sinangle; - matrix.yy = (FT_Fixed)cosangle; - clear(); - bbox.xMin = bbox.yMin = 32000; - bbox.xMax = bbox.yMax = -32000; - auto rq = raqm_create(); if (!rq) { throw std::runtime_error("failed to compute text layout"); @@ -375,7 +359,6 @@ void FT2Font::set_text( } std::vector> face_substitutions; - std::set glyph_seen_fonts; glyph_seen_fonts.insert(face->family_name); // Attempt to use fallback fonts if necessary. @@ -452,9 +435,34 @@ void FT2Font::set_text( size_t num_glyphs = 0; auto const& rq_glyphs = raqm_get_glyphs(rq, &num_glyphs); - for (size_t i = 0; i < num_glyphs; i++) { - auto const& rglyph = rq_glyphs[i]; + return std::vector(rq_glyphs, rq_glyphs + num_glyphs); +} + +void FT2Font::set_text( + std::u32string_view text, double angle, FT_Int32 flags, + LanguageType languages, + std::vector &xys) +{ + FT_Matrix matrix; /* transformation matrix */ + + angle = angle * (2 * M_PI / 360.0); + + // this computes width and height in subpixels so we have to multiply by 64 + double cosangle = cos(angle) * 0x10000L; + double sinangle = sin(angle) * 0x10000L; + + matrix.xx = (FT_Fixed)cosangle; + matrix.xy = (FT_Fixed)-sinangle; + matrix.yx = (FT_Fixed)sinangle; + matrix.yy = (FT_Fixed)cosangle; + + std::set glyph_seen_fonts; + auto rq_glyphs = layout(text, flags, languages, glyph_seen_fonts); + + bbox.xMin = bbox.yMin = 32000; + bbox.xMax = bbox.yMax = -32000; + for (auto const& rglyph : rq_glyphs) { // Warn for missing glyphs. if (rglyph.index == 0) { ft_glyph_warn(text[rglyph.cluster], glyph_seen_fonts); diff --git a/src/ft2font.h b/src/ft2font.h index b468804b4830..841c66cfb5ee 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -113,6 +113,9 @@ class FT2Font void set_size(double ptsize, double dpi); void set_charmap(int i); void select_charmap(unsigned long i); + std::vector layout(std::u32string_view text, FT_Int32 flags, + LanguageType languages, + std::set& glyph_seen_fonts); void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags, LanguageType languages, std::vector &xys); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode); From e422defc007d6b06106a7bdc2dd792d2295c6fd1 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 18 Apr 2025 02:30:06 -0400 Subject: [PATCH 035/108] Remove dead code from Auto{Height,Width}Char --- 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 b85cdffd6d88..594439813b53 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -1531,7 +1531,7 @@ class AutoHeightChar(Hlist): """ def __init__(self, c: str, height: float, depth: float, state: ParserState, - always: bool = False, factor: float | None = None): + factor: float | None = None): alternatives = state.fontset.get_sized_alternatives_for_symbol(state.font, c) x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi) @@ -1568,7 +1568,7 @@ class AutoWidthChar(Hlist): always just return a scaled version of the glyph. """ - def __init__(self, c: str, width: float, state: ParserState, always: bool = False, + def __init__(self, c: str, width: float, state: ParserState, char_class: type[Char] = Char): alternatives = state.fontset.get_sized_alternatives_for_symbol(state.font, c) @@ -2706,7 +2706,7 @@ def sqrt(self, toks: ParseResults) -> T.Any: # the height so it doesn't seem cramped height = body.height - body.shift_amount + 5 * thickness depth = body.depth + body.shift_amount - check = AutoHeightChar(r'\__sqrt__', height, depth, state, always=True) + check = AutoHeightChar(r'\__sqrt__', height, depth, state) height = check.height - check.shift_amount depth = check.depth + check.shift_amount From 3ba2c1321cb565a1e7103d3f9f502132e1a9b324 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 17 Apr 2025 17:04:39 -0400 Subject: [PATCH 036/108] Fix auto-sized glyphs with BaKoMa fonts If the larger glyphs for an auto-sized character in `cmex10` uses a character that is in the `latex_to_bakoma` table, then it will be mapped an extra time into `cmr10` (usually). Thus we end up with a large version of a "normal" character, such as an exclamation point. Instead map these glyphs through the `latex_to_bakoma` table by using their glyph names as "commands". This ensures they don't get double-mapped to the wrong font and fixes the following issues: - slash (/) uses a comma at the larger sizes - right parenthesis uses an exclamation point at the largest size - left and right braces use parentheses at the largest size - right floor uses a percentage sign at the largest size - left ceiling uses an ampersand at the largest size Also, drop the regular size braces, as they are the same as the first `big`-sized version. --- lib/matplotlib/_mathtext.py | 84 ++++++++++----------------- lib/matplotlib/_mathtext_data.py | 71 ++++++++++++++++++++++ lib/matplotlib/tests/test_mathtext.py | 13 ++++- 3 files changed, 114 insertions(+), 54 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 594439813b53..d628b18aebf4 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -482,60 +482,40 @@ def _get_glyph(self, fontname: str, font_class: str, else: return self._stix_fallback._get_glyph(fontname, font_class, sym) - # The Bakoma fonts contain many pre-sized alternatives for the - # delimiters. The AutoSizedChar class will use these alternatives - # and select the best (closest sized) glyph. + # The Bakoma fonts contain many pre-sized alternatives for the delimiters. The + # Auto(Height|Width)Char classes will use these alternatives and select the best + # (closest sized) glyph. + _latex_sizes = ('big', 'Big', 'bigg', 'Bigg') _size_alternatives = { - '(': [('rm', '('), ('ex', '\xa1'), ('ex', '\xb3'), - ('ex', '\xb5'), ('ex', '\xc3')], - ')': [('rm', ')'), ('ex', '\xa2'), ('ex', '\xb4'), - ('ex', '\xb6'), ('ex', '\x21')], - '{': [('cal', '{'), ('ex', '\xa9'), ('ex', '\x6e'), - ('ex', '\xbd'), ('ex', '\x28')], - '}': [('cal', '}'), ('ex', '\xaa'), ('ex', '\x6f'), - ('ex', '\xbe'), ('ex', '\x29')], - # The fourth size of '[' is mysteriously missing from the BaKoMa - # font, so I've omitted it for both '[' and ']' - '[': [('rm', '['), ('ex', '\xa3'), ('ex', '\x68'), - ('ex', '\x22')], - ']': [('rm', ']'), ('ex', '\xa4'), ('ex', '\x69'), - ('ex', '\x23')], - r'\lfloor': [('ex', '\xa5'), ('ex', '\x6a'), - ('ex', '\xb9'), ('ex', '\x24')], - r'\rfloor': [('ex', '\xa6'), ('ex', '\x6b'), - ('ex', '\xba'), ('ex', '\x25')], - r'\lceil': [('ex', '\xa7'), ('ex', '\x6c'), - ('ex', '\xbb'), ('ex', '\x26')], - r'\rceil': [('ex', '\xa8'), ('ex', '\x6d'), - ('ex', '\xbc'), ('ex', '\x27')], - r'\langle': [('ex', '\xad'), ('ex', '\x44'), - ('ex', '\xbf'), ('ex', '\x2a')], - r'\rangle': [('ex', '\xae'), ('ex', '\x45'), - ('ex', '\xc0'), ('ex', '\x2b')], - r'\__sqrt__': [('ex', '\x70'), ('ex', '\x71'), - ('ex', '\x72'), ('ex', '\x73')], - r'\backslash': [('ex', '\xb2'), ('ex', '\x2f'), - ('ex', '\xc2'), ('ex', '\x2d')], - r'/': [('rm', '/'), ('ex', '\xb1'), ('ex', '\x2e'), - ('ex', '\xcb'), ('ex', '\x2c')], - r'\widehat': [('rm', '\x5e'), ('ex', '\x62'), ('ex', '\x63'), - ('ex', '\x64')], - r'\widetilde': [('rm', '\x7e'), ('ex', '\x65'), ('ex', '\x66'), - ('ex', '\x67')], - r'<': [('cal', 'h'), ('ex', 'D')], - r'>': [('cal', 'i'), ('ex', 'E')] - } + '(': [('rm', '('), *[('ex', fr'\__parenleft{s}__') for s in _latex_sizes]], + ')': [('rm', ')'), *[('ex', fr'\__parenright{s}__') for s in _latex_sizes]], + '{': [('ex', fr'\__braceleft{s}__') for s in _latex_sizes], + '}': [('ex', fr'\__braceright{s}__') for s in _latex_sizes], + '[': [('rm', '['), *[('ex', fr'\__bracketleft{s}__') for s in _latex_sizes]], + ']': [('rm', ']'), *[('ex', fr'\__bracketright{s}__') for s in _latex_sizes]], + '<': [('cal', r'\__angbracketleft__'), + *[('ex', fr'\__angbracketleft{s}__') for s in _latex_sizes]], + '>': [('cal', r'\__angbracketright__'), + *[('ex', fr'\__angbracketright{s}__') for s in _latex_sizes]], + r'\lfloor': [('ex', fr'\__floorleft{s}__') for s in _latex_sizes], + r'\rfloor': [('ex', fr'\__floorright{s}__') for s in _latex_sizes], + r'\lceil': [('ex', fr'\__ceilingleft{s}__') for s in _latex_sizes], + r'\rceil': [('ex', fr'\__ceilingright{s}__') for s in _latex_sizes], + r'\__sqrt__': [('ex', fr'\__radical{s}__') for s in _latex_sizes], + r'\backslash': [('ex', fr'\__backslash{s}__') for s in _latex_sizes], + r'/': [('rm', '/'), *[('ex', fr'\__slash{s}__') for s in _latex_sizes]], + r'\widehat': [('rm', '\x5e'), ('ex', r'\__hatwide__'), ('ex', r'\__hatwider__'), + ('ex', r'\__hatwidest__')], + r'\widetilde': [('rm', '\x7e'), ('ex', r'\__tildewide__'), + ('ex', r'\__tildewider__'), ('ex', r'\__tildewidest__')], + } - for alias, target in [(r'\leftparen', '('), - (r'\rightparen', ')'), - (r'\leftbrace', '{'), - (r'\rightbrace', '}'), - (r'\leftbracket', '['), - (r'\rightbracket', ']'), - (r'\{', '{'), - (r'\}', '}'), - (r'\[', '['), - (r'\]', ']')]: + for alias, target in [(r'\leftparen', '('), (r'\rightparen', ')'), + (r'\leftbrace', '{'), (r'\rightbrace', '}'), + (r'\leftbracket', '['), (r'\rightbracket', ']'), + (r'\langle', '<'), (r'\rangle', '>'), + (r'\{', '{'), (r'\}', '}'), + (r'\[', '['), (r'\]', ']')]: _size_alternatives[alias] = _size_alternatives[target] def get_sized_alternatives_for_symbol(self, fontname: str, diff --git a/lib/matplotlib/_mathtext_data.py b/lib/matplotlib/_mathtext_data.py index 0451791e9f26..f8b7c9ac2c33 100644 --- a/lib/matplotlib/_mathtext_data.py +++ b/lib/matplotlib/_mathtext_data.py @@ -36,6 +36,75 @@ '{' : ('cmex10', 0xa9), '}' : ('cmex10', 0xaa), + '\\__angbracketleft__' : ('cmsy10', 0x68), + '\\__angbracketright__' : ('cmsy10', 0x69), + '\\__angbracketleftbig__' : ('cmex10', 0xad), + '\\__angbracketleftBig__' : ('cmex10', 0x44), + '\\__angbracketleftbigg__' : ('cmex10', 0xbf), + '\\__angbracketleftBigg__' : ('cmex10', 0x2a), + '\\__angbracketrightbig__' : ('cmex10', 0xae), + '\\__angbracketrightBig__' : ('cmex10', 0x45), + '\\__angbracketrightbigg__' : ('cmex10', 0xc0), + '\\__angbracketrightBigg__' : ('cmex10', 0x2b), + '\\__backslashbig__' : ('cmex10', 0xb2), + '\\__backslashBig__' : ('cmex10', 0x2f), + '\\__backslashbigg__' : ('cmex10', 0xc2), + '\\__backslashBigg__' : ('cmex10', 0x2d), + '\\__braceleftbig__' : ('cmex10', 0xa9), + '\\__braceleftBig__' : ('cmex10', 0x6e), + '\\__braceleftbigg__' : ('cmex10', 0xbd), + '\\__braceleftBigg__' : ('cmex10', 0x28), + '\\__bracerightbig__' : ('cmex10', 0xaa), + '\\__bracerightBig__' : ('cmex10', 0x6f), + '\\__bracerightbigg__' : ('cmex10', 0xbe), + '\\__bracerightBigg__' : ('cmex10', 0x29), + '\\__bracketleftbig__' : ('cmex10', 0xa3), + '\\__bracketleftBig__' : ('cmex10', 0x68), + '\\__bracketleftbigg__' : ('cmex10', 0x2219), + '\\__bracketleftBigg__' : ('cmex10', 0x22), + '\\__bracketrightbig__' : ('cmex10', 0xa4), + '\\__bracketrightBig__' : ('cmex10', 0x69), + '\\__bracketrightbigg__' : ('cmex10', 0xb8), + '\\__bracketrightBigg__' : ('cmex10', 0x23), + '\\__ceilingleftbig__' : ('cmex10', 0xa7), + '\\__ceilingleftBig__' : ('cmex10', 0x6c), + '\\__ceilingleftbigg__' : ('cmex10', 0xbb), + '\\__ceilingleftBigg__' : ('cmex10', 0x26), + '\\__ceilingrightbig__' : ('cmex10', 0xa8), + '\\__ceilingrightBig__' : ('cmex10', 0x6d), + '\\__ceilingrightbigg__' : ('cmex10', 0xbc), + '\\__ceilingrightBigg__' : ('cmex10', 0x27), + '\\__floorleftbig__' : ('cmex10', 0xa5), + '\\__floorleftBig__' : ('cmex10', 0x6a), + '\\__floorleftbigg__' : ('cmex10', 0xb9), + '\\__floorleftBigg__' : ('cmex10', 0x24), + '\\__floorrightbig__' : ('cmex10', 0xa6), + '\\__floorrightBig__' : ('cmex10', 0x6b), + '\\__floorrightbigg__' : ('cmex10', 0xba), + '\\__floorrightBigg__' : ('cmex10', 0x25), + '\\__hatwide__' : ('cmex10', 0x62), + '\\__hatwider__' : ('cmex10', 0x63), + '\\__hatwidest__' : ('cmex10', 0x64), + '\\__parenleftbig__' : ('cmex10', 0xa1), + '\\__parenleftBig__' : ('cmex10', 0xb3), + '\\__parenleftbigg__' : ('cmex10', 0xb5), + '\\__parenleftBigg__' : ('cmex10', 0xc3), + '\\__parenrightbig__' : ('cmex10', 0xa2), + '\\__parenrightBig__' : ('cmex10', 0xb4), + '\\__parenrightbigg__' : ('cmex10', 0xb6), + '\\__parenrightBigg__' : ('cmex10', 0x21), + '\\__radicalbig__' : ('cmex10', 0x70), + '\\__radicalBig__' : ('cmex10', 0x71), + '\\__radicalbigg__' : ('cmex10', 0x72), + '\\__radicalBigg__' : ('cmex10', 0x73), + '\\__slashbig__' : ('cmex10', 0xb1), + '\\__slashBig__' : ('cmex10', 0x2e), + '\\__slashbigg__' : ('cmex10', 0xc1), + '\\__slashBigg__' : ('cmex10', 0x2c), + '\\__tildewide__' : ('cmex10', 0x65), + '\\__tildewider__' : ('cmex10', 0x66), + '\\__tildewidest__' : ('cmex10', 0x67), + ',' : ('cmmi10', 0x3b), '.' : ('cmmi10', 0x3a), '/' : ('cmmi10', 0x3d), @@ -1112,6 +1181,8 @@ '|' : 0x2016, '}' : 0x7d, } +tex2uni['__angbracketleft__'] = tex2uni['langle'] +tex2uni['__angbracketright__'] = tex2uni['rangle'] # Each element is a 4-tuple of the form: # src_start, src_end, dst_font, dst_start diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 4fc04a627dd5..5d0245bc5049 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -125,12 +125,21 @@ r'$,$ $.$ $1{,}234{, }567{ , }890$ and $1,234,567,890$', # github issue 5799 r'$\left(X\right)_{a}^{b}$', # github issue 7615 r'$\dfrac{\$100.00}{y}$', # github issue #1888 - r'$a=-b-c$' # github issue #28180 + r'$a=-b-c$', # github issue #28180 ] # 'svgastext' tests switch svg output to embed text as text (rather than as # paths). svgastext_math_tests = [ r'$-$-', + # Check all AutoHeightChar substitutions. + *[ + r'$\left' + lc + r' M \middle/ ? \middle\backslash ? \right' + rc + ' ' + # Normal size. + r'\left' + lc + r' \frac{M}{B} \middle/ ? \middle\backslash ? \right' + rc + ' ' + # big size. + r'\left' + lc + r' \frac{\frac{M}{I}}{B} \middle/ ? \middle\backslash ? \right' + rc + ' ' + # bigg size. + r'\left' + lc + r' \frac{\frac{M}{I}}{\frac{B}{U}} \middle/ ? \middle\backslash ? \right' + rc + ' ' + # Big size. + r'\left' + lc + r'\frac{\frac{\frac{M}{I}}{N}}{\frac{\frac{B}{U}}{G}} \middle/ ? \middle\backslash ? \right' + rc + '$' # Bigg size. + for lc, rc in ['()', '[]', '<>', (r'\{', r'\}'), (r'\lfloor', r'\rfloor'), (r'\lceil', r'\rceil')] + ], ] # 'lightweight' tests test only a single fontset (dejavusans, which is the # default) and only png outputs, in order to minimize the size of baseline @@ -237,7 +246,7 @@ def test_mathtext_rendering_svgastext(baseline_images, fontset, index, text): mpl.rcParams['svg.fonttype'] = 'none' # Minimize image size. fig = plt.figure(figsize=(5.25, 0.75)) fig.patch.set(visible=False) # Minimize image size. - fig.text(0.5, 0.5, text, + fig.text(0.5, 0.5, text, fontsize=16, horizontalalignment='center', verticalalignment='center') From b70fb888657ce566950a5f83b3e6e88b4c44404d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 05:21:04 -0400 Subject: [PATCH 037/108] pdf: Improve text with characters outside embedded font limits For character codes outside the embedded font limits (256 for type 3 and 65536 for type 42), we output them as XObjects instead of using text commands. But there is nothing in the PDF spec that requires any specific encoding like this. Since we now support subsetting all fonts before embedding, split each font into groups based on the maximum character code (e.g., 256-entry groups for type 3), then switch text strings to a different font subset and re-map character codes to it when necessary. This means all text is true text (albeit with some strange encoding), and we no longer need any XObjects for glyphs. For users of non-English text, this means it will become selectable and copyable again. Fixes #21797 --- .../deprecations/30512-ES.rst | 3 + lib/matplotlib/backends/backend_pdf.py | 245 +++++------------- 2 files changed, 71 insertions(+), 177 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30512-ES.rst diff --git a/doc/api/next_api_changes/deprecations/30512-ES.rst b/doc/api/next_api_changes/deprecations/30512-ES.rst new file mode 100644 index 000000000000..f235964c5502 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30512-ES.rst @@ -0,0 +1,3 @@ +``PdfFile.multi_byte_charprocs`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... is deprecated with no replacement. diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index ebbc70eb68c8..06fa09793553 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -19,7 +19,6 @@ import sys import time import types -import warnings import zlib import numpy as np @@ -369,19 +368,10 @@ def pdfRepr(obj): "objects") -def _font_supports_glyph(fonttype, glyph): - """ - Returns True if the font is able to provide codepoint *glyph* in a PDF. - - For a Type 3 font, this method returns True only for single-byte - characters. For Type 42 fonts this method return True if the character is - from the Basic Multilingual Plane. - """ - if fonttype == 3: - return glyph <= 255 - if fonttype == 42: - return glyph <= 65535 - raise NotImplementedError() +_FONT_MAX_GLYPH = { + 3: 256, + 42: 65536, +} class Reference: @@ -700,7 +690,8 @@ def __init__(self, filename, metadata=None): self._internal_font_seq = (Name(f'F{i}') for i in itertools.count(1)) self._fontNames = {} # maps filenames to internal font names self._dviFontInfo = {} # maps pdf names to dvifonts - self._character_tracker = _backend_pdf_ps.CharacterTracker() + self._character_tracker = _backend_pdf_ps.CharacterTracker( + _FONT_MAX_GLYPH.get(mpl.rcParams['pdf.fonttype'], 0)) self.alphaStates = {} # maps alpha values to graphics state objects self._alpha_state_seq = (Name(f'A{i}') for i in itertools.count(1)) @@ -715,7 +706,6 @@ def __init__(self, filename, metadata=None): self._image_seq = (Name(f'I{i}') for i in itertools.count(1)) self.markers = {} - self.multi_byte_charprocs = {} self.paths = [] @@ -742,6 +732,7 @@ def __init__(self, filename, metadata=None): self.writeObject(self.resourceObject, resources) fontNames = _api.deprecated("3.11")(property(lambda self: self._fontNames)) + multi_byte_charprocs = _api.deprecated("3.11")(property(lambda _: {})) type1Descriptors = _api.deprecated("3.11")(property(lambda _: {})) @_api.deprecated("3.11") @@ -829,7 +820,7 @@ def toStr(n, base): @staticmethod def _get_subsetted_psname(ps_name, charmap): - return PdfFile._get_subset_prefix(frozenset(charmap.keys())) + ps_name + return PdfFile._get_subset_prefix(frozenset(charmap.values())) + ps_name def finalize(self): """Write out the various deferred objects and the pdf end matter.""" @@ -845,8 +836,6 @@ def finalize(self): name: ob for image, name, ob in self._images.values()} for tup in self.markers.values(): xobjects[tup[0]] = tup[1] - for name, value in self.multi_byte_charprocs.items(): - xobjects[name] = value for name, path, trans, ob, join, cap, padding, filled, stroked \ in self.paths: xobjects[name] = ob @@ -903,7 +892,7 @@ def _write_annotations(self): for annotsObject, annotations in self._annotations: self.writeObject(annotsObject, annotations) - def fontName(self, fontprop): + def fontName(self, fontprop, subset=0): """ Select a font based on fontprop and return a name suitable for ``Op.selectfont``. If fontprop is a string, it will be interpreted @@ -920,13 +909,13 @@ def fontName(self, fontprop): filenames = _fontManager._find_fonts_by_props(fontprop) first_Fx = None for fname in filenames: - Fx = self._fontNames.get(fname) + Fx = self._fontNames.get((fname, subset)) if not first_Fx: first_Fx = Fx if Fx is None: Fx = next(self._internal_font_seq) - self._fontNames[fname] = Fx - _log.debug('Assigning font %s = %r', Fx, fname) + self._fontNames[(fname, subset)] = Fx + _log.debug('Assigning font %s (subset %d) = %r', Fx, subset, fname) if not first_Fx: first_Fx = Fx @@ -950,9 +939,8 @@ def writeFonts(self): for pdfname, dvifont in sorted(self._dviFontInfo.items()): _log.debug('Embedding Type-1 font %s from dvi.', dvifont.texname) fonts[pdfname] = self._embedTeXFont(dvifont) - for filename in sorted(self._fontNames): - Fx = self._fontNames[filename] - _log.debug('Embedding font %s.', filename) + for (filename, subset), Fx in sorted(self._fontNames.items()): + _log.debug('Embedding font %s:%d.', filename, subset) if filename.endswith('.afm'): # from pdf.use14corefonts _log.debug('Writing AFM font.') @@ -960,7 +948,7 @@ def writeFonts(self): else: # a normal TrueType font _log.debug('Writing TrueType font.') - charmap = self._character_tracker.used.get((filename, 0)) + charmap = self._character_tracker.used.get((filename, subset)) if charmap: fonts[Fx] = self.embedTTF(filename, charmap) self.writeObject(self.fontObject, fonts) @@ -1108,13 +1096,6 @@ def createType1Descriptor(self, t1font, fontfile=None): return fontdescObject - def _get_xobject_glyph_name(self, filename, glyph_name): - Fx = self.fontName(filename) - return "-".join([ - Fx.name.decode(), - os.path.splitext(os.path.basename(filename))[0], - glyph_name]) - _identityToUnicodeCMap = b"""/CIDInit /ProcSet findresource begin 12 dict begin begincmap @@ -1159,7 +1140,7 @@ def embedTTFType3(font, charmap, descriptor): fontdictObject = self.reserveObject('font dictionary') charprocsObject = self.reserveObject('character procs') differencesArray = [] - firstchar, lastchar = 0, 255 + firstchar, lastchar = min(charmap), max(charmap) bbox = [cvt(x, nearest=False) for x in font.bbox] fontdict = { @@ -1181,32 +1162,19 @@ def embedTTFType3(font, charmap, descriptor): # Make the "Widths" array def get_char_width(charcode): - width = font.load_char( - charcode, + width = font.load_glyph( + charmap.get(charcode, 0), flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING).horiAdvance return cvt(width) - with warnings.catch_warnings(): - # Ignore 'Required glyph missing from current font' warning - # from ft2font: here we're just building the widths table, but - # the missing glyphs may not even be used in the actual string. - warnings.filterwarnings("ignore") - widths = [get_char_width(charcode) - for charcode in range(firstchar, lastchar+1)] + widths = [get_char_width(charcode) + for charcode in range(firstchar, lastchar+1)] descriptor['MaxWidth'] = max(widths) - # Make the "Differences" array, sort the ccodes < 255 from - # the multi-byte ccodes, and build the whole set of glyph ids - # that we need from this font. - differences = [] - multi_byte_chars = set() - for ccode, gind in charmap.items(): - glyph_name = font.get_glyph_name(gind) - if ccode is not None and ccode <= 255: - differences.append((ccode, glyph_name)) - else: - multi_byte_chars.add(glyph_name) - differences.sort() - + # Make the "Differences" array with the whole set of character codes that we + # need from this font. + differences = sorted([ + (ccode, font.get_glyph_name(gind)) for ccode, gind in charmap.items() + ]) last_c = -2 for c, name in differences: if c != last_c + 1: @@ -1219,30 +1187,9 @@ def get_char_width(charcode): charprocs = {} for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] - charprocDict = {} - # The 2-byte characters are used as XObjects, so they - # need extra info in their dictionary - if charname in multi_byte_chars: - charprocDict = {'Type': Name('XObject'), - 'Subtype': Name('Form'), - 'BBox': bbox} - # Each glyph includes bounding box information, - # but xpdf and ghostscript can't handle it in a - # Form XObject (they segfault!!!), so we remove it - # from the stream here. It's not needed anyway, - # since the Form XObject includes it in its BBox - # value. - stream = stream[stream.find(b"d1") + 2:] charprocObject = self.reserveObject('charProc') - self.outputStream(charprocObject, stream, extra=charprocDict) - - # Send the glyphs with ccode > 255 to the XObject dictionary, - # and the others to the font itself - if charname in multi_byte_chars: - name = self._get_xobject_glyph_name(filename, charname) - self.multi_byte_charprocs[name] = charprocObject - else: - charprocs[charname] = charprocObject + self.outputStream(charprocObject, stream) + charprocs[charname] = charprocObject # Write everything out self.writeObject(fontdictObject, fontdict) @@ -1271,9 +1218,6 @@ def embedTTFType42(font, charmap, descriptor): os.stat(filename).st_size, fontdata.getbuffer().nbytes ) - # We need this ref for XObjects - full_font = font - # reload the font object from the subset # (all the necessary data could probably be obtained directly # using fontLib.ttLib) @@ -1351,32 +1295,6 @@ def embedTTFType42(font, charmap, descriptor): unicode_cmap = (self._identityToUnicodeCMap % (len(unicode_groups), b"\n".join(unicode_bfrange))) - # Add XObjects for unsupported chars - glyph_indices = [ - glyph_index for ccode, glyph_index in charmap.items() - if not _font_supports_glyph(fonttype, ccode) - ] - - bbox = [cvt(x, nearest=False) for x in full_font.bbox] - rawcharprocs = _get_pdf_charprocs(filename, glyph_indices) - for charname in sorted(rawcharprocs): - stream = rawcharprocs[charname] - charprocDict = {'Type': Name('XObject'), - 'Subtype': Name('Form'), - 'BBox': bbox} - # Each glyph includes bounding box information, - # but xpdf and ghostscript can't handle it in a - # Form XObject (they segfault!!!), so we remove it - # from the stream here. It's not needed anyway, - # since the Form XObject includes it in its BBox - # value. - stream = stream[stream.find(b"d1") + 2:] - charprocObject = self.reserveObject('charProc') - self.outputStream(charprocObject, stream, extra=charprocDict) - - name = self._get_xobject_glyph_name(filename, charname) - self.multi_byte_charprocs[name] = charprocObject - # CIDToGIDMap stream cid_to_gid_map = "".join(cid_to_gid_map).encode("utf-16be") self.outputStream(cidToGidMapObject, cid_to_gid_map) @@ -1396,10 +1314,7 @@ def embedTTFType42(font, charmap, descriptor): # Beginning of main embedTTF function... - ps_name = self._get_subsetted_psname( - font.postscript_name, - font.get_charmap() - ) + ps_name = self._get_subsetted_psname(font.postscript_name, charmap) ps_name = ps_name.encode('ascii', 'replace') ps_name = Name(ps_name) pclt = font.get_sfnt_table('pclt') or {'capHeight': 0, 'xHeight': 0} @@ -2203,30 +2118,22 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): self.check_gc(gc, gc._rgb) prev_font = None, None oldx, oldy = 0, 0 - unsupported_chars = [] self.file.output(Op.begin_text) for font, fontsize, ccode, glyph_index, ox, oy in glyphs: - self.file._character_tracker.track_glyph(font, ccode, glyph_index) + subset_index, subset_charcode = self.file._character_tracker.track_glyph( + font, ccode, glyph_index) fontname = font.fname - if not _font_supports_glyph(fonttype, ccode): - # Unsupported chars (i.e. multibyte in Type 3 or beyond BMP in - # Type 42) must be emitted separately (below). - unsupported_chars.append((font, fontsize, ox, oy, glyph_index)) - else: - self._setup_textpos(ox, oy, 0, oldx, oldy) - oldx, oldy = ox, oy - if (fontname, fontsize) != prev_font: - self.file.output(self.file.fontName(fontname), fontsize, - Op.selectfont) - prev_font = fontname, fontsize - self.file.output(self.encode_string(chr(ccode), fonttype), - Op.show) + self._setup_textpos(ox, oy, 0, oldx, oldy) + oldx, oldy = ox, oy + if (fontname, subset_index, fontsize) != prev_font: + self.file.output(self.file.fontName(fontname, subset_index), fontsize, + Op.selectfont) + prev_font = fontname, subset_index, fontsize + self.file.output(self._encode_glyphs([subset_charcode], fonttype), + Op.show) self.file.output(Op.end_text) - for font, fontsize, ox, oy, glyph_index in unsupported_chars: - self._draw_xobject_glyph(font, fontsize, glyph_index, ox, oy) - # Draw any horizontal lines in the math layout for ox, oy, width, height in rects: self.file.output(Op.gsave, ox, oy, width, height, @@ -2319,6 +2226,11 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): [0, 0]], pathops) self.draw_path(boxgc, path, mytrans, gc._rgb) + def _encode_glyphs(self, subset, fonttype): + if fonttype in (1, 3): + return bytes(subset) + return b''.join(glyph.to_bytes(2, 'big') for glyph in subset) + def encode_string(self, s, fonttype): match fonttype: case 1: @@ -2345,7 +2257,6 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): fonttype = 1 else: font = self._get_font_ttf(prop) - self.file._character_tracker.track(font, s) fonttype = mpl.rcParams['pdf.fonttype'] if gc.get_url() is not None: @@ -2365,23 +2276,23 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # A sequence of characters is broken into multiple chunks. The chunking # serves two purposes: - # - For Type 3 fonts, there is no way to access multibyte characters, - # as they cannot have a CIDMap. Therefore, in this case we break - # the string into chunks, where each chunk contains either a string - # of consecutive 1-byte characters or a single multibyte character. - # - A sequence of 1-byte characters is split into chunks to allow for - # kerning adjustments between consecutive chunks. + # - For Type 3 fonts, there is no way to access multibyte characters, as they + # cannot have a CIDMap. Therefore, in this case we break the string into + # chunks, where each chunk contains a string of consecutive 1-byte + # characters in a 256-character subset of the font. A distinct version of + # the original font is created for each 256-character subset. + # - A sequence of characters is split into chunks to allow for kerning + # adjustments between consecutive chunks. # - # Each chunk is emitted with a separate command: 1-byte characters use - # the regular text show command (TJ) with appropriate kerning between - # chunks, whereas multibyte characters use the XObject command (Do). + # Each chunk is emitted with the regular text show command (TJ) with appropriate + # kerning between chunks. else: def output_singlebyte_chunk(kerns_or_chars): self.file.output( # See pdf spec "Text space details" for the 1000/fontsize # (aka. 1000/T_fs) factor. [(-1000 * next(group) / fontsize) if tp == float # a kern - else self.encode_string("".join(group), fonttype) + else self._encode_glyphs(group, fonttype) for tp, group in itertools.groupby(kerns_or_chars, type)], Op.showkern) # Do the rotation and global translation as a single matrix @@ -2393,51 +2304,31 @@ def output_singlebyte_chunk(kerns_or_chars): x, y, Op.concat_matrix) # List of [prev_kern, char, char, ...] w/o zero kerns. singlebyte_chunk = [] - # List of (ft_object, start_x, glyph_index). - multibyte_glyphs = [] prev_font = None prev_start_x = 0 - # Emit all the 1-byte characters in a BT/ET group. + # Emit all the characters in a BT/ET group. self.file.output(Op.begin_text) for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED, language=language): - if _font_supports_glyph(fonttype, ord(item.char)): - if item.ft_object != prev_font: - if singlebyte_chunk: - output_singlebyte_chunk(singlebyte_chunk) - ft_name = self.file.fontName(item.ft_object.fname) - self.file.output(ft_name, fontsize, Op.selectfont) - self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0) - singlebyte_chunk = [] - prev_font = item.ft_object - prev_start_x = item.x - if item.prev_kern: - singlebyte_chunk.append(item.prev_kern) - singlebyte_chunk.append(item.char) - else: - prev_font = None - multibyte_glyphs.append((item.ft_object, item.x, item.glyph_index)) + subset, charcode = self.file._character_tracker.track_glyph( + item.ft_object, ord(item.char), item.glyph_index) + if (item.ft_object, subset) != prev_font: + if singlebyte_chunk: + output_singlebyte_chunk(singlebyte_chunk) + ft_name = self.file.fontName(item.ft_object.fname, subset) + self.file.output(ft_name, fontsize, Op.selectfont) + self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0) + singlebyte_chunk = [] + prev_font = (item.ft_object, subset) + prev_start_x = item.x + if item.prev_kern: + singlebyte_chunk.append(item.prev_kern) + singlebyte_chunk.append(charcode) if singlebyte_chunk: output_singlebyte_chunk(singlebyte_chunk) self.file.output(Op.end_text) - # Then emit all the multibyte characters, one at a time. - for ft_object, start_x, glyph_index in multibyte_glyphs: - self._draw_xobject_glyph( - ft_object, fontsize, glyph_index, start_x, 0 - ) self.file.output(Op.grestore) - def _draw_xobject_glyph(self, font, fontsize, glyph_index, x, y): - """Draw a multibyte character from a Type 3 font as an XObject.""" - glyph_name = font.get_glyph_name(glyph_index) - name = self.file._get_xobject_glyph_name(font.fname, glyph_name) - self.file.output( - Op.gsave, - 0.001 * fontsize, 0, 0, 0.001 * fontsize, x, y, Op.concat_matrix, - Name(name), Op.use_xobject, - Op.grestore, - ) - def new_gc(self): # docstring inherited return GraphicsContextPdf(self.file) From 1c4af68657f0017055beada1c7172666cf147001 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 01:17:42 -0400 Subject: [PATCH 038/108] pdf: Correct Unicode mapping for out-of-range font chunks For Type 3 fonts, add a `ToUnicode` mapping (which was added in PDF 1.2), and for Type 42 fonts, correct the Unicode encoding, which should be UTF-16BE, not UCS2. --- lib/matplotlib/backends/_backend_pdf_ps.py | 19 ++++++ lib/matplotlib/backends/backend_pdf.py | 76 +++++++++++++--------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 1fdcccbab61a..1dde801d8665 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -205,6 +205,25 @@ def track_glyph( self.used.setdefault((font.fname, subset), {})[subset_charcode] = glyph return (subset, subset_charcode) + def subset_to_unicode(self, index: int, + charcode: CharacterCodeType) -> CharacterCodeType: + """ + Map a subset index and character code to a Unicode character code. + + Parameters + ---------- + index : int + The subset index within a font. + charcode : CharacterCodeType + The character code within a subset to map back. + + Returns + ------- + CharacterCodeType + The Unicode character code corresponding to the subsetted one. + """ + return index * self.subset_size + charcode + class RendererPDFPSBase(RendererBase): # The following attributes must be defined by the subclasses: diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 06fa09793553..0f7720b1022f 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -950,7 +950,7 @@ def writeFonts(self): _log.debug('Writing TrueType font.') charmap = self._character_tracker.used.get((filename, subset)) if charmap: - fonts[Fx] = self.embedTTF(filename, charmap) + fonts[Fx] = self.embedTTF(filename, subset, charmap) self.writeObject(self.fontObject, fonts) def _write_afm_font(self, filename): @@ -1117,7 +1117,7 @@ def createType1Descriptor(self, t1font, fontfile=None): end end""" - def embedTTF(self, filename, charmap): + def embedTTF(self, filename, subset_index, charmap): """Embed the TTF font from the named file into the document.""" font = get_font(filename) fonttype = mpl.rcParams['pdf.fonttype'] @@ -1133,12 +1133,40 @@ def cvt(length, upe=font.units_per_EM, nearest=True): else: return math.ceil(value) - def embedTTFType3(font, charmap, descriptor): + def generate_unicode_cmap(subset_index, charmap): + # Make the ToUnicode CMap. + last_ccode = -2 + unicode_groups = [] + for ccode in sorted(charmap.keys()): + if ccode != last_ccode + 1: + unicode_groups.append([ccode, ccode]) + else: + unicode_groups[-1][1] = ccode + last_ccode = ccode + + width = 2 if fonttype == 3 else 4 + unicode_bfrange = [] + for start, end in unicode_groups: + real_start = self._character_tracker.subset_to_unicode(subset_index, + start) + real_end = self._character_tracker.subset_to_unicode(subset_index, end) + real_values = ' '.join('<%s>' % chr(x).encode('utf-16be').hex() + for x in range(real_start, real_end+1)) + unicode_bfrange.append( + f'<{start:0{width}x}> <{end:0{width}x}> [{real_values}]') + unicode_cmap = (self._identityToUnicodeCMap % + (len(unicode_groups), + '\n'.join(unicode_bfrange).encode('ascii'))) + + return unicode_cmap + + def embedTTFType3(font, subset_index, charmap, descriptor): """The Type 3-specific part of embedding a Truetype font""" widthsObject = self.reserveObject('font widths') fontdescObject = self.reserveObject('font descriptor') fontdictObject = self.reserveObject('font dictionary') charprocsObject = self.reserveObject('character procs') + toUnicodeMapObject = self.reserveObject('ToUnicode map') differencesArray = [] firstchar, lastchar = min(charmap), max(charmap) bbox = [cvt(x, nearest=False) for x in font.bbox] @@ -1157,8 +1185,9 @@ def embedTTFType3(font, charmap, descriptor): 'Encoding': { 'Type': Name('Encoding'), 'Differences': differencesArray}, - 'Widths': widthsObject - } + 'Widths': widthsObject, + 'ToUnicode': toUnicodeMapObject, + } # Make the "Widths" array def get_char_width(charcode): @@ -1191,15 +1220,18 @@ def get_char_width(charcode): self.outputStream(charprocObject, stream) charprocs[charname] = charprocObject + unicode_cmap = generate_unicode_cmap(subset_index, charmap) + # Write everything out self.writeObject(fontdictObject, fontdict) self.writeObject(fontdescObject, descriptor) self.writeObject(widthsObject, widths) self.writeObject(charprocsObject, charprocs) + self.outputStream(toUnicodeMapObject, unicode_cmap) return fontdictObject - def embedTTFType42(font, charmap, descriptor): + def embedTTFType42(font, subset_index, charmap, descriptor): """The Type 42-specific part of embedding a Truetype font""" fontdescObject = self.reserveObject('font descriptor') cidFontDictObject = self.reserveObject('CID font dictionary') @@ -1209,12 +1241,12 @@ def embedTTFType42(font, charmap, descriptor): wObject = self.reserveObject('Type 0 widths') toUnicodeMapObject = self.reserveObject('ToUnicode map') - _log.debug("SUBSET %s characters: %s", filename, charmap) + _log.debug("SUBSET %s:%d characters: %s", filename, subset_index, charmap) with _backend_pdf_ps.get_glyphs_subset(filename, charmap.values()) as subset: fontdata = _backend_pdf_ps.font_as_file(subset) _log.debug( - "SUBSET %s %d -> %d", filename, + "SUBSET %s:%d %d -> %d", filename, subset_index, os.stat(filename).st_size, fontdata.getbuffer().nbytes ) @@ -1251,8 +1283,7 @@ def embedTTFType42(font, charmap, descriptor): fontfileObject, fontdata.getvalue(), extra={'Length1': fontdata.getbuffer().nbytes}) - # Make the 'W' (Widths) array, CidToGidMap and ToUnicode CMap - # at the same time + # Make the 'W' (Widths) array and CidToGidMap at the same time. cid_to_gid_map = ['\0'] * 65536 widths = [] max_ccode = 0 @@ -1260,8 +1291,7 @@ def embedTTFType42(font, charmap, descriptor): glyph = font.load_glyph(gind, flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) widths.append((ccode, cvt(glyph.horiAdvance))) - if ccode < 65536: - cid_to_gid_map[ccode] = chr(gind) + cid_to_gid_map[ccode] = chr(gind) max_ccode = max(ccode, max_ccode) widths.sort() cid_to_gid_map = cid_to_gid_map[:max_ccode + 1] @@ -1269,37 +1299,21 @@ def embedTTFType42(font, charmap, descriptor): last_ccode = -2 w = [] max_width = 0 - unicode_groups = [] for ccode, width in widths: if ccode != last_ccode + 1: w.append(ccode) w.append([width]) - unicode_groups.append([ccode, ccode]) else: w[-1].append(width) - unicode_groups[-1][1] = ccode max_width = max(max_width, width) last_ccode = ccode - unicode_bfrange = [] - for start, end in unicode_groups: - # Ensure the CID map contains only chars from BMP - if start > 65535: - continue - end = min(65535, end) - - unicode_bfrange.append( - b"<%04x> <%04x> [%s]" % - (start, end, - b" ".join(b"<%04x>" % x for x in range(start, end+1)))) - unicode_cmap = (self._identityToUnicodeCMap % - (len(unicode_groups), b"\n".join(unicode_bfrange))) - # CIDToGIDMap stream cid_to_gid_map = "".join(cid_to_gid_map).encode("utf-16be") self.outputStream(cidToGidMapObject, cid_to_gid_map) # ToUnicode CMap + unicode_cmap = generate_unicode_cmap(subset_index, charmap) self.outputStream(toUnicodeMapObject, unicode_cmap) descriptor['MaxWidth'] = max_width @@ -1355,9 +1369,9 @@ def embedTTFType42(font, charmap, descriptor): } if fonttype == 3: - return embedTTFType3(font, charmap, descriptor) + return embedTTFType3(font, subset_index, charmap, descriptor) elif fonttype == 42: - return embedTTFType42(font, charmap, descriptor) + return embedTTFType42(font, subset_index, charmap, descriptor) def alphaState(self, alpha): """Return name of an ExtGState that sets alpha to the given value.""" From 6cedcf7094696567e9ee761a43d2935b3c5bb577 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 01:57:17 -0400 Subject: [PATCH 039/108] TST: Add emoji to multi-font text These characters are outside the BMP and should test subset splitting for type 42 output in PDF. --- lib/matplotlib/testing/__init__.py | 4 +++- lib/matplotlib/tests/test_backend_svg.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 6a9351ede7f6..d3a6265fab3b 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -276,11 +276,13 @@ def _gen_multi_font_text(): latin1_supplement = [chr(x) for x in range(start, 0xFF+1)] latin_extended_A = [chr(x) for x in range(0x100, 0x17F+1)] latin_extended_B = [chr(x) for x in range(0x180, 0x24F+1)] + non_basic_multilingual_plane = [chr(x) for x in range(0x1F600, 0x1F610)] count = itertools.count(start - 0xA0) non_basic_characters = '\n'.join( ''.join(line) for _, line in itertools.groupby( # Replace with itertools.batched for Py3.12+. - [*latin1_supplement, *latin_extended_A, *latin_extended_B], + [*latin1_supplement, *latin_extended_A, *latin_extended_B, + *non_basic_multilingual_plane], key=lambda x: next(count) // 32) # 32 characters per line. ) test_str = f"""There are basic characters diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index e865dbbe92da..bcac62854580 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -526,7 +526,7 @@ def test_svg_metadata(): @image_comparison(["multi_font_aspath.svg"]) -def test_multi_font_type3(): +def test_multi_font_aspath(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('svg', fonttype='path') @@ -537,7 +537,7 @@ def test_multi_font_type3(): @image_comparison(["multi_font_astext.svg"]) -def test_multi_font_type42(): +def test_multi_font_astext(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('svg', fonttype='none') From c908bbfcc05b74766d98848dc617997582ed2d13 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 19 Sep 2025 03:01:02 -0400 Subject: [PATCH 040/108] DOC: Add a release note for PDF font embedding fixes --- doc/release/next_whats_new/pdf_fonts.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 doc/release/next_whats_new/pdf_fonts.rst diff --git a/doc/release/next_whats_new/pdf_fonts.rst b/doc/release/next_whats_new/pdf_fonts.rst new file mode 100644 index 000000000000..4d8665386a72 --- /dev/null +++ b/doc/release/next_whats_new/pdf_fonts.rst @@ -0,0 +1,10 @@ +Improved font embedding in PDF +------------------------------ + +Both Type 3 and Type 42 fonts (see :ref:`fonts` for more details) are now +embedded into PDFs without limitation. Fonts may be split into multiple +embedded subsets in order to satisfy format limits. Additionally, a corrected +Unicode mapping is added for each. + +This means that *all* text should now be selectable and copyable in PDF viewers +that support doing so. From 50f76ffb9661450fb8818bf9937f849df0beea84 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 26 Sep 2025 22:34:18 -0400 Subject: [PATCH 041/108] Deduplicate `CharacterTracker.track` implementation No need to repeat the calculation of subset blocks, but instead offload it to `track_glyph`. --- lib/matplotlib/backends/_backend_pdf_ps.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 1dde801d8665..2c34304cea3a 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -157,20 +157,10 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: whole). If *subset_size* is not specified, then the subset will always be 0 and the character codes will be returned from the string unchanged. """ - font_glyphs = [] - char_to_font = font._get_fontmap(s) - for _c, _f in char_to_font.items(): - charcode = ord(_c) - glyph_index = _f.get_char_index(charcode) - if self.subset_size != 0: - subset = charcode // self.subset_size - subset_charcode = charcode % self.subset_size - else: - subset = 0 - subset_charcode = charcode - self.used.setdefault((_f.fname, subset), {})[subset_charcode] = glyph_index - font_glyphs.append((subset, subset_charcode)) - return font_glyphs + return [ + self.track_glyph(f, ord(c), f.get_char_index(ord(c))) + for c, f in font._get_fontmap(s).items() + ] def track_glyph( self, font: FT2Font, charcode: CharacterCodeType, From 8274e1733a0deb3fd3ca81c3ee05a97f2d59c81f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 27 Sep 2025 04:22:40 -0400 Subject: [PATCH 042/108] pdf/ps: Compress subsetted font blocks Instead of splitting fonts into `subset_size` blocks and writing text as character code modulo `subset_size`, compress the blocks by doing two things: 1. Preserve the character code if it lies in the first block. This keeps ASCII (for Type 3) and the Basic Multilingual Plane (for Type 42) as their normal codes. 2. Push everything else into the next spot in the next block, splitting by `subset_size` as necessary. This should reduce the number of additional font subsets to embed. --- lib/matplotlib/backends/_backend_pdf_ps.py | 102 ++++++++++++++++++--- lib/matplotlib/backends/backend_pdf.py | 24 +++-- lib/matplotlib/backends/backend_ps.py | 28 +++--- 3 files changed, 116 insertions(+), 38 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 2c34304cea3a..381f7477278c 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -103,6 +103,58 @@ def font_as_file(font): return fh +class GlyphMap: + """ + A two-way glyph mapping. + + The forward glyph map is from (character code, glyph index)-pairs to (subset index, + subset character code)-pairs. + + The inverse glyph map is from to (subset index, subset character code)-pairs to + (character code, glyph index)-pairs. + """ + + def __init__(self) -> None: + self._forward: dict[tuple[CharacterCodeType, GlyphIndexType], + tuple[int, CharacterCodeType]] = {} + self._inverse: dict[tuple[int, CharacterCodeType], + tuple[CharacterCodeType, GlyphIndexType]] = {} + + def get(self, charcode: CharacterCodeType, + glyph_index: GlyphIndexType) -> tuple[int, CharacterCodeType] | None: + """ + Get the forward mapping from a (character code, glyph index)-pair. + + This may return *None* if the pair is not currently mapped. + """ + return self._forward.get((charcode, glyph_index)) + + def iget(self, subset: int, + subset_charcode: CharacterCodeType) -> tuple[CharacterCodeType, + GlyphIndexType]: + """Get the inverse mapping from a (subset, subset charcode)-pair.""" + return self._inverse[(subset, subset_charcode)] + + def add(self, charcode: CharacterCodeType, glyph_index: GlyphIndexType, subset: int, + subset_charcode: CharacterCodeType) -> None: + """ + Add a mapping to this instance. + + Parameters + ---------- + charcode : CharacterCodeType + The character code to record. + glyph : GlyphIndexType + The corresponding glyph index to record. + subset : int + The subset in which the subset character code resides. + subset_charcode : CharacterCodeType + The subset character code within the above subset. + """ + self._forward[(charcode, glyph_index)] = (subset, subset_charcode) + self._inverse[(subset, subset_charcode)] = (charcode, glyph_index) + + class CharacterTracker: """ Helper for font subsetting by the PDF and PS backends. @@ -114,16 +166,20 @@ class CharacterTracker: ---------- subset_size : int The size at which characters are grouped into subsets. - used : dict[tuple[str, int], dict[CharacterCodeType, GlyphIndexType]] + used : dict A dictionary of font files to character maps. - The key is a font filename and subset within that font. + The key is a font filename. - The value is a dictionary mapping a character code to a glyph index. Note this - mapping is the inverse of FreeType, which maps glyph indices to character codes. + The value is a list of dictionaries, each mapping at most *subset_size* + character codes to glyph indices. Note this mapping is the inverse of FreeType, + which maps glyph indices to character codes. If *subset_size* is not set, then there will only be one subset per font filename. + glyph_maps : dict + A dictionary of font files to glyph maps. You probably will want to use the + `.subset_to_unicode` method instead of this attribute. """ def __init__(self, subset_size: int = 0): @@ -134,7 +190,8 @@ def __init__(self, subset_size: int = 0): The maximum size that is supported for an embedded font. If provided, then characters will be grouped into these sized subsets. """ - self.used: dict[tuple[str, int], dict[CharacterCodeType, GlyphIndexType]] = {} + self.used: dict[str, list[dict[CharacterCodeType, GlyphIndexType]]] = {} + self.glyph_maps: dict[str, GlyphMap] = {} self.subset_size = subset_size def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: @@ -186,25 +243,42 @@ def track_glyph( The character code within the above subset. If *subset_size* was not specified on this instance, then this is just *charcode* unmodified. """ - if self.subset_size != 0: - subset = charcode // self.subset_size - subset_charcode = charcode % self.subset_size + glyph_map = self.glyph_maps.setdefault(font.fname, GlyphMap()) + if result := glyph_map.get(charcode, glyph): + return result + + subset_maps = self.used.setdefault(font.fname, [{}]) + # Default to preserving the character code as it was. + use_next_charmap = ( + self.subset_size != 0 + # But start filling a new subset if outside the first block; this preserves + # ASCII (for Type 3) or the Basic Multilingual Plane (for Type 42). + and charcode >= self.subset_size + ) + if use_next_charmap: + if len(subset_maps) == 1 or len(subset_maps[-1]) == self.subset_size: + subset_maps.append({}) + subset = len(subset_maps) - 1 + subset_charcode = len(subset_maps[-1]) else: subset = 0 subset_charcode = charcode - self.used.setdefault((font.fname, subset), {})[subset_charcode] = glyph + subset_maps[subset][subset_charcode] = glyph + glyph_map.add(charcode, glyph, subset, subset_charcode) return (subset, subset_charcode) - def subset_to_unicode(self, index: int, - charcode: CharacterCodeType) -> CharacterCodeType: + def subset_to_unicode(self, fontname: str, subset: int, + subset_charcode: CharacterCodeType) -> CharacterCodeType: """ Map a subset index and character code to a Unicode character code. Parameters ---------- - index : int + fontname : str + The name of the font, from the *used* dictionary key. + subset : int The subset index within a font. - charcode : CharacterCodeType + subset_charcode : CharacterCodeType The character code within a subset to map back. Returns @@ -212,7 +286,7 @@ def subset_to_unicode(self, index: int, CharacterCodeType The Unicode character code corresponding to the subsetted one. """ - return index * self.subset_size + charcode + return self.glyph_maps[fontname].iget(subset, subset_charcode)[0] class RendererPDFPSBase(RendererBase): diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 0f7720b1022f..fa2131e53899 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -948,9 +948,8 @@ def writeFonts(self): else: # a normal TrueType font _log.debug('Writing TrueType font.') - charmap = self._character_tracker.used.get((filename, subset)) - if charmap: - fonts[Fx] = self.embedTTF(filename, subset, charmap) + charmap = self._character_tracker.used[filename][subset] + fonts[Fx] = self.embedTTF(filename, subset, charmap) self.writeObject(self.fontObject, fonts) def _write_afm_font(self, filename): @@ -992,8 +991,11 @@ def _embedTeXFont(self, dvifont): # Reduce the font to only the glyphs used in the document, get the encoding # for that subset, and compute various properties based on the encoding. - charmap = self._character_tracker.used[(dvifont.fname, 0)] - chars = frozenset(charmap.keys()) + charmap = self._character_tracker.used[dvifont.fname][0] + chars = { + self._character_tracker.subset_to_unicode(dvifont.fname, 0, ccode) + for ccode in charmap + } t1font = t1font.subset(chars, self._get_subset_prefix(charmap.values())) fontdict['BaseFont'] = Name(t1font.prop['FontName']) # createType1Descriptor writes the font data as a side effect @@ -1144,14 +1146,16 @@ def generate_unicode_cmap(subset_index, charmap): unicode_groups[-1][1] = ccode last_ccode = ccode + def _to_unicode(ccode): + real_ccode = self._character_tracker.subset_to_unicode( + filename, subset_index, ccode) + unicodestr = chr(real_ccode).encode('utf-16be').hex() + return f'<{unicodestr}>' + width = 2 if fonttype == 3 else 4 unicode_bfrange = [] for start, end in unicode_groups: - real_start = self._character_tracker.subset_to_unicode(subset_index, - start) - real_end = self._character_tracker.subset_to_unicode(subset_index, end) - real_values = ' '.join('<%s>' % chr(x).encode('utf-16be').hex() - for x in range(real_start, real_end+1)) + real_values = ' '.join(_to_unicode(x) for x in range(start, end+1)) unicode_bfrange.append( f'<{start:0{width}x}> <{end:0{width}x}> [{real_values}]') unicode_cmap = (self._identityToUnicodeCMap % diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 14518a38c4ef..fe32ea7d2559 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -1065,24 +1065,24 @@ def print_figure_impl(fh): Ndict = len(_psDefs) print("%%BeginProlog", file=fh) if not mpl.rcParams['ps.useafm']: - Ndict += len(ps_renderer._character_tracker.used) + Ndict += sum(map(len, ps_renderer._character_tracker.used.values())) print("/mpldict %d dict def" % Ndict, file=fh) print("mpldict begin", file=fh) print("\n".join(_psDefs), file=fh) if not mpl.rcParams['ps.useafm']: - for (font, subset_index), charmap in \ - ps_renderer._character_tracker.used.items(): - if not charmap: - continue - fonttype = mpl.rcParams['ps.fonttype'] - # Can't use more than 255 chars from a single Type 3 font. - if len(charmap) > 255: - fonttype = 42 - fh.flush() - if fonttype == 3: - fh.write(_font_to_ps_type3(font, charmap.values())) - else: # Type 42 only. - _font_to_ps_type42(font, charmap.values(), fh) + for font, subsets in ps_renderer._character_tracker.used.items(): + for charmap in subsets: + if not charmap: + continue + fonttype = mpl.rcParams['ps.fonttype'] + # Can't use more than 255 chars from a single Type 3 font. + if len(charmap) > 255: + fonttype = 42 + fh.flush() + if fonttype == 3: + fh.write(_font_to_ps_type3(font, charmap.values())) + else: # Type 42 only. + _font_to_ps_type42(font, charmap.values(), fh) print("end", file=fh) print("%%EndProlog", file=fh) From 70dc3880a7f04dd92ea18974a995034af95b60ab Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 27 Sep 2025 04:35:05 -0400 Subject: [PATCH 043/108] pdf: Fix first-block characters using multiple glyph representations If mixing languages, sometimes a single character may use different glyphs in one document. In that case, we need to give it a new character code in the next subset, since subset 0 is preserving character codes. --- lib/matplotlib/backends/_backend_pdf_ps.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 381f7477278c..30a611335d92 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -251,9 +251,15 @@ def track_glyph( # Default to preserving the character code as it was. use_next_charmap = ( self.subset_size != 0 - # But start filling a new subset if outside the first block; this preserves - # ASCII (for Type 3) or the Basic Multilingual Plane (for Type 42). - and charcode >= self.subset_size + and ( + # But start filling a new subset if outside the first block; this + # preserves ASCII (for Type 3) or the Basic Multilingual Plane (for + # Type 42). + charcode >= self.subset_size + # Or, use a new subset if the character code is already mapped for the + # first block. This means it's using an alternate glyph. + or charcode in subset_maps[0] + ) ) if use_next_charmap: if len(subset_maps) == 1 or len(subset_maps[-1]) == self.subset_size: From df670cf4509173d392b5bf852c98af643bb0da14 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 27 Sep 2025 05:32:38 -0400 Subject: [PATCH 044/108] pdf: Support multi-character glyphs when subsetting For ligatures or complex shapings, multiple characters may map to a single glyph. In this case, we still want to output a single character code for the string using the font subset, but the `ToUnicode` map should give back all the characters. --- lib/matplotlib/backends/_backend_pdf_ps.py | 48 +++++++++++++--------- lib/matplotlib/backends/backend_pdf.py | 11 ++--- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 30a611335d92..242a8716552d 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -107,11 +107,11 @@ class GlyphMap: """ A two-way glyph mapping. - The forward glyph map is from (character code, glyph index)-pairs to (subset index, - subset character code)-pairs. + The forward glyph map is from (character string, glyph index)-pairs to + (subset index, subset character code)-pairs. The inverse glyph map is from to (subset index, subset character code)-pairs to - (character code, glyph index)-pairs. + (character string, glyph index)-pairs. """ def __init__(self) -> None: @@ -120,22 +120,21 @@ def __init__(self) -> None: self._inverse: dict[tuple[int, CharacterCodeType], tuple[CharacterCodeType, GlyphIndexType]] = {} - def get(self, charcode: CharacterCodeType, + def get(self, charcodes: str, glyph_index: GlyphIndexType) -> tuple[int, CharacterCodeType] | None: """ - Get the forward mapping from a (character code, glyph index)-pair. + Get the forward mapping from a (character string, glyph index)-pair. This may return *None* if the pair is not currently mapped. """ - return self._forward.get((charcode, glyph_index)) + return self._forward.get((charcodes, glyph_index)) def iget(self, subset: int, - subset_charcode: CharacterCodeType) -> tuple[CharacterCodeType, - GlyphIndexType]: + subset_charcode: CharacterCodeType) -> tuple[str, GlyphIndexType]: """Get the inverse mapping from a (subset, subset charcode)-pair.""" return self._inverse[(subset, subset_charcode)] - def add(self, charcode: CharacterCodeType, glyph_index: GlyphIndexType, subset: int, + def add(self, charcode: str, glyph_index: GlyphIndexType, subset: int, subset_charcode: CharacterCodeType) -> None: """ Add a mapping to this instance. @@ -219,9 +218,8 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: for c, f in font._get_fontmap(s).items() ] - def track_glyph( - self, font: FT2Font, charcode: CharacterCodeType, - glyph: GlyphIndexType) -> tuple[int, CharacterCodeType]: + def track_glyph(self, font: FT2Font, chars: str | CharacterCodeType, + glyph: GlyphIndexType) -> tuple[int, CharacterCodeType]: """ Record character code *charcode* at glyph index *glyph* as using font *font*. @@ -229,8 +227,10 @@ def track_glyph( ---------- font : FT2Font A font that is being used for the provided string. - charcode : CharacterCodeType - The character code to record. + chars : str or CharacterCodeType + The character(s) to record. This may be a single character code, or multiple + characters in a string, if the glyph maps to several characters. It will be + normalized to a string internally. glyph : GlyphIndexType The corresponding glyph index to record. @@ -243,13 +243,21 @@ def track_glyph( The character code within the above subset. If *subset_size* was not specified on this instance, then this is just *charcode* unmodified. """ + if isinstance(chars, str): + charcode = ord(chars[0]) + else: + charcode = chars + chars = chr(chars) + glyph_map = self.glyph_maps.setdefault(font.fname, GlyphMap()) - if result := glyph_map.get(charcode, glyph): + if result := glyph_map.get(chars, glyph): return result subset_maps = self.used.setdefault(font.fname, [{}]) - # Default to preserving the character code as it was. use_next_charmap = ( + # Multi-character glyphs always go in the non-0 subset. + len(chars) > 1 or + # Default to preserving the character code as it was. self.subset_size != 0 and ( # But start filling a new subset if outside the first block; this @@ -270,11 +278,11 @@ def track_glyph( subset = 0 subset_charcode = charcode subset_maps[subset][subset_charcode] = glyph - glyph_map.add(charcode, glyph, subset, subset_charcode) + glyph_map.add(chars, glyph, subset, subset_charcode) return (subset, subset_charcode) def subset_to_unicode(self, fontname: str, subset: int, - subset_charcode: CharacterCodeType) -> CharacterCodeType: + subset_charcode: CharacterCodeType) -> str: """ Map a subset index and character code to a Unicode character code. @@ -289,8 +297,8 @@ def subset_to_unicode(self, fontname: str, subset: int, Returns ------- - CharacterCodeType - The Unicode character code corresponding to the subsetted one. + str + The Unicode character(s) corresponding to the subsetted character code. """ return self.glyph_maps[fontname].iget(subset, subset_charcode)[0] diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index fa2131e53899..19f04786eb26 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -993,7 +993,8 @@ def _embedTeXFont(self, dvifont): # for that subset, and compute various properties based on the encoding. charmap = self._character_tracker.used[dvifont.fname][0] chars = { - self._character_tracker.subset_to_unicode(dvifont.fname, 0, ccode) + # DVI type 1 fonts always map single glyph to single character. + ord(self._character_tracker.subset_to_unicode(dvifont.fname, 0, ccode)) for ccode in charmap } t1font = t1font.subset(chars, self._get_subset_prefix(charmap.values())) @@ -1147,10 +1148,10 @@ def generate_unicode_cmap(subset_index, charmap): last_ccode = ccode def _to_unicode(ccode): - real_ccode = self._character_tracker.subset_to_unicode( + chars = self._character_tracker.subset_to_unicode( filename, subset_index, ccode) - unicodestr = chr(real_ccode).encode('utf-16be').hex() - return f'<{unicodestr}>' + hexstr = chars.encode('utf-16be').hex() + return f'<{hexstr}>' width = 2 if fonttype == 3 else 4 unicode_bfrange = [] @@ -2329,7 +2330,7 @@ def output_singlebyte_chunk(kerns_or_chars): for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED, language=language): subset, charcode = self.file._character_tracker.track_glyph( - item.ft_object, ord(item.char), item.glyph_index) + item.ft_object, item.char, item.glyph_index) if (item.ft_object, subset) != prev_font: if singlebyte_chunk: output_singlebyte_chunk(singlebyte_chunk) From ed5e07459235379bed74f9e6f51b4224e181e23f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 30 Sep 2025 00:50:43 -0400 Subject: [PATCH 045/108] ps: Fix font subset handling Previously, this was supposed to "upgrade" type 3 to type 42 if the number of glyphs overflowed. However, as `CharacterTracker` can suggest a new subset for other reasons (i.e., multiple glyphs for the same character or a glyph for multiple characters may go to a second subset), we do need proper subset handling here as well. Since that is now done, we can drop the "promotion" from type 3 to type 42, as we don't get too many glyphs in each embedded font. --- lib/matplotlib/backends/_backend_pdf_ps.py | 6 +++ lib/matplotlib/backends/backend_pdf.py | 8 +-- lib/matplotlib/backends/backend_ps.py | 58 +++++++++++++--------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 242a8716552d..0ff17a105c20 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -22,6 +22,12 @@ from fontTools.ttLib import TTFont +_FONT_MAX_GLYPH = { + 3: 256, + 42: 65536, +} + + @functools.lru_cache(50) def _cached_get_afm_from_fname(fname): with open(fname, "rb") as fh: diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 19f04786eb26..a850f229ab29 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -368,12 +368,6 @@ def pdfRepr(obj): "objects") -_FONT_MAX_GLYPH = { - 3: 256, - 42: 65536, -} - - class Reference: """ PDF reference object. @@ -691,7 +685,7 @@ def __init__(self, filename, metadata=None): self._fontNames = {} # maps filenames to internal font names self._dviFontInfo = {} # maps pdf names to dvifonts self._character_tracker = _backend_pdf_ps.CharacterTracker( - _FONT_MAX_GLYPH.get(mpl.rcParams['pdf.fonttype'], 0)) + _backend_pdf_ps._FONT_MAX_GLYPH.get(mpl.rcParams['ps.fonttype'], 0)) self.alphaStates = {} # maps alpha values to graphics state objects self._alpha_state_seq = (Name(f'A{i}') for i in itertools.count(1)) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index fe32ea7d2559..374e06da68e9 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -88,7 +88,7 @@ def _move_path_to_path_or_stream(src, dst): shutil.move(src, dst, copy_function=shutil.copyfile) -def _font_to_ps_type3(font_path, glyph_indices): +def _font_to_ps_type3(font_path, subset_index, glyph_indices): """ Subset *glyphs_indices* from the font at *font_path* into a Type 3 font. @@ -96,6 +96,8 @@ def _font_to_ps_type3(font_path, glyph_indices): ---------- font_path : path-like Path to the font to be subsetted. + subset_index : int + The subset of the above font being created. glyph_indices : set[int] The glyphs to include in the subsetted font. @@ -111,7 +113,7 @@ def _font_to_ps_type3(font_path, glyph_indices): %!PS-Adobe-3.0 Resource-Font %%Creator: Converted from TrueType to Type 3 by Matplotlib. 10 dict begin -/FontName /{font_name} def +/FontName /{font_name}-{subset} def /PaintType 0 def /FontMatrix [{inv_units_per_em} 0 0 {inv_units_per_em} 0 0] def /FontBBox [{bbox}] def @@ -119,7 +121,7 @@ def _font_to_ps_type3(font_path, glyph_indices): /Encoding [{encoding}] def /CharStrings {num_glyphs} dict dup begin /.notdef 0 def -""".format(font_name=font.postscript_name, +""".format(font_name=font.postscript_name, subset=subset_index, inv_units_per_em=1 / font.units_per_EM, bbox=" ".join(map(str, font.bbox)), encoding=" ".join(f"/{font.get_glyph_name(glyph_index)}" @@ -168,7 +170,7 @@ def _font_to_ps_type3(font_path, glyph_indices): return preamble + "\n".join(entries) + postamble -def _font_to_ps_type42(font_path, glyph_indices, fh): +def _font_to_ps_type42(font_path, subset_index, glyph_indices, fh): """ Subset *glyph_indices* from the font at *font_path* into a Type 42 font at *fh*. @@ -176,12 +178,14 @@ def _font_to_ps_type42(font_path, glyph_indices, fh): ---------- font_path : path-like Path to the font to be subsetted. + subset_index : int + The subset of the above font being created. glyph_indices : set[int] The glyphs to include in the subsetted font. fh : file-like Where to write the font. """ - _log.debug("SUBSET %s characters: %s", font_path, glyph_indices) + _log.debug("SUBSET %s:%d characters: %s", font_path, subset_index, glyph_indices) try: kw = {} # fix this once we support loading more fonts from a collection @@ -192,10 +196,10 @@ def _font_to_ps_type42(font_path, glyph_indices, fh): _backend_pdf_ps.get_glyphs_subset(font_path, glyph_indices) as subset): fontdata = _backend_pdf_ps.font_as_file(subset).getvalue() _log.debug( - "SUBSET %s %d -> %d", font_path, os.stat(font_path).st_size, - len(fontdata) + "SUBSET %s:%d %d -> %d", font_path, subset_index, + os.stat(font_path).st_size, len(fontdata) ) - fh.write(_serialize_type42(font, subset, fontdata)) + fh.write(_serialize_type42(font, subset_index, subset, fontdata)) except RuntimeError: _log.warning( "The PostScript backend does not currently support the selected font (%s).", @@ -203,7 +207,7 @@ def _font_to_ps_type42(font_path, glyph_indices, fh): raise -def _serialize_type42(font, subset, fontdata): +def _serialize_type42(font, subset_index, subset, fontdata): """ Output a PostScript Type-42 format representation of font @@ -211,6 +215,8 @@ def _serialize_type42(font, subset, fontdata): ---------- font : fontTools.ttLib.ttFont.TTFont The original font object + subset_index : int + The subset of the above font to be created. subset : fontTools.ttLib.ttFont.TTFont The subset font object fontdata : bytes @@ -231,7 +237,7 @@ def _serialize_type42(font, subset, fontdata): 10 dict begin /FontType 42 def /FontMatrix [1 0 0 1 0 0] def - /FontName /{name.getDebugName(6)} def + /FontName /{name.getDebugName(6)}-{subset_index} def /FontInfo 7 dict dup begin /FullName ({name.getDebugName(4)}) def /FamilyName ({name.getDebugName(1)}) def @@ -425,7 +431,8 @@ def __init__(self, width, height, pswriter, imagedpi=72): self._clip_paths = {} self._path_collection_id = 0 - self._character_tracker = _backend_pdf_ps.CharacterTracker() + self._character_tracker = _backend_pdf_ps.CharacterTracker( + _backend_pdf_ps._FONT_MAX_GLYPH.get(mpl.rcParams['ps.fonttype'], 0)) self._logwarn_once = functools.cache(_log.warning) def _is_transparent(self, rgb_or_rgba): @@ -793,12 +800,16 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): else: language = mtext.get_language() if mtext is not None else None font = self._get_font_ttf(prop) - self._character_tracker.track(font, s) for item in _text_helpers.layout(s, font, language=language): + # NOTE: We ignore the character code in the subset, because PS uses the + # glyph name to write text. The subset is only used to ensure that each + # one does not overflow format limits. + subset, _ = self._character_tracker.track_glyph( + item.ft_object, item.char, item.glyph_index) ps_name = (item.ft_object.postscript_name .encode("ascii", "replace").decode("ascii")) glyph_name = item.ft_object.get_glyph_name(item.glyph_index) - stream.append((ps_name, item.x, glyph_name)) + stream.append((f'{ps_name}-{subset}', item.x, glyph_name)) self.set_color(*gc.get_rgb()) for ps_name, group in itertools. \ @@ -827,11 +838,15 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): f"{angle:g} rotate\n") lastfont = None for font, fontsize, ccode, glyph_index, ox, oy in glyphs: - self._character_tracker.track_glyph(font, ccode, glyph_index) - if (font.postscript_name, fontsize) != lastfont: - lastfont = font.postscript_name, fontsize + # NOTE: We ignore the character code in the subset, because PS uses the + # glyph name to write text. The subset is only used to ensure that each one + # does not overflow format limits. + subset, _ = self._character_tracker.track_glyph( + font, ccode, glyph_index) + if (font.postscript_name, subset, fontsize) != lastfont: + lastfont = font.postscript_name, subset, fontsize self._pswriter.write( - f"/{font.postscript_name} {fontsize} selectfont\n") + f"/{font.postscript_name}-{subset} {fontsize} selectfont\n") glyph_name = font.get_glyph_name(glyph_index) self._pswriter.write( f"{ox:g} {oy:g} moveto\n" @@ -1071,18 +1086,15 @@ def print_figure_impl(fh): print("\n".join(_psDefs), file=fh) if not mpl.rcParams['ps.useafm']: for font, subsets in ps_renderer._character_tracker.used.items(): - for charmap in subsets: + for subset, charmap in enumerate(subsets): if not charmap: continue fonttype = mpl.rcParams['ps.fonttype'] - # Can't use more than 255 chars from a single Type 3 font. - if len(charmap) > 255: - fonttype = 42 fh.flush() if fonttype == 3: - fh.write(_font_to_ps_type3(font, charmap.values())) + fh.write(_font_to_ps_type3(font, subset, charmap.values())) else: # Type 42 only. - _font_to_ps_type42(font, charmap.values(), fh) + _font_to_ps_type42(font, subset, charmap.values(), fh) print("end", file=fh) print("%%EndProlog", file=fh) From 972a6888c4d0ebfc655bdc6dd7a46faa95eae917 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 1 Mar 2025 00:39:18 -0500 Subject: [PATCH 046/108] Add font feature API to FontProperties and Text Font features allow font designers to provide alternate glyphs or shaping within a single font. These features may be accessed via special tags corresponding to internal tables of glyphs. The mplcairo backend supports font features via an elaborate re-use of the font file path [1]. This commit adds the API to make this officially supported in the main user API. [1] https://github.com/matplotlib/mplcairo/blob/v0.6.1/README.rst#font-formats-and-features --- doc/release/next_whats_new/font_features.rst | 41 ++++++++++++++++++++ lib/matplotlib/_text_helpers.py | 6 ++- lib/matplotlib/backends/backend_agg.py | 1 + lib/matplotlib/backends/backend_pdf.py | 11 ++++-- lib/matplotlib/backends/backend_ps.py | 9 ++++- lib/matplotlib/font_manager.py | 2 +- lib/matplotlib/ft2font.pyi | 1 + lib/matplotlib/tests/test_ft2font.py | 13 +++++++ lib/matplotlib/tests/test_text.py | 16 ++++++++ lib/matplotlib/text.py | 40 +++++++++++++++++++ lib/matplotlib/text.pyi | 2 + lib/matplotlib/textpath.py | 11 +++--- lib/matplotlib/textpath.pyi | 8 +++- src/ft2font.cpp | 21 ++++++++-- src/ft2font.h | 2 + src/ft2font_wrapper.cpp | 12 +++++- 16 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 doc/release/next_whats_new/font_features.rst diff --git a/doc/release/next_whats_new/font_features.rst b/doc/release/next_whats_new/font_features.rst new file mode 100644 index 000000000000..022d36e1e21d --- /dev/null +++ b/doc/release/next_whats_new/font_features.rst @@ -0,0 +1,41 @@ +Specifying font feature tags +---------------------------- + +OpenType fonts may support feature tags that specify alternate glyph shapes or +substitutions to be made optionally. The text API now supports setting a list of feature +tags to be used with the associated font. Feature tags can be set/get with: + +- `matplotlib.text.Text.set_fontfeatures` / `matplotlib.text.Text.get_fontfeatures` +- Any API that creates a `.Text` object by passing the *fontfeatures* argument (e.g., + ``plt.xlabel(..., fontfeatures=...)``) + +Font feature strings are eventually passed to HarfBuzz, and so all `string formats +supported by hb_feature_from_string() +`__ are +supported. Note though that subranges are not explicitly supported and behaviour may +change in the future. + +For example, the default font ``DejaVu Sans`` enables Standard Ligatures (the ``'liga'`` +tag) by default, and also provides optional Discretionary Ligatures (the ``dlig`` tag.) +These may be toggled with ``+`` or ``-``. + +.. plot:: + :include-source: + + fig = plt.figure(figsize=(7, 3)) + + fig.text(0.5, 0.85, 'Ligatures', fontsize=40, horizontalalignment='center') + + # Default has Standard Ligatures (liga). + fig.text(0, 0.6, 'Default: fi ffi fl st', fontsize=40) + + # Disable Standard Ligatures with -liga. + fig.text(0, 0.35, 'Disabled: fi ffi fl st', fontsize=40, + fontfeatures=['-liga']) + + # Enable Discretionary Ligatures with dlig. + fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=40, + fontfeatures=['dlig']) + +Available font feature tags may be found at +https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index fa5d36bc99c8..e4e6bb03a145 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -26,7 +26,7 @@ def warn_on_missing_glyph(codepoint, fontnames): f"missing from font(s) {fontnames}.") -def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None): +def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None): """ Render *string* with *font*. @@ -39,6 +39,8 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None): The string to be rendered. font : FT2Font The font. + features : tuple of str, optional + The font features to apply to the text. kern_mode : Kerning A FreeType kerning mode. language : str, optional @@ -51,7 +53,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None): """ x = 0 prev_glyph_index = None - char_to_font = font._get_fontmap(string) # TODO: Pass in language. + char_to_font = font._get_fontmap(string) # TODO: Pass in features and language. base_font = font for char in string: # This has done the fallback logic diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 2da422a88e84..43d40d1c0c68 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -191,6 +191,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # 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(), + 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()) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index a850f229ab29..a5035d16e24f 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2263,7 +2263,11 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): return self.draw_mathtext(gc, x, y, s, prop, angle) fontsize = prop.get_size_in_points() - language = mtext.get_language() if mtext is not None else None + if mtext is not None: + features = mtext.get_fontfeatures() + language = mtext.get_language() + else: + features = language = None if mpl.rcParams['pdf.use14corefonts']: font = self._get_font_afm(prop) @@ -2273,7 +2277,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): fonttype = mpl.rcParams['pdf.fonttype'] if gc.get_url() is not None: - font.set_text(s, language=language) + font.set_text(s, features=features, language=language) width, height = font.get_width_height() self.file._annotations[-1][1].append(_get_link_annotation( gc, x, y, width / 64, height / 64, angle)) @@ -2321,7 +2325,8 @@ def output_singlebyte_chunk(kerns_or_chars): prev_start_x = 0 # Emit all the characters in a BT/ET group. self.file.output(Op.begin_text) - for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED, + for item in _text_helpers.layout(s, font, features=features, + kern_mode=Kerning.UNFITTED, language=language): subset, charcode = self.file._character_tracker.track_glyph( item.ft_object, item.char, item.glyph_index) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 374e06da68e9..2743da13aec5 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -798,9 +798,14 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): thisx += width * scale else: - language = mtext.get_language() if mtext is not None else None + if mtext is not None: + features = mtext.get_fontfeatures() + language = mtext.get_language() + else: + features = language = None font = self._get_font_ttf(prop) - for item in _text_helpers.layout(s, font, language=language): + for item in _text_helpers.layout(s, font, features=features, + language=language): # NOTE: We ignore the character code in the subset, because PS uses the # glyph name to write text. The subset is only used to ensure that each # one does not overflow format limits. diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 47339d4491dd..d1a96826fbf6 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -540,7 +540,7 @@ def afmFontProperty(fontpath, font): def _cleanup_fontproperties_init(init_method): """ - A decorator to limit the call signature to single a positional argument + A decorator to limit the call signature to a single positional argument or alternatively only keyword arguments. We still accept but deprecate all other call signatures. diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index fce67131e67b..a4d1c77061be 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -249,6 +249,7 @@ class FT2Font(Buffer): angle: float = ..., flags: LoadFlags = ..., *, + features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> NDArray[np.float64]: ... @property diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index e27a00b740e3..3c066a59e939 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -229,6 +229,19 @@ def test_ft2font_set_size(): assert font.get_width_height() == tuple(pytest.approx(2 * x, 1e-1) for x in orig) +def test_ft2font_features(): + # Smoke test that these are accepted as intended. + file = fm.findfont('DejaVu Sans') + font = ft2font.FT2Font(file) + font.set_text('foo', features=None) # unset + font.set_text('foo', features=['calt', 'dlig']) # list + font.set_text('foo', features=('calt', 'dlig')) # tuple + with pytest.raises(TypeError): + font.set_text('foo', features=123) + with pytest.raises(TypeError): + font.set_text('foo', features=[123, 456]) + + def test_ft2font_charmaps(): def enc(name): # We don't expose the encoding enum from FreeType, but can generate it here. diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 4d8a7a59c731..e3bec7c36910 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -1204,6 +1204,22 @@ def test_ytick_rotation_mode(): plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01) +@image_comparison(baseline_images=['features.png'], remove_text=False, style='mpl20') +def test_text_features(): + fig = plt.figure(figsize=(5, 1.5)) + t = fig.text(1, 0.7, 'Default: fi ffi fl st', + fontsize=32, horizontalalignment='right') + assert t.get_fontfeatures() is None + t = fig.text(1, 0.4, 'Disabled: fi ffi fl st', + fontsize=32, horizontalalignment='right', + fontfeatures=['-liga']) + assert t.get_fontfeatures() == ('-liga', ) + t = fig.text(1, 0.1, 'Discretionary: fi ffi fl st', + fontsize=32, horizontalalignment='right') + t.set_fontfeatures(['dlig']) + assert t.get_fontfeatures() == ('dlig', ) + + @pytest.mark.parametrize( 'input, match', [ diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 4d80f9874941..827b6bcb7667 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -137,6 +137,7 @@ def __init__(self, super().__init__() self._x, self._y = x, y self._text = '' + self._features = None self.set_language(None) self._reset_visual_defaults( text=text, @@ -849,6 +850,12 @@ def get_fontfamily(self): """ return self._fontproperties.get_family() + def get_fontfeatures(self): + """ + Return a tuple of font feature tags to enable. + """ + return self._features + def get_fontname(self): """ Return the font name as a string. @@ -1096,6 +1103,39 @@ def set_fontfamily(self, fontname): self._fontproperties.set_family(fontname) self.stale = True + def set_fontfeatures(self, features): + """ + Set the feature tags to enable on the font. + + Parameters + ---------- + features : list of str, or tuple of str, or None + A list of feature tags to be used with the associated font. These strings + are eventually passed to HarfBuzz, and so all `string formats supported by + hb_feature_from_string() + `__ + are supported. Note though that subranges are not explicitly supported and + behaviour may change in the future. + + For example, if your desired font includes Stylistic Sets which enable + various typographic alternates including one that you do not wish to use + (e.g., Contextual Ligatures), then you can pass the following to enable one + and not the other:: + + fp.set_features([ + 'ss01', # Use Stylistic Set 1. + '-clig', # But disable Contextural Ligatures. + ]) + + Available font feature tags may be found at + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + """ + _api.check_isinstance((Sequence, None), features=features) + if features is not None: + features = tuple(features) + self._features = features + self.stale = True + def set_fontvariant(self, variant): """ Set the font variant. diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index eb3c076b1c5c..7992ecb20a8d 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -56,6 +56,7 @@ class Text(Artist): def get_color(self) -> ColorType: ... def get_fontproperties(self) -> FontProperties: ... def get_fontfamily(self) -> list[str]: ... + def get_fontfeatures(self) -> tuple[str, ...] | None: ... def get_fontname(self) -> str: ... def get_fontstyle(self) -> Literal["normal", "italic", "oblique"]: ... def get_fontsize(self) -> float | str: ... @@ -80,6 +81,7 @@ class Text(Artist): def set_multialignment(self, align: Literal["left", "center", "right"]) -> None: ... def set_linespacing(self, spacing: float) -> None: ... def set_fontfamily(self, fontname: str | Iterable[str]) -> None: ... + def set_fontfeatures(self, features: Sequence[str] | None) -> None: ... def set_fontvariant(self, variant: Literal["normal", "small-caps"]) -> None: ... def set_fontstyle( self, fontstyle: Literal["normal", "italic", "oblique"] diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index 6f6f4daa4cfa..e7bb95159deb 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -67,7 +67,7 @@ def get_text_width_height_descent(self, s, prop, ismath): d /= 64.0 return w * scale, h * scale, d * scale - def get_text_path(self, prop, s, ismath=False, *, language=None): + def get_text_path(self, prop, s, ismath=False, *, features=None, language=None): """ Convert text *s* to path (a tuple of vertices and codes for matplotlib.path.Path). @@ -110,8 +110,8 @@ def get_text_path(self, prop, s, ismath=False, *, language=None): glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s) elif not ismath: font = self._get_font(prop) - glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s, - language=language) + glyph_info, glyph_map, rects = self.get_glyphs_with_font( + font, s, features=features, language=language) else: glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) @@ -132,7 +132,8 @@ def get_text_path(self, prop, s, ismath=False, *, language=None): return verts, codes def get_glyphs_with_font(self, font, s, glyph_map=None, - return_new_glyphs_only=False, *, language=None): + return_new_glyphs_only=False, *, features=None, + language=None): """ Convert string *s* to vertices and codes using the provided ttf font. """ @@ -147,7 +148,7 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, xpositions = [] glyph_reprs = [] - for item in _text_helpers.layout(s, font, language=language): + for item in _text_helpers.layout(s, font, features=features, language=language): glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index) glyph_reprs.append(glyph_repr) xpositions.append(item.x) diff --git a/lib/matplotlib/textpath.pyi b/lib/matplotlib/textpath.pyi index b83b337aa541..07f81598aa75 100644 --- a/lib/matplotlib/textpath.pyi +++ b/lib/matplotlib/textpath.pyi @@ -16,7 +16,12 @@ class TextToPath: self, s: str, prop: FontProperties, ismath: bool | Literal["TeX"] ) -> tuple[float, float, float]: ... def get_text_path( - self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ..., *, + self, + prop: FontProperties, + s: str, + ismath: bool | Literal["TeX"] = ..., + *, + features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> list[np.ndarray]: ... def get_glyphs_with_font( @@ -26,6 +31,7 @@ class TextToPath: glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ..., return_new_glyphs_only: bool = ..., *, + features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> tuple[ list[tuple[str, float, float, float]], diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 6f3db040f17d..8838f68ee5f8 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -320,7 +320,7 @@ void FT2Font::set_kerning_factor(int factor) std::vector FT2Font::layout( std::u32string_view text, FT_Int32 flags, - LanguageType languages, + std::optional> features, LanguageType languages, std::set& glyph_seen_fonts) { clear(); @@ -344,6 +344,13 @@ std::vector FT2Font::layout( if (!raqm_set_freetype_load_flags(rq, flags)) { throw std::runtime_error("failed to set text flags for layout"); } + if (features) { + for (auto const& feature : *features) { + if (!raqm_add_font_feature(rq, feature.c_str(), feature.size())) { + throw std::runtime_error("failed to set font feature {}"_s.format(feature)); + } + } + } if (languages) { for (auto & [lang_str, start, end] : *languages) { if (!raqm_set_language(rq, lang_str.c_str(), start, end - start)) { @@ -417,6 +424,14 @@ std::vector FT2Font::layout( if (!raqm_set_freetype_load_flags(rq, flags)) { throw std::runtime_error("failed to set text flags for layout"); } + if (features) { + for (auto const& feature : *features) { + if (!raqm_add_font_feature(rq, feature.c_str(), feature.size())) { + throw std::runtime_error( + "failed to set font feature {}"_s.format(feature)); + } + } + } if (languages) { for (auto & [lang_str, start, end] : *languages) { if (!raqm_set_language(rq, lang_str.c_str(), start, end - start)) { @@ -440,7 +455,7 @@ std::vector FT2Font::layout( void FT2Font::set_text( std::u32string_view text, double angle, FT_Int32 flags, - LanguageType languages, + std::optional> features, LanguageType languages, std::vector &xys) { FT_Matrix matrix; /* transformation matrix */ @@ -457,7 +472,7 @@ void FT2Font::set_text( matrix.yy = (FT_Fixed)cosangle; std::set glyph_seen_fonts; - auto rq_glyphs = layout(text, flags, languages, glyph_seen_fonts); + auto rq_glyphs = layout(text, flags, features, languages, glyph_seen_fonts); bbox.xMin = bbox.yMin = 32000; bbox.xMax = bbox.yMax = -32000; diff --git a/src/ft2font.h b/src/ft2font.h index 841c66cfb5ee..b1458fe28ada 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -114,9 +114,11 @@ class FT2Font void set_charmap(int i); void select_charmap(unsigned long i); std::vector layout(std::u32string_view text, FT_Int32 flags, + std::optional> features, LanguageType languages, std::set& glyph_seen_fonts); void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags, + std::optional> features, LanguageType languages, std::vector &xys); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode); void set_kerning_factor(int factor); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index e0d5e0c23391..a348f0d312b6 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -687,6 +687,13 @@ const char *PyFT2Font_set_text__doc__ = R"""( .. versionchanged:: 3.10 This now takes an `.ft2font.LoadFlags` instead of an int. + features : tuple[str, ...] + The font feature tags to use for the font. + + Available font feature tags may be found at + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + + .. versionadded:: 3.11 Returns ------- @@ -697,6 +704,7 @@ const char *PyFT2Font_set_text__doc__ = R"""( static py::array_t PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0, std::variant flags_or_int = LoadFlags::FORCE_AUTOHINT, + std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { std::vector xys; @@ -731,7 +739,7 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 throw py::type_error("languages must be str or list of tuple"); } - self->set_text(text, angle, static_cast(flags), languages, xys); + self->set_text(text, angle, static_cast(flags), features, languages, xys); py::ssize_t dims[] = { static_cast(xys.size()) / 2, 2 }; py::array_t result(dims); @@ -1553,7 +1561,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Font_get_kerning__doc__) .def("set_text", &PyFT2Font_set_text, "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(), - "language"_a=nullptr, + "features"_a=nullptr, "language"_a=nullptr, PyFT2Font_set_text__doc__) .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, PyFT2Font_get_fontmap__doc__) From 959799138e6b53bea788fb647f5315659f7c4e79 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 24 Sep 2025 01:13:22 -0400 Subject: [PATCH 047/108] ft2font: Add a wrapper around layouting for vector usage --- ci/mypy-stubtest-allowlist.txt | 1 + lib/matplotlib/ft2font.pyi | 23 ++++++ src/ft2font_wrapper.cpp | 142 +++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 6e3e487d934f..1a9a888a1896 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -6,6 +6,7 @@ matplotlib\._.* matplotlib\.rcsetup\._listify_validator matplotlib\.rcsetup\._validate_linestyle matplotlib\.ft2font\.Glyph +matplotlib\.ft2font\.LayoutItem matplotlib\.testing\.jpl_units\..* matplotlib\.sphinxext(\..*)? diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index a4d1c77061be..88745e5e5cc9 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -191,6 +191,22 @@ class _SfntPcltDict(TypedDict): widthType: int serifStyle: int +@final +class LayoutItem: + @property + def ft_object(self) -> FT2Font: ... + @property + def char(self) -> str: ... + @property + def glyph_index(self) -> GlyphIndexType: ... + @property + def x(self) -> float: ... + @property + def y(self) -> float: ... + @property + def prev_kern(self) -> float: ... + def __str__(self) -> str: ... + @final class FT2Font(Buffer): def __init__( @@ -204,6 +220,13 @@ class FT2Font(Buffer): if sys.version_info[:2] >= (3, 12): def __buffer__(self, flags: int) -> memoryview: ... def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ... + def _layout( + self, + text: str, + flags: LoadFlags, + features: tuple[str, ...] | None = ..., + language: str | tuple[tuple[str, int, int], ...] | None = ..., + ) -> list[LayoutItem]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, antialiased: bool = ... diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index a348f0d312b6..6aa9188317fa 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1409,6 +1409,119 @@ PyFT2Font__get_type1_encoding_vector(PyFT2Font *self) return indices; } +/********************************************************************** + * Layout items + * */ + +struct LayoutItem { + PyFT2Font *ft_object; + std::u32string character; + int glyph_index; + double x; + double y; + double prev_kern; + + LayoutItem(PyFT2Font *f, std::u32string c, int i, double x, double y, double k) : + ft_object(f), character(c), glyph_index(i), x(x), y(y), prev_kern(k) {} +}; + +const char *PyFT2Font_layout__doc__ = R"""( + Layout a string and yield information about each used glyph. + + .. warning:: + This API uses the fallback list and is both private and provisional: do not use + it directly. + + .. versionadded:: 3.11 + + Parameters + ---------- + text : str + The characters for which to find fonts. + flags : LoadFlags, default: `.LoadFlags.FORCE_AUTOHINT` + Any bitwise-OR combination of the `.LoadFlags` flags. + features : tuple[str, ...], optional + The font feature tags to use for the font. + + Available font feature tags may be found at + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + language : str, optional + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. + + Returns + ------- + list[LayoutItem] +)"""; + +static auto +PyFT2Font_layout(PyFT2Font *self, std::u32string text, LoadFlags flags, + std::optional> features = std::nullopt, + std::variant languages_or_str = nullptr) +{ + const auto hinting_factor = self->get_hinting_factor(); + const auto load_flags = static_cast(flags); + + FT2Font::LanguageType languages; + if (auto value = std::get_if(&languages_or_str)) { + languages = std::move(*value); + } else if (auto value = std::get_if(&languages_or_str)) { + languages = std::vector{ + FT2Font::LanguageRange{*value, 0, text.size()} + }; + } else { + // NOTE: this can never happen as pybind11 would have checked the type in the + // Python wrapper before calling this function, but we need to keep the + // std::get_if instead of std::get for macOS 10.12 compatibility. + throw py::type_error("languages must be str or list of tuple"); + } + + std::set glyph_seen_fonts; + auto glyphs = self->layout(text, load_flags, features, languages, glyph_seen_fonts); + + std::set clusters; + for (auto &glyph : glyphs) { + clusters.emplace(glyph.cluster); + } + + std::vector items; + + double x = 0.0; + double y = 0.0; + std::optional prev_advance = std::nullopt; + double prev_x = 0.0; + for (auto &glyph : glyphs) { + auto ft_object = static_cast(glyph.ftface->generic.data); + + ft_object->load_glyph(glyph.index, load_flags); + + double prev_kern = 0.0; + if (prev_advance) { + double actual_advance = (x + glyph.x_offset) - prev_x; + prev_kern = actual_advance - *prev_advance; + } + + auto next = clusters.upper_bound(glyph.cluster); + auto end = (next != clusters.end()) ? *next : text.size(); + auto substr = text.substr(glyph.cluster, end - glyph.cluster); + + items.emplace_back(ft_object, substr, glyph.index, + (x + glyph.x_offset) / 64.0, (y + glyph.y_offset) / 64.0, + prev_kern / 64.0); + prev_x = x + glyph.x_offset; + x += glyph.x_advance; + y += glyph.y_advance; + // Note, linearHoriAdvance is a 16.16 instead of 26.6 fixed-point value. + prev_advance = ft_object->get_face()->glyph->linearHoriAdvance / 1024.0 / hinting_factor; + } + + return items; +} + +/********************************************************************** + * Deprecations + * */ + static py::object ft2font__getattr__(std::string name) { auto api = py::module_::import("matplotlib._api"); @@ -1543,6 +1656,32 @@ 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, "LayoutItem", py::is_final()) + .def(py::init<>([]() -> LayoutItem { + // LayoutItem is not useful from Python, so mark it as not constructible. + throw std::runtime_error("LayoutItem is not constructible"); + })) + .def_readonly("ft_object", &LayoutItem::ft_object, + "The FT_Face of the item.") + .def_readonly("char", &LayoutItem::character, + "The character code for the item.") + .def_readonly("glyph_index", &LayoutItem::glyph_index, + "The glyph index for the item.") + .def_readonly("x", &LayoutItem::x, + "The x position of the item.") + .def_readonly("y", &LayoutItem::y, + "The y position of the item.") + .def_readonly("prev_kern", &LayoutItem::prev_kern, + "The kerning between this item and the previous one.") + .def("__str__", + [](const LayoutItem& item) { + return + "LayoutItem(ft_object={}, char={!r}, glyph_index={}, "_s + "x={}, y={}, prev_kern={})"_s.format( + PyFT2Font_fname(item.ft_object), item.character, + item.glyph_index, item.x, item.y, item.prev_kern); + }); + auto cls = py::class_(m, "FT2Font", py::is_final(), py::buffer_protocol(), PyFT2Font__doc__) .def(py::init(&PyFT2Font_init), @@ -1559,6 +1698,9 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Font_select_charmap__doc__) .def("get_kerning", &PyFT2Font_get_kerning, "left"_a, "right"_a, "mode"_a, PyFT2Font_get_kerning__doc__) + .def("_layout", &PyFT2Font_layout, "string"_a, "flags"_a, py::kw_only(), + "features"_a=nullptr, "language"_a=nullptr, + PyFT2Font_layout__doc__) .def("set_text", &PyFT2Font_set_text, "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(), "features"_a=nullptr, "language"_a=nullptr, From a47bd3fcf232f9deebf86ab066f2af06e7eabe31 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 17 Dec 2024 21:08:58 -0500 Subject: [PATCH 048/108] Remove forced fallback from FT2Font::load_char The only thing that expected this to work is Type 3 fonts in the PDF backend, but only to avoid an error when loading a range of characters (not all of which would be used.) This would introduce an odd behaviour in that `load_char` could never fail even if the glyph never existed. You would instead end up with the `null` glyph from the last font. --- src/ft2font.cpp | 9 ++++----- src/ft2font.h | 3 +-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 8838f68ee5f8..0e98506536d0 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -552,7 +552,7 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool bool was_found = load_char_with_fallback(ft_object_with_glyph, final_glyph_index, glyphs, char_to_font, charcode, flags, charcode_error, glyph_error, - glyph_seen_fonts, true); + glyph_seen_fonts); if (!was_found) { ft_glyph_warn(charcode, glyph_seen_fonts); if (charcode_error) { @@ -613,15 +613,14 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, FT_Int32 flags, FT_Error &charcode_error, FT_Error &glyph_error, - std::set &glyph_seen_fonts, - bool override = false) + std::set &glyph_seen_fonts) { FT_UInt glyph_index = FT_Get_Char_Index(face, charcode); if (!warn_if_used) { glyph_seen_fonts.insert(face->family_name); } - if (glyph_index || override) { + if (glyph_index) { charcode_error = FT_Load_Glyph(face, glyph_index, flags); if (charcode_error) { return false; @@ -647,7 +646,7 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, bool was_found = fallback->load_char_with_fallback( ft_object_with_glyph, final_glyph_index, parent_glyphs, parent_char_to_font, charcode, flags, - charcode_error, glyph_error, glyph_seen_fonts, override); + charcode_error, glyph_error, glyph_seen_fonts); if (was_found) { return true; } diff --git a/src/ft2font.h b/src/ft2font.h index b1458fe28ada..b36bc4f02a76 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -131,8 +131,7 @@ class FT2Font FT_Int32 flags, FT_Error &charcode_error, FT_Error &glyph_error, - std::set &glyph_seen_fonts, - bool override); + std::set &glyph_seen_fonts); void load_glyph(FT_UInt glyph_index, FT_Int32 flags); std::tuple get_width_height(); std::tuple get_bitmap_offset(); From bd17cd43dd71b879928828c899cb2f9087ce745e Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 24 May 2025 05:02:40 -0400 Subject: [PATCH 049/108] Use libraqm for text in vector outputs --- lib/matplotlib/_text_helpers.py | 44 ++++++------------- lib/matplotlib/backends/_backend_pdf_ps.py | 18 ++++++-- lib/matplotlib/backends/backend_pdf.py | 23 +++++++--- lib/matplotlib/backends/backend_ps.py | 13 +++--- lib/matplotlib/backends/backend_svg.py | 8 +++- lib/matplotlib/ft2font.pyi | 1 - lib/matplotlib/tests/test_ft2font.py | 18 ++++---- lib/matplotlib/tests/test_text.py | 8 ++-- lib/matplotlib/textpath.py | 3 +- src/ft2font_wrapper.cpp | 50 ---------------------- 10 files changed, 72 insertions(+), 114 deletions(-) diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index e4e6bb03a145..0ebbf3ac139d 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -4,29 +4,23 @@ from __future__ import annotations -import dataclasses +from collections.abc import Iterator from . import _api -from .ft2font import FT2Font, GlyphIndexType, Kerning, LoadFlags +from .ft2font import FT2Font, CharacterCodeType, LayoutItem, LoadFlags -@dataclasses.dataclass(frozen=True) -class LayoutItem: - ft_object: FT2Font - char: str - glyph_index: GlyphIndexType - x: float - prev_kern: float - - -def warn_on_missing_glyph(codepoint, fontnames): +def warn_on_missing_glyph(codepoint: CharacterCodeType, fontnames: str): _api.warn_external( f"Glyph {codepoint} " f"({chr(codepoint).encode('ascii', 'namereplace').decode('ascii')}) " f"missing from font(s) {fontnames}.") -def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None): +def layout(string: str, font: FT2Font, *, + features: tuple[str] | None = None, + language: str | tuple[tuple[str, int, int], ...] | None = None + ) -> Iterator[LayoutItem]: """ Render *string* with *font*. @@ -41,8 +35,6 @@ def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=N The font. features : tuple of str, optional The font features to apply to the text. - kern_mode : Kerning - A FreeType kerning mode. language : str, optional The language of the text in a format accepted by libraqm, namely `a BCP47 language code `_. @@ -51,20 +43,8 @@ def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=N ------ LayoutItem """ - x = 0 - prev_glyph_index = None - char_to_font = font._get_fontmap(string) # TODO: Pass in features and language. - base_font = font - for char in string: - # This has done the fallback logic - font = char_to_font.get(char, base_font) - glyph_index = font.get_char_index(ord(char)) - kern = ( - base_font.get_kerning(prev_glyph_index, glyph_index, kern_mode) / 64 - if prev_glyph_index is not None else 0. - ) - x += kern - glyph = font.load_glyph(glyph_index, flags=LoadFlags.NO_HINTING) - yield LayoutItem(font, char, glyph_index, x, kern) - x += glyph.linearHoriAdvance / 65536 - prev_glyph_index = glyph_index + for raqm_item in font._layout(string, LoadFlags.NO_HINTING, + features=features, language=language): + raqm_item.ft_object.load_glyph(raqm_item.glyph_index, + flags=LoadFlags.NO_HINTING) + yield raqm_item diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 0ff17a105c20..b1a3f5c9f18b 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -199,7 +199,10 @@ def __init__(self, subset_size: int = 0): self.glyph_maps: dict[str, GlyphMap] = {} self.subset_size = subset_size - def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: + def track(self, font: FT2Font, s: str, + features: tuple[str, ...] | None = ..., + language: str | tuple[tuple[str, int, int], ...] | None = None + ) -> list[tuple[int, CharacterCodeType]]: """ Record that string *s* is being typeset using font *font*. @@ -209,6 +212,14 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: A font that is being used for the provided string. s : str The string that should be marked as tracked by the provided font. + features : tuple[str, ...], optional + The font feature tags to use for the font. + + Available font feature tags may be found at + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + language : str, optional + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. Returns ------- @@ -220,8 +231,9 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]: and the character codes will be returned from the string unchanged. """ return [ - self.track_glyph(f, ord(c), f.get_char_index(ord(c))) - for c, f in font._get_fontmap(s).items() + self.track_glyph(raqm_item.ft_object, raqm_item.char, raqm_item.glyph_index) + for raqm_item in font._layout(s, ft2font.LoadFlags.NO_HINTING, + features=features, language=language) ] def track_glyph(self, font: FT2Font, chars: str | CharacterCodeType, diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index a5035d16e24f..613b00987730 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -34,7 +34,7 @@ from matplotlib.figure import Figure from matplotlib.font_manager import get_font, fontManager as _fontManager from matplotlib._afm import AFM -from matplotlib.ft2font import FT2Font, FaceFlags, Kerning, LoadFlags, StyleFlags +from matplotlib.ft2font import FT2Font, FaceFlags, LoadFlags, StyleFlags from matplotlib.transforms import Affine2D, BboxBase from matplotlib.path import Path from matplotlib.dates import UTC @@ -469,6 +469,7 @@ class Op(Enum): textpos = b'Td' selectfont = b'Tf' textmatrix = b'Tm' + textrise = b'Ts' show = b'Tj' showkern = b'TJ' setlinewidth = b'w' @@ -2285,6 +2286,9 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # If fonttype is neither 3 nor 42, emit the whole string at once # without manual kerning. if fonttype not in [3, 42]: + if not mpl.rcParams['pdf.use14corefonts']: + self.file._character_tracker.track(font, s, + features=features, language=language) self.file.output(Op.begin_text, self.file.fontName(prop), fontsize, Op.selectfont) self._setup_textpos(x, y, angle) @@ -2305,6 +2309,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # kerning between chunks. else: def output_singlebyte_chunk(kerns_or_chars): + if not kerns_or_chars: + return self.file.output( # See pdf spec "Text space details" for the 1000/fontsize # (aka. 1000/T_fs) factor. @@ -2312,6 +2318,7 @@ def output_singlebyte_chunk(kerns_or_chars): else self._encode_glyphs(group, fonttype) for tp, group in itertools.groupby(kerns_or_chars, type)], Op.showkern) + kerns_or_chars.clear() # Do the rotation and global translation as a single matrix # concatenation up front self.file.output(Op.gsave) @@ -2326,24 +2333,26 @@ def output_singlebyte_chunk(kerns_or_chars): # Emit all the characters in a BT/ET group. self.file.output(Op.begin_text) for item in _text_helpers.layout(s, font, features=features, - kern_mode=Kerning.UNFITTED, language=language): subset, charcode = self.file._character_tracker.track_glyph( item.ft_object, item.char, item.glyph_index) if (item.ft_object, subset) != prev_font: - if singlebyte_chunk: - output_singlebyte_chunk(singlebyte_chunk) + output_singlebyte_chunk(singlebyte_chunk) ft_name = self.file.fontName(item.ft_object.fname, subset) self.file.output(ft_name, fontsize, Op.selectfont) self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0) - singlebyte_chunk = [] prev_font = (item.ft_object, subset) prev_start_x = item.x + if item.y: + output_singlebyte_chunk(singlebyte_chunk) + self.file.output(item.y, Op.textrise) if item.prev_kern: singlebyte_chunk.append(item.prev_kern) singlebyte_chunk.append(charcode) - if singlebyte_chunk: - output_singlebyte_chunk(singlebyte_chunk) + if item.y: + output_singlebyte_chunk(singlebyte_chunk) + self.file.output(0, Op.textrise) + output_singlebyte_chunk(singlebyte_chunk) self.file.output(Op.end_text) self.file.output(Op.grestore) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 2743da13aec5..8ad31290a643 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -776,7 +776,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) - stream = [] # list of (ps_name, x, char_name) + stream = [] # list of (ps_name, x, y, char_name) if mpl.rcParams['ps.useafm']: font = self._get_font_afm(prop) @@ -794,7 +794,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): kern = font.get_kern_dist_from_name(last_name, name) last_name = name thisx += kern * scale - stream.append((ps_name, thisx, name)) + stream.append((ps_name, thisx, 0, name)) thisx += width * scale else: @@ -814,14 +814,13 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): ps_name = (item.ft_object.postscript_name .encode("ascii", "replace").decode("ascii")) glyph_name = item.ft_object.get_glyph_name(item.glyph_index) - stream.append((f'{ps_name}-{subset}', item.x, glyph_name)) + stream.append((f'{ps_name}-{subset}', item.x, item.y, glyph_name)) self.set_color(*gc.get_rgb()) - for ps_name, group in itertools. \ - groupby(stream, lambda entry: entry[0]): + for ps_name, group in itertools.groupby(stream, lambda entry: entry[0]): self.set_font(ps_name, prop.get_size_in_points(), False) - thetext = "\n".join(f"{x:g} 0 m /{name:s} glyphshow" - for _, x, name in group) + thetext = "\n".join(f"{x:g} {y:g} m /{name:s} glyphshow" + for _, x, y, name in group) self._pswriter.write(f"""\ gsave {self._get_clip_cmd(gc)} diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 7b94a2b9ba2b..06d868bdab62 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -1048,6 +1048,11 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): text2path = self._text2path color = rgb2hex(gc.get_rgb()) fontsize = prop.get_size_in_points() + if mtext is not None: + features = mtext.get_fontfeatures() + language = mtext.get_language() + else: + features = language = None style = {} if color != '#000000': @@ -1068,7 +1073,8 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): if not ismath: font = text2path._get_font(prop) glyph_info, glyph_map_new, rects = text2path.get_glyphs_with_font( - font, s, glyph_map=glyph_map, return_new_glyphs_only=True) + font, s, features=features, language=language, + glyph_map=glyph_map, return_new_glyphs_only=True) self._update_glyph_map_defs(glyph_map_new) for glyph_repr, xposition, yposition, scale in glyph_info: diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 88745e5e5cc9..9345c1c9057f 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -219,7 +219,6 @@ class FT2Font(Buffer): ) -> None: ... if sys.version_info[:2] >= (3, 12): def __buffer__(self, flags: int) -> memoryview: ... - def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ... def _layout( self, text: str, diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 3c066a59e939..4a874deb5343 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -972,7 +972,7 @@ def test_fallback_last_resort(recwarn): "Glyph 128579 (\\N{UPSIDE-DOWN FACE}) missing from font(s)") -def test__get_fontmap(): +def test__layout(): fonts, test_str = _gen_multi_font_text() # Add some glyphs that don't exist in either font to check the Last Resort fallback. missing_glyphs = '\n几个汉字' @@ -981,11 +981,11 @@ def test__get_fontmap(): ft = fm.get_font( fm.fontManager._find_fonts_by_props(fm.FontProperties(family=fonts)) ) - fontmap = ft._get_fontmap(test_str) - for char, font in fontmap.items(): - if char in missing_glyphs: - assert Path(font.fname).name == 'LastResortHE-Regular.ttf' - elif ord(char) > 127: - assert Path(font.fname).name == 'DejaVuSans.ttf' - else: - assert Path(font.fname).name == 'cmr10.ttf' + for substr in test_str.split('\n'): + for item in ft._layout(substr, ft2font.LoadFlags.DEFAULT): + if item.char in missing_glyphs: + assert Path(item.ft_object.fname).name == 'LastResortHE-Regular.ttf' + elif ord(item.char) > 127: + assert Path(item.ft_object.fname).name == 'DejaVuSans.ttf' + else: + assert Path(item.ft_object.fname).name == 'cmr10.ttf' diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 84d833a1a0ea..5ae606e413f0 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -115,7 +115,7 @@ def find_matplotlib_font(**kw): ax.set_yticks([]) -@image_comparison(['complex.png']) +@image_comparison(['complex'], extensions=['png', 'pdf', 'svg', 'eps']) def test_complex_shaping(): # Raqm is Arabic for writing; note that because Arabic is RTL, the characters here # may seem to be in a different order than expected, but libraqm will order them @@ -1240,7 +1240,8 @@ def test_ytick_rotation_mode(): plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01) -@image_comparison(baseline_images=['features.png'], remove_text=False, style='mpl20') +@image_comparison(['features'], remove_text=False, style='mpl20', + extensions=['png', 'pdf', 'svg', 'eps']) def test_text_features(): fig = plt.figure(figsize=(5, 1.5)) t = fig.text(1, 0.7, 'Default: fi ffi fl st', @@ -1270,7 +1271,8 @@ def test_text_language_invalid(input, match): Text(0, 0, 'foo', language=input) -@image_comparison(baseline_images=['language.png'], remove_text=False, style='mpl20') +@image_comparison(['language'], remove_text=False, style='mpl20', + extensions=['png', 'pdf', 'svg', 'eps']) def test_text_language(): fig = plt.figure(figsize=(5, 3)) diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index e7bb95159deb..d7c1cdf1622f 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -147,15 +147,16 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, glyph_map_new = glyph_map xpositions = [] + ypositions = [] glyph_reprs = [] for item in _text_helpers.layout(s, font, features=features, language=language): glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index) glyph_reprs.append(glyph_repr) xpositions.append(item.x) + ypositions.append(item.y) if glyph_repr not in glyph_map: glyph_map_new[glyph_repr] = item.ft_object.get_path() - ypositions = [0] * len(xpositions) sizes = [1.] * len(xpositions) rects = [] diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 6aa9188317fa..21d8b01656b8 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -623,54 +623,6 @@ PyFT2Font_get_kerning(PyFT2Font *self, FT_UInt left, FT_UInt right, return self->get_kerning(left, right, mode); } -const char *PyFT2Font_get_fontmap__doc__ = R"""( - Get a mapping between characters and the font that includes them. - - .. warning:: - This API uses the fallback list and is both private and provisional: do not use - it directly. - - Parameters - ---------- - text : str - The characters for which to find fonts. - - Returns - ------- - dict[str, FT2Font] - A dictionary mapping unicode characters to `.FT2Font` objects. -)"""; - -static py::dict -PyFT2Font_get_fontmap(PyFT2Font *self, std::u32string text) -{ - std::set codepoints; - - py::dict char_to_font; - for (auto code : text) { - if (!codepoints.insert(code).second) { - continue; - } - - py::object target_font; - int index; - if (self->get_char_fallback_index(code, index)) { - if (index >= 0) { - target_font = self->fallbacks[index]; - } else { - target_font = py::cast(self); - } - } else { - // TODO Handle recursion! - target_font = py::cast(self); - } - - auto key = py::cast(std::u32string(1, code)); - char_to_font[key] = target_font; - } - return char_to_font; -} - const char *PyFT2Font_set_text__doc__ = R"""( Set the text *string* and *angle*. @@ -1705,8 +1657,6 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(), "features"_a=nullptr, "language"_a=nullptr, PyFT2Font_set_text__doc__) - .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, - PyFT2Font_get_fontmap__doc__) .def("get_num_glyphs", &PyFT2Font::get_num_glyphs, PyFT2Font_get_num_glyphs__doc__) .def("load_char", &PyFT2Font_load_char, From 4a99a83773ea6fd7bb5c65472f1928c4040394d3 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sat, 1 Nov 2025 23:09:37 +0100 Subject: [PATCH 050/108] Fix spacing in `r"$\max f$"`. Previously, in a mathtext string like `r"$\sin x$"`, a thin space would (correctly) be added between "sin" and "x", but that space would be missing in expressions like `r"$\max f$"`. The difference arose because of the slightly different handling of subscripts and superscripts after the `\sin` and `\max` operators: `\sin^n` puts the superscript as a normal exponent, but `\max_x` puts the subscript centered below the operator name ("overunder symbol). The previous code for inserting the thin space did not handle the "overunder" case; fix that. The new behavior is tested by the change in test_operator_space, as well as by mathtext1_dejavusans_06. The change in mathtext_foo_29 arises because the extra thin space now inserted after `\limsup` slightly shifts the centering of the whole string. Ideally that thin space should be suppressed if there's no token after the operator, but that's not something currently implemented either for e.g. `\sin` (compare e.g. the right-alignments in `text(.5, .9, r"$\sin$", ha="right"); text(.5, .8, r"$\mathrm{sin}$", ha="right"); axvline(.5)` where the extra thin space after `\sin` is visible), so this patch just makes things more consistent. --- lib/matplotlib/_mathtext.py | 10 +++++++--- lib/matplotlib/tests/test_mathtext.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index d628b18aebf4..4c3ad3c8d676 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2362,8 +2362,7 @@ def operatorname(self, s: str, loc: int, toks: ParseResults) -> T.Any: next_char_loc += len('operatorname{}') next_char = next((c for c in s[next_char_loc:] if c != ' '), '') delimiters = self._delims | {'^', '_'} - if (next_char not in delimiters and - name not in self._overunder_functions): + if next_char not in delimiters: # Add thin space except when followed by parenthesis, bracket, etc. hlist_list += [self._make_space(self._space_widths[r'\,'])] self.pop_state() @@ -2483,7 +2482,12 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: shift = hlist.height + vgap + nucleus.depth vlt = Vlist(vlist) vlt.shift_amount = shift - result = Hlist([vlt]) + result = Hlist([ + vlt, + *([self._make_space(self._space_widths[r'\,'])] + if self._in_subscript_or_superscript else []), + ]) + self._in_subscript_or_superscript = False return [result] # We remove kerning on the last character for consistency (otherwise diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 5d0245bc5049..31b5d37ea041 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -400,7 +400,7 @@ def test_operator_space(fig_test, fig_ref): fig_test.text(0.1, 0.6, r"$\operatorname{op}[6]$") fig_test.text(0.1, 0.7, r"$\cos^2$") fig_test.text(0.1, 0.8, r"$\log_2$") - fig_test.text(0.1, 0.9, r"$\sin^2 \cos$") # GitHub issue #17852 + fig_test.text(0.1, 0.9, r"$\sin^2 \max \cos$") # GitHub issue #17852 fig_ref.text(0.1, 0.1, r"$\mathrm{log\,}6$") fig_ref.text(0.1, 0.2, r"$\mathrm{log}(6)$") @@ -410,7 +410,7 @@ def test_operator_space(fig_test, fig_ref): fig_ref.text(0.1, 0.6, r"$\mathrm{op}[6]$") fig_ref.text(0.1, 0.7, r"$\mathrm{cos}^2$") fig_ref.text(0.1, 0.8, r"$\mathrm{log}_2$") - fig_ref.text(0.1, 0.9, r"$\mathrm{sin}^2 \mathrm{\,cos}$") + fig_ref.text(0.1, 0.9, r"$\mathrm{sin}^2 \mathrm{\,max} \mathrm{\,cos}$") @check_figures_equal() From 17428e3e2f1ac21b2415be173c9eaed1043c176e Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sat, 1 Nov 2025 23:24:12 +0100 Subject: [PATCH 051/108] Tweak sub/superscript spacing implementation. Rename _in_subscript_or_superscript to the more descriptive _needs_space_after_subsuper; simplify its setting in operatorname(); avoid the need to introduce an extra explicitly-typed spaced_nucleus variable. --- lib/matplotlib/_mathtext.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 4c3ad3c8d676..ff2045a5b8b4 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2132,7 +2132,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex: self._math_expression = p.math # To add space to nucleus operators after sub/superscripts - self._in_subscript_or_superscript = False + self._needs_space_after_subsuper = False def parse(self, s: str, fonts_object: Fonts, fontsize: float, dpi: float) -> Hlist: """ @@ -2150,7 +2150,7 @@ def parse(self, s: str, fonts_object: Fonts, fontsize: float, dpi: float) -> Hli # explain becomes a plain method on pyparsing 3 (err.explain(0)). raise ValueError("\n" + ParseException.explain(err, 0)) from None self._state_stack = [] - self._in_subscript_or_superscript = False + self._needs_space_after_subsuper = False # prevent operator spacing from leaking into a new expression self._em_width_cache = {} ParserElement.reset_cache() @@ -2260,7 +2260,7 @@ def symbol(self, s: str, loc: int, prev_char = next((c for c in s[:loc][::-1] if c != ' '), '') # Binary operators at start of string should not be spaced # Also, operators in sub- or superscripts should not be spaced - if (self._in_subscript_or_superscript or ( + if (self._needs_space_after_subsuper or ( c in self._binary_operators and ( len(s[:loc].split()) == 0 or prev_char in { '{', *self._left_delims, *self._relation_symbols}))): @@ -2366,13 +2366,9 @@ def operatorname(self, s: str, loc: int, toks: ParseResults) -> T.Any: # Add thin space except when followed by parenthesis, bracket, etc. hlist_list += [self._make_space(self._space_widths[r'\,'])] self.pop_state() - # if followed by a super/subscript, set flag to true - # This flag tells subsuper to add space after this operator - if next_char in {'^', '_'}: - self._in_subscript_or_superscript = True - else: - self._in_subscript_or_superscript = False - + # If followed by a sub/superscript, set flag to true to tell subsuper + # to add space after this operator. + self._needs_space_after_subsuper = next_char in {'^', '_'} return Hlist(hlist_list) def start_group(self, toks: ParseResults) -> T.Any: @@ -2482,12 +2478,10 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: shift = hlist.height + vgap + nucleus.depth vlt = Vlist(vlist) vlt.shift_amount = shift - result = Hlist([ - vlt, - *([self._make_space(self._space_widths[r'\,'])] - if self._in_subscript_or_superscript else []), - ]) - self._in_subscript_or_superscript = False + optional_spacing = ([self._make_space(self._space_widths[r'\,'])] + if self._needs_space_after_subsuper else []) + self._needs_space_after_subsuper = False + result = Hlist([vlt, *optional_spacing]) return [result] # We remove kerning on the last character for consistency (otherwise @@ -2579,12 +2573,10 @@ 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: 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 - - result = Hlist(spaced_nucleus) + optional_spacing = ([self._make_space(self._space_widths[r'\,'])] + if self._needs_space_after_subsuper else []) + self._needs_space_after_subsuper = False + result = Hlist([nucleus, x, *optional_spacing]) return [result] def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathStyle, From daae68a3686dcdc761291ff3b6192bf08736d033 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 18 Jul 2025 03:50:41 -0400 Subject: [PATCH 052/108] Expose face index when loading fonts This enables loading a non-initial font from collections (`.ttc` files). Currently exposed for `FT2Font`, only. --- lib/matplotlib/ft2font.pyi | 3 +++ lib/matplotlib/tests/test_ft2font.py | 19 +++++++++++++++++++ src/ft2font.cpp | 4 ++-- src/ft2font.h | 2 +- src/ft2font_wrapper.cpp | 19 +++++++++++++++---- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 9345c1c9057f..71bd89f81561 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -214,6 +214,7 @@ class FT2Font(Buffer): filename: str | bytes | PathLike | BinaryIO, hinting_factor: int = ..., *, + face_index: int = ..., _fallback_list: list[FT2Font] | None = ..., _kerning_factor: int | None = ... ) -> None: ... @@ -283,6 +284,8 @@ class FT2Font(Buffer): @property def face_flags(self) -> FaceFlags: ... @property + def face_index(self) -> int: ... + @property def family_name(self) -> str: ... @property def fname(self) -> str | bytes: ... diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 4a874deb5343..17492e7690c0 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -199,6 +199,25 @@ def test_ft2font_invalid_args(tmp_path): ft2font.FT2Font(file, _kerning_factor=123) +@pytest.mark.parametrize('name, size, skippable', + [('DejaVu Sans', 1, False), ('WenQuanYi Zen Hei', 3, True)]) +def test_ft2font_face_index(name, size, skippable): + try: + file = fm.findfont(name, fallback_to_default=False) + except ValueError: + if skippable: + pytest.skip(r'Font {name} may be missing') + raise + for index in range(size): + font = ft2font.FT2Font(file, face_index=index) + assert font.num_faces >= size + assert font.face_index == index + with pytest.raises(ValueError, match='must be between'): # out of bounds for spec + ft2font.FT2Font(file, face_index=0x1ffff) + with pytest.raises(RuntimeError, match='invalid argument'): # invalid for this font + ft2font.FT2Font(file, face_index=0xff) + + def test_ft2font_clear(): file = fm.findfont('DejaVu Sans') font = ft2font.FT2Font(file) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 0e98506536d0..b70f3a29d469 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -222,9 +222,9 @@ FT2Font::~FT2Font() close(); } -void FT2Font::open(FT_Open_Args &open_args) +void FT2Font::open(FT_Open_Args &open_args, FT_Long face_index) { - FT_CHECK(FT_Open_Face, _ft2Library, &open_args, 0, &face); + FT_CHECK(FT_Open_Face, _ft2Library, &open_args, face_index, &face); if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } diff --git a/src/ft2font.h b/src/ft2font.h index b36bc4f02a76..68d31bac9a41 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -107,7 +107,7 @@ class FT2Font FT2Font(long hinting_factor, std::vector &fallback_list, bool warn_if_used); virtual ~FT2Font(); - void open(FT_Open_Args &open_args); + void open(FT_Open_Args &open_args, FT_Long face_index); void close(); void clear(); void set_size(double ptsize, double dpi); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 21d8b01656b8..d5cf07e7762d 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -430,6 +430,9 @@ const char *PyFT2Font_init__doc__ = R"""( hinting_factor : int, optional Must be positive. Used to scale the hinting in the x-direction. + face_index : int, optional + The index of the face in the font file to load. + _fallback_list : list of FT2Font, optional A list of FT2Font objects used to find missing glyphs. @@ -444,7 +447,7 @@ const char *PyFT2Font_init__doc__ = R"""( )"""; static PyFT2Font * -PyFT2Font_init(py::object filename, long hinting_factor = 8, +PyFT2Font_init(py::object filename, long hinting_factor = 8, FT_Long face_index = 0, std::optional> fallback_list = std::nullopt, std::optional kerning_factor = std::nullopt, bool warn_if_used = false) @@ -460,6 +463,10 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, kerning_factor = 0; } + if (face_index < 0 || face_index > 0xffff) { + throw std::range_error("face_index must be between 0 and 65535, inclusive"); + } + std::vector fallback_fonts; if (fallback_list) { // go through fallbacks to add them to our lists @@ -509,7 +516,7 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, self->stream.close = nullptr; } - self->open(open_args); + self->open(open_args, face_index); return self; } @@ -1637,7 +1644,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) 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(), + "filename"_a, "hinting_factor"_a=8, py::kw_only(), "face_index"_a=0, "_fallback_list"_a=py::none(), "_kerning_factor"_a=py::none(), "_warn_if_used"_a=false, PyFT2Font_init__doc__) @@ -1714,8 +1721,12 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) }, "PostScript name of the font.") .def_property_readonly( "num_faces", [](PyFT2Font *self) { - return self->get_face()->num_faces; + return self->get_face()->num_faces & 0xffff; }, "Number of faces in file.") + .def_property_readonly( + "face_index", [](PyFT2Font *self) { + return self->get_face()->face_index; + }, "The index of the font in the file.") .def_property_readonly( "family_name", [](PyFT2Font *self) { if (const char *name = self->get_face()->family_name) { From b839f87b93f0a6447569768ff745de2ddefec5aa Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 18 Jul 2025 04:51:05 -0400 Subject: [PATCH 053/108] Parse data from all fonts within a collection This should allow listing the metadata from the whole collection, which will also pick the right one if specified, though it will not load the specific index yet. --- lib/matplotlib/font_manager.py | 12 +++++++++--- lib/matplotlib/font_manager.pyi | 1 + lib/matplotlib/tests/test_font_manager.py | 11 ++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index d1a96826fbf6..a8427cdbaa30 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -319,6 +319,7 @@ class FontEntry: """ fname: str = '' + index: int = 0 name: str = '' style: str = 'normal' variant: str = 'normal' @@ -465,7 +466,8 @@ def get_weight(): # From fontconfig's FcFreeTypeQueryFaceInternal. raise NotImplementedError("Non-scalable fonts are not supported") size = 'scalable' - return FontEntry(font.fname, name, style, variant, weight, stretch, size) + return FontEntry(font.fname, font.face_index, name, + style, variant, weight, stretch, size) def afmFontProperty(fontpath, font): @@ -535,7 +537,7 @@ def afmFontProperty(fontpath, font): size = 'scalable' - return FontEntry(fontpath, name, style, variant, weight, stretch, size) + return FontEntry(fontpath, 0, name, style, variant, weight, stretch, size) def _cleanup_fontproperties_init(init_method): @@ -1069,7 +1071,7 @@ class FontManager: # Increment this version number whenever the font cache data # format or behavior has changed and requires an existing font # cache files to be rebuilt. - __version__ = '3.11.0a1' + __version__ = '3.11.0a2' def __init__(self, size=None, weight='normal'): self._version = self.__version__ @@ -1134,6 +1136,10 @@ def addfont(self, path): font = ft2font.FT2Font(path) prop = ttfFontProperty(font) self.ttflist.append(prop) + for face_index in range(1, font.num_faces): + subfont = ft2font.FT2Font(path, face_index=face_index) + prop = ttfFontProperty(subfont) + self.ttflist.append(prop) self._findfont_cached.cache_clear() @property diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index f5e3910e5f63..6b072f707f66 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -29,6 +29,7 @@ def findSystemFonts( @dataclass class FontEntry: fname: str = ... + index: int = ... name: str = ... style: str = ... variant: str = ... diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index d51eb8d9837f..6301163b4527 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -117,8 +117,13 @@ def test_utf16m_sfnt(): def test_find_ttc(): fp = FontProperties(family=["WenQuanYi Zen Hei"]) - if Path(findfont(fp)).name != "wqy-zenhei.ttc": + fontpath = findfont(fp) + if Path(fontpath).name != "wqy-zenhei.ttc": pytest.skip("Font wqy-zenhei.ttc may be missing") + # All fonts from this collection should have loaded as well. + for name in ["WenQuanYi Zen Hei Mono", "WenQuanYi Zen Hei Sharp"]: + assert findfont(FontProperties(family=[name]), + fallback_to_default=False) == fontpath fig, ax = plt.subplots() ax.text(.5, .5, "\N{KANGXI RADICAL DRAGON}", fontproperties=fp) for fmt in ["raw", "svg", "pdf", "ps"]: @@ -363,6 +368,10 @@ def test_get_font_names(): font = ft2font.FT2Font(path) prop = ttfFontProperty(font) ttf_fonts.append(prop.name) + for face_index in range(1, font.num_faces): + font = ft2font.FT2Font(path, face_index=face_index) + prop = ttfFontProperty(font) + ttf_fonts.append(prop.name) except Exception: pass available_fonts = sorted(list(set(ttf_fonts))) From 6d2ae678f44e0250e5ecbedccad5dc634d4cbd62 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 25 Jul 2025 03:33:54 -0400 Subject: [PATCH 054/108] Implement loading of any font in a collection For backwards-compatibility, the path+index is passed around in a lightweight subclass of `str`. --- lib/matplotlib/font_manager.py | 97 ++++++++++++++++++++--- lib/matplotlib/font_manager.pyi | 22 ++++- lib/matplotlib/tests/test_font_manager.py | 44 +++++++++- 3 files changed, 144 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index a8427cdbaa30..5ca1c9aeafb7 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -310,6 +310,69 @@ def findSystemFonts(fontpaths=None, fontext='ttf'): return [fname for fname in fontfiles if os.path.exists(fname)] +# To maintain backwards-compatibility with the current code we need to continue to +# return a str. However to support indexing into the file we need to return both the +# path and the index. Thus, we sub-class str to maintain compatibility and extend it to +# carry the index. +# +# The other alternative would be to create a completely new API and deprecate the +# existing one. In this case, sub-classing str is the simpler and less-disruptive +# option. +class FontPath(str): + """ + A class to describe a path to a font with a face index. + + Parameters + ---------- + path : str + The path to a font. + face_index : int + The face index in the font. + """ + + __match_args__ = ('path', 'face_index') + + def __new__(cls, path, face_index): + ret = super().__new__(cls, path) + ret._face_index = face_index + return ret + + @property + def path(self): + """The path to a font.""" + return str(self) + + @property + def face_index(self): + """The face index in a font.""" + return self._face_index + + def _as_tuple(self): + return (self.path, self.face_index) + + def __eq__(self, other): + if isinstance(other, FontPath): + return self._as_tuple() == other._as_tuple() + return super().__eq__(other) + + def __ne__(self, other): + return not (self == other) + + def __lt__(self, other): + if isinstance(other, FontPath): + return self._as_tuple() < other._as_tuple() + return super().__lt__(other) + + def __gt__(self, other): + return not (self == other or self < other) + + def __hash__(self): + return hash(self._as_tuple()) + + def __repr__(self): + return f'FontPath{self._as_tuple()}' + + @dataclasses.dataclass(frozen=True) class FontEntry: """ @@ -1326,7 +1389,7 @@ def findfont(self, prop, fontext='ttf', directory=None, Returns ------- - str + FontPath The filename of the best matching font. Notes @@ -1396,7 +1459,7 @@ def _find_fonts_by_props(self, prop, fontext='ttf', directory=None, Returns ------- - list[str] + list[FontPath] The paths of the fonts found. Notes @@ -1542,7 +1605,7 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default, # actually raised. return cbook._ExceptionInfo(ValueError, "No valid font could be found") - return _cached_realpath(result) + return FontPath(_cached_realpath(result), best_font.index) @_api.deprecated("3.11") @@ -1562,15 +1625,16 @@ def is_opentype_cff_font(filename): @lru_cache(64) def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id, enable_last_resort): - first_fontpath, *rest = font_filepaths + (first_fontpath, first_fontindex), *rest = font_filepaths fallback_list = [ - ft2font.FT2Font(fpath, hinting_factor, _kerning_factor=_kerning_factor) - for fpath in rest + ft2font.FT2Font(fpath, hinting_factor, face_index=index, + _kerning_factor=_kerning_factor) + for fpath, index in rest ] last_resort_path = _cached_realpath( cbook._get_data_path('fonts', 'ttf', 'LastResortHE-Regular.ttf')) try: - last_resort_index = font_filepaths.index(last_resort_path) + last_resort_index = font_filepaths.index((last_resort_path, 0)) except ValueError: last_resort_index = -1 # Add Last Resort font so we always have glyphs regardless of font, unless we're @@ -1582,7 +1646,7 @@ def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id, _warn_if_used=True)) last_resort_index = len(fallback_list) font = ft2font.FT2Font( - first_fontpath, hinting_factor, + first_fontpath, hinting_factor, face_index=first_fontindex, _fallback_list=fallback_list, _kerning_factor=_kerning_factor ) @@ -1617,7 +1681,8 @@ def get_font(font_filepaths, hinting_factor=None): Parameters ---------- - font_filepaths : Iterable[str, bytes, os.PathLike], str, bytes, os.PathLike + font_filepaths : Iterable[str, bytes, os.PathLike, FontPath], \ +str, bytes, os.PathLike, FontPath Relative or absolute paths to the font files to be used. If a single string, bytes, or `os.PathLike`, then it will be treated @@ -1632,10 +1697,16 @@ def get_font(font_filepaths, hinting_factor=None): `.ft2font.FT2Font` """ - if isinstance(font_filepaths, (str, bytes, os.PathLike)): - paths = (_cached_realpath(font_filepaths),) - else: - paths = tuple(_cached_realpath(fname) for fname in font_filepaths) + match font_filepaths: + case FontPath(path, index): + paths = ((_cached_realpath(path), index), ) + case str() | bytes() | os.PathLike() as path: + paths = ((_cached_realpath(path), 0), ) + case _: + paths = tuple( + (_cached_realpath(fname.path), fname.face_index) + if isinstance(fname, FontPath) else (_cached_realpath(fname), 0) + for fname in font_filepaths) hinting_factor = mpl._val_or_rc(hinting_factor, 'text.hinting_factor') diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index 6b072f707f66..936dad426522 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -3,7 +3,7 @@ from dataclasses import dataclass from numbers import Integral import os from pathlib import Path -from typing import Any, Literal +from typing import Any, Final, Literal from matplotlib._afm import AFM from matplotlib import ft2font @@ -26,6 +26,22 @@ def _get_fontconfig_fonts() -> list[Path]: ... def findSystemFonts( fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ... ) -> list[str]: ... + +class FontPath(str): + __match_args__: Final[tuple[str, ...]] + def __new__(cls: type[str], path: str, face_index: int) -> FontPath: ... + @property + def path(self) -> str: ... + @property + def face_index(self) -> int: ... + def _as_tuple(self) -> tuple[str, int]: ... + def __eq__(self, other: Any) -> bool: ... + def __ne__(self, other: Any) -> bool: ... + def __lt__(self, other: Any) -> bool: ... + def __gt__(self, other: Any) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + @dataclass class FontEntry: fname: str = ... @@ -116,12 +132,12 @@ class FontManager: directory: str | None = ..., fallback_to_default: bool = ..., rebuild_if_missing: bool = ..., - ) -> str: ... + ) -> FontPath: ... def get_font_names(self) -> list[str]: ... def is_opentype_cff_font(filename: str) -> bool: ... def get_font( - font_filepaths: Iterable[str | bytes | os.PathLike] | str | bytes | os.PathLike, + font_filepaths: Iterable[str | bytes | os.PathLike | FontPath] | str | bytes | os.PathLike | FontPath, hinting_factor: int | None = ..., ) -> ft2font.FT2Font: ... diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 6301163b4527..cc8ae03a9f97 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -13,7 +13,7 @@ import matplotlib as mpl from matplotlib.font_manager import ( - findfont, findSystemFonts, FontEntry, FontProperties, fontManager, + findfont, findSystemFonts, FontEntry, FontPath, FontProperties, fontManager, json_dump, json_load, get_font, is_opentype_cff_font, MSUserFontDirectories, ttfFontProperty, _get_fontconfig_fonts, _normalize_weight) @@ -24,6 +24,38 @@ has_fclist = shutil.which('fc-list') is not None +def test_font_path(): + fp = FontPath('foo', 123) + fp2 = FontPath('foo', 321) + assert str(fp) == 'foo' + assert repr(fp) == "FontPath('foo', 123)" + assert fp.path == 'foo' + assert fp.face_index == 123 + # Should be immutable. + with pytest.raises(AttributeError, match='has no setter'): + fp.path = 'bar' + with pytest.raises(AttributeError, match='has no setter'): + fp.face_index = 321 + # Should be comparable with str and itself. + assert fp == 'foo' + assert fp == FontPath('foo', 123) + assert fp <= fp + assert fp >= fp + assert fp != fp2 + assert fp < fp2 + assert fp <= fp2 + assert fp2 > fp + assert fp2 >= fp + # Should be hashable, but not the same as str. + d = {fp: 1, 'bar': 2} + assert fp in d + assert d[fp] == 1 + assert d[FontPath('foo', 123)] == 1 + assert fp2 not in d + assert 'foo' not in d + assert FontPath('bar', 0) not in d + + def test_font_priority(): with rc_context(rc={ 'font.sans-serif': @@ -122,8 +154,12 @@ def test_find_ttc(): pytest.skip("Font wqy-zenhei.ttc may be missing") # All fonts from this collection should have loaded as well. for name in ["WenQuanYi Zen Hei Mono", "WenQuanYi Zen Hei Sharp"]: - assert findfont(FontProperties(family=[name]), - fallback_to_default=False) == fontpath + subfontpath = findfont(FontProperties(family=[name]), fallback_to_default=False) + assert subfontpath.path == fontpath.path + assert subfontpath.face_index != fontpath.face_index + subfont = get_font(subfontpath) + assert subfont.fname == subfontpath.path + assert subfont.face_index == subfontpath.face_index fig, ax = plt.subplots() ax.text(.5, .5, "\N{KANGXI RADICAL DRAGON}", fontproperties=fp) for fmt in ["raw", "svg", "pdf", "ps"]: @@ -161,6 +197,8 @@ def __fspath__(self): assert font.fname == file_str font = get_font(PathLikeClass(file_bytes)) assert font.fname == file_bytes + font = get_font(FontPath(file_str, 0)) + assert font.fname == file_str # Note, fallbacks are not currently accessible. font = get_font([file_str, file_bytes, From ea80bbb58f6c349da05b3eb94c766059c0ac9bc8 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 25 Jul 2025 06:13:59 -0400 Subject: [PATCH 055/108] pdf/ps: Support any font in a collection --- lib/matplotlib/backends/_backend_pdf_ps.py | 10 +++--- lib/matplotlib/backends/backend_pdf.py | 29 +++++++++-------- lib/matplotlib/backends/backend_ps.py | 12 +++----- lib/matplotlib/dviread.py | 4 +++ lib/matplotlib/dviread.pyi | 2 ++ lib/matplotlib/tests/test_backend_pdf.py | 35 ++++++++++++++++++++- lib/matplotlib/tests/test_backend_ps.py | 36 +++++++++++++++++++++- 7 files changed, 101 insertions(+), 27 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index b1a3f5c9f18b..83a8566517a7 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -42,7 +42,7 @@ def get_glyphs_subset(fontfile: str, glyphs: set[GlyphIndexType]) -> TTFont: Parameters ---------- - fontfile : str + fontfile : FontPath Path to the font file glyphs : set[GlyphIndexType] Set of glyph indices to include in subset. @@ -80,8 +80,7 @@ def get_glyphs_subset(fontfile: str, glyphs: set[GlyphIndexType]) -> TTFont: 'xref', # The cross-reference table (some Apple font tooling information). ] # if fontfile is a ttc, specify font number - if fontfile.endswith(".ttc"): - options.font_number = 0 + options.font_number = fontfile.face_index font = subset.load_font(fontfile, options) subsetter = subset.Subsetter(options=options) @@ -267,11 +266,12 @@ def track_glyph(self, font: FT2Font, chars: str | CharacterCodeType, charcode = chars chars = chr(chars) - glyph_map = self.glyph_maps.setdefault(font.fname, GlyphMap()) + font_path = font_manager.FontPath(font.fname, font.face_index) + glyph_map = self.glyph_maps.setdefault(font_path, GlyphMap()) if result := glyph_map.get(chars, glyph): return result - subset_maps = self.used.setdefault(font.fname, [{}]) + subset_maps = self.used.setdefault(font_path, [{}]) use_next_charmap = ( # Multi-character glyphs always go in the non-0 subset. len(chars) > 1 or diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 613b00987730..a926cb41bb3b 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -32,7 +32,7 @@ RendererBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.figure import Figure -from matplotlib.font_manager import get_font, fontManager as _fontManager +from matplotlib.font_manager import FontPath, get_font, fontManager as _fontManager from matplotlib._afm import AFM from matplotlib.ft2font import FT2Font, FaceFlags, LoadFlags, StyleFlags from matplotlib.transforms import Affine2D, BboxBase @@ -894,8 +894,10 @@ def fontName(self, fontprop, subset=0): as the filename of the font. """ - if isinstance(fontprop, str): + if isinstance(fontprop, FontPath): filenames = [fontprop] + elif isinstance(fontprop, str): + filenames = [FontPath(fontprop, 0)] elif mpl.rcParams['pdf.use14corefonts']: filenames = _fontManager._find_fonts_by_props( fontprop, fontext='afm', directory=RendererPdf._afm_font_dir @@ -935,7 +937,7 @@ def writeFonts(self): _log.debug('Embedding Type-1 font %s from dvi.', dvifont.texname) fonts[pdfname] = self._embedTeXFont(dvifont) for (filename, subset), Fx in sorted(self._fontNames.items()): - _log.debug('Embedding font %s:%d.', filename, subset) + _log.debug('Embedding font %r:%d.', filename, subset) if filename.endswith('.afm'): # from pdf.use14corefonts _log.debug('Writing AFM font.') @@ -986,10 +988,11 @@ def _embedTeXFont(self, dvifont): # Reduce the font to only the glyphs used in the document, get the encoding # for that subset, and compute various properties based on the encoding. - charmap = self._character_tracker.used[dvifont.fname][0] + font_path = FontPath(dvifont.fname, dvifont.face_index) + charmap = self._character_tracker.used[font_path][0] chars = { # DVI type 1 fonts always map single glyph to single character. - ord(self._character_tracker.subset_to_unicode(dvifont.fname, 0, ccode)) + ord(self._character_tracker.subset_to_unicode(font_path, 0, ccode)) for ccode in charmap } t1font = t1font.subset(chars, self._get_subset_prefix(charmap.values())) @@ -1241,12 +1244,12 @@ def embedTTFType42(font, subset_index, charmap, descriptor): wObject = self.reserveObject('Type 0 widths') toUnicodeMapObject = self.reserveObject('ToUnicode map') - _log.debug("SUBSET %s:%d characters: %s", filename, subset_index, charmap) + _log.debug("SUBSET %r:%d characters: %s", filename, subset_index, charmap) with _backend_pdf_ps.get_glyphs_subset(filename, charmap.values()) as subset: fontdata = _backend_pdf_ps.font_as_file(subset) _log.debug( - "SUBSET %s:%d %d -> %d", filename, subset_index, + "SUBSET %r:%d %d -> %d", filename, subset_index, os.stat(filename).st_size, fontdata.getbuffer().nbytes ) @@ -2137,13 +2140,13 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): for font, fontsize, ccode, glyph_index, ox, oy in glyphs: subset_index, subset_charcode = self.file._character_tracker.track_glyph( font, ccode, glyph_index) - fontname = font.fname + font_path = FontPath(font.fname, font.face_index) self._setup_textpos(ox, oy, 0, oldx, oldy) oldx, oldy = ox, oy - if (fontname, subset_index, fontsize) != prev_font: - self.file.output(self.file.fontName(fontname, subset_index), fontsize, + if (font_path, subset_index, fontsize) != prev_font: + self.file.output(self.file.fontName(font_path, subset_index), fontsize, Op.selectfont) - prev_font = fontname, subset_index, fontsize + prev_font = font_path, subset_index, fontsize self.file.output(self._encode_glyphs([subset_charcode], fonttype), Op.show) self.file.output(Op.end_text) @@ -2338,7 +2341,9 @@ def output_singlebyte_chunk(kerns_or_chars): item.ft_object, item.char, item.glyph_index) if (item.ft_object, subset) != prev_font: output_singlebyte_chunk(singlebyte_chunk) - ft_name = self.file.fontName(item.ft_object.fname, subset) + font_path = FontPath(item.ft_object.fname, + item.ft_object.face_index) + ft_name = self.file.fontName(font_path, subset) self.file.output(ft_name, fontsize, Op.selectfont) self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0) prev_font = (item.ft_object, subset) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 8ad31290a643..3bdf7a0c514b 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -94,7 +94,7 @@ def _font_to_ps_type3(font_path, subset_index, glyph_indices): Parameters ---------- - font_path : path-like + font_path : FontPath Path to the font to be subsetted. subset_index : int The subset of the above font being created. @@ -176,7 +176,7 @@ def _font_to_ps_type42(font_path, subset_index, glyph_indices, fh): Parameters ---------- - font_path : path-like + font_path : FontPath Path to the font to be subsetted. subset_index : int The subset of the above font being created. @@ -187,12 +187,8 @@ def _font_to_ps_type42(font_path, subset_index, glyph_indices, fh): """ _log.debug("SUBSET %s:%d characters: %s", font_path, subset_index, glyph_indices) try: - kw = {} - # fix this once we support loading more fonts from a collection - # https://github.com/matplotlib/matplotlib/issues/3135#issuecomment-571085541 - if font_path.endswith('.ttc'): - kw['fontNumber'] = 0 - with (fontTools.ttLib.TTFont(font_path, **kw) as font, + with (fontTools.ttLib.TTFont(font_path.path, + fontNumber=font_path.face_index) as font, _backend_pdf_ps.get_glyphs_subset(font_path, glyph_indices) as subset): fontdata = _backend_pdf_ps.font_as_file(subset).getvalue() _log.debug( diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index f07157a63524..1a79e7277be8 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -719,6 +719,10 @@ def fname(self): """A fake filename""" return self.texname.decode('latin-1') + @property + def face_index(self): # For compatibility with FT2Font. + return 0 + def _get_fontmap(self, string): """Get the mapping from characters to the font that includes them. diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 1a3b3943d07b..de429bd0b7f1 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -78,6 +78,8 @@ class DviFont: def widths(self) -> list[int]: ... @property def fname(self) -> str: ... + @property + def face_index(self) -> int: ... def resolve_path(self) -> Path: ... @property def subfont(self) -> int: ... diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 2dc22fd9170e..2a9c3542e277 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -3,6 +3,7 @@ import io import os from pathlib import Path +import string import numpy as np import pytest @@ -365,7 +366,7 @@ def test_glyphs_subset(): # subsetted FT2Font glyph_indices = {nosubcmap[ord(c)] for c in chars} - with get_glyphs_subset(fpath, glyph_indices) as subset: + with get_glyphs_subset(fm.FontPath(fpath, 0), glyph_indices) as subset: subfont = FT2Font(font_as_file(subset)) subfont.set_text(chars) subcmap = subfont.get_charmap() @@ -402,6 +403,38 @@ def test_multi_font_type42(): horizontalalignment='center', verticalalignment='center') +@image_comparison(['ttc_type3.pdf'], style='mpl20') +def test_ttc_type3(): + fp = fm.FontProperties(family=['WenQuanYi Zen Hei']) + if Path(fm.findfont(fp)).name != 'wqy-zenhei.ttc': + pytest.skip('Font wqy-zenhei.ttc may be missing') + + fonts = ['WenQuanYi Zen Hei', 'WenQuanYi Zen Hei Mono'] + plt.rc('font', size=16) + plt.rc('pdf', fonttype=3) + + figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts)) + for font, fig in zip(fonts, figs): + fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font, + horizontalalignment='center', verticalalignment='center') + + +@image_comparison(['ttc_type42.pdf'], style='mpl20') +def test_ttc_type42(): + fp = fm.FontProperties(family=['WenQuanYi Zen Hei']) + if Path(fm.findfont(fp)).name != 'wqy-zenhei.ttc': + pytest.skip('Font wqy-zenhei.ttc may be missing') + + fonts = ['WenQuanYi Zen Hei', 'WenQuanYi Zen Hei Mono'] + plt.rc('font', size=16) + plt.rc('pdf', fonttype=42) + + figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts)) + for font, fig in zip(fonts, figs): + fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font, + horizontalalignment='center', verticalalignment='center') + + @pytest.mark.parametrize('family_name, file_name', [("Noto Sans", "NotoSans-Regular.otf"), ("FreeMono", "FreeMono.otf")]) diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index 9859a286e5fd..bb6b08d14a6d 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -1,12 +1,14 @@ from collections import Counter import io +from pathlib import Path import re +import string import tempfile import numpy as np import pytest -from matplotlib import cbook, path, patheffects +from matplotlib import cbook, font_manager, path, patheffects from matplotlib.figure import Figure from matplotlib.patches import Ellipse from matplotlib.testing import _gen_multi_font_text @@ -340,6 +342,38 @@ def test_multi_font_type42(): horizontalalignment='center', verticalalignment='center') +@image_comparison(['ttc_type3.eps'], style='mpl20') +def test_ttc_type3(): + fp = font_manager.FontProperties(family=['WenQuanYi Zen Hei']) + if Path(font_manager.findfont(fp)).name != 'wqy-zenhei.ttc': + pytest.skip('Font wqy-zenhei.ttc may be missing') + + fonts = ['WenQuanYi Zen Hei', 'WenQuanYi Zen Hei Mono'] + plt.rc('font', size=16) + plt.rc('pdf', fonttype=3) + + figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts)) + for font, fig in zip(fonts, figs): + fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font, + horizontalalignment='center', verticalalignment='center') + + +@image_comparison(['ttc_type42.eps'], style='mpl20') +def test_ttc_type42(): + fp = font_manager.FontProperties(family=['WenQuanYi Zen Hei']) + if Path(font_manager.findfont(fp)).name != 'wqy-zenhei.ttc': + pytest.skip('Font wqy-zenhei.ttc may be missing') + + fonts = ['WenQuanYi Zen Hei', 'WenQuanYi Zen Hei Mono'] + plt.rc('font', size=16) + plt.rc('pdf', fonttype=42) + + figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts)) + for font, fig in zip(fonts, figs): + fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font, + horizontalalignment='center', verticalalignment='center') + + @image_comparison(["scatter.eps"]) def test_path_collection(): rng = np.random.default_rng(19680801) From e9917908b800db32f57acd3a8b3a58df54937dd0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 25 Jul 2025 19:53:21 -0400 Subject: [PATCH 056/108] pgf: Support any font in a collection Note, this only has an effect if set as the global font. Otherwise, just the font name is recorded, and the TeX engine's normal lookup is performed. --- lib/matplotlib/backends/backend_pgf.py | 25 ++++++++++++------------ lib/matplotlib/tests/test_backend_pgf.py | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 48b6e8ac152c..ab9782b369a3 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -38,9 +38,17 @@ def _get_preamble(): """Prepare a LaTeX preamble based on the rcParams configuration.""" - font_size_pt = FontProperties( - size=mpl.rcParams["font.size"] - ).get_size_in_points() + def _to_fontspec(): + for command, family in [("setmainfont", "serif"), + ("setsansfont", "sans\\-serif"), + ("setmonofont", "monospace")]: + font_path = fm.findfont(family) + path = pathlib.Path(font_path) + yield r" \%s{%s}[Path=\detokenize{%s/}%s]" % ( + command, path.name, path.parent.as_posix(), + f',FontIndex={font_path.face_index:d}' if path.suffix == '.ttc' else '') + + font_size_pt = FontProperties(size=mpl.rcParams["font.size"]).get_size_in_points() return "\n".join([ # Remove Matplotlib's custom command \mathdefault. (Not using # \mathnormal instead since this looks odd with Computer Modern.) @@ -63,15 +71,8 @@ def _get_preamble(): *([ r"\ifdefined\pdftexversion\else % non-pdftex case.", r" \usepackage{fontspec}", - ] + [ - r" \%s{%s}[Path=\detokenize{%s/}]" - % (command, path.name, path.parent.as_posix()) - for command, path in zip( - ["setmainfont", "setsansfont", "setmonofont"], - [pathlib.Path(fm.findfont(family)) - for family in ["serif", "sans\\-serif", "monospace"]] - ) - ] + [r"\fi"] if mpl.rcParams["pgf.rcfonts"] else []), + *_to_fontspec(), + r"\fi"] if mpl.rcParams["pgf.rcfonts"] else []), # Documented as "must come last". mpl.texmanager._usepackage_if_not_loaded("underscore", option="strings"), ]) diff --git a/lib/matplotlib/tests/test_backend_pgf.py b/lib/matplotlib/tests/test_backend_pgf.py index e218a81cdceb..e5b73c9450f3 100644 --- a/lib/matplotlib/tests/test_backend_pgf.py +++ b/lib/matplotlib/tests/test_backend_pgf.py @@ -1,7 +1,9 @@ import datetime from io import BytesIO import os +from pathlib import Path import shutil +import string import numpy as np from packaging.version import parse as parse_version @@ -9,6 +11,7 @@ import matplotlib as mpl import matplotlib.pyplot as plt +from matplotlib.font_manager import FontProperties, findfont from matplotlib.testing import _has_tex_package, _check_for_pgf from matplotlib.testing.exceptions import ImageComparisonFailure from matplotlib.testing.compare import compare_images @@ -330,6 +333,23 @@ def test_png_transparency(): # Actually, also just testing that png works. assert (t[..., 3] == 0).all() # fully transparent. +@needs_pgf_xelatex +@pytest.mark.backend('pgf') +@image_comparison(['ttc_pgf.pdf'], style='mpl20') +def test_ttc_output(): + fp = FontProperties(family=['WenQuanYi Zen Hei']) + if Path(findfont(fp)).name != 'wqy-zenhei.ttc': + pytest.skip('Font wqy-zenhei.ttc may be missing') + + fonts = {'sans-serif': 'WenQuanYi Zen Hei', 'monospace': 'WenQuanYi Zen Hei Mono'} + plt.rc('font', size=16, **fonts) + + figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts)) + for font, fig in zip(fonts.values(), figs): + fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font, + horizontalalignment='center', verticalalignment='center') + + @needs_pgf_xelatex def test_unknown_font(caplog): with caplog.at_level("WARNING"): From 9c20b0ce57938213201897e7c9cf3dc012e78cb2 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 31 Oct 2025 04:33:26 -0400 Subject: [PATCH 057/108] DOC: Add what's new note for TTC loading --- doc/release/next_whats_new/ttc_fonts.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 doc/release/next_whats_new/ttc_fonts.rst diff --git a/doc/release/next_whats_new/ttc_fonts.rst b/doc/release/next_whats_new/ttc_fonts.rst new file mode 100644 index 000000000000..b80b1186707b --- /dev/null +++ b/doc/release/next_whats_new/ttc_fonts.rst @@ -0,0 +1,18 @@ +Support for loading TrueType Collection fonts +--------------------------------------------- + +TrueType Collection fonts (commonly found as files with a ``.ttc`` extension) are now +supported. Namely, Matplotlib will include these file extensions in its scan for system +fonts, and will add all sub-fonts to its list of available fonts (i.e., the list from +`~.font_manager.get_font_names`). + +From most high-level API, this means you should be able to specify the name of any +sub-font in a collection just as you would any other font. Note that at this time, there +is no way to specify the entire collection with any sort of automated selection of the +internal sub-fonts. + +In the low-level API, to ensure backwards-compatibility while facilitating this new +support, a `.FontPath` instance (comprised of a font path and a sub-font index, with +behaviour similar to a `str`) may be passed to the font management API in place of a +simple `os.PathLike` path. Any font management API that previously returned a string path +now returns a `.FontPath` instance instead. From b2aa1f238d355551d6cff5866c2feccb69f27cbd Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 26 Sep 2025 00:26:26 -0400 Subject: [PATCH 058/108] Update test images for previous changes --- .../test_backend_pdf/ttc_type3.pdf | Bin 0 -> 21114 bytes .../test_backend_pdf/ttc_type42.pdf | Bin 0 -> 9696 bytes .../test_backend_pgf/ttc_pgf.pdf | Bin 0 -> 15526 bytes .../test_backend_ps/ttc_type3.eps | 1483 +++++++++++++++++ .../test_backend_ps/ttc_type42.eps | 1483 +++++++++++++++++ 5 files changed, 2966 insertions(+) create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_pdf/ttc_type3.pdf create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_pdf/ttc_type42.pdf create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_pgf/ttc_pgf.pdf create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_ps/ttc_type3.eps create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_ps/ttc_type42.eps diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/ttc_type3.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/ttc_type3.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a3fad017236451b768f01358368b601991bf5cf8 GIT binary patch literal 21114 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 Ch|0hK literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/ttc_type42.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/ttc_type42.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d43f233ef4e6bba524f535b400d38f7fe5a45bac GIT binary patch literal 9696 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 LJG-2QJn?@4aq!mr literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pgf/ttc_pgf.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pgf/ttc_pgf.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5d695e734577100ab881040016fbbdb0d76a016a GIT binary patch literal 15526 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 K Date: Wed, 21 Jan 2026 04:24:00 -0500 Subject: [PATCH 059/108] MNT: Update rcParam code to match new text settings --- lib/matplotlib/rcsetup.py | 29 ++++++++++++++++++++--------- lib/matplotlib/typing.py | 1 + 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 49b002da45fe..af20db88426b 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1762,8 +1762,19 @@ class _Param: default="black", validator=validate_color ), - _Param("text.hinting", - default="force_autohint", + _Param( + "text.language", + default=None, + validator=validate_string_or_None, + description="The language of the text in a format accepted by libraqm, namely " + "`a BCP47 language code " + "`_. If " + "None, then no particular language will be implied, and default " + "font settings will be used." + ), + _Param( + "text.hinting", + default="default", validator=[ "default", "no_autohint", "force_autohint", "no_hinting", "auto", "native", "either", "none", @@ -1781,7 +1792,7 @@ class _Param: ), _Param( "text.hinting_factor", - default=8, + default=1, validator=validate_int, description="Specifies the amount of softness for hinting in the horizontal " "direction. A value of 1 will hint to full pixels. A value of 2 " @@ -1789,12 +1800,12 @@ class _Param: ), _Param( "text.kerning_factor", - default=0, - validator=validate_int, - description="Specifies the scaling factor for kerning values. This is " - "provided solely to allow old test images to remain unchanged. " - "Set to 6 to obtain previous behavior. Values other than 0 or 6 " - "have no defined meaning." + default=None, + validator=validate_int_or_None, + description="[DEPRECATED] Specifies the scaling factor for kerning values. " + "This is provided solely to allow old test images to remain " + "unchanged. Set to 6 to obtain previous behavior. Values other " + "than 0 or 6 have no defined meaning." ), _Param( "text.antialiased", diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index d2e12c6e08d9..ea9f6d7db2fd 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -451,6 +451,7 @@ "text.hinting", "text.hinting_factor", "text.kerning_factor", + "text.language", "text.latex.preamble", "text.parse_math", "text.usetex", From 9d7d7b42ab5f241499f69cadab99e2cd506173c9 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 16 May 2025 08:29:24 +0200 Subject: [PATCH 060/108] 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; From 535fdaa61e64597c2f0b4b03f6cd4bcec2e9ab14 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 2 Feb 2026 11:01:50 +0100 Subject: [PATCH 061/108] Keep mathtext boxes in xywh representation throughout. Previously, mathtext boxes would swap between xywh and x1y1x2y2 representation in the code: ship() uses xywh, calls Rule.render() which converts to x1y1x2y2 and stores in Output.rects in that format; then Output.to_vector() would convert back to xywh (while also swapping downwards y's to upwards y's). Instead, stick to xywh representation throughout (the rectangle always goes from x to x+w and y to y+h, regardless of whether y is upwards or downwards). No actual calculation is changed. Also remove an incorrect comment in RendererAgg.draw_mathtext: "dy" (i.e., y) isn't at the rect top side, but actually usually the bottom one; in any case, again it always goes from dy to dy+h. --- lib/matplotlib/_mathtext.py | 35 +++++++++++++------------- lib/matplotlib/backends/backend_agg.py | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 65ae36091243..a7a1ff277a99 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -127,7 +127,7 @@ class Output: def __init__(self, box: Box): self.box = box self.glyphs: list[tuple[float, float, FontInfo]] = [] # (ox, oy, info) - self.rects: list[tuple[float, float, float, float]] = [] # (x1, y1, x2, y2) + self.rects: list[tuple[float, float, float, float]] = [] # (x, y, w, h) def to_vector(self) -> VectorParse: w, h, d = map( @@ -135,21 +135,22 @@ def to_vector(self) -> VectorParse: gs = [(info.font, info.fontsize, info.num, info.glyph_index, ox, h - oy + info.offset) for ox, oy, info in self.glyphs] - rs = [(x1, h - y2, x2 - x1, y2 - y1) - for x1, y1, x2, y2 in self.rects] + rs = [(bx, h - (by + bh), bw, bh) + for bx, by, bw, bh in self.rects] + # Output.rects has downwards ys, VectorParse.rects has upwards ys. return VectorParse(w, h + d, d, gs, rs) def to_raster(self, *, antialiased: bool) -> RasterParse: # Metrics y's and mathtext y's are oriented in opposite directions, # hence the switch between ymin and ymax. xmin = min([*[ox + info.metrics.xmin for ox, oy, info in self.glyphs], - *[x1 for x1, y1, x2, y2 in self.rects], 0]) - 1 + *[x for x, y, w, h in self.rects], 0]) - 1 ymin = min([*[oy - info.metrics.ymax for ox, oy, info in self.glyphs], - *[y1 for x1, y1, x2, y2 in self.rects], 0]) - 1 + *[y for x, y, w, h in self.rects], 0]) - 1 xmax = max([*[ox + info.metrics.xmax for ox, oy, info in self.glyphs], - *[x2 for x1, y1, x2, y2 in self.rects], 0]) + 1 + *[x + w for x, y, w, h in self.rects], 0]) + 1 ymax = max([*[oy - info.metrics.ymin for ox, oy, info in self.glyphs], - *[y2 for x1, y1, x2, y2 in self.rects], 0]) + 1 + *[y + h for x, y, w, h in self.rects], 0]) + 1 w = xmax - xmin h = ymax - ymin - self.box.depth d = ymax - ymin - self.box.height @@ -165,15 +166,15 @@ def to_raster(self, *, antialiased: bool) -> RasterParse: info.font.draw_glyph_to_bitmap( image, int(ox), int(oy - info.metrics.iceberg), info.glyph, antialiased=antialiased) - for x1, y1, x2, y2 in shifted.rects: - height = max(int(y2 - y1) - 1, 0) + for x, y, bw, bh in shifted.rects: + height = max(int(bh) - 1, 0) if height == 0: - center = (y2 + y1) / 2 + center = y + bh / 2 y = int(center - (height + 1) / 2) else: - y = int(y1) - x1 = math.floor(x1) - x2 = math.ceil(x2) + y = int(y) + x1 = math.floor(x) + x2 = math.ceil(x + bw) image[y:y+height+1, x1:x2+1] = 0xff return RasterParse(0, 0, w, h + d, d, image) @@ -299,11 +300,11 @@ def render_glyph(self, output: Output, ox: float, oy: float, font: str, output.glyphs.append((ox, oy, info)) def render_rect_filled(self, output: Output, - x1: float, y1: float, x2: float, y2: float) -> None: + x: float, y: float, w: float, h: float) -> None: """ - Draw a filled rectangle from (*x1*, *y1*) to (*x2*, *y2*). + Draw a filled rectangle at (*x*, *y*) with size (*w*, *h*). """ - output.rects.append((x1, y1, x2, y2)) + output.rects.append((x, y, w, h)) def get_xheight(self, font: str, fontsize: float, dpi: float) -> float: """ @@ -1385,7 +1386,7 @@ def __init__(self, width: float, height: float, depth: float, state: ParserState def render(self, output: Output, # type: ignore[override] x: float, y: float, w: float, h: float) -> None: - self.fontset.render_rect_filled(output, x, y, x + w, y + h) + self.fontset.render_rect_filled(output, x, y, w, h) class Hrule(Rule): diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index e5dd81a1110c..7372cf5b4e32 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -205,7 +205,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): 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. + for dx, dy, w, h in parse.rects: # dy is upwards. if gc1.get_snap() in [None, True]: # Prevent thin bars from disappearing by growing symmetrically. if w < 1: From 2aa38ab8bcb883f3699941d23696657dcc8cd25e Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 31 Jan 2026 00:49:50 -0500 Subject: [PATCH 062/108] ft2font: Extend OS/2 table with new fields Versions 1, 2, and 5 add additional fields to this struct, which may or may not be useful in the future. --- lib/matplotlib/ft2font.pyi | 13 ++++++++++++- lib/matplotlib/tests/test_ft2font.py | 7 +++++++ src/ft2font_wrapper.cpp | 21 +++++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index c8cd14bbe042..10be9e9e69a9 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -1,7 +1,7 @@ from enum import Enum, Flag from os import PathLike import sys -from typing import BinaryIO, Literal, NewType, TypeAlias, TypedDict, cast, final, overload +from typing import BinaryIO, Literal, NewType, NotRequired, TypeAlias, TypedDict, cast, final, overload from typing_extensions import Buffer # < Py 3.12 import numpy as np @@ -142,6 +142,17 @@ class _SfntOs2Dict(TypedDict): fsSelection: int fsFirstCharIndex: int fsLastCharIndex: int + # version >= 1 + ulCodePageRange: NotRequired[tuple[int, int]] + # version >= 2 + sxHeight: NotRequired[int] + sCapHeight: NotRequired[int] + usDefaultChar: NotRequired[int] + usBreakChar: NotRequired[int] + usMaxContext: NotRequired[int] + # version >= 5 + usLowerOpticalPointSize: NotRequired[int] + usUpperOpticalPointSize: NotRequired[int] class _SfntHheaDict(TypedDict): version: tuple[int, int] diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 17492e7690c0..61312f57bb6f 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -590,6 +590,7 @@ def test_ft2font_get_sfnt(font_name, expected): 'ulCharRange': (3875565311, 3523280383, 170156073, 67117068), 'achVendID': b'PfEd', 'fsSelection': 64, 'fsFirstCharIndex': 32, 'fsLastCharIndex': 65535, + 'ulCodePageRange': (1610613247, 3758030848), }, 'hhea': { 'version': (1, 0), @@ -736,6 +737,12 @@ def test_ft2font_get_sfnt(font_name, expected): 'ulCharRange': (3, 192, 0, 0), 'achVendID': b'STIX', 'fsSelection': 32, 'fsFirstCharIndex': 32, 'fsLastCharIndex': 10217, + 'ulCodePageRange': (2688417793, 2432565248), + 'sxHeight': 0, + 'sCapHeight': 0, + 'usDefaultChar': 0, + 'usBreakChar': 32, + 'usMaxContext': 1, }, 'hhea': { 'version': (1, 0), diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 084feb299a63..5eea0d64f6b2 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1263,8 +1263,9 @@ PyFT2Font_get_sfnt_table(PyFT2Font *self, std::string tagname) } case FT_SFNT_OS2: { auto t = static_cast(table); - return py::dict( - "version"_a=t->version, + auto version = t->version; + auto result = py::dict( + "version"_a=version, "xAvgCharWidth"_a=t->xAvgCharWidth, "usWeightClass"_a=t->usWeightClass, "usWidthClass"_a=t->usWidthClass, @@ -1287,6 +1288,22 @@ PyFT2Font_get_sfnt_table(PyFT2Font *self, std::string tagname) "fsSelection"_a=t->fsSelection, "fsFirstCharIndex"_a=t->usFirstCharIndex, "fsLastCharIndex"_a=t->usLastCharIndex); + if (version >= 1) { + result["ulCodePageRange"] = py::make_tuple(t->ulCodePageRange1, + t->ulCodePageRange2); + } + if (version >= 2) { + result["sxHeight"] = t->sxHeight; + result["sCapHeight"] = t->sCapHeight; + result["usDefaultChar"] = t->usDefaultChar; + result["usBreakChar"] = t->usBreakChar; + result["usMaxContext"] = t->usMaxContext; + } + if (version >= 5) { + result["usLowerOpticalPointSize"] = t->usLowerOpticalPointSize; + result["usUpperOpticalPointSize"] = t->usUpperOpticalPointSize; + } + return result; } case FT_SFNT_HHEA: { auto t = static_cast(table); From 6402d86c0adba338ccc3961d1dd681b32a26a956 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 4 Feb 2026 19:55:50 -0500 Subject: [PATCH 063/108] DOC: Fix missing references for updated FT2Font.set_text --- doc/missing-references.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/missing-references.json b/doc/missing-references.json index 907795f57677..7799e5b313da 100644 --- a/doc/missing-references.json +++ b/doc/missing-references.json @@ -3,6 +3,9 @@ "HashableList[_HT]": [ ":1" ], + "collections.abc.Sequence[tuple[str": [ + "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.set_text:1" + ], "matplotlib.axes._base._AxesBase": [ "doc/api/artist_api.rst:203" ], @@ -122,8 +125,7 @@ "doc/api/_as_gen/mpl_toolkits.axisartist.floating_axes.rst:32::1" ], "numpy.float64": [ - "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.set_text:1", - "doc/docstring of matplotlib.ft2font.PyCapsule.set_text:1" + "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.set_text:1" ], "numpy.typing.NDArray": [ "doc/docstring of matplotlib.ft2font.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1.get_image:1", From 762711831e7d6e21d7c804e9c5dea55ff1a6b961 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 4 Sep 2025 14:00:08 +0200 Subject: [PATCH 064/108] Rasterize dvi files without dvipng This patch drops the reliance on dvipng to rasterize dvi files prior to inclusion by agg, instead performing the rasterization ourselves (as a consequence, the rasterization output also becomes dependent of the freetype version used). Note that this approach will be needed anyways to support xetex and luatex, as dvipng doesn't support dvi files generated by these engines. Baseline images change slightly, for the better or the worse. The top-left blue cross text in test_rotation.py ("Myrt0") seems to be better top-aligned against the blue line (the old version overshot a bit); the bounding box of the formulas in test_usetex.py seems a bit worse. --- lib/matplotlib/backends/backend_agg.py | 84 +++++++++++++++++++++++--- lib/matplotlib/mpl-data/matplotlibrc | 10 +++ lib/matplotlib/rcsetup.py | 15 +++++ lib/matplotlib/typing.py | 1 + src/ft2font_wrapper.cpp | 2 +- 5 files changed, 102 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 7372cf5b4e32..d96a27b544af 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -31,6 +31,7 @@ from matplotlib import _api, cbook from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) +from matplotlib.dviread import Dvi from matplotlib.font_manager import fontManager as _fontManager, get_font from matplotlib.ft2font import LoadFlags, RenderMode from matplotlib.mathtext import MathTextParser @@ -266,19 +267,84 @@ def get_text_width_height_descent(self, s, prop, ismath): def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): # docstring inherited # todo, handle props, angle, origins + size = prop.get_size_in_points() - texmanager = self.get_texmanager() + if mpl.rcParams["text.latex.engine"] == "latex+dvipng": + Z = self.get_texmanager().get_grey(s, size, self.dpi) + Z = (Z * 0xff).astype(np.uint8) + w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX") + 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) + return + + dvifile = self.get_texmanager().make_dvi(s, size) + with Dvi(dvifile, self.dpi) as dvi: + page, = dvi - Z = texmanager.get_grey(s, size, self.dpi) - Z = np.array(Z * 255.0, np.uint8) + cos = math.cos(math.radians(angle)) + sin = math.sin(math.radians(angle)) - w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX") - 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) + for text in page.text: + hf = mpl.rcParams["text.hinting_factor"] + # Resolving text.index will implicitly call get_font(), which + # resets the font transform, so it has to be done before explicitly + # setting the font transform below. + index = text.index + font = get_font(text.font_path) + font.set_size(text.font_size, self.dpi) + slant = text.font_effects.get("slant", 0) + extend = text.font_effects.get("extend", 1) + font._set_transform( + (0x10000 * np.array([[cos, -sin], [sin, cos]]) + @ [[extend, extend * slant], [0, 1]] + @ [[1 / hf, 0], [0, 1]]).round().astype(int), + [round(0x40 * (x + text.x * cos - text.y * sin)), + # FreeType's y is upwards. + round(0x40 * (self.height - y + text.x * sin + text.y * cos))] + ) + bitmap = font._render_glyph( + index, get_hinting_flag(), + RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) + buffer = np.asarray(bitmap.buffer) + if not gc.get_antialiased(): + buffer *= 0xff + # draw_text_image's y is downwards & the bitmap bottom side. + self._renderer.draw_text_image( + buffer, + bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], + 0, gc) + + rgba = gc.get_rgb() + if len(rgba) == 3 or gc.get_forced_alpha(): + rgba = rgba[:3] + (gc.get_alpha(),) + gc1 = self.new_gc() + gc1.set_linewidth(0) + gc1.set_snap(gc.get_snap()) + for box in page.boxes: + bx = box.x + by = box.y + bw = box.width + bh = box.height + if gc1.get_snap() in [None, True]: + # Prevent thin bars from disappearing by growing symmetrically. + if bw < 1: + bx -= (1 - bw) / 2 + bw = 1 + if bh < 1: + by -= (1 - bh) / 2 + bh = 1 + path = Path._create_closed([ + (bx, by), (bx + bw, by), (bx + bw, by + bh), (bx, by + bh)]) + self._renderer.draw_path( + gc1, path, + mpl.transforms.Affine2D() + .rotate_deg(angle).translate(x, self.height - y), + rgba) + gc1.restore() def get_canvas_width_height(self): # docstring inherited diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 524cdeb7169c..4fd08aa60578 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -331,6 +331,16 @@ # zapf chancery, charter, serif, sans-serif, helvetica, # avant garde, courier, monospace, computer modern roman, # computer modern sans serif, computer modern typewriter + +## The TeX engine/format to use. The following values are supported: +## - "latex": The classic TeX engine (the current default). All backends render +## TeX's output by parsing the DVI output into glyphs and boxes and emitting +## those one by one. +## - "latex+dvipng": The same as "latex", with the exception that Agg-based +## backends rely on dvipng to rasterize TeX's output. This value was the +## default up to Matplotlib 3.10. +#text.latex.engine: latex + #text.latex.preamble: # IMPROPER USE OF THIS FEATURE WILL LEAD TO LATEX FAILURES # AND IS THEREFORE UNSUPPORTED. PLEASE DO NOT ASK FOR HELP # IF THIS FEATURE DOES NOT DO WHAT YOU EXPECT IT TO. diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index f92f6b7645fa..f70697fdab45 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1073,6 +1073,7 @@ def _convert_validator_spec(key, conv): # text props "text.color": validate_color, "text.usetex": validate_bool, + "text.latex.engine": ["latex", "latex+dvipng"], "text.latex.preamble": validate_string, "text.hinting": ["default", "no_autohint", "force_autohint", "no_hinting", "auto", "native", "either", "none"], @@ -1831,6 +1832,20 @@ class _Param: "monospace, computer modern roman, computer modern sans serif, " "computer modern typewriter" ), + _Param( + "text.latex.engine", + default="latex", + validator=["latex", "latex+dvipng"], + description=( + "The TeX engine/format to use. The following values are supported:\n" + "- 'latex': The classic TeX engine (the current default). All backends " + "render TeX's output by parsing the DVI output into glyphs and boxes and " + "emitting those one by one.\n" + "- 'latex+dvipng': The same as 'latex', with the exception that Agg-based " + "backends rely on dvipng to rasterize TeX's output. This value was the " + "default up to Matplotlib 3.10." + ) + ), _Param( "text.latex.preamble", default="", diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index ea9f6d7db2fd..3c94c26c64ee 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -452,6 +452,7 @@ "text.hinting_factor", "text.kerning_factor", "text.language", + "text.latex.engine", "text.latex.preamble", "text.parse_math", "text.usetex", diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 084feb299a63..0a661f1bcee3 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -971,7 +971,7 @@ const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = R"""( 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. + The position of the glyph's top left corner. glyph : Glyph The glyph to draw. antialiased : bool, default: True From a16165863e751b2d604bfaf036eb1a9d83580678 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sun, 4 Jan 2026 15:57:38 -0500 Subject: [PATCH 065/108] Update bundled FreeType to 2.14.1 --- .circleci/config.yml | 6 ++-- lib/matplotlib/__init__.py | 2 +- subprojects/freetype2.wrap | 20 +++++------ ...d655f1696da774b5cdd4c5effb312153232f.patch | 36 ------------------- 4 files changed, 12 insertions(+), 52 deletions(-) delete mode 100644 subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch diff --git a/.circleci/config.yml b/.circleci/config.yml index 40ba933cf0d9..2810d542c2af 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,7 +67,7 @@ commands: fonts-install: steps: - restore_cache: - key: fonts-4 + key: fonts-5 - run: name: Install custom fonts command: | @@ -80,7 +80,7 @@ commands: -O ~/.local/share/fonts/xkcd-Script.ttf || true fc-cache -f -v - save_cache: - key: fonts-4 + key: fonts-5 paths: - ~/.local/share/fonts/ @@ -125,7 +125,7 @@ commands: --no-build-isolation --editable .[dev] fi - save_cache: - key: build-deps-2 + key: build-deps-3 paths: - subprojects/packagecache diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index d7a3bf4f0fd7..00da911a4bad 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1335,7 +1335,7 @@ def _val_or_rc(val, *rc_names): def _init_tests(): # The version of FreeType to install locally for running the tests. This must match # the value in `subprojects/freetype2.wrap`. - LOCAL_FREETYPE_VERSION = '2.13.3' + LOCAL_FREETYPE_VERSION = '2.14.1' from matplotlib import ft2font if (ft2font.__freetype_version__ != LOCAL_FREETYPE_VERSION or diff --git a/subprojects/freetype2.wrap b/subprojects/freetype2.wrap index e1d0fb112ca9..f68ce88bcafe 100644 --- a/subprojects/freetype2.wrap +++ b/subprojects/freetype2.wrap @@ -1,16 +1,12 @@ -# This is the version of FreeType to use when building a local version. It -# must match the value in `lib/matplotlib.__init__.py`. Also update the docs -# in `docs/devel/dependencies.rst`. Bump the cache key in -# `.circleci/config.yml` when changing requirements. +# This is the version of FreeType to use when building a local version. It must match +# the `LOCAL_FREETYPE_VERSION` value in `lib/matplotlib/__init__.py`. Bump the cache key +# in `.circleci/config.yml` when changing requirements. [wrap-file] -directory = freetype-2.13.3 -source_url = https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz -source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.13.3/freetype-2.13.3.tar.xz -source_filename = freetype-2.13.3.tar.xz -source_hash = 0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289 - -# https://gitlab.freedesktop.org/freetype/freetype/-/commit/34aed655f1696da774b5cdd4c5effb312153232f -diff_files = freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch +directory = freetype-2.14.1 +source_url = https://download.savannah.gnu.org/releases/freetype/freetype-2.14.1.tar.xz +source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.14.1/freetype-2.14.1.tar.xz +source_filename = freetype-2.14.1.tar.xz +source_hash = 32427e8c471ac095853212a37aef816c60b42052d4d9e48230bab3bdf2936ccc [provide] freetype2 = freetype_dep diff --git a/subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch b/subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch deleted file mode 100644 index c00baa702f65..000000000000 --- a/subprojects/packagefiles/freetype-34aed655f1696da774b5cdd4c5effb312153232f.patch +++ /dev/null @@ -1,36 +0,0 @@ -From 34aed655f1696da774b5cdd4c5effb312153232f Mon Sep 17 00:00:00 2001 -From: Benoit Pierre -Date: Sat, 12 Oct 2024 10:49:46 +0000 -Subject: [PATCH] * meson.build: Fix `bzip2` option handling. - ---- - meson.build | 11 ++++++++--- - 1 file changed, 8 insertions(+), 3 deletions(-) - -diff --git a/meson.build b/meson.build -index 72b7f9900..2e8d5355e 100644 ---- a/meson.build -+++ b/meson.build -@@ -320,11 +320,16 @@ else - endif - - # BZip2 support. --bzip2_dep = dependency('bzip2', required: false) -+bzip2_dep = dependency( -+ 'bzip2', -+ required: get_option('bzip2').disabled() ? get_option('bzip2') : false, -+) - if not bzip2_dep.found() -- bzip2_dep = cc.find_library('bz2', -+ bzip2_dep = cc.find_library( -+ 'bz2', - has_headers: ['bzlib.h'], -- required: get_option('bzip2')) -+ required: get_option('bzip2'), -+ ) - endif - - if bzip2_dep.found() --- -GitLab - From 57e1c85702275a18319151b06bf809ff957e4ffe Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sun, 4 Jan 2026 22:14:55 -0500 Subject: [PATCH 066/108] Update bundled HarfBuzz to 12.3.0 --- extern/meson.build | 10 +++++- subprojects/harfbuzz.wrap | 15 ++++---- .../harfbuzz-11.2.0-bundle-freetype.patch | 36 ------------------- 3 files changed, 15 insertions(+), 46 deletions(-) delete mode 100644 subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch diff --git a/extern/meson.build b/extern/meson.build index 2723baa47505..967f0321b313 100644 --- a/extern/meson.build +++ b/extern/meson.build @@ -30,17 +30,25 @@ else subproject('harfbuzz', default_options: [ 'default_library=static', + 'benchmark=disabled', 'cairo=disabled', + 'chafa=disabled', 'coretext=disabled', 'directwrite=disabled', + 'docs=disabled', + 'doc_tests=false', 'fontations=disabled', 'freetype=enabled', 'gdi=disabled', 'glib=disabled', 'gobject=disabled', - 'harfruzz=disabled', + 'harfrust=disabled', 'icu=disabled', + 'introspection=disabled', + 'kbts=disabled', 'tests=disabled', + 'utilities=disabled', + 'wasm=disabled', ] ) subproject('sheenbidi', default_options: ['default_library=static']) diff --git a/subprojects/harfbuzz.wrap b/subprojects/harfbuzz.wrap index cc5e227f0ca2..da0f7590a589 100644 --- a/subprojects/harfbuzz.wrap +++ b/subprojects/harfbuzz.wrap @@ -1,13 +1,10 @@ [wrap-file] -directory = harfbuzz-11.2.1 -source_url = https://github.com/harfbuzz/harfbuzz/releases/download/11.2.1/harfbuzz-11.2.1.tar.xz -source_filename = harfbuzz-11.2.1.tar.xz -source_hash = 093714c8548a285094685f0bdc999e202d666b59eeb3df2ff921ab68b8336a49 -source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/harfbuzz_11.2.1-1/harfbuzz-11.2.1.tar.xz -wrapdb_version = 11.2.1-1 - -# This patch allows using our bundled FreeType. -diff_files = harfbuzz-11.2.0-bundle-freetype.patch +directory = harfbuzz-12.3.0 +source_url = https://github.com/harfbuzz/harfbuzz/releases/download/12.3.0/harfbuzz-12.3.0.tar.xz +source_filename = harfbuzz-12.3.0.tar.xz +source_hash = 8660ebd3c27d9407fc8433b5d172bafba5f0317cb0bb4339f28e5370c93d42b7 +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/harfbuzz_12.3.0-1/harfbuzz-12.3.0.tar.xz +wrapdb_version = 12.3.0-1 [provide] dependency_names = harfbuzz, harfbuzz-cairo, harfbuzz-gobject, harfbuzz-icu, harfbuzz-subset diff --git a/subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch b/subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch deleted file mode 100644 index fa7be0b54afd..000000000000 --- a/subprojects/packagefiles/harfbuzz-11.2.0-bundle-freetype.patch +++ /dev/null @@ -1,36 +0,0 @@ -diff -uPNr harfbuzz-11.2.0.orig/meson.build harfbuzz-11.2.0/meson.build ---- harfbuzz-11.2.0.orig/meson.build 2025-04-28 08:56:32.000000000 -0400 -+++ harfbuzz-11.2.0/meson.build 2025-05-03 03:25:39.602646412 -0400 -@@ -115,31 +115,7 @@ - # Sadly, FreeType's versioning schemes are different between pkg-config and CMake - - # Try pkg-config name -- freetype_dep = dependency('freetype2', -- version: freetype_min_version, -- method: 'pkg-config', -- required: false, -- allow_fallback: false) -- if not freetype_dep.found() -- # Try cmake name -- freetype_dep = dependency('Freetype', -- version: freetype_min_version_actual, -- method: 'cmake', -- required: false, -- allow_fallback: false) -- # Subproject fallback -- if not freetype_dep.found() -- freetype_proj = subproject('freetype2', -- version: freetype_min_version_actual, -- required: get_option('freetype'), -- default_options: ['harfbuzz=disabled']) -- if freetype_proj.found() -- freetype_dep = freetype_proj.get_variable('freetype_dep') -- else -- freetype_dep = dependency('', required: false) -- endif -- endif -- endif -+ freetype_dep = dependency('freetype2', version: freetype_min_version) - endif - - glib_dep = dependency('glib-2.0', version: glib_min_version, required: get_option('glib')) From 9619bcc8ab1c1e2a8908b51f70c9f7566969ad1f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sun, 4 Jan 2026 22:31:24 -0500 Subject: [PATCH 067/108] Enable HarfBuzz support in bundled FreeType Due to the circular dependency between FreeType and HarfBuzz, this requires a small patch to make FreeType not look for HarfBuzz, allowing it to come from the superproject linkage. While FreeType does support dynamically loading HarfBuzz, this should be a bit safer at avoiding any chance of mixing system with bundled libraries. --- extern/meson.build | 2 +- subprojects/freetype2.wrap | 3 ++ .../freetype-2.14.1-static-harfbuzz.patch | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 subprojects/packagefiles/freetype-2.14.1-static-harfbuzz.patch diff --git a/extern/meson.build b/extern/meson.build index 967f0321b313..d2b5f0427573 100644 --- a/extern/meson.build +++ b/extern/meson.build @@ -15,7 +15,7 @@ else 'default_library=static', 'brotli=disabled', 'bzip2=disabled', - 'harfbuzz=disabled', + get_option('system-libraqm') ? 'harfbuzz=disabled' : 'harfbuzz=static', 'mmap=auto', 'png=disabled', 'tests=disabled', diff --git a/subprojects/freetype2.wrap b/subprojects/freetype2.wrap index f68ce88bcafe..4a131cf45270 100644 --- a/subprojects/freetype2.wrap +++ b/subprojects/freetype2.wrap @@ -8,6 +8,9 @@ source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetyp source_filename = freetype-2.14.1.tar.xz source_hash = 32427e8c471ac095853212a37aef816c60b42052d4d9e48230bab3bdf2936ccc +# This patch allows using our bundled HarfBuzz. +diff_files = freetype-2.14.1-static-harfbuzz.patch + [provide] freetype2 = freetype_dep freetype = freetype_dep diff --git a/subprojects/packagefiles/freetype-2.14.1-static-harfbuzz.patch b/subprojects/packagefiles/freetype-2.14.1-static-harfbuzz.patch new file mode 100644 index 000000000000..c09416a6e155 --- /dev/null +++ b/subprojects/packagefiles/freetype-2.14.1-static-harfbuzz.patch @@ -0,0 +1,29 @@ +diff -uPNr freetype-2.14.1.orig/meson.build freetype-2.14.1/meson.build +--- freetype-2.14.1.orig/meson.build 2025-09-11 07:12:24.000000000 -0400 ++++ freetype-2.14.1/meson.build 2026-01-04 15:49:14.198061441 -0500 +@@ -364,6 +364,13 @@ + endif + endif + ++if harfbuzz_opt == 'static' ++ harfbuzz_dep = declare_dependency() ++ harfbuzz_opt = 'YES' ++ ftoption_command += ['--enable=FT_CONFIG_OPTION_USE_HARFBUZZ'] ++ ft2_deps += [harfbuzz_dep] ++endif ++ + if not harfbuzz_dep.found() and \ + (harfbuzz_opt == 'dynamic' or harfbuzz_opt == 'auto') + # On Windows we don't need libdl, but on other platforms we need it. +diff -uPNr freetype-2.14.1.orig/meson_options.txt freetype-2.14.1/meson_options.txt +--- freetype-2.14.1.orig/meson_options.txt 2025-09-07 22:48:18.000000000 -0400 ++++ freetype-2.14.1/meson_options.txt 2026-01-04 15:49:30.087034418 -0500 +@@ -24,7 +24,7 @@ + + option('harfbuzz', + type: 'combo', +- choices: ['auto', 'enabled', 'dynamic', 'disabled'], ++ choices: ['auto', 'enabled', 'dynamic', 'static', 'disabled'], + value: 'auto', + description: 'Use Harfbuzz library to improve auto-hinting;' + + ' if available, many glyphs not directly addressable' From c2fa7bacc36f2d039f9dae997af85699ea30320c Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 3 Feb 2026 19:52:23 +0100 Subject: [PATCH 068/108] Fix positioning of wide mathtext accents. A wide accent over more than one character should just be centered over the underlying box. In any case this works better than the current strategy of shifting by an amount depending on the box's width (which results in absurdly large shifts for wide boxes). When positioning over a single character the behavior is different -- TeX uses specific font metrics info for that case, and we can try rendering as combining characters instead (not done in this PR). --- lib/matplotlib/_mathtext.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index a7a1ff277a99..8f0d85bd5ab3 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2324,12 +2324,13 @@ def accent(self, toks: ParseResults) -> T.Any: if accent in self._wide_accents: accent_box = AutoWidthChar( '\\' + accent, sym.width, state, char_class=Accent) + centered = HCentered([accent_box]) else: accent_box = Accent(self._accent_map[accent], state) - if accent == 'mathring': - accent_box.shrink() - accent_box.shrink() - centered = HCentered([Hbox(sym.width / 4.0), accent_box]) + if accent == 'mathring': + accent_box.shrink() + accent_box.shrink() + centered = HCentered([Hbox(sym.width / 4.0), accent_box]) centered.hpack(sym.width, 'exactly') return Vlist([ centered, From 931bcf3f034172ddd22e905e5a6cf4ed515f25e6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 04:00:55 -0500 Subject: [PATCH 069/108] Change RendererAgg.draw_text to use FT2Font._render_glyph This makes it more similar to `draw_mathtext`, and is necessary for colour fonts, where the previously-cached bitmap would have been downgraded to black&white strokes by FreeType when copied. It should also reduce the necessary code paths with the other text drawing methods as well. --- lib/matplotlib/backends/backend_agg.py | 26 +++++++++++++++++++------- src/ft2font_wrapper.cpp | 10 ---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index d96a27b544af..409348c1cb66 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -229,13 +229,25 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) font = self._prepare_font(prop) - font.set_text(s, angle, flags=get_hinting_flag(), - features=mtext.get_fontfeatures() if mtext is not None else None, - language=mtext.get_language() if mtext is not None else None) - for bitmap in font._render_glyphs( - x, self.height - y, - RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO, - ): + cos = math.cos(math.radians(angle)) + sin = math.sin(math.radians(angle)) + load_flags = get_hinting_flag() + items = font._layout( + s, flags=load_flags, + features=mtext.get_fontfeatures() if mtext is not None else None, + language=mtext.get_language() if mtext is not None else None) + for item in items: + hf = item.ft_object._hinting_factor + item.ft_object._set_transform( + [[round(0x10000 * cos / hf), round(0x10000 * -sin)], + [round(0x10000 * sin / hf), round(0x10000 * cos)]], + [round(0x40 * (x + item.x * cos - item.y * sin)), + # FreeType's y is upwards. + round(0x40 * (self.height - y + item.x * sin + item.y * cos))] + ) + bitmap = item.ft_object._render_glyph( + item.glyph_index, load_flags, + RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) buffer = bitmap.buffer if not gc.get_antialiased(): buffer *= 0xff diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 7a3d4cfe9479..72aec2a437ab 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1933,16 +1933,6 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) FT_CHECK(FT_Render_Glyph, face->glyph, render_mode); return PyPositionedBitmap{face->glyph}; }) - .def("_render_glyphs", - [](PyFT2Font *self, double x, double y, FT_Render_Mode render_mode) { - auto origin = FT_Vector{std::lround(x * 64), std::lround(y * 64)}; - auto pbs = std::vector{}; - for (auto &g: self->get_glyphs()) { - FT_CHECK(FT_Glyph_To_Bitmap, &g, render_mode, &origin, 1); - pbs.emplace_back(reinterpret_cast(g)); - } - return pbs; - }) ; m.attr("__freetype_version__") = version_string; From 6ec8fc85ad778a5a472c4c6767f40a99edf7bef2 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 04:08:40 -0500 Subject: [PATCH 070/108] Deduplicate RendererAgg.draw_{mathtext,text} All the glyph drawing loop is the same, other than a flexible size in the former. --- lib/matplotlib/backends/backend_agg.py | 50 +++++++++++--------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 409348c1cb66..efdc84fa5fd8 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -172,14 +172,12 @@ def draw_path(self, gc, path, transform, rgbFace=None): raise OverflowError(msg) from None - def draw_mathtext(self, gc, x, y, s, prop, angle): - """Draw mathtext using :mod:`matplotlib.mathtext`.""" + def _draw_text_glyphs(self, gc, x, y, angle, glyphs): # y is downwards. - parse = self.mathtext_parser.parse( - s, self.dpi, prop, antialiased=gc.get_antialiased()) cos = math.cos(math.radians(angle)) sin = math.sin(math.radians(angle)) - for font, size, _char, glyph_index, dx, dy in parse.glyphs: # dy is upwards. + load_flags = get_hinting_flag() + for font, size, glyph_index, dx, dy in glyphs: # dy is upwards. font.set_size(size, self.dpi) hf = font._hinting_factor font._set_transform( @@ -190,9 +188,9 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): round(0x40 * (self.height - y + dx * sin + dy * cos))] ) bitmap = font._render_glyph( - glyph_index, get_hinting_flag(), + glyph_index, load_flags, RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) - buffer = np.asarray(bitmap.buffer) + buffer = bitmap.buffer if not gc.get_antialiased(): buffer *= 0xff # draw_text_image's y is downwards & the bitmap bottom side. @@ -200,6 +198,15 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): buffer, bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], 0, gc) + + def draw_mathtext(self, gc, x, y, s, prop, angle): + """Draw mathtext using :mod:`matplotlib.mathtext`.""" + parse = self.mathtext_parser.parse( + s, self.dpi, prop, antialiased=gc.get_antialiased()) + self._draw_text_glyphs( + gc, x, y, angle, + ((font, size, glyph_index, dx, dy) + for font, size, _char, glyph_index, dx, dy in parse.glyphs)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): rgba = rgba[:3] + (gc.get_alpha(),) @@ -229,32 +236,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) font = self._prepare_font(prop) - cos = math.cos(math.radians(angle)) - sin = math.sin(math.radians(angle)) - load_flags = get_hinting_flag() items = font._layout( - s, flags=load_flags, + s, flags=get_hinting_flag(), features=mtext.get_fontfeatures() if mtext is not None else None, language=mtext.get_language() if mtext is not None else None) - for item in items: - hf = item.ft_object._hinting_factor - item.ft_object._set_transform( - [[round(0x10000 * cos / hf), round(0x10000 * -sin)], - [round(0x10000 * sin / hf), round(0x10000 * cos)]], - [round(0x40 * (x + item.x * cos - item.y * sin)), - # FreeType's y is upwards. - round(0x40 * (self.height - y + item.x * sin + item.y * cos))] - ) - bitmap = item.ft_object._render_glyph( - item.glyph_index, load_flags, - RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) - buffer = bitmap.buffer - if not gc.get_antialiased(): - buffer *= 0xff - self._renderer.draw_text_image( - buffer, - bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], - 0, gc) + size = prop.get_size_in_points() + self._draw_text_glyphs( + gc, x, y, angle, + ((item.ft_object, size, item.glyph_index, item.x, item.y) + for item in items)) def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited From 9a185c6f40730ba8a2dccb133f2755b4c5aa7da6 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 05:20:54 -0500 Subject: [PATCH 071/108] Deduplicate RendererAgg.draw_tex with draw_{mathtext,text} All the glyph drawing loop is the same, other than a slight transform difference with slant/extend in the former. --- lib/matplotlib/backends/backend_agg.py | 49 +++++++------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index efdc84fa5fd8..3cf7f071bf33 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -177,12 +177,13 @@ def _draw_text_glyphs(self, gc, x, y, angle, glyphs): cos = math.cos(math.radians(angle)) sin = math.sin(math.radians(angle)) load_flags = get_hinting_flag() - for font, size, glyph_index, dx, dy in glyphs: # dy is upwards. + for font, size, glyph_index, slant, extend, dx, dy in glyphs: # dy is upwards. font.set_size(size, self.dpi) hf = font._hinting_factor font._set_transform( - [[round(0x10000 * cos / hf), round(0x10000 * -sin)], - [round(0x10000 * sin / hf), round(0x10000 * cos)]], + (0x10000 * np.array([[cos, -sin], [sin, cos]]) + @ [[extend, extend * slant], [0, 1]] + @ [[1 / hf, 0], [0, 1]]).round().astype(int), [round(0x40 * (x + dx * cos - dy * sin)), # FreeType's y is upwards. round(0x40 * (self.height - y + dx * sin + dy * cos))] @@ -205,7 +206,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): s, self.dpi, prop, antialiased=gc.get_antialiased()) self._draw_text_glyphs( gc, x, y, angle, - ((font, size, glyph_index, dx, dy) + ((font, size, glyph_index, 0, 1, dx, dy) for font, size, _char, glyph_index, dx, dy in parse.glyphs)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): @@ -243,7 +244,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): size = prop.get_size_in_points() self._draw_text_glyphs( gc, x, y, angle, - ((item.ft_object, size, item.glyph_index, item.x, item.y) + ((item.ft_object, size, item.glyph_index, 0, 1, item.x, item.y) for item in items)) def get_text_width_height_descent(self, s, prop, ismath): @@ -287,38 +288,12 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): with Dvi(dvifile, self.dpi) as dvi: page, = dvi - cos = math.cos(math.radians(angle)) - sin = math.sin(math.radians(angle)) - - for text in page.text: - hf = mpl.rcParams["text.hinting_factor"] - # Resolving text.index will implicitly call get_font(), which - # resets the font transform, so it has to be done before explicitly - # setting the font transform below. - index = text.index - font = get_font(text.font_path) - font.set_size(text.font_size, self.dpi) - slant = text.font_effects.get("slant", 0) - extend = text.font_effects.get("extend", 1) - font._set_transform( - (0x10000 * np.array([[cos, -sin], [sin, cos]]) - @ [[extend, extend * slant], [0, 1]] - @ [[1 / hf, 0], [0, 1]]).round().astype(int), - [round(0x40 * (x + text.x * cos - text.y * sin)), - # FreeType's y is upwards. - round(0x40 * (self.height - y + text.x * sin + text.y * cos))] - ) - bitmap = font._render_glyph( - index, get_hinting_flag(), - RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO) - buffer = np.asarray(bitmap.buffer) - if not gc.get_antialiased(): - buffer *= 0xff - # draw_text_image's y is downwards & the bitmap bottom side. - self._renderer.draw_text_image( - buffer, - bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], - 0, gc) + self._draw_text_glyphs( + gc, x, y, angle, + ((get_font(text.font_path), text.font_size, text.index, + text.font_effects.get('slant', 0), text.font_effects.get('extend', 1), + text.x, text.y) + for text in page.text)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): From 754bdab5f174513fa00afad716c47299c31960d0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Feb 2026 05:43:27 -0500 Subject: [PATCH 072/108] Also deduplicate box rendering in RendererAgg.draw_{mathtext,tex} --- lib/matplotlib/backends/backend_agg.py | 60 ++++++++------------------ 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 3cf7f071bf33..f0006a2d7dbe 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -172,7 +172,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): raise OverflowError(msg) from None - def _draw_text_glyphs(self, gc, x, y, angle, glyphs): + def _draw_text_glyphs_and_boxes(self, gc, x, y, angle, glyphs, boxes): # y is downwards. cos = math.cos(math.radians(angle)) sin = math.sin(math.radians(angle)) @@ -200,21 +200,13 @@ def _draw_text_glyphs(self, gc, x, y, angle, glyphs): bitmap.left, int(self.height) - bitmap.top + buffer.shape[0], 0, gc) - def draw_mathtext(self, gc, x, y, s, prop, angle): - """Draw mathtext using :mod:`matplotlib.mathtext`.""" - parse = self.mathtext_parser.parse( - s, self.dpi, prop, antialiased=gc.get_antialiased()) - self._draw_text_glyphs( - gc, x, y, angle, - ((font, size, glyph_index, 0, 1, dx, dy) - for font, size, _char, glyph_index, dx, dy in parse.glyphs)) rgba = gc.get_rgb() if len(rgba) == 3 or gc.get_forced_alpha(): rgba = rgba[:3] + (gc.get_alpha(),) gc1 = self.new_gc() gc1.set_linewidth(0) gc1.set_snap(gc.get_snap()) - for dx, dy, w, h in parse.rects: # dy is upwards. + for dx, dy, w, h in boxes: # dy is upwards. if gc1.get_snap() in [None, True]: # Prevent thin bars from disappearing by growing symmetrically. if w < 1: @@ -232,6 +224,16 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): rgba) gc1.restore() + def draw_mathtext(self, gc, x, y, s, prop, angle): + """Draw mathtext using :mod:`matplotlib.mathtext`.""" + parse = self.mathtext_parser.parse( + s, self.dpi, prop, antialiased=gc.get_antialiased()) + self._draw_text_glyphs_and_boxes( + gc, x, y, angle, + ((font, size, glyph_index, 0, 1, dx, dy) + for font, size, _char, glyph_index, dx, dy in parse.glyphs), + parse.rects) + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited if ismath: @@ -242,10 +244,11 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): features=mtext.get_fontfeatures() if mtext is not None else None, language=mtext.get_language() if mtext is not None else None) size = prop.get_size_in_points() - self._draw_text_glyphs( + self._draw_text_glyphs_and_boxes( gc, x, y, angle, ((item.ft_object, size, item.glyph_index, 0, 1, item.x, item.y) - for item in items)) + for item in items), + []) def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited @@ -288,40 +291,13 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): with Dvi(dvifile, self.dpi) as dvi: page, = dvi - self._draw_text_glyphs( + self._draw_text_glyphs_and_boxes( gc, x, y, angle, ((get_font(text.font_path), text.font_size, text.index, text.font_effects.get('slant', 0), text.font_effects.get('extend', 1), text.x, text.y) - for text in page.text)) - - rgba = gc.get_rgb() - if len(rgba) == 3 or gc.get_forced_alpha(): - rgba = rgba[:3] + (gc.get_alpha(),) - gc1 = self.new_gc() - gc1.set_linewidth(0) - gc1.set_snap(gc.get_snap()) - for box in page.boxes: - bx = box.x - by = box.y - bw = box.width - bh = box.height - if gc1.get_snap() in [None, True]: - # Prevent thin bars from disappearing by growing symmetrically. - if bw < 1: - bx -= (1 - bw) / 2 - bw = 1 - if bh < 1: - by -= (1 - bh) / 2 - bh = 1 - path = Path._create_closed([ - (bx, by), (bx + bw, by), (bx + bw, by + bh), (bx, by + bh)]) - self._renderer.draw_path( - gc1, path, - mpl.transforms.Affine2D() - .rotate_deg(angle).translate(x, self.height - y), - rgba) - gc1.restore() + for text in page.text), + ((box.x, box.y, box.width, box.height) for box in page.boxes)) def get_canvas_width_height(self): # docstring inherited From 94ff452edd18042395cf6578c538e41398378eca Mon Sep 17 00:00:00 2001 From: tfpf <19171016+tfpf@users.noreply.github.com> Date: Sun, 17 Jul 2022 13:34:00 +0530 Subject: [PATCH 073/108] Implement TeX's fraction and script alignment As described in *TeX: the Program* by Don Knuth. New font constants are set to the nearest integral multiples of 0.1 for which numerators and denominators containing normal text do not have to be shifted beyond their default shift amounts at font size 30 in display and text styles. To better process superscripts and subscripts, the x-height is now always calculated instead of being retrieved from the font table (which was the case for Computer Modern); the affected font constants have been changed. A duplicate test was also fixed in the process. --- lib/matplotlib/_mathtext.py | 211 ++++++++++++++++++++------ lib/matplotlib/tests/test_mathtext.py | 11 +- 2 files changed, 172 insertions(+), 50 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 8f0d85bd5ab3..67184c5a27d2 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -306,6 +306,12 @@ def render_rect_filled(self, output: Output, """ output.rects.append((x, y, w, h)) + def get_axis_height(self, font: str, fontsize: float, dpi: float) -> float: + """ + Get the axis height for the given *font* and *fontsize*. + """ + raise NotImplementedError() + def get_xheight(self, font: str, fontsize: float, dpi: float) -> float: """ Get the xheight for the given *font* and *fontsize*. @@ -407,17 +413,19 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, offset=offset ) + def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float: + # The fraction line (if present) must be aligned with the minus sign. Therefore, + # the height of the latter from the baseline is the axis height. + metrics = self.get_metrics( + fontname, mpl.rcParams['mathtext.default'], '\u2212', fontsize, dpi) + return (metrics.ymax + metrics.ymin) / 2 + def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float: - font = self._get_font(fontname) - font.set_size(fontsize, dpi) - pclt = font.get_sfnt_table('pclt') - if pclt is None: - # Some fonts don't store the xHeight, so we do a poor man's xHeight - metrics = self.get_metrics( - fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi) - return metrics.iceberg - x_height = (pclt['xHeight'] / 64) * (fontsize / 12) * (dpi / 100) - return x_height + # Some fonts report the wrong x-height, while some don't store it, so + # we do a poor man's x-height. + metrics = self.get_metrics( + fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi) + return metrics.iceberg def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float: # This function used to grab underline thickness from the font @@ -895,7 +903,10 @@ class FontConstantsBase: # Percentage of x-height of additional horiz. space after sub/superscripts script_space: T.ClassVar[float] = 0.05 - # Percentage of x-height that sub/superscripts drop below the baseline + # Percentage of x-height that superscripts drop below the top of large box + supdrop: T.ClassVar[float] = 0.4 + + # Percentage of x-height that subscripts drop below the bottom of large box subdrop: T.ClassVar[float] = 0.4 # Percentage of x-height that superscripts are raised from the baseline @@ -921,16 +932,45 @@ class FontConstantsBase: # integrals delta_integral: T.ClassVar[float] = 0.1 + # Percentage of x-height the numerator is shifted up in display style. + num1: T.ClassVar[float] = 1.4 + + # Percentage of x-height the numerator is shifted up in text, script and + # scriptscript styles if there is a fraction line. + num2: T.ClassVar[float] = 1.5 + + # Percentage of x-height the numerator is shifted up in text, script and + # scriptscript styles if there is no fraction line. + num3: T.ClassVar[float] = 1.3 + + # Percentage of x-height the denominator is shifted down in display style. + denom1: T.ClassVar[float] = 1.3 + + # Percentage of x-height the denominator is shifted down in text, script + # and scriptscript styles. + denom2: T.ClassVar[float] = 1.1 + class ComputerModernFontConstants(FontConstantsBase): - script_space = 0.075 - subdrop = 0.2 - sup1 = 0.45 - sub1 = 0.2 - sub2 = 0.3 - delta = 0.075 + # Previously, the x-height of Computer Modern was obtained from the font + # table. However, that x-height was greater than the the actual (rendered) + # x-height by a factor of 1.771484375 (at font size 12, DPI 100 and hinting + # type 32). Now that we're using the rendered x-height, some font constants + # have been increased by the same factor to compensate. + script_space = 0.132861328125 + supdrop = 0.354296875 + subdrop = 0.354296875 + sup1 = 0.79716796875 + sub1 = 0.354296875 + sub2 = 0.5314453125 + delta = 0.132861328125 delta_slanted = 0.3 delta_integral = 0.3 + num1 = 1.5 + num2 = 1.5 + num3 = 1.5 + denom1 = 1.6 + denom2 = 1.2 class STIXFontConstants(FontConstantsBase): @@ -940,6 +980,10 @@ class STIXFontConstants(FontConstantsBase): delta = 0.05 delta_slanted = 0.3 delta_integral = 0.3 + num1 = 1.6 + num2 = 1.6 + num3 = 1.6 + denom1 = 1.6 class STIXSansFontConstants(FontConstantsBase): @@ -947,10 +991,16 @@ class STIXSansFontConstants(FontConstantsBase): sup1 = 0.8 delta_slanted = 0.6 delta_integral = 0.3 + num1 = 1.5 + num3 = 1.5 + denom1 = 1.5 class DejaVuSerifFontConstants(FontConstantsBase): - pass + num1 = 1.5 + num2 = 1.6 + num3 = 1.4 + denom1 = 1.4 class DejaVuSansFontConstants(FontConstantsBase): @@ -1015,6 +1065,15 @@ def shrink(self) -> None: def render(self, output: Output, x: float, y: float) -> None: """Render this node.""" + def is_char_node(self) -> bool: + # TeX defines a `char_node` as one which represents a single character, + # but also states that a `char_node` will never appear in a `Vlist` + # (node134). Further, nuclei made of one `Char` and nuclei made of + # multiple `Char`s have their superscripts and subscripts shifted by + # the same amount. In order to make Mathtext behave similarly, just + # check whether this node is a `Vlist` or has any `Vlist` descendants. + return True + class Box(Node): """A node with a physical location.""" @@ -1204,6 +1263,10 @@ def __init__(self, elements: T.Sequence[Node], w: float = 0.0, self.kern() self.hpack(w=w, m=m) + def is_char_node(self) -> bool: + # See description in Node.is_char_node. + return all(map(lambda node: node.is_char_node(), self.children)) + def kern(self) -> None: """ Insert `Kern` nodes between `Char` nodes to set kerning. @@ -1295,6 +1358,10 @@ def __init__(self, elements: T.Sequence[Node], h: float = 0.0, super().__init__(elements) self.vpack(h=h, m=m) + def is_char_node(self) -> bool: + # See description in Node.is_char_node. + return False + def vpack(self, h: float = 0.0, m: T.Literal['additional', 'exactly'] = 'additional', l: float = np.inf) -> None: @@ -2111,6 +2178,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex: | p.text | p.boldsymbol | p.substack + | p.auto_delim ) mdelim = r"\middle" - (p.delim("mdelim") | Error("Expected a delimiter")) @@ -2440,8 +2508,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: state = self.get_state() rule_thickness = state.fontset.get_underline_thickness( state.font, state.fontsize, state.dpi) - x_height = state.fontset.get_xheight( - state.font, state.fontsize, state.dpi) + x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi) if napostrophes: if super is None: @@ -2530,9 +2597,19 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: else: subkern = 0 + # Set the minimum shifts for the superscript and subscript (node756). + if nucleus.is_char_node(): + shift_up = 0.0 + shift_down = 0.0 + else: + shrunk_x_height = state.fontset.get_xheight( + state.font, state.fontsize * SHRINK_FACTOR, state.dpi) + shift_up = nucleus.height - consts.supdrop * shrunk_x_height + shift_down = nucleus.depth + consts.subdrop * shrunk_x_height + x: List if super is None: - # node757 + # Align subscript without superscript (node757). # Note: One of super or sub must be a Node if we're in this function, but # mypy can't know this, since it can't interpret pyparsing expressions, # hence the cast. @@ -2541,29 +2618,37 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: if self.is_dropsub(last_char): shift_down = lc_baseline + consts.subdrop * x_height else: - shift_down = consts.sub1 * x_height + shift_down = max(shift_down, consts.sub1 * x_height, + x.height - x_height * 4 / 5) x.shift_amount = shift_down else: + # Align superscript (node758). x = Hlist([Kern(superkern), super]) x.shrink() if self.is_dropsub(last_char): shift_up = lc_height - consts.subdrop * x_height else: - shift_up = consts.sup1 * x_height + shift_up = max(shift_up, consts.sup1 * x_height, x.depth + x_height / 4) if sub is None: x.shift_amount = -shift_up - else: # Both sub and superscript + else: + # Align subscript with superscript (node759). y = Hlist([Kern(subkern), sub]) y.shrink() if self.is_dropsub(last_char): shift_down = lc_baseline + consts.subdrop * x_height else: - shift_down = consts.sub2 * x_height - # If sub and superscript collide, move super up - clr = (2 * rule_thickness - + shift_down = max(shift_down, consts.sub2 * x_height) + # If the subscript and superscript are too close to each other, + # move the subscript down. + clr = (4 * rule_thickness - ((shift_up - x.depth) - (y.height - shift_down))) if clr > 0.: - shift_up += clr + shift_down += clr + clr = x_height * 4 / 5 - shift_up + x.depth + if clr > 0: + shift_up += clr + shift_down -= clr x = Vlist([ x, Kern((shift_up - x.depth) - (y.height - shift_down)), @@ -2586,7 +2671,13 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty state = self.get_state() thickness = state.get_current_underline_thickness() + axis_height = state.fontset.get_axis_height( + state.font, state.fontsize, state.dpi) + consts = _get_font_constant_set(state) + x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi) + for _ in range(style.value): + x_height *= SHRINK_FACTOR num.shrink() den.shrink() cnum = HCentered([num]) @@ -2594,24 +2685,54 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty width = max(num.width, den.width) cnum.hpack(width, 'exactly') cden.hpack(width, 'exactly') - vlist = Vlist([ - cnum, # numerator - Vbox(0, 2 * thickness), # space - Hrule(state, rule), # rule - Vbox(0, 2 * thickness), # space - cden, # denominator - ]) - # Shift so the fraction line sits in the middle of the - # equals sign - metrics = state.fontset.get_metrics( - state.font, mpl.rcParams['mathtext.default'], - '=', state.fontsize, state.dpi) - shift = (cden.height - - ((metrics.ymax + metrics.ymin) / 2 - 3 * thickness)) - vlist.shift_amount = shift - - result: list[Box | Char | str] = [Hlist([vlist, Hbox(2 * thickness)])] + # Align the fraction with a fraction line (node743, node744 and node746). + if rule: + if style is self._MathStyle.DISPLAYSTYLE: + num_shift_up = consts.num1 * x_height + den_shift_down = consts.denom1 * x_height + clr = 3 * rule # The minimum clearance. + else: + num_shift_up = consts.num2 * x_height + den_shift_down = consts.denom2 * x_height + clr = rule # The minimum clearance. + delta = rule / 2 + num_clr = max((num_shift_up - cnum.depth) - (axis_height + delta), clr) + den_clr = max((axis_height - delta) - (cden.height - den_shift_down), clr) + # Possible bug in fraction rendering. See GitHub PR 22852 comments. + vlist = Vlist([cnum, # numerator + Vbox(0, num_clr - rule), # space + Hrule(state, rule), # rule + Vbox(0, den_clr + rule), # space + cden # denominator + ]) + vlist.shift_amount = cden.height + den_clr + delta - axis_height + + # Align the fraction without a fraction line (node743, node744 and node745). + else: + if style is self._MathStyle.DISPLAYSTYLE: + num_shift_up = consts.num1 * x_height + den_shift_down = consts.denom1 * x_height + min_clr = 7 * thickness # The minimum clearance. + else: + num_shift_up = consts.num3 * x_height + den_shift_down = consts.denom2 * x_height + min_clr = 3 * thickness # The minimum clearance. + def_clr = (num_shift_up - cnum.depth) - (cden.height - den_shift_down) + clr = max(def_clr, min_clr) + vlist = Vlist([cnum, # numerator + Vbox(0, clr), # space + cden # denominator + ]) + vlist.shift_amount = den_shift_down + if def_clr < min_clr: + vlist.shift_amount += (min_clr - def_clr) / 2 + + result: list[Box | Char | str] = [Hlist([ + Hbox(thickness), + vlist, + Hbox(thickness) + ])] if ldelim or rdelim: return self._auto_sized_delimiter(ldelim or ".", result, rdelim or ".") return result diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 31b5d37ea041..3d62d2640ce6 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -78,7 +78,7 @@ r'$x+{y}^{\frac{2}{k+1}}$', r'$\frac{a}{b/2}$', r'${a}_{0}+\frac{1}{{a}_{1}+\frac{1}{{a}_{2}+\frac{1}{{a}_{3}+\frac{1}{{a}_{4}}}}}$', - r'${a}_{0}+\frac{1}{{a}_{1}+\frac{1}{{a}_{2}+\frac{1}{{a}_{3}+\frac{1}{{a}_{4}}}}}$', + r'${a}_{0}+\dfrac{1}{{a}_{1}+\dfrac{1}{{a}_{2}+\dfrac{1}{{a}_{3}+\dfrac{1}{{a}_{4}}}}}$', r'$\binom{n}{k/2}$', r'$\binom{p}{2}{x}^{2}{y}^{p-2}-\frac{1}{1-x}\frac{1}{1-{x}^{2}}$', r'${x}^{2y}$', @@ -577,11 +577,12 @@ def test_box_repr(): _mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING), fontsize=12, dpi=100)) assert s == textwrap.dedent("""\ - Hlist[ + Hlist[ Hlist[], - Hlist[ - Hlist[ - Vlist[ + Hlist[ + Hlist[ + Hbox, + Vlist[ HCentered[ Glue, Hlist[ From 4bfa0f975042ab82d65d5233cf9d926bcaef8ce2 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 1 Feb 2026 17:38:00 -0500 Subject: [PATCH 074/108] Fix positioning of mathtext Rules At the call site https://github.com/matplotlib/matplotlib/blob/51fbfc4eb0e882ef7e95ceab9777c7047f4db819/lib/matplotlib/_mathtext.py#L1706-L1709 the box should clearly go from `cur_v + off_v` to `cur_v + off_v - rule_height` (this is why `cur_v` is shifted by `+ rule_height` just before; also at that point some print debugging indicates that y's go *downwards*), so `Rule.render` should indeed call `render_rect_filled` from `y - h` to `y`. --- lib/matplotlib/_mathtext.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 67184c5a27d2..1882e76d3921 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -1453,7 +1453,7 @@ def __init__(self, width: float, height: float, depth: float, state: ParserState def render(self, output: Output, # type: ignore[override] x: float, y: float, w: float, h: float) -> None: - self.fontset.render_rect_filled(output, x, y, w, h) + self.fontset.render_rect_filled(output, x, y - h, w, h) class Hrule(Rule): @@ -2699,12 +2699,11 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty delta = rule / 2 num_clr = max((num_shift_up - cnum.depth) - (axis_height + delta), clr) den_clr = max((axis_height - delta) - (cden.height - den_shift_down), clr) - # Possible bug in fraction rendering. See GitHub PR 22852 comments. - vlist = Vlist([cnum, # numerator - Vbox(0, num_clr - rule), # space - Hrule(state, rule), # rule - Vbox(0, den_clr + rule), # space - cden # denominator + vlist = Vlist([cnum, # numerator + Vbox(0, num_clr), # space + Hrule(state, rule), # rule + Vbox(0, den_clr), # space + cden # denominator ]) vlist.shift_amount = cden.height + den_clr + delta - axis_height From 1cd851066810b5acb870c3172eb3fcefe6edbb49 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 6 Feb 2026 03:00:39 -0500 Subject: [PATCH 075/108] Fix up mathtext constants for fraction/script alignment Computer Modern values are taken from `cmsy10.tfm`, and divided by the x-height in that output to match the scale used in Matplotlib. DejaVu Sans/Serif and STIX constants are taken from the embedded TeX table extracted with FontForge. --- lib/matplotlib/_mathtext.py | 86 ++++++++++++++++++--------- lib/matplotlib/tests/test_mathtext.py | 8 +-- 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 1882e76d3921..c41d2d4caa55 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -958,53 +958,83 @@ class ComputerModernFontConstants(FontConstantsBase): # type 32). Now that we're using the rendered x-height, some font constants # have been increased by the same factor to compensate. script_space = 0.132861328125 - supdrop = 0.354296875 - subdrop = 0.354296875 - sup1 = 0.79716796875 - sub1 = 0.354296875 - sub2 = 0.5314453125 delta = 0.132861328125 delta_slanted = 0.3 delta_integral = 0.3 - num1 = 1.5 - num2 = 1.5 - num3 = 1.5 - denom1 = 1.6 - denom2 = 1.2 + _x_height = 451470 + # These all come from the cmsy10.tfm metrics, divided by the design xheight from + # there, since we multiply these values by the scaled xheight later. + supdrop = 404864 / _x_height + subdrop = 52429 / _x_height + sup1 = 432949 / _x_height + sub1 = 157286 / _x_height + sub2 = 259226 / _x_height + num1 = 709370 / _x_height + num2 = 412858 / _x_height + num3 = 465286 / _x_height + denom1 = 719272 / _x_height + denom2 = 361592 / _x_height class STIXFontConstants(FontConstantsBase): script_space = 0.1 - sup1 = 0.8 - sub2 = 0.6 delta = 0.05 delta_slanted = 0.3 delta_integral = 0.3 - num1 = 1.6 - num2 = 1.6 - num3 = 1.6 - denom1 = 1.6 - - -class STIXSansFontConstants(FontConstantsBase): + # These values are extracted from the TeX table of STIXGeneral.ttf using FreeType, + # and then divided by design xheight, since we multiply these values by the scaled + # xheight later. + _x_height = 450 + supdrop = 386 / _x_height + subdrop = 50.0002 / _x_height + sup1 = 413 / _x_height + sub1 = 150 / _x_height + sub2 = 309 / _x_height + num1 = 747 / _x_height + num2 = 424 / _x_height + num3 = 474 / _x_height + denom1 = 756 / _x_height + denom2 = 375 / _x_height + + +class STIXSansFontConstants(STIXFontConstants): script_space = 0.05 - sup1 = 0.8 delta_slanted = 0.6 delta_integral = 0.3 - num1 = 1.5 - num3 = 1.5 - denom1 = 1.5 class DejaVuSerifFontConstants(FontConstantsBase): - num1 = 1.5 - num2 = 1.6 - num3 = 1.4 - denom1 = 1.4 + # These values are extracted from the TeX table of DejaVuSerif.ttf using FreeType, + # and then divided by design xheight, since we multiply these values by the scaled + # xheight later. + _x_height = 1063 + supdrop = 790.527 / _x_height + subdrop = 102.4 / _x_height + sup1 = 845.824 / _x_height + sub1 = 307.199 / _x_height + sub2 = 632.832 / _x_height + num1 = 1529.86 / _x_height + num2 = 868.352 / _x_height + num3 = 970.752 / _x_height + denom1 = 1548.29 / _x_height + denom2 = 768 / _x_height class DejaVuSansFontConstants(FontConstantsBase): - pass + # These values are extracted from the TeX table of DejaVuSans.ttf using FreeType, + # and then divided by design xheight, since we multiply these values by the scaled + # xheight later. + _x_height = 1120 + supdrop = 790.527 / _x_height + subdrop = 102.4 / _x_height + sup1 = 845.824 / _x_height + sub1 = 307.199 / _x_height + sub2 = 632.832 / _x_height + num1 = 1529.86 / _x_height + num2 = 868.352 / _x_height + num3 = 970.752 / _x_height + denom1 = 1548.29 / _x_height + denom2 = 768 / _x_height # Maps font family names to the FontConstantBase subclass to use diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 3d62d2640ce6..ff3e4a4c0e60 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -577,12 +577,12 @@ def test_box_repr(): _mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING), fontsize=12, dpi=100)) assert s == textwrap.dedent("""\ - Hlist[ + Hlist[ Hlist[], - Hlist[ - Hlist[ + Hlist[ + Hlist[ Hbox, - Vlist[ + Vlist[ HCentered[ Glue, Hlist[ From 60f231068d7aeb96a58f83b4e1ad779ae36087bd Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 6 Feb 2026 13:45:57 +0100 Subject: [PATCH 076/108] Fix confusion between text height and ascent in metrics calculations. The text height includes both the ascender and the descender, but the logic in _get_layout is that multiline texts should be treated as having an ascent at least as large as "l" and a descent at least as large as "p" (not a height at least as large as "lp" and a descent at least as large as "p") to prevent lines from bumping into each other (see changes to test_text/test_multiline, where the topmost superscript was close to bumping into the "p" descender previously). --- lib/matplotlib/offsetbox.py | 13 +++---- lib/matplotlib/text.py | 72 ++++++++++++++++++------------------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 9b9c7a69f35f..dc11c4205f5d 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -799,23 +799,24 @@ def get_bbox(self, renderer): ismath="TeX" if self._text.get_usetex() else False, dpi=self.get_figure(root=True).dpi) - bbox, info, yd = self._text._get_layout(renderer) + bbox, info = self._text._get_layout(renderer) + _last_line, (_last_width, _last_ascent, last_descent), _last_xy = info[-1] w, h = bbox.size self._baseline_transform.clear() if len(info) > 1 and self._multilinebaseline: yd_new = 0.5 * h - 0.5 * (h_ - d_) - self._baseline_transform.translate(0, yd - yd_new) - yd = yd_new + self._baseline_transform.translate(0, last_descent - yd_new) + last_descent = yd_new else: # single line - h_d = max(h_ - d_, h - yd) - h = h_d + yd + h_d = max(h_ - d_, h - last_descent) + h = h_d + last_descent ha = self._text.get_horizontalalignment() x0 = {"left": 0, "center": -w / 2, "right": -w}[ha] - return Bbox.from_bounds(x0, -yd, w, h) + return Bbox.from_bounds(x0, -last_descent, w, h) def draw(self, renderer): # docstring inherited diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 51eb8fa8cd15..6238401f83dd 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -37,23 +37,22 @@ def _get_textbox(text, renderer): # called within the _get_textbox. So, it would be better to move this # function as a method with some refactoring of _get_layout method. - projected_xs = [] - projected_ys = [] + projected_xys = [] theta = np.deg2rad(text.get_rotation()) tr = Affine2D().rotate(-theta) - _, parts, d = text._get_layout(renderer) + _, parts = text._get_layout(renderer) - for t, wh, x, y in parts: - w, h = wh - - xt1, yt1 = tr.transform((x, y)) - yt1 -= d - xt2, yt2 = xt1 + w, yt1 + h - - projected_xs.extend([xt1, xt2]) - projected_ys.extend([yt1, yt2]) + for t, (w, a, d), xy in parts: + xt, yt = tr.transform(xy) + projected_xys.extend([ + (xt, yt + a), + (xt, yt - d), + (xt + w, yt + a), + (xt + w, yt - d), + ]) + projected_xs, projected_ys = zip(*projected_xys) xt_box, yt_box = min(projected_xs), min(projected_ys) w_box, h_box = max(projected_xs) - xt_box, max(projected_ys) - yt_box @@ -434,15 +433,18 @@ def update_from(self, other): def _get_layout(self, renderer): """ - Return the extent (bbox) of the text together with - multiple-alignment information. Note that it returns an extent - of a rotated text when necessary. + Return + + - the (rotated) text bbox, and + - a list of ``(line, (width, ascent, descent), xy)`` tuples for each line. """ thisx, thisy = 0.0, 0.0 lines = self._get_wrapped_text().split("\n") # Ensures lines is not empty. - ws = [] - hs = [] + # Reminder: The ascent (a) goes from the baseline to the top and the + # descent (d) from the baseline to the bottom; both are (typically) + # nonnegative. The height h is the sum, h = a + d. + wads = [] # (width, ascents, descents) xs = [] ys = [] @@ -451,7 +453,8 @@ def _get_layout(self, renderer): renderer, "lp", self._fontproperties, ismath="TeX" if self.get_usetex() else False, dpi=self.get_figure(root=True).dpi) - min_dy = (lp_h - lp_d) * self._linespacing + lp_a = lp_h - lp_d + min_dy = lp_a * self._linespacing for i, line in enumerate(lines): clean_line, ismath = self._preprocess_math(line) @@ -462,25 +465,21 @@ def _get_layout(self, renderer): else: w = h = d = 0 - # For multiline text, increase the line spacing when the text - # net-height (excluding baseline) is larger than that of a "l" - # (e.g., use of superscripts), which seems what TeX does. - h = max(h, lp_h) + a = h - d + # To ensure good linespacing, pretend that the ascent (resp. + # descent) of all lines is at least as large as "l" (resp. "p"). + a = max(a, lp_a) d = max(d, lp_d) - ws.append(w) - hs.append(h) - # Metrics of the last line that are needed later: - baseline = (h - d) - thisy + baseline = a - thisy - if i == 0: - # position at baseline - thisy = -(h - d) - else: - # put baseline a good distance from bottom of previous line - thisy -= max(min_dy, (h - d) * self._linespacing) + if i == 0: # position at baseline + thisy = -a + else: # put baseline a good distance from bottom of previous line + thisy -= max(min_dy, a * self._linespacing) + wads.append((w, a, d)) xs.append(thisx) # == 0. ys.append(thisy) @@ -490,6 +489,7 @@ def _get_layout(self, renderer): descent = d # Bounding box definition: + ws = [w for w, a, d in wads] width = max(ws) xmin = 0 xmax = width @@ -587,7 +587,7 @@ def _get_layout(self, renderer): # now rotate the positions around the first (x, y) position xys = M.transform(offset_layout) - (offsetx, offsety) - return bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent + return bbox, list(zip(lines, wads, xys)) def set_bbox(self, rectprops): """ @@ -840,7 +840,7 @@ def draw(self, renderer): renderer.open_group('text', self.get_gid()) with self._cm_set(text=self._get_wrapped_text()): - bbox, info, descent = self._get_layout(renderer) + bbox, info = self._get_layout(renderer) trans = self.get_transform() # don't use self.get_position here, which refers to text @@ -876,7 +876,7 @@ def draw(self, renderer): angle = self.get_rotation() - for line, wh, x, y in info: + for line, wad, (x, y) in info: mtext = self if len(info) == 1 else None x = x + posx @@ -1064,7 +1064,7 @@ def get_window_extent(self, renderer=None, dpi=None): "want to call 'figure.draw_without_rendering()' first.") with cbook._setattr_cm(fig, dpi=dpi): - bbox, info, descent = self._get_layout(self._renderer) + bbox, _ = self._get_layout(self._renderer) x, y = self.get_unitless_position() x, y = self.get_transform().transform((x, y)) bbox = bbox.translated(x, y) From 8d2cc44a3973749d0cfc0cb80e2a77015d048750 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 24 Feb 2026 09:31:54 +0530 Subject: [PATCH 077/108] TST: Switch mathtext tests to mpl20 In order to preserve existing image, explicitly set the fontsize to the old setting, which is easier to read anyway. --- lib/matplotlib/tests/test_mathtext.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index ff3e4a4c0e60..c4ccd7540836 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -224,12 +224,12 @@ def baseline_images(request, fontset, index, text): @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathtext'], indirect=True) -@image_comparison(baseline_images=None, +@image_comparison(baseline_images=None, style='mpl20', tol=0.011 if platform.machine() in ('ppc64le', 's390x') else 0) def test_mathtext_rendering(baseline_images, fontset, index, text): mpl.rcParams['mathtext.fontset'] = fontset fig = plt.figure(figsize=(5.25, 0.75)) - fig.text(0.5, 0.5, text, + fig.text(0.5, 0.5, text, fontsize=12, horizontalalignment='center', verticalalignment='center') @@ -238,7 +238,7 @@ def test_mathtext_rendering(baseline_images, fontset, index, text): @pytest.mark.parametrize('fontset', ['cm', 'dejavusans']) @pytest.mark.parametrize('baseline_images', ['mathtext0'], indirect=True) @image_comparison( - baseline_images=None, extensions=['svg'], + baseline_images=None, extensions=['svg'], style='mpl20', savefig_kwarg={'metadata': { # Minimize image size. 'Creator': None, 'Date': None, 'Format': None, 'Type': None}}) def test_mathtext_rendering_svgastext(baseline_images, fontset, index, text): @@ -254,10 +254,10 @@ def test_mathtext_rendering_svgastext(baseline_images, fontset, index, text): ids=range(len(lightweight_math_tests))) @pytest.mark.parametrize('fontset', ['dejavusans']) @pytest.mark.parametrize('baseline_images', ['mathtext1'], indirect=True) -@image_comparison(baseline_images=None, extensions=['png']) +@image_comparison(baseline_images=None, extensions=['png'], style='mpl20') def test_mathtext_rendering_lightweight(baseline_images, fontset, index, text): fig = plt.figure(figsize=(5.25, 0.75)) - fig.text(0.5, 0.5, text, math_fontfamily=fontset, + fig.text(0.5, 0.5, text, fontsize=12, math_fontfamily=fontset, horizontalalignment='center', verticalalignment='center') @@ -266,12 +266,12 @@ def test_mathtext_rendering_lightweight(baseline_images, fontset, index, text): @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathfont'], indirect=True) -@image_comparison(baseline_images=None, extensions=['png'], +@image_comparison(baseline_images=None, extensions=['png'], style='mpl20', tol=0.011 if platform.machine() in ('ppc64le', 's390x') else 0) def test_mathfont_rendering(baseline_images, fontset, index, text): mpl.rcParams['mathtext.fontset'] = fontset fig = plt.figure(figsize=(5.25, 0.75)) - fig.text(0.5, 0.5, text, + fig.text(0.5, 0.5, text, fontsize=12, horizontalalignment='center', verticalalignment='center') @@ -477,7 +477,7 @@ def test_math_to_image(tmp_path): @image_comparison(baseline_images=['math_fontfamily_image.png'], - savefig_kwarg={'dpi': 40}) + savefig_kwarg={'dpi': 40}, style='mpl20') def test_math_fontfamily(): fig = plt.figure(figsize=(10, 3)) fig.text(0.2, 0.7, r"$This\ text\ should\ have\ one\ font$", From 692df3f8cd0487faae933a9dce2fb6a7fe2b2288 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 7 Feb 2026 04:14:09 -0500 Subject: [PATCH 078/108] mathtext: Fetch axis height from font metrics This mostly has a minimal change to results as the axis height is fairly closely aligned with the minus sign as previously implemented. --- lib/matplotlib/_mathtext.py | 49 ++++++++++++++++++++------- lib/matplotlib/tests/test_mathtext.py | 8 ++--- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 6ecbcd546a74..b97b213ae5cc 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -414,11 +414,15 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, ) def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float: - # The fraction line (if present) must be aligned with the minus sign. Therefore, - # the height of the latter from the baseline is the axis height. - metrics = self.get_metrics( - fontname, mpl.rcParams['mathtext.default'], '\u2212', fontsize, dpi) - return (metrics.ymax + metrics.ymin) / 2 + consts = _get_font_constants(self, fontname) + if consts.axis_height is not None: + return consts.axis_height * fontsize * dpi / 72 + else: + # The fraction line (if present) must be aligned with the minus sign. + # Therefore, the height of the latter from the baseline is the axis height. + metrics = self.get_metrics( + fontname, mpl.rcParams['mathtext.default'], '\u2212', fontsize, dpi) + return (metrics.ymax + metrics.ymin) / 2 def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float: # Some fonts report the wrong x-height, while some don't store it, so @@ -950,6 +954,10 @@ class FontConstantsBase: # and scriptscript styles. denom2: T.ClassVar[float] = 1.1 + # The height of a horizontal reference line used for positioning elements in a + # formula, similar to a baseline, as a multiple of design size. + axis_height: T.ClassVar[float | None] = None + class ComputerModernFontConstants(FontConstantsBase): # Previously, the x-height of Computer Modern was obtained from the font @@ -974,6 +982,9 @@ class ComputerModernFontConstants(FontConstantsBase): num3 = 465286 / _x_height denom1 = 719272 / _x_height denom2 = 361592 / _x_height + # These come from the cmsy10.tfm metrics, scaled so they are in multiples of design + # size. + axis_height = 262144 / 2**20 class STIXFontConstants(FontConstantsBase): @@ -981,7 +992,7 @@ class STIXFontConstants(FontConstantsBase): delta = 0.05 delta_slanted = 0.3 delta_integral = 0.3 - # These values are extracted from the TeX table of STIXGeneral.ttf using FreeType, + # These values are extracted from the TeX table of STIXGeneral.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. _x_height = 450 @@ -995,6 +1006,9 @@ class STIXFontConstants(FontConstantsBase): num3 = 474 / _x_height denom1 = 756 / _x_height denom2 = 375 / _x_height + # These come from the same TeX table, scaled by Em size so they are in multiples of + # design size. + axis_height = 250 / 1000 class STIXSansFontConstants(STIXFontConstants): @@ -1004,7 +1018,7 @@ class STIXSansFontConstants(STIXFontConstants): class DejaVuSerifFontConstants(FontConstantsBase): - # These values are extracted from the TeX table of DejaVuSerif.ttf using FreeType, + # These values are extracted from the TeX table of DejaVuSerif.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. _x_height = 1063 @@ -1018,10 +1032,13 @@ class DejaVuSerifFontConstants(FontConstantsBase): num3 = 970.752 / _x_height denom1 = 1548.29 / _x_height denom2 = 768 / _x_height + # These come from the same TeX table, scaled by Em size so they are in multiples of + # design size. + axis_height = 512 / 2048 class DejaVuSansFontConstants(FontConstantsBase): - # These values are extracted from the TeX table of DejaVuSans.ttf using FreeType, + # These values are extracted from the TeX table of DejaVuSans.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. _x_height = 1120 @@ -1035,6 +1052,9 @@ class DejaVuSansFontConstants(FontConstantsBase): num3 = 970.752 / _x_height denom1 = 1548.29 / _x_height denom2 = 768 / _x_height + # These come from the same TeX table, scaled by Em size so they are in multiples of + # design size. + axis_height = 512 / 2048 # Maps font family names to the FontConstantBase subclass to use @@ -1062,17 +1082,20 @@ class DejaVuSansFontConstants(FontConstantsBase): } -def _get_font_constant_set(state: ParserState) -> type[FontConstantsBase]: - constants = _font_constant_mapping.get( - state.fontset._get_font(state.font).family_name, FontConstantsBase) +def _get_font_constants(fontset: Fonts, font: str) -> type[FontConstantsBase]: + constants = _font_constant_mapping.get(fontset._get_font(font).family_name, + FontConstantsBase) # STIX sans isn't really its own fonts, just different code points # in the STIX fonts, so we have to detect this one separately. - if (constants is STIXFontConstants and - isinstance(state.fontset, StixSansFonts)): + if constants is STIXFontConstants and isinstance(fontset, StixSansFonts): return STIXSansFontConstants return constants +def _get_font_constant_set(state: ParserState) -> type[FontConstantsBase]: + return _get_font_constants(state.fontset, state.font) + + class Node: """A node in the TeX box model.""" diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index ff3e4a4c0e60..46cc253ad945 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -577,12 +577,12 @@ def test_box_repr(): _mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING), fontsize=12, dpi=100)) assert s == textwrap.dedent("""\ - Hlist[ + Hlist[ Hlist[], - Hlist[ - Hlist[ + Hlist[ + Hlist[ Hbox, - Vlist[ + Vlist[ HCentered[ Glue, Hlist[ From 383028bcfa7c6d3a66f672d42763d8fc19f95566 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 7 Feb 2026 04:25:22 -0500 Subject: [PATCH 079/108] mathtext: Fetch quad width from font metrics These are mostly wider than the previous calculation of the 'm' width. The values are invalid in the DejaVu fonts (-2048), so those still use the old method. --- lib/matplotlib/_mathtext.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index b97b213ae5cc..b6cdb1fa6e8e 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -312,6 +312,12 @@ def get_axis_height(self, font: str, fontsize: float, dpi: float) -> float: """ raise NotImplementedError() + def get_quad(self, font: str, fontsize: float, dpi: float) -> float: + """ + Get the size of a quad for the given *font* and *fontsize*. + """ + raise NotImplementedError() + def get_xheight(self, font: str, fontsize: float, dpi: float) -> float: """ Get the xheight for the given *font* and *fontsize*. @@ -424,6 +430,16 @@ def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float: fontname, mpl.rcParams['mathtext.default'], '\u2212', fontsize, dpi) return (metrics.ymax + metrics.ymin) / 2 + def get_quad(self, fontname: str, fontsize: float, dpi: float) -> float: + consts = _get_font_constants(self, fontname) + if consts.quad is not None: + return consts.quad * fontsize * dpi / 72 + else: + # With no other option, we measure the size of an 'm'. + metrics = self.get_metrics( + fontname, mpl.rcParams['mathtext.default'], 'm', fontsize, dpi) + return metrics.advance + def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float: # Some fonts report the wrong x-height, while some don't store it, so # we do a poor man's x-height. @@ -958,6 +974,9 @@ class FontConstantsBase: # formula, similar to a baseline, as a multiple of design size. axis_height: T.ClassVar[float | None] = None + # The size of a quad space in LaTeX, as a multiple of design size. + quad: T.ClassVar[float | None] = None + class ComputerModernFontConstants(FontConstantsBase): # Previously, the x-height of Computer Modern was obtained from the font @@ -985,6 +1004,7 @@ class ComputerModernFontConstants(FontConstantsBase): # These come from the cmsy10.tfm metrics, scaled so they are in multiples of design # size. axis_height = 262144 / 2**20 + quad = 1048579 / 2**20 class STIXFontConstants(FontConstantsBase): @@ -1009,6 +1029,7 @@ class STIXFontConstants(FontConstantsBase): # These come from the same TeX table, scaled by Em size so they are in multiples of # design size. axis_height = 250 / 1000 + quad = 1000 / 1000 class STIXSansFontConstants(STIXFontConstants): @@ -2329,10 +2350,7 @@ def _make_space(self, percentage: float) -> Kern: key = (state.font, state.fontsize, state.dpi) width = self._em_width_cache.get(key) if width is None: - metrics = state.fontset.get_metrics( - 'it', mpl.rcParams['mathtext.default'], 'm', - state.fontsize, state.dpi) - width = metrics.advance + width = state.fontset.get_quad('it', state.fontsize, state.dpi) self._em_width_cache[key] = width return Kern(width * percentage) From 813709181fed75e1ca5599219f3783ae59d7b4ec Mon Sep 17 00:00:00 2001 From: Leon Merten Lohse Date: Mon, 9 Feb 2026 09:13:43 +0100 Subject: [PATCH 080/108] mathtext refactoring: replace FontConstants lookup Replace lookup table for font constants based on the family name by methods in their respective classes. Removes non-local call of private _get_font method in _get_font_constant_set. This simplifies implementing dynamically loaded font constants. --- lib/matplotlib/_mathtext.py | 65 +++++++++++++------------------------ 1 file changed, 22 insertions(+), 43 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index b6cdb1fa6e8e..3b36d6d4ea14 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -341,6 +341,9 @@ def get_sized_alternatives_for_symbol(self, fontname: str, """ return [(fontname, sym)] + def get_font_constants(self) -> type[FontConstantsBase]: + return FontConstantsBase + class TruetypeFonts(Fonts, metaclass=abc.ABCMeta): """ @@ -420,7 +423,7 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, ) def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float: - consts = _get_font_constants(self, fontname) + consts = self.get_font_constants() if consts.axis_height is not None: return consts.axis_height * fontsize * dpi / 72 else: @@ -431,7 +434,7 @@ def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float: return (metrics.ymax + metrics.ymin) / 2 def get_quad(self, fontname: str, fontsize: float, dpi: float) -> float: - consts = _get_font_constants(self, fontname) + consts = self.get_font_constants() if consts.quad is not None: return consts.quad * fontsize * dpi / 72 else: @@ -551,6 +554,9 @@ def get_sized_alternatives_for_symbol(self, fontname: str, sym: str) -> list[tuple[str, str]]: return self._size_alternatives.get(sym, [(fontname, sym)]) + def get_font_constants(self) -> type[FontConstantsBase]: + return ComputerModernFontConstants + class UnicodeFonts(TruetypeFonts): """ @@ -741,6 +747,9 @@ class DejaVuSerifFonts(DejaVuFonts): 0: 'DejaVu Serif', } + def get_font_constants(self) -> type[FontConstantsBase]: + return DejaVuSerifFontConstants + class DejaVuSansFonts(DejaVuFonts): """ @@ -759,6 +768,9 @@ class DejaVuSansFonts(DejaVuFonts): 0: 'DejaVu Sans', } + def get_font_constants(self) -> type[FontConstantsBase]: + return DejaVuSansFontConstants + class StixFonts(UnicodeFonts): """ @@ -874,6 +886,12 @@ def get_sized_alternatives_for_symbol( # type: ignore[override] alternatives = alternatives[:-1] return alternatives + def get_font_constants(self) -> type[FontConstantsBase]: + if self._sans: + return STIXSansFontConstants + else: + return STIXFontConstants + class StixSansFonts(StixFonts): """ @@ -1078,45 +1096,6 @@ class DejaVuSansFontConstants(FontConstantsBase): axis_height = 512 / 2048 -# Maps font family names to the FontConstantBase subclass to use -_font_constant_mapping = { - 'DejaVu Sans': DejaVuSansFontConstants, - 'DejaVu Sans Mono': DejaVuSansFontConstants, - 'DejaVu Serif': DejaVuSerifFontConstants, - 'cmb10': ComputerModernFontConstants, - 'cmex10': ComputerModernFontConstants, - 'cmmi10': ComputerModernFontConstants, - 'cmr10': ComputerModernFontConstants, - 'cmss10': ComputerModernFontConstants, - 'cmsy10': ComputerModernFontConstants, - 'cmtt10': ComputerModernFontConstants, - 'STIXGeneral': STIXFontConstants, - 'STIXNonUnicode': STIXFontConstants, - 'STIXSizeFiveSym': STIXFontConstants, - 'STIXSizeFourSym': STIXFontConstants, - 'STIXSizeThreeSym': STIXFontConstants, - 'STIXSizeTwoSym': STIXFontConstants, - 'STIXSizeOneSym': STIXFontConstants, - # Map the fonts we used to ship, just for good measure - 'Bitstream Vera Sans': DejaVuSansFontConstants, - 'Bitstream Vera': DejaVuSansFontConstants, - } - - -def _get_font_constants(fontset: Fonts, font: str) -> type[FontConstantsBase]: - constants = _font_constant_mapping.get(fontset._get_font(font).family_name, - FontConstantsBase) - # STIX sans isn't really its own fonts, just different code points - # in the STIX fonts, so we have to detect this one separately. - if constants is STIXFontConstants and isinstance(fontset, StixSansFonts): - return STIXSansFontConstants - return constants - - -def _get_font_constant_set(state: ParserState) -> type[FontConstantsBase]: - return _get_font_constants(state.fontset, state.font) - - class Node: """A node in the TeX box model.""" @@ -2649,7 +2628,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: nucleus = Hlist([nucleus]) # Handle regular sub/superscripts - consts = _get_font_constant_set(state) + consts = state.fontset.get_font_constants() lc_height = last_char.height lc_baseline = 0 if self.is_dropsub(last_char): @@ -2743,7 +2722,7 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty axis_height = state.fontset.get_axis_height( state.font, state.fontsize, state.dpi) - consts = _get_font_constant_set(state) + consts = state.fontset.get_font_constants() x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi) for _ in range(style.value): From a6913f3b26d539846896d87b7729d1b1b74da2fa Mon Sep 17 00:00:00 2001 From: Leon Merten Lohse Date: Mon, 9 Feb 2026 10:30:07 +0100 Subject: [PATCH 081/108] mathtext: implement \mathnormal and distinguish between normal and it To replicate LaTeX behaviour, distinguish between "italic" and "normal" math. In particular, digits should be set italic in the \mathit env. For `cm`, use cmti font for "it" For general UnicodeFont (stix, DejaVu, ...), maps digits to roman or italic alphabet, depending on "normal" or "it" environment. --- doc/release/next_whats_new/mathnormal.rst | 10 + lib/matplotlib/_mathtext.py | 40 ++- lib/matplotlib/_mathtext_data.py | 15 +- lib/matplotlib/font_manager.py | 2 +- lib/matplotlib/mpl-data/fonts/afm/cmti10.afm | 333 ++++++++++++++++++ lib/matplotlib/mpl-data/fonts/ttf/cmti10.ttf | Bin 0 -> 32808 bytes lib/matplotlib/mpl-data/matplotlibrc | 8 +- .../mpl-data/stylelib/classic.mplstyle | 8 +- lib/matplotlib/rcsetup.py | 6 +- 9 files changed, 385 insertions(+), 37 deletions(-) create mode 100644 doc/release/next_whats_new/mathnormal.rst create mode 100644 lib/matplotlib/mpl-data/fonts/afm/cmti10.afm create mode 100644 lib/matplotlib/mpl-data/fonts/ttf/cmti10.ttf diff --git a/doc/release/next_whats_new/mathnormal.rst b/doc/release/next_whats_new/mathnormal.rst new file mode 100644 index 000000000000..7e4cd5d333fe --- /dev/null +++ b/doc/release/next_whats_new/mathnormal.rst @@ -0,0 +1,10 @@ +Mathtext distinguishes *italic* and *normal* font +------------------------------------------------- + +Matplotlib's lightweight TeX expression parser (``usetex=False``) now distinguishes between *italic* and *normal* math fonts to closer replicate the behaviour of LaTeX. +The normal math font is selected by default in math environment (unless the rcParam ``mathtext.default`` is overwritten) but can be explicitly set with the new ``\mathnormal`` command. Italic font is selected with ``\mathit``. +The main difference is that *italic* produces italic digits, whereas *normal* produces upright digits. Previously, it was not possible to typeset italic digits. +Note that ``normal`` now corresponds to what used to be ``it``, whereas ``it`` now renders all characters italic. +**Important**: In case the default mathematics font is overwritten by setting ``mathtext.default: it`` in ``matplotlibrc``, it must be either commented out or changed to ``mathtext.default: normal`` to preserve its behaviour. Otherwise, all alphanumeric characters, including digits, are rendered italic. + +One difference to traditional LaTeX is that LaTeX further distinguishes between *normal* (``\mathnormal``) and *default math*, where the default uses roman digits and normal uses oldstyle digits. This distinction is no longer present with modern LaTeX engines and unicode-math nor in Matplotlib. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 3b36d6d4ea14..dc09dedcca2c 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -269,8 +269,9 @@ def get_metrics(self, font: str, font_class: str, sym: str, fontsize: float, ---------- font : str One of the TeX font names: "tt", "it", "rm", "cal", "sf", "bf", - "default", "regular", "bb", "frak", "scr". "default" and "regular" - are synonyms and use the non-math font. + "default", "regular", "normal", "bb", "frak", "scr". "default" + and "regular" are synonyms and use the non-math font. + "normal" denotes the normal math font. font_class : str One of the TeX font names (as for *font*), but **not** "bb", "frak", or "scr". This is used to combine two font classes. The @@ -477,10 +478,11 @@ class BakomaFonts(TruetypeFonts): its own proprietary 8-bit encoding. """ _fontmap = { + 'normal': 'cmmi10', 'cal': 'cmsy10', 'rm': 'cmr10', 'tt': 'cmtt10', - 'it': 'cmmi10', + 'it': 'cmti10', 'bf': 'cmb10', 'sf': 'cmss10', 'ex': 'cmex10', @@ -500,12 +502,18 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag def _get_glyph(self, fontname: str, font_class: str, sym: str) -> tuple[FT2Font, CharacterCodeType, bool]: font = None + if fontname in self.fontmap and sym in latex_to_bakoma: basename, num = latex_to_bakoma[sym] - slanted = (basename == "cmmi10") or sym in self._slanted_symbols + slanted = (basename in ("cmmi10", "cmti10")) or sym in self._slanted_symbols font = self._get_font(basename) elif len(sym) == 1: - slanted = (fontname == "it") + slanted = (fontname in ("it", "normal")) + if fontname == "normal" and sym.isdigit(): + # use digits from cmr (roman alphabet) instead of cmm (math alphabet), + # same as LaTeX does. + fontname = "rm" + slanted = False font = self._get_font(fontname) if font is not None: num = ord(sym) @@ -636,11 +644,14 @@ def _get_glyph(self, fontname: str, font_class: str, # Only characters in the "Letter" class should be italicized in 'it' # mode. Greek capital letters should be Roman. if found_symbol: - if fontname == 'it' and uniindex < 0x10000: + if fontname == 'normal' and uniindex < 0x10000: + # normal mathematics font char = chr(uniindex) if (unicodedata.category(char)[0] != "L" or unicodedata.name(char).startswith("GREEK CAPITAL")): new_fontname = 'rm' + else: + new_fontname = 'it' slanted = (new_fontname == 'it') or sym in self._slanted_symbols found_symbol = False @@ -657,7 +668,7 @@ def _get_glyph(self, fontname: str, font_class: str, if not found_symbol: if self._fallback_font: - if (fontname in ('it', 'regular') + if (fontname in ('it', 'regular', 'normal') and isinstance(self._fallback_font, StixFonts)): fontname = 'rm' @@ -669,7 +680,7 @@ def _get_glyph(self, fontname: str, font_class: str, return g else: - if (fontname in ('it', 'regular') + if (fontname in ('it', 'regular', 'normal') and isinstance(self, StixFonts)): return self._get_glyph('rm', font_class, sym) _log.warning("Font %r does not have a glyph for %a [U+%x], " @@ -854,7 +865,7 @@ def _map_virtual_font(self, fontname: str, font_class: str, fontname = mpl.rcParams['mathtext.default'] # Fix some incorrect glyphs. - if fontname in ('rm', 'it'): + if fontname in ('rm', 'it', 'normal'): uniindex = stix_glyph_fixes.get(uniindex, uniindex) # Handle private use area glyphs @@ -1875,7 +1886,7 @@ def font(self) -> str: @font.setter def font(self, name: str) -> None: - if name in ('rm', 'it', 'bf', 'bfit'): + if name in ('normal', 'rm', 'it', 'bf', 'bfit'): self.font_class = name self._font = name @@ -2047,7 +2058,7 @@ class _MathStyle(enum.Enum): _dropsub_symbols = set(r'\int \oint \iint \oiint \iiint \oiiint \iiiint'.split()) _fontnames = set("rm cal it tt sf bf bfit " - "default bb frak scr regular".split()) + "default bb frak scr regular normal".split()) _function_names = set(""" arccos csc ker min arcsin deg lg Pr arctan det lim sec arg dim @@ -2304,7 +2315,7 @@ def non_math(self, toks: ParseResults) -> T.Any: s = toks[0].replace(r'\$', '$') symbols = [Char(c, self.get_state()) for c in s] hlist = Hlist(symbols) - # We're going into math now, so set font to 'it' + # We're going into math now, so set font to 'normal' self.push_state() self.get_state().font = mpl.rcParams['mathtext.default'] return [hlist] @@ -2323,13 +2334,14 @@ def _make_space(self, percentage: float) -> Kern: # In TeX, an em (the unit usually used to measure horizontal lengths) # is not the width of the character 'm'; it is the same in different # font styles (e.g. roman or italic). Mathtext, however, uses 'm' in - # the italic style so that horizontal spaces don't depend on the + # the normal style so that horizontal spaces don't depend on the # current font style. + # TODO: this should be read from the font file state = self.get_state() key = (state.font, state.fontsize, state.dpi) width = self._em_width_cache.get(key) if width is None: - width = state.fontset.get_quad('it', state.fontsize, state.dpi) + width = state.fontset.get_quad('normal', state.fontsize, state.dpi) self._em_width_cache[key] = width return Kern(width * percentage) diff --git a/lib/matplotlib/_mathtext_data.py b/lib/matplotlib/_mathtext_data.py index f8b7c9ac2c33..6d0c20a1b2a2 100644 --- a/lib/matplotlib/_mathtext_data.py +++ b/lib/matplotlib/_mathtext_data.py @@ -161,16 +161,6 @@ '(' : ('cmr10', 0x28), ')' : ('cmr10', 0x29), '+' : ('cmr10', 0x2b), - '0' : ('cmr10', 0x30), - '1' : ('cmr10', 0x31), - '2' : ('cmr10', 0x32), - '3' : ('cmr10', 0x33), - '4' : ('cmr10', 0x34), - '5' : ('cmr10', 0x35), - '6' : ('cmr10', 0x36), - '7' : ('cmr10', 0x37), - '8' : ('cmr10', 0x38), - '9' : ('cmr10', 0x39), ':' : ('cmr10', 0x3a), ';' : ('cmr10', 0x3b), '=' : ('cmr10', 0x3d), @@ -1350,7 +1340,7 @@ "\N{DOUBLE-STRUCK CAPITAL PI}"), ("\N{GREEK CAPITAL LETTER SIGMA}", "\N{GREEK CAPITAL LETTER SIGMA}", - "it", + "rm", # not in STIX italic "\N{DOUBLE-STRUCK N-ARY SUMMATION}"), # \Sigma (not in beta STIX fonts) ("\N{GREEK SMALL LETTER GAMMA}", "\N{GREEK SMALL LETTER GAMMA}", @@ -1778,6 +1768,9 @@ ], } +_stix_virtual_fonts['bb']['normal'] = _stix_virtual_fonts['bb']['it'] # type:ignore[call-overload] +_stix_virtual_fonts['sf']['normal'] = _stix_virtual_fonts['sf']['it'] # type:ignore[call-overload] + @overload def _normalize_stix_fontcodes(d: _EntryTypeIn) -> _EntryTypeOut: ... diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index e7bef5f29f46..b4076e686561 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1134,7 +1134,7 @@ class FontManager: # Increment this version number whenever the font cache data # format or behavior has changed and requires an existing font # cache files to be rebuilt. - __version__ = '3.11.0a2' + __version__ = '3.11.0a3' def __init__(self, size=None, weight='normal'): self._version = self.__version__ diff --git a/lib/matplotlib/mpl-data/fonts/afm/cmti10.afm b/lib/matplotlib/mpl-data/fonts/afm/cmti10.afm new file mode 100644 index 000000000000..ac9e89f676b8 --- /dev/null +++ b/lib/matplotlib/mpl-data/fonts/afm/cmti10.afm @@ -0,0 +1,333 @@ +StartFontMetrics 2.0 +FontName cmti10 +FullName cmti10 +FamilyName cmti10 +Weight Medium +ItalicAngle 0.000000 +IsFixedPitch false +UnderlinePosition -133 +UnderlineThickness 20 +Version 1.1/12-Nov-94 +FontBBox -35, -250, 1125, 750 +Notice Copyright \(C\) 1994, Basil K. Malyshev. All Rights Reserved.\nBaKoMa Fonts Collection, Level-B. +EncodingScheme FontSpecific +CapHeight 683 +XHeight 431 +Descender -194 +Ascender 694 +StartCharMetrics 129 +C 0 ; WX 627.22 ; N Gamma ; B 59 0 706 683 ; +C 1 ; WX 817.78 ; N Delta ; B 70 0 752 716 ; +C 2 ; WX 766.67 ; N Theta ; B 148 -22 788 705 ; +C 3 ; WX 692.22 ; N Lambda ; B 58 0 643 716 ; +C 4 ; WX 664.44 ; N Xi ; B 75 0 755 683 ; +C 5 ; WX 743.33 ; N Pi ; B 59 0 854 683 ; +C 6 ; WX 715.56 ; N Sigma ; B 80 0 782 683 ; +C 7 ; WX 766.67 ; N Upsilon ; B 213 0 833 705 ; +C 8 ; WX 715.56 ; N Phi ; B 158 0 729 683 ; +C 9 ; WX 766.67 ; N Psi ; B 211 0 825 683 ; +C 10 ; WX 715.56 ; N Omega ; B 100 0 759 705 ; +C 11 ; WX 613.33 ; N ff ; B -25 -205 758 705 ; L i ffi ; L l ffl ; +C 12 ; WX 562.22 ; N fi ; B -25 -205 597 705 ; +C 13 ; WX 587.78 ; N fl ; B -25 -205 639 705 ; +C 14 ; WX 881.67 ; N ffi ; B -25 -205 917 705 ; +C 15 ; WX 894.44 ; N ffl ; B -25 -205 945 705 ; +C 16 ; WX 306.67 ; N dotlessi ; B 81 -11 334 442 ; +C 17 ; WX 332.22 ; N dotlessj ; B -35 -205 322 442 ; +C 18 ; WX 511.11 ; N grave ; B 291 503 433 696 ; +C 19 ; WX 511.11 ; N acute ; B 339 503 551 696 ; +C 20 ; WX 511.11 ; N caron ; B 279 505 538 633 ; +C 21 ; WX 511.11 ; N breve ; B 280 521 567 694 ; +C 22 ; WX 511.11 ; N macron ; B 232 555 566 589 ; +C 23 ; WX 831.28 ; N ring ; B 476 541 670 716 ; +C 24 ; WX 460 ; N cedilla ; B 99 -194 338 0 ; +C 25 ; WX 536.67 ; N germandbls ; B -20 -205 578 705 ; +C 26 ; WX 715.56 ; N ae ; B 90 -11 721 442 ; +C 27 ; WX 715.56 ; N oe ; B 106 -15 721 446 ; +C 28 ; WX 511.11 ; N oslash ; B 66 -109 553 540 ; +C 29 ; WX 882.78 ; N AE ; B 59 0 949 683 ; +C 30 ; WX 985 ; N OE ; B 162 -22 1051 705 ; +C 31 ; WX 766.67 ; N Oslash ; B 120 -62 818 745 ; +C 32 ; WX 255.56 ; N polishlcross ; B 91 280 345 393 ; +C 33 ; WX 306.67 ; N exclam ; B 111 0 376 716 ; L quoteleft exclamdown ; +C 34 ; WX 514.44 ; N quotedblright ; B 176 390 520 694 ; +C 35 ; WX 817.78 ; N numbersign ; B 115 -194 828 694 ; +C 36 ; WX 769.11 ; N dollar ; B 88 -11 698 709 ; +C 37 ; WX 817.78 ; N percent ; B 145 -56 847 750 ; +C 38 ; WX 766.67 ; N ampersand ; B 127 -22 803 716 ; +C 39 ; WX 306.67 ; N quoteright ; B 218 390 375 694 ; L quoteright quotedblright ; +C 40 ; WX 408.89 ; N parenleft ; B 150 -250 517 750 ; +C 41 ; WX 408.89 ; N parenright ; B 17 -250 384 750 ; +C 42 ; WX 511.11 ; N asterisk ; B 195 319 584 750 ; +C 43 ; WX 766.67 ; N plus ; B 140 -57 753 557 ; +C 44 ; WX 306.67 ; N comma ; B 72 -194 226 110 ; +C 45 ; WX 357.78 ; N hyphen ; B 85 185 340 245 ; L hyphen endash ; +C 46 ; WX 306.67 ; N period ; B 111 0 224 110 ; +C 47 ; WX 511.11 ; N slash ; B 20 -250 617 750 ; +C 48 ; WX 511.11 ; N zero ; B 115 -22 556 666 ; +C 49 ; WX 511.11 ; N one ; B 115 0 463 666 ; +C 50 ; WX 511.11 ; N two ; B 82 -22 551 666 ; +C 51 ; WX 511.11 ; N three ; B 95 -22 562 666 ; +C 52 ; WX 511.11 ; N four ; B 44 -194 475 666 ; +C 53 ; WX 511.11 ; N five ; B 107 -22 567 666 ; +C 54 ; WX 511.11 ; N six ; B 120 -22 567 666 ; +C 55 ; WX 511.11 ; N seven ; B 142 -22 628 666 ; +C 56 ; WX 511.11 ; N eight ; B 97 -22 554 666 ; +C 57 ; WX 511.11 ; N nine ; B 105 -22 553 666 ; +C 58 ; WX 306.67 ; N colon ; B 111 0 305 431 ; +C 59 ; WX 306.67 ; N semicolon ; B 72 -194 305 431 ; +C 60 ; WX 306.67 ; N exclamdown ; B 57 -216 322 500 ; +C 61 ; WX 766.67 ; N equal ; B 115 133 776 367 ; +C 62 ; WX 511.11 ; N questiondown ; B 85 -216 442 500 ; +C 63 ; WX 511.11 ; N question ; B 194 0 551 716 ; L quoteleft questiondown ; +C 64 ; WX 766.67 ; N at ; B 151 -11 789 705 ; +C 65 ; WX 743.33 ; N A ; B 58 0 693 716 ; +C 66 ; WX 703.89 ; N B ; B 62 0 734 683 ; +C 67 ; WX 715.56 ; N C ; B 150 -22 813 705 ; +C 68 ; WX 755 ; N D ; B 60 0 775 683 ; +C 69 ; WX 678.33 ; N E ; B 59 0 744 683 ; +C 70 ; WX 652.78 ; N F ; B 59 0 732 683 ; +C 71 ; WX 773.61 ; N G ; B 150 -22 813 705 ; +C 72 ; WX 743.33 ; N H ; B 59 0 854 683 ; +C 73 ; WX 385.56 ; N I ; B 55 0 503 683 ; +C 74 ; WX 525 ; N J ; B 90 -22 622 683 ; +C 75 ; WX 768.89 ; N K ; B 59 0 860 683 ; +C 76 ; WX 627.22 ; N L ; B 59 0 626 683 ; +C 77 ; WX 896.67 ; N M ; B 63 0 1004 683 ; +C 78 ; WX 743.33 ; N N ; B 59 0 854 683 ; +C 79 ; WX 766.67 ; N O ; B 148 -22 788 705 ; +C 80 ; WX 678.33 ; N P ; B 60 0 730 683 ; +C 81 ; WX 766.67 ; N Q ; B 148 -194 788 705 ; +C 82 ; WX 729.44 ; N R ; B 60 -22 723 683 ; +C 83 ; WX 562.22 ; N S ; B 74 -22 633 705 ; +C 84 ; WX 715.56 ; N T ; B 175 0 808 683 ; +C 85 ; WX 743.33 ; N U ; B 200 -22 854 683 ; +C 86 ; WX 743.33 ; N V ; B 208 -22 868 683 ; +C 87 ; WX 998.89 ; N W ; B 207 -22 1125 683 ; +C 88 ; WX 743.33 ; N X ; B 50 0 825 683 ; +C 89 ; WX 743.33 ; N Y ; B 201 0 875 683 ; +C 90 ; WX 613.33 ; N Z ; B 79 0 704 683 ; +C 91 ; WX 306.67 ; N bracketleft ; B 73 -250 446 750 ; +C 92 ; WX 514.44 ; N quotedblleft ; B 265 390 609 694 ; +C 93 ; WX 306.67 ; N bracketright ; B -14 -250 359 750 ; +C 94 ; WX 511.11 ; N circumflex ; B 264 533 524 694 ; +C 95 ; WX 306.67 ; N dotaccent ; B 251 559 364 669 ; +C 96 ; WX 306.67 ; N quoteleft ; B 204 390 361 694 ; L quoteleft quotedblleft ; +C 97 ; WX 511.11 ; N a ; B 107 -11 538 442 ; +C 98 ; WX 460 ; N b ; B 113 -11 461 694 ; +C 99 ; WX 460 ; N c ; B 108 -11 470 442 ; +C 100 ; WX 511.11 ; N d ; B 107 -11 562 694 ; +C 101 ; WX 460 ; N e ; B 112 -11 468 442 ; +C 102 ; WX 306.67 ; N f ; B -25 -205 452 705 ; L i fi ; L f ff ; L l fl ; +C 103 ; WX 460 ; N g ; B 51 -205 489 442 ; +C 104 ; WX 511.11 ; N h ; B 74 -11 538 694 ; +C 105 ; WX 306.67 ; N i ; B 81 -11 334 656 ; +C 106 ; WX 306.67 ; N j ; B -35 -205 359 656 ; +C 107 ; WX 460 ; N k ; B 74 -11 501 694 ; +C 108 ; WX 255.56 ; N l ; B 92 -11 308 694 ; +C 109 ; WX 817.78 ; N m ; B 81 -11 845 442 ; +C 110 ; WX 562.22 ; N n ; B 81 -11 589 442 ; +C 111 ; WX 511.11 ; N o ; B 108 -11 511 442 ; +C 112 ; WX 511.11 ; N p ; B 12 -194 512 442 ; +C 113 ; WX 460 ; N q ; B 107 -194 499 442 ; +C 114 ; WX 421.67 ; N r ; B 81 -11 488 442 ; +C 115 ; WX 408.89 ; N s ; B 76 -11 419 442 ; +C 116 ; WX 332.22 ; N t ; B 90 -11 373 626 ; +C 117 ; WX 536.67 ; N u ; B 81 -11 564 442 ; +C 118 ; WX 460 ; N v ; B 81 -11 492 443 ; +C 119 ; WX 664.44 ; N w ; B 81 -11 696 443 ; +C 120 ; WX 463.89 ; N x ; B 55 -11 517 442 ; +C 121 ; WX 485.56 ; N y ; B 81 -205 517 442 ; +C 122 ; WX 408.89 ; N z ; B 60 -11 465 442 ; +C 123 ; WX 511.11 ; N endash ; B 92 253 552 279 ; L hyphen emdash ; +C 124 ; WX 1022.22 ; N emdash ; B 118 253 1037 279 ; +C 125 ; WX 511.11 ; N hungarumlaut ; B 267 505 576 697 ; +C 126 ; WX 511.11 ; N tilde ; B 248 565 572 668 ; +C 127 ; WX 511.11 ; N dieresis ; B 268 565 552 669 ; +C -1 ; WX 357.78 ; N space ; B 357 0 358 0 ; +EndCharMetrics +StartKernData +StartKernPairs 180 +KPX A C -25.56 +KPX A G -25.56 +KPX A O -25.56 +KPX A Q -25.56 +KPX A T -76.67 +KPX A U -25.56 +KPX A V -102.22 +KPX A W -102.22 +KPX A Y -76.67 +KPX A a -51.11 +KPX A b -25.56 +KPX A c -51.11 +KPX A d -51.11 +KPX A e -51.11 +KPX A g -51.11 +KPX A h -25.56 +KPX A i -25.56 +KPX A k -25.56 +KPX A l -25.56 +KPX A m -25.56 +KPX A n -25.56 +KPX A o -51.11 +KPX A q -51.11 +KPX A r -25.56 +KPX A t -25.56 +KPX A u -25.56 +KPX A v -25.56 +KPX A w -25.56 +KPX D A -25.56 +KPX D V -25.56 +KPX D W -25.56 +KPX D X -25.56 +KPX D Y -25.56 +KPX F A -102.22 +KPX F C -25.56 +KPX F G -25.56 +KPX F O -25.56 +KPX F Q -25.56 +KPX F a -76.67 +KPX F e -76.67 +KPX F o -76.67 +KPX F r -76.67 +KPX F u -76.67 +KPX K C -25.56 +KPX K G -25.56 +KPX K O -25.56 +KPX K Q -25.56 +KPX L T -76.67 +KPX L V -102.22 +KPX L W -102.22 +KPX L Y -76.67 +KPX L a -51.11 +KPX L c -51.11 +KPX L d -51.11 +KPX L e -51.11 +KPX L g -51.11 +KPX L o -51.11 +KPX L q -51.11 +KPX O A -25.56 +KPX O V -25.56 +KPX O W -25.56 +KPX O X -25.56 +KPX O Y -25.56 +KPX P A -76.67 +KPX R C -25.56 +KPX R G -25.56 +KPX R O -25.56 +KPX R Q -25.56 +KPX R T -76.67 +KPX R U -25.56 +KPX R V -102.22 +KPX R W -102.22 +KPX R Y -76.67 +KPX R a -51.11 +KPX R b -25.56 +KPX R c -51.11 +KPX R d -51.11 +KPX R e -51.11 +KPX R g -51.11 +KPX R h -25.56 +KPX R i -25.56 +KPX R k -25.56 +KPX R l -25.56 +KPX R m -25.56 +KPX R n -25.56 +KPX R o -51.11 +KPX R q -51.11 +KPX R r -25.56 +KPX R t -25.56 +KPX R u -25.56 +KPX R v -25.56 +KPX R w -25.56 +KPX T A -76.67 +KPX T a -76.67 +KPX T e -76.67 +KPX T o -76.67 +KPX T r -76.67 +KPX T u -76.67 +KPX T y -76.67 +KPX V A -102.22 +KPX V C -25.56 +KPX V G -25.56 +KPX V O -25.56 +KPX V Q -25.56 +KPX V a -76.67 +KPX V e -76.67 +KPX V o -76.67 +KPX V r -76.67 +KPX V u -76.67 +KPX W A -76.67 +KPX X C -25.56 +KPX X G -25.56 +KPX X O -25.56 +KPX X Q -25.56 +KPX Y A -76.67 +KPX Y a -76.67 +KPX Y e -76.67 +KPX Y o -76.67 +KPX Y r -76.67 +KPX Y u -76.67 +KPX b a -51.11 +KPX b c -51.11 +KPX b d -51.11 +KPX b e -51.11 +KPX b g -51.11 +KPX b o -51.11 +KPX b q -51.11 +KPX c a -51.11 +KPX c c -51.11 +KPX c d -51.11 +KPX c e -51.11 +KPX c g -51.11 +KPX c o -51.11 +KPX c q -51.11 +KPX d l 51.11 +KPX e a -51.11 +KPX e c -51.11 +KPX e d -51.11 +KPX e e -51.11 +KPX e g -51.11 +KPX e o -51.11 +KPX e q -51.11 +KPX f bracketright 104.31 +KPX f exclam 104.31 +KPX f parenright 104.31 +KPX f question 104.31 +KPX f quoteright 104.31 +KPX ff bracketright 104.31 +KPX ff exclam 104.31 +KPX ff parenright 104.31 +KPX ff question 104.31 +KPX ff quoteright 104.31 +KPX l l 51.11 +KPX n quoteright -102.22 +KPX o a -51.11 +KPX o c -51.11 +KPX o d -51.11 +KPX o e -51.11 +KPX o g -51.11 +KPX o o -51.11 +KPX o q -51.11 +KPX p a -51.11 +KPX p c -51.11 +KPX p d -51.11 +KPX p e -51.11 +KPX p g -51.11 +KPX p o -51.11 +KPX p q -51.11 +KPX polishlcross L -320.55 +KPX polishlcross l -255.55 +KPX quoteright exclam 102.22 +KPX quoteright question 102.22 +KPX r a -51.11 +KPX r c -51.11 +KPX r d -51.11 +KPX r e -51.11 +KPX r g -51.11 +KPX r o -51.11 +KPX r q -51.11 +KPX w l 51.11 +EndKernPairs +EndKernData +EndFontMetrics diff --git a/lib/matplotlib/mpl-data/fonts/ttf/cmti10.ttf b/lib/matplotlib/mpl-data/fonts/ttf/cmti10.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8311a98ac9e307d0f6657c27da01906c5a35ea51 GIT binary patch literal 32808 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 V(AWQ@1b_LHxCW&1Y1w8$_&?N+Z$1D3 literal 0 HcmV?d00001 diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 4fd08aa60578..01a2f37bfd9d 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -375,10 +375,10 @@ # 'stixsans'] when a symbol cannot be found in one of the # custom math fonts. Select 'None' to not perform fallback # and replace the missing character by a dummy symbol. -#mathtext.default: it # The default font to use for math. - # Can be any of the LaTeX font names, including - # the special name "regular" for the same font - # used in regular text. +#mathtext.default: normal # The default font to use for math. + # Can be any of the LaTeX font names (normal, it, bf, + # etc.), including the special name "regular" for the + # same font used in regular text. ## *************************************************************************** diff --git a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle index cd636d65c7c8..302a25ca29a9 100644 --- a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle +++ b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle @@ -162,10 +162,10 @@ mathtext.fallback: cm # Select fallback font from ['cm' (Computer Modern), 'sti # custom math fonts. Select 'None' to not perform fallback # and replace the missing character by a dummy. -mathtext.default : it # The default font to use for math. - # Can be any of the LaTeX font names, including - # the special name "regular" for the same font - # used in regular text. +mathtext.default: normal # The default font to use for math. + # Can be any of the LaTeX font names (normal, it, bf, + # etc.), including the special name "regular" for the + # same font used in regular text. ### AXES # default face and edge color, default tick sizes, diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index f70697fdab45..4d0d2ccf89be 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1093,7 +1093,7 @@ def _convert_validator_spec(key, conv): "mathtext.fontset": ["dejavusans", "dejavuserif", "cm", "stix", "stixsans", "custom"], "mathtext.default": ["rm", "cal", "bfit", "it", "tt", "sf", "bf", "default", - "bb", "frak", "scr", "regular"], + "bb", "frak", "scr", "regular", "normal"], "mathtext.fallback": _validate_mathtext_fallback, "image.aspect": validate_aspect, # equal, auto, a number @@ -1886,9 +1886,9 @@ class _Param: "math fonts. Select 'None' to not perform fallback and replace the " "missing character by a dummy symbol." ), - _Param("mathtext.default", "it", + _Param("mathtext.default", "normal", ["rm", "cal", "bfit", "it", "tt", "sf", "bf", "default", "bb", "frak", "scr", - "regular", ], + "regular", "normal"], description='The default font to use for math. Can be any of the LaTeX font ' 'names, including the special name "regular" for the same font ' 'used in regular text.', From 77d4e52f5b96f257903405e17a7ae22ba2ef552b Mon Sep 17 00:00:00 2001 From: Leon Merten Lohse Date: Thu, 26 Feb 2026 13:57:50 +0100 Subject: [PATCH 082/108] Add test case for mathnormal Ensure that \mathnormal is parsed and sets digits upright. --- lib/matplotlib/tests/test_mathtext.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 46cc253ad945..51963bd590cc 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -571,6 +571,13 @@ def test_boldsymbol(fig_test, fig_ref): fig_ref.text(0.1, 0.2, r"$\mathrm{abc0123\alpha}$") +@check_figures_equal() +def test_mathnormal(fig_test, fig_ref): + # ensure that \mathnormal is parsed and sets digits upright + fig_test.text(0.1, 0.2, r"$\mathnormal{0123456789}$") + fig_ref.text(0.1, 0.2, r"$\mathrm{0123456789}$") + + def test_box_repr(): s = repr(_mathtext.Parser().parse( r"$\frac{1}{2}$", From 9d233735d7207480dd0965caa0146999ef1ec97e Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 15:34:17 -0400 Subject: [PATCH 083/108] TST: Remove redundant font tests - `test_backend_ps::test_type3_font` is covered by `test_backend_ps::test_multi_font_type3` - `test_text::test_pdf_chars_beyond_bmp` is covered by `test_backend_pdf::test_multi_font_type3` and `test_backend_pdf::test_multi_font_type42` - `test_text::test_pdf_kerning` is covered by `test_backend_pdf::test_kerning` - `test_text::test_pdf_type42_kerning` is covered by `test_backend_pdf::test_kerning` --- .../baseline_images/test_backend_ps/type3.eps | 112 ------------------ .../test_text/text_pdf_chars_beyond_bmp.pdf | Bin 9428 -> 0 bytes .../test_text/text_pdf_font42_kerning.pdf | Bin 5364 -> 0 bytes .../test_text/text_pdf_kerning.pdf | Bin 3232 -> 0 bytes lib/matplotlib/tests/test_backend_ps.py | 5 - lib/matplotlib/tests/test_text.py | 21 ---- 6 files changed, 138 deletions(-) delete mode 100644 lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps delete mode 100644 lib/matplotlib/tests/baseline_images/test_text/text_pdf_chars_beyond_bmp.pdf delete mode 100644 lib/matplotlib/tests/baseline_images/test_text/text_pdf_font42_kerning.pdf delete mode 100644 lib/matplotlib/tests/baseline_images/test_text/text_pdf_kerning.pdf diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps deleted file mode 100644 index 9c9645b47cf0..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps +++ /dev/null @@ -1,112 +0,0 @@ -%!PS-Adobe-3.0 EPSF-3.0 -%%Orientation: portrait -%%BoundingBox: 18.0 180.0 594.0 612.0 -%%EndComments -%%BeginProlog -/mpldict 11 dict def -mpldict begin -/d { bind def } bind def -/m { moveto } d -/l { lineto } d -/r { rlineto } d -/c { curveto } d -/cl { closepath } d -/ce { closepath eofill } d -/box { - m - 1 index 0 r - 0 exch r - neg 0 r - cl - } d -/clipbox { - box - clip - newpath - } d -/sc { setcachedevice } d -%!PS-Adobe-3.0 Resource-Font -%%Creator: Converted from TrueType to Type 3 by Matplotlib. -10 dict begin -/FontName /DejaVuSans def -/PaintType 0 def -/FontMatrix [0.00048828125 0 0 0.00048828125 0 0] def -/FontBBox [-2090 -948 3673 2524] def -/FontType 3 def -/Encoding [/I /J /slash] def -/CharStrings 4 dict dup begin -/.notdef 0 def -/I{604 0 201 0 403 1493 sc -201 1493 m -403 1493 l -403 0 l -201 0 l -201 1493 l - -ce} d -/J{604 0 -106 -410 403 1493 sc -201 1493 m -403 1493 l -403 104 l -403 -76 369 -207 300 -288 c -232 -369 122 -410 -29 -410 c --106 -410 l --106 -240 l --43 -240 l -46 -240 109 -215 146 -165 c -183 -115 201 -25 201 104 c -201 1493 l - -ce} d -/slash{690 0 0 -190 690 1493 sc -520 1493 m -690 1493 l -170 -190 l -0 -190 l -520 1493 l - -ce} d -end readonly def - -/BuildGlyph { - exch begin - CharStrings exch - 2 copy known not {pop /.notdef} if - true 3 1 roll get exec - end -} d - -/BuildChar { - 1 index /Encoding get exch get - 1 index /BuildGlyph get exec -} d - -FontName currentdict end definefont pop -end -%%EndProlog -mpldict begin -18 180 translate -576 432 0 0 clipbox -gsave -0 0 m -576 0 l -576 432 l -0 432 l -cl -1.000 setgray -fill -grestore -0.000 setgray -/DejaVuSans findfont -12.000 scalefont -setfont -gsave -288.000000 216.000000 translate -0.000000 rotate -0.000000 0 m /I glyphshow -3.539062 0 m /slash glyphshow -7.582031 0 m /J glyphshow -grestore - -end -showpage diff --git a/lib/matplotlib/tests/baseline_images/test_text/text_pdf_chars_beyond_bmp.pdf b/lib/matplotlib/tests/baseline_images/test_text/text_pdf_chars_beyond_bmp.pdf deleted file mode 100644 index 8890790d2ea21383ef3f54dd03102d898d7ad544..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9428 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 diff --git a/lib/matplotlib/tests/baseline_images/test_text/text_pdf_font42_kerning.pdf b/lib/matplotlib/tests/baseline_images/test_text/text_pdf_font42_kerning.pdf deleted file mode 100644 index a8ce9fca346c69d372dbbc0f2a0f4b60fdafa6da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5364 zcma)Ac|25Y8&R?7 zl&z3NiXN|%PZ+(Bf^T(WXuHW*X z0$>&=>CkCx0KIr`N@lYuOd6mJJ;LCoOuC~vg$)qFb?`c1dgB!(>4d51n|4IkBv;Yknjm~BPNW@|WT3|L58VIN^J{wV- zsANq#2h0H)fx#k_Q~)$aSqTe}zy!%maPW`v2^1FHi|GjR0Tuj{4~U{AWI%y`u%|Oq9)kG{lN@B4q#BzOL*(j?n<#grCTN|=ixb_p=1QSH;VCVQo0{@~b*m#` z_3ZV^SCM%CZR;}x_h#mZdnC6mo3Rg$G3rR)CDE-t8UB?hnY&^}R8cKAbeo$$cmbHS537gZkdEMYfOt27|^zEdM0yiKTDAp(mpI zrjCt0yg)$-pFVJrBDK~ZkSY2r&DcS+tM0&ZneGn5IE4e)3j!hH8f%Jn|GiP?03NR| znxk`8XeDuFr$e_~UB!;G7B+Yf(dTCGCvU|Hq|KM!2>R4HJMhB4iq*PrEOAy>z)Znp zk8csWSjG%qBou1)AiViuIm(AI8sZg(-jv_^FfkFyzDGGDE=k#XOG=$yG+%VlSt|GB z_$7JPy`6{a_S@it`Ch5AyRR zyguZ=14f}%M?q;9dhfEq&XM`O-nr1{AZXLSJ^(P z)S`=dyxtH-8t>8YiFzJ3xF;yLI_~m(X|zR$t?fwi!4}rMg_l}vY{x{86;Y#wBK^cL z_DPJwl>uguX!FHb5wy#R>L!yG(XVciNS0ko^w%#;w>+C8jO)#F(H*vx*Un0h7-Tnb zlyIX{_vb6yHg(QUu6bi>BVygB%&Uoijf;}-5FP8DR!s3i)jNpq+A9MEB`F+jQ&A4y0b`?8sdPTHb zq5JmsuDX;{g|qSqii_V)41r{k2j;JjcPeXY(p0qlP{GI;;C4N0RHZ zTLP|!D86pn`mt{)wdPDsB$w#V841XPza`^tk6=@qMk-WSU4HCEtg~`Us|`>$sB}Ho z)p+gV4F9EKqvu-kR6*AI&{~J^nESU9CsifLjTUV2O16->EVmp_*AK4}@hyq|Ax-x0>WGdQhbzvkvz5O8BGF z#u5uQ!|u%R+S~(4H^K}qq((F6;6=IKPx}nL=3w3-s|nq@1D=zk*ArcSmwu_Qd=ev=f7Urcgbdt{BbEa^7Ht05(ZhW zuY1CeW|2w6oQFy}K+N?wSTS{ec zK0&F+EYEeMtl9j=^M+fm>>Re@CsuF>;0 zywcLQy&CtvJsB9UGkeUOP|tNKMpfoGi&9WPjD82n}sO=WZHXUcKT-6LpgHUS)H&@~UIQulqmCdbLPjRk9Tf0X9yV;jKu7 zGI7I7enG6`j#Eco0E{$Jx5r$<=4a0-`1FrMtYZXtI+D68BI&G=Q9oa<@k~~9(4ZJ` z*YIdMzf#w$P@&XEv4T4sO2m(bDW0ClI^i~CSw!R-72nCb7s~O7xZlZ4ZNit|z++rH z(=6@XNe)D5%cIR{ShskAj#&{x3TbDE2y*or@pK8(Oyzu=FHtdHy5AOe=Dj~3Q9RDs zi6JPM$N7(4K$o>2O0G@yxU$8Ir?r1VFaG0DDBXl1WS^vOEcDoB$HYYPz=fFy zX^mom&L@w~1uwUU7j4*?$#7edYh2lC=UClJgX1;F{^|q{pVa;~IyzkIV$vKzN?EVk zKIA+o=aF)(Dy71}W~c3Dg@)Al!DxfkO~;kCNv9?3yjqZyJ&^T!OrI)NP#j`=YiqpO zEr*)bP9x7I+%V*oIbua_*cY^6#hz~8FTTvb)(L8r@V+pHOF8bsyS39@seYHY(k~fW zOBuwcnnXQ|S^hrnYPZ_4q+1c2Z{830OS5&drajm7A)aP=mX~<+j|FevIq#SD*w|Obkl50g6(qiX)6os(GG80UFLztMZqt<; zw4B=5l=Pn+@%Bz~`ph{cCUWoEKLa?t;oH=gq3M3 z+8pUDmnb(q@hFEz^pcU|l?Hol=BzJpstbDQWn0#%_POIuRT%AXeY?)qz^5}~YR)L- zTd+!M6;SbDtt zbmyL!T&$vW@tDZwS@m4j9-F}x0#$eC#AXJ`%E{|9F{G zfJEJNIXUMB1!|r-=r$i&9U#a7I*Y!(q%N)log?oDcQi*4=Y9yu&aP`K_j1`5!LwoAi!Go5+>Cdk%jt@>$(%_td+4Rd2osNBX}J=2jt+ zJ`6|t7ar!tYK)q33{xz4X553zr@1ROjD2o8v!d&^yyg4P4u=)C^*L)2B0Jd{1eb3+HV(Lbaopcbpp$*(@5PQlJ7`Cuo&!Dyd0YUAkRiZHi8cO^SYs z)G~gNflActIok*3yXl&nP7%05+~YO2T!)q4@FLs~k9Uj6*`_f{H8lc*6;^NGzx;50 zeRK8&ndQV0tfQAiU0+ z8^7bq&ZoT**(Gyu2a!w=u-0&rv(1xkYSy~n6*)=$AZwe!7?v)aAmEhr&PB!6NxkcL zy)?jo?`6NM;UQg%I7F}Fu3$u9f_a3GV^5c^>ch~&sGF{Qgw&`2a=M}AO@xr1qZEaJ zX}~+MQ%MotQf(1tjpC{`@(2G$IB;^d^6EcWt=;lAy0#tJac+CBuIkRedv(`Zil;L) z)(WY#23v8Ki;LIr8Iuq2&8M|R%6FOY<^&!#A5c5R;Z`l1U2{^CdTjt9r@aKBqjQf-fPT$FY)O8*;Tt8k+ zJc$=)U}7u|n(8!E#s;abX;lfG^@>pOvfTR=bEi*Mh*l_7v_^Z~ThdXBo+O5NM%u!I(G7tpi3w za#YWkK5{LJNwN6WAE~W8q&R=b%MrLMsOL;uSjcn;< zPNuP7a4kK&xi5=N@zA3=(*a1yn=qXyOh`TN)^h?yEtTyn3&05!7b>Wze1Y8>ppvJ6 zh`ASo;ZE^@WG@1002JvE{h%J+h|Bb}wb)jm$X9T5!038Op1&z8O!xuK(@_f_|i=0WHb~R8gvs_rcJmP+eRh za=xd-1{6`CG=((23kyJl^+Lfk7d-_wU91;M2fw!Q00^VYOqCchkQciS`0CK^^fwstZX}@G|{sNcu_7}M1 z_3{f`VhsEOA&=xoAGGLRU>lGC+<@w|(0j<{AwX;{`cjKl)sneOY@lB#he>gUApjH# zhWPmbNl->B1J1xt4BF_>2cZ4HFgUP>zhhVonDuYtusHA)_Zx--BmIGa8{-cQ0~Y-6 zxd;TP7#*{8W~%%Vv_nUk-D@o?LjlK}14!9Dz=U_Q%4#In|E>HhJ+c Vgv?|wv>l^@QHDuNYn$l6{s+5*s~Z3S diff --git a/lib/matplotlib/tests/baseline_images/test_text/text_pdf_kerning.pdf b/lib/matplotlib/tests/baseline_images/test_text/text_pdf_kerning.pdf deleted file mode 100644 index 7db9a1b44fad530adc4a59230a156a3cbfbca8b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3232 zcmb_fYitx%6c&lZOi-&piH#&T!a{24&hySALZ$6?5p1D$Ng*Q9>F(`z>UL(CoytNr zkQjLwNz?=|Dv2>lNFYXps7PW8`U5Nwg8Gji8l%yFB!Ehc#B=WK>`u20A;xV^?##K5 z^PO|PbM7?Jn@YFJNli!`y+V$BDab@3-oUW1Y#9-I?K~?H1vGs`T(`Bzh}dZdcEQV& zwl;yeIb3MQHguJPm6^Z}7}3H_Y0nLa8b0^hLBM>MP<$1{Uf;`RSU@&{om3jmXXSv1 zt46?PM`i4+jH}2dB4&o{oa5$+9&X>Tc3_xg17h&QYjEVa1MRNs1tlU&A%ado!(32U z;TbD^Z?s6z)6NVLQ|v`2%lcM=b$DCn+it07`^?R5jhqkHgDtU#D#3~nrp2q}tt+9^P!h?p)wC{rx!=B{--Vb|^31?O`r2fA7T66ZNllhCMP6$n(%=vguOJd3Cx&1d^Uh=5k zsO!nu=a(M$mXDPio5q|E^DXnDaZSdrywtIye8vv%|}?tCP=M-Tclsk8Lk}wdLxAaILSTe@08Up(Isr$yIdsAbF zzIgC2bH|^1KA8K*t&!cW^RF!beb2SR^SOh&7M}RI@zX=aOn2X!!&_z#e`G9p{I|q~ zf3$Pkl1*+;r?n)h)*q8t1xtH2zdyfoE<4TNmUh( zz$GbG%KA<*@O&ckt`P3*u>;@X^^=q&tXcT0KEc**yJAV90Bems0wqxMOb|1p0|DVOJ|Y$(;O90P|oaoM}~5o!4N#L{#OD z{jG-zJq%i=lWlkN1%`3!vCAAF;L{Z7H31vxN4sIX>p~`Or~-@mfsTchqe~L`A4K@C zXQ;ACG{_%xKw_#U(N$frs6-4?B8owYYDruu;KX@cuj+C*ss^sVoe0sq3TsjDj7X>| zLu4*9oO?2=LUNoz8=RBiiRl!vF#X2&{lMDeU1viNN8=nTD1$e339j2pe6) z5rpRj&4nURaFOrC_|_f(V0eTA9^(w60^|`5qI5+gq)do_a8#NhquU%l@?a8F^of%R z2!*?BuIpi1IUBP!eb4*Ku*Mj^8q;#3Fk_M6Uc|SATIt6XCOBs zLCACKf5eS+iVjSw z*HO4^rs#kT^*UXGVB#-+9^52TbddQvog~3MTd%{auh(Iw>vhUhyONIAD*kF%WNgRv zI%TR(vZnJF@6E~Q0puw*BonDMd7WZFC02ETZ#xC%^M55)xq}|z2?Q?oc^>AS^D^UX YW01V?Q_1!No=&(qR3VY*TALRB1v20>%K!iX diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index bb6b08d14a6d..5037c15370a5 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -219,11 +219,6 @@ def test_useafm(): ax.text(.5, .5, "qk") -@image_comparison(["type3.eps"]) -def test_type3_font(): - plt.figtext(.5, .5, "I/J") - - @image_comparison(["coloredhatcheszerolw.eps"]) def test_colored_hatch_zero_linewidth(): ax = plt.gca() diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 26399c499401..ec5ee924899b 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -845,12 +845,6 @@ def test_invalid_color(): plt.figtext(.5, .5, "foo", c="foobar") -@image_comparison(['text_pdf_kerning.pdf'], style='mpl20') -def test_pdf_kerning(): - plt.figure() - plt.figtext(0.1, 0.5, "ATATATATATATATATATA", size=30) - - # See gh-26152 for more information on this xfail @pytest.mark.xfail(pyparsing_version.release == (3, 1, 0), reason="Error messages are incorrect with pyparsing 3.1.0") @@ -881,21 +875,6 @@ def test_parse_math_rcparams(): fig.canvas.draw() -@image_comparison(['text_pdf_font42_kerning.pdf'], style='mpl20') -def test_pdf_font42_kerning(): - plt.rcParams['pdf.fonttype'] = 42 - plt.figure() - plt.figtext(0.1, 0.5, "ATAVATAVATAVATAVATA", size=30) - - -@image_comparison(['text_pdf_chars_beyond_bmp.pdf'], style='mpl20') -def test_pdf_chars_beyond_bmp(): - plt.rcParams['pdf.fonttype'] = 42 - plt.rcParams['mathtext.fontset'] = 'stixsans' - plt.figure() - plt.figtext(0.1, 0.5, "Mass $m$ \U00010308", size=30) - - @needs_usetex def test_metrics_cache(): # dig into the signature to get the mutable default used as a cache From 530fc16d1c2945e1059552417bd6b50b9acb820f Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:53:19 -0700 Subject: [PATCH 084/108] PERF: Text handling speedups (#31001) * Cache text rotation Affine2D Fix tests * Direct array creation More robust BBox creation * Speed up min/max calcs * Fast path for unrotated text * Faster FontProperties copy * Faster array operations * Faster shape check * Code review updates * More robust FontProperties hashing / copying * Skip redundant wrapped text context manager * Code review updates * Restore stub __copy__ * Text consolidate rotation code path * Prefer np.vstack().T to np.column_stack() for speed Revert "Prefer np.vstack().T to np.column_stack() for speed" This reverts commit 2e32436e30339448830c3f567a165c1df8417aaf. Simplify column stack * Code review updates * Cleanup --------- Co-authored-by: Scott Shambaugh --- lib/matplotlib/font_manager.py | 25 ++--- lib/matplotlib/font_manager.pyi | 1 + lib/matplotlib/lines.py | 3 +- lib/matplotlib/text.py | 178 +++++++++++++++++--------------- lib/matplotlib/transforms.py | 2 +- 5 files changed, 112 insertions(+), 97 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index e7bef5f29f46..aa16e133da04 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -28,7 +28,6 @@ from __future__ import annotations from base64 import b64encode -import copy import dataclasses from functools import cache, lru_cache import functools @@ -767,15 +766,7 @@ def _from_any(cls, arg): return cls(**arg) def __hash__(self): - l = (tuple(self.get_family()), - self.get_slant(), - self.get_variant(), - self.get_weight(), - self.get_stretch(), - self.get_size(), - self.get_file(), - self.get_math_fontfamily()) - return hash(l) + return hash(tuple(self.__dict__.values())) def __eq__(self, other): return hash(self) == hash(other) @@ -791,7 +782,7 @@ def get_family(self): from their respective rcParams when searching for a matching font) in the order of preference. """ - return self._family + return list(self._family) def get_name(self): """ @@ -860,8 +851,8 @@ def set_family(self, family): """ family = mpl._val_or_rc(family, 'font.family') if isinstance(family, str): - family = [family] - self._family = family + family = (family,) + self._family = tuple(family) def set_style(self, style): """ @@ -1021,9 +1012,15 @@ def set_math_fontfamily(self, fontfamily): _api.check_in_list(valid_fonts, math_fontfamily=fontfamily) self._math_fontfamily = fontfamily + def __copy__(self): + # Bypass __init__ for speed, since values are already validated + new = FontProperties.__new__(FontProperties) + new.__dict__.update(self.__dict__) + return new + def copy(self): """Return a copy of self.""" - return copy.copy(self) + return self.__copy__() # Aliases set_name = set_family diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index 936dad426522..d4d0324bb02a 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -71,6 +71,7 @@ class FontProperties: math_fontfamily: str | None = ..., ) -> None: ... def __hash__(self) -> int: ... + def __copy__(self) -> FontProperties: ... def __eq__(self, other: object) -> bool: ... def get_family(self) -> list[str]: ... def get_name(self) -> str: ... diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 7c374843b5c1..d9d58868fb6e 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -684,7 +684,8 @@ def recache(self, always=False): y = self._y self._xy = np.column_stack(np.broadcast_arrays(x, y)).astype(float) - self._x, self._y = self._xy.T # views + self._x = self._xy[:, 0] # views of the x and y data + self._y = self._xy[:, 1] self._subslice = False if (self.axes diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index a53b0072c7e9..14e74d08887b 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -4,6 +4,7 @@ from collections.abc import Sequence import functools +import itertools import logging import math from numbers import Real @@ -24,6 +25,25 @@ _log = logging.getLogger(__name__) +@functools.lru_cache(maxsize=128) +def _rotate(theta): + """ + Return an Affine2D object that rotates by the given angle in radians. + """ + return Affine2D().rotate(theta) + + +def _rotate_point(angle, x, y): + """ + Rotate point (x, y) by rotation angle in degrees + """ + if angle == 0: + return (x, y) + angle_rad = math.radians(angle) + cos, sin = math.cos(angle_rad), math.sin(angle_rad) + return (cos * x - sin * y, sin * x + cos * y) + + def _get_textbox(text, renderer): """ Calculate the bounding box of the text. @@ -39,8 +59,8 @@ def _get_textbox(text, renderer): projected_xys = [] - theta = np.deg2rad(text.get_rotation()) - tr = Affine2D().rotate(-theta) + theta = math.radians(text.get_rotation()) + tr = _rotate(-theta) _, parts = text._get_layout(renderer) @@ -57,7 +77,7 @@ def _get_textbox(text, renderer): xt_box, yt_box = min(projected_xs), min(projected_ys) w_box, h_box = max(projected_xs) - xt_box, max(projected_ys) - yt_box - x_box, y_box = Affine2D().rotate(theta).transform((xt_box, yt_box)) + x_box, y_box = _rotate(theta).transform((xt_box, yt_box)) return x_box, y_box, w_box, h_box @@ -355,10 +375,10 @@ def _char_index_at(self, x): return (np.abs(size_accum - std_x)).argmin() def get_rotation(self): - """Return the text angle in degrees between 0 and 360.""" + """Return the text angle in degrees in the range [0, 360).""" if self.get_transform_rotates_text(): return self.get_transform().transform_angles( - [self._rotation], [self.get_unitless_position()]).item(0) + [self._rotation], [self.get_unitless_position()]).item(0) % 360 else: return self._rotation @@ -496,9 +516,6 @@ def _get_layout(self, renderer): ymax = 0 ymin = ys[-1] - descent # baseline of last line minus its descent - # get the rotation matrix - M = Affine2D().rotate_deg(self.get_rotation()) - # now offset the individual text lines within the box malign = self._get_multialignment() if malign == 'left': @@ -511,16 +528,17 @@ def _get_layout(self, renderer): for x, y, w in zip(xs, ys, ws)] # the corners of the unrotated bounding box - corners_horiz = np.array( - [(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]) + corners_horiz = [(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)] # now rotate the bbox - corners_rotated = M.transform(corners_horiz) + angle = self.get_rotation() + rotate = functools.partial(_rotate_point, angle) + corners_rotated = [rotate(x, y) for x, y in corners_horiz] + # compute the bounds of the rotated box - xmin = corners_rotated[:, 0].min() - xmax = corners_rotated[:, 0].max() - ymin = corners_rotated[:, 1].min() - ymax = corners_rotated[:, 1].max() + xs, ys = zip(*corners_rotated) + xmin, xmax = min(xs), max(xs) + ymin, ymax = min(ys), max(ys) width = xmax - xmin height = ymax - ymin @@ -531,7 +549,6 @@ def _get_layout(self, renderer): rotation_mode = self.get_rotation_mode() if rotation_mode != "anchor": - angle = self.get_rotation() if rotation_mode == 'xtick': halign = self._ha_for_angle(angle) elif rotation_mode == 'ytick': @@ -577,7 +594,7 @@ def _get_layout(self, renderer): else: offsety = ymin1 - offsetx, offsety = M.transform((offsetx, offsety)) + offsetx, offsety = rotate(offsetx, offsety) xmin -= offsetx ymin -= offsety @@ -585,7 +602,8 @@ def _get_layout(self, renderer): bbox = Bbox.from_bounds(xmin, ymin, width, height) # now rotate the positions around the first (x, y) position - xys = M.transform(offset_layout) - (offsetx, offsety) + xys = [(x - offsetx, y - offsety) + for x, y in itertools.starmap(rotate, offset_layout)] return bbox, list(zip(lines, wads, xys)) @@ -726,11 +744,11 @@ def _get_wrap_line_width(self): # Calculate available width based on text alignment alignment = self.get_horizontalalignment() self.set_rotation_mode('anchor') - rotation = self.get_rotation() + angle = self.get_rotation() - left = self._get_dist_to_box(rotation, x0, y0, figure_box) + left = self._get_dist_to_box(angle, x0, y0, figure_box) right = self._get_dist_to_box( - (180 + rotation) % 360, x0, y0, figure_box) + (180 + angle) % 360, x0, y0, figure_box) if alignment == 'left': line_width = left @@ -839,67 +857,65 @@ def draw(self, renderer): renderer.open_group('text', self.get_gid()) - with self._cm_set(text=self._get_wrapped_text()): - bbox, info = self._get_layout(renderer) - trans = self.get_transform() + bbox, info = self._get_layout(renderer) + trans = self.get_transform() + + # don't use self.get_position here, which refers to text + # position in Text: + x, y = self._x, self._y + if np.ma.is_masked(x): + x = np.nan + if np.ma.is_masked(y): + y = np.nan + posx = float(self.convert_xunits(x)) + posy = float(self.convert_yunits(y)) + posx, posy = trans.transform((posx, posy)) + if np.isnan(posx) or np.isnan(posy): + return # don't throw a warning here + if not np.isfinite(posx) or not np.isfinite(posy): + _log.warning("posx and posy should be finite values") + return + canvasw, canvash = renderer.get_canvas_width_height() - # don't use self.get_position here, which refers to text - # position in Text: - x, y = self._x, self._y - if np.ma.is_masked(x): - x = np.nan - if np.ma.is_masked(y): - y = np.nan - posx = float(self.convert_xunits(x)) - posy = float(self.convert_yunits(y)) - posx, posy = trans.transform((posx, posy)) - if np.isnan(posx) or np.isnan(posy): - return # don't throw a warning here - if not np.isfinite(posx) or not np.isfinite(posy): - _log.warning("posx and posy should be finite values") - return - canvasw, canvash = renderer.get_canvas_width_height() - - # Update the location and size of the bbox - # (`.patches.FancyBboxPatch`), and draw it. - if self._bbox_patch: - self.update_bbox_position_size(renderer) - self._bbox_patch.draw(renderer) - - gc = renderer.new_gc() - gc.set_foreground(mcolors.to_rgba(self.get_color()), isRGBA=True) - 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() - - for line, wad, (x, y) in info: - - mtext = self if len(info) == 1 else None - x = x + posx - y = y + posy - if renderer.flipy(): - y = canvash - y - clean_line, ismath = self._preprocess_math(line) - - if self.get_path_effects(): - from matplotlib.patheffects import PathEffectRenderer - textrenderer = PathEffectRenderer( - self.get_path_effects(), renderer) - else: - textrenderer = renderer - - if self.get_usetex(): - textrenderer.draw_tex(gc, x, y, clean_line, - self._fontproperties, angle, - mtext=mtext) - else: - textrenderer.draw_text(gc, x, y, clean_line, - self._fontproperties, angle, - ismath=ismath, mtext=mtext) + # Update the location and size of the bbox + # (`.patches.FancyBboxPatch`), and draw it. + if self._bbox_patch: + self.update_bbox_position_size(renderer) + self._bbox_patch.draw(renderer) + + gc = renderer.new_gc() + gc.set_foreground(mcolors.to_rgba(self.get_color()), isRGBA=True) + 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() + + for line, wad, (x, y) in info: + + mtext = self if len(info) == 1 else None + x = x + posx + y = y + posy + if renderer.flipy(): + y = canvash - y + clean_line, ismath = self._preprocess_math(line) + + if self.get_path_effects(): + from matplotlib.patheffects import PathEffectRenderer + textrenderer = PathEffectRenderer(self.get_path_effects(), renderer) + else: + textrenderer = renderer + + if self.get_usetex(): + textrenderer.draw_tex(gc, x, y, clean_line, + self._fontproperties, angle, + mtext=mtext) + else: + textrenderer.draw_text(gc, x, y, clean_line, + self._fontproperties, angle, + ismath=ismath, mtext=mtext) gc.restore() renderer.close_group('text') diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 44d01926f2e8..a9dd63436a77 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -841,7 +841,7 @@ def from_extents(*args, minpos=None): set. This is useful when dealing with logarithmic scales and other scales where negative bounds result in floating point errors. """ - bbox = Bbox(np.reshape(args, (2, 2))) + bbox = Bbox(np.asarray(args, dtype=float).reshape((2, 2))) if minpos is not None: bbox._minpos[:] = minpos return bbox From 10a67001a69b9fe41b9977e702e2b66cbb59ce96 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 2 Mar 2026 23:21:54 +0530 Subject: [PATCH 085/108] ft2font: Read more entries from OS/2 font table Apparently, when adding the additional fields in #31050, I never noticed that some of the required fields were not exposed. Also, fix a few typos in the field names. --- lib/matplotlib/ft2font.pyi | 13 +++++++++---- lib/matplotlib/tests/test_ft2font.py | 18 ++++++++++++------ src/ft2font_wrapper.cpp | 15 ++++++++++----- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 10be9e9e69a9..2be018c40d63 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -137,11 +137,16 @@ class _SfntOs2Dict(TypedDict): yStrikeoutPosition: int sFamilyClass: int panose: bytes - ulCharRange: tuple[int, int, int, int] + ulUnicodeRange: tuple[int, int, int, int] achVendID: bytes fsSelection: int - fsFirstCharIndex: int - fsLastCharIndex: int + usFirstCharIndex: int + usLastCharIndex: int + sTypoAscender: int + sTypoDescender: int + sTypoLineGap: int + usWinAscent: int + usWinDescent: int # version >= 1 ulCodePageRange: NotRequired[tuple[int, int]] # version >= 2 @@ -176,7 +181,7 @@ class _SfntVheaDict(TypedDict): vertTypoLineGap: int advanceHeightMax: int minTopSideBearing: int - minBottomSizeBearing: int + minBottomSideBearing: int yMaxExtent: int caretSlopeRise: int caretSlopeRun: int diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 61312f57bb6f..a55d1051779b 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -587,9 +587,11 @@ def test_ft2font_get_sfnt(font_name, expected): 'yStrikeoutSize': 102, 'yStrikeoutPosition': 530, 'sFamilyClass': 0, 'panose': b'\x02\x0b\x06\x03\x03\x08\x04\x02\x02\x04', - 'ulCharRange': (3875565311, 3523280383, 170156073, 67117068), + 'ulUnicodeRange': (3875565311, 3523280383, 170156073, 67117068), 'achVendID': b'PfEd', - 'fsSelection': 64, 'fsFirstCharIndex': 32, 'fsLastCharIndex': 65535, + 'fsSelection': 64, 'usFirstCharIndex': 32, 'usLastCharIndex': 65535, + 'sTypoAscender': 1556, 'sTypoDescender': -492, 'sTypoLineGap': 410, + 'usWinAscent': 1901, 'usWinDescent': 483, 'ulCodePageRange': (1610613247, 3758030848), }, 'hhea': { @@ -654,9 +656,11 @@ def test_ft2font_get_sfnt(font_name, expected): 'yStrikeoutSize': 102, 'yStrikeoutPosition': 530, 'sFamilyClass': 0, 'panose': b'\x02\x0b\x05\x00\x00\x00\x00\x00\x00\x00', - 'ulCharRange': (0, 0, 0, 0), + 'ulUnicodeRange': (0, 0, 0, 0), 'achVendID': b'\x00\x00\x00\x00', - 'fsSelection': 64, 'fsFirstCharIndex': 32, 'fsLastCharIndex': 9835, + 'fsSelection': 64, 'usFirstCharIndex': 32, 'usLastCharIndex': 9835, + 'sTypoAscender': 1276, 'sTypoDescender': -469, 'sTypoLineGap': 0, + 'usWinAscent': 1430, 'usWinDescent': 477, }, 'hhea': { 'version': (1, 0), @@ -734,9 +738,11 @@ def test_ft2font_get_sfnt(font_name, expected): 'yStrikeoutSize': 20, 'yStrikeoutPosition': 1037, 'sFamilyClass': 0, 'panose': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'ulCharRange': (3, 192, 0, 0), + 'ulUnicodeRange': (3, 192, 0, 0), 'achVendID': b'STIX', - 'fsSelection': 32, 'fsFirstCharIndex': 32, 'fsLastCharIndex': 10217, + 'fsSelection': 32, 'usFirstCharIndex': 32, 'usLastCharIndex': 10217, + 'sTypoAscender': 750, 'sTypoDescender': -250, 'sTypoLineGap': 1499, + 'usWinAscent': 2095, 'usWinDescent': 404, 'ulCodePageRange': (2688417793, 2432565248), 'sxHeight': 0, 'sCapHeight': 0, diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 72aec2a437ab..bf345cd1d044 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1282,12 +1282,17 @@ PyFT2Font_get_sfnt_table(PyFT2Font *self, std::string tagname) "yStrikeoutPosition"_a=t->yStrikeoutPosition, "sFamilyClass"_a=t->sFamilyClass, "panose"_a=py::bytes(reinterpret_cast(t->panose), 10), - "ulCharRange"_a=py::make_tuple(t->ulUnicodeRange1, t->ulUnicodeRange2, - t->ulUnicodeRange3, t->ulUnicodeRange4), + "ulUnicodeRange"_a=py::make_tuple(t->ulUnicodeRange1, t->ulUnicodeRange2, + t->ulUnicodeRange3, t->ulUnicodeRange4), "achVendID"_a=py::bytes(reinterpret_cast(t->achVendID), 4), "fsSelection"_a=t->fsSelection, - "fsFirstCharIndex"_a=t->usFirstCharIndex, - "fsLastCharIndex"_a=t->usLastCharIndex); + "usFirstCharIndex"_a=t->usFirstCharIndex, + "usLastCharIndex"_a=t->usLastCharIndex, + "sTypoAscender"_a=t->sTypoAscender, + "sTypoDescender"_a=t->sTypoDescender, + "sTypoLineGap"_a=t->sTypoLineGap, + "usWinAscent"_a=t->usWinAscent, + "usWinDescent"_a=t->usWinDescent); if (version >= 1) { result["ulCodePageRange"] = py::make_tuple(t->ulCodePageRange1, t->ulCodePageRange2); @@ -1333,7 +1338,7 @@ PyFT2Font_get_sfnt_table(PyFT2Font *self, std::string tagname) "vertTypoLineGap"_a=t->Line_Gap, "advanceHeightMax"_a=t->advance_Height_Max, "minTopSideBearing"_a=t->min_Top_Side_Bearing, - "minBottomSizeBearing"_a=t->min_Bottom_Side_Bearing, + "minBottomSideBearing"_a=t->min_Bottom_Side_Bearing, "yMaxExtent"_a=t->yMax_Extent, "caretSlopeRise"_a=t->caret_Slope_Rise, "caretSlopeRun"_a=t->caret_Slope_Run, From 8e1fa16aeac8db92a8ac650feb7d67ba1f5aba7f Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 13 Feb 2026 14:16:23 +0100 Subject: [PATCH 086/108] Remove mpl.text._get_textbox. Consider a multiline Text object with nonzero rotation. Text._get_layout performs the layouting of the individual lines. Previously, it would return (among other things) the axes-aligned bbox of the whole Text (which can be much bigger than the rotated bbox), as well as the metrics of each individual line. mpl.text._get_layout would then take these individual metrics and unrotate their positions (to horizontal lines) to compute the size of the *rotated* bbox of the text (and its position as well); this is used solely when drawing a (text-aligned) bbox around the text (`plt.text(..., bbox=...)`). The back-and-forth rotation seems a bit complicated; one can instead simply return the relevant info in Text._get_layout as well: the rotated bbox size is already available (in the variable previously named `corners_horiz`), and getting its position is also simple. Do that, and get rid of mpl.text._get_textbox (and also adjust the callers of Text._get_layout accordingly). Also add some additional drive-by reformatting of Text._get_layout. --- lib/matplotlib/offsetbox.py | 2 +- lib/matplotlib/text.py | 123 ++++++++++++------------------------ 2 files changed, 40 insertions(+), 85 deletions(-) diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index dc11c4205f5d..ca19a26f2b17 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -799,7 +799,7 @@ def get_bbox(self, renderer): ismath="TeX" if self._text.get_usetex() else False, dpi=self.get_figure(root=True).dpi) - bbox, info = self._text._get_layout(renderer) + bbox, info, _ = self._text._get_layout(renderer) _last_line, (_last_width, _last_ascent, last_descent), _last_xy = info[-1] w, h = bbox.size diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 14e74d08887b..403baefa3975 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -44,44 +44,6 @@ def _rotate_point(angle, x, y): return (cos * x - sin * y, sin * x + cos * y) -def _get_textbox(text, renderer): - """ - Calculate the bounding box of the text. - - The bbox position takes text rotation into account, but the width and - height are those of the unrotated box (unlike `.Text.get_window_extent`). - """ - # TODO : This function may move into the Text class as a method. As a - # matter of fact, the information from the _get_textbox function - # should be available during the Text._get_layout() call, which is - # called within the _get_textbox. So, it would be better to move this - # function as a method with some refactoring of _get_layout method. - - projected_xys = [] - - theta = math.radians(text.get_rotation()) - tr = _rotate(-theta) - - _, parts = text._get_layout(renderer) - - for t, (w, a, d), xy in parts: - xt, yt = tr.transform(xy) - projected_xys.extend([ - (xt, yt + a), - (xt, yt - d), - (xt + w, yt + a), - (xt + w, yt - d), - ]) - projected_xs, projected_ys = zip(*projected_xys) - - xt_box, yt_box = min(projected_xs), min(projected_ys) - w_box, h_box = max(projected_xs) - xt_box, max(projected_ys) - yt_box - - x_box, y_box = _rotate(theta).transform((xt_box, yt_box)) - - return x_box, y_box, w_box, h_box - - def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): """Call ``renderer.get_text_width_height_descent``, caching the results.""" @@ -455,8 +417,11 @@ def _get_layout(self, renderer): """ Return - - the (rotated) text bbox, and - - a list of ``(line, (width, ascent, descent), xy)`` tuples for each line. + - the rotated, axis-aligned text bbox; + - a list of ``(line, (width, ascent, descent), xy)`` tuples for each line; + - a ``(xy, (width, height))` pair of the lower-left corner and size of the + rotated, *text-aligned* text box (i.e. describing how to draw the + text-surrounding box). """ thisx, thisy = 0.0, 0.0 lines = self._get_wrapped_text().split("\n") # Ensures lines is not empty. @@ -529,7 +494,7 @@ def _get_layout(self, renderer): # the corners of the unrotated bounding box corners_horiz = [(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)] - + size_horiz = (xmax - xmin, ymax - ymin) # now rotate the bbox angle = self.get_rotation() rotate = functools.partial(_rotate_point, angle) @@ -539,8 +504,8 @@ def _get_layout(self, renderer): xs, ys = zip(*corners_rotated) xmin, xmax = min(xs), max(xs) ymin, ymax = min(ys), max(ys) - width = xmax - xmin - height = ymax - ymin + width_rot = xmax - xmin + height_rot = ymax - ymin # Now move the box to the target position offset the display # bbox by alignment @@ -555,57 +520,47 @@ def _get_layout(self, renderer): valign = self._va_for_angle(angle) # compute the text location in display coords and the offsets # necessary to align the bbox with that location - if halign == 'center': - offsetx = (xmin + xmax) / 2 - elif halign == 'right': - offsetx = xmax - else: - offsetx = xmin - - if valign == 'center': - offsety = (ymin + ymax) / 2 - elif valign == 'top': - offsety = ymax - elif valign == 'baseline': - offsety = ymin + descent - elif valign == 'center_baseline': - offsety = ymin + height - baseline / 2.0 - else: - offsety = ymin + offsetx = ( + xmin if halign == "left" else + xmax if halign == "right" else + (xmin + xmax) / 2 # halign == "center" + ) + offsety = ( + ymin if valign == "bottom" else + ymax if valign == "top" else + (ymin + ymax) / 2 if valign == "center" else + ymin + descent if valign == "baseline" else + ymin + height_rot - baseline / 2 # valign == "center_baseline" + ) else: xmin1, ymin1 = corners_horiz[0] xmax1, ymax1 = corners_horiz[2] - - if halign == 'center': - offsetx = (xmin1 + xmax1) / 2.0 - elif halign == 'right': - offsetx = xmax1 - else: - offsetx = xmin1 - - if valign == 'center': - offsety = (ymin1 + ymax1) / 2.0 - elif valign == 'top': - offsety = ymax1 - elif valign == 'baseline': - offsety = ymax1 - baseline - elif valign == 'center_baseline': - offsety = ymax1 - baseline / 2.0 - else: - offsety = ymin1 - + offsetx = ( + xmin1 if halign == "left" else + xmax1 if halign == "right" else + (xmin1 + xmax1) / 2 # halign == "center" + ) + offsety = ( + ymin1 if valign == "bottom" else + ymax1 if valign == "top" else + (ymin1 + ymax1) / 2 if valign == "center" else + ymax1 - baseline if valign == "baseline" else + ymax1 - baseline / 2 # valign == "center_baseline" + ) offsetx, offsety = rotate(offsetx, offsety) xmin -= offsetx ymin -= offsety - bbox = Bbox.from_bounds(xmin, ymin, width, height) + bbox_rot = Bbox.from_bounds(xmin, ymin, width_rot, height_rot) # now rotate the positions around the first (x, y) position xys = [(x - offsetx, y - offsety) for x, y in itertools.starmap(rotate, offset_layout)] + x, y = corners_rotated[0] + xy_corner = (x - offsetx, y - offsety) - return bbox, list(zip(lines, wads, xys)) + return bbox_rot, list(zip(lines, wads, xys)), (xy_corner, size_horiz) def set_bbox(self, rectprops): """ @@ -677,7 +632,7 @@ def update_bbox_position_size(self, renderer): posy = float(self.convert_yunits(self._y)) posx, posy = self.get_transform().transform((posx, posy)) - x_box, y_box, w_box, h_box = _get_textbox(self, renderer) + _, _, ((x_box, y_box), (w_box, h_box)) = self._get_layout(renderer) self._bbox_patch.set_bounds(0., 0., w_box, h_box) self._bbox_patch.set_transform( Affine2D() @@ -857,7 +812,7 @@ def draw(self, renderer): renderer.open_group('text', self.get_gid()) - bbox, info = self._get_layout(renderer) + bbox, info, _ = self._get_layout(renderer) trans = self.get_transform() # don't use self.get_position here, which refers to text @@ -1080,7 +1035,7 @@ def get_window_extent(self, renderer=None, dpi=None): "want to call 'figure.draw_without_rendering()' first.") with cbook._setattr_cm(fig, dpi=dpi): - bbox, _ = self._get_layout(self._renderer) + bbox, _, _ = self._get_layout(self._renderer) x, y = self.get_unitless_position() x, y = self.get_transform().transform((x, y)) bbox = bbox.translated(x, y) From 6c357eefae7ce1ebe3676854ee513be826031989 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 13 Nov 2025 10:33:08 +0100 Subject: [PATCH 087/108] Add mathtext support for \phantom, \llap, \rlap for faking text metrics --- doc/release/next_whats_new/tex_phantoms.rst | 11 ++++++ lib/matplotlib/_mathtext.py | 41 +++++++++++++++++---- lib/matplotlib/tests/test_mathtext.py | 15 ++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 doc/release/next_whats_new/tex_phantoms.rst diff --git a/doc/release/next_whats_new/tex_phantoms.rst b/doc/release/next_whats_new/tex_phantoms.rst new file mode 100644 index 000000000000..82d39d502fb5 --- /dev/null +++ b/doc/release/next_whats_new/tex_phantoms.rst @@ -0,0 +1,11 @@ +mathtext support for ``\phantom``, ``\llap``, ``\rlap`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +mathtext gained support for the TeX macros ``\phantom``, ``\llap``, and +``\rlap``. ``\phantom`` allows to occupy some space on the canvas as if +some text was being rendered, without actually rendering that text, whereas +``\llap`` and ``\rlap`` allows to render some text on the canvas while +pretending that it occupies no space. Altogether these macros allow some finer +control of text alignments. + +See https://www.tug.org/TUGboat/tb22-4/tb72perlS.pdf for a detailed description +of these macros. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index dc09dedcca2c..3a60a31eb58d 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -1326,6 +1326,7 @@ def __init__(self, elements: T.Sequence[Node], w: float = 0.0, if do_kern: self.kern() self.hpack(w=w, m=m) + self.is_phantom = False def is_char_node(self) -> bool: # See description in Node.is_char_node. @@ -1716,6 +1717,11 @@ def ship(box: Box, xy: tuple[float, float] = (0, 0)) -> Output: off_v = oy + box.height output = Output(box) + phantom: list[bool] = [] + def render(node, *args): + if not any(phantom): + node.render(*args) + def clamp(value: float) -> float: return -1e9 if value < -1e9 else +1e9 if value > +1e9 else value @@ -1729,9 +1735,11 @@ def hlist_out(box: Hlist) -> None: base_line = cur_v left_edge = cur_h + phantom.append(box.is_phantom) + for p in box.children: if isinstance(p, Char): - p.render(output, cur_h + off_h, cur_v + off_v) + render(p, output, cur_h + off_h, cur_v + off_v) cur_h += p.width elif isinstance(p, Kern): cur_h += p.width @@ -1762,9 +1770,9 @@ def hlist_out(box: Hlist) -> None: rule_depth = box.depth if rule_height > 0 and rule_width > 0: cur_v = base_line + rule_depth - p.render(output, - cur_h + off_h, cur_v + off_v, - rule_width, rule_height) + render(p, output, + cur_h + off_h, cur_v + off_v, + rule_width, rule_height) cur_v = base_line cur_h += rule_width elif isinstance(p, Glue): @@ -1782,6 +1790,8 @@ def hlist_out(box: Hlist) -> None: rule_width += cur_g cur_h += rule_width + phantom.pop() + def vlist_out(box: Vlist) -> None: nonlocal cur_v, cur_h @@ -1821,9 +1831,9 @@ def vlist_out(box: Vlist) -> None: rule_height += rule_depth if rule_height > 0 and rule_depth > 0: cur_v += rule_height - p.render(output, - cur_h + off_h, cur_v + off_v, - rule_width, rule_height) + render(p, output, + cur_h + off_h, cur_v + off_v, + rule_width, rule_height) elif isinstance(p, Glue): glue_spec = p.glue_spec rule_height = glue_spec.width - cur_g @@ -2159,6 +2169,10 @@ def csnames(group: str, names: Iterable[str]) -> Regex: p.customspace = cmd(r"\hspace", "{" + p.float_literal("space") + "}") + p.phantom = cmd(r"\phantom", p.optional_group("value")) + p.llap = cmd(r"\llap", p.optional_group("value")) + p.rlap = cmd(r"\rlap", p.optional_group("value")) + p.accent = ( csnames("accent", [*self._accent_map, *self._wide_accents]) - p.named_placeable("sym")) @@ -2225,7 +2239,8 @@ def csnames(group: str, names: Iterable[str]) -> Regex: r"\boldsymbol", "{" + ZeroOrMore(p.simple)("value") + "}") p.placeable <<= ( - p.accent # Must be before symbol as all accents are symbols + p.phantom | p.llap | p.rlap + | p.accent # Must be before symbol as all accents are symbols | p.symbol # Must be second to catch all named symbols and single # chars not in a group | p.function @@ -2419,6 +2434,16 @@ def symbol(self, s: str, loc: int, def unknown_symbol(self, s: str, loc: int, toks: ParseResults) -> T.Any: raise ParseFatalException(s, loc, f"Unknown symbol: {toks['name']}") + def phantom(self, toks: ParseResults) -> T.Any: + toks["value"].is_phantom = True + return toks["value"] + + def llap(self, toks: ParseResults) -> T.Any: + return [Hlist([Kern(-toks["value"].width), toks["value"]])] + + def rlap(self, toks: ParseResults) -> T.Any: + return [Hlist([toks["value"], Kern(-toks["value"].width)])] + _accent_map = { r'hat': r'\circumflexaccent', r'breve': r'\combiningbreve', diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 264d0b44c320..c85cbc5e21a8 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -578,6 +578,21 @@ def test_mathnormal(fig_test, fig_ref): fig_ref.text(0.1, 0.2, r"$\mathrm{0123456789}$") +# Test vector output because in raster output some minor differences remain, +# likely due to double-striking. +@check_figures_equal(extensions=["pdf"]) +def test_phantoms(fig_test, fig_ref): + fig_test.text(0.5, 0.9, r"$\rlap{rlap}extra$", ha="left") + fig_ref.text(0.5, 0.9, r"$rlap$", ha="left") + fig_ref.text(0.5, 0.9, r"$extra$", ha="left") + + fig_test.text(0.5, 0.8, r"$extra\llap{llap}$", ha="right") + fig_ref.text(0.5, 0.8, r"$llap$", ha="right") + fig_ref.text(0.5, 0.8, r"$extra$", ha="right") + + fig_test.text(0.5, 0.7, r"$\phantom{phantom}$") + + def test_box_repr(): s = repr(_mathtext.Parser().parse( r"$\frac{1}{2}$", From d772043d67603f0d0260fdbe990ba82e6f47f4c6 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Wed, 11 Mar 2026 16:08:49 -0600 Subject: [PATCH 088/108] Ignore empty text for tightbbox --- lib/matplotlib/text.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index f30572382fe9..f0dd963fe477 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -1042,6 +1042,8 @@ def get_window_extent(self, renderer=None, dpi=None): return bbox def get_tightbbox(self, renderer=None): + if not self.get_visible() or self.get_text() == "": + return Bbox.null() # Exclude text at data coordinates outside the valid domain of the axes # scales (e.g., negative coordinates with a log scale). if (self.axes From 2057583f1763a34c25452dc3696386fcef538377 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 10 Mar 2026 10:51:18 +0100 Subject: [PATCH 089/108] Drop axis_artist tickdir image compat, due to text-overhaul merge. Also replace style='default' by style='mpl20'. --- .../axisartist/tests/test_axis_artist.py | 4 +++- .../axisartist/tests/test_axislines.py | 24 +++++-------------- .../axisartist/tests/test_floating_axes.py | 13 ++-------- .../tests/test_grid_helper_curvelinear.py | 17 ++++--------- 4 files changed, 16 insertions(+), 42 deletions(-) diff --git a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py index dd56f4e98b99..1e50d71b5876 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py @@ -83,7 +83,9 @@ def test_axis_artist(): for loc in ('left', 'right', 'bottom'): helper = AxisArtistHelperRectlinear.Fixed(ax, loc=loc) axisline = AxisArtist(ax, helper, offset=None, axis_direction=loc) - axisline.major_ticks.set_tick_direction("in") + axisline.major_ticks.set_tick_direction({ + "left": "in", "right": "out", "bottom": "inout", + }[loc]) ax.add_artist(axisline) # Settings for bottom AxisArtist. diff --git a/lib/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py index 01b92a93f9da..a13432182c58 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_axislines.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py @@ -7,12 +7,8 @@ from mpl_toolkits.axisartist import Axes, SubplotHost -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['SubplotZero.png'], style='default', tol=0.02) +@image_comparison(['SubplotZero.png'], style='mpl20') def test_SubplotZero(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) - fig = plt.figure() ax = SubplotZero(fig, 1, 1, 1) @@ -29,12 +25,8 @@ def test_SubplotZero(): ax.set_ylabel("Test") -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['Subplot.png'], style='default', tol=0.02) +@image_comparison(['Subplot.png'], style='mpl20') def test_Subplot(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) - fig = plt.figure() ax = Subplot(fig, 1, 1, 1) @@ -44,8 +36,8 @@ def test_Subplot(): ax.plot(xx, np.sin(xx)) ax.set_ylabel("Test") - ax.axis["top"].major_ticks.set_tick_out(True) - ax.axis["bottom"].major_ticks.set_tick_out(True) + ax.axis["left"].major_ticks.set_tick_out(False) + ax.axis["right"].major_ticks.set_tick_out(False) ax.axis["bottom"].set_label("Tk0") @@ -60,9 +52,8 @@ def test_Axes(): @image_comparison(['ParasiteAxesAuxTrans_meshplot.png'], - remove_text=True, style='default', tol=0.075) + remove_text=True, style='mpl20', tol=0.075) def test_ParasiteAxesAuxTrans(): - # Remove this line when this test image is regenerated. plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) data = np.ones((6, 6)) data[2, 2] = 2 @@ -140,11 +131,8 @@ def test_axisline_style_tight(): ax.axis[direction].set_visible(False) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['subplotzero_ylabel.png'], style='mpl20', tol=0.02) +@image_comparison(['subplotzero_ylabel.png'], style='mpl20') def test_subplotzero_ylabel(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) fig = plt.figure() ax = fig.add_subplot(111, axes_class=SubplotZero) diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index 41fe0fca7c09..6672bd0ac3a0 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -19,12 +19,8 @@ def test_subplot(): fig.add_subplot(ax) -# Rather high tolerance to allow ongoing work with floating axes internals; -# remove when image is regenerated. -@image_comparison(['curvelinear3.png'], style='default', tol=5) +@image_comparison(['curvelinear3.png'], style='mpl20') def test_curvelinear3(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + @@ -67,13 +63,8 @@ def test_curvelinear3(): l.set_clip_path(ax1.patch) -# Rather high tolerance to allow ongoing work with floating axes internals; -# remove when image is regenerated. -@image_comparison(['curvelinear4.png'], style='default', tol=0.9) +@image_comparison(['curvelinear4.png'], style='mpl20') def test_curvelinear4(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) - fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + 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 9916a4fe9541..f49d02766421 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -15,10 +15,9 @@ GridHelperCurveLinear -@image_comparison(['custom_transform.png'], style='default', tol=0.2) +@image_comparison(['custom_transform.png'], style='mpl20', tol=0.2) def test_custom_transform(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) + plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "inout"}) class MyTransform(Transform): input_dims = output_dims = 2 @@ -79,11 +78,9 @@ def inverted(self): ax1.grid(True) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['polar_box.png'], style='default', tol=0.09) +@image_comparison(['polar_box.png'], style='mpl20') def test_polar_box(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) + plt.rcParams.update({"xtick.direction": "inout", "ytick.direction": "out"}) fig = plt.figure(figsize=(5, 5)) # PolarAxes.PolarTransform takes radian. However, we want our coordinate @@ -141,12 +138,8 @@ def test_polar_box(): ax1.grid(True) -# Remove tol when this test image is regenerated. -@image_comparison(['axis_direction.png'], style='default', tol=0.15) +@image_comparison(['axis_direction.png'], style='mpl20') def test_axis_direction(): - # Remove this line when this test image is regenerated. - plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "in"}) - fig = plt.figure(figsize=(5, 5)) # PolarAxes.PolarTransform takes radian. However, we want our coordinate From 3374e25236545aa95d029e0e3e6c9acca682a06b Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 21 Feb 2026 10:58:35 +0100 Subject: [PATCH 090/108] ENH: Register all SFNT family names so fonts are addressable by any platform name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TTF/OTF file can advertise its family name in multiple places in the SFNT name table. FreeType exposes the primary name (usually from the Macintosh-platform Name ID 1 slot), but other entries may carry different (equally valid) names that users reasonably expect to work: - Name ID 1 on the other platform (e.g. Ubuntu Light stores "Ubuntu" in the Mac slot and "Ubuntu Light" in the Microsoft slot) - Name ID 16 — Typographic/Preferred Family - Name ID 21 — WWS Family --- lib/matplotlib/font_manager.py | 84 +++++++++++++ lib/matplotlib/font_manager.pyi | 3 + lib/matplotlib/tests/test_font_manager.py | 143 ++++++++++++++++++++-- 3 files changed, 221 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index a9b5c58d5823..0a58e8a18242 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -532,6 +532,82 @@ def get_weight(): # From fontconfig's FcFreeTypeQueryFaceInternal. style, variant, weight, stretch, size) +def _get_font_alt_names(font, primary_name): + """ + Return ``(name, weight)`` pairs for alternate family names of *font*. + + A font file can advertise its family name in several places. FreeType + exposes ``font.family_name``, which is typically derived from the + Macintosh-platform Name ID 1 entry. However, other entries may carry + different (equally valid) names that users reasonably expect to work: + + - **Name ID 1, other platform** — some fonts store a different family name + on the Microsoft platform than on the Macintosh platform. + - **Name ID 16** — "Typographic Family" (a.k.a. preferred family): groups + more than the traditional four styles under one name. + - **Name ID 21** — "WWS Family": an even narrower grouping used by some + fonts (weight/width/slope only). + + Each name is paired with a weight derived from the corresponding subfamily + entry on the *same* platform. This ensures that the weight of the alternate entry + reflects the font's role *within that named family* rather than its absolute + typographic weight. + + Parameters + ---------- + font : `.FT2Font` + primary_name : str + The family name already extracted from the font (``font.family_name``). + + Returns + ------- + list of (str, int) + ``(alternate_family_name, weight)`` pairs, not including *primary_name*. + """ + try: + sfnt = font.get_sfnt() + except ValueError: + return [] + + mac_key = (1, # platform: macintosh + 0, # id: roman + 0) # langid: english + ms_key = (3, # platform: microsoft + 1, # id: unicode_cs + 0x0409) # langid: english_united_states + + seen = {primary_name} + result = [] + + def _weight_from_subfam(subfam): + subfam = subfam.replace(" ", "") + for regex, weight in _weight_regexes: + if re.search(regex, subfam, re.I): + return weight + return 400 # "Regular" or unrecognised + + def _try_add(name, subfam): + name = name.strip() + if not name or name in seen: + return + seen.add(name) + result.append((name, _weight_from_subfam(subfam.strip()))) + + # Each family-name ID is paired with its corresponding subfamily ID on the + # same platform: (family_id, subfamily_id). + for fam_id, subfam_id in ((1, 2), (16, 17), (21, 22)): + _try_add( + sfnt.get((*mac_key, fam_id), b'').decode('latin-1'), + sfnt.get((*mac_key, subfam_id), b'').decode('latin-1'), + ) + _try_add( + sfnt.get((*ms_key, fam_id), b'').decode('utf_16_be'), + sfnt.get((*ms_key, subfam_id), b'').decode('utf_16_be'), + ) + + return result + + def afmFontProperty(fontpath, font): """ Extract information from an AFM font file. @@ -1196,10 +1272,18 @@ def addfont(self, path): font = ft2font.FT2Font(path) prop = ttfFontProperty(font) self.ttflist.append(prop) + for alt_name, alt_weight in _get_font_alt_names(font, prop.name): + self.ttflist.append( + dataclasses.replace(prop, name=alt_name, weight=alt_weight)) + for face_index in range(1, font.num_faces): subfont = ft2font.FT2Font(path, face_index=face_index) prop = ttfFontProperty(subfont) self.ttflist.append(prop) + for alt_name, alt_weight in _get_font_alt_names(subfont, prop.name): + self.ttflist.append( + dataclasses.replace(prop, name=alt_name, weight=alt_weight)) + self._findfont_cached.cache_clear() @property diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index d4d0324bb02a..22d925ea9273 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -23,6 +23,9 @@ def get_fontext_synonyms(fontext: str) -> list[str]: ... def list_fonts(directory: str, extensions: Iterable[str]) -> list[str]: ... def win32FontDirectory() -> str: ... def _get_fontconfig_fonts() -> list[Path]: ... +def _get_font_alt_names( + font: ft2font.FT2Font, primary_name: str +) -> list[tuple[str, int]]: ... def findSystemFonts( fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ... ) -> list[str]: ... diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 09776de29747..26b4ce3bf252 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -11,11 +11,14 @@ import numpy as np import pytest +from unittest.mock import MagicMock, patch + import matplotlib as mpl +import matplotlib.font_manager as fm_mod from matplotlib.font_manager import ( findfont, findSystemFonts, FontEntry, FontPath, FontProperties, fontManager, json_dump, json_load, get_font, is_opentype_cff_font, - MSUserFontDirectories, ttfFontProperty, + MSUserFontDirectories, ttfFontProperty, _get_font_alt_names, _get_fontconfig_fonts, _normalize_weight) from matplotlib import cbook, ft2font, pyplot as plt, rc_context, figure as mfigure from matplotlib.testing import subprocess_run_helper, subprocess_run_for_testing @@ -400,23 +403,145 @@ def test_get_font_names(): paths_mpl = [cbook._get_data_path('fonts', subdir) for subdir in ['ttf']] fonts_mpl = findSystemFonts(paths_mpl, fontext='ttf') fonts_system = findSystemFonts(fontext='ttf') - ttf_fonts = [] + ttf_fonts = set() for path in fonts_mpl + fonts_system: try: font = ft2font.FT2Font(path) prop = ttfFontProperty(font) - ttf_fonts.append(prop.name) + ttf_fonts.add(prop.name) for face_index in range(1, font.num_faces): font = ft2font.FT2Font(path, face_index=face_index) prop = ttfFontProperty(font) - ttf_fonts.append(prop.name) + ttf_fonts.add(prop.name) except Exception: pass - available_fonts = sorted(list(set(ttf_fonts))) - mpl_font_names = sorted(fontManager.get_font_names()) - assert set(available_fonts) == set(mpl_font_names) - assert len(available_fonts) == len(mpl_font_names) - assert available_fonts == mpl_font_names + # fontManager may contain additional entries for alternative family names + # (e.g. typographic family, platform-specific Name ID 1) registered by + # addfont(), so primary names must be a subset of the manager's names. + assert ttf_fonts <= set(fontManager.get_font_names()) + + +def test_addfont_alternative_names(tmp_path): + """ + Fonts that advertise different family names across platforms or name IDs + should be registered under all of those names so users can address the font + by any of them. + + Two real-world patterns are covered: + + - **MS platform ID 1 differs from Mac platform ID 1** (e.g. Ubuntu Light): + FreeType returns the Mac ID 1 value as ``family_name``; the MS ID 1 + value ("Ubuntu Light") is an equally valid name that users expect to work. + - **Name ID 16 (Typographic Family) differs from ID 1** (older fonts): + some fonts store a broader family name in ID 16. + """ + mac_key = (1, 0, 0) + ms_key = (3, 1, 0x0409) + + # Case 1: MS ID1 differs from Mac ID1 (Ubuntu Light pattern) + # Mac ID1="Test Family" → FreeType family_name (primary) + # MS ID1="Test Family Light" → alternate name users expect to work + ubuntu_style_sfnt = { + (*mac_key, 1): "Test Family".encode("latin-1"), + (*ms_key, 1): "Test Family Light".encode("utf-16-be"), + (*mac_key, 2): "Light".encode("latin-1"), + (*ms_key, 2): "Regular".encode("utf-16-be"), + } + fake_font = MagicMock() + fake_font.get_sfnt.return_value = ubuntu_style_sfnt + + assert _get_font_alt_names(fake_font, "Test Family") == [("Test Family Light", 400)] + assert _get_font_alt_names(fake_font, "Test Family Light") == [ + ("Test Family", 300)] + + # Case 2: ID 16 differs from ID 1 (older typographic-family pattern) + # ID 17 (typographic subfamily) is absent → defaults to weight 400 + id16_sfnt = { + (*mac_key, 1): "Test Family".encode("latin-1"), + (*ms_key, 1): "Test Family".encode("utf-16-be"), + (*ms_key, 16): "Test Family Light".encode("utf-16-be"), + } + fake_font_id16 = MagicMock() + fake_font_id16.get_sfnt.return_value = id16_sfnt + + assert _get_font_alt_names( + fake_font_id16, "Test Family" + ) == [("Test Family Light", 400)] + + # Case 3: all entries agree → no alternates + same_sfnt = { + (*mac_key, 1): "Test Family".encode("latin-1"), + (*ms_key, 1): "Test Family".encode("utf-16-be"), + } + fake_font_same = MagicMock() + fake_font_same.get_sfnt.return_value = same_sfnt + assert _get_font_alt_names(fake_font_same, "Test Family") == [] + + # Case 4: get_sfnt() raises ValueError (e.g. non-SFNT font) → empty list + fake_font_no_sfnt = MagicMock() + fake_font_no_sfnt.get_sfnt.side_effect = ValueError + assert _get_font_alt_names(fake_font_no_sfnt, "Test Family") == [] + + fake_path = str(tmp_path / "fake.ttf") + primary_entry = FontEntry(fname=fake_path, name="Test Family", + style="normal", variant="normal", + weight=300, stretch="normal", size="scalable") + + with patch("matplotlib.font_manager.ft2font.FT2Font", + return_value=fake_font), \ + patch("matplotlib.font_manager.ttfFontProperty", + return_value=primary_entry): + fm_instance = fm_mod.FontManager.__new__(fm_mod.FontManager) + fm_instance.ttflist = [] + fm_instance.afmlist = [] + fm_instance._findfont_cached = MagicMock() + fm_instance._findfont_cached.cache_clear = MagicMock() + fm_instance.addfont(fake_path) + + names = [e.name for e in fm_instance.ttflist] + assert names == ["Test Family", "Test Family Light"] + alt_entry = fm_instance.ttflist[1] + assert alt_entry.weight == 400 + assert alt_entry.style == primary_entry.style + assert alt_entry.fname == primary_entry.fname + + +@pytest.mark.parametrize("subfam,expected", [ + ("Thin", 100), + ("ExtraLight", 200), + ("UltraLight", 200), + ("DemiLight", 350), + ("SemiLight", 350), + ("Light", 300), + ("Book", 380), + ("Regular", 400), + ("Normal", 400), + ("Medium", 500), + ("DemiBold", 600), + ("Demi", 600), + ("SemiBold", 600), + ("ExtraBold", 800), + ("SuperBold", 800), + ("UltraBold", 800), + ("Bold", 700), + ("UltraBlack", 1000), + ("SuperBlack", 1000), + ("ExtraBlack", 1000), + ("Ultra", 1000), + ("Black", 900), + ("Heavy", 900), + ("", 400), # fallback: unrecognised → regular +]) +def test_alt_name_weight_from_subfamily(subfam, expected): + """_get_font_alt_names derives weight from the paired subfamily string.""" + ms_key = (3, 1, 0x0409) + fake_font = MagicMock() + fake_font.get_sfnt.return_value = { + (*ms_key, 1): "Family Alt".encode("utf-16-be"), + (*ms_key, 2): subfam.encode("utf-16-be"), + } + result = _get_font_alt_names(fake_font, "Family") + assert result == [("Family Alt", expected)] def test_donot_cache_tracebacks(): From af4ded1aad6705ffad203e779432d3f26541779c Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 21 Feb 2026 10:58:52 +0100 Subject: [PATCH 091/108] DOC: Add what's new entry for font alternative family name registration --- .../next_whats_new/font_alt_family_names.rst | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 doc/release/next_whats_new/font_alt_family_names.rst diff --git a/doc/release/next_whats_new/font_alt_family_names.rst b/doc/release/next_whats_new/font_alt_family_names.rst new file mode 100644 index 000000000000..11b67bf6d584 --- /dev/null +++ b/doc/release/next_whats_new/font_alt_family_names.rst @@ -0,0 +1,25 @@ +Fonts addressable by all their SFNT family names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Fonts can now be selected by any of the family names they advertise in +the OpenType name table, not just the one FreeType reports as the primary +family name. + +Some fonts store different family names on different platforms or in +different name-table entries. For example, Ubuntu Light stores +``"Ubuntu"`` in the Macintosh-platform Name ID 1 slot (which FreeType +uses as the primary name) and ``"Ubuntu Light"`` in the Microsoft-platform +Name ID 1 slot. Previously only the FreeType-derived name was registered, +requiring an obscure weight-based workaround:: + + # Previously required + matplotlib.rcParams['font.family'] = 'Ubuntu' + matplotlib.rcParams['font.weight'] = 300 + +All name-table entries that describe a family — Name ID 1 on both +platforms, the Typographic Family (Name ID 16), and the WWS Family +(Name ID 21) — are now registered as separate entries in the +`~matplotlib.font_manager.FontManager`, so any of those names can be +used directly:: + + matplotlib.rcParams['font.family'] = 'Ubuntu Light' From b6cde63c6bd1569fed743b4f54914a6d8f6f191a Mon Sep 17 00:00:00 2001 From: Milan Gittler <55838375+Cemonix@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:29:44 +0100 Subject: [PATCH 092/108] Fixed the encoding string to match the rest of the file. Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/font_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 0a58e8a18242..ac60c417c75f 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -601,8 +601,8 @@ def _try_add(name, subfam): sfnt.get((*mac_key, subfam_id), b'').decode('latin-1'), ) _try_add( - sfnt.get((*ms_key, fam_id), b'').decode('utf_16_be'), - sfnt.get((*ms_key, subfam_id), b'').decode('utf_16_be'), + sfnt.get((*ms_key, fam_id), b'').decode('utf-16-be'), + sfnt.get((*ms_key, subfam_id), b'').decode('utf-16-be'), ) return result From 3ab6a275b2724d798b597571512b5cc9a8f7690a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 10 Mar 2026 21:42:58 -0400 Subject: [PATCH 093/108] mathtext: Fetch x-height from font metrics This is minimally different from the `x` measurement, but technically more correct. We still do the measurement for fonts we don't ship, but that may change with Unicode Math fonts in the future. --- lib/matplotlib/_mathtext.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 3a60a31eb58d..a53595261fcf 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -445,11 +445,15 @@ def get_quad(self, fontname: str, fontsize: float, dpi: float) -> float: return metrics.advance def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float: - # Some fonts report the wrong x-height, while some don't store it, so - # we do a poor man's x-height. - metrics = self.get_metrics( - fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi) - return metrics.iceberg + consts = self.get_font_constants() + if consts.x_height is not None: + return consts.x_height * fontsize * dpi / 72 + else: + # Some fonts report the wrong x-height, while some don't store it, so + # we do a poor man's x-height. + metrics = self.get_metrics( + fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi) + return metrics.iceberg def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float: # This function used to grab underline thickness from the font @@ -1006,6 +1010,10 @@ class FontConstantsBase: # The size of a quad space in LaTeX, as a multiple of design size. quad: T.ClassVar[float | None] = None + # The size of x-height in font design units (i.e., divided by units-per-em). If not + # provided, then this will be measured from the font itself. + x_height: T.ClassVar[float | None] = None + class ComputerModernFontConstants(FontConstantsBase): # Previously, the x-height of Computer Modern was obtained from the font @@ -1034,6 +1042,7 @@ class ComputerModernFontConstants(FontConstantsBase): # size. axis_height = 262144 / 2**20 quad = 1048579 / 2**20 + x_height = _x_height / 2**20 class STIXFontConstants(FontConstantsBase): @@ -1041,10 +1050,11 @@ class STIXFontConstants(FontConstantsBase): delta = 0.05 delta_slanted = 0.3 delta_integral = 0.3 + _x_height = 450 + x_height = _x_height / 1000 # These values are extracted from the TeX table of STIXGeneral.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. - _x_height = 450 supdrop = 386 / _x_height subdrop = 50.0002 / _x_height sup1 = 413 / _x_height @@ -1068,10 +1078,11 @@ class STIXSansFontConstants(STIXFontConstants): class DejaVuSerifFontConstants(FontConstantsBase): + _x_height = 1063 + x_height = _x_height / 2048 # These values are extracted from the TeX table of DejaVuSerif.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. - _x_height = 1063 supdrop = 790.527 / _x_height subdrop = 102.4 / _x_height sup1 = 845.824 / _x_height @@ -1088,10 +1099,11 @@ class DejaVuSerifFontConstants(FontConstantsBase): class DejaVuSansFontConstants(FontConstantsBase): + _x_height = 1120 + x_height = _x_height / 2048 # These values are extracted from the TeX table of DejaVuSans.ttf using FontForge, # and then divided by design xheight, since we multiply these values by the scaled # xheight later. - _x_height = 1120 supdrop = 790.527 / _x_height subdrop = 102.4 / _x_height sup1 = 845.824 / _x_height From d961462910d4bc45535708affb7b2e6778f814d2 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 12 Mar 2026 14:01:08 -0400 Subject: [PATCH 094/108] text: Set line spacing to 'normal' by default This follows from CSS' default for line height. At the moment, the behaviour has not been changed, and still just falls back to 1.2 for 'normal'. --- lib/matplotlib/text.py | 25 +++++++++++++++++-------- lib/matplotlib/text.pyi | 5 +++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index f0dd963fe477..a9da4cd59108 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -241,7 +241,7 @@ def _reset_visual_defaults( self._bbox_patch = None # a FancyBboxPatch instance self._renderer = None if linespacing is None: - linespacing = 1.2 # Maybe use rcParam later. + linespacing = 'normal' # Maybe use rcParam later. self.set_linespacing(linespacing) self.set_rotation_mode(rotation_mode) self.set_antialiased(mpl._val_or_rc(antialiased, 'text.antialiased')) @@ -439,7 +439,8 @@ def _get_layout(self, renderer): ismath="TeX" if self.get_usetex() else False, dpi=self.get_figure(root=True).dpi) lp_a = lp_h - lp_d - min_dy = lp_a * self._linespacing + linespacing = 1.2 if self._linespacing == 'normal' else self._linespacing + min_dy = lp_a * linespacing for i, line in enumerate(lines): clean_line, ismath = self._preprocess_math(line) @@ -462,7 +463,7 @@ def _get_layout(self, renderer): if i == 0: # position at baseline thisy = -a else: # put baseline a good distance from bottom of previous line - thisy -= max(min_dy, a * self._linespacing) + thisy -= max(min_dy, a * linespacing) wads.append((w, a, d)) xs.append(thisx) # == 0. @@ -1122,18 +1123,26 @@ def set_multialignment(self, align): def set_linespacing(self, spacing): """ - Set the line spacing as a multiple of the font size. - - The default line spacing is 1.2. + Set the line spacing. Parameters ---------- - spacing : float (multiple of font size) + spacing : 'normal' or float, default: 'normal' + If 'normal', then the line spacing is automatically determined by font + metrics for each line individually. + + If a float, then line spacing will be fixed to this multiple of the font + size for every line. """ - _api.check_isinstance(Real, spacing=spacing) + if not cbook._str_equal(spacing, 'normal'): + _api.check_isinstance(Real, spacing=spacing) self._linespacing = spacing self.stale = True + def get_linespacing(self): + """Get the line spacing.""" + return self._linespacing + def set_fontfamily(self, fontname): """ Set the font family. Can be either a single string, or a list of diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index e89a03396d7e..15811462224a 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -34,7 +34,7 @@ class Text(Artist): multialignment: Literal["left", "center", "right"] | None = ..., fontproperties: str | Path | FontProperties | None = ..., rotation: float | Literal["vertical", "horizontal"] | None = ..., - linespacing: float | None = ..., + linespacing: Literal["normal"] | float | None = ..., rotation_mode: Literal["default", "anchor"] | None = ..., usetex: bool | None = ..., wrap: bool = ..., @@ -79,7 +79,8 @@ class Text(Artist): self, align: Literal["left", "center", "right"] ) -> None: ... def set_multialignment(self, align: Literal["left", "center", "right"]) -> None: ... - def set_linespacing(self, spacing: float) -> None: ... + def set_linespacing(self, spacing: Literal["normal"] | float) -> None: ... + def get_linespacing(self) -> Literal["normal"] | float: ... def set_fontfamily(self, fontname: str | Iterable[str]) -> None: ... def set_fontfeatures(self, features: Sequence[str] | None) -> None: ... def set_fontvariant(self, variant: Literal["normal", "small-caps"]) -> None: ... From 97f4943cf279c82a4dc64e40ec52dcb1bcd218fe Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 12 Mar 2026 19:57:29 -0400 Subject: [PATCH 095/108] text: Fetch line height metrics from the used font itself We follow the process from [the CSS Inline Layout module](https://www.w3.org/TR/css-inline-3/), specifically: 1. The default ascent and descent come from the `OS/2` font table, or failing that, the `hhea` table, with final fallback to the measurement we used to do. 2. If `linespacing` (cf line height in CSS) is normal, then we do as before and size each line based on the maximum ascent/descent of its contents. Additionally, apply the line gap from the font metrics as half-leading around each line. 3. If `linespacing` is a float, then scale it by font size of the first available font, and keep it fixed for each line. However, if we are drawing a single line, then we do not add the line gap around the line, to keep them a similar height as before. --- lib/matplotlib/testing/conftest.py | 11 +++++ lib/matplotlib/tests/test_axes.py | 8 ++-- lib/matplotlib/tests/test_legend.py | 32 +++++++------- lib/matplotlib/tests/test_polar.py | 2 +- lib/matplotlib/tests/test_text.py | 16 +++++-- lib/matplotlib/text.py | 68 +++++++++++++++++++++-------- 6 files changed, 93 insertions(+), 44 deletions(-) diff --git a/lib/matplotlib/testing/conftest.py b/lib/matplotlib/testing/conftest.py index 6f87d9826cc3..c60a38254aad 100644 --- a/lib/matplotlib/testing/conftest.py +++ b/lib/matplotlib/testing/conftest.py @@ -149,6 +149,15 @@ def text_placeholders(monkeypatch): """ from matplotlib.patches import Rectangle + def patched_get_sfnt_table(font, name): + """ + Replace ``FT2Font.get_sfnt_table`` with empty results. + + This forces ``Text._get_layout`` to fall back to + ``get_text_width_height_descent``, which produces results from the patch below. + """ + return None + def patched_get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): """ Replace ``_get_text_metrics_with_cache`` with fixed results. @@ -183,6 +192,8 @@ def patched_text_draw(self, renderer): facecolor=self.get_color(), edgecolor='none') rect.draw(renderer) + monkeypatch.setattr('matplotlib.ft2font.FT2Font.get_sfnt_table', + patched_get_sfnt_table) monkeypatch.setattr('matplotlib.text._get_text_metrics_with_cache', patched_get_text_metrics_with_cache) monkeypatch.setattr('matplotlib.text.Text.draw', patched_text_draw) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index deb74d4e7341..b18857d2f103 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -7753,7 +7753,7 @@ def test_titletwiny(): bbox_y0_title = title.get_window_extent(renderer).y0 # bottom of title bbox_y1_xlabel2 = xlabel2.get_window_extent(renderer).y1 # top of xlabel2 y_diff = bbox_y0_title - bbox_y1_xlabel2 - assert np.isclose(y_diff, 3) + assert y_diff >= 3 def test_titlesetpos(): @@ -8525,8 +8525,8 @@ def test_normal_axes(): # test the axis bboxes target = [ - [124.0, 76.89, 982.0, 32.0], - [86.89, 100.5, 52.0, 992.0], + [124.0, 75.56, 982.0, 33.33], + [86.89, 99.33, 52.0, 993.33], ] for nn, b in enumerate(bbaxis): targetbb = mtransforms.Bbox.from_bounds(*target[nn]) @@ -8546,7 +8546,7 @@ def test_normal_axes(): targetbb = mtransforms.Bbox.from_bounds(*target) assert_array_almost_equal(bbax.bounds, targetbb.bounds, decimal=2) - target = [86.89, 76.89, 1019.11, 1015.61] + target = [86.89, 75.56, 1019.11, 1017.11] targetbb = mtransforms.Bbox.from_bounds(*target) assert_array_almost_equal(bbtb.bounds, targetbb.bounds, decimal=2) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index ae14ef6cb423..5112aba843db 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -481,27 +481,27 @@ def test_figure_legend_outside(): todos += ['left ' + pos for pos in ['lower', 'center', 'upper']] todos += ['right ' + pos for pos in ['lower', 'center', 'upper']] - upperext = [20.722556, 26.722556, 790.333, 545.999] - lowerext = [20.722556, 70.056556, 790.333, 589.333] - leftext = [152.056556, 26.722556, 790.333, 589.333] - rightext = [20.722556, 26.722556, 658.999, 589.333] + upperext = [20.722556, 26.389222, 790.333, 545.16762] + lowerext = [20.722556, 70.723222, 790.333, 589.50162] + leftext = [152.056556, 26.389222, 790.333, 589.50162] + rightext = [20.722556, 26.389222, 658.999, 589.50162] axbb = [upperext, upperext, upperext, lowerext, lowerext, lowerext, leftext, leftext, leftext, rightext, rightext, rightext] - legbb = [[10., 555., 133., 590.], # upper left - [338.5, 555., 461.5, 590.], # upper center - [667, 555., 790., 590.], # upper right - [10., 10., 133., 45.], # lower left - [338.5, 10., 461.5, 45.], # lower center - [667., 10., 790., 45.], # lower right - [10., 10., 133., 45.], # left lower - [10., 282.5, 133., 317.5], # left center - [10., 555., 133., 590.], # left upper - [667, 10., 790., 45.], # right lower - [667., 282.5, 790., 317.5], # right center - [667., 555., 790., 590.]] # right upper + legbb = [[10., 554., 133., 590.], # upper left + [338.5, 554., 461.5, 590.], # upper center + [667, 554., 790., 590.], # upper right + [10., 10., 133., 46.], # lower left + [338.5, 10., 461.5, 46.], # lower center + [667., 10., 790., 46.], # lower right + [10., 10., 133., 46.], # left lower + [10., 282., 133., 318.], # left center + [10., 554., 133., 590.], # left upper + [667, 10., 790., 46.], # right lower + [667., 282., 790., 318.], # right center + [667., 554., 790., 590.]] # right upper for nn, todo in enumerate(todos): print(todo) diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 6b3e1b99bc8a..63d5c45308f1 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -332,7 +332,7 @@ def test_get_tightbbox_polar(): fig.canvas.draw() bb = ax.get_tightbbox(fig.canvas.get_renderer()) assert_allclose( - bb.extents, [108.27778, 28.7778, 539.7222, 451.2222], rtol=1e-03) + bb.extents, [108.27778, 29.1111, 539.7222, 450.8889], rtol=1e-03) @check_figures_equal() diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index dec91ce31979..0eed2f5aeb87 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -14,7 +14,7 @@ from matplotlib.backend_bases import MouseEvent from matplotlib.backends.backend_agg import RendererAgg from matplotlib.figure import Figure -from matplotlib.font_manager import FontProperties +from matplotlib.font_manager import FontProperties, fontManager, get_font import matplotlib.patches as mpatches import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec @@ -1061,8 +1061,16 @@ def test_text_annotation_get_window_extent(): _, _, d = renderer.get_text_width_height_descent( 'text', annotation._fontproperties, ismath=False) - _, _, lp_d = renderer.get_text_width_height_descent( - 'lp', annotation._fontproperties, ismath=False) + font = get_font(fontManager._find_fonts_by_props(annotation._fontproperties)) + for name, key in [('OS/2', 'sTypoDescender'), ('hhea', 'descent')]: + if (table := font.get_sfnt_table(name)) is not None: + units_per_em = font.get_sfnt_table('head')['unitsPerEm'] + fontsize = annotation._fontproperties.get_size_in_points() + lp_d = -table[key] / units_per_em * fontsize * figure.dpi / 72 + break + else: + _, _, lp_d = renderer.get_text_width_height_descent( + 'lp', annotation._fontproperties, ismath=False) below_line = max(d, lp_d) # These numbers are specific to the current implementation of Text @@ -1101,7 +1109,7 @@ def test_text_with_arrow_annotation_get_window_extent(): assert bbox.width == text_bbox.width + 50.0 # make sure the annotation text bounding box is same size # as the bounding box of the same string as a Text object - assert ann_txt_bbox.height == text_bbox.height + assert_almost_equal(ann_txt_bbox.height, text_bbox.height) assert ann_txt_bbox.width == text_bbox.width # compute the expected bounding box of arrow + text expected_bbox = mtransforms.Bbox.union([ann_txt_bbox, arrow_bbox]) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index a9da4cd59108..f6ea6673ff0f 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -15,7 +15,7 @@ import matplotlib as mpl from . import _api, artist, cbook, _docstring, colors as mcolors from .artist import Artist -from .font_manager import FontProperties +from .font_manager import FontProperties, fontManager, get_font from .patches import FancyArrowPatch, FancyBboxPatch, Rectangle from .textpath import TextPath, TextToPath # noqa # Logically located here from .transforms import ( @@ -433,16 +433,40 @@ def _get_layout(self, renderer): xs = [] ys = [] - # Full vertical extent of font, including ascenders and descenders: - _, lp_h, lp_d = _get_text_metrics_with_cache( - renderer, "lp", self._fontproperties, - ismath="TeX" if self.get_usetex() else False, - dpi=self.get_figure(root=True).dpi) - lp_a = lp_h - lp_d - linespacing = 1.2 if self._linespacing == 'normal' else self._linespacing - min_dy = lp_a * linespacing - - for i, line in enumerate(lines): + min_ascent = min_descent = line_gap = None + dpi = self.get_figure(root=True).dpi + # Determine full vertical extent of font, including ascenders and descenders: + if not self.get_usetex(): + font = get_font(fontManager._find_fonts_by_props(self._fontproperties)) + possible_metrics = [ + ('OS/2', 'sTypoLineGap', 'sTypoAscender', 'sTypoDescender'), + ('hhea', 'lineGap', 'ascent', 'descent') + ] + for table_name, linegap_key, ascent_key, descent_key in possible_metrics: + table = font.get_sfnt_table(table_name) + if table is None: + continue + # Rescale to font size/DPI if the metrics were available. + fontsize = self._fontproperties.get_size_in_points() + units_per_em = font.get_sfnt_table('head')['unitsPerEm'] + line_gap = table[linegap_key] / units_per_em * fontsize * dpi / 72 + min_ascent = table[ascent_key] / units_per_em * fontsize * dpi / 72 + min_descent = -table[descent_key] / units_per_em * fontsize * dpi / 72 + break + if None in (min_ascent, min_descent): + # Fallback to font measurement. + _, h, min_descent = _get_text_metrics_with_cache( + renderer, "lp", self._fontproperties, + ismath="TeX" if self.get_usetex() else False, + dpi=dpi) + min_ascent = h - min_descent + line_gap = 0 + + # Don't increase text height too much if it's not multiple lines. + if len(lines) == 1: + line_gap = 0 + + for line in lines: clean_line, ismath = self._preprocess_math(line) if clean_line: w, h, d = _get_text_metrics_with_cache( @@ -452,18 +476,24 @@ def _get_layout(self, renderer): w = h = d = 0 a = h - d - # To ensure good linespacing, pretend that the ascent (resp. - # descent) of all lines is at least as large as "l" (resp. "p"). - a = max(a, lp_a) - d = max(d, lp_d) + + if self.get_usetex() or self._linespacing == 'normal': + # To ensure good linespacing, pretend that the ascent / descent of all + # lines is at least as large as the measured sizes. + a = max(a, min_ascent) + line_gap / 2 + d = max(d, min_descent) + line_gap / 2 + else: + # If using a fixed line spacing, then every line's spacing will be + # determined by the font metrics of the first available font. + line_height = self._linespacing * (min_ascent + min_descent) + leading = line_height - (a + d) + a += leading / 2 + d += leading / 2 # Metrics of the last line that are needed later: baseline = a - thisy - if i == 0: # position at baseline - thisy = -a - else: # put baseline a good distance from bottom of previous line - thisy -= max(min_dy, a * linespacing) + thisy -= a wads.append((w, a, d)) xs.append(thisx) # == 0. From 716796e7b8566813ca4e58f3f9b12198344b6421 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 20 Mar 2026 23:38:10 +0100 Subject: [PATCH 096/108] Bump FontManager cache version for alt family name entries --- lib/matplotlib/font_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index ac60c417c75f..b07dd1345f54 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1207,7 +1207,7 @@ class FontManager: # Increment this version number whenever the font cache data # format or behavior has changed and requires an existing font # cache files to be rebuilt. - __version__ = '3.11.0a3' + __version__ = '3.11.0a4' def __init__(self, size=None, weight='normal'): self._version = self.__version__ From e0913d466ed4e8f5fb9b03195ecd8b0dab5f43ce Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 24 Mar 2026 18:41:07 -0400 Subject: [PATCH 097/108] ps/pdf: Override font height metrics to support AFM files When outputting files using the "core 14 fonts" (e.g., `rcParams['pdf.use14corefonts'] = True`), text will be measured using our internal AFM files instead of any external files. While we don't have a full API decided for how to pass full `Text` objects through backends, add in a small internal helper to allow the PS/PDF backends to override font height metrics with the AFM files. --- lib/matplotlib/_afm.py | 8 ++++ lib/matplotlib/backends/_backend_pdf_ps.py | 26 ++++++++++++ .../test_backend_pdf/pdf_use14corefonts.pdf | Bin 3589 -> 3257 bytes lib/matplotlib/text.py | 39 +++++++++++------- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py index 3d7f7a44baca..af607b0374fc 100644 --- a/lib/matplotlib/_afm.py +++ b/lib/matplotlib/_afm.py @@ -478,10 +478,18 @@ def get_angle(self) -> float: """Return the fontangle as float.""" return self._header['ItalicAngle'] + def get_ascender(self) -> float: + """Return the ascent as float.""" + return self._header['Ascender'] + def get_capheight(self) -> float: """Return the cap height as float.""" return self._header['CapHeight'] + def get_descender(self) -> float: + """Return the descent as float.""" + return self._header['Descender'] + def get_xheight(self) -> float: """Return the xheight as float.""" return self._header['XHeight'] diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 83a8566517a7..a06779b8efee 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -348,6 +348,32 @@ def get_canvas_width_height(self): # docstring inherited return self.width * 72.0, self.height * 72.0 + def _get_font_height_metrics(self, prop): + """ + Return the ascent, descent, and line gap for font described by *prop*. + + TODO: This is a temporary method until we design a proper API for the backends. + + Parameters + ---------- + prop : `.font_manager.FontProperties` + The properties describing the font to measure. + + Returns + ------- + ascent, descent, line_gap : float or None + The ascent, descent and line gap of the determined font, or None to fall + back to normal measurements. + """ + if not mpl.rcParams[self._use_afm_rc_name]: + return None, None, None + font = self._get_font_afm(prop) + scale = prop.get_size_in_points() / 1000 + a = font.get_ascender() * scale + d = -font.get_descender() * scale + g = (a + d) * 0.2 # Preserve previous line spacing of 1.2. + return a, d, g + def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited if ismath == "TeX": diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/pdf_use14corefonts.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/pdf_use14corefonts.pdf index 5cdc2e34e25d21c805df535b9e1795de50917e4b..b7dbb9adec70559ea8d3cc0a214e443db6e9e146 100644 GIT binary patch delta 999 zcmZuwOKTHR6s9&wlZjmvONtQiv``VXx%13qGz5~gsam9(2o*|^$>cUpXlEpITWe8T zg@Q}<;KG%<5cevG;BRs5%75VF%%rg~b>MQ(YtHNY?m_m;>1Wx!TMzDWU{zb#8-5$u z4ZF<)P(g+|lu8o!T7I)56-W>7Vi8!)_XKEB0K2{1>~Keb*;5yG#9~9(f`i^O+^~h< zT@Q>N^G8QyAH1}R-oD#)SN(4phKrk z|90ry$FC!AQeh?q>WhGzCq7TeaER(Q4rj)~dkUY7W$<+*XYkk96lRkZ{Fa=; zhsg}SN|s2TkDEEmwD4SNC9dW)%fy4!T0)g`ilyQA)XTka+Hi@gY+8K;qh*A~Rod7g zs!E9BzuS($WsR+-h-eW)pa84b0q=RP@QrOQT*sE!g6H@Vnry}O%3k314cP(W3|6Qq zplBFN&tM}xFN17J&TDFjc76yt`~G%AD`XT^T$&e^6^v!W1wBpwcQQgjb2^D-Q_eBu)6PuV~6jPUG>&| ZPDueGd$=m5$z# zL#481*{7S(XohT_HP}2ly(x9B zE?y&;?-?FbMKJLKE$&lmS5jdcmxJgwt`}iy1nl6r=fh=@Cd#-7s8B9R!9wp>PzoA2 zErjmla_|5RWg})PC5-f$QZS#G?#erf<*xiCai=Sv#PE^6$u9n=FVcV07wP|u;pt?A zXOatD+r1e6DLK={eBrM?m zqFQh)Js&T!HZ}$xuDG_~a>($qpmfBw7P<<43|u`dBFnhTyhYJi Date: Fri, 20 Mar 2026 19:12:46 -0400 Subject: [PATCH 098/108] TST: Restore some tolerances for some arch/platform-specific failures The tolerances on these were increased in various PRs, with notes to reduce/remove them, so they were removed in #30184, but should have been reverted to a smaller value: - `test_floating_axes.py::test_curvelinear4[png]` fails on macOS - `test_grid_helper_curvelinear.py::test_axis_direction[png]` fails on ARM and macOS - `test_grid_helper_curvelinear.py::test_polar_box[png]` fails on Windows, ARM, and macOS These fail on macOS; there's tolerances on them already, they fail by just a small amount, so increase them: - `test_bbox_tight.py::test_bbox_inches_tight_suptile_legend[png]` - `test_patheffects.py::test_collection[png]` - `test_axes3d.py::test_scale3d_artists_log[png]` This fails only on AppVeyor (Windows), but not Azure. There is a tolerance there already, so just add it to the list: - `test_mathtext.py::test_mathtext_rendering` --- lib/matplotlib/tests/test_bbox_tight.py | 2 +- lib/matplotlib/tests/test_mathtext.py | 7 +++++-- lib/matplotlib/tests/test_patheffects.py | 2 +- lib/mpl_toolkits/axisartist/tests/test_floating_axes.py | 2 +- .../axisartist/tests/test_grid_helper_curvelinear.py | 4 ++-- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 2 +- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 99b56bee91ba..677cdf37dd24 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -47,7 +47,7 @@ def test_bbox_inches_tight(text_placeholders): @image_comparison(['bbox_inches_tight_suptile_legend'], savefig_kwarg={'bbox_inches': 'tight'}, - tol=0 if platform.machine() == 'x86_64' else 0.022) + tol=0 if platform.machine() == 'x86_64' else 0.024) def test_bbox_inches_tight_suptile_legend(): plt.plot(np.arange(10), label='a straight line') plt.legend(bbox_to_anchor=(0.9, 1), loc='upper left') diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index c85cbc5e21a8..33fb8918d22a 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -224,8 +224,11 @@ def baseline_images(request, fontset, index, text): @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathtext'], indirect=True) -@image_comparison(baseline_images=None, style='mpl20', - tol=0.011 if platform.machine() in ('ppc64le', 's390x') else 0) +@image_comparison( + baseline_images=None, style='mpl20', + tol=(0.013 + if platform.machine() in ('ppc64le', 's390x') or platform.system() == 'Windows' + else 0)) def test_mathtext_rendering(baseline_images, fontset, index, text): mpl.rcParams['mathtext.fontset'] = fontset fig = plt.figure(figsize=(5.25, 0.75)) diff --git a/lib/matplotlib/tests/test_patheffects.py b/lib/matplotlib/tests/test_patheffects.py index d957ef2a5510..0b99a954afb3 100644 --- a/lib/matplotlib/tests/test_patheffects.py +++ b/lib/matplotlib/tests/test_patheffects.py @@ -120,7 +120,7 @@ def test_SimplePatchShadow_offset(): assert pe._offset == (4, 5) -@image_comparison(['collection'], tol=0.03, style='mpl20') +@image_comparison(['collection'], tol=0.032, style='mpl20') def test_collection(): x, y = np.meshgrid(np.linspace(0, 10, 150), np.linspace(-5, 5, 100)) data = np.sin(x) + np.cos(y) diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index 6672bd0ac3a0..5575a48d499a 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -63,7 +63,7 @@ def test_curvelinear3(): l.set_clip_path(ax1.patch) -@image_comparison(['curvelinear4.png'], style='mpl20') +@image_comparison(['curvelinear4.png'], style='mpl20', tol=0.04) def test_curvelinear4(): fig = plt.figure(figsize=(5, 5)) 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 f49d02766421..62feaee4279a 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -78,7 +78,7 @@ def inverted(self): ax1.grid(True) -@image_comparison(['polar_box.png'], style='mpl20') +@image_comparison(['polar_box.png'], style='mpl20', tol=0.04) def test_polar_box(): plt.rcParams.update({"xtick.direction": "inout", "ytick.direction": "out"}) fig = plt.figure(figsize=(5, 5)) @@ -138,7 +138,7 @@ def test_polar_box(): ax1.grid(True) -@image_comparison(['axis_direction.png'], style='mpl20') +@image_comparison(['axis_direction.png'], style='mpl20', tol=0.04) def test_axis_direction(): fig = plt.figure(figsize=(5, 5)) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 8d2441393dde..1cff9fdbe76e 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2878,7 +2878,7 @@ def _make_triangulation_data(): @mpl3d_image_comparison(['scale3d_artists_log.png'], style='mpl20', - remove_text=False, tol=0.03) + remove_text=False, tol=0.032) def test_scale3d_artists_log(): """Test all 3D artist types with log scale.""" fig = plt.figure(figsize=(16, 12)) From 5e1c1ca5a5d6e84d2388e54c622b7f2b835b0ab8 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 26 Mar 2026 16:30:28 -0400 Subject: [PATCH 099/108] BLD: Temporarily pin setuptools-scm<10 This is currently causing warnings at runtime in the editable install, which breaks almost all tests. --- .github/workflows/cygwin.yml | 2 +- .github/workflows/tests.yml | 2 +- pyproject.toml | 4 ++-- requirements/dev/build-requirements.txt | 2 +- requirements/testing/mypy.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cygwin.yml b/.github/workflows/cygwin.yml index 8a01d76c00f5..39fcedcd3862 100644 --- a/.github/workflows/cygwin.yml +++ b/.github/workflows/cygwin.yml @@ -182,7 +182,7 @@ jobs: export PATH="/usr/local/bin:$PATH" python -m pip install --no-build-isolation 'contourpy>=1.0.1' python -m pip install --upgrade cycler fonttools \ - packaging pyparsing python-dateutil setuptools-scm \ + packaging pyparsing python-dateutil 'setuptools-scm<10' \ -r requirements_test.txt sphinx ipython python -m pip install --upgrade pycairo 'cairocffi>=0.8' PyGObject && python -c 'import gi; gi.require_version("Gtk", "3.0"); from gi.repository import Gtk' && diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6a794bfe3f6b..86c90c77b21a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -256,7 +256,7 @@ jobs: # Preinstall build requirements to enable no-build-isolation builds. python -m pip install --upgrade $PRE \ 'contourpy>=1.0.1' cycler fonttools kiwisolver importlib_resources \ - packaging pillow 'pyparsing!=3.1.0' python-dateutil setuptools-scm \ + packaging pillow 'pyparsing!=3.1.0' python-dateutil 'setuptools-scm<10' \ 'meson-python>=0.13.1' 'pybind11>=2.13.2' \ -r requirements/testing/all.txt \ ${{ matrix.extra-requirements }} diff --git a/pyproject.toml b/pyproject.toml index 7fd25147eb05..2ede26597a31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ requires-python = ">=3.11" dev = [ "meson-python>=0.13.2,!=0.17.*", "pybind11>=2.13.2,!=2.13.3", - "setuptools_scm>=7", + "setuptools_scm>=7,<10", # Not required by us but setuptools_scm without a version, cso _if_ # installed, then setuptools_scm 8 requires at least this version. # Unfortunately, we can't do a sort of minimum-if-installed dependency, so @@ -75,7 +75,7 @@ requires = [ # you really need it and aren't using an sdist. "meson-python>=0.13.2,!=0.17.*", "pybind11>=2.13.2,!=2.13.3", - "setuptools_scm>=7", + "setuptools_scm>=7,<10", ] [tool.meson-python.args] diff --git a/requirements/dev/build-requirements.txt b/requirements/dev/build-requirements.txt index 4d2a098c3c4f..372a7d669fb1 100644 --- a/requirements/dev/build-requirements.txt +++ b/requirements/dev/build-requirements.txt @@ -1,3 +1,3 @@ pybind11>=2.13.2,!=2.13.3 meson-python -setuptools-scm +setuptools-scm<10 diff --git a/requirements/testing/mypy.txt b/requirements/testing/mypy.txt index 343517263f40..451a096f6a96 100644 --- a/requirements/testing/mypy.txt +++ b/requirements/testing/mypy.txt @@ -22,5 +22,5 @@ packaging>=20.0 pillow>=9 pyparsing>=3 python-dateutil>=2.7 -setuptools_scm>=7 +setuptools_scm>=7,<10 setuptools>=64 From 1305c4f8c5cd4b74f2f6e983156ab167e2ec5a5c Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 24 Mar 2026 21:35:36 -0400 Subject: [PATCH 100/108] BLD: Update bundled libraqm to 0.10.4 This incorporates our patch, so we can drop it, and adds `tests` option, which we now disable. --- extern/meson.build | 5 ++-- subprojects/libraqm-0.10.3.wrap | 8 ------- subprojects/libraqm-0.10.4.wrap | 7 ++++++ subprojects/packagefiles/libraqm-203.patch | 27 ---------------------- 4 files changed, 10 insertions(+), 37 deletions(-) delete mode 100644 subprojects/libraqm-0.10.3.wrap create mode 100644 subprojects/libraqm-0.10.4.wrap delete mode 100644 subprojects/packagefiles/libraqm-203.patch diff --git a/extern/meson.build b/extern/meson.build index d2b5f0427573..df6557a8e699 100644 --- a/extern/meson.build +++ b/extern/meson.build @@ -25,7 +25,7 @@ else endif if get_option('system-libraqm') - libraqm_dep = dependency('raqm', version: '>=0.10.3') + libraqm_dep = dependency('raqm', version: '>=0.10.4') else subproject('harfbuzz', default_options: [ @@ -52,10 +52,11 @@ else ] ) subproject('sheenbidi', default_options: ['default_library=static']) - libraqm_proj = subproject('libraqm-0.10.3', + libraqm_proj = subproject('libraqm-0.10.4', default_options: [ 'default_library=static', 'sheenbidi=true', + 'tests=false', ] ) libraqm_dep = libraqm_proj.get_variable('libraqm_dep') diff --git a/subprojects/libraqm-0.10.3.wrap b/subprojects/libraqm-0.10.3.wrap deleted file mode 100644 index 87061a231cba..000000000000 --- a/subprojects/libraqm-0.10.3.wrap +++ /dev/null @@ -1,8 +0,0 @@ -[wrap-file] -source_url = https://github.com/HOST-Oman/libraqm/archive/v0.10.3/libraqm-0.10.3.tar.gz -source_filename = libraqm-0.10.3.tar.gz -source_hash = fe1fe28b32f97ef97b325ca5d2defb0704da1ef048372ec20e85e1f587e20965 - -# First patch allows using our bundled FreeType. -# Second patch is for use as a subproject https://github.com/HOST-Oman/libraqm/pull/203 -diff_files = libraqm-0.10.2-bundle-freetype.patch, libraqm-203.patch diff --git a/subprojects/libraqm-0.10.4.wrap b/subprojects/libraqm-0.10.4.wrap new file mode 100644 index 000000000000..5fad16334895 --- /dev/null +++ b/subprojects/libraqm-0.10.4.wrap @@ -0,0 +1,7 @@ +[wrap-file] +source_url = https://github.com/HOST-Oman/libraqm/archive/v0.10.4/libraqm-0.10.4.tar.gz +source_filename = libraqm-0.10.4.tar.gz +source_hash = 6b583fb0eb159a3727a1e8c653bb0294173a14af8eb60195a775879de72320a3 + +# First patch allows using our bundled FreeType. +diff_files = libraqm-0.10.2-bundle-freetype.patch diff --git a/subprojects/packagefiles/libraqm-203.patch b/subprojects/packagefiles/libraqm-203.patch deleted file mode 100644 index 6628fec1d111..000000000000 --- a/subprojects/packagefiles/libraqm-203.patch +++ /dev/null @@ -1,27 +0,0 @@ -From 8cedfc989998bb2cf23c2c1b40802effad72b0ed Mon Sep 17 00:00:00 2001 -From: Elliott Sales de Andrade -Date: Thu, 7 Aug 2025 18:07:15 -0400 -Subject: [PATCH] Add dependency override for use as a subproject - ---- - src/meson.build | 7 +++++++ - 1 file changed, 7 insertions(+) - -diff --git a/src/meson.build b/src/meson.build -index 0a32f832..ca7c13d1 100644 ---- a/src/meson.build -+++ b/src/meson.build -@@ -42,6 +42,13 @@ libraqm = library( - install: true, - ) - -+libraqm_dep = declare_dependency( -+ include_directories: include_directories('.'), -+ link_with: libraqm, -+) -+ -+meson.override_dependency(meson.project_name(), libraqm_dep) -+ - libraqm_test = static_library( - 'raqm-test', - 'raqm.c', From 38e392152a8f3ee52185ef569ecb859bb340350a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 2 Apr 2026 15:21:58 -0400 Subject: [PATCH 101/108] BLD: Update bundled FreeType to 2.14.3 (#31407) * BLD: Update bundled FreeType to 2.14.3 These releases mostly consist of security fixes. * BLD: adjust upstream url for freetype to nongnu savanah mirror Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --------- Co-authored-by: Thomas A Caswell Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- subprojects/freetype2.wrap | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/subprojects/freetype2.wrap b/subprojects/freetype2.wrap index 4a131cf45270..c3b26ed24625 100644 --- a/subprojects/freetype2.wrap +++ b/subprojects/freetype2.wrap @@ -2,11 +2,11 @@ # the `LOCAL_FREETYPE_VERSION` value in `lib/matplotlib/__init__.py`. Bump the cache key # in `.circleci/config.yml` when changing requirements. [wrap-file] -directory = freetype-2.14.1 -source_url = https://download.savannah.gnu.org/releases/freetype/freetype-2.14.1.tar.xz -source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.14.1/freetype-2.14.1.tar.xz -source_filename = freetype-2.14.1.tar.xz -source_hash = 32427e8c471ac095853212a37aef816c60b42052d4d9e48230bab3bdf2936ccc +directory = freetype-2.14.3 +source_url = https://download.savannah.nongnu.org/releases/freetype/freetype-2.14.3.tar.xz +source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.14.3/freetype-2.14.3.tar.xz +source_filename = freetype-2.14.3.tar.xz +source_hash = 36bc4f1cc413335368ee656c42afca65c5a3987e8768cc28cf11ba775e785a5f # This patch allows using our bundled HarfBuzz. diff_files = freetype-2.14.1-static-harfbuzz.patch From 4b4660ed5c951b2472378cc420fd59e62affb47a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 27 Mar 2026 18:25:12 -0400 Subject: [PATCH 102/108] Fix wasm build on text-overhaul branch - Port FreeType symbol visibility patch to 2.14.1 - Add test image preloading to wasm CI workflow - Temporarily disable testing `test_complex_shaping`, which triggers some code path that fails somehow --- .github/workflows/wasm.yml | 20 ++++++++++++++ pyproject.toml | 5 +++- subprojects/freetype2.wrap | 5 ++-- .../freetype-2.14.1-wasm-visibility.patch | 26 +++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 subprojects/packagefiles/freetype-2.14.1-wasm-visibility.patch diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 11c73ce242a4..725daa918566 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -44,6 +44,25 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Preload test images + run: | + git config --global user.name 'Matplotlib' + git config --global user.email 'nobody@matplotlib.org' + git fetch https://github.com/QuLogic/matplotlib.git text-overhaul-figures:text-overhaul-figures + git merge --no-commit text-overhaul-figures || true + # If there are any conflicts in baseline images, then pick "ours", + # which should be the updated images in the PR. + conflicts=$(git diff --name-only --diff-filter=U \ + lib/matplotlib/tests/baseline_images \ + lib/mpl_toolkits/*/tests/baseline_images) + if [ -n "${conflicts}" ]; then + git checkout --ours -- ${conflicts} + git add -- ${conflicts} + fi + # If committing fails, there were conflicts other than the baseline images, + # which should not be allowed to happen, and should fail the build. + git commit -m 'Preload test images from branch text-overhaul-figures' + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 name: Install Python with: @@ -54,6 +73,7 @@ jobs: env: CIBW_BUILD: "cp312-*" CIBW_PLATFORM: "pyodide" + CIBW_TEST_COMMAND: "true" - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: diff --git a/pyproject.toml b/pyproject.toml index aec3c37121fd..2d1308b63347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -417,7 +417,10 @@ test-command = [ cp -a {package}/lib/${subdir}/tests/baseline_images $basedir/${subdir}/tests/ done""", # Test installed, not repository, copy as we aren't using an editable install. - "pytest -p no:cacheprovider --pyargs matplotlib mpl_toolkits.axes_grid1 mpl_toolkits.axisartist mpl_toolkits.mplot3d", + """\ + pytest -p no:cacheprovider --pyargs \ + matplotlib mpl_toolkits.axes_grid1 mpl_toolkits.axisartist mpl_toolkits.mplot3d \ + -k 'not test_complex_shaping'""", ] [tool.cibuildwheel.pyodide.environment] # Exceptions are needed for pybind11: diff --git a/subprojects/freetype2.wrap b/subprojects/freetype2.wrap index c3b26ed24625..28b452035cf0 100644 --- a/subprojects/freetype2.wrap +++ b/subprojects/freetype2.wrap @@ -8,8 +8,9 @@ source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetyp source_filename = freetype-2.14.3.tar.xz source_hash = 36bc4f1cc413335368ee656c42afca65c5a3987e8768cc28cf11ba775e785a5f -# This patch allows using our bundled HarfBuzz. -diff_files = freetype-2.14.1-static-harfbuzz.patch +# First patch allows using our bundled HarfBuzz. +# Second patch fixes symbol problems on wasm. +diff_files = freetype-2.14.1-static-harfbuzz.patch, freetype-2.14.1-wasm-visibility.patch [provide] freetype2 = freetype_dep diff --git a/subprojects/packagefiles/freetype-2.14.1-wasm-visibility.patch b/subprojects/packagefiles/freetype-2.14.1-wasm-visibility.patch new file mode 100644 index 000000000000..9c96e4191543 --- /dev/null +++ b/subprojects/packagefiles/freetype-2.14.1-wasm-visibility.patch @@ -0,0 +1,26 @@ +diff -uPNr freetype-2.14.3.orig/meson.build freetype-2.14.3/meson.build +--- freetype-2.14.3.orig/meson.build 2026-03-27 05:26:03.270830734 -0400 ++++ freetype-2.14.3/meson.build 2026-03-27 16:19:46.222942478 -0400 +@@ -453,16 +453,21 @@ + ft2_defines += ['-DFT_CONFIG_CONFIG_H='] + endif + ++if cc.get_id() == 'emscripten' ++ kwargs = {} ++else ++ kwargs = {'gnu_symbol_visibility': 'hidden'} ++endif + + ft2_lib = library('freetype', + sources: ft2_sources + [ftmodule_h], + c_args: ft2_defines, +- gnu_symbol_visibility: 'hidden', + include_directories: ft2_includes, + dependencies: ft2_deps, + install: true, + version: ft2_so_version, + link_args: common_ldflags, ++ kwargs: kwargs, + ) + + From 17b1f3121ac4095bb7c8acb3f059141df1c91514 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 30 Mar 2026 23:17:52 -0400 Subject: [PATCH 103/108] TST: Skip test_animation.py::test_failing_ffmpeg on wasm builds This test requires a subprocess call, so should be skipped like the others. --- lib/matplotlib/tests/test_animation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/tests/test_animation.py b/lib/matplotlib/tests/test_animation.py index b34dc01e41cb..4ca5c1220972 100644 --- a/lib/matplotlib/tests/test_animation.py +++ b/lib/matplotlib/tests/test_animation.py @@ -300,6 +300,8 @@ def test_embed_limit(method_name, caplog, anim): and record.levelname == "WARNING") +@pytest.mark.skipif(sys.platform == 'emscripten', + reason='emscripten does not support subprocesses') @pytest.mark.skipif(shutil.which("/bin/sh") is None, reason="requires a POSIX OS") def test_failing_ffmpeg(tmp_path, monkeypatch, anim): """ From 0b4e7875ea463d698ddcc87c5baa11869147829b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 2 Apr 2026 19:30:17 -0400 Subject: [PATCH 104/108] Fix FreeType runtime version check Unfortunately, this was missed in #31407. --- lib/matplotlib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 2182c7c2c2d4..a7bdc9d28347 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1358,7 +1358,7 @@ def _val_or_rc(val, *rc_names): def _init_tests(): # The version of FreeType to install locally for running the tests. This must match # the value in `subprojects/freetype2.wrap`. - LOCAL_FREETYPE_VERSION = '2.14.1' + LOCAL_FREETYPE_VERSION = '2.14.3' from matplotlib import ft2font if (ft2font.__freetype_version__ != LOCAL_FREETYPE_VERSION or From 7c33379e32fb02ca54199d782e061f87156dbfcc Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 13 Mar 2026 00:32:08 -0400 Subject: [PATCH 105/108] TST: Cleanup back-compat code in tests touched by text overhaul These tests had previously kept some code or settings to prevent regenerating the test image, but since they are now going to be regenerated from the text overhaul, we can remove those. --- lib/matplotlib/tests/test_agg_filter.py | 3 --- lib/matplotlib/tests/test_axes.py | 8 +------- lib/matplotlib/tests/test_colorbar.py | 3 --- lib/matplotlib/tests/test_colors.py | 6 ------ lib/matplotlib/tests/test_constrainedlayout.py | 3 --- lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py | 9 --------- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 2 -- 7 files changed, 1 insertion(+), 33 deletions(-) diff --git a/lib/matplotlib/tests/test_agg_filter.py b/lib/matplotlib/tests/test_agg_filter.py index 545e62d20d7c..9e0b5a0c3afa 100644 --- a/lib/matplotlib/tests/test_agg_filter.py +++ b/lib/matplotlib/tests/test_agg_filter.py @@ -7,9 +7,6 @@ @image_comparison(baseline_images=['agg_filter_alpha'], extensions=['gif', 'png', 'pdf']) def test_agg_filter_alpha(): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - ax = plt.axes() x, y = np.mgrid[0:7, 0:8] data = x**2 - y**2 diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index c4ed8d467060..25b93eb57b26 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1030,9 +1030,6 @@ def test_hexbin_pickable(): def test_hexbin_log(): # Issue #1636 (and also test log scaled colorbar) - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - np.random.seed(19680801) n = 100000 x = np.random.standard_normal(n) @@ -1520,9 +1517,6 @@ def test_pcolormesh_log_scale(fig_test, fig_ref): # TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['pcolormesh_datetime_axis.png'], style='mpl20', tol=0.3) def test_pcolormesh_datetime_axis(): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - fig = plt.figure() fig.subplots_adjust(hspace=0.4, top=0.98, bottom=.15) base = datetime.datetime(2013, 1, 1) @@ -5159,7 +5153,7 @@ def test_hist_stacked_bar(): colors = [(0.5759849696758961, 1.0, 0.0), (0.0, 1.0, 0.350624650815206), (0.0, 1.0, 0.6549834156005998), (0.0, 0.6569064625276622, 1.0), (0.28302699607823545, 0.0, 1.0), (0.6849123462299822, 0.0, 1.0)] - labels = ['green', 'orange', ' yellow', 'magenta', 'black'] + labels = ['first', 'second', 'third', 'fourth', 'fifth'] fig, ax = plt.subplots() ax.hist(d, bins=10, histtype='barstacked', align='mid', color=colors, label=labels) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 77ff797be11d..2655f5557298 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -161,9 +161,6 @@ def test_colorbar_extension_inverted_axis(orientation, extend, expected): ], remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) def test_colorbar_positioning(use_gridspec): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - data = np.arange(1200).reshape(30, 40) levels = [0, 200, 400, 600, 800, 1000, 1200] diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 24a2f31f7594..dcccd92c54cb 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -830,9 +830,6 @@ def _mask_tester(norm_instance, vals): @image_comparison(['levels_and_colors.png']) def test_cmap_and_norm_from_levels_and_colors(): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - data = np.linspace(-2, 4, 49).reshape(7, 7) levels = [-1, 2, 2.5, 3] colors = ['red', 'green', 'blue', 'yellow', 'black'] @@ -849,9 +846,6 @@ def test_cmap_and_norm_from_levels_and_colors(): @image_comparison(['boundarynorm_and_colorbar.png'], tol=1.0) def test_boundarynorm_and_colorbarbase(): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - # Make a figure and axes with dimensions as desired. fig = plt.figure() ax1 = fig.add_axes((0.05, 0.80, 0.9, 0.15)) diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index 91aaa2fd9172..ff757c1ce9fc 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -411,9 +411,6 @@ def test_colorbar_location(): Test that colorbar handling is as expected for various complicated cases... """ - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - fig, axs = plt.subplots(4, 5, layout="constrained") for ax in axs.flat: pcm = example_pcolor(ax) 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 7f54466a3cce..3ee8e36ecedc 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -562,15 +562,6 @@ def test_anchored_artists(): box.drawing_area.add_artist(el) ax.add_artist(box) - # This block used to test the AnchoredEllipse class, but that was removed. The block - # remains, though it duplicates the above ellipse, so that the test image doesn't - # need to be regenerated. - box = AnchoredAuxTransformBox(ax.transData, loc='lower left', frameon=True, - pad=0.5, borderpad=0.4) - el = Ellipse((0, 0), width=0.1, height=0.25, angle=-60) - box.drawing_area.add_artist(el) - ax.add_artist(box) - asb = AnchoredSizeBar(ax.transData, 0.2, r"0.2 units", loc='lower right', pad=0.3, borderpad=0.4, sep=4, fill_bar=True, frameon=False, label_top=True, prop={'size': 20}, diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 1cff9fdbe76e..b1910f7259b2 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -650,7 +650,6 @@ def test_surface3d(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20', tol=0.07) def test_surface3d_label_offset_tick_position(): - plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax = plt.figure().add_subplot(projection="3d") x, y = np.mgrid[0:6 * np.pi:0.25, 0:4 * np.pi:0.25] @@ -1733,7 +1732,6 @@ def test_errorbar3d(): @image_comparison(['stem3d.png'], style='mpl20', tol=0.009) def test_stem3d(): - plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig, axs = plt.subplots(2, 3, figsize=(8, 6), constrained_layout=True, subplot_kw={'projection': '3d'}) From dbf8c17d538892b1e759fa72a28ce710051efc6e Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 5 Apr 2026 13:48:29 +0200 Subject: [PATCH 106/108] Clarify fonttype switch in backend_pdf. Make it clearer that switches are only over 3 possible fonttypes (1, 3, 42). In draw_text, the logic is easier to follow if one directly switches over rcParams['pdf.use14corefonts']; this requires putting the url handling at the end, which doesn't matter. --- lib/matplotlib/backends/backend_pdf.py | 38 +++++++++----------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 8ce3cd95accb..280a72d534ad 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2288,13 +2288,8 @@ def _encode_glyphs(self, subset, fonttype): return b''.join(glyph.to_bytes(2, 'big') for glyph in subset) def encode_string(self, s, fonttype): - match fonttype: - case 1: - return s.encode('cp1252', 'replace') - case 3: - return s.encode('latin-1', 'replace') - case _: - return s.encode('utf-16be', 'replace') + encoding = {1: 'cp1252', 3: 'latin-1', 42: 'utf-16be'}[fonttype] + return s.encode(encoding, 'replace') def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited @@ -2312,29 +2307,13 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): else: features = language = None + # For Type-1 fonts, emit the whole string at once without manual kerning. if mpl.rcParams['pdf.use14corefonts']: font = self._get_font_afm(prop) - fonttype = 1 - else: - font = self._get_font_ttf(prop) - fonttype = mpl.rcParams['pdf.fonttype'] - - if gc.get_url() is not None: - font.set_text(s, features=features, language=language) - width, height = font.get_width_height() - self.file._annotations[-1][1].append(_get_link_annotation( - gc, x, y, width / 64, height / 64, angle)) - - # If fonttype is neither 3 nor 42, emit the whole string at once - # without manual kerning. - if fonttype not in [3, 42]: - if not mpl.rcParams['pdf.use14corefonts']: - self.file._character_tracker.track(font, s, - features=features, language=language) self.file.output(Op.begin_text, self.file.fontName(prop), fontsize, Op.selectfont) self._setup_textpos(x, y, angle) - self.file.output(self.encode_string(s, fonttype), + self.file.output(self.encode_string(s, fonttype=1), Op.show, Op.end_text) # A sequence of characters is broken into multiple chunks. The chunking @@ -2350,6 +2329,9 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # Each chunk is emitted with the regular text show command (TJ) with appropriate # kerning between chunks. else: + font = self._get_font_ttf(prop) + fonttype = mpl.rcParams['pdf.fonttype'] + def output_singlebyte_chunk(kerns_or_chars): if not kerns_or_chars: return @@ -2400,6 +2382,12 @@ def output_singlebyte_chunk(kerns_or_chars): self.file.output(Op.end_text) self.file.output(Op.grestore) + if gc.get_url() is not None: + font.set_text(s, features=features, language=language) + width, height = font.get_width_height() + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, width / 64, height / 64, angle)) + def new_gc(self): # docstring inherited return GraphicsContextPdf(self.file) From 41c4d8d24516c690b63f83c4c17ac11ec776bd5f Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Mon, 6 Apr 2026 21:52:57 -0400 Subject: [PATCH 107/108] TST: Set tests touched by text overhaul to mpl20 style (#31300) * TST: Set tests touched by text overhaul to mpl20 style * TST: Tweak some tests to work better in mpl20 style --- lib/matplotlib/tests/test_agg_filter.py | 2 +- lib/matplotlib/tests/test_arrow_patches.py | 2 +- lib/matplotlib/tests/test_axes.py | 139 +++++++++--------- lib/matplotlib/tests/test_backend_pdf.py | 14 +- lib/matplotlib/tests/test_backend_ps.py | 8 +- lib/matplotlib/tests/test_backend_svg.py | 10 +- lib/matplotlib/tests/test_bbox_tight.py | 9 +- lib/matplotlib/tests/test_collections.py | 31 ++-- lib/matplotlib/tests/test_colorbar.py | 2 +- lib/matplotlib/tests/test_colors.py | 2 +- lib/matplotlib/tests/test_dates.py | 14 +- lib/matplotlib/tests/test_figure.py | 10 +- lib/matplotlib/tests/test_ft2font.py | 2 +- lib/matplotlib/tests/test_image.py | 2 +- lib/matplotlib/tests/test_legend.py | 40 ++--- lib/matplotlib/tests/test_patches.py | 10 +- lib/matplotlib/tests/test_patheffects.py | 11 +- lib/matplotlib/tests/test_polar.py | 2 +- lib/matplotlib/tests/test_quiver.py | 8 +- lib/matplotlib/tests/test_simplification.py | 2 +- lib/matplotlib/tests/test_spines.py | 6 +- lib/matplotlib/tests/test_subplots.py | 4 +- lib/matplotlib/tests/test_table.py | 6 +- lib/matplotlib/tests/test_text.py | 52 +++---- lib/matplotlib/tests/test_triangulation.py | 2 +- lib/matplotlib/tests/test_usetex.py | 2 +- .../axes_grid1/tests/test_axes_grid1.py | 21 +-- 27 files changed, 211 insertions(+), 202 deletions(-) diff --git a/lib/matplotlib/tests/test_agg_filter.py b/lib/matplotlib/tests/test_agg_filter.py index 9e0b5a0c3afa..4c5b55a3d15c 100644 --- a/lib/matplotlib/tests/test_agg_filter.py +++ b/lib/matplotlib/tests/test_agg_filter.py @@ -5,7 +5,7 @@ @image_comparison(baseline_images=['agg_filter_alpha'], - extensions=['gif', 'png', 'pdf']) + extensions=['gif', 'png', 'pdf'], style='mpl20') def test_agg_filter_alpha(): ax = plt.axes() x, y = np.mgrid[0:7, 0:8] diff --git a/lib/matplotlib/tests/test_arrow_patches.py b/lib/matplotlib/tests/test_arrow_patches.py index 9cf1636f7913..08d3d62f0a84 100644 --- a/lib/matplotlib/tests/test_arrow_patches.py +++ b/lib/matplotlib/tests/test_arrow_patches.py @@ -28,7 +28,7 @@ def test_fancyarrow(): ax.tick_params(labelleft=False, labelbottom=False) -@image_comparison(['boxarrow_test_image.png']) +@image_comparison(['boxarrow_test_image.png'], style='mpl20') def test_boxarrow(): styles = mpatches.BoxStyle.get_styles() diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 25b93eb57b26..315722b8fd36 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -240,13 +240,13 @@ def test_matshow(fig_test, fig_ref): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison([f'formatter_ticker_{i:03d}.png' for i in range(1, 6)], +@image_comparison([f'formatter_ticker_{i:03d}.png' for i in range(1, 6)], style='mpl20', tol=0.02 if platform.machine() == 'x86_64' else 0.04) def test_formatter_ticker(): import matplotlib.testing.jpl_units as units units.register() - # This should affect the tick size. (Tests issue #543) + # This should not affect the tick size. (Tests issue #543) matplotlib.rcParams['lines.markeredgewidth'] = 30 # This essentially test to see if user specified labels get overwritten @@ -332,7 +332,7 @@ def test_strmethodformatter_auto_formatter(): assert ax.yaxis.get_minor_formatter().fmt == targ_strformatter.fmt -@image_comparison(["twin_axis_locators_formatters.png"]) +@image_comparison(["twin_axis_locators_formatters.png"], style='mpl20') def test_twin_axis_locators_formatters(): vals = np.linspace(0, 1, num=5, endpoint=True) locs = np.sin(np.pi * vals / 2.0) @@ -342,6 +342,7 @@ def test_twin_axis_locators_formatters(): fig = plt.figure() ax1 = fig.add_subplot(1, 1, 1) + ax1.margins(0) ax1.plot([0.1, 100], [0, 1]) ax1.yaxis.set_major_locator(majl) ax1.yaxis.set_minor_locator(minl) @@ -735,7 +736,7 @@ def test_nargs_pcolorfast(): ax.pcolorfast([(0, 1), (0, 2)], [[1, 2, 3], [1, 2, 3]]) -@image_comparison(['offset_points'], remove_text=True) +@image_comparison(['offset_points'], remove_text=True, style='mpl20') def test_basic_annotate(): # Setup some data t = np.arange(0.0, 5.0, 0.01) @@ -811,7 +812,7 @@ def test_annotate_signature(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['fill_units.png'], savefig_kwarg={'dpi': 60}, tol=0.2) +@image_comparison(['fill_units.png'], savefig_kwarg={'dpi': 60}, style='mpl20', tol=0.2) def test_fill_units(): import matplotlib.testing.jpl_units as units units.register() @@ -876,7 +877,7 @@ def test_errorbar_mapview_kwarg(): ax.errorbar(x=D.keys(), y=D.values(), xerr=D.values()) -@image_comparison(['single_point', 'single_point']) +@image_comparison(['single_point', 'single_point'], style='mpl20') def test_single_point(): # Issue #1796: don't let lines.marker affect the grid matplotlib.rcParams['lines.marker'] = 'o' @@ -935,7 +936,7 @@ def test_aitoff_proj(): ax.plot(X.flat, Y.flat, 'o', markersize=4) -@image_comparison(['axvspan_epoch.png']) +@image_comparison(['axvspan_epoch.png'], style='mpl20') def test_axvspan_epoch(): import matplotlib.testing.jpl_units as units units.register() @@ -950,7 +951,7 @@ def test_axvspan_epoch(): ax.set_xlim(t0 - 5.0*dt, tf + 5.0*dt) -@image_comparison(['axhspan_epoch.png'], tol=0.02) +@image_comparison(['axhspan_epoch.png'], style='mpl20', tol=0.02) def test_axhspan_epoch(): import matplotlib.testing.jpl_units as units units.register() @@ -1148,7 +1149,7 @@ def test_inverted_limits(): assert ax.get_ylim() == (10, 1) -@image_comparison(['nonfinite_limits']) +@image_comparison(['nonfinite_limits'], style='mpl20') def test_nonfinite_limits(): x = np.arange(0., np.e, 0.01) # silence divide by zero warning from log(0) @@ -1342,7 +1343,7 @@ def test_fill_between_interpolate_nan(): # test_symlog and test_symlog2 used to have baseline images in all three # formats, but the png and svg baselines got invalidated by the removal of # minor tick overstriking. -@image_comparison(['symlog.pdf']) +@image_comparison(['symlog.pdf'], style='mpl20') def test_symlog(): x = np.array([0, 1, 2, 4, 6, 9, 12, 24]) y = np.array([1000000, 500000, 100000, 100, 5, 0, 0, 0]) @@ -1751,8 +1752,8 @@ def test_pcolorauto(fig_test, fig_ref, snap): ax.pcolormesh(x2, y2, Z, snap=snap) -@image_comparison(['canonical'], - tol=0 if platform.machine() == 'x86_64' else 0.02) +@image_comparison(['canonical'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.03) def test_canonical(): fig, ax = plt.subplots() ax.plot([1, 2, 3]) @@ -1837,7 +1838,7 @@ def test_marker_as_markerstyle(): ax.errorbar([1, 2, 3], [5, 4, 3], marker=m) -@image_comparison(['markevery.png'], remove_text=True) +@image_comparison(['markevery.png'], remove_text=True, style='mpl20') def test_markevery(): x = np.linspace(0, 10, 100) y = np.sin(x) * np.sqrt(x/10 + 0.5) @@ -1845,13 +1846,13 @@ def test_markevery(): # check marker only plot fig, ax = plt.subplots() ax.plot(x, y, 'o', label='default') - ax.plot(x, y, 'd', markevery=None, label='mark all') - ax.plot(x, y, 's', markevery=10, label='mark every 10') - ax.plot(x, y, '+', markevery=(5, 20), label='mark every 5 starting at 10') + ax.plot(x, y+1, 'd', markevery=None, label='mark all') + ax.plot(x, y+2, 's', markevery=10, label='mark every 10') + ax.plot(x, y+3, '+', markevery=(5, 20), label='mark every 20 starting at 5') ax.legend() -@image_comparison(['markevery_line.png'], remove_text=True, tol=0.005) +@image_comparison(['markevery_line.png'], remove_text=True, style='mpl20', tol=0.005) def test_markevery_line(): # TODO: a slight change in rendering between Inkscape versions may explain # why one had to introduce a small non-zero tolerance for the SVG test @@ -1863,9 +1864,9 @@ def test_markevery_line(): # check line/marker combos fig, ax = plt.subplots() ax.plot(x, y, '-o', label='default') - ax.plot(x, y, '-d', markevery=None, label='mark all') - ax.plot(x, y, '-s', markevery=10, label='mark every 10') - ax.plot(x, y, '-+', markevery=(5, 20), label='mark every 5 starting at 10') + ax.plot(x, y+1, '-d', markevery=None, label='mark all') + ax.plot(x, y+2, '-s', markevery=10, label='mark every 10') + ax.plot(x, y+3, '-+', markevery=(5, 20), label='mark every 20 starting at 5') ax.legend() @@ -2007,7 +2008,8 @@ def test_marker_edges(): ax.plot(x+0.2, np.sin(x), 'y.', ms=30.0, mew=2, mec='b') -@image_comparison(['bar_tick_label_single.png', 'bar_tick_label_single.png']) +@image_comparison(['bar_tick_label_single.png', 'bar_tick_label_single.png'], + style='mpl20') def test_bar_tick_label_single(): # From 2516: plot bar with array of string labels for x axis ax = plt.gca() @@ -2030,7 +2032,7 @@ def test_bar_ticklabel_fail(): ax.bar([], []) -@image_comparison(['bar_tick_label_multiple.png']) +@image_comparison(['bar_tick_label_multiple.png'], style='mpl20') def test_bar_tick_label_multiple(): # From 2516: plot bar with array of string labels for x axis ax = plt.gca() @@ -2038,7 +2040,7 @@ def test_bar_tick_label_multiple(): align='center') -@image_comparison(['bar_tick_label_multiple_old_label_alignment.png']) +@image_comparison(['bar_tick_label_multiple_old_label_alignment.png'], style='mpl20') def test_bar_tick_label_multiple_old_alignment(): # Test that the alignment for class is backward compatible matplotlib.rcParams["ytick.alignment"] = "center" @@ -2119,7 +2121,7 @@ def test_bar_edgecolor_none_alpha(): assert rect.get_edgecolor() == (0, 0, 0, 0) -@image_comparison(['barh_tick_label.png']) +@image_comparison(['barh_tick_label.png'], style='mpl20') def test_barh_tick_label(): # From 2516: plot barh with array of string labels for y axis ax = plt.gca() @@ -2518,7 +2520,7 @@ def test_hist_step_filled(): assert all(p.get_facecolor() == p.get_edgecolor() for p in patches) -@image_comparison(['hist_density.png']) +@image_comparison(['hist_density.png'], style='mpl20') def test_hist_density(): np.random.seed(19680801) data = np.random.standard_normal(2000) @@ -2752,7 +2754,7 @@ def test_stairs_invalid_update2(): h.set_data(edges=np.arange(5)) -@image_comparison(['test_stairs_options.png'], remove_text=True) +@image_comparison(['test_stairs_options.png'], style='mpl20', remove_text=True) def test_stairs_options(): x, y = np.array([1, 2, 3, 4, 5]), np.array([1, 2, 3, 4]).astype(float) yn = y.copy() @@ -2777,7 +2779,7 @@ def test_stairs_options(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['test_stairs_datetime.png'], tol=0.2) +@image_comparison(['test_stairs_datetime.png'], style='mpl20', tol=0.2) def test_stairs_datetime(): f, ax = plt.subplots(constrained_layout=True) ax.stairs(np.arange(36), @@ -3401,6 +3403,7 @@ def test_log_scales_invalid(): @image_comparison(['stackplot_test_image.png', 'stackplot_test_image.png'], + style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.031) def test_stackplot(): fig = plt.figure() @@ -4017,7 +4020,7 @@ def test_boxplot_mod_artist_after_plotting(): @image_comparison(['violinplot_vert_baseline.png', - 'violinplot_vert_baseline.png']) + 'violinplot_vert_baseline.png'], style='mpl20') def test_vert_violinplot_baseline(): # First 9 digits of frac(sqrt(2)) np.random.seed(414213562) @@ -4033,7 +4036,7 @@ def test_vert_violinplot_baseline(): showmedians=False, data=data) -@image_comparison(['violinplot_vert_showmeans.png']) +@image_comparison(['violinplot_vert_showmeans.png'], style='mpl20') def test_vert_violinplot_showmeans(): ax = plt.axes() # First 9 digits of frac(sqrt(3)) @@ -4043,7 +4046,7 @@ def test_vert_violinplot_showmeans(): showmedians=False) -@image_comparison(['violinplot_vert_showextrema.png']) +@image_comparison(['violinplot_vert_showextrema.png'], style='mpl20') def test_vert_violinplot_showextrema(): ax = plt.axes() # First 9 digits of frac(sqrt(5)) @@ -4053,7 +4056,7 @@ def test_vert_violinplot_showextrema(): showmedians=False) -@image_comparison(['violinplot_vert_showmedians.png']) +@image_comparison(['violinplot_vert_showmedians.png'], style='mpl20') def test_vert_violinplot_showmedians(): ax = plt.axes() # First 9 digits of frac(sqrt(7)) @@ -4063,7 +4066,7 @@ def test_vert_violinplot_showmedians(): showmedians=True) -@image_comparison(['violinplot_vert_showall.png']) +@image_comparison(['violinplot_vert_showall.png'], style='mpl20') def test_vert_violinplot_showall(): ax = plt.axes() # First 9 digits of frac(sqrt(11)) @@ -4074,7 +4077,7 @@ def test_vert_violinplot_showall(): quantiles=[[0.1, 0.9], [0.2, 0.8], [0.3, 0.7], [0.4, 0.6]]) -@image_comparison(['violinplot_vert_custompoints_10.png']) +@image_comparison(['violinplot_vert_custompoints_10.png'], style='mpl20') def test_vert_violinplot_custompoints_10(): ax = plt.axes() # First 9 digits of frac(sqrt(13)) @@ -4084,7 +4087,7 @@ def test_vert_violinplot_custompoints_10(): showmedians=False, points=10) -@image_comparison(['violinplot_vert_custompoints_200.png']) +@image_comparison(['violinplot_vert_custompoints_200.png'], style='mpl20') def test_vert_violinplot_custompoints_200(): ax = plt.axes() # First 9 digits of frac(sqrt(17)) @@ -4094,7 +4097,7 @@ def test_vert_violinplot_custompoints_200(): showmedians=False, points=200) -@image_comparison(['violinplot_horiz_baseline.png']) +@image_comparison(['violinplot_horiz_baseline.png'], style='mpl20') def test_horiz_violinplot_baseline(): ax = plt.axes() # First 9 digits of frac(sqrt(19)) @@ -4104,7 +4107,7 @@ def test_horiz_violinplot_baseline(): showextrema=False, showmedians=False) -@image_comparison(['violinplot_horiz_showmedians.png']) +@image_comparison(['violinplot_horiz_showmedians.png'], style='mpl20') def test_horiz_violinplot_showmedians(): ax = plt.axes() # First 9 digits of frac(sqrt(23)) @@ -4114,7 +4117,7 @@ def test_horiz_violinplot_showmedians(): showextrema=False, showmedians=True) -@image_comparison(['violinplot_horiz_showmeans.png']) +@image_comparison(['violinplot_horiz_showmeans.png'], style='mpl20') def test_horiz_violinplot_showmeans(): ax = plt.axes() # First 9 digits of frac(sqrt(29)) @@ -4124,7 +4127,7 @@ def test_horiz_violinplot_showmeans(): showextrema=False, showmedians=False) -@image_comparison(['violinplot_horiz_showextrema.png']) +@image_comparison(['violinplot_horiz_showextrema.png'], style='mpl20') def test_horiz_violinplot_showextrema(): ax = plt.axes() # First 9 digits of frac(sqrt(31)) @@ -4134,7 +4137,7 @@ def test_horiz_violinplot_showextrema(): showextrema=True, showmedians=False) -@image_comparison(['violinplot_horiz_showall.png']) +@image_comparison(['violinplot_horiz_showall.png'], style='mpl20') def test_horiz_violinplot_showall(): ax = plt.axes() # First 9 digits of frac(sqrt(37)) @@ -4145,7 +4148,7 @@ def test_horiz_violinplot_showall(): quantiles=[[0.1, 0.9], [0.2, 0.8], [0.3, 0.7], [0.4, 0.6]]) -@image_comparison(['violinplot_horiz_custompoints_10.png']) +@image_comparison(['violinplot_horiz_custompoints_10.png'], style='mpl20') def test_horiz_violinplot_custompoints_10(): ax = plt.axes() # First 9 digits of frac(sqrt(41)) @@ -4155,7 +4158,7 @@ def test_horiz_violinplot_custompoints_10(): showextrema=False, showmedians=False, points=10) -@image_comparison(['violinplot_horiz_custompoints_200.png']) +@image_comparison(['violinplot_horiz_custompoints_200.png'], style='mpl20') def test_horiz_violinplot_custompoints_200(): ax = plt.axes() # First 9 digits of frac(sqrt(43)) @@ -4464,7 +4467,8 @@ def test_tick_space_size_0(): plt.savefig(b, dpi=80, format='raw') -@image_comparison(['errorbar_basic.png', 'errorbar_mixed.png', 'errorbar_basic.png']) +@image_comparison(['errorbar_basic.png', 'errorbar_mixed.png', 'errorbar_basic.png'], + style='mpl20') def test_errorbar(): # longdouble due to floating point rounding issues with certain # computer chipsets @@ -4601,7 +4605,7 @@ def test_errorbar_shape(): ax.errorbar(x, y, yerr=yerr, xerr=xerr, fmt='o') -@image_comparison(['errorbar_limits.png']) +@image_comparison(['errorbar_limits.png'], style='mpl20') def test_errorbar_limits(): x = np.arange(0.5, 5.5, 0.5) y = np.exp(-x) @@ -4627,7 +4631,7 @@ def test_errorbar_limits(): color='red') # including upper and lower limits - ax.errorbar(x, y+1.5, marker='o', ms=8, xerr=xerr, yerr=yerr, + ax.errorbar(x, y+1.5, marker='o', ms=6, xerr=xerr, yerr=yerr, lolims=lolims, uplims=uplims, ls=ls, color='magenta') # including xlower and xupper limits @@ -4640,7 +4644,7 @@ def test_errorbar_limits(): uplims = np.zeros_like(x) lolims[[6]] = True uplims[[3]] = True - ax.errorbar(x, y+2.1, marker='o', ms=8, xerr=xerr, yerr=yerr, + ax.errorbar(x, y+2.1, marker='o', ms=6, xerr=xerr, yerr=yerr, xlolims=xlolims, xuplims=xuplims, uplims=uplims, lolims=lolims, ls='none', mec='blue', capsize=0, color='cyan') @@ -4851,7 +4855,8 @@ def test_errorbar_masked_negative(fig_test, fig_ref): ax.errorbar([4], [3], yerr=[6], fmt="C0") -@image_comparison(['hist_stacked_stepfilled.png', 'hist_stacked_stepfilled.png']) +@image_comparison(['hist_stacked_stepfilled.png', 'hist_stacked_stepfilled.png'], + style='mpl20') def test_hist_stacked_stepfilled(): # make some data d1 = np.linspace(1, 3, 20) @@ -4865,7 +4870,7 @@ def test_hist_stacked_stepfilled(): ax.hist("x", histtype="stepfilled", stacked=True, data=data) -@image_comparison(['hist_offset.png']) +@image_comparison(['hist_offset.png'], style='mpl20') def test_hist_offset(): # make some data d1 = np.linspace(0, 10, 50) @@ -4885,7 +4890,7 @@ def test_hist_step(): ax.set_xlim(-1, 5) -@image_comparison(['hist_step_horiz.png']) +@image_comparison(['hist_step_horiz.png'], style='mpl20') def test_hist_step_horiz(): # make some data d1 = np.linspace(0, 10, 50) @@ -4894,7 +4899,7 @@ def test_hist_step_horiz(): ax.hist((d1, d2), histtype="step", orientation="horizontal") -@image_comparison(['hist_stacked_weights.png']) +@image_comparison(['hist_stacked_weights.png'], style='mpl20') def test_hist_stacked_weighted(): # make some data d1 = np.linspace(0, 10, 50) @@ -5036,7 +5041,7 @@ def test_stem_polar_baseline(): assert container.baseline.get_path()._interpolation_steps > 100 -@image_comparison(['hist_stacked_stepfilled_alpha.png']) +@image_comparison(['hist_stacked_stepfilled_alpha.png'], style='mpl20') def test_hist_stacked_stepfilled_alpha(): # make some data d1 = np.linspace(1, 3, 20) @@ -5045,7 +5050,7 @@ def test_hist_stacked_stepfilled_alpha(): ax.hist((d1, d2), histtype="stepfilled", stacked=True, alpha=0.5) -@image_comparison(['hist_stacked_step.png']) +@image_comparison(['hist_stacked_step.png'], style='mpl20') def test_hist_stacked_step(): # make some data d1 = np.linspace(1, 3, 20) @@ -5054,7 +5059,7 @@ def test_hist_stacked_step(): ax.hist((d1, d2), histtype="step", stacked=True) -@image_comparison(['hist_stacked_normed.png']) +@image_comparison(['hist_stacked_normed.png'], style='mpl20') def test_hist_stacked_density(): # make some data d1 = np.linspace(1, 3, 20) @@ -5142,7 +5147,7 @@ def test_hist_stacked_step_bottom_geometry(): assert_array_equal(polygon.get_xy(), xy[1]) -@image_comparison(['hist_stacked_bar.png']) +@image_comparison(['hist_stacked_bar.png'], style='mpl20') def test_hist_stacked_bar(): # make some data d = [[100, 100, 100, 100, 200, 320, 450, 80, 20, 600, 310, 800], @@ -5545,7 +5550,7 @@ def test_marker_styles(): marker=marker, markersize=10+y/5, label=marker) -@image_comparison(['rc_markerfill.png'], +@image_comparison(['rc_markerfill.png'], style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.037) def test_markers_fillstyle_rcparams(): fig, ax = plt.subplots() @@ -5568,7 +5573,7 @@ def test_vertex_markers(): ax.set_ylim(-1, 10) -@image_comparison(['vline_hline_zorder.png', 'errorbar_zorder.png'], +@image_comparison(['vline_hline_zorder.png', 'errorbar_zorder.png'], style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.026) def test_eb_line_zorder(): x = list(range(10)) @@ -5691,7 +5696,8 @@ def test_axline_args(): plt.draw() -@image_comparison(['vlines_basic.png', 'vlines_with_nan.png', 'vlines_masked.png']) +@image_comparison(['vlines_basic.png', 'vlines_with_nan.png', 'vlines_masked.png'], + style='mpl20') def test_vlines(): # normal x1 = [2, 3, 4, 5, 7] @@ -5737,7 +5743,8 @@ def test_vlines_default(): assert mpl.colors.same_color(lines.get_color(), 'red') -@image_comparison(['hlines_basic.png', 'hlines_with_nan.png', 'hlines_masked.png']) +@image_comparison(['hlines_basic.png', 'hlines_with_nan.png', 'hlines_masked.png'], + style='mpl20') def test_hlines(): # normal y1 = [2, 3, 4, 5, 7] @@ -6485,7 +6492,7 @@ def test_text_labelsize(): # These tolerances could likely go away when numpy 2.0 is the minimum supported # numpy and the images are regenerated. -@image_comparison(['pie_default.png'], tol=0.01) +@image_comparison(['pie_default.png'], style='mpl20', tol=0.01) def test_pie_default(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6623,7 +6630,7 @@ def test_pie_rotatelabels_true(): plt.axis('equal') -@image_comparison(['pie_no_label.png'], tol=0.01) +@image_comparison(['pie_no_label.png'], style='mpl20', tol=0.01) def test_pie_nolabel_but_legend(): labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' sizes = [15, 30, 45, 10] @@ -6789,8 +6796,8 @@ def test_pie_label_fail(): ax.pie_label(pie, labels) -@image_comparison(['set_get_ticklabels.png'], - tol=0 if platform.machine() == 'x86_64' else 0.025) +@image_comparison(['set_get_ticklabels.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.03) def test_set_get_ticklabels(): # test issue 2246 fig, ax = plt.subplots(2) @@ -6891,7 +6898,7 @@ def test_empty_ticks_fixed_loc(): ax.set_xticklabels([]) -@image_comparison(['retain_tick_visibility.png']) +@image_comparison(['retain_tick_visibility.png'], style='mpl20') def test_retain_tick_visibility(): fig, ax = plt.subplots() plt.plot([0, 1, 2], [0, -1, 4]) @@ -6933,7 +6940,7 @@ def formatter_func(x, pos): assert tick_texts == ["", "", "unit value", "", ""] -@image_comparison(['o_marker_path_snap.png'], savefig_kwarg={'dpi': 72}) +@image_comparison(['o_marker_path_snap.png'], savefig_kwarg={'dpi': 72}, style='mpl20') def test_o_marker_path_snap(): fig, ax = plt.subplots() ax.margins(.1) @@ -7111,7 +7118,7 @@ def test_move_offsetlabel(): assert ax.xaxis.offsetText.get_verticalalignment() == 'bottom' -@image_comparison(['rc_spines.png'], savefig_kwarg={'dpi': 40}) +@image_comparison(['rc_spines.png'], savefig_kwarg={'dpi': 40}, style='mpl20') def test_rc_spines(): rc_dict = { 'axes.spines.left': False, @@ -7122,7 +7129,7 @@ def test_rc_spines(): plt.subplots() # create a figure and axes with the spine properties -@image_comparison(['rc_grid.png'], savefig_kwarg={'dpi': 40}) +@image_comparison(['rc_grid.png'], savefig_kwarg={'dpi': 40}, style='mpl20') def test_rc_grid(): fig = plt.figure() rc_dict0 = { @@ -9160,7 +9167,7 @@ def test_bar_label_location_center(): assert labels[1].get_verticalalignment() == 'center' -@image_comparison(['test_centered_bar_label_nonlinear.svg']) +@image_comparison(['test_centered_bar_label_nonlinear.svg'], style='mpl20') def test_centered_bar_label_nonlinear(): _, ax = plt.subplots() bar_container = ax.barh(['c', 'b', 'a'], [1_000, 5_000, 7_000]) diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index dac796336fe9..20776af13307 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -346,7 +346,7 @@ def test_empty_rasterized(): fig.savefig(io.BytesIO(), format="pdf") -@image_comparison(['kerning.pdf']) +@image_comparison(['kerning.pdf'], style='mpl20') def test_kerning(): fig = plt.figure() s = "AVAVAVAVAVAVAVAV€AAVV" @@ -380,24 +380,24 @@ def test_glyphs_subset(): assert subfont.get_num_glyphs() == nosubfont.get_num_glyphs() -@image_comparison(["multi_font_type3.pdf"]) +@image_comparison(["multi_font_type3.pdf"], style='mpl20') def test_multi_font_type3(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('pdf', fonttype=3) - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) fig.text(0.5, 0.5, test_str, horizontalalignment='center', verticalalignment='center') -@image_comparison(["multi_font_type42.pdf"]) +@image_comparison(["multi_font_type42.pdf"], style='mpl20') def test_multi_font_type42(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('pdf', fonttype=42) - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) fig.text(0.5, 0.5, test_str, horizontalalignment='center', verticalalignment='center') @@ -450,14 +450,14 @@ def test_otf_font_smoke(family_name, file_name): fig.savefig(io.BytesIO(), format="pdf") -@image_comparison(["truetype-conversion.pdf"]) +@image_comparison(["truetype-conversion.pdf"], style='mpl20') # mpltest.ttf does not have "l"/"p" glyphs so we get a warning when trying to # get the font extents. def test_truetype_conversion(recwarn): mpl.rcParams['pdf.fonttype'] = 3 fig, ax = plt.subplots() ax.text(0, 0, "ABCDE", - font=Path(__file__).parent / "data/mpltest.ttf", fontsize=80) + font=Path(__file__).parent / "data/mpltest.ttf", fontsize=72) ax.set_xticks([]) ax.set_yticks([]) diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index 5037c15370a5..6eac82678362 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -315,24 +315,24 @@ def test_no_duplicate_definition(): assert max(Counter(wds).values()) == 1 -@image_comparison(["multi_font_type3.eps"]) +@image_comparison(["multi_font_type3.eps"], style='mpl20') def test_multi_font_type3(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('ps', fonttype=3) - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) fig.text(0.5, 0.5, test_str, horizontalalignment='center', verticalalignment='center') -@image_comparison(["multi_font_type42.eps"]) +@image_comparison(["multi_font_type42.eps"], style='mpl20') def test_multi_font_type42(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('ps', fonttype=42) - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) fig.text(0.5, 0.5, test_str, horizontalalignment='center', verticalalignment='center') diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index a95ed48e12d5..6c540ccebd76 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -68,7 +68,7 @@ def test_text_urls(): assert expected in buf -@image_comparison(['bold_font_output.svg']) +@image_comparison(['bold_font_output.svg'], style='mpl20') def test_bold_font_output(): fig, ax = plt.subplots() ax.plot(np.arange(10), np.arange(10)) @@ -527,24 +527,24 @@ def test_svg_metadata(): assert values == metadata['Keywords'] -@image_comparison(["multi_font_aspath.svg"]) +@image_comparison(["multi_font_aspath.svg"], style='mpl20') def test_multi_font_aspath(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('svg', fonttype='path') - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) fig.text(0.5, 0.5, test_str, horizontalalignment='center', verticalalignment='center') -@image_comparison(["multi_font_astext.svg"]) +@image_comparison(["multi_font_astext.svg"], style='mpl20') def test_multi_font_astext(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('svg', fonttype='none') - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) fig.text(0.5, 0.5, test_str, horizontalalignment='center', verticalalignment='center') diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 677cdf37dd24..f6d910a7f208 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -46,7 +46,7 @@ def test_bbox_inches_tight(text_placeholders): @image_comparison(['bbox_inches_tight_suptile_legend'], - savefig_kwarg={'bbox_inches': 'tight'}, + savefig_kwarg={'bbox_inches': 'tight'}, style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.024) def test_bbox_inches_tight_suptile_legend(): plt.plot(np.arange(10), label='a straight line') @@ -66,7 +66,7 @@ def y_formatter(y, pos): @image_comparison(['bbox_inches_tight_suptile_non_default.png'], - savefig_kwarg={'bbox_inches': 'tight'}, + savefig_kwarg={'bbox_inches': 'tight'}, style='mpl20', tol=0.1) # large tolerance because only testing clipping. def test_bbox_inches_tight_suptitle_non_default(): fig, ax = plt.subplots() @@ -111,7 +111,8 @@ def test_bbox_inches_tight_clipping(): @image_comparison(['bbox_inches_tight_raster'], tol=0.15, # For Ghostscript 10.06+. - remove_text=True, savefig_kwarg={'bbox_inches': 'tight'}) + remove_text=True, savefig_kwarg={'bbox_inches': 'tight'}, + style='mpl20') def test_bbox_inches_tight_raster(): """Test rasterization with tight_layout""" fig, ax = plt.subplots() @@ -168,7 +169,7 @@ def test_noop_tight_bbox(): @image_comparison(['bbox_inches_fixed_aspect.png'], remove_text=True, - savefig_kwarg={'bbox_inches': 'tight'}) + savefig_kwarg={'bbox_inches': 'tight'}, style='mpl20') def test_bbox_inches_fixed_aspect(): with plt.rc_context({'figure.constrained_layout.use': True}): fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index c0ac4ac28c8b..dc397ffde93e 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -66,7 +66,7 @@ def generate_EventCollection_plot(): return ax, coll, props -@image_comparison(['EventCollection_plot__default.png']) +@image_comparison(['EventCollection_plot__default.png'], style='mpl20') def test__EventCollection__get_props(): _, coll, props = generate_EventCollection_plot() # check that the default segments have the correct coordinates @@ -92,7 +92,7 @@ def test__EventCollection__get_props(): np.testing.assert_array_equal(color, props['color']) -@image_comparison(['EventCollection_plot__set_positions.png']) +@image_comparison(['EventCollection_plot__set_positions.png'], style='mpl20') def test__EventCollection__set_positions(): splt, coll, props = generate_EventCollection_plot() new_positions = np.hstack([props['positions'], props['extra_positions']]) @@ -106,7 +106,7 @@ def test__EventCollection__set_positions(): splt.set_xlim(-1, 90) -@image_comparison(['EventCollection_plot__add_positions.png']) +@image_comparison(['EventCollection_plot__add_positions.png'], style='mpl20') def test__EventCollection__add_positions(): splt, coll, props = generate_EventCollection_plot() new_positions = np.hstack([props['positions'], @@ -124,7 +124,7 @@ def test__EventCollection__add_positions(): splt.set_xlim(-1, 35) -@image_comparison(['EventCollection_plot__append_positions.png']) +@image_comparison(['EventCollection_plot__append_positions.png'], style='mpl20') def test__EventCollection__append_positions(): splt, coll, props = generate_EventCollection_plot() new_positions = np.hstack([props['positions'], @@ -140,7 +140,7 @@ def test__EventCollection__append_positions(): splt.set_xlim(-1, 90) -@image_comparison(['EventCollection_plot__extend_positions.png']) +@image_comparison(['EventCollection_plot__extend_positions.png'], style='mpl20') def test__EventCollection__extend_positions(): splt, coll, props = generate_EventCollection_plot() new_positions = np.hstack([props['positions'], @@ -156,7 +156,7 @@ def test__EventCollection__extend_positions(): splt.set_xlim(-1, 90) -@image_comparison(['EventCollection_plot__switch_orientation.png']) +@image_comparison(['EventCollection_plot__switch_orientation.png'], style='mpl20') def test__EventCollection__switch_orientation(): splt, coll, props = generate_EventCollection_plot() new_orientation = 'vertical' @@ -173,7 +173,7 @@ def test__EventCollection__switch_orientation(): splt.set_xlim(0, 2) -@image_comparison(['EventCollection_plot__switch_orientation__2x.png']) +@image_comparison(['EventCollection_plot__switch_orientation__2x.png'], style='mpl20') def test__EventCollection__switch_orientation_2x(): """ Check that calling switch_orientation twice sets the orientation back to @@ -194,7 +194,7 @@ def test__EventCollection__switch_orientation_2x(): splt.set_title('EventCollection: switch_orientation 2x') -@image_comparison(['EventCollection_plot__set_orientation.png']) +@image_comparison(['EventCollection_plot__set_orientation.png'], style='mpl20') def test__EventCollection__set_orientation(): splt, coll, props = generate_EventCollection_plot() new_orientation = 'vertical' @@ -211,7 +211,7 @@ def test__EventCollection__set_orientation(): splt.set_xlim(0, 2) -@image_comparison(['EventCollection_plot__set_linelength.png']) +@image_comparison(['EventCollection_plot__set_linelength.png'], style='mpl20') def test__EventCollection__set_linelength(): splt, coll, props = generate_EventCollection_plot() new_linelength = 15 @@ -226,7 +226,7 @@ def test__EventCollection__set_linelength(): splt.set_ylim(-20, 20) -@image_comparison(['EventCollection_plot__set_lineoffset.png']) +@image_comparison(['EventCollection_plot__set_lineoffset.png'], style='mpl20') def test__EventCollection__set_lineoffset(): splt, coll, props = generate_EventCollection_plot() new_lineoffset = -5. @@ -245,11 +245,12 @@ def test__EventCollection__set_lineoffset(): 'EventCollection_plot__set_linestyle.png', 'EventCollection_plot__set_linestyle.png', 'EventCollection_plot__set_linewidth.png', -]) +], style='mpl20') def test__EventCollection__set_prop(): for prop, value, expected in [ - ('linestyle', 'dashed', [(0, (6.0, 6.0))]), - ('linestyle', (0, (6., 6.)), [(0, (6.0, 6.0))]), + ('linestyle', 'dashed', [(0, [7.4, 3.2])]), + # Dashes are scaled by linewidth. + ('linestyle', (0, (3.7, 1.6)), [(0, [7.4, 3.2])]), ('linewidth', 5, 5), ]: splt, coll, _ = generate_EventCollection_plot() @@ -258,7 +259,7 @@ def test__EventCollection__set_prop(): splt.set_title(f'EventCollection: set_{prop}') -@image_comparison(['EventCollection_plot__set_color.png']) +@image_comparison(['EventCollection_plot__set_color.png'], style='mpl20') def test__EventCollection__set_color(): splt, coll, _ = generate_EventCollection_plot() new_color = np.array([0, 1, 1, 1]) @@ -720,7 +721,7 @@ def test_joinstyle(): assert col.get_joinstyle() == 'miter' -@image_comparison(['cap_and_joinstyle.png']) +@image_comparison(['cap_and_joinstyle.png'], style='mpl20') def test_cap_and_joinstyle_image(): fig, ax = plt.subplots() ax.set_xlim([-0.5, 1.5]) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 2655f5557298..4fd943a3e7a4 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -159,7 +159,7 @@ def test_colorbar_extension_inverted_axis(orientation, extend, expected): 'double_cbar.png', 'cbar_sharing.png', ], - remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) + remove_text=True, savefig_kwarg={'dpi': 40}, style='mpl20', tol=0.05) def test_colorbar_positioning(use_gridspec): data = np.arange(1200).reshape(30, 40) levels = [0, 200, 400, 600, 800, 1000, 1200] diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index dcccd92c54cb..1d3868320743 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -828,7 +828,7 @@ def _mask_tester(norm_instance, vals): assert_array_equal(masked_array.mask, norm_instance(masked_array).mask) -@image_comparison(['levels_and_colors.png']) +@image_comparison(['levels_and_colors.png'], style='mpl20') def test_cmap_and_norm_from_levels_and_colors(): data = np.linspace(-2, 4, 49).reshape(7, 7) levels = [-1, 2, 2.5, 3] diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index d3f64d73002e..c456e3aca089 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -140,7 +140,7 @@ def test_axhline(): mdates._reset_epoch_test_example() -@image_comparison(['date_axhspan.png']) +@image_comparison(['date_axhspan.png'], style='mpl20') def test_date_axhspan(): # test axhspan with date inputs t0 = datetime.datetime(2009, 1, 20) @@ -153,7 +153,7 @@ def test_date_axhspan(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['date_axvspan.png'], tol=0.07) +@image_comparison(['date_axvspan.png'], style='mpl20', tol=0.07) def test_date_axvspan(): # test axvspan with date inputs t0 = datetime.datetime(2000, 1, 20) @@ -165,7 +165,7 @@ def test_date_axvspan(): fig.autofmt_xdate() -@image_comparison(['date_axhline.png']) +@image_comparison(['date_axhline.png'], style='mpl20') def test_date_axhline(): # test axhline with date inputs t0 = datetime.datetime(2009, 1, 20) @@ -178,7 +178,7 @@ def test_date_axhline(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['date_axvline.png'], tol=0.09) +@image_comparison(['date_axvline.png'], style='mpl20', tol=0.09) def test_date_axvline(): # test axvline with date inputs t0 = datetime.datetime(2000, 1, 20) @@ -229,7 +229,7 @@ def wrapper(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['RRuleLocator_bounds.png'], tol=0.07) +@image_comparison(['RRuleLocator_bounds.png'], style='mpl20', tol=0.07) def test_RRuleLocator(): import matplotlib.testing.jpl_units as units units.register() @@ -274,7 +274,7 @@ def test_RRuleLocator_close_minmax(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['DateFormatter_fractionalSeconds.png'], tol=0.11) +@image_comparison(['DateFormatter_fractionalSeconds.png'], style='mpl20', tol=0.11) def test_DateFormatter(): import matplotlib.testing.jpl_units as units units.register() @@ -957,7 +957,7 @@ def _create_auto_date_locator(date1, date2, tz): assert st == expected -@image_comparison(['date_inverted_limit.png']) +@image_comparison(['date_inverted_limit.png'], style='mpl20') def test_date_inverted_limit(): # test ax hline with date inputs t0 = datetime.datetime(2009, 1, 20) diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index c0508a00d9c8..9f002b672b84 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -27,7 +27,7 @@ # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['figure_align_labels'], extensions=['png', 'svg'], +@image_comparison(['figure_align_labels'], extensions=['png', 'svg'], style='mpl20', tol=0.1 if platform.machine() == 'x86_64' else 0.1) def test_align_labels(): fig = plt.figure(layout='tight') @@ -210,8 +210,8 @@ def test_clf_keyword(): assert [t.get_text() for t in fig2.texts] == [] -@image_comparison(['figure_today.png'], - tol=0 if platform.machine() == 'x86_64' else 0.015) +@image_comparison(['figure_today.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.022) def test_figure(): # named figure support fig = plt.figure('today') @@ -226,7 +226,7 @@ def test_figure(): plt.close('tomorrow') -@image_comparison(['figure_legend.png']) +@image_comparison(['figure_legend.png'], style='mpl20') def test_figure_legend(): fig, axs = plt.subplots(2) axs[0].plot([0, 1], [1, 0], label='x', color='g') @@ -324,7 +324,7 @@ def test_add_subplot_invalid(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['figure_suptitle.png'], tol=0.02) +@image_comparison(['figure_suptitle.png'], style='mpl20', tol=0.02) def test_suptitle(): fig, _ = plt.subplots() fig.suptitle('hello', color='r') diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index a55d1051779b..8b44792a0c2d 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -993,7 +993,7 @@ def test_fallback_missing(recwarn, font_list): assert all([font in recwarn[0].message.args[0] for font in font_list]) -@image_comparison(['last_resort']) +@image_comparison(['last_resort'], style='mpl20') def test_fallback_last_resort(recwarn): fig = plt.figure(figsize=(3, 0.5)) fig.text(.5, .5, "Hello 🙃 World!", size=24, diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 649e345b3613..fcac9bacdd26 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -50,7 +50,7 @@ def test_alpha_interp(): @image_comparison(['interp_nearest_vs_none'], tol=3.7, # For Ghostscript 10.06+. - extensions=['pdf', 'svg'], remove_text=True) + extensions=['pdf', 'svg'], remove_text=True, style='mpl20') def test_interp_nearest_vs_none(): """Test the effect of "nearest" and "none" interpolation""" # Setting dpi to something really small makes the difference very diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 06a9d6cf8b63..4e4f021f2df6 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -54,7 +54,7 @@ def test_legend_generator(): ax.legend(handles, labels, loc='upper left') -@image_comparison(['legend_auto1.png'], remove_text=True) +@image_comparison(['legend_auto1.png'], remove_text=True, style='mpl20') def test_legend_auto1(): """Test automatic legend placement""" fig, ax = plt.subplots() @@ -64,7 +64,7 @@ def test_legend_auto1(): ax.legend(loc='best') -@image_comparison(['legend_auto2.png'], remove_text=True) +@image_comparison(['legend_auto2.png'], remove_text=True, style='mpl20') def test_legend_auto2(): """Test automatic legend placement""" fig, ax = plt.subplots() @@ -74,7 +74,7 @@ def test_legend_auto2(): ax.legend([b1[0], b2[0]], ['up', 'down'], loc='best') -@image_comparison(['legend_auto3.png']) +@image_comparison(['legend_auto3.png'], style='mpl20') def test_legend_auto3(): """Test automatic legend placement""" fig, ax = plt.subplots() @@ -140,7 +140,7 @@ def test_legend_auto5(): assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds) -@image_comparison(['legend_various_labels.png'], remove_text=True) +@image_comparison(['legend_various_labels.png'], remove_text=True, style='mpl20') def test_various_labels(): # tests all sorts of label types fig = plt.figure() @@ -151,8 +151,8 @@ def test_various_labels(): ax.legend(numpoints=1, loc='best') -@image_comparison(['legend_labels_first.png'], remove_text=True, - tol=0 if platform.machine() == 'x86_64' else 0.013) +@image_comparison(['legend_labels_first.png'], remove_text=True, style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.015) def test_labels_first(): # test labels to left of markers fig, ax = plt.subplots() @@ -162,8 +162,8 @@ def test_labels_first(): ax.legend(loc='best', markerfirst=False) -@image_comparison(['legend_multiple_keys.png'], remove_text=True, - tol=0 if platform.machine() == 'x86_64' else 0.013) +@image_comparison(['legend_multiple_keys.png'], remove_text=True, style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.033) def test_multiple_keys(): # test legend entries with multiple keys fig, ax = plt.subplots() @@ -176,16 +176,18 @@ def test_multiple_keys(): (p2, p1): HandlerTuple(ndivide=None, pad=0)}) -@image_comparison(['rgba_alpha.png'], remove_text=True, +@image_comparison(['rgba_alpha.png'], remove_text=True, style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.03) def test_alpha_rgba(): + # This rcParam would override the explicit setting below, so disable it. + plt.rcParams['legend.framealpha'] = None fig, ax = plt.subplots() ax.plot(range(10), lw=5) leg = plt.legend(['Longlabel that will go away'], loc='center') leg.legendPatch.set_facecolor([1, 0, 0, 0.5]) -@image_comparison(['rcparam_alpha.png'], remove_text=True, +@image_comparison(['rcparam_alpha.png'], remove_text=True, style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.03) def test_alpha_rcparam(): fig, ax = plt.subplots() @@ -199,7 +201,7 @@ def test_alpha_rcparam(): leg.legendPatch.set_facecolor([1, 0, 0, 0.5]) -@image_comparison(['fancy.png'], remove_text=True, tol=0.05) +@image_comparison(['fancy.png'], remove_text=True, style='mpl20', tol=0.05) def test_fancy(): # Tolerance caused by changing default shadow "shade" from 0.3 to 1 - 0.7 = # 0.30000000000000004 @@ -213,18 +215,20 @@ def test_fancy(): ncols=2, shadow=True, title="My legend", numpoints=1) -@image_comparison(['framealpha'], remove_text=True, +@image_comparison(['framealpha'], remove_text=True, style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.024) def test_framealpha(): x = np.linspace(1, 100, 100) y = x plt.plot(x, y, label='mylabel', lw=10) - plt.legend(framealpha=0.5) + plt.legend(framealpha=0.5, loc='upper right') -@image_comparison(['scatter_rc3.png', 'scatter_rc1.png'], remove_text=True) +@image_comparison(['scatter_rc3.png', 'scatter_rc1.png'], remove_text=True, + style='mpl20') def test_rc(): # using subplot triggers some offsetbox functionality untested elsewhere + mpl.rcParams['legend.scatterpoints'] = 3 plt.figure() ax = plt.subplot(121) ax.scatter(np.arange(10), np.arange(10, 0, -1), label='three') @@ -239,7 +243,7 @@ def test_rc(): title="My legend") -@image_comparison(['legend_expand.png'], remove_text=True) +@image_comparison(['legend_expand.png'], remove_text=True, style='mpl20') def test_legend_expand(): """Test expand mode""" legend_modes = [None, "expand"] @@ -525,7 +529,7 @@ def test_figure_legend_outside(): rtol=1e-4) -@image_comparison(['legend_stackplot.png'], +@image_comparison(['legend_stackplot.png'], style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.031) def test_legend_stackplot(): """Test legend for PolyCollection using stackplot.""" @@ -584,7 +588,7 @@ def test_legend_repeatcheckok(): assert len(lab) == 2 -@image_comparison(['not_covering_scatter.png']) +@image_comparison(['not_covering_scatter.png'], style='mpl20') def test_not_covering_scatter(): colors = ['b', 'g', 'r'] @@ -596,7 +600,7 @@ def test_not_covering_scatter(): plt.gca().set_ylim(-0.5, 2.2) -@image_comparison(['not_covering_scatter_transform.png']) +@image_comparison(['not_covering_scatter_transform.png'], style='mpl20') def test_not_covering_scatter_transform(): # Offsets point to top left, the default auto position offset = mtransforms.Affine2D().translate(-20, 20) diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 80dcc43894c4..12a12cf3e90d 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -241,7 +241,7 @@ def test_negative_rect(): assert_array_equal(np.roll(neg_vertices, 2, 0), pos_vertices) -@image_comparison(['clip_to_bbox.png']) +@image_comparison(['clip_to_bbox.png'], style='mpl20') def test_clip_to_bbox(): fig, ax = plt.subplots() ax.set_xlim([-18, 20]) @@ -550,7 +550,7 @@ def test_multi_color_hatch(): ax.add_patch(r) -@image_comparison(['units_rectangle.png']) +@image_comparison(['units_rectangle.png'], style='mpl20') def test_units_rectangle(): import matplotlib.testing.jpl_units as U U.register() @@ -813,7 +813,7 @@ def test_boxstyle_errors(fmt, match): BoxStyle(fmt) -@image_comparison(['annulus.png']) +@image_comparison(['annulus.png'], style='mpl20') def test_annulus(): fig, ax = plt.subplots() @@ -825,7 +825,7 @@ def test_annulus(): ax.set_aspect('equal') -@image_comparison(['annulus.png']) +@image_comparison(['annulus.png'], style='mpl20') def test_annulus_setters(): fig, ax = plt.subplots() @@ -846,7 +846,7 @@ def test_annulus_setters(): ell.angle = 45 -@image_comparison(['annulus.png']) +@image_comparison(['annulus.png'], style='mpl20') def test_annulus_setters2(): fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_patheffects.py b/lib/matplotlib/tests/test_patheffects.py index 0b99a954afb3..05110d5cc1cf 100644 --- a/lib/matplotlib/tests/test_patheffects.py +++ b/lib/matplotlib/tests/test_patheffects.py @@ -11,7 +11,7 @@ from matplotlib.patheffects import PathEffectRenderer -@image_comparison(['patheffect1'], remove_text=True) +@image_comparison(['patheffect1'], remove_text=True, style='mpl20') def test_patheffect1(): ax1 = plt.subplot() ax1.imshow([[1, 2], [2, 3]]) @@ -45,9 +45,10 @@ def test_patheffect2(): foreground="w")]) -@image_comparison(['patheffect3'], +@image_comparison(['patheffect3'], style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.019) def test_patheffect3(): + plt.figure(figsize=(8, 6)) p1, = plt.plot([1, 3, 5, 4, 3], 'o-b', lw=4) p1.set_path_effects([path_effects.SimpleLineShadow(), path_effects.Normal()]) @@ -74,7 +75,7 @@ def test_patheffect3(): t.set_path_effects(pe) -@image_comparison(['stroked_text.png']) +@image_comparison(['stroked_text.png'], style='mpl20') def test_patheffects_stroked_text(): text_chunks = [ 'A B C D E F G H I J K L', @@ -87,7 +88,7 @@ def test_patheffects_stroked_text(): ] font_size = 50 - ax = plt.axes((0, 0, 1, 1)) + ax = plt.figure(figsize=(8, 6)).add_axes((0, 0, 1, 1)) for i, chunk in enumerate(text_chunks): text = ax.text(x=0.01, y=(0.9 - i * 0.13), s=chunk, fontdict={'ha': 'left', 'va': 'center', @@ -186,7 +187,7 @@ def test_tickedstroke(text_placeholders): ax3.set_ylim(0, 4) -@image_comparison(['spaces_and_newlines.png'], remove_text=True) +@image_comparison(['spaces_and_newlines.png'], remove_text=True, style='mpl20') def test_patheffects_spaces_and_newlines(): ax = plt.subplot() s1 = " " diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 63d5c45308f1..efc9f52e7586 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -72,7 +72,7 @@ def test_polar_coord_annotations(): ax.set_ylim(-20, 20) -@image_comparison(['polar_alignment.png']) +@image_comparison(['polar_alignment.png'], style='mpl20') def test_polar_alignment(): # Test changing the vertical/horizontal alignment of a polar graph. angles = np.arange(0, 360, 90) diff --git a/lib/matplotlib/tests/test_quiver.py b/lib/matplotlib/tests/test_quiver.py index ef4d7a0598eb..4784a7e4dc42 100644 --- a/lib/matplotlib/tests/test_quiver.py +++ b/lib/matplotlib/tests/test_quiver.py @@ -101,16 +101,16 @@ def test_zero_headlength(): fig.canvas.draw() # Check that no warning is emitted. -@image_comparison(['quiver_animated_test_image.png']) +@image_comparison(['quiver_animated_test_image.png'], style='mpl20') def test_quiver_animate(): # Tests fix for #2616 fig, ax = plt.subplots() Q = draw_quiver(ax, animated=True) - ax.quiverkey(Q, 0.5, 0.92, 2, r'$2 \frac{m}{s}$', + ax.quiverkey(Q, 0.5, 0.88, 2, r'$2 \frac{m}{s}$', labelpos='W', fontproperties={'weight': 'bold'}) -@image_comparison(['quiver_with_key_test_image.png']) +@image_comparison(['quiver_with_key_test_image.png'], style='mpl20') def test_quiver_with_key(): fig, ax = plt.subplots() ax.margins(0.1) @@ -138,7 +138,7 @@ def test_quiver_copy(): assert q0.V[0] == 2.0 -@image_comparison(['quiver_key_pivot.png'], remove_text=True) +@image_comparison(['quiver_key_pivot.png'], remove_text=True, style='mpl20') def test_quiver_key_pivot(): fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_simplification.py b/lib/matplotlib/tests/test_simplification.py index 98d3728b1d34..6b2e5b4cd301 100644 --- a/lib/matplotlib/tests/test_simplification.py +++ b/lib/matplotlib/tests/test_simplification.py @@ -493,7 +493,7 @@ def test_para_equal_perp(): ax.plot(x + 1, y + 1, 'ro') -@image_comparison(['clipping_with_nans']) +@image_comparison(['clipping_with_nans'], style='mpl20') def test_clipping_with_nans(): x = np.linspace(0, 3.14 * 2, 3000) y = np.sin(x) diff --git a/lib/matplotlib/tests/test_spines.py b/lib/matplotlib/tests/test_spines.py index 5aecf6c2ad55..4945c53d904d 100644 --- a/lib/matplotlib/tests/test_spines.py +++ b/lib/matplotlib/tests/test_spines.py @@ -55,7 +55,7 @@ def set_val(self, val): spines['top':] -@image_comparison(['spines_axes_positions.png']) +@image_comparison(['spines_axes_positions.png'], style='mpl20') def test_spines_axes_positions(): # SF bug 2852168 fig = plt.figure() @@ -72,7 +72,7 @@ def test_spines_axes_positions(): ax.spines.bottom.set_color('none') -@image_comparison(['spines_data_positions.png']) +@image_comparison(['spines_data_positions.png'], style='mpl20') def test_spines_data_positions(): fig, ax = plt.subplots() ax.spines.left.set_position(('data', -1.5)) @@ -81,6 +81,8 @@ def test_spines_data_positions(): ax.spines.bottom.set_position('zero') ax.set_xlim([-2, 2]) ax.set_ylim([-2, 2]) + ax.xaxis.set_ticks_position('both') + ax.yaxis.set_ticks_position('both') @check_figures_equal() diff --git a/lib/matplotlib/tests/test_subplots.py b/lib/matplotlib/tests/test_subplots.py index 0f00a88aa72d..ed07b0226bc8 100644 --- a/lib/matplotlib/tests/test_subplots.py +++ b/lib/matplotlib/tests/test_subplots.py @@ -180,11 +180,11 @@ def test_exceptions(): plt.subplots(2, 2, sharey='blah') -@image_comparison(['subplots_offset_text.png'], +@image_comparison(['subplots_offset_text.png'], style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.028) def test_subplots_offsettext(): x = np.arange(0, 1e10, 1e9) - y = np.arange(0, 100, 10)+1e4 + y = np.arange(0, 100, 10)+1e5 fig, axs = plt.subplots(2, 2, sharex='col', sharey='all') axs[0, 0].plot(x, x) axs[1, 0].plot(x, x) diff --git a/lib/matplotlib/tests/test_table.py b/lib/matplotlib/tests/test_table.py index 43b8702737a6..304e69322f81 100644 --- a/lib/matplotlib/tests/test_table.py +++ b/lib/matplotlib/tests/test_table.py @@ -17,7 +17,7 @@ def test_non_square(): plt.table(cellColours=cellcolors) -@image_comparison(['table_zorder.png'], remove_text=True) +@image_comparison(['table_zorder.png'], remove_text=True, style='mpl20') def test_zorder(): data = [[66386, 174296], [58230, 381139]] @@ -50,7 +50,7 @@ def test_zorder(): plt.yticks([]) -@image_comparison(['table_labels.png']) +@image_comparison(['table_labels.png'], style='mpl20') def test_label_colours(): dim = 3 @@ -123,7 +123,7 @@ def test_customcell(): assert c == code -@image_comparison(['table_auto_column.png']) +@image_comparison(['table_auto_column.png'], style='mpl20') def test_auto_column(): fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 2027e863a7cf..7b2afcf19791 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -26,7 +26,7 @@ pyparsing_version = parse_version(pyparsing.__version__) -@image_comparison(['font_styles']) +@image_comparison(['font_styles'], style='mpl20') def test_font_styles(): def find_matplotlib_font(**kw): @@ -115,7 +115,7 @@ def find_matplotlib_font(**kw): ax.set_yticks([]) -@image_comparison(['complex'], extensions=['png', 'pdf', 'svg', 'eps']) +@image_comparison(['complex'], extensions=['png', 'pdf', 'svg', 'eps'], style='mpl20') def test_complex_shaping(): # Raqm is Arabic for writing; note that because Arabic is RTL, the characters here # may seem to be in a different order than expected, but libraqm will order them @@ -135,7 +135,7 @@ def test_complex_shaping(): family=['cmr10', 'DejaVu Sans Display', 'DejaVu Sans']) -@image_comparison(['multiline']) +@image_comparison(['multiline'], style='mpl20') def test_multiline(): plt.figure() ax = plt.subplot(1, 1, 1) @@ -229,9 +229,9 @@ def test_antialiasing(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['text_contains.png'], tol=0.05) +@image_comparison(['text_contains.png'], style='mpl20', tol=0.05) def test_contains(): - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) ax = plt.axes() mevent = MouseEvent('button_press_event', fig.canvas, 0.5, 0.5, 1, None) @@ -287,7 +287,7 @@ def test_annotate_errors(err, xycoords, match): fig.canvas.draw() -@image_comparison(['titles']) +@image_comparison(['titles'], style='mpl20') def test_titles(): # left and right side titles plt.figure() @@ -339,7 +339,7 @@ def test_rotation_mode_anchor(): verticalalignment='center_baseline') -@image_comparison(['axes_titles.png']) +@image_comparison(['axes_titles.png'], style='mpl20') def test_axes_titles(): # Related to issue #3327 plt.figure() @@ -465,14 +465,14 @@ def test_null_rotation_with_rotation_mode(ha, va): t1.get_window_extent(fig.canvas.renderer).get_points()) -@image_comparison(['text_bboxclip']) +@image_comparison(['text_bboxclip'], style='mpl20') def test_bbox_clipping(): plt.text(0.9, 0.2, 'Is bbox clipped?', backgroundcolor='r', clip_on=True) t = plt.text(0.9, 0.5, 'Is fancy bbox clipped?', clip_on=True) t.set_bbox({"boxstyle": "round, pad=0.1"}) -@image_comparison(['annotation_negative_ax_coords.png']) +@image_comparison(['annotation_negative_ax_coords.png'], style='mpl20') def test_annotation_negative_ax_coords(): fig, ax = plt.subplots() @@ -500,31 +500,31 @@ def test_annotation_negative_ax_coords(): va='top') -@image_comparison(['annotation_negative_fig_coords.png']) +@image_comparison(['annotation_negative_fig_coords.png'], style='mpl20') def test_annotation_negative_fig_coords(): fig, ax = plt.subplots() ax.annotate('+ pts', - xytext=[10, 120], textcoords='figure points', - xy=[10, 120], xycoords='figure points', fontsize=32) + xytext=[10, 250], textcoords='figure points', + xy=[10, 250], xycoords='figure points', fontsize=32) ax.annotate('- pts', - xytext=[-10, 180], textcoords='figure points', - xy=[-10, 180], xycoords='figure points', fontsize=32, + xytext=[-10, 310], textcoords='figure points', + xy=[-10, 310], xycoords='figure points', fontsize=32, va='top') ax.annotate('+ frac', - xytext=[0.05, 0.55], textcoords='figure fraction', - xy=[0.05, 0.55], xycoords='figure fraction', fontsize=32) + xytext=[0.05, 0.5], textcoords='figure fraction', + xy=[0.05, 0.5], xycoords='figure fraction', fontsize=32) ax.annotate('- frac', - xytext=[-0.05, 0.5], textcoords='figure fraction', - xy=[-0.05, 0.5], xycoords='figure fraction', fontsize=32, + xytext=[-0.05, 0.45], textcoords='figure fraction', + xy=[-0.05, 0.45], xycoords='figure fraction', fontsize=32, va='top') ax.annotate('+ pixels', xytext=[50, 50], textcoords='figure pixels', xy=[50, 50], xycoords='figure pixels', fontsize=32) ax.annotate('- pixels', - xytext=[-50, 100], textcoords='figure pixels', - xy=[-50, 100], xycoords='figure pixels', fontsize=32, + xytext=[-50, 150], textcoords='figure pixels', + xy=[-50, 150], xycoords='figure pixels', fontsize=32, va='top') @@ -551,7 +551,7 @@ def test_text_stale(): assert not fig.stale -@image_comparison(['agg_text_clip.png']) +@image_comparison(['agg_text_clip.png'], style='mpl20') def test_agg_text_clip(): np.random.seed(1) fig, (ax1, ax2) = plt.subplots(2) @@ -569,7 +569,7 @@ def test_text_size_binding(): assert sz1 == fp.get_size_in_points() -@image_comparison(['font_scaling.pdf']) +@image_comparison(['font_scaling.pdf'], style='mpl20') def test_font_scaling(): mpl.rcParams['pdf.fonttype'] = 42 fig, ax = plt.subplots(figsize=(6.4, 12.4)) @@ -1142,9 +1142,9 @@ def test_empty_annotation_get_window_extent(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['basictext_wrap.png'], tol=0.3) +@image_comparison(['basictext_wrap.png'], style='mpl20', tol=0.3) def test_basic_wrap(): - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) plt.axis([0, 10, 0, 10]) t = "This is a really long string that I'd rather have wrapped so that" \ " it doesn't go outside of the figure, but if it's long enough it" \ @@ -1159,9 +1159,9 @@ def test_basic_wrap(): # TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['fonttext_wrap.png'], tol=0.3) +@image_comparison(['fonttext_wrap.png'], style='mpl20', tol=0.3) def test_font_wrap(): - fig = plt.figure() + fig = plt.figure(figsize=(8, 6)) plt.axis([0, 10, 0, 10]) t = "This is a really long string that I'd rather have wrapped so that" \ " it doesn't go outside of the figure, but if it's long enough it" \ diff --git a/lib/matplotlib/tests/test_triangulation.py b/lib/matplotlib/tests/test_triangulation.py index ae065a231fd9..c9187915b5a2 100644 --- a/lib/matplotlib/tests/test_triangulation.py +++ b/lib/matplotlib/tests/test_triangulation.py @@ -232,7 +232,7 @@ def tris_contain_point(triang, xy): triang = mtri.Triangulation(tri_points[1:, 0], tri_points[1:, 1]) -@image_comparison(['tripcolor1.png']) +@image_comparison(['tripcolor1.png'], style='mpl20') def test_tripcolor(): x = np.asarray([0, 0.5, 1, 0, 0.5, 1, 0, 0.5, 1, 0.75]) y = np.asarray([0, 0, 0, 0.5, 0.5, 0.5, 1, 1, 1, 0.75]) diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py index 78d9fd6cc948..440716db2a66 100644 --- a/lib/matplotlib/tests/test_usetex.py +++ b/lib/matplotlib/tests/test_usetex.py @@ -66,7 +66,7 @@ def test_mathdefault(): fig.canvas.draw() -@image_comparison(['eqnarray.png']) +@image_comparison(['eqnarray.png'], style='mpl20') def test_multiline_eqnarray(): text = ( r'\begin{eqnarray*}' 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 3ee8e36ecedc..fb46dcb1900b 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -61,16 +61,14 @@ def test_divider_append_axes(): assert bboxes["top"].x1 == bboxes["main"].x1 == bboxes["bottom"].x1 -# Update style when regenerating the test image -@image_comparison(['twin_axes_empty_and_removed.png'], tol=1, - style=('classic', '_classic_test_patch')) +@image_comparison(['twin_axes_empty_and_removed.png'], tol=1, style='mpl20') def test_twin_axes_empty_and_removed(): # Purely cosmetic font changes (avoid overlap) - mpl.rcParams.update( - {"font.size": 8, "xtick.labelsize": 8, "ytick.labelsize": 8}) + mpl.rcParams.update({"font.size": 8, "xtick.labelsize": 8, "ytick.labelsize": 8}) generators = ["twinx", "twiny", "twin"] modifiers = ["", "host invisible", "twin removed", "twin invisible", "twin removed\nhost invisible"] + plt.figure(figsize=(8, 6)) # Unmodified host subplot at the beginning for reference h = host_subplot(len(modifiers)+1, len(generators), 2) h.text(0.5, 0.5, "host_subplot", @@ -343,10 +341,8 @@ def test_fill_facecolor(): mark_inset(ax[3], axins, loc1=2, loc2=4, fc="g", ec="0.5", fill=False) -# Update style when regenerating the test image -@image_comparison(['zoomed_axes.png', 'inverted_zoomed_axes.png'], - style=('classic', '_classic_test_patch'), - tol=0 if platform.machine() == 'x86_64' else 0.02) +@image_comparison(['zoomed_axes.png', 'inverted_zoomed_axes.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.03) def test_zooming_with_inverted_axes(): fig, ax = plt.subplots() ax.plot([1, 2, 3], [1, 2, 3]) @@ -361,10 +357,9 @@ def test_zooming_with_inverted_axes(): inset_ax.axis([1.4, 1.1, 1.4, 1.1]) -# Update style when regenerating the test image @image_comparison(['anchored_direction_arrows.png'], tol=0 if platform.machine() == 'x86_64' else 0.01, - style=('classic', '_classic_test_patch')) + style='mpl20') def test_anchored_direction_arrows(): fig, ax = plt.subplots() ax.imshow(np.zeros((10, 10)), interpolation='nearest') @@ -373,9 +368,7 @@ def test_anchored_direction_arrows(): ax.add_artist(simple_arrow) -# Update style when regenerating the test image -@image_comparison(['anchored_direction_arrows_many_args.png'], - style=('classic', '_classic_test_patch')) +@image_comparison(['anchored_direction_arrows_many_args.png'], style='mpl20') def test_anchored_direction_arrows_many_args(): fig, ax = plt.subplots() ax.imshow(np.ones((10, 10))) From bca638de43112b45297956a824f1fe2283d80d6b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 13 Mar 2026 21:45:44 -0400 Subject: [PATCH 108/108] TST: Reset tolerances on tests changed by text overhaul --- lib/matplotlib/tests/test_axes.py | 64 +++++++------------ lib/matplotlib/tests/test_colorbar.py | 6 +- lib/matplotlib/tests/test_colors.py | 2 +- lib/matplotlib/tests/test_contour.py | 11 ++-- lib/matplotlib/tests/test_dates.py | 12 ++-- lib/matplotlib/tests/test_figure.py | 15 ++--- lib/matplotlib/tests/test_image.py | 9 ++- lib/matplotlib/tests/test_legend.py | 13 ++-- lib/matplotlib/tests/test_patheffects.py | 6 +- lib/matplotlib/tests/test_polar.py | 13 ++-- lib/matplotlib/tests/test_text.py | 23 ++----- lib/matplotlib/tests/test_units.py | 6 +- lib/matplotlib/tests/test_usetex.py | 3 +- .../axes_grid1/tests/test_axes_grid1.py | 11 ++-- .../axisartist/tests/test_axis_artist.py | 9 +-- .../tests/test_grid_helper_curvelinear.py | 5 +- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 23 +++---- .../mplot3d/tests/test_legend3d.py | 4 +- 18 files changed, 95 insertions(+), 140 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 315722b8fd36..2504407767dc 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -239,9 +239,8 @@ def test_matshow(fig_test, fig_ref): ax_ref.xaxis.set_ticks_position('both') -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison([f'formatter_ticker_{i:03d}.png' for i in range(1, 6)], style='mpl20', - tol=0.02 if platform.machine() == 'x86_64' else 0.04) + tol=0.03 if sys.platform == 'darwin' else 0) def test_formatter_ticker(): import matplotlib.testing.jpl_units as units units.register() @@ -811,8 +810,7 @@ def test_annotate_signature(): assert p1 == p2 -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['fill_units.png'], savefig_kwarg={'dpi': 60}, style='mpl20', tol=0.2) +@image_comparison(['fill_units.png'], savefig_kwarg={'dpi': 60}, style='mpl20') def test_fill_units(): import matplotlib.testing.jpl_units as units units.register() @@ -951,7 +949,7 @@ def test_axvspan_epoch(): ax.set_xlim(t0 - 5.0*dt, tf + 5.0*dt) -@image_comparison(['axhspan_epoch.png'], style='mpl20', tol=0.02) +@image_comparison(['axhspan_epoch.png'], style='mpl20') def test_axhspan_epoch(): import matplotlib.testing.jpl_units as units units.register() @@ -1515,8 +1513,7 @@ def test_pcolormesh_log_scale(fig_test, fig_ref): ax.set_xscale('log') -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['pcolormesh_datetime_axis.png'], style='mpl20', tol=0.3) +@image_comparison(['pcolormesh_datetime_axis.png'], style='mpl20') def test_pcolormesh_datetime_axis(): fig = plt.figure() fig.subplots_adjust(hspace=0.4, top=0.98, bottom=.15) @@ -1541,8 +1538,7 @@ def test_pcolormesh_datetime_axis(): label.set_rotation(30) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['pcolor_datetime_axis.png'], style='mpl20', tol=0.3) +@image_comparison(['pcolor_datetime_axis.png'], style='mpl20') def test_pcolor_datetime_axis(): fig = plt.figure() fig.subplots_adjust(hspace=0.4, top=0.98, bottom=.15) @@ -1852,12 +1848,8 @@ def test_markevery(): ax.legend() -@image_comparison(['markevery_line.png'], remove_text=True, style='mpl20', tol=0.005) +@image_comparison(['markevery_line.png'], remove_text=True, style='mpl20') def test_markevery_line(): - # TODO: a slight change in rendering between Inkscape versions may explain - # why one had to introduce a small non-zero tolerance for the SVG test - # to pass. One may try to remove this hack once Travis' Inkscape version - # is modern enough. FWIW, no failure with 0.92.3 on my computer (#11358). x = np.linspace(0, 10, 100) y = np.sin(x) * np.sqrt(x/10 + 0.5) @@ -2778,8 +2770,7 @@ def test_stairs_options(): ax.legend(loc=0) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['test_stairs_datetime.png'], style='mpl20', tol=0.2) +@image_comparison(['test_stairs_datetime.png'], style='mpl20') def test_stairs_datetime(): f, ax = plt.subplots(constrained_layout=True) ax.stairs(np.arange(36), @@ -3403,8 +3394,7 @@ def test_log_scales_invalid(): @image_comparison(['stackplot_test_image.png', 'stackplot_test_image.png'], - style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.031) + style='mpl20') def test_stackplot(): fig = plt.figure() x = np.linspace(0, 10, 10) @@ -3565,10 +3555,7 @@ def test_bxp_horizontal(): _bxp_test_helper(bxp_kwargs=dict(orientation='horizontal')) -@image_comparison(['bxp_with_ylabels.png'], - savefig_kwarg={'dpi': 40}, - style='default', - tol=0.1) +@image_comparison(['bxp_with_ylabels.png'], savefig_kwarg={'dpi': 40}, style='default') def test_bxp_with_ylabels(): def transform(stats): for s, label in zip(stats, list('ABCD')): @@ -3769,7 +3756,7 @@ def test_bxp_bad_capwidths(): _bxp_test_helper(bxp_kwargs=dict(capwidths=[1])) -@image_comparison(['boxplot.png', 'boxplot.png'], tol=1.28, style='default') +@image_comparison(['boxplot.png', 'boxplot.png'], tol=0.43, style='default') def test_boxplot(): # Randomness used for bootstrapping. np.random.seed(937) @@ -5551,7 +5538,7 @@ def test_marker_styles(): @image_comparison(['rc_markerfill.png'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.037) + tol=0.033 if sys.platform == 'darwin' else 0) def test_markers_fillstyle_rcparams(): fig, ax = plt.subplots() x = np.arange(7) @@ -5574,7 +5561,7 @@ def test_vertex_markers(): @image_comparison(['vline_hline_zorder.png', 'errorbar_zorder.png'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.026) + tol=0.02 if sys.platform == 'darwin' else 0) def test_eb_line_zorder(): x = list(range(10)) @@ -6487,12 +6474,7 @@ def test_text_labelsize(): ax.tick_params(direction='out') -# Note: The `pie` image tests were affected by Numpy 2.0 changing promotions -# (NEP 50). While the changes were only marginal, tolerances were introduced. -# These tolerances could likely go away when numpy 2.0 is the minimum supported -# numpy and the images are regenerated. - -@image_comparison(['pie_default.png'], style='mpl20', tol=0.01) +@image_comparison(['pie_default.png'], style='mpl20') def test_pie_default(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6505,7 +6487,7 @@ def test_pie_default(): @image_comparison(['pie_linewidth_0.png', 'pie_linewidth_0.png', 'pie_linewidth_0.png'], - style='mpl20', tol=0.01) + style='mpl20') def test_pie_linewidth_0(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6537,7 +6519,8 @@ def test_pie_linewidth_0(): plt.axis('equal') -@image_comparison(['pie_center_radius.png'], style='mpl20', tol=0.011) +@image_comparison(['pie_center_radius.png'], style='mpl20', + tol=0.01 if sys.platform == 'darwin' else 0) def test_pie_center_radius(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6557,7 +6540,7 @@ def test_pie_center_radius(): plt.axis('equal') -@image_comparison(['pie_linewidth_2.png'], style='mpl20', tol=0.01) +@image_comparison(['pie_linewidth_2.png'], style='mpl20') def test_pie_linewidth_2(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6572,7 +6555,7 @@ def test_pie_linewidth_2(): plt.axis('equal') -@image_comparison(['pie_ccw_true.png'], style='mpl20', tol=0.01) +@image_comparison(['pie_ccw_true.png'], style='mpl20') def test_pie_ccw_true(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6587,7 +6570,7 @@ def test_pie_ccw_true(): plt.axis('equal') -@image_comparison(['pie_frame_grid.png'], style='mpl20', tol=0.002) +@image_comparison(['pie_frame_grid.png'], style='mpl20') def test_pie_frame_grid(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6614,8 +6597,7 @@ def test_pie_frame_grid(): plt.axis('equal') -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['pie_rotatelabels_true.png'], style='mpl20', tol=0.1) +@image_comparison(['pie_rotatelabels_true.png'], style='mpl20') def test_pie_rotatelabels_true(): # The slices will be ordered and plotted counter-clockwise. labels = 'Hogwarts', 'Frogs', 'Dogs', 'Logs' @@ -6630,7 +6612,7 @@ def test_pie_rotatelabels_true(): plt.axis('equal') -@image_comparison(['pie_no_label.png'], style='mpl20', tol=0.01) +@image_comparison(['pie_no_label.png'], style='mpl20') def test_pie_nolabel_but_legend(): labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' sizes = [15, 30, 45, 10] @@ -8356,7 +8338,7 @@ def inverted(self): @image_comparison(['secondary_xy.png'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.027) + tol=0 if platform.machine() == 'x86_64' else 0.024) def test_secondary_xy(): fig, axs = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True) @@ -9648,7 +9630,7 @@ def test_zorder_and_explicit_rasterization(): @image_comparison(["preset_clip_paths.png"], remove_text=True, style="mpl20", - tol=0 if platform.machine() == 'x86_64' else 0.027) + tol=0.01 if sys.platform == 'darwin' else 0) def test_preset_clip_paths(): fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 4fd943a3e7a4..0991221f1339 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -152,14 +152,13 @@ def test_colorbar_extension_inverted_axis(orientation, extend, expected): assert len(cbar._extend_patches) == 1 -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @pytest.mark.parametrize('use_gridspec', [True, False]) @image_comparison(['cbar_with_orientation.png', 'cbar_locationing.png', 'double_cbar.png', 'cbar_sharing.png', ], - remove_text=True, savefig_kwarg={'dpi': 40}, style='mpl20', tol=0.05) + remove_text=True, savefig_kwarg={'dpi': 40}, style='mpl20') def test_colorbar_positioning(use_gridspec): data = np.arange(1200).reshape(30, 40) levels = [0, 200, 400, 600, 800, 1000, 1200] @@ -728,8 +727,7 @@ def test_colorbar_label(): assert cbar3.ax.get_xlabel() == 'horizontal cbar' -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['colorbar_keeping_xlabel.png'], style='mpl20', tol=0.03) +@image_comparison(['colorbar_keeping_xlabel.png'], style='mpl20') def test_keeping_xlabel(): # github issue #23398 - xlabels being ignored in colorbar axis arr = np.arange(25).reshape((5, 5)) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 1d3868320743..808770e1d52c 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -844,7 +844,7 @@ def test_cmap_and_norm_from_levels_and_colors(): ax.tick_params(labelleft=False, labelbottom=False) -@image_comparison(['boundarynorm_and_colorbar.png'], tol=1.0) +@image_comparison(['boundarynorm_and_colorbar.png']) def test_boundarynorm_and_colorbarbase(): # Make a figure and axes with dimensions as desired. fig = plt.figure() diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index 42ad75862b2e..5c2674355d10 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -94,7 +94,7 @@ def test_contour_set_paths(fig_test, fig_ref): cs_test.set_paths(cs_ref.get_paths()) -@image_comparison(['contour_manual_labels'], remove_text=True, style='mpl20', tol=0.26) +@image_comparison(['contour_manual_labels'], remove_text=True, style='mpl20') def test_contour_manual_labels(): x, y = np.meshgrid(np.arange(0, 10), np.arange(0, 10)) z = np.max(np.dstack([abs(x), abs(y)]), 2) @@ -127,9 +127,8 @@ def test_contour_manual_moveto(): assert clabels[0].get_text() == "0" -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['contour_disconnected_segments.png'], - remove_text=True, style='mpl20', tol=0.01) + remove_text=True, style='mpl20') def test_contour_label_with_disconnected_segments(): x, y = np.mgrid[-1:1:21j, -1:1:21j] z = 1 / np.sqrt(0.01 + (x + 0.3) ** 2 + y ** 2) @@ -229,8 +228,7 @@ def test_lognorm_levels(n_levels): assert len(visible_levels) <= n_levels + 1 -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['contour_datetime_axis.png'], style='mpl20', tol=0.3) +@image_comparison(['contour_datetime_axis.png'], style='mpl20') def test_contour_datetime_axis(): fig = plt.figure() fig.subplots_adjust(hspace=0.4, top=0.98, bottom=.15) @@ -256,7 +254,8 @@ def test_contour_datetime_axis(): @image_comparison(['contour_test_label_transforms.png'], - remove_text=True, style='mpl20', tol=1.1) + remove_text=True, style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.005) def test_labels(): # Adapted from pylab_examples example code: contour_demo.py # see issues #2475, #2843, and #2818 for explanation diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index c456e3aca089..45948ea1e7f0 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -152,8 +152,7 @@ def test_date_axhspan(): fig.subplots_adjust(left=0.25) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['date_axvspan.png'], style='mpl20', tol=0.07) +@image_comparison(['date_axvspan.png'], style='mpl20') def test_date_axvspan(): # test axvspan with date inputs t0 = datetime.datetime(2000, 1, 20) @@ -177,8 +176,7 @@ def test_date_axhline(): fig.subplots_adjust(left=0.25) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['date_axvline.png'], style='mpl20', tol=0.09) +@image_comparison(['date_axvline.png'], style='mpl20') def test_date_axvline(): # test axvline with date inputs t0 = datetime.datetime(2000, 1, 20) @@ -228,8 +226,7 @@ def wrapper(): return wrapper -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['RRuleLocator_bounds.png'], style='mpl20', tol=0.07) +@image_comparison(['RRuleLocator_bounds.png'], style='mpl20') def test_RRuleLocator(): import matplotlib.testing.jpl_units as units units.register() @@ -273,8 +270,7 @@ def test_RRuleLocator_close_minmax(): assert list(map(str, mdates.num2date(loc.tick_values(d1, d2)))) == expected -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['DateFormatter_fractionalSeconds.png'], style='mpl20', tol=0.11) +@image_comparison(['DateFormatter_fractionalSeconds.png'], style='mpl20') def test_DateFormatter(): import matplotlib.testing.jpl_units as units units.register() diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 9f002b672b84..0e318cab0d4f 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -26,9 +26,8 @@ import matplotlib.dates as mdates -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['figure_align_labels'], extensions=['png', 'svg'], style='mpl20', - tol=0.1 if platform.machine() == 'x86_64' else 0.1) + tol=0 if platform.machine() == 'x86_64' else 0.01) def test_align_labels(): fig = plt.figure(layout='tight') gs = gridspec.GridSpec(3, 3) @@ -68,11 +67,9 @@ def test_align_labels(): fig.align_labels() -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['figure_align_titles_tight.png', 'figure_align_titles_constrained.png'], - tol=0.3 if platform.machine() == 'x86_64' else 0.04, - style='mpl20') + style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.021) def test_align_titles(): for layout in ['tight', 'constrained']: fig, axs = plt.subplots(1, 2, layout=layout, width_ratios=[2, 1]) @@ -323,8 +320,7 @@ def test_add_subplot_invalid(): fig.add_subplot(ax) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['figure_suptitle.png'], style='mpl20', tol=0.02) +@image_comparison(['figure_suptitle.png'], style='mpl20') def test_suptitle(): fig, _ = plt.subplots() fig.suptitle('hello', color='r') @@ -1401,7 +1397,7 @@ def test_subfigure_dpi(): @image_comparison(['test_subfigure_ss.png'], style='mpl20', savefig_kwarg={'facecolor': 'teal'}, - tol=0.022) + tol=0.022 if sys.platform == 'darwin' else 0) def test_subfigure_ss(): # test assigning the subfigure via subplotspec np.random.seed(19680801) @@ -1423,9 +1419,8 @@ def test_subfigure_ss(): fig.suptitle('Figure suptitle', fontsize='xx-large') -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['test_subfigure_double.png'], style='mpl20', - savefig_kwarg={'facecolor': 'teal'}, tol=0.02) + savefig_kwarg={'facecolor': 'teal'}) def test_subfigure_double(): # test assigning the subfigure via subplotspec np.random.seed(19680801) diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index fcac9bacdd26..78dacfd72907 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -1487,7 +1487,7 @@ def test_nonuniform_logscale(): ax.add_image(im) -@image_comparison(['rgba_antialias.png'], style='mpl20', remove_text=True, tol=0.02) +@image_comparison(['rgba_antialias.png'], style='mpl20', remove_text=True) def test_rgba_antialias(): fig, axs = plt.subplots(2, 2, figsize=(3.5, 3.5), sharex=False, sharey=False, constrained_layout=True) @@ -1741,8 +1741,8 @@ def test_non_transdata_image_does_not_touch_aspect(): assert ax.get_aspect() == 2 -@image_comparison( - ['downsampling.png'], style='mpl20', remove_text=True, tol=0.09) +@image_comparison(['downsampling.png'], style='mpl20', remove_text=True, + tol=0 if platform.machine() == 'x86_64' else 0.07) def test_downsampling(): N = 450 x = np.arange(N) / N - 0.5 @@ -1776,8 +1776,7 @@ def test_downsampling(): ax.set_title(f"interpolation='{interp}'\nspace='{space}'") -@image_comparison( - ['downsampling_speckle.png'], style='mpl20', remove_text=True, tol=0.09) +@image_comparison(['downsampling_speckle.png'], style='mpl20', remove_text=True) def test_downsampling_speckle(): fig, axs = plt.subplots(1, 2, figsize=(5, 2.7), sharex=True, sharey=True, layout="compressed") diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 4e4f021f2df6..fe9405bcbdae 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -2,6 +2,7 @@ import io import itertools import platform +import sys import time from unittest import mock import warnings @@ -201,10 +202,9 @@ def test_alpha_rcparam(): leg.legendPatch.set_facecolor([1, 0, 0, 0.5]) -@image_comparison(['fancy.png'], remove_text=True, style='mpl20', tol=0.05) +@image_comparison(['fancy.png'], remove_text=True, style='mpl20', + tol=0.01 if sys.platform == 'darwin' else 0) def test_fancy(): - # Tolerance caused by changing default shadow "shade" from 0.3 to 1 - 0.7 = - # 0.30000000000000004 # using subplot triggers some offsetbox functionality untested elsewhere plt.subplot(121) plt.plot([5] * 10, 'o--', label='XX') @@ -216,7 +216,7 @@ def test_fancy(): @image_comparison(['framealpha'], remove_text=True, style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.024) + tol=0 if platform.machine() == 'x86_64' else 0.021) def test_framealpha(): x = np.linspace(1, 100, 100) y = x @@ -529,8 +529,7 @@ def test_figure_legend_outside(): rtol=1e-4) -@image_comparison(['legend_stackplot.png'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.031) +@image_comparison(['legend_stackplot.png'], style='mpl20') def test_legend_stackplot(): """Test legend for PolyCollection using stackplot.""" # related to #1341, #1943, and PR #3303 @@ -666,7 +665,7 @@ def test_empty_bar_chart_with_legend(): @image_comparison(['shadow_argument_types.png'], remove_text=True, style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.028) + tol=0.028 if sys.platform == 'darwin' else 0) def test_shadow_argument_types(): # Test that different arguments for shadow work as expected fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_patheffects.py b/lib/matplotlib/tests/test_patheffects.py index 05110d5cc1cf..7095f6b3855b 100644 --- a/lib/matplotlib/tests/test_patheffects.py +++ b/lib/matplotlib/tests/test_patheffects.py @@ -1,4 +1,4 @@ -import platform +import sys import numpy as np @@ -30,7 +30,7 @@ def test_patheffect1(): @image_comparison(['patheffect2'], remove_text=True, style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.06) + tol=0.051 if sys.platform == 'darwin' else 0) def test_patheffect2(): ax2 = plt.subplot() @@ -46,7 +46,7 @@ def test_patheffect2(): @image_comparison(['patheffect3'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.019) + tol=0.02 if sys.platform == 'darwin' else 0) def test_patheffect3(): plt.figure(figsize=(8, 6)) p1, = plt.plot([1, 3, 5, 4, 3], 'o-b', lw=4) diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index efc9f52e7586..a805fb61d238 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -1,3 +1,5 @@ +import sys + import numpy as np from numpy.testing import assert_allclose import pytest @@ -9,7 +11,8 @@ import matplotlib.ticker as mticker -@image_comparison(['polar_axes.png'], style='default', tol=0.012) +@image_comparison(['polar_axes.png'], style='default', + tol=0.009 if sys.platform == 'darwin' else 0) def test_polar_annotations(): # You can specify the xypoint and the xytext in different positions and # coordinate systems, and optionally turn on a connecting line and mark the @@ -44,7 +47,7 @@ def test_polar_annotations(): @image_comparison(['polar_coords.png'], style='default', remove_text=True, - tol=0.014) + tol=0.013 if sys.platform == 'darwin' else 0) def test_polar_coord_annotations(): # You can also use polar notation on a cartesian axes. Here the native # coordinate system ('data') is cartesian, so you need to specify the @@ -214,8 +217,7 @@ def test_polar_theta_position(): ax.set_theta_direction('clockwise') -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['polar_rlabel_position.png'], style='default', tol=0.07) +@image_comparison(['polar_rlabel_position.png'], style='default') def test_polar_rlabel_position(): fig = plt.figure() ax = fig.add_subplot(projection='polar') @@ -230,8 +232,7 @@ def test_polar_title_position(): ax.set_title('foo') -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['polar_theta_wedge.png'], style='default', tol=0.2) +@image_comparison(['polar_theta_wedge.png'], style='default') def test_polar_theta_limits(): r = np.arange(0, 3.0, 0.01) theta = 2*np.pi*r diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 7b2afcf19791..c1cd22a9a2f3 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -159,8 +159,7 @@ def test_multiline(): ax.set_yticks([]) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['multiline2'], style='mpl20', tol=0.05) +@image_comparison(['multiline2'], style='mpl20') def test_multiline2(): fig, ax = plt.subplots() @@ -228,8 +227,7 @@ def test_antialiasing(): mpl.rcParams['text.antialiased'] = False # Should not affect existing text. -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['text_contains.png'], style='mpl20', tol=0.05) +@image_comparison(['text_contains.png'], style='mpl20') def test_contains(): fig = plt.figure(figsize=(8, 6)) ax = plt.axes() @@ -298,8 +296,7 @@ def test_titles(): ax.set_yticks([]) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['text_alignment'], style='mpl20', tol=0.08) +@image_comparison(['text_alignment'], style='mpl20') def test_alignment(): plt.figure() ax = plt.subplot(1, 1, 1) @@ -1141,8 +1138,7 @@ def test_empty_annotation_get_window_extent(): assert points[0, 1] == 50.0 -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['basictext_wrap.png'], style='mpl20', tol=0.3) +@image_comparison(['basictext_wrap.png'], style='mpl20') def test_basic_wrap(): fig = plt.figure(figsize=(8, 6)) plt.axis([0, 10, 0, 10]) @@ -1158,8 +1154,7 @@ def test_basic_wrap(): plt.text(-1, 0, t, ha='left', rotation=-15, wrap=True) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['fonttext_wrap.png'], style='mpl20', tol=0.3) +@image_comparison(['fonttext_wrap.png'], style='mpl20') def test_font_wrap(): fig = plt.figure(figsize=(8, 6)) plt.axis([0, 10, 0, 10]) @@ -1191,9 +1186,7 @@ def test_va_for_angle(): assert alignment in ['center', 'top', 'baseline'] -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['xtick_rotation_mode.png'], remove_text=False, style='mpl20', - tol=0.3) +@image_comparison(['xtick_rotation_mode.png'], remove_text=False, style='mpl20') def test_xtick_rotation_mode(): fig, ax = plt.subplots(figsize=(12, 1)) ax.set_yticks([]) @@ -1212,9 +1205,7 @@ def test_xtick_rotation_mode(): plt.subplots_adjust(left=0.01, right=0.99, top=.6, bottom=.4) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['ytick_rotation_mode.png'], remove_text=False, style='mpl20', - tol=0.3) +@image_comparison(['ytick_rotation_mode.png'], remove_text=False, style='mpl20') def test_ytick_rotation_mode(): fig, ax = plt.subplots(figsize=(1, 12)) ax.set_xticks([]) diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py index c13c54a101fc..d2350667e94f 100644 --- a/lib/matplotlib/tests/test_units.py +++ b/lib/matplotlib/tests/test_units.py @@ -80,9 +80,8 @@ def default_units(value, axis): # Tests that the conversion machinery works properly for classes that # work as a facade over numpy arrays (like pint) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['plot_pint.png'], style='mpl20', - tol=0.03 if platform.machine() == 'x86_64' else 0.04) + tol=0 if platform.machine() == 'x86_64' else 0.03) def test_numpy_facade(quantity_converter): # use former defaults to match existing baseline image plt.rcParams['axes.formatter.limits'] = -7, 7 @@ -143,9 +142,8 @@ def test_jpl_bar_units(): ax.set_ylim([b - 1 * day, b + w[-1] + (1.001) * day]) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['jpl_barh_units.png'], - savefig_kwarg={'dpi': 120}, style='mpl20', tol=0.02) + savefig_kwarg={'dpi': 120}, style='mpl20') def test_jpl_barh_units(): import matplotlib.testing.jpl_units as units units.register() diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py index 440716db2a66..87277f152789 100644 --- a/lib/matplotlib/tests/test_usetex.py +++ b/lib/matplotlib/tests/test_usetex.py @@ -226,9 +226,8 @@ def test_pdf_type1_font_subsetting(): _old_gs_version = True -# TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(baseline_images=['rotation'], extensions=['eps', 'pdf', 'png', 'svg'], - style='mpl20', tol=3.91 if _old_gs_version else 0.2) + style='mpl20', tol=3.91 if _old_gs_version else 0) def test_rotation(): mpl.rcParams['text.usetex'] = True 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 fb46dcb1900b..5a6a229f3c59 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -1,6 +1,7 @@ from itertools import product import io import platform +import sys import matplotlib as mpl import matplotlib.pyplot as plt @@ -61,7 +62,7 @@ def test_divider_append_axes(): assert bboxes["top"].x1 == bboxes["main"].x1 == bboxes["bottom"].x1 -@image_comparison(['twin_axes_empty_and_removed.png'], tol=1, style='mpl20') +@image_comparison(['twin_axes_empty_and_removed.png'], style='mpl20') def test_twin_axes_empty_and_removed(): # Purely cosmetic font changes (avoid overlap) mpl.rcParams.update({"font.size": 8, "xtick.labelsize": 8, "ytick.labelsize": 8}) @@ -357,9 +358,8 @@ def test_zooming_with_inverted_axes(): inset_ax.axis([1.4, 1.1, 1.4, 1.1]) -@image_comparison(['anchored_direction_arrows.png'], - tol=0 if platform.machine() == 'x86_64' else 0.01, - style='mpl20') +@image_comparison(['anchored_direction_arrows.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.006) def test_anchored_direction_arrows(): fig, ax = plt.subplots() ax.imshow(np.zeros((10, 10)), interpolation='nearest') @@ -368,7 +368,8 @@ def test_anchored_direction_arrows(): ax.add_artist(simple_arrow) -@image_comparison(['anchored_direction_arrows_many_args.png'], style='mpl20') +@image_comparison(['anchored_direction_arrows_many_args.png'], style='mpl20', + tol=0.002 if sys.platform == 'win32' else 0) def test_anchored_direction_arrows_many_args(): fig, ax = plt.subplots() ax.imshow(np.ones((10, 10))) diff --git a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py index 1e50d71b5876..8c67b18c0349 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py @@ -24,8 +24,7 @@ def test_ticks(): ax.add_artist(ticks_out) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['axis_artist_labelbase.png'], style='default', tol=0.02) +@image_comparison(['axis_artist_labelbase.png'], style='default') def test_labelbase(): fig, ax = plt.subplots() @@ -39,8 +38,7 @@ def test_labelbase(): ax.add_artist(label) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['axis_artist_ticklabels.png'], style='default', tol=0.03) +@image_comparison(['axis_artist_ticklabels.png'], style='default') def test_ticklabels(): fig, ax = plt.subplots() @@ -72,8 +70,7 @@ def test_ticklabels(): ax.set_ylim(0, 1) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['axis_artist.png'], style='default', tol=0.03) +@image_comparison(['axis_artist.png'], style='default') def test_axis_artist(): fig, ax = plt.subplots() 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 62feaee4279a..ab1eedd9b797 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -1,3 +1,5 @@ +import platform + import numpy as np import matplotlib.pyplot as plt @@ -15,7 +17,8 @@ GridHelperCurveLinear -@image_comparison(['custom_transform.png'], style='mpl20', tol=0.2) +@image_comparison(['custom_transform.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.04) def test_custom_transform(): plt.rcParams.update({"xtick.direction": "in", "ytick.direction": "inout"}) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index b1910f7259b2..ac0168ce775e 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -117,7 +117,7 @@ def test_axes3d_repr(): @mpl3d_image_comparison(['axes3d_primary_views.png'], style='mpl20', - tol=0.05 if sys.platform == "darwin" else 0) + tol=0.045 if sys.platform == 'darwin' else 0) def test_axes3d_primary_views(): # (elev, azim, roll) views = [(90, -90, 0), # XY @@ -647,8 +647,7 @@ def test_surface3d(): fig.colorbar(surf, shrink=0.5, aspect=5) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20', tol=0.07) +@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20') def test_surface3d_label_offset_tick_position(): ax = plt.figure().add_subplot(projection="3d") @@ -743,8 +742,7 @@ def test_surface3d_masked_strides(): ax.view_init(60, -45, 0) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20', tol=0.1) +@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20') def test_text3d(): fig = plt.figure() ax = fig.add_subplot(projection='3d') @@ -1123,9 +1121,7 @@ def test_poly3dCollection_autoscaling(): assert np.allclose(ax.get_zlim3d(), (-0.0833333333333333, 4.083333333333333)) -# TODO: tighten tolerance after baseline image is regenerated for text overhaul -@mpl3d_image_comparison(['axes3d_labelpad.png'], - remove_text=False, style='mpl20', tol=0.06) +@mpl3d_image_comparison(['axes3d_labelpad.png'], remove_text=False, style='mpl20') def test_axes3d_labelpad(): fig = plt.figure() ax = fig.add_axes(Axes3D(fig)) @@ -1500,8 +1496,8 @@ def test_alpha(self): assert voxels[coord], "faces returned for absent voxel" assert isinstance(poly, art3d.Poly3DCollection) - @mpl3d_image_comparison(['voxels-xyz.png'], - tol=0.01, remove_text=False, style='mpl20') + @mpl3d_image_comparison(['voxels-xyz.png'], remove_text=False, style='mpl20', + tol=0.002 if sys.platform == 'win32' else 0) def test_xyz(self): fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) @@ -1714,7 +1710,7 @@ def test_errorbar3d_errorevery(): @mpl3d_image_comparison(['errorbar3d.png'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.02) + tol=0.015 if sys.platform == 'darwin' else 0) def test_errorbar3d(): """Tests limits, color styling, and legend for 3D errorbars.""" fig = plt.figure() @@ -1730,7 +1726,8 @@ def test_errorbar3d(): ax.legend() -@image_comparison(['stem3d.png'], style='mpl20', tol=0.009) +@image_comparison(['stem3d.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.008) def test_stem3d(): fig, axs = plt.subplots(2, 3, figsize=(8, 6), constrained_layout=True, @@ -2876,7 +2873,7 @@ def _make_triangulation_data(): @mpl3d_image_comparison(['scale3d_artists_log.png'], style='mpl20', - remove_text=False, tol=0.032) + remove_text=False, tol=0.016) def test_scale3d_artists_log(): """Test all 3D artist types with log scale.""" fig = plt.figure(figsize=(16, 12)) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py index 9ca048e18ba9..a46c958222d8 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -1,4 +1,4 @@ -import platform +import sys import numpy as np @@ -28,7 +28,7 @@ def test_legend_bar(): @image_comparison(['fancy.png'], remove_text=True, style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.011) + tol=0.01 if sys.platform == 'darwin' else 0) def test_fancy(): fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) ax.plot(np.arange(10), np.full(10, 5), np.full(10, 5), 'o--', label='line')