Skip to content

Commit 4efa3c0

Browse files
authored
Merge pull request matplotlib#31183 from Cemonix/fix-font-alt-family-names
ENH: Allow fonts to be addressed by any of their SFNT family names
2 parents 67fb6b9 + 716796e commit 4efa3c0

4 files changed

Lines changed: 247 additions & 10 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Fonts addressable by all their SFNT family names
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
Fonts can now be selected by any of the family names they advertise in
5+
the OpenType name table, not just the one FreeType reports as the primary
6+
family name.
7+
8+
Some fonts store different family names on different platforms or in
9+
different name-table entries. For example, Ubuntu Light stores
10+
``"Ubuntu"`` in the Macintosh-platform Name ID 1 slot (which FreeType
11+
uses as the primary name) and ``"Ubuntu Light"`` in the Microsoft-platform
12+
Name ID 1 slot. Previously only the FreeType-derived name was registered,
13+
requiring an obscure weight-based workaround::
14+
15+
# Previously required
16+
matplotlib.rcParams['font.family'] = 'Ubuntu'
17+
matplotlib.rcParams['font.weight'] = 300
18+
19+
All name-table entries that describe a family — Name ID 1 on both
20+
platforms, the Typographic Family (Name ID 16), and the WWS Family
21+
(Name ID 21) — are now registered as separate entries in the
22+
`~matplotlib.font_manager.FontManager`, so any of those names can be
23+
used directly::
24+
25+
matplotlib.rcParams['font.family'] = 'Ubuntu Light'

lib/matplotlib/font_manager.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,82 @@ def get_weight(): # From fontconfig's FcFreeTypeQueryFaceInternal.
532532
style, variant, weight, stretch, size)
533533

534534

