From 74d668424b105ac6626316aed89426061719fef7 Mon Sep 17 00:00:00 2001 From: Marcos Lemos Date: Fri, 6 Jun 2025 19:09:05 +0100 Subject: [PATCH] Add font.superfamily support with genre-aware resolution Closes #29866 This commit adds support for rcParam 'font.superfamily', allowing users to define logical font groups (superfamilies) that span multiple genres like 'serif', 'sans', and 'mono'. Each superfamily maps genre + weight + style to real font names using keys like 'bold-italic' or 'normal-normal'. Font resolution uses genre from font.family and matches against the selected superfamily, trying exact and fallback variants in order of specificity to generality. Superfamilies can be defined by name (preloaded registry) or passed as inline dicts in rcParams['font.superfamily']. Legacy behavior is preserved unless superfamily is set. If set, genre-aware matching replaces generic family name. Includes defaults for DejaVu, Noto, Liberation, and more. Co-authored-by: Tiago Marques --- lib/matplotlib/font_manager.py | 496 +++++++++++++++++- lib/matplotlib/font_manager.pyi | 34 ++ lib/matplotlib/mpl-data/matplotlibrc | 8 + lib/matplotlib/rcsetup.py | 48 ++ lib/matplotlib/rcsetup.pyi | 3 +- lib/matplotlib/tests/test_font_superfamily.py | 333 ++++++++++++ 6 files changed, 917 insertions(+), 5 deletions(-) create mode 100644 lib/matplotlib/tests/test_font_superfamily.py diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 2db98b75ab2e..08f012de4dd6 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -50,6 +50,8 @@ parse_fontconfig_pattern, generate_fontconfig_pattern) from matplotlib.rcsetup import _validators +from typing import Any, Dict, List, Optional, Set, Union + _log = logging.getLogger(__name__) font_scalings = { @@ -131,6 +133,129 @@ 'monospace', 'sans', } +_default_superfamilies = [ + { + "name": "DejaVu", + "variants": { + "serif": { + "normal-normal": "DejaVu Serif" + }, + "sans": { + "normal-normal": "DejaVu Sans" + }, + "mono": { + "normal-normal": "DejaVu Sans Mono" + } + } + }, + { + "name": "Bitstream Vera", + "variants": { + "serif": { + "normal-normal": "Bitstream Vera Serif" + }, + "sans": { + "normal-normal": "Bitstream Vera Sans" + }, + "mono": { + "normal-normal": "Bitstream Vera Sans Mono" + } + } + }, + { + "name": "Computer Modern", + "variants": { + "serif": { + "normal-normal": "Computer Modern Roman" + }, + "sans": { + "normal-normal": "Computer Modern Sans Serif" + }, + "mono": { + "normal-normal": "Computer Modern Typewriter" + } + } + }, + { + "name": "Arial", + "variants": { + "sans": { + "normal-normal": "Arial", + "bold-normal": "Arial Bold", + "normal-italic": "Arial Italic", + "bold-italic": "Arial Bold Italic" + } + } + }, + { + "name": "Times", + "variants": { + "serif": { + "normal-normal": "Times New Roman", + "bold-normal": "Times New Roman Bold", + "normal-italic": "Times New Roman Italic", + "bold-italic": "Times New Roman Bold Italic" + } + } + }, + { + "name": "Courier", + "variants": { + "mono": { + "normal-normal": "Courier New", + "bold-normal": "Courier New Bold", + "normal-italic": "Courier New Italic", + "bold-italic": "Courier New Bold Italic" + } + } + }, + { + "name": "Liberation", + "variants": { + "serif": { + "normal-normal": "Liberation Serif", + "bold-normal": "Liberation Serif Bold", + "normal-italic": "Liberation Serif Italic", + "bold-italic": "Liberation Serif Bold Italic" + }, + "sans": { + "normal-normal": "Liberation Sans", + "bold-normal": "Liberation Sans Bold", + "normal-italic": "Liberation Sans Italic", + "bold-italic": "Liberation Sans Bold Italic" + }, + "mono": { + "normal-normal": "Liberation Mono", + "bold-normal": "Liberation Mono Bold", + "normal-italic": "Liberation Mono Italic", + "bold-italic": "Liberation Mono Bold Italic" + } + } + }, + { + "name": "Noto", + "variants": { + "serif": { + "normal-normal": "Noto Serif", + "bold-normal": "Noto Serif Bold", + "normal-italic": "Noto Serif Italic", + "bold-italic": "Noto Serif Bold Italic" + }, + "sans": { + "normal-normal": "Noto Sans", + "bold-normal": "Noto Sans Bold", + "normal-italic": "Noto Sans Italic", + "bold-italic": "Noto Sans Bold Italic" + }, + "mono": { + "normal-normal": "Noto Mono", + "bold-normal": "Noto Mono Bold", + "normal-italic": "Noto Mono Italic", + "bold-italic": "Noto Mono Bold Italic" + } + } + } +] # OS Font paths try: @@ -306,6 +431,278 @@ def findSystemFonts(fontpaths=None, fontext='ttf'): return [fname for fname in fontfiles if os.path.exists(fname)] +class FontSuperfamily: + """ + Represents a typographic superfamily — a logical grouping of fonts that + share a common design but span different genres + like 'serif', 'sans', 'mono', etc. + Example: the 'Roboto' superfamily includes 'Roboto', + 'Roboto Serif', and 'Roboto Mono'. + """ + _registry: Dict[str, "FontSuperfamily"] = {} + + def __init__(self, name: str): + """ + Initialize a superfamily with a given name. + + Parameters + ---------- + name : str + Logical name of the superfamily (e.g., 'Roboto'). + """ + self.name = name + # genre -> style_key -> font_name + self.variants: Dict[str, Dict[str, str]] = {} + + def register(self, genre: str, font_name: str, + weight: str = "normal", style: str = "normal"): + """ + Register a specific font under a given genre and variation. + + Parameters + ---------- + genre : str + One of: 'serif', 'sans', 'mono', 'cursive', 'fantasy', etc. + font_name : str + The actual font name (e.g., 'Roboto Serif'). + weight : str + Font weight (e.g., 'normal', 'bold'). + style : str + Font style (e.g., 'normal', 'italic'). + """ + genre = genre.lower() + key = f"{weight.lower()}-{style.lower()}" + self.variants.setdefault(genre, {})[key] = font_name + + def get_family(self, genre: str, + weight: Optional[Union[int, str]] = None, + style: Optional[str] = None) -> Optional[str]: + """ + Return the font name associated with a genre in this superfamily. + + Tries multiple fallbacks: + - Exact match on weight + style + - Match on weight only (style=normal) + - Match on style only (weight=normal) + - Default to normal-normal + + Parameters + ---------- + genre : str + The genre to look up ('serif', 'sans', etc.). + weight : int or str, optional + Font weight (e.g., 'bold'). + style : str, optional + Font style (e.g., 'italic'). + + Returns + ------- + str or None + The name of the registered font, or None if not found. + """ + genre = genre.lower() + variants = self.variants.get(genre, {}) + + def norm(x, is_style=False): + if x is None: + return "normal" + s = str(x).lower() + return "italic" if is_style and s == "oblique" else s + + + keys = [ + f"{norm(weight)}-{norm(style, is_style=True)}", + f"{norm(weight)}-normal", + f"normal-{norm(style, is_style=True)}", + "normal-normal", + ] + + + for key in keys: + if key in variants: + return variants[key] + + return None + + @classmethod + def get_superfamily(cls, name: str) -> "FontSuperfamily": + """ + Retrieve or create a FontSuperfamily instance by name. + + Parameters + ---------- + name : str + The logical superfamily name. + + Returns + ------- + FontSuperfamily + """ + if name not in cls._registry: + cls._registry[name] = FontSuperfamily(name) + return cls._registry[name] + + def __repr__(self): + return f"" + + def _populate_default_superfamilies(): + """ + Populate the FontSuperfamily registry using only fonts listed in + matplotlibrc default genres, but grouping only those that actually + form multi-genre superfamilies. + """ + if FontSuperfamily._registry: + return # Already populated + for data in _default_superfamilies: + sf = FontSuperfamily.from_dict(data) + FontSuperfamily._registry[sf.name] = sf + + def to_dict(self) -> Dict[str, Any]: + return {"name": self.name, "variants": self.variants} + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "FontSuperfamily": + sf = cls(data["name"]) + for genre, variants in data["variants"].items(): + for key, font in variants.items(): + weight, style = key.split("-") + sf.register(genre, font, weight, style) + return sf + + def genres(self) -> List[str]: + """ + Return the list of genre categories defined in this superfamily. + + Returns + ------- + list of str + The genre keys present (e.g., ['serif', 'sans', 'mono']). + """ + return list(self.variants.keys()) + + def keys(self, genre: str) -> List[str]: + """ + Return the list of style keys defined under a given genre. + + Parameters + ---------- + genre : str + The genre to inspect. + + Returns + ------- + list of str + The style keys (e.g., ['normal-normal', 'bold-italic']) defined + for the given genre. Returns an empty list if the genre is not found. + """ + return list(self.variants.get(genre.lower(), {}).keys()) + + def all_fonts(self) -> Set[str]: + """ + Return the set of all font names registered in this superfamily. + + Returns + ------- + set of str + All font names used in the superfamily across all genres and styles. + """ + return { + font for genre_map in self.variants.values() + for font in genre_map.values() + } + + def has_genre(self, genre: str) -> bool: + """ + Check if a given genre is defined in the superfamily. + + Parameters + ---------- + genre : str + The genre to check. + + Returns + ------- + bool + True if the genre exists, False otherwise. + """ + return genre.lower() in self.variants + + def describe(self) -> str: + """ + Return a string summary of the superfamily content. + + Useful for debugging or interactive inspection. + + Returns + ------- + str + Human-readable listing of all genres and their font mappings. + """ + lines = [f"Superfamily '{self.name}':"] + for genre, styles in self.variants.items(): + for key, font in styles.items(): + lines.append(f" {genre:<10} | {key:<13} → {font}") + return "\n".join(lines) + + def to_json(self) -> str: + """ + Serialize the superfamily as a JSON string. + + Returns + ------- + str + JSON representation of the superfamily. + """ + import json + return json.dumps(self.to_dict(), indent=2) + + @classmethod + def from_json(cls, s: str) -> "FontSuperfamily": + """ + Construct a FontSuperfamily instance from a JSON string. + + Parameters + ---------- + s : str + JSON-encoded string describing a superfamily. + + Returns + ------- + FontSuperfamily + A new instance populated from the data. + """ + import json + return cls.from_dict(json.loads(s)) + + @classmethod + def register_from_dict(cls, data: dict) -> "FontSuperfamily": + """ + Construct and register a FontSuperfamily from a dictionary. + + Parameters + ---------- + data : dict + Dictionary with keys "name" and "variants", in the same + format accepted by rcParams["font.superfamily"]. + + Returns + ------- + FontSuperfamily + The registered instance. + + Raises + ------ + ValueError + If required keys are missing. + """ + if "name" not in data or "variants" not in data: + raise ValueError("Sfdict must contain 'name' and 'variants' keys.") + + sf = cls.from_dict(data) + cls._registry[sf.name] = sf + return sf + + @dataclasses.dataclass(frozen=True) class FontEntry: """ @@ -655,10 +1052,10 @@ class FontProperties: @_cleanup_fontproperties_init def __init__(self, family=None, style=None, variant=None, weight=None, - stretch=None, size=None, - fname=None, # if set, it's a hardcoded filename to use - math_fontfamily=None): - self.set_family(family) + stretch=None, size=None, + fname=None, # if set, it's a hardcoded filename to use + math_fontfamily=None): + self.set_style(style) self.set_variant(variant) self.set_weight(weight) @@ -666,6 +1063,30 @@ def __init__(self, family=None, style=None, variant=None, weight=None, self.set_file(fname) self.set_size(size) self.set_math_fontfamily(math_fontfamily) + + font_family_is_generic = ( + family is None or ( + isinstance(family, list) + and len(family) == 1 + and str(family[0]).lower() in { + "serif", "sans-serif", "sans", "monospace", "cursive", "fantasy" + } + ) + ) + + if font_family_is_generic: + resolved = self._resolve_superfamily_from_rc(family=family) + else: + resolved = None + + + if resolved: + self.set_family([resolved]) + else: + self.set_family( + family if family is not None else mpl.rcParams["font.family"] + ) + # Treat family as a fontconfig pattern if it is the only parameter # provided. Even in that case, call the other setters first to set # attributes not specified by the pattern to the rcParams defaults. @@ -714,6 +1135,73 @@ def __eq__(self, other): def __str__(self): return self.get_fontconfig_pattern() + def _resolve_superfamily_from_rc(self, family=None): + """ + Resolve the font superfamily from rcParams["font.superfamily"], + supporting both string names and dict-based definitions. + + If a dict is given, it is lazily instantiated as a FontSuperfamily + and registered. If resolution fails, falls back to font.family. + """ + superfamily_val = mpl.rcParams.get("font.superfamily", None) + + if isinstance(superfamily_val, dict): + try: + # Lazily register the dictionary-defined superfamily + sf = FontSuperfamily.from_dict(superfamily_val) + FontSuperfamily._registry[sf.name] = sf + superfamily_name = sf.name + except Exception as e: + import warnings + warnings.warn( + f"Invalid font.superfamily dict: {e}. Falling back to font.family.", + UserWarning + ) + return None + + elif isinstance(superfamily_val, str): + superfamily_name = superfamily_val.strip() + if superfamily_name.lower() in {"", "none"}: + return None + else: + return None + + try: + # Ensure defaults are present + FontSuperfamily._populate_default_superfamilies() + + sf = FontSuperfamily.get_superfamily(superfamily_name) + + # Map font.family (e.g. "sans-serif") to genre key used in the superfamily + raw_genre = next(iter(family), "sans-serif") if family else \ + next(iter(mpl.rcParams["font.family"]), "sans-serif") + genre = { + "sans-serif": "sans", + "serif": "serif", + "monospace": "mono", + "cursive": "cursive", + "fantasy": "fantasy" + }.get(raw_genre.lower(), raw_genre.lower()) + + weight = self.get_weight() + if isinstance(weight, int): + weight = "bold" if weight >= 600 else "normal" + + style = self.get_style() + + resolved = sf.get_family(genre=genre, weight=weight, style=style) + return resolved # name or path + except Exception as e: + import warnings + warnings.warn( + f"Failed to resolve font.superfamily '{superfamily_name}': {e}. " + f"Falling back to font.family.", + UserWarning + ) + + return None + + def get_family(self): """ Return a list of individual font family names or generic family names. diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index c64ddea3e073..bddc6c7e9f40 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -9,6 +9,9 @@ from pathlib import Path from collections.abc import Iterable from typing import Any, Literal +from typing import Dict, Optional, Union, List, Set + + font_scalings: dict[str | None, float] stretch_dict: dict[str, int] weight_dict: dict[str, int] @@ -134,3 +137,34 @@ def findfont( rebuild_if_missing: bool = ..., ) -> str: ... def get_font_names() -> list[str]: ... + +class FontSuperfamily: + name: str + variants: Dict[str, Dict[str, str]] + _registry: Dict[str, "FontSuperfamily"] + + def __init__(self, name: str) -> None: ... + def register(self, genre: str, font_name: str, + weight: str = ..., style: str = ...) -> None: ... + def get_family(self, genre: str, + weight: Optional[Union[int, str]] = ..., + style: Optional[str] = ...) -> Optional[str]: ... + @classmethod + def get_superfamily(cls, name: str) -> "FontSuperfamily": ... + def to_dict(self) -> Dict[str, Any]: ... + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "FontSuperfamily": ... + def to_json(self) -> str: ... + @classmethod + def from_json(cls, s: str) -> "FontSuperfamily": ... + def genres(self) -> List[str]: ... + def keys(self, genre: str) -> List[str]: ... + def all_fonts(self) -> Set[str]: ... + def has_genre(self, genre: str) -> bool: ... + def describe(self) -> str: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +def _populate_default_superfamilies() -> None: ... + +_default_superfamilies: List[Dict[str, Any]] diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 780dcd377041..9c91f942a154 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -264,7 +264,15 @@ ## settings for axes and ticks. Special text sizes can be defined ## relative to font.size, using the following values: xx-small, x-small, ## small, medium, large, x-large, xx-large, larger, or smaller +## +## font.superfamily: None +## A logical font superfamily name (e.g., 'DejaVu', 'Computer Modern', 'Bitstream Vera') +## that maps genre-specific families like 'serif', 'sans-serif', or 'monospace' +## to coordinated fonts. When set, it overrides genre-specific defaults based on +## the current style and weight. + +#font.superfamily: None #font.family: sans-serif #font.style: normal #font.variant: normal diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index ce29c5076100..3df3a2155246 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -461,6 +461,53 @@ def validate_font_properties(s): return s +def validate_font_superfamily(val): + """ + Validate the font.superfamily rcParam. + + Accepts: + - A string (e.g. "Roboto", "DejaVu", or "None"). + The string "None" (case-insensitive) + is interpreted as disabling the feature and returns None. + - A dictionary with "name" and "variants" keys + (used to register a new superfamily). + - The Python value None. + + Parameters + ---------- + val : str, dict, or None + The value passed from the rc file or directly in code. + + Returns + ------- + str, dict, or None + The normalized value, or None if superfamily is not to be used. + + Raises + ------ + ValueError + If the input is not a valid superfamily specification. + """ + if isinstance(val, str): + if val.strip().lower() == "none": + return None + return val.strip() + if val is None: + return None + if isinstance(val, dict): + if "name" not in val or "variants" not in val: + raise ValueError( + "font.superfamily dict must include " \ + "both 'name' and 'variants' keys" + ) + return val # Delay actual FontSuperfamily creation + + raise ValueError( + "font.superfamily must be a string (e.g. 'DejaVu'), " \ + "a dict with 'name' and 'variants', or None" + ) + + def _validate_mathtext_fallback(s): _fallback_fonts = ['cm', 'stix', 'stixsans'] if isinstance(s, str): @@ -1038,6 +1085,7 @@ def _convert_validator_spec(key, conv): "font.enable_last_resort": validate_bool, "font.family": validate_stringlist, # used by text object "font.style": validate_string, + "font.superfamily": validate_font_superfamily, # list of logical superfamily name "font.variant": validate_string, "font.stretch": validate_fontstretch, "font.weight": validate_fontweight, diff --git a/lib/matplotlib/rcsetup.pyi b/lib/matplotlib/rcsetup.pyi index 79538511c0e4..a0e112b3a1c6 100644 --- a/lib/matplotlib/rcsetup.pyi +++ b/lib/matplotlib/rcsetup.pyi @@ -1,7 +1,7 @@ from cycler import Cycler from collections.abc import Callable, Iterable -from typing import Any, Literal, TypeVar +from typing import Any, Literal, TypeVar, Optional from matplotlib.typing import ColorType, LineStyleType, MarkEveryType interactive_bk: list[str] @@ -42,6 +42,7 @@ def validate_floatlist(s: Any) -> list[float]: ... def _validate_marker(s: Any) -> int | str: ... def _validate_markerlist(s: Any) -> list[int | str]: ... def validate_fonttype(s: Any) -> int: ... +def validate_font_superfamily(val: Any) -> Optional[str]: ... _auto_backend_sentinel: object diff --git a/lib/matplotlib/tests/test_font_superfamily.py b/lib/matplotlib/tests/test_font_superfamily.py new file mode 100644 index 000000000000..4c6a11ba3d94 --- /dev/null +++ b/lib/matplotlib/tests/test_font_superfamily.py @@ -0,0 +1,333 @@ +import sys +import pytest +import matplotlib as mpl +import matplotlib.pyplot as plt +from matplotlib import font_manager +from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas +from matplotlib.font_manager import FontSuperfamily +from matplotlib.font_manager import FontProperties, _default_superfamilies + + +# Define a testable superfamily registry for isolated tests +def setup_superfamily(): + sf = FontSuperfamily.get_superfamily("TestFamily") + sf.register("serif", "Test Serif") + sf.register("serif", "Test Serif Bold", weight="bold") + sf.register("sans", "Test Sans") + sf.register("mono", "Test Mono", weight="bold", style="italic") + return sf + + +# Create test functions +def test_register_and_get_family_default(): + sf = setup_superfamily() + assert sf.get_family("serif") == "Test Serif" + + +def test_get_family_with_weight(): + sf = setup_superfamily() + assert sf.get_family("serif", weight="bold") == "Test Serif Bold" + + +def test_get_family_with_style_and_weight(): + sf = setup_superfamily() + assert sf.get_family("mono", weight="bold", style="italic") == "Test Mono" + + +def test_get_family_fallback_to_default(): + sf = setup_superfamily() + # This should fallback to "normal-normal" entry + assert sf.get_family("sans", weight="light") == "Test Sans" + + +def test_get_family_not_found_returns_none(): + sf = setup_superfamily() + assert sf.get_family("fantasy") is None + + +def test_superfamily_takes_precedence_over_genre_family(monkeypatch): + """ + If both font.superfamily and font.serif are defined, + font.superfamily should take precedence. + """ + setup_superfamily() + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "TestFamily") + monkeypatch.setitem(mpl.rcParams, "font.family", ["serif"]) + monkeypatch.setitem(mpl.rcParams, "font.serif", ["Some Custom Serif Font"]) + + fp = FontProperties(weight="bold") + # Should resolve to 'Test Serif Bold', not 'Some Custom Serif Font' + assert fp.get_family() == ["Test Serif Bold"] + + +def test_fontproperties_with_superfamily(monkeypatch): + """Validate FontProperties resolves the superfamily correctly""" + setup_superfamily() + # Inject rcParams temporarily + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "TestFamily") + monkeypatch.setitem(mpl.rcParams, "font.family", "serif") + + fp = FontProperties(weight="bold") + assert fp.get_family() == ["Test Serif Bold"] + + +def test_superfamily_can_be_disabled(monkeypatch): + """ + Ensure that font.superfamily can be disabled using None or 'None'. + """ + monkeypatch.setitem(mpl.rcParams, "font.superfamily", None) + monkeypatch.setitem(mpl.rcParams, "font.family", ["serif"]) + + fp = FontProperties() + assert fp.get_family() == ["serif"] + + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "None") + fp2 = FontProperties() + assert fp2.get_family() == ["serif"] + + +def test_fontproperties_without_superfamily(monkeypatch): + monkeypatch.setitem(mpl.rcParams, "font.family", "serif") + monkeypatch.setitem(mpl.rcParams, "font.superfamily", None) + + fp = FontProperties() + # Should not use the superfamily logic, and preserve original family + assert fp.get_family() == ["serif"] + + +def test_get_family_with_nonexistent_weight_style_combination(): + sf = setup_superfamily() + # Should fall back to default genre if exact match for weight+style doesn't exist + assert sf.get_family("mono", weight="bold", style="oblique") == "Test Mono" + + +def test_fontproperties_superfamily_partial_match(monkeypatch): + # Only genre match exists, weight and style do not + setup_superfamily() + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "TestFamily") + monkeypatch.setitem(mpl.rcParams, "font.family", "sans") + + # No specific weight/style for sans, should still resolve + fp = FontProperties(weight="black", style="italic") + assert fp.get_family() == ["Test Sans"] + + +def test_fontproperties_superfamily_not_defined(monkeypatch): + # Superfamily name exists but no mapping for genre + setup_superfamily() + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "TestFamily") + monkeypatch.setitem(mpl.rcParams, "font.family", "fantasy") + + fp = FontProperties() + # Should fall back to original family + assert fp.get_family() == ["fantasy"] + + +def test_fontproperties_superfamily_unknown(monkeypatch): + # Non-existent superfamily + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "UnknownFamily") + monkeypatch.setitem(mpl.rcParams, "font.family", "serif") + + fp = FontProperties() + # Should fall back to family as superfamily doesn't exist + assert fp.get_family() == ["serif"] + + +def test_superfamily_render_with_dejavu(monkeypatch): + """ + Ensure that the default registered superfamily 'DejaVu' + resolves and applies 'DejaVu Sans' + at render time using FontProperties and rcParams. + """ + + # Guarantee the font is in the known system fonts + available_fonts = [f.name for f in font_manager.fontManager.ttflist] + assert "DejaVu Sans Mono" in available_fonts, "DejaVu Sans must be available" + + # Ensure default superfamilies are loaded + FontSuperfamily._registry.clear() + FontSuperfamily._populate_default_superfamilies() + + # Set rcParams to trigger superfamily logic + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "DejaVu") + monkeypatch.setitem(mpl.rcParams, "font.family", ["mono"]) + monkeypatch.setitem(mpl.rcParams, "font.style", "normal") + monkeypatch.setitem(mpl.rcParams, "font.weight", "normal") + + fig, ax = plt.subplots() + canvas = FigureCanvas(fig) + text = ax.text(0.5, 0.5, "DejaVu test", ha="center") + + # Trigger draw to resolve font fully + canvas.draw() + + actual_font = text.get_fontproperties().get_name() + assert actual_font == "DejaVu Sans Mono" + + +def test_superfamily_overrides_genre_specific_list(monkeypatch): + """ + Verify that font.superfamily takes precedence over font.family and + genre-specific fallback lists like font.serif during rendering. + """ + import matplotlib.pyplot as plt + from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas + from matplotlib import font_manager + + # Ensure default superfamilies are available + FontSuperfamily._registry.clear() + FontSuperfamily._populate_default_superfamilies() + + # Use a known default superfamily + superfamily = "DejaVu" + expected_font = "DejaVu Serif" # Must exist in the default registry + + # Ensure the font is present in the system + available_fonts = {f.name for f in font_manager.fontManager.ttflist} + if expected_font not in available_fonts: + pytest.skip(f"{expected_font} is not available") + + # Apply conflicting font settings + monkeypatch.setitem(mpl.rcParams, "font.superfamily", superfamily) + monkeypatch.setitem(mpl.rcParams, "font.family", ["serif"]) + monkeypatch.setitem(mpl.rcParams, "font.serif", + ["Times New Roman"]) # Should be ignored + + # Plot and trigger font resolution + fig, ax = plt.subplots() + canvas = FigureCanvas(fig) + text_obj = ax.text(0.5, 0.5, "Testing precedence", ha="center") + canvas.draw() + + # Get actual font used + actual_font = text_obj.get_fontproperties().get_name() + + assert actual_font == expected_font, ( + f"Expected font '{expected_font}' from superfamily '{superfamily}', " + f"but got '{actual_font}'" + ) + + +@pytest.mark.parametrize("sf_data", _default_superfamilies) +def test_registered_superfamilies_resolve_if_fonts_exist(sf_data, monkeypatch): + """ + Verify that each registered superfamily correctly resolves to its expected + default 'normal-normal' font per genre, provided the font is installed. + """ + + sf_name = sf_data["name"] + expected_fonts = [] + + # Collect (genre, expected_font) for each "normal-normal" mapping + for genre, variants in sf_data["variants"].items(): + if "normal-normal" in variants: + expected_fonts.append((genre, variants["normal-normal"])) + + # Skip test if none of the fonts are available in the system + available_fonts = {f.name for f in font_manager.fontManager.ttflist} + if all(expected not in available_fonts for _, expected in expected_fonts): + pytest.skip(f"None of the fonts for superfamily '{sf_name}' are available") + + # Apply the superfamily setting in rcParams + monkeypatch.setitem(mpl.rcParams, "font.superfamily", sf_name) + + for genre, expected_font in expected_fonts: + # Set genre in rcParams + genre_rc = { + "sans": "sans-serif", + "serif": "serif", + "mono": "monospace", + "cursive": "cursive", + "fantasy": "fantasy" + }.get(genre, genre) + + monkeypatch.setitem(mpl.rcParams, "font.family", [genre_rc]) + + # Use FontProperties to trigger resolution + fp = FontProperties() + resolved = fp.get_family() + + assert isinstance(resolved, list) + assert expected_font in resolved, ( + f"Exp'{expected_font}' from sf '{sf_name}' with genre '{genre}', " + f"but got {resolved}" + ) + + +@pytest.mark.skipif(sys.platform != "darwin", + reason="This test only runs on macOS") +def test_superfamily_render_with_apple_fonts(monkeypatch): + """ + Test that a superfamily composed of macOS default fonts: + Helvetica, Times New Roman, Courier New; + correctly resolves and is used for rendering + via FontProperties and rcParams. + """ + # Ensure expected system fonts are available + required_fonts = {"Helvetica", "Times New Roman", "Courier New"} + available_fonts = {f.name for f in font_manager.fontManager.ttflist} + if not required_fonts.issubset(available_fonts): + pytest.skip("macOS system fonts not all available") + + # Define and register a superfamily using macOS default fonts + + sf = FontSuperfamily.get_superfamily("Apple") + sf.register("serif", "Times New Roman") + sf.register("sans", "Helvetica") + sf.register("mono", "Courier New") + + # Trigger superfamily resolution via rcParams + monkeypatch.setitem(mpl.rcParams, "font.superfamily", "Apple") + monkeypatch.setitem(mpl.rcParams, "font.family", ["sans"]) + #monkeypatch.setitem(mpl.rcParams, "font.style", "normal") + #monkeypatch.setitem(mpl.rcParams, "font.weight", "normal") + + # Create and render a figure + fig, ax = plt.subplots() + canvas = FigureCanvas(fig) + text = ax.text(0.5, 0.5, "Test Apple Font", ha="center") + canvas.draw() + + # Check the resolved font name + actual_font = text.get_fontproperties().get_name() + assert actual_font == "Helvetica" + + +def test_font_superfamily_dict_registration_and_resolution(monkeypatch): + """ + Ensure that a dict-based font.superfamily entry is correctly registered + and resolves the expected font name through FontProperties. + """ + # Clear registry to isolate test + FontSuperfamily._registry.clear() + + # Define a new superfamily directly via dict + test_superfamily_dict = { + "name": "CustomFamily", + "variants": { + "sans": { + "normal-normal": "Arial" + }, + "serif": { + "normal-normal": "Times New Roman" + } + } + } + + # Set rcParams using the dict + monkeypatch.setitem(mpl.rcParams, "font.superfamily", test_superfamily_dict) + monkeypatch.setitem(mpl.rcParams, "font.family", ["serif"]) + monkeypatch.setitem(mpl.rcParams, "font.style", "normal") + monkeypatch.setitem(mpl.rcParams, "font.weight", "normal") + + fp = FontProperties() + + # It should now resolve to the font registered in the dict + resolved = fp.get_family() + assert isinstance(resolved, list) + assert "Times New Roman" in resolved + + # Registry should contain the new superfamily under the right name + assert "CustomFamily" in FontSuperfamily._registry + register =FontSuperfamily._registry["CustomFamily"].get_family("serif") + assert register == "Times New Roman"