Source code for simvx.graphics.text_renderer

"""Text rendering: font caching, vertex batching, and font fallback chain."""


from __future__ import annotations

import logging
import math
import subprocess
from pathlib import Path

import numpy as np

from simvx.core.text import Font, MSDFAtlas

__all__ = ["TextRenderer", "_find_font", "_find_cjk_fonts", "get_shared_text_renderer"]

log = logging.getLogger(__name__)

# Vertex: pos(vec2) + uv(vec2) + colour(vec4) = 8 floats = 32 bytes
_FLOATS_PER_VERTEX = 8
_VERTS_PER_CHAR = 4
_INDICES_PER_CHAR = 6

# Nerd font families (best glyph/icon coverage)
_NERD_FAMILIES = [
    "Hack Nerd Font Mono",
    "MesloLGS NF",
    "FiraCode Nerd Font Mono",
    "JetBrainsMono Nerd Font Mono",
    "DejaVuSansM Nerd Font Mono",
]

# Hardcoded system font paths (fallback when fontconfig unavailable)
_SYSTEM_FONTS = [
    "/usr/share/fonts/TTF/HackNerdFontMono-Regular.ttf",
    "/usr/share/fonts/truetype/hack/HackNerdFontMono-Regular.ttf",
    "/usr/share/fonts/TTF/MesloLGS-NF-Regular.ttf",
    "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/TTF/DejaVuSans.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
    "/usr/share/fonts/noto/NotoSans-Regular.ttf",
]

# CJK fallback font paths (checked in order)
_CJK_FONT_PATHS = [
    "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc",
    # macOS
    "/System/Library/Fonts/PingFang.ttc",
    "/System/Library/Fonts/Hiragino Sans GB.ttc",
    # Windows
    "C:/Windows/Fonts/msyh.ttc",  # Microsoft YaHei
    "C:/Windows/Fonts/simsun.ttc",
]

# CJK fontconfig family names for fc-list lookup
_CJK_FC_FAMILIES = [
    "Noto Sans CJK SC",
    "Noto Sans CJK",
    "Noto Sans CJK JP",
    "Noto Sans CJK TC",
    "Noto Sans CJK KR",
    "WenQuanYi Micro Hei",
    "Source Han Sans",
]


