Files
portal/leds/text.py
Jimmy 5094c7bcee Add portal web simulator, SPI bridges, and Pico firmware updates.
Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:54:51 +12:00

292 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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