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>
This commit is contained in:
169
leds/text.py
169
leds/text.py
@@ -1,7 +1,8 @@
|
||||
"""5×7 bitmap text for LED matrices."""
|
||||
"""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
|
||||
@@ -53,6 +54,15 @@ 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()
|
||||
@@ -122,3 +132,160 @@ def draw_text_centered(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user