"""
SDF glyph atlas for GPU text rendering.
Renders TrueType glyphs at high resolution via PIL, computes a Signed
Distance Field (SDF), and packs them into a single-channel OpenGL texture
atlas. The SDF allows **resolution-independent** rendering with clean
glow/outline effects at any zoom level.
Usage::
atlas = GlyphAtlas.get("arial.ttf") # cached singleton per font
atlas.bind(0) # bind to texture unit 0
m = atlas.get_metrics('A') # query glyph layout info
Author: HECE - University of Liege, Pierre Archambeau
Date: 2026
Copyright (c) 2026 University of Liege. All rights reserved.
"""
import logging
import math
import numpy as np
from dataclasses import dataclass
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
_HAS_PIL = False
try:
from scipy.ndimage import distance_transform_edt as _edt
except ImportError:
_HAS_SCIPY = False
# ================================================================
# Default character set
# ================================================================
[docs]
_DEFAULT_CHARSET: list[str] = (
[chr(c) for c in range(32, 127)]
+ list(
"àáâãäåæçèéêëìíîïðñòóôõöùúûüýÿ"
"ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖÙÚÛÜÝ"
"°±²³µ·€"
)
)
# ================================================================
# Glyph metrics
# ================================================================
@dataclass
[docs]
class GlyphMetrics:
"""Layout metrics for a single glyph in the SDF atlas.
All spatial values are **normalised** so that 1.0 equals one *em*
(i.e. the ``RENDER_SIZE`` used during atlas construction).
Attributes:
char: The character.
uv_x: Left U coordinate in the atlas (0..1).
uv_y: Top V coordinate in the atlas (0..1).
uv_w: U extent of the cell.
uv_h: V extent of the cell.
offset_x: X offset from cursor to quad left edge (normalised).
offset_y: Y offset from baseline to quad top in font coords
(negative = above baseline).
quad_w: Total quad width including SDF padding (normalised).
quad_h: Total quad height including SDF padding (normalised).
advance: Horizontal advance to the next character (normalised).
"""
# ================================================================
# Glyph Atlas
# ================================================================
[docs]
class GlyphAtlas:
"""SDF glyph atlas backed by a single-channel OpenGL texture.
Build once per font; the atlas is cached and reused for all rendering.
The SDF encoding (0.5 = edge, >0.5 = inside, <0.5 = outside) makes
it trivial to render glow, outlines, and anti-aliased text at any
scale from a single texture.
Parameters:
font_name: TrueType font file name (resolved via
:func:`wolfhece.textpillow.load_font`).
charset: List of characters to include. Defaults to ASCII
printable + common accented latin characters.
"""
[docs]
RENDER_SIZE: int = 64 # Pixels-per-em for high-res rasterisation
[docs]
SDF_SPREAD: float = 8.0 # Distance spread in render pixels
[docs]
PAD: int = 12 # Pixel padding around each glyph cell
[docs]
ATLAS_MAX_WIDTH: int = 1024 # Maximum atlas texture width
[docs]
_cache: dict[str, "GlyphAtlas"] = {}
def __init__(self, font_name: str = "arial.ttf",
charset: list[str] | None = None):
[docs]
self.font_name = font_name
[docs]
self._charset = charset or list(_DEFAULT_CHARSET)
[docs]
self._metrics: dict[str, GlyphMetrics] = {}
[docs]
self._texture_id: int | None = None
[docs]
self._atlas_width: int = 0
[docs]
self._atlas_height: int = 0
[docs]
self._atlas_data: np.ndarray | None = None
[docs]
self._line_height: float = 1.0
[docs]
self._ascender: float = 0.8
[docs]
self._descender: float = 0.2
self._build()
# ----------------------------------------------------------------
# Factory / cache
# ----------------------------------------------------------------
@classmethod
[docs]
def get(cls, font_name: str = "arial.ttf") -> "GlyphAtlas":
"""Return a cached atlas for *font_name*, creating it if needed."""
key = font_name.lower()
if key not in cls._cache:
cls._cache[key] = cls(font_name)
return cls._cache[key]
@classmethod
[docs]
def clear_cache(cls):
"""Destroy all cached atlases and free GPU textures."""
for atlas in cls._cache.values():
atlas.destroy()
cls._cache.clear()
# ----------------------------------------------------------------
# Atlas construction
# ----------------------------------------------------------------
[docs]
def _build(self):
"""Render all glyphs, compute SDF, shelf-pack into atlas."""
from wolfhece.textpillow import load_font
font = load_font(self.font_name, self.RENDER_SIZE)
rs = float(self.RENDER_SIZE)
pad = self.PAD
# --- Line metrics from reference characters ---
try:
_l, _t, _r, _b = font.getbbox("Hg")
self._line_height = (_b - _t) / rs
# PIL top can be positive (below anchor) or negative (above);
# the ascender is the positive distance from anchor to top.
self._ascender = abs(_t) / rs
self._descender = abs(_b) / rs
except Exception:
self._line_height = 1.0
self._ascender = 0.8
self._descender = 0.2
# --- First pass: render glyphs and measure cell sizes ---
# Tuple: (char, sdf|None, cell_w, cell_h, left, top, right, bottom, advance)
glyph_cells: list[tuple] = []
for char in self._charset:
try:
left, top, right, bottom = font.getbbox(char)
except Exception:
continue
gw = right - left
gh = bottom - top
try:
advance = font.getlength(char)
except Exception:
advance = float(gw)
if gw <= 0 or gh <= 0:
glyph_cells.append((char, None, 0, 0, 0, 0, 0, 0, advance))
continue
cell_w = gw + 2 * pad
cell_h = gh + 2 * pad
img = Image.new('L', (cell_w, cell_h), 0)
draw = ImageDraw.Draw(img)
draw.text((pad - left, pad - top), char, font=font, fill=255)
bitmap = np.asarray(img, dtype=np.float32) / 255.0
sdf = self._compute_sdf(bitmap, self.SDF_SPREAD)
glyph_cells.append((char, sdf, cell_w, cell_h,
left, top, right, bottom, advance))
# --- Second pass: shelf-based packing ---
max_w = self.ATLAS_MAX_WIDTH
shelf_x = 0
shelf_y = 0
shelf_h = 0
positions: list[tuple[int, int]] = []
for _, sdf, cw, ch, *_ in glyph_cells:
if sdf is None:
positions.append((0, 0))
continue
if shelf_x + cw > max_w:
shelf_y += shelf_h
shelf_x = 0
shelf_h = 0
positions.append((shelf_x, shelf_y))
shelf_x += cw
shelf_h = max(shelf_h, ch)
atlas_h = shelf_y + shelf_h
atlas_h = max(1 << (atlas_h - 1).bit_length() if atlas_h > 1 else 1, 64)
atlas_w = max(max_w, 64)
atlas = np.zeros((atlas_h, atlas_w), dtype=np.float32)
# --- Place glyphs and build metrics ---
for i, (char, sdf, cw, ch, left, top, right, bottom, advance) in enumerate(glyph_cells):
m = GlyphMetrics(char=char)
m.advance = advance / rs
if sdf is None:
self._metrics[char] = m
continue
ax, ay = positions[i]
atlas[ay:ay + ch, ax:ax + cw] = sdf
m.uv_x = ax / atlas_w
m.uv_y = ay / atlas_h
m.uv_w = cw / atlas_w
m.uv_h = ch / atlas_h
m.offset_x = (left - pad) / rs
m.offset_y = (top - pad) / rs
m.quad_w = cw / rs
m.quad_h = ch / rs
self._metrics[char] = m
self._atlas_width = atlas_w
self._atlas_height = atlas_h
self._atlas_data = atlas
@staticmethod
[docs]
def _compute_sdf(bitmap: np.ndarray, spread: float) -> np.ndarray:
"""Compute SDF from a greyscale bitmap.
Returns array in ``[0, 1]``: 0.5 = edge, >0.5 = inside, <0.5 = outside.
Falls back to a simple ramp when *scipy* is not installed.
"""
if not _HAS_SCIPY:
return np.clip(bitmap * 0.5 + 0.25, 0.0, 1.0)
threshold = 0.5
inside = bitmap >= threshold
outside = ~inside
dist_outside = _edt(outside).astype(np.float32)
dist_inside = _edt(inside).astype(np.float32)
sdf = (dist_inside - dist_outside) / (spread * 2.0) + 0.5
return np.clip(sdf, 0.0, 1.0)
# ----------------------------------------------------------------
# OpenGL texture management
# ----------------------------------------------------------------
[docs]
def upload(self):
"""Upload atlas data as a single-channel ``GL_R8`` OpenGL texture."""
if self._texture_id is not None:
return
if self._atlas_data is None:
return
from OpenGL.GL import (
glGenTextures, glBindTexture, glTexImage2D,
glTexParameteri, glGenerateMipmap, glPixelStorei,
GL_TEXTURE_2D, GL_RED, GL_UNSIGNED_BYTE,
GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER,
GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T,
GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR,
GL_CLAMP_TO_EDGE, GL_UNPACK_ALIGNMENT,
)
import ctypes as _ct
tex_id = (_ct.c_uint * 1)()
glGenTextures(1, tex_id)
self._texture_id = int(tex_id[0])
glBindTexture(GL_TEXTURE_2D, self._texture_id)
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
data_u8 = (self._atlas_data * 255).astype(np.uint8)
# GL_RED as internal format — the driver promotes it to GL_R8
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED,
self._atlas_width, self._atlas_height,
0, GL_RED, GL_UNSIGNED_BYTE,
data_u8.tobytes())
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glGenerateMipmap(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, 0)
[docs]
def bind(self, unit: int = 0):
"""Bind atlas texture to the given texture unit."""
if self._texture_id is None:
self.upload()
from OpenGL.GL import glActiveTexture, glBindTexture, GL_TEXTURE0, GL_TEXTURE_2D
glActiveTexture(GL_TEXTURE0 + unit)
glBindTexture(GL_TEXTURE_2D, self._texture_id)
[docs]
def destroy(self):
"""Free GPU texture memory."""
if self._texture_id is not None:
from OpenGL.GL import glDeleteTextures
glDeleteTextures(1, [self._texture_id])
self._texture_id = None
# ----------------------------------------------------------------
# Queries
# ----------------------------------------------------------------
[docs]
def get_metrics(self, char: str) -> GlyphMetrics | None:
"""Return metrics for *char*, or ``None`` if absent from the atlas."""
return self._metrics.get(char)
@property
[docs]
def metrics(self) -> dict[str, GlyphMetrics]:
"""Full metrics dictionary (read-only view)."""
return self._metrics
@property
[docs]
def line_height(self) -> float:
"""Line height relative to one em."""
return self._line_height
@property
[docs]
def ascender(self) -> float:
"""Ascender relative to one em."""
return self._ascender
@property
[docs]
def descender(self) -> float:
"""Descender relative to one em."""
return self._descender
@property
[docs]
def texture_id(self) -> int | None:
return self._texture_id
@property
[docs]
def atlas_size(self) -> tuple[int, int]:
"""``(width, height)`` of the atlas texture in pixels."""
return (self._atlas_width, self._atlas_height)
@property
[docs]
def atlas_data(self) -> np.ndarray | None:
"""Raw SDF atlas as a float32 array (for testing)."""
return self._atlas_data