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>
292 lines
8.7 KiB
Python
292 lines
8.7 KiB
Python
"""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
|