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

Skip to content

Commit f428011

Browse files
committed
ENH: Register all SFNT family names so fonts are addressable by any platform name
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
1 parent 54d3856 commit f428011

3 files changed

Lines changed: 177 additions & 8 deletions

File tree

lib/matplotlib/font_manager.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,82 @@ def get_weight(): # From fontconfig's FcFreeTypeQueryFaceInternal.
468468
return FontEntry(font.fname, name, style, variant, weight, stretch, size)
469469

470470

471+
def _get_font_alt_names(font, primary_name):
472+
"""
473+
Return ``(name, weight)`` pairs for alternate family names of *font*.
474+
475+
A font file can advertise its family name in several places. FreeType
476+
exposes ``font.family_name``, which is typically derived from the
477+
Macintosh-platform Name ID 1 entry. However, other entries may carry
478+
different (equally valid) names that users reasonably expect to work:
479+
480+
- **Name ID 1, other platform** — some fonts store a different family name
481+
on the Microsoft platform than on the Macintosh platform.
482+
- **Name ID 16** — "Typographic Family" (a.k.a. preferred family): groups
483+
more than the traditional four styles under one name.
484+
- **Name ID 21** — "WWS Family": an even narrower grouping used by some
485+
fonts (weight/width/slope only).
486+
487+
Each name is paired with a weight derived from the corresponding subfamily
488+
entry on the *same* platform. This ensures that the weight of the alternate entry
489+
reflects the font's role *within that named family* rather than its absolute
490+
typographic weight.
491+
492+
Parameters
493+
----------
494+
font : `.FT2Font`
495+
primary_name : str
496+
The family name already extracted from the font (``font.family_name``).
497+
498+
Returns
499+
-------
500+
list of (str, int)
501+
``(alternate_family_name, weight)`` pairs, not including *primary_name*.
502+
"""
503+
try:
504+
sfnt = font.get_sfnt()
505+
except ValueError:
506+
return []
507+
508+
mac_key = (1, # platform: macintosh
509+
0, # id: roman
510+
0) # langid: english
511+
ms_key = (3, # platform: microsoft
512+
1, # id: unicode_cs
513+
0x0409) # langid: english_united_states
514+
515+
seen = {primary_name}
516+
result = []
517+
518+
def _weight_from_subfam(subfam):
519+
subfam = subfam.replace(" ", "")
520+
for regex, weight in _weight_regexes:
521+
if re.search(regex, subfam, re.I):
522+
return weight
523+
return 400 # "Regular" or unrecognised
524+
525+
def _try_add(name, subfam):
526+
name = name.strip()
527+
if not name or name in seen:
528+
return
529+
seen.add(name)
530+
result.append((name, _weight_from_subfam(subfam.strip())))
531+
532+
# Each family-name ID is paired with its corresponding subfamily ID on the
533+
# same platform: (family_id, subfamily_id).
534+
for fam_id, subfam_id in ((1, 2), (16, 17), (21, 22)):
535+
_try_add(
536+
sfnt.get((*mac_key, fam_id), b'').decode('latin-1'),
537+
sfnt.get((*mac_key, subfam_id), b'').decode('latin-1'),
538+
)
539+
_try_add(
540+
sfnt.get((*ms_key, fam_id), b'').decode('utf_16_be'),
541+
sfnt.get((*ms_key, subfam_id), b'').decode('utf_16_be'),
542+
)
543+
544+
return result
545+
546+
471547
def afmFontProperty(fontpath, font):
472548
"""
473549
Extract information from an AFM font file.
@@ -1134,6 +1210,9 @@ def addfont(self, path):
11341210
font = ft2font.FT2Font(path)
11351211
prop = ttfFontProperty(font)
11361212
self.ttflist.append(prop)
1213+
for alt_name, alt_weight in _get_font_alt_names(font, prop.name):
1214+
self.ttflist.append(
1215+
dataclasses.replace(prop, name=alt_name, weight=alt_weight))
11371216
self._findfont_cached.cache_clear()
11381217

11391218
@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 | Path] | None = ..., fontext: str = ...
2831
) -> list[str]: ...

lib/matplotlib/tests/test_font_manager.py

Lines changed: 95 additions & 8 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, 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
@@ -334,19 +337,103 @@ def test_get_font_names():
334337
paths_mpl = [cbook._get_data_path('fonts', subdir) for subdir in ['ttf']]
335338
fonts_mpl = findSystemFonts(paths_mpl, fontext='ttf')
336339
fonts_system = findSystemFonts(fontext='ttf')
337-
ttf_fonts = []
340+
ttf_fonts = set()
338341
for path in fonts_mpl + fonts_system:
339342
try:
340343
font = ft2font.FT2Font(path)
341344
prop = ttfFontProperty(font)
342-
ttf_fonts.append(prop.name)
345+
ttf_fonts.add(prop.name)
343346
except Exception:
344347
pass
345-
available_fonts = sorted(list(set(ttf_fonts)))
346-
mpl_font_names = sorted(fontManager.get_font_names())
347-
assert set(available_fonts) == set(mpl_font_names)
348-
assert len(available_fonts) == len(mpl_font_names)
349-
assert available_fonts == mpl_font_names
348+
# fontManager may contain additional entries for alternative family names
349+
# (e.g. typographic family, platform-specific Name ID 1) registered by
350+
# addfont(), so primary names must be a subset of the manager's names.
351+
assert ttf_fonts <= set(fontManager.get_font_names())
352+
353+
354+
def test_addfont_alternative_names(tmp_path):
355+
"""
356+
Fonts that advertise different family names across platforms or name IDs
357+
should be registered under all of those names so users can address the font
358+
by any of them.
359+
360+
Two real-world patterns are covered:
361+
362+
- **MS platform ID 1 differs from Mac platform ID 1** (e.g. Ubuntu Light):
363+
FreeType returns the Mac ID 1 value as ``family_name``; the MS ID 1
364+
value ("Ubuntu Light") is an equally valid name that users expect to work.
365+
- **Name ID 16 (Typographic Family) differs from ID 1** (older fonts):
366+
some fonts store a broader family name in ID 16.
367+
"""
368+
mac_key = (1, 0, 0)
369+
ms_key = (3, 1, 0x0409)
370+
371+
# Case 1: MS ID1 differs from Mac ID1 (Ubuntu Light pattern)
372+
# Mac ID1="Test Family" → FreeType family_name (primary)
373+
# MS ID1="Test Family Light" → alternate name users expect to work
374+
ubuntu_style_sfnt = {
375+
(*mac_key, 1): "Test Family".encode("latin-1"),
376+
(*ms_key, 1): "Test Family Light".encode("utf-16-be"),
377+
(*mac_key, 2): "Light".encode("latin-1"),
378+
(*ms_key, 2): "Regular".encode("utf-16-be"),
379+
}
380+
fake_font = MagicMock()
381+
fake_font.get_sfnt.return_value = ubuntu_style_sfnt
382+
383+
assert _get_font_alt_names(fake_font, "Test Family") == [("Test Family Light", 400)]
384+
assert _get_font_alt_names(fake_font, "Test Family Light") == [
385+
("Test Family", 300)]
386+
387+
# Case 2: ID 16 differs from ID 1 (older typographic-family pattern)
388+
# ID 17 (typographic subfamily) is absent → defaults to weight 400
389+
id16_sfnt = {
390+
(*mac_key, 1): "Test Family".encode("latin-1"),
391+
(*ms_key, 1): "Test Family".encode("utf-16-be"),
392+
(*ms_key, 16): "Test Family Light".encode("utf-16-be"),
393+
}
394+
fake_font_id16 = MagicMock()
395+
fake_font_id16.get_sfnt.return_value = id16_sfnt
396+
397+
assert _get_font_alt_names(
398+
fake_font_id16, "Test Family"
399+
) == [("Test Family Light", 400)]
400+
401+
# Case 3: all entries agree → no alternates
402+
same_sfnt = {
403+
(*mac_key, 1): "Test Family".encode("latin-1"),
404+
(*ms_key, 1): "Test Family".encode("utf-16-be"),
405+
}
406+
fake_font_same = MagicMock()
407+
fake_font_same.get_sfnt.return_value = same_sfnt
408+
assert _get_font_alt_names(fake_font_same, "Test Family") == []
409+
410+
# Case 4: get_sfnt() raises ValueError (e.g. non-SFNT font) → empty list
411+
fake_font_no_sfnt = MagicMock()
412+
fake_font_no_sfnt.get_sfnt.side_effect = ValueError
413+
assert _get_font_alt_names(fake_font_no_sfnt, "Test Family") == []
414+
415+
fake_path = str(tmp_path / "fake.ttf")
416+
primary_entry = FontEntry(fname=fake_path, name="Test Family",
417+
style="normal", variant="normal",
418+
weight=300, stretch="normal", size="scalable")
419+
420+
with patch("matplotlib.font_manager.ft2font.FT2Font",
421+
return_value=fake_font), \
422+
patch("matplotlib.font_manager.ttfFontProperty",
423+
return_value=primary_entry):
424+
fm_instance = fm_mod.FontManager.__new__(fm_mod.FontManager)
425+
fm_instance.ttflist = []
426+
fm_instance.afmlist = []
427+
fm_instance._findfont_cached = MagicMock()
428+
fm_instance._findfont_cached.cache_clear = MagicMock()
429+
fm_instance.addfont(fake_path)
430+
431+
names = [e.name for e in fm_instance.ttflist]
432+
assert names == ["Test Family", "Test Family Light"]
433+
alt_entry = fm_instance.ttflist[1]
434+
assert alt_entry.weight == 400
435+
assert alt_entry.style == primary_entry.style
436+
assert alt_entry.fname == primary_entry.fname
350437

351438

352439
def test_donot_cache_tracebacks():

0 commit comments

Comments
 (0)