[docs] class TextRenderer: """Manages font atlases and batches text geometry for GPU submission. Supports a font fallback chain: when the primary font is missing a glyph (e.g. CJK characters), fallback fonts are tried in order. Fallback glyphs are packed into the primary atlas so the GPU sees a single texture. """ def __init__(self, max_chars: int = 4096): self._atlases: dict[str, MSDFAtlas] = {} self._max_chars = max_chars self._atlas_version = 0 # Tracks atlas changes for GPU re-upload self._known_glyphs: dict[str, set[str]] = {} # atlas key -> set of chars already ensured # Fallback font chain (list of Font objects, tried in order) self._fallback_fonts: list[Font] = [] self._fallback_auto_detected = False # Glyph->font cache: char -> Font (or None for tofu) self._glyph_font_cache: dict[str, Font | None] = {} # Pre-allocate vertex/index arrays self._vertices = np.zeros( (max_chars * _VERTS_PER_CHAR, _FLOATS_PER_VERTEX), dtype=np.float32, ) self._indices = np.zeros(max_chars * _INDICES_PER_CHAR, dtype=np.uint32) self._char_count = 0 # Pre-build index pattern (same for every quad) for i in range(max_chars): base = i * 4 off = i * 6 self._indices[off : off + 6] = [base, base + 1, base + 2, base, base + 2, base + 3] @property def fallback_fonts(self) -> list[Font]: """The current fallback font chain.""" self._ensure_fallbacks() return list(self._fallback_fonts)
[docs] def set_font_fallbacks(self, paths: list[str], font_size: int = 64) -> None: """Set the font fallback chain (replaces auto-detected fallbacks). Fonts are tried in order when the primary font is missing a glyph. Each path should be a .ttf or .ttc file. """ self._fallback_fonts = [] for p in paths: try: self._fallback_fonts.append(Font(p, size=font_size)) except (OSError, ImportError) as exc: log.warning("Fallback font %s not loadable: %s", p, exc) self._fallback_auto_detected = True # Skip auto-detection self._glyph_font_cache.clear() # Invalidate cache log.debug("Font fallback chain set: %s", [f.path for f in self._fallback_fonts])
def _ensure_fallbacks(self) -> None: """Auto-detect CJK fallback fonts if not already set.""" if self._fallback_auto_detected: return self._fallback_auto_detected = True for p in _find_cjk_fonts(): try: self._fallback_fonts.append(Font(p, size=64)) except (OSError, ImportError): continue if self._fallback_fonts: log.debug("Auto-detected CJK fallback fonts: %s", [f.path for f in self._fallback_fonts]) def _find_fallback_font(self, char: str, primary_font: Font) -> Font | None: """Find which font can render *char*. Returns Font or None (tofu). Result is cached for O(1) subsequent lookups. """ cached = self._glyph_font_cache.get(char) if cached is not None: return cached # Sentinel check: None stored means "no font found" if char in self._glyph_font_cache: return None # Try primary font first if primary_font.has_glyph(char): self._glyph_font_cache[char] = primary_font return primary_font # Try fallback fonts in order self._ensure_fallbacks() for fb in self._fallback_fonts: if fb.has_glyph(char): self._glyph_font_cache[char] = fb return fb # No font has this glyph self._glyph_font_cache[char] = None # type: ignore[assignment] return None def _ensure_with_fallback(self, atlas: MSDFAtlas, text: str) -> None: """Ensure all glyphs in *text* are in *atlas*, using fallbacks as needed. Glyphs the primary font can render are added normally. Glyphs only available in fallback fonts are packed into the same atlas via ensure_glyphs_from, keeping a single GPU texture. """ # First pass: add what the primary font has if atlas.ensure_glyphs(text): self._atlas_version = atlas.version # Second pass: check for chars still missing (not in font) missing = atlas.missing_glyphs(text) if not missing: return self._ensure_fallbacks() # Group missing chars by fallback font for batch packing fb_groups: dict[int, tuple[Font, list[str]]] = {} for ch in missing: fb = self._find_fallback_font(ch, atlas.font) if fb is None or fb is atlas.font: continue fid = id(fb) if fid not in fb_groups: fb_groups[fid] = (fb, []) fb_groups[fid][1].append(ch) for fb_font, chars in fb_groups.values(): if atlas.ensure_glyphs_from("".join(chars), fb_font): self._atlas_version = atlas.version
[docs] def get_atlas(self, font_path: str, font_size: int = 64) -> MSDFAtlas: """Get or create an MSDF atlas for the given font.""" key = f"{font_path}@{font_size}" if key not in self._atlases: face_index = 0 # Default; index 0 = JP for NotoSansCJK .ttc font = Font(font_path, size=font_size, face_index=face_index) self._atlases[key] = MSDFAtlas(font, atlas_size=1024, sdf_range=2.0) self._atlas_version = self._atlases[key].version log.debug("Generated MSDF atlas for %s @ %d", font_path, font_size) return self._atlases[key]
[docs] def begin_frame(self) -> None: """Reset vertex batch for new frame.""" self._char_count = 0
[docs] def draw_text( self, text: str, x: float, y: float, font_path: str | None = None, size: float = 24.0, colour: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0), font_size: int = 48, ) -> None: """Append text quads to the per-frame vertex batch. Args: text: String to render. x, y: Top-left position in pixel coordinates. font_path: Path to .ttf file (auto-detected if None). size: Display size in pixels. colour: RGBA colour tuple. font_size: Atlas generation size (higher = sharper base quality). """ if font_path is None: font_path = _find_font() if font_path is None: return key = f"{font_path}@{font_size}" atlas = self.get_atlas(font_path, font_size) # Ensure glyphs with fallback chain known = self._known_glyphs.get(key) text_chars = set(text) if known is None or not text_chars <= known: self._ensure_with_fallback(atlas, text) if known is None: self._known_glyphs[key] = text_chars else: known.update(text_chars) font = atlas.font scale = size / font.size pad = atlas.glyph_padding cursor_x = x prev_char = None # Snap baseline once -- all glyphs share it (no per-glyph bounce, no sub-pixel blur) baseline_y = round(y + font.ascender * scale) r, g, b = colour[0], colour[1], colour[2] a = colour[3] if len(colour) > 3 else 1.0 for ch in text: if self._char_count >= self._max_chars: break if ch == "\n": cursor_x = x y += font.line_height * scale baseline_y = round(y + font.ascender * scale) prev_char = None continue if ch == " " or ch not in atlas.regions: # Space / unknown char (tofu): advance cursor, no quad gm = font.get_glyph(ch) if font.has_glyph(ch) else None if gm: cursor_x += gm.advance_x * scale else: # Tofu placeholder: advance by average char width cursor_x += size * 0.6 prev_char = ch continue # Kerning if prev_char: cursor_x += font.get_kerning(prev_char, ch) * scale region = atlas.regions[ch] gm = region.metrics # Snap x to pixel grid for crisp vertical stems. # y derived from shared pixel-snapped baseline -- consistent and crisp. # Offset by -pad: atlas region includes padding around the glyph. qx = round(cursor_x + (gm.bearing_x - pad) * scale) # DON'T round per-glyph y -- preserves baseline alignment qy = baseline_y - (gm.bearing_y + pad) * scale # ceil for dimensions -- must fully contain glyph + padding qw = math.ceil(region.w * scale) qh = math.ceil(region.h * scale) # Write 4 vertices into pre-allocated array vi = self._char_count * 4 self._vertices[vi] = [qx, qy, region.u0, region.v0, r, g, b, a] self._vertices[vi + 1] = [qx + qw, qy, region.u1, region.v0, r, g, b, a] self._vertices[vi + 2] = [qx + qw, qy + qh, region.u1, region.v1, r, g, b, a] self._vertices[vi + 3] = [qx, qy + qh, region.u0, region.v1, r, g, b, a] self._char_count += 1 cursor_x += gm.advance_x * scale prev_char = ch
[docs] def get_vertices(self) -> np.ndarray: """Get the vertex array for the current frame (trimmed to actual size).""" count = self._char_count * _VERTS_PER_CHAR return self._vertices[:count]
[docs] def get_indices(self) -> np.ndarray: """Get the index array for the current frame (trimmed to actual size).""" count = self._char_count * _INDICES_PER_CHAR return self._indices[:count]
@property def char_count(self) -> int: return self._char_count @property def has_text(self) -> bool: return self._char_count > 0 @property def atlas_version(self) -> int: return self._atlas_version
_FONT_NOT_RESOLVED = object() # Sentinel: distinguishes "not yet looked up" from "looked up, not found (None)" _cached_font_path: str | None | object = _FONT_NOT_RESOLVED def _find_font() -> str | None: """Find a usable font, preferring nerd fonts for best glyph coverage. Result is cached after the first call -- subprocess lookup happens at most once. Strategy: fontconfig nerd font query first, then hardcoded system paths, then fc-match monospace fallback. """ global _cached_font_path if _cached_font_path is not _FONT_NOT_RESOLVED: return _cached_font_path # type: ignore[return-value] resolved: str | None = None # 1. Query fontconfig for nerd font families for family in _NERD_FAMILIES: try: result = subprocess.run( ["fc-list", f":family={family}:style=Regular", "-f", "%{file}\n"], capture_output=True, text=True, timeout=2, ) if result.returncode == 0 and result.stdout.strip(): resolved = result.stdout.strip().splitlines()[0] break except (FileNotFoundError, subprocess.TimeoutExpired): break # fontconfig not available # 2. Hardcoded paths (nerd fonts + standard system fonts) if resolved is None: for p in _SYSTEM_FONTS: if Path(p).exists(): resolved = p break # 3. Last resort: any monospace font via fontconfig if resolved is None: try: result = subprocess.run( ["fc-match", "-f", "%{file}", "monospace"], capture_output=True, text=True, timeout=2, ) if result.returncode == 0 and result.stdout.strip(): resolved = result.stdout.strip() except (FileNotFoundError, subprocess.TimeoutExpired): pass _cached_font_path = resolved return resolved _cached_cjk_fonts: list[str] | None = None def _find_cjk_fonts() -> list[str]: """Auto-detect CJK fallback fonts on the system. Returns a list of font paths (may be empty). Result is cached. """ global _cached_cjk_fonts if _cached_cjk_fonts is not None: return _cached_cjk_fonts found: list[str] = [] seen: set[str] = set() # 1. Try fontconfig for known CJK families for family in _CJK_FC_FAMILIES: try: result = subprocess.run( ["fc-list", f":family={family}:style=Regular", "-f", "%{file}\n"], capture_output=True, text=True, timeout=2, ) if result.returncode == 0 and result.stdout.strip(): for line in result.stdout.strip().splitlines(): path = line.strip() if path and path not in seen: seen.add(path) found.append(path) except (FileNotFoundError, subprocess.TimeoutExpired): break # fontconfig not available # 2. Hardcoded paths for p in _CJK_FONT_PATHS: if p not in seen and Path(p).exists(): seen.add(p) found.append(p) _cached_cjk_fonts = found return found _shared_text_renderer: TextRenderer | None = None
[docs] def get_shared_text_renderer() -> TextRenderer: """Return the module-level shared TextRenderer (lazy singleton).""" global _shared_text_renderer if _shared_text_renderer is None: _shared_text_renderer = TextRenderer() return _shared_text_renderer