132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""Render text/images into the Shining Mask's custom bitmap format.
|
|
|
|
The mask shows a 16-pixel-tall, arbitrarily-wide image. Scrolling text is
|
|
uploaded as two blobs concatenated together:
|
|
|
|
* a **bitmap** -- 2 bytes per column encoding which of the 16 rows are lit,
|
|
* a **color array** -- 3 bytes (RGB) per column giving each column's color.
|
|
|
|
This module ports mask-go/mask/draw.go. Text is rasterized with Pillow using
|
|
NotoSans (bundled) at the same metrics the Go reference uses (14px, baseline at
|
|
y=12, 16px tall), then thresholded to 1-bit. Output is byte-identical in
|
|
structure to the Go encoder; exact pixels may differ slightly because Pillow and
|
|
freetype-go rasterize antialiasing differently, but the result is the same
|
|
readable scrolling text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
from typing import List, Sequence, Tuple
|
|
|
|
from constants import MASK_HEIGHT
|
|
|
|
Color = Tuple[int, int, int]
|
|
|
|
# Bundled font, matching the Go reference. Override via text_to_columns(font=...).
|
|
DEFAULT_FONT = os.path.join(os.path.dirname(__file__), "NotoSans-Regular.ttf")
|
|
|
|
# Go converts the rasterized gray value (0..65535) and lights a pixel when it is
|
|
# brighter than 25000. For an 8-bit gray value v, Go sees v*257, so the
|
|
# equivalent 8-bit threshold is ceil(25000/257) = 98.
|
|
_GRAY8_ON_THRESHOLD = 98
|
|
|
|
|
|
def text_to_columns(
|
|
text: str,
|
|
*,
|
|
font: str = DEFAULT_FONT,
|
|
font_size: int = 14,
|
|
baseline: int = 12,
|
|
) -> List[List[int]]:
|
|
"""Rasterize ``text`` into a list of columns, each a list of 16 ints (0/1).
|
|
|
|
Column order is left-to-right; within a column, index 0 is the top row.
|
|
"""
|
|
from PIL import Image, ImageDraw, ImageFont # local import: optional dep
|
|
|
|
try:
|
|
pil_font = ImageFont.truetype(font, font_size)
|
|
except OSError:
|
|
# Fall back to a system NotoSans, then Pillow's built-in bitmap font.
|
|
for candidate in ("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", "NotoSans-Regular.ttf"):
|
|
try:
|
|
pil_font = ImageFont.truetype(candidate, font_size)
|
|
break
|
|
except OSError:
|
|
pil_font = None
|
|
if pil_font is None:
|
|
pil_font = ImageFont.load_default()
|
|
|
|
# Empty text -> no columns (matches mask-go, whose canvas width is 0).
|
|
width = math.ceil(pil_font.getlength(text)) if text else 0
|
|
if width <= 0:
|
|
return []
|
|
|
|
img = Image.new("L", (width, MASK_HEIGHT), color=0)
|
|
draw = ImageDraw.Draw(img)
|
|
# anchor="ls": x is the left pen origin, y is the text baseline (like Go's Pt).
|
|
try:
|
|
draw.text((0, baseline), text, font=pil_font, fill=255, anchor="ls")
|
|
except (TypeError, ValueError):
|
|
# Older Pillow / bitmap fonts: no baseline anchor support.
|
|
draw.text((0, baseline - font_size), text, font=pil_font, fill=255)
|
|
|
|
px = img.load()
|
|
columns: List[List[int]] = []
|
|
for x in range(img.width):
|
|
column = [1 if px[x, y] >= _GRAY8_ON_THRESHOLD else 0 for y in range(MASK_HEIGHT)]
|
|
columns.append(column)
|
|
return columns
|
|
|
|
|
|
def encode_bitmap(columns: Sequence[Sequence[int]]) -> bytes:
|
|
"""Encode 1-bit columns into the mask's 2-bytes-per-column format.
|
|
|
|
Each 16-row column becomes two bytes (little-endian within the column):
|
|
* byte 0 (low): rows 0..7, with row 0 as the most-significant bit,
|
|
* byte 1 (high): rows 8..15, with row 8 as the most-significant bit.
|
|
"""
|
|
out = bytearray()
|
|
for i, column in enumerate(columns):
|
|
if len(column) != MASK_HEIGHT:
|
|
raise ValueError(f"column {i} has {len(column)} rows, expected {MASK_HEIGHT}")
|
|
low = 0
|
|
high = 0
|
|
for j in range(8):
|
|
if column[j]:
|
|
low |= 1 << (7 - j) # row 0 -> bit 7
|
|
for j in range(8, MASK_HEIGHT):
|
|
if column[j]:
|
|
high |= 1 << (15 - j) # row 8 -> bit 7, row 15 -> bit 0
|
|
out.append(low)
|
|
out.append(high)
|
|
return bytes(out)
|
|
|
|
|
|
def encode_color_array(num_columns: int, color: Color = (255, 255, 255)) -> bytes:
|
|
"""One RGB triple per column. The color stays fixed to the text pixels."""
|
|
r, g, b = color
|
|
return bytes([r & 0xFF, g & 0xFF, b & 0xFF]) * num_columns
|
|
|
|
|
|
def encode_color_array_per_column(colors: Sequence[Color]) -> bytes:
|
|
"""Per-column RGB colors (one triple per column)."""
|
|
out = bytearray()
|
|
for r, g, b in colors:
|
|
out += bytes([r & 0xFF, g & 0xFF, b & 0xFF])
|
|
return bytes(out)
|
|
|
|
|
|
def build_text_upload(text: str, color: Color = (255, 255, 255), **render_kwargs):
|
|
"""Render ``text`` and return ``(bitmap_bytes, color_array_bytes)``.
|
|
|
|
The mask upload payload is ``bitmap + color_array``; the DATS command needs
|
|
``len(bitmap)`` separately so the mask can split the two.
|
|
"""
|
|
columns = text_to_columns(text, **render_kwargs)
|
|
bitmap = encode_bitmap(columns)
|
|
color_array = encode_color_array(len(columns), color)
|
|
return bitmap, color_array
|