"""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