Source code for dartwork_mpl.asset_viz._font

"""Font visualization functions.

Functions for displaying available fonts registered with matplotlib,
with weight spectrum, pangram samples, and grouped italic variants.
"""

from __future__ import annotations

import math
import os
from collections import defaultdict

import matplotlib.font_manager as fm
import matplotlib.pyplot as plt
from matplotlib.figure import Figure

# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

_WEIGHT_ORDER: dict[str, int] = {
    "Thin": 100,
    "ExtraLight": 200,
    "Light": 300,
    "Regular": 400,
    "Medium": 500,
    "SemiBold": 600,
    "Bold": 700,
    "ExtraBold": 800,
    "Black": 900,
}

_PANGRAM = "The dartwork designs beautiful data artworks since 2021. 0123456789"


def _parse_font_weight(font_file: str) -> tuple[str, bool]:
    """Extract weight name and italic flag from a font filename.

    Parameters
    ----------
    font_file : str
        Font filename like ``"Inter-BoldItalic.ttf"``.

    Returns
    -------
    tuple[str, bool]
        ``(weight_name, is_italic)``
    """
    base = os.path.splitext(font_file)[0]
    # Handle Paperlogy naming: "Paperlogy-7Bold" etc.
    if "-" in base:
        parts = base.split("-", 1)
        style_part = parts[1] if len(parts) > 1 else ""
    else:
        style_part = base

    is_italic = "Italic" in style_part
    # Remove "Italic" to isolate weight
    weight_part = style_part.replace("Italic", "")

    # Strip leading digits (Paperlogy uses "1Thin", "7Bold", etc.)
    import re

    weight_part = re.sub(r"^\d+", "", weight_part)

    if not weight_part:
        weight_part = "Regular"

    return weight_part, is_italic


def _weight_sort_key(weight_name: str) -> int:
    """Return numeric sort key for a weight name."""
    return _WEIGHT_ORDER.get(weight_name, 400)


[docs] def plot_fonts( font_dir: str | None = None, ncols: int = 2, font_size: int = 11 ) -> Figure: """Plot available font families with weight spectrum and samples. Each font family is displayed as a titled section showing: - Family header with file count - Each weight rendered with pangram sample text - Italic variants shown inline with lighter color Parameters ---------- font_dir : str, optional Directory path containing font files. If None, defaults to the ``asset/font`` directory within the package. ncols : int, optional Number of columns to display font families, by default 2. font_size : int, optional Font size for sample text, by default 11. Returns ------- fig : matplotlib.figure.Figure Figure object. """ if font_dir is None: font_dir = os.path.join( os.path.dirname(os.path.dirname(__file__)), "asset", "font" ) # Collect font files extensions = {".ttf", ".otf"} font_files = [ f for f in os.listdir(font_dir) if os.path.splitext(f)[1].lower() in extensions ] # Group by family font_families: dict[str, list[str]] = defaultdict(list) for font in font_files: family = font.split("-")[0] font_families[family].append(font) # For each family, group by weight and separate italic from typing import Any family_data: list[dict[str, Any]] = [] for family_name, files in sorted(font_families.items()): weight_groups: dict[str, dict[str, str | None]] = defaultdict( lambda: {"roman": None, "italic": None} ) for f in files: weight_name, is_italic = _parse_font_weight(f) slot = "italic" if is_italic else "roman" weight_groups[weight_name][slot] = f # Sort by weight sorted_weights = sorted( weight_groups.items(), key=lambda x: _weight_sort_key(x[0]) ) weights = [] for weight_name, variants in sorted_weights: weights.append( { "name": weight_name, "roman": variants["roman"], "italic": variants["italic"], } ) n_files = len(files) n_weights = len(weights) family_data.append( { "family": family_name, "n_files": n_files, "n_weights": n_weights, "weights": weights, } ) # ------------------------------------------------------------------ # Layout calculation # ------------------------------------------------------------------ total_families = len(family_data) families_per_column = math.ceil(total_families / ncols) # Height per weight line + header header_line_height = 1.8 weight_line_height = 1.3 family_gap = 1.5 # Calculate max height needed for any column col_heights = [0.0] * ncols family_col_map: list[int] = [] for idx, fam in enumerate(family_data): col = idx // families_per_column if col >= ncols: col = ncols - 1 family_col_map.append(col) n_lines = len(fam["weights"]) col_heights[col] += ( header_line_height + n_lines * weight_line_height + family_gap ) total_height = max(col_heights) if col_heights else 10 col_width = 7.5 fig, ax = plt.subplots( figsize=(col_width * ncols, total_height * 0.32 + 0.5) ) ax.set_xlim(0, col_width * ncols) ax.set_ylim(0, total_height + 0.5) ax.axis("off") # ------------------------------------------------------------------ # Draw families # ------------------------------------------------------------------ col_cursors = [total_height] * ncols for idx, fam in enumerate(family_data): col = family_col_map[idx] x_pos = col * col_width cursor = col_cursors[col] family_name = fam["family"] n_files = fam["n_files"] n_weights = fam["n_weights"] # --- Family header --- cursor -= 0.3 header_text = f"{family_name}" meta_text = f" {n_weights} weights ยท {n_files} files" ax.text( x_pos, cursor, header_text, size=13, weight="bold", color="#1a1a2e" ) ax.text( x_pos + len(family_name) * 0.08 + 0.05, cursor, meta_text, size=9, color="#888888", verticalalignment="baseline", ) # Divider cursor -= 0.35 ax.plot( [x_pos, x_pos + col_width - 0.5], [cursor, cursor], color="#e0e0e0", linewidth=0.6, ) cursor -= 0.2 # --- Weight lines --- for w in fam["weights"]: cursor -= weight_line_height weight_name = w["name"] roman_file = w["roman"] italic_file = w["italic"] # Weight label weight_num = _WEIGHT_ORDER.get(weight_name, "") label = ( f"{weight_name} ({weight_num})" if weight_num else weight_name ) # Draw label ax.text( x_pos, cursor + 0.5, label, size=8, color="#999999", verticalalignment="center", ) # Draw sample text with actual font sample_x = x_pos + 2.0 if roman_file is not None: font_path = os.path.join(font_dir, roman_file) try: font_prop = fm.FontProperties(fname=font_path) ax.text( sample_x, cursor + 0.5, _PANGRAM, fontproperties=font_prop, size=font_size, color="#1a1a2e", verticalalignment="center", clip_on=True, ) except Exception: ax.text( sample_x, cursor + 0.5, f"({roman_file})", size=font_size - 2, color="#cccccc", verticalalignment="center", ) # Italic indicator if italic_file is not None: ax.text( x_pos + col_width - 0.8, cursor + 0.5, "I", size=9, color="#4a90d9", fontstyle="italic", fontweight="bold", verticalalignment="center", horizontalalignment="center", alpha=0.7, ) cursor -= family_gap col_cursors[col] = cursor return fig