diff --git a/doc/release/next_whats_new/mathtext_font_fallback.rst b/doc/release/next_whats_new/mathtext_font_fallback.rst new file mode 100644 index 000000000000..208399be64ac --- /dev/null +++ b/doc/release/next_whats_new/mathtext_font_fallback.rst @@ -0,0 +1,9 @@ +Font fallback in mathtext +------------------------- + +Mathematical text (``mathtext`` and ``$...$`` strings) now falls back to other +fonts when a character is missing from the selected math font, mirroring the +behavior of regular text. Previously, characters absent from the math fonts -- +such as CJK characters -- were replaced by a dummy glyph; they now render using +the configured fonts and their fallback chain (with the Last Resort font as a +final fallback), just like regular text. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 9f23e5e3ab08..409dd90628a8 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -32,7 +32,7 @@ from . import cbook from ._mathtext_data import ( latex_to_bakoma, stix_glyph_fixes, stix_virtual_fonts, tex2uni) -from .font_manager import FontProperties, findfont, get_font +from .font_manager import FontProperties, findfont, fontManager, get_font from .ft2font import FT2Font, Kerning, LoadFlags @@ -683,6 +683,19 @@ def _get_glyph(self, fontname: str, font_class: str, if (fontname in ('it', 'regular', 'normal') and isinstance(self, StixFonts)): return self._get_glyph('rm', font_class, sym) + # Before substituting a dummy glyph, fall back through the + # configured text fonts and their fallback chain (system fonts + # and the Last Resort font), mirroring the regular-text path, so + # characters the math fonts lack -- such as CJK characters -- + # still render. See + # https://github.com/matplotlib/matplotlib/issues/29173. + text_font = get_font( + fontManager._find_fonts_by_props(self.default_font_prop)) + for fallback_font in [text_font, *text_font._fallbacks]: + if fallback_font.get_char_index(uniindex, _fallback=False): + _log.info("Substituting symbol %a from font %s.", + sym, fallback_font.family_name) + return fallback_font, uniindex, False _log.warning("Font %r does not have a glyph for %a [U+%x], " "substituting with a dummy symbol.", new_fontname, sym, uniindex) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index ea47811a1822..674163901221 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -249,13 +249,17 @@ class FT2Font(Buffer): features: tuple[str, ...] | None = ..., language: str | tuple[tuple[str, int, int], ...] | None = ..., ) -> list[LayoutItem]: ... + @property + def _fallbacks(self) -> list[FT2Font]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, antialiased: bool = ... ) -> None: ... def draw_glyphs_to_bitmap(self, antialiased: bool = ...) -> None: ... def get_bitmap_offset(self) -> tuple[int, int]: ... - def get_char_index(self, codepoint: CharacterCodeType) -> GlyphIndexType: ... + def get_char_index( + self, codepoint: CharacterCodeType, *, _fallback: bool = ... + ) -> GlyphIndexType: ... def get_charmap(self) -> dict[CharacterCodeType, GlyphIndexType]: ... def get_descent(self) -> int: ... def get_glyph_name(self, index: GlyphIndexType) -> str: ... diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index a8f9236b5208..02274886b562 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -474,6 +474,20 @@ def test_mathtext_fallback(fallback, fontlist): mpl.font_manager.fontManager.ttflist.pop() +@pytest.mark.parametrize("char", ['中', '한', 'あ']) # Chinese, Korean, Japanese +def test_mathtext_cjk_fallback(char): + # Characters that the math fonts lack -- such as CJK characters -- should + # fall back through the font's fallback chain (mirroring regular text) + # rather than being replaced by a dummy glyph. + # https://github.com/matplotlib/matplotlib/issues/29173 + fonts = _mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING) + font, num, _ = fonts._get_glyph('rm', 'normal', char) + # The original code point is preserved (not the 0xA4 dummy currency sign)... + assert num == ord(char) + # ... and the returned font actually contains a glyph for it. + assert font.get_char_index(num, _fallback=False) != 0 + + def test_math_to_image(tmp_path): mathtext.math_to_image('$x^2$', tmp_path / 'example.png') mathtext.math_to_image('$x^2$', io.BytesIO()) diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 771f1db5a191..9012a1467133 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -1922,6 +1922,9 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def_property_readonly( "fname", &PyFT2Font_fname, "The original filename for this object.") + .def_property_readonly( + "_fallbacks", [](PyFT2Font *self) { return self->fallbacks; }, + "The fallback fonts used to find glyphs missing from this font.") .def_buffer([](PyFT2Font &self) -> py::buffer_info { return self.get_image().request();