From 3374e25236545aa95d029e0e3e6c9acca682a06b Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 21 Feb 2026 10:58:35 +0100 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 716796e7b8566813ca4e58f3f9b12198344b6421 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 20 Mar 2026 23:38:10 +0100 Subject: [PATCH 4/4] 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__