"""5×7 bitmap text and optional TrueType (cursive) rendering for LED matrices.""" from __future__ import annotations from pathlib import Path from typing import Tuple from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index RGB = Tuple[int, int, int] # Each letter: 7 rows, 5 LSBs = columns (bit 4 = left). _FONT: dict[str, tuple[int, ...]] = { " ": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), "A": (0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11), "B": (0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E), "C": (0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E), "D": (0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E), "E": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F), "F": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10), "G": (0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E), "H": (0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11), "I": (0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E), "J": (0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C), "K": (0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11), "L": (0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F), "M": (0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11), "N": (0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11), "O": (0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E), "P": (0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10), "Q": (0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D), "R": (0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11), "S": (0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E), "T": (0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04), "U": (0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E), "V": (0x11, 0x11, 0x11, 0x11, 0x0A, 0x0A, 0x04), "W": (0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11), "X": (0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11), "Y": (0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04), "Z": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F), "0": (0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E), "1": (0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E), "2": (0x0E, 0x11, 0x01, 0x06, 0x08, 0x10, 0x1F), "3": (0x1F, 0x02, 0x04, 0x06, 0x01, 0x11, 0x0E), "4": (0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02), "5": (0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E), "6": (0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E), "7": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08), "8": (0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E), "9": (0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C), } CHAR_WIDTH = 5 CHAR_HEIGHT = 7 CHAR_SPACING = 1 CURSIVE_FONT_CANDIDATES = ( # Prefer readable italics at 9px; true script fonts turn to mush. "/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf", "/usr/share/fonts/opentype/dancingscript/DancingScript-Bold.otf", "/usr/share/fonts/opentype/dancingscript/DancingScript-Regular.otf", ) def text_width(text: str, spacing: int = CHAR_SPACING) -> int: text = text.upper() if not text: return 0 return len(text) * CHAR_WIDTH + (len(text) - 1) * spacing def _set(surface, x: int, y: int, color: RGB) -> None: if hasattr(surface, "width") and hasattr(surface, "height"): if 0 <= x < surface.width and 0 <= y < surface.height: surface[x, y] = color else: w = getattr(surface, "width", MATRIX_WIDTH) idx = matrix_pixel_index(x, y, w) if 0 <= idx < len(surface): surface[idx] = color def draw_char( surface, ch: str, x: int, y: int, color: RGB, *, scale: int = 1, ) -> None: glyph = _FONT.get(ch.upper(), _FONT[" "]) for row, bits in enumerate(glyph): for col in range(CHAR_WIDTH): if bits & (1 << (CHAR_WIDTH - 1 - col)): for sy in range(scale): for sx in range(scale): _set(surface, x + col * scale + sx, y + row * scale + sy, color) def draw_text( surface, text: str, x: int, y: int, color: RGB, *, scale: int = 1, spacing: int = CHAR_SPACING, ) -> None: cursor = x step = (CHAR_WIDTH + spacing) * scale for ch in text.upper(): draw_char(surface, ch, cursor, y, color, scale=scale) cursor += step def draw_text_centered( surface, text: str, color: RGB, *, scale: int = 1, spacing: int = CHAR_SPACING, ) -> None: w = getattr(surface, "width", MATRIX_WIDTH) h = getattr(surface, "height", MATRIX_HEIGHT) tw = text_width(text, spacing) * scale th = CHAR_HEIGHT * scale x = max(0, (w - tw) // 2) y = max(0, (h - th) // 2) draw_text(surface, text, x, y, color, scale=scale, spacing=spacing) def resolve_cursive_font(path: str | None = None) -> str: if path: if not Path(path).is_file(): raise FileNotFoundError(f"font not found: {path}") return path for candidate in CURSIVE_FONT_CANDIDATES: if Path(candidate).is_file(): return candidate raise FileNotFoundError( "No cursive font found. Install with: sudo apt install fonts-dancingscript" ) def render_font_mask( text: str, *, height: int = MATRIX_HEIGHT, font_path: str | None = None, size: int | None = None, threshold: int = 100, letter_spacing: int = 1, ) -> tuple[int, int, list[tuple[int, int]]]: """Rasterize ``text`` into on-pixels for an LED row height. Letters are drawn one-by-one with spacing so names stay readable on short matrices. Returns ``(width, height, [(x, y), ...])``. """ try: from PIL import Image, ImageDraw, ImageFont except ImportError as exc: raise ImportError( "Pillow is required for cursive text (pipenv install pillow)" ) from exc path = resolve_cursive_font(font_path) # Slightly larger than panel height reads better after vertical fit. point_size = size if size is not None else max(10, height + 3) font = ImageFont.truetype(path, point_size) glyphs: list[Image.Image] = [] for ch in text: bbox = font.getbbox(ch) gw = max(1, bbox[2] - bbox[0]) gh = max(1, bbox[3] - bbox[1]) g = Image.new("L", (gw + 2, height), 0) draw = ImageDraw.Draw(g) y = (height - gh) // 2 - bbox[1] draw.text((1 - bbox[0], y), ch, font=font, fill=255) glyphs.append(g) total_w = sum(g.width for g in glyphs) if glyphs: total_w += letter_spacing * (len(glyphs) - 1) canvas = Image.new("L", (max(1, total_w), height), 0) x = 0 for i, g in enumerate(glyphs): canvas.paste(g.point(lambda v: 255 if v > threshold else 0), (x, 0)) x += g.width + (letter_spacing if i < len(glyphs) - 1 else 0) pixels = canvas.load() xs = [ xx for xx in range(canvas.width) for yy in range(height) if pixels[xx, yy] > 0 ] if not xs: return 0, height, [] left, right = min(xs), max(xs) + 1 ons: list[tuple[int, int]] = [] for yy in range(height): for xx in range(left, right): if pixels[xx, yy] > 0: ons.append((xx - left, yy)) return right - left, height, ons def blit_mask( surface, ons: list[tuple[int, int]], x: int, y: int, color: RGB, ) -> None: for px, py in ons: _set(surface, x + px, y + py, color) def blit_mask_gradient( surface, ons: list[tuple[int, int]], x: int, y: int, *, width: int, phase: float = 0.0, cycles: float = 2.0, ) -> None: """Blit mask with a horizontal rainbow (hue shifts with ``phase``). ``cycles`` = how many full rainbows span the text width (higher = more colorful). """ from leds.colors import hsv_to_rgb span = max(1, width) for px, py in ons: hue = ((px / span) * cycles + phase) % 1.0 _set(surface, x + px, y + py, hsv_to_rgb(hue, 1.0, 1.0)) def draw_font_text( surface, text: str, x: int, y: int, color: RGB, *, font_path: str | None = None, size: int | None = None, letter_spacing: int = 1, ) -> int: """Draw TrueType text; returns rendered width in pixels.""" h = getattr(surface, "height", MATRIX_HEIGHT) width, _, ons = render_font_mask( text, height=h, font_path=font_path, size=size, letter_spacing=letter_spacing, ) blit_mask(surface, ons, x, y, color) return width def draw_font_text_centered( surface, text: str, color: RGB, *, font_path: str | None = None, size: int | None = None, letter_spacing: int = 1, ) -> int: w = getattr(surface, "width", MATRIX_WIDTH) h = getattr(surface, "height", MATRIX_HEIGHT) width, _, ons = render_font_mask( text, height=h, font_path=font_path, size=size, letter_spacing=letter_spacing, ) x = max(0, (w - width) // 2) blit_mask(surface, ons, x, 0, color) return width