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