535+
def _get_font_alt_names(font, primary_name):
536+
"""
537+
Return ``(name, weight)`` pairs for alternate family names of *font*.
538+
539+
A font file can advertise its family name in several places. FreeType
540+
exposes ``font.family_name``, which is typically derived from the
541+
Macintosh-platform Name ID 1 entry. However, other entries may carry
542+
different (equally valid) names that users reasonably expect to work:
543+
544+
- **Name ID 1, other platform** — some fonts store a different family name
545+
on the Microsoft platform than on the Macintosh platform.
546+
- **Name ID 16** — "Typographic Family" (a.k.a. preferred family): groups
547+
more than the traditional four styles under one name.
548+
- **Name ID 21** — "WWS Family": an even narrower grouping used by some
549+
fonts (weight/width/slope only).
550+
551+
Each name is paired with a weight derived from the corresponding subfamily
552+
entry on the *same* platform. This ensures that the weight of the alternate entry
553+
reflects the font's role *within that named family* rather than its absolute
554+
typographic weight.
555+
556+
Parameters
557+
----------
558+
font : `.FT2Font`
559+
primary_name : str
560+
The family name already extracted from the font (``font.family_name``).
561+
562+
Returns
563+
-------
564+
list of (str, int)
565+
``(alternate_family_name, weight)`` pairs, not including *primary_name*.
566+
"""
567+
try:
568+
sfnt = font.get_sfnt()
569+
except ValueError:
570+
return []
571+
572+
mac_key = (1, # platform: macintosh
573+
0, # id: roman
574+
0) # langid: english
575+
ms_key = (3, # platform: microsoft
576+
1, # id: unicode_cs
577+
0x0409) # langid: english_united_states
578+
579+
seen = {primary_name}
580+
result = []
581+
582+
def _weight_from_subfam(subfam):
583+
subfam = subfam.replace(" ", "")
584+
for regex, weight in _weight_regexes:
585+
if re.search(regex, subfam, re.I):
586+
return weight
587+
return 400 # "Regular" or unrecognised
588+
589+
def _try_add(name, subfam):
590+
name = name.strip()
591+
if not name or name in seen:
592+
return
593+
seen.add(name)
594+
result.append((name, _weight_from_subfam(subfam.strip())))
595+
596+
# Each family-name ID is paired with its corresponding subfamily ID on the
597+
# same platform: (family_id, subfamily_id).
598+
for fam_id, subfam_id in ((1, 2), (16, 17), (21, 22)):
599+
_try_add(
600+
sfnt.get((*mac_key, fam_id), b'').decode('latin-1'),
601+
sfnt.get((*mac_key, subfam_id), b'').decode('latin-1'),
602+
)
603+
_try_add(
604+
sfnt.get((*ms_key, fam_id), b'').decode('utf-16-be'),
605+
sfnt.get((*ms_key, subfam_id), b'').decode('utf-16-be'),
606+
)
607+
608+
return result
609+
610+
535611
def afmFontProperty(fontpath, font):
536612
"""
537613
Extract information from an AFM font file.
@@ -1131,7 +1207,7 @@ class FontManager:
11311207
# Increment this version number whenever the font cache data
11321208
# format or behavior has changed and requires an existing font
11331209
# cache files to be rebuilt.
1134-
__version__ = '3.11.0a3'
1210+
__version__ = '3.11.0a4'
11351211

11361212
def __init__(self, size=None, weight='normal'):
11371213
self._version = self.__version__
@@ -1196,10 +1272,18 @@ def addfont(self, path):
11961272
font = ft2font.FT2Font(path)
11971273
prop = ttfFontProperty(font)
11981274
self.ttflist.append(prop)
1275+
for alt_name, alt_weight in _get_font_alt_names(font, prop.name):
1276+
self.ttflist.append(
1277+
dataclasses.replace(prop, name=alt_name, weight=alt_weight))
1278+
11991279
for face_index in range(1, font.num_faces):
12001280
subfont = ft2font.FT2Font(path, face_index=face_index)
12011281
prop = ttfFontProperty(subfont)
12021282
self.ttflist.append(prop)
1283+
for alt_name, alt_weight in _get_font_alt_names(subfont, prop.name):
1284+
self.ttflist.append(
1285+
dataclasses.replace(prop, name=alt_name, weight=alt_weight))
1286+
12031287
self._findfont_cached.cache_clear()
12041288

12051289
@property

lib/matplotlib/font_manager.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def get_fontext_synonyms(fontext: str) -> list[str]: ...
2323
def list_fonts(directory: str, extensions: Iterable[str]) -> list[str]: ...
2424
def win32FontDirectory() -> str: ...
2525
def _get_fontconfig_fonts() -> list[Path]: ...
26+
def _get_font_alt_names(
27+
font: ft2font.FT2Font, primary_name: str
28+
) -> list[tuple[str, int]]: ...
2629
def findSystemFonts(
2730
fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ...
2831
) -> list[str]: ...

lib/matplotlib/tests/test_font_manager.py

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
import numpy as np
1212
import pytest
1313

14+
from unittest.mock import MagicMock, patch
15+
1416
import matplotlib as mpl
17+
import matplotlib.font_manager as fm_mod
1518
from matplotlib.font_manager import (
1619
findfont, findSystemFonts, FontEntry, FontPath, FontProperties, fontManager,
1720
json_dump, json_load, get_font, is_opentype_cff_font,
18-
MSUserFontDirectories, ttfFontProperty,
21+
MSUserFontDirectories, ttfFontProperty, _get_font_alt_names,
1922
_get_fontconfig_fonts, _normalize_weight)
2023
from matplotlib import cbook, ft2font, pyplot as plt, rc_context, figure as mfigure
2124
from matplotlib.testing import subprocess_run_helper, subprocess_run_for_testing
@@ -400,23 +403,145 @@ def test_get_font_names():
400403
paths_mpl = [cbook._get_data_path('fonts', subdir) for subdir in ['ttf']]
401404
fonts_mpl = findSystemFonts(paths_mpl, fontext='ttf')
402405
fonts_system = findSystemFonts(fontext='ttf')
403-
ttf_fonts = []
406+
ttf_fonts = set()
404407
for path in fonts_mpl + fonts_system:
405408
try:
406409
font = ft2font.FT2Font(path)
407410
prop = ttfFontProperty(font)
408-
ttf_fonts.append(prop.name)
411+
ttf_fonts.add(prop.name)
409412
for face_index in range(1, font.num_faces):
410413
font = ft2font.FT2Font(path, face_index=face_index)
411414
prop = ttfFontProperty(font)
412-
ttf_fonts.append(prop.name)
415+
ttf_fonts.add(prop.name)
413416
except Exception:
414417
pass
415-
available_fonts = sorted(list(set(ttf_fonts)))
416-
mpl_font_names = sorted(fontManager.get_font_names())
417-
assert set(available_fonts) == set(mpl_font_names)
418-
assert len(available_fonts) == len(mpl_font_names)
419-
assert available_fonts == mpl_font_names
418+
# fontManager may contain additional entries for alternative family names
419+
# (e.g. typographic family, platform-specific Name ID 1) registered by
420+
# addfont(), so primary names must be a subset of the manager's names.
421+
assert ttf_fonts <= set(fontManager.get_font_names())
422+
423+
424+
def test_addfont_alternative_names(tmp_path):
425+
"""
426+
Fonts that advertise different family names across platforms or name IDs
427+
should be registered under all of those names so users can address the font
428+
by any of them.
429+
430+
Two real-world patterns are covered:
431+
432+
- **MS platform ID 1 differs from Mac platform ID 1** (e.g. Ubuntu Light):
433+
FreeType returns the Mac ID 1 value as ``family_name``; the MS ID 1
434+
value ("Ubuntu Light") is an equally valid name that users expect to work.
435+
- **Name ID 16 (Typographic Family) differs from ID 1** (older fonts):
436+
some fonts store a broader family name in ID 16.
437+
"""
438+
mac_key = (1, 0, 0)
439+
ms_key = (3, 1, 0x0409)
440+
441+
# Case 1: MS ID1 differs from Mac ID1 (Ubuntu Light pattern)
442+
# Mac ID1="Test Family" → FreeType family_name (primary)
443+
# MS ID1="Test Family Light" → alternate name users expect to work
444+
ubuntu_style_sfnt = {
445+
(*mac_key, 1): "Test Family".encode("latin-1"),
446+
(*ms_key, 1): "Test Family Light".encode("utf-16-be"),
447+
(*mac_key, 2): "Light".encode("latin-1"),
448+
(*ms_key, 2): "Regular".encode("utf-16-be"),
449+
}
450+
fake_font = MagicMock()
451+
fake_font.get_sfnt.return_value = ubuntu_style_sfnt
452+
453+
assert _get_font_alt_names(fake_font, "Test Family") == [("Test Family Light", 400)]
454+
assert _get_font_alt_names(fake_font, "Test Family Light") == [
455+
("Test Family", 300)]
456+
457+
# Case 2: ID 16 differs from ID 1 (older typographic-family pattern)
458+
# ID 17 (typographic subfamily) is absent → defaults to weight 400
459+
id16_sfnt = {
460+
(*mac_key, 1): "Test Family".encode("latin-1"),
461+
(*ms_key, 1): "Test Family".encode("utf-16-be"),
462+
(*ms_key, 16): "Test Family Light".encode("utf-16-be"),
463+
}
464+
fake_font_id16 = MagicMock()
465+
fake_font_id16.get_sfnt.return_value = id16_sfnt
466+
467+
assert _get_font_alt_names(
468+
fake_font_id16, "Test Family"
469+
) == [("Test Family Light", 400)]
470+
471+
# Case 3: all entries agree → no alternates
472+
same_sfnt = {
473+
(*mac_key, 1): "Test Family".encode("latin-1"),
474+
(*ms_key, 1): "Test Family".encode("utf-16-be"),
475+
}
476+
fake_font_same = MagicMock()
477+
fake_font_same.get_sfnt.return_value = same_sfnt
478+
assert _get_font_alt_names(fake_font_same, "Test Family") == []
479+
480+
# Case 4: get_sfnt() raises ValueError (e.g. non-SFNT font) → empty list
481+
fake_font_no_sfnt = MagicMock()
482+
fake_font_no_sfnt.get_sfnt.side_effect = ValueError
483+
assert _get_font_alt_names(fake_font_no_sfnt, "Test Family") == []
484+
485+
fake_path = str(tmp_path / "fake.ttf")
486+
primary_entry = FontEntry(fname=fake_path, name="Test Family",
487+
style="normal", variant="normal",
488+
weight=300, stretch="normal", size="scalable")
489+
490+
with patch("matplotlib.font_manager.ft2font.FT2Font",
491+
return_value=fake_font), \
492+
patch("matplotlib.font_manager.ttfFontProperty",
493+
return_value=primary_entry):
494+
fm_instance = fm_mod.FontManager.__new__(fm_mod.FontManager)
495+
fm_instance.ttflist = []
496+
fm_instance.afmlist = []
497+
fm_instance._findfont_cached = MagicMock()
498+
fm_instance._findfont_cached.cache_clear = MagicMock()
499+
fm_instance.addfont(fake_path)
500+
501+
names = [e.name for e in fm_instance.ttflist]
502+
assert names == ["Test Family", "Test Family Light"]
503+
alt_entry = fm_instance.ttflist[1]
504+
assert alt_entry.weight == 400
505+
assert alt_entry.style == primary_entry.style
506+
assert alt_entry.fname == primary_entry.fname
507+
508+
509+
@pytest.mark.parametrize("subfam,expected", [
510+
("Thin", 100),
511+
("ExtraLight", 200),
512+
("UltraLight", 200),
513+
("DemiLight", 350),
514+
("SemiLight", 350),
515+
("Light", 300),
516+
("Book", 380),
517+
("Regular", 400),
518+
("Normal", 400),
519+
("Medium", 500),
520+
("DemiBold", 600),
521+
("Demi", 600),
522+
("SemiBold", 600),
523+
("ExtraBold", 800),
524+
("SuperBold", 800),
525+
("UltraBold", 800),
526+
("Bold", 700),
527+
("UltraBlack", 1000),
528+
("SuperBlack", 1000),
529+
("ExtraBlack", 1000),
530+
("Ultra", 1000),
531+
("Black", 900),
532+
("Heavy", 900),
533+
("", 400), # fallback: unrecognised → regular
534+
])
535+
def test_alt_name_weight_from_subfamily(subfam, expected):
536+
"""_get_font_alt_names derives weight from the paired subfamily string."""
537+
ms_key = (3, 1, 0x0409)
538+
fake_font = MagicMock()
539+
fake_font.get_sfnt.return_value = {
540+
(*ms_key, 1): "Family Alt".encode("utf-16-be"),
541+
(*ms_key, 2): subfam.encode("utf-16-be"),
542+
}
543+
result = _get_font_alt_names(fake_font, "Family")
544+
assert result == [("Family Alt", expected)]
420545

421546

422547
def test_donot_cache_tracebacks():

0 commit comments

Comments
 (0)