Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions doc/release/next_whats_new/font_alt_family_names.rst
Original file line number Diff line number Diff line change
@@ -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'
86 changes: 85 additions & 1 deletion lib/matplotlib/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1131,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__
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lib/matplotlib/font_manager.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...
Expand Down
143 changes: 134 additions & 9 deletions lib/matplotlib/tests/test_font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
Loading