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"