"""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