diff --git a/CHANGELOG.md b/CHANGELOG.md index 92011dd8..995f3e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,16 @@ accepts both, but the store flags the old spelling as deprecated its own default (a failed lookup is retried after 30 minutes). Clearing an app's location in the web UI now actually clears it; the save used to drop the blank field, so the old value stayed. +- `src.common.bdf_font` — `load_bdf_face(path, size)` (a cached + `freetype.Face` plus the pixel size it really renders at, falling back to + the file's native strike) and `draw_bdf_text(draw, text, x, y, face, color)`. + `DisplayManager`, `FontManager`, `element_style` and the plugin test harness + now all load and draw BDF text through it; the panel's pixels are unchanged + and BDF text draws 10-250x faster. The plugin test harness's + `calendar_font` / `bdf_5x7_font` now has the panel's 7px size set: it used + to be an unsized face, so in golden images and `check_plugin` / + `dev_server` previews its text sat 6px above where the panel draws it (off + the canvas entirely near the top) and `get_font_height()` returned 0. - The web UI's Fonts tab has a **Used by** column: the loaded plugins that registered each font with `FontManager.register_manager_font()`, published diff --git a/src/common/README.md b/src/common/README.md index a35ee734..4c682574 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -36,6 +36,15 @@ Utilities for loading and managing team logos. Utilities for text processing and formatting. +## BDF Fonts (`bdf_font.py`) + +The one way to load and draw BDF bitmap fonts. `load_bdf_face(path, size)` +returns `(face, realised_px)`, falling back to the file's native strike when +it has none at `size`; `draw_bdf_text(draw, text, x, y, face, color)` draws +top-left anchored onto a PIL `ImageDraw` exactly as the panel does. +`DisplayManager`, `FontManager`, `element_style` and the plugin test harness +all go through it. + ## Scroll Helpers (`scroll_helper.py`) Utilities for scrolling text on the display. diff --git a/src/common/bdf_font.py b/src/common/bdf_font.py new file mode 100644 index 00000000..a792c3ab --- /dev/null +++ b/src/common/bdf_font.py @@ -0,0 +1,261 @@ +"""Loading and drawing BDF bitmap fonts: one loader, one rasterizer. + +BDF fonts are fixed-size bitmap strikes. FreeType renders them at the size +baked into the file and rejects any other size, and PIL cannot draw a +``freetype.Face`` at all, so the core draws BDF text itself, glyph by glyph. + +This used to be done in several places that drifted apart: +``FontManager``, ``element_style`` and ``DisplayManager`` each loaded faces +their own way, and ``DisplayManager`` and the plugin test harness +(``VisualTestDisplayManager``) each had a copy of the glyph drawing loop. The +harness renders plugin golden images and ``check_plugin`` / ``dev_server`` +previews, so a copy that differs from the panel's shows something the panel +never draws. Everything now goes through the two functions here: + +* :func:`load_bdf_face` -- a ``freetype.Face`` at the requested pixel size, + or at the file's native strike when the file has no strike at that size. +* :func:`draw_bdf_text` -- draw a string in a ``freetype.Face`` onto a PIL + ``ImageDraw``, top-left anchored like ``ImageDraw.text``. + +Only PIL and freetype-py are imported, so the module is as cheap to import +from the test harness as from core. +""" + +from __future__ import annotations + +import ctypes +import logging +import os +import threading +from collections import OrderedDict +from typing import Any, Optional, Sequence, Tuple + +from PIL import Image + +try: + import freetype +except ImportError: # pragma: no cover - freetype-py is a core requirement + freetype = None + +logger = logging.getLogger(__name__) + +__all__ = ["read_bdf_native_size", "load_bdf_face", "draw_bdf_text"] + + +# -------------------------------------------------------------------------- +# Loading +# -------------------------------------------------------------------------- + +def read_bdf_native_size(bdf_path: str) -> Optional[int]: + """A BDF file's one true pixel size, read from its header, or None. + + Prefers the PIXEL_SIZE property, which states the real pixel height + directly; falls back to the SIZE line's point-size only if PIXEL_SIZE is + absent, since point-size only equals pixel height at exactly 100dpi -- + several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined at 75dpi, where + the two values genuinely differ. Stops at the first STARTCHAR. + """ + size_line_value = None + try: + with open(bdf_path, "r", encoding="ascii", errors="ignore") as f: + for line in f: + if line.startswith("PIXEL_SIZE"): + parts = line.split() + if len(parts) >= 2: + return int(float(parts[1])) + elif line.startswith("SIZE") and size_line_value is None: + # Format: "SIZE " + parts = line.split() + if len(parts) >= 2: + size_line_value = int(float(parts[1])) + elif line.startswith("STARTCHAR"): + break + except (OSError, ValueError): + return None + return size_line_value + + +#: Loaded faces, keyed on (absolute path, requested size, mtime_ns, file size) +#: so a font file replaced on disk under the same name is loaded afresh. +#: Bounded LRU: the display process runs for weeks and every config save can +#: introduce a new (font, size) pair, but a panel draws from a handful. +_FACE_CACHE_MAX = 256 +_face_cache: "OrderedDict[tuple, Tuple[Any, int]]" = OrderedDict() +_face_cache_lock = threading.Lock() + + +def _face_at(path: str, size_px: int) -> Any: + face = freetype.Face(path) + # Character size in 1/64th points at 72dpi == pixel size. + face.set_char_size(size_px * 64, size_px * 64, 72, 72) + return face + + +def load_bdf_face(path: str, size_px: int) -> Tuple[Any, int]: + """``(face, realised_px)`` for the BDF file at ``path``. + + ``realised_px`` is ``size_px`` when the file has a strike at that size, + otherwise the file's native size: FreeType refuses any other size for a + bitmap font, and answering that with some other typeface (which both + ``FontManager`` and ``element_style`` once did) is worse than drawing the + font that was asked for at the size it can do. Callers that lay out by + size need ``realised_px``, not the size they asked for. + + Faces are cached per thread. A ``freetype.Face`` holds per-glyph state + (``load_char`` rewrites its glyph slot), and FreeType does not allow two + threads to use one face at once, so the display thread and a plugin's + update thread must never be handed the same object. Within a thread the + face is shared by every caller. Raises if the file can't be loaded at + either size. + """ + if freetype is None: + raise RuntimeError("freetype-py is not installed; BDF fonts need it") + size_px = int(size_px) + abs_path = os.path.abspath(path) + try: + st = os.stat(abs_path) + key = (threading.get_ident(), abs_path, size_px, + st.st_mtime_ns, st.st_size) + except OSError: + key = None # let freetype raise its own error below + + if key is not None: + with _face_cache_lock: + cached = _face_cache.get(key) + if cached is not None: + _face_cache.move_to_end(key) + return cached + + try: + entry = (_face_at(abs_path, size_px), size_px) + except Exception: + native = read_bdf_native_size(abs_path) + if not native or native == size_px: + raise + # A fresh Face: the first one already took a failed set_char_size. + entry = (_face_at(abs_path, native), native) + logger.debug( + "BDF font %s requested at %spx renders at its native %spx " + "(the file has no strike at the requested size)", + abs_path, size_px, native, + ) + + if key is not None: + with _face_cache_lock: + _face_cache[key] = entry + _face_cache.move_to_end(key) + while len(_face_cache) > _FACE_CACHE_MAX: + _face_cache.popitem(last=False) + return entry + + +def clear_face_cache() -> None: + """Drop every cached face (tests; a font directory swapped wholesale).""" + with _face_cache_lock: + _face_cache.clear() + + +# -------------------------------------------------------------------------- +# Drawing +# -------------------------------------------------------------------------- + +def _bitmap_bytes(bitmap: Any, nbytes: int) -> bytes: + """The first ``nbytes`` of a glyph bitmap's buffer, zero-padded. + + ``bitmap.buffer`` builds a Python list one byte at a time; reading the + underlying FT_Bitmap directly is the same bytes without that cost. + """ + raw = getattr(bitmap, "_FT_Bitmap", None) + if raw is not None and raw.buffer: + return ctypes.string_at(raw.buffer, nbytes) + buf = bytes(bitmap.buffer[:nbytes]) + if len(buf) < nbytes: + buf += bytes(nbytes - len(buf)) + return buf + + +def _glyph_points(bitmap: Any, left: int, top: int, + clip_w: int, clip_h: int) -> list: + """Every lit pixel of a glyph, clipped, as ``(x, y)`` pairs. + + The reference definition of which pixels a glyph lights: the MSB-first + bit ``j`` of byte ``i * pitch + j // 8``. Used only where the fast path + below can't express exactly the same thing. + """ + buffer = bitmap.buffer + pitch = bitmap.pitch + points = [] + for i in range(bitmap.rows): + for j in range(bitmap.width): + byte_index = i * pitch + (j // 8) + if byte_index < len(buffer) and buffer[byte_index] & (1 << (7 - (j % 8))): + px = left + j + py = top + i + if 0 <= px < clip_w and 0 <= py < clip_h: + points.append((px, py)) + return points + + +def draw_bdf_text(draw: Any, text: str, x: int, y: int, face: Any, + color: Any = (255, 255, 255), + clip: Optional[Sequence[int]] = None) -> int: + """Draw ``text`` in a ``freetype.Face`` with ``draw``; return the pen x. + + ``(x, y)`` is the top-left of the line, as for ``ImageDraw.text``: the + baseline is ``y`` plus the face's ascender. Each glyph's lit bits are set + to ``color`` exactly -- no blending, no anti-aliasing -- and pixels + outside ``[0, clip_w) x [0, clip_h)`` are skipped (``clip`` defaults to + the image size). The pen advances by each glyph's advance width. + + Glyphs are drawn as 1-bit masks with ``ImageDraw.bitmap`` rather than a + point at a time, which is pixel-identical and far faster. A ``draw`` that + blends (``ImageDraw.Draw(rgb_image, "RGBA")``) is drawn point by point, so + a translucent colour still blends exactly as it always has. + + Errors (a non-BDF ``face``, a bad colour) propagate after any glyphs + before the failing one are drawn; callers decide whether to log them. + """ + try: + ascender_px = face.size.ascender >> 6 + except Exception: + ascender_px = 0 + baseline_y = y + ascender_px + + if clip is None: + clip_w, clip_h = draw.im.size + else: + clip_w, clip_h = int(clip[0]), int(clip[1]) + blending = draw.mode != draw.im.mode + + for char in text: + face.load_char(char) + glyph = face.glyph + bitmap = glyph.bitmap + rows, width, pitch = bitmap.rows, bitmap.width, bitmap.pitch + left = x + glyph.bitmap_left + top = baseline_y - glyph.bitmap_top + + if rows > 0 and width > 0: + if blending or pitch <= 0: + points = _glyph_points(bitmap, left, top, clip_w, clip_h) + if points: + draw.point(points, fill=color) + else: + # The visible part of the glyph box, in glyph coordinates. + x0, y0 = max(0, -left), max(0, -top) + x1, y1 = min(width, clip_w - left), min(rows, clip_h - top) + if x0 < x1 and y0 < y1: + # Raw mode "1" with stride=pitch reads exactly the bits + # _glyph_points does, whatever the glyph's pixel mode. + mask = Image.frombytes( + "1", (width, rows), _bitmap_bytes(bitmap, rows * pitch), + "raw", "1", pitch) + if (x0, y0, x1, y1) != (0, 0, width, rows): + mask = mask.crop((x0, y0, x1, y1)) + # An all-blank glyph draws nothing -- and, as before, + # never touches the colour. + if mask.getbbox() is not None: + draw.bitmap((left + x0, top + y0), mask, fill=color) + + x += glyph.advance.x >> 6 + return x diff --git a/src/display_manager.py b/src/display_manager.py index bcbdfeac..603ee042 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -34,6 +34,7 @@ else: from contextlib import contextmanager from pathlib import Path from PIL import Image, ImageDraw, ImageFont +from src.common.bdf_font import draw_bdf_text, load_bdf_face from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path from src.display_geometry import ( DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, @@ -884,43 +885,16 @@ class DisplayManager: logger.error(f"Error clearing display: {e}") def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None): - """Draw text using BDF font with proper bitmap handling.""" + """Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left. + + Delegates to :func:`src.common.bdf_font.draw_bdf_text`, which the + plugin test harness uses too, so previews and golden images show the + pixels the panel does. Clipped to the logical display size. + """ try: - # Use the passed font or fall back to calendar_font face = font if font else self.calendar_font - - # Compute baseline from font ascender so caller can pass top-left y - try: - ascender_px = face.size.ascender >> 6 - except Exception: - ascender_px = 0 - baseline_y = y + ascender_px - - for char in text: - face.load_char(char) - bitmap = face.glyph.bitmap - - # Get glyph metrics - glyph_left = face.glyph.bitmap_left - glyph_top = face.glyph.bitmap_top - - # Draw the character - for i in range(bitmap.rows): - for j in range(bitmap.width): - byte_index = i * bitmap.pitch + (j // 8) - if byte_index < len(bitmap.buffer): - byte = bitmap.buffer[byte_index] - if byte & (1 << (7 - (j % 8))): - # Calculate actual pixel position - pixel_x = x + glyph_left + j - pixel_y = baseline_y - glyph_top + i - # Only draw if within bounds - if (0 <= pixel_x < self.width and 0 <= pixel_y < self.height): - self.draw.point((pixel_x, pixel_y), fill=color) - - # Move to next character - x += face.glyph.advance.x >> 6 - + draw_bdf_text(self.draw, text, x, y, face, color, + clip=(self.width, self.height)) except Exception as e: logger.error(f"Error drawing BDF text: {e}", exc_info=True) @@ -969,19 +943,13 @@ class DisplayManager: if not os.path.exists(self.calendar_font_path): raise FileNotFoundError(f"Font file not found at {self.calendar_font_path}") - # Load with freetype for proper BDF handling - face = freetype.Face(self.calendar_font_path) - # A freshly constructed Face has no active size, so - # face.size.height is 0 until set_char_size is called -- and - # get_font_height() reads exactly that. Without this, every - # caller measuring the 5x7 face got 0 and stacked rows on top - # of one another; the "Calendar font size: 0 pixels" line - # below has been printing the symptom on every start-up. - # font_manager._load_bdf_font already does this; the two paths - # disagreed about whether a Face was usable for measurement. - # 5x7.bdf is a fixed strike, so FreeType renders 7px whatever - # is asked for -- this sets the metrics, not the raster. - face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72) + # load_bdf_face sets the size: a Face built without + # set_char_size reports face.size.height 0, and every caller + # measuring the 5x7 face with get_font_height() got 0 and + # stacked rows on top of one another. 5x7.bdf is a fixed + # strike, so FreeType renders 7px whatever is asked for -- + # the size sets the metrics, not the raster. + face, _ = load_bdf_face(self.calendar_font_path, _CALENDAR_FONT_PX) logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}") logger.info(f"Calendar font size: {face.size.height >> 6} pixels") diff --git a/src/element_style.py b/src/element_style.py index 0285406f..50c45660 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -53,13 +53,9 @@ from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple, Union from PIL import ImageFont +from src.common.bdf_font import load_bdf_face, read_bdf_native_size from src.common.font_layout import load_truetype -try: - import freetype -except ImportError: # pragma: no cover - freetype ships with the core - freetype = None - logger = logging.getLogger(__name__) # Core install root (the directory that contains src/ and assets/fonts/), @@ -164,56 +160,25 @@ def native_bdf_size(font_name: str) -> Optional[int]: def _read_bdf_native_size(path: str) -> Optional[int]: - """A BDF file's own pixel size, delegated to FontManager. + """A BDF file's own pixel size (the web UI's fonts API imports this name). - Deliberately not reimplemented: FontManager's reader prefers PIXEL_SIZE - over the SIZE line's point-size (they differ on the several bundled - fonts defined at 75dpi) and stops at the first STARTCHAR. Core always - ships it; the guard is for the plugin test harnesses that stub the - module out. + See :func:`src.common.bdf_font.read_bdf_native_size`: it prefers + PIXEL_SIZE over the SIZE line's point-size, which differ on the several + bundled fonts defined at 75dpi. """ - try: - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(path) - except Exception: # pragma: no cover - defensive - return None + return read_bdf_native_size(path) def _load_bdf(path: str, size: int) -> Tuple[Any, int]: """A ``freetype.Face`` for a BDF file at the closest size it can do. - BDF fonts are fixed-size bitmap strikes, not scalable outlines: - FreeType accepts only the exact pixel size baked into the file and - raises for anything else. 32 of the 35 shipped fonts are BDF, so a - size the user picked in the web UI usually is not a valid strike. - - Retrying at the file's native size is the behaviour SportsCore already - has (``_load_custom_font_from_element_config``). Without it this - function fell through to the generic except below and returned - *PressStart2P* — so choosing 5x7.bdf at size 10 silently rendered a - completely different typeface rather than 5x7 at 7px. + BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel + size baked into the file, and 32 of the 35 shipped fonts are BDF, so a + size the user picked in the web UI usually is not a valid strike. The + shared loader retries at the native size; without that, 5x7.bdf at size + 10 used to fall through to *PressStart2P*, a different typeface. """ - if freetype is None: - raise RuntimeError("freetype not available for BDF fonts") - - def _face_at(px: int) -> Any: - face = freetype.Face(path) - # Character size in 1/64th points at 72dpi == pixel size. - face.set_char_size(px * 64, px * 64, 72, 72) - return face - - try: - return _face_at(size), size - except Exception: - native = _read_bdf_native_size(path) - if not native or native == size: - raise - # A fresh Face: the first one already took a failed set_char_size. - face = _face_at(native) - logger.debug("BDF font %s loaded at its native size %s " - "(requested %s is not a strike in this file)", - path, native, size) - return face, native + return load_bdf_face(path, size) def load_font(font_name: str, size: int) -> Any: diff --git a/src/font_manager.py b/src/font_manager.py index eb6f72b1..f013aecc 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -38,6 +38,7 @@ import time from collections import OrderedDict from pathlib import Path from PIL import ImageFont +from src.common.bdf_font import load_bdf_face, read_bdf_native_size from src.common.font_layout import load_truetype, resolve_asset_path from typing import Dict, Tuple, Optional, Union, Any, List from src.deprecation import deprecated @@ -517,29 +518,14 @@ class FontManager: return font def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face: - """Load a BDF font using FreeType.""" + """Load a BDF font through the shared loader. + + A size the file has no strike for comes back at the native strike + rather than failing over to PIL's default font, a different typeface + (see :func:`src.common.bdf_font.load_bdf_face`). + """ try: - native_size = self._read_bdf_native_size(font_path) - if native_size is not None and native_size != size_px: - # BDF is a fixed-strike bitmap format: FreeType renders the - # native size no matter what set_char_size asks for. - logger.debug( - "BDF font %s requested at %spx but renders at its native " - "%spx", font_path, size_px, native_size - ) - face = freetype.Face(font_path) - try: - # Character size in 1/64th points at 72dpi == pixel size. - face.set_char_size(size_px * 64, size_px * 64, 72, 72) - except freetype.FT_Exception: - # FreeType rejects any size but the strike's own, and get_font - # used to answer that with PIL's default font -- a different - # typeface. Use the native strike, as element_style does. - if native_size is None or native_size == size_px: - raise - face = freetype.Face(font_path) - face.set_char_size(native_size * 64, native_size * 64, 72, 72) - return face + return load_bdf_face(font_path, size_px)[0] except Exception as e: logger.error(f"Error loading BDF font {font_path}: {e}") raise @@ -554,30 +540,9 @@ class FontManager: @staticmethod def _read_bdf_native_size(bdf_path: str) -> Optional[int]: - """Read a BDF file's own header to find its one true pixel size. - Prefers the PIXEL_SIZE property, which states the real pixel height - directly; falls back to the SIZE line's point-size only if PIXEL_SIZE - is absent, since point-size only equals pixel height at exactly - 100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined - at 75dpi, where the two values genuinely differ.""" - size_line_value = None - try: - with open(bdf_path, "r", encoding="ascii", errors="ignore") as f: - for line in f: - if line.startswith("PIXEL_SIZE"): - parts = line.split() - if len(parts) >= 2: - return int(float(parts[1])) - elif line.startswith("SIZE") and size_line_value is None: - # Format: "SIZE " - parts = line.split() - if len(parts) >= 2: - size_line_value = int(float(parts[1])) - elif line.startswith("STARTCHAR"): - break - except (OSError, ValueError): - return None - return size_line_value + """A BDF file's one true pixel size; see + :func:`src.common.bdf_font.read_bdf_native_size`.""" + return read_bdf_native_size(bdf_path) def _get_fallback_font(self) -> ImageFont.ImageFont: """Get a fallback font when loading fails.""" diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index c5c1f8b4..d3e7ba13 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -14,13 +14,16 @@ PIL Image canvas and draws text using the actual project fonts. MAINTENANCE WARNING: this class is a deliberate fork of src/display_manager.py so it can run without hardware. It mirrors these DisplayManager methods by name and behavior: _load_fonts, -_draw_bdf_text, get_font_height, get_text_width, draw_text, +get_font_height, get_text_width, draw_text, draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/ _draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal, capture_mode, set_scrolling_state, is_currently_scrolling, process_deferred_updates, update_display, render_size. A behavior change to any of those in DisplayManager must be mirrored here, or plugin visual tests will pass against stale behavior. + +BDF text is not mirrored: both classes load BDF faces and draw BDF glyphs +through src/common/bdf_font.py, so those pixels cannot drift. """ import math @@ -31,6 +34,7 @@ from pathlib import Path from typing import Any, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont +from src.common.bdf_font import draw_bdf_text, load_bdf_face from src.common.font_layout import crisp_size, load_truetype from src.logging_config import get_logger @@ -147,16 +151,18 @@ class VisualTestDisplayManager: self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8)) self.font = self.regular_font # alias used by some code paths - # 5x7 BDF font via freetype + # 5x7 BDF font, loaded exactly as DisplayManager._load_fonts does + # (same loader, same 7px request as its _CALENDAR_FONT_PX). A bare + # freetype.Face has no active size, so its ascender reads 0 and + # every line drew a baseline too high. try: - import freetype bdf_path = str(fonts_dir / '5x7.bdf') if not os.path.exists(bdf_path): raise FileNotFoundError(f"BDF font not found: {bdf_path}") - face = freetype.Face(bdf_path) + face, _ = load_bdf_face(bdf_path, 7) self.calendar_font = face self.bdf_5x7_font = face - except (ImportError, FileNotFoundError, OSError) as e: + except Exception as e: # freetype missing or the file unloadable logger.debug("BDF font not available, using small_font as fallback: %s", e) self.calendar_font = self.small_font self.bdf_5x7_font = self.small_font @@ -300,41 +306,18 @@ class VisualTestDisplayManager: logger.debug(f"Error drawing image: {e}") def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None): - """Draw text using BDF font with proper bitmap handling. + """Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left. - Replicated from DisplayManager._draw_bdf_text(). + Not a copy: DisplayManager._draw_bdf_text calls the same + :func:`src.common.bdf_font.draw_bdf_text`, so what this draws is + what the panel draws. """ try: if isinstance(color, list): color = tuple(color) face = font if font else self.calendar_font - - # Compute baseline from font ascender - try: - ascender_px = face.size.ascender >> 6 - except Exception: - ascender_px = 0 - baseline_y = y + ascender_px - - for char in text: - face.load_char(char) - bitmap = face.glyph.bitmap - - glyph_left = face.glyph.bitmap_left - glyph_top = face.glyph.bitmap_top - - for i in range(bitmap.rows): - for j in range(bitmap.width): - byte_index = i * bitmap.pitch + (j // 8) - if byte_index < len(bitmap.buffer): - byte = bitmap.buffer[byte_index] - if byte & (1 << (7 - (j % 8))): - pixel_x = x + glyph_left + j - pixel_y = baseline_y - glyph_top + i - if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height: - self.draw.point((pixel_x, pixel_y), fill=color) - - x += face.glyph.advance.x >> 6 + draw_bdf_text(self.draw, text, x, y, face, color, + clip=(self.width, self.height)) except Exception as e: logger.debug(f"Error drawing BDF text: {e}") diff --git a/test/test_bdf_font.py b/test/test_bdf_font.py new file mode 100644 index 00000000..9a8bda48 --- /dev/null +++ b/test/test_bdf_font.py @@ -0,0 +1,283 @@ +"""The one BDF loader and the one BDF rasterizer (src/common/bdf_font.py). + +DisplayManager, the plugin test harness (VisualTestDisplayManager), FontManager +and element_style used to carry their own copies of both. Plugin golden images +depend on the exact pixels, so the rasterizer is checked against a frozen copy +of the per-pixel loop DisplayManager._draw_bdf_text ran before it was shared +(``_reference_draw`` below) for every bundled BDF font, at native and off-strike +sizes, clipped and unclipped. Pixels are compared as raw bytes, never PNG +hashes, so a Pillow upgrade can't fake or mask a difference. +""" + +import os +import sys +import types +from pathlib import Path + +import freetype +import pytest +from PIL import Image, ImageDraw + +os.environ.setdefault("EMULATOR", "true") + +from src.common import bdf_font +from src.common.bdf_font import draw_bdf_text, load_bdf_face, read_bdf_native_size + +FONTS_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts" +BDF_FONTS = sorted(p.name for p in FONTS_DIR.glob("*.bdf")) + +STRINGS = [ + "Hello, World!", "0123456789", "12:34 PM", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "AaBbGgJjQqYy|", "", " ", "café üñ 72°F —€", +] + + +def _reference_draw(draw, width, height, text, x, y, face, color): + """DisplayManager._draw_bdf_text as it was before the shared rasterizer. + + Frozen on purpose: it is the definition of the panel's output that the + fast path must reproduce. Do not "fix" it. + """ + try: + ascender_px = face.size.ascender >> 6 + except Exception: + ascender_px = 0 + baseline_y = y + ascender_px + for char in text: + face.load_char(char) + bitmap = face.glyph.bitmap + glyph_left = face.glyph.bitmap_left + glyph_top = face.glyph.bitmap_top + for i in range(bitmap.rows): + for j in range(bitmap.width): + byte_index = i * bitmap.pitch + (j // 8) + if byte_index < len(bitmap.buffer): + byte = bitmap.buffer[byte_index] + if byte & (1 << (7 - (j % 8))): + pixel_x = x + glyph_left + j + pixel_y = baseline_y - glyph_top + i + if 0 <= pixel_x < width and 0 <= pixel_y < height: + draw.point((pixel_x, pixel_y), fill=color) + x += face.glyph.advance.x >> 6 + + +def _pair(size=(64, 32), mode="RGB", draw_mode=None): + a = Image.new(mode, size) + b = Image.new(mode, size) + return a, ImageDraw.Draw(a, draw_mode), b, ImageDraw.Draw(b, draw_mode) + + +def _assert_same(expected, actual, what): + assert expected.tobytes() == actual.tobytes(), what + + +def _sizes(name): + native = read_bdf_native_size(str(FONTS_DIR / name)) + return sorted({native, native + 3, max(1, native - 2)}) + + +# ---------------------------------------------------------------- rasterizer + +@pytest.mark.parametrize("name", BDF_FONTS) +def test_every_bundled_font_matches_the_reference_raster(name): + path = str(FONTS_DIR / name) + w, h = 96, 24 + cases = [((0, 0), (255, 255, 255), text) for text in STRINGS] + # Clipped on every edge, in a colour that isn't all-or-nothing per channel. + cases += [(xy, (12, 200, 77), text) + for xy in ((-4, -3), (w - 11, h - 5), (w // 2, -9)) + for text in STRINGS[:2]] + for size in _sizes(name): + face, _ = load_bdf_face(path, size) + for (x, y), color, text in cases: + ref, rdraw, new, ndraw = _pair((w, h)) + _reference_draw(rdraw, w, h, text, x, y, face, color) + draw_bdf_text(ndraw, text, x, y, face, color) + _assert_same(ref, new, (name, size, x, y, color, text)) + + +def test_returns_the_pen_position(): + face, _ = load_bdf_face(str(FONTS_DIR / "5x7.bdf"), 7) + _, _, _, draw = _pair() + assert draw_bdf_text(draw, "", 3, 0, face) == 3 + assert draw_bdf_text(draw, "12:34", 3, 0, face) == 3 + 5 * 5 + + +def test_clip_smaller_than_the_canvas_matches_the_reference(): + # DisplayManager clips to its logical size, which is the canvas size in + # practice but not by construction; honour the clip as the loop did. + face, _ = load_bdf_face(str(FONTS_DIR / "6x10.bdf"), 10) + for clip in ((20, 7), (1, 1), (0, 0), (200, 200)): + ref, rdraw, new, ndraw = _pair() + _reference_draw(rdraw, clip[0], clip[1], "Mixed 123", -2, -1, face, (1, 2, 3)) + draw_bdf_text(ndraw, "Mixed 123", -2, -1, face, (1, 2, 3), clip=clip) + _assert_same(ref, new, clip) + + +def test_blending_draw_blends_exactly_like_the_reference(): + # ImageDraw.Draw(rgb, "RGBA") blends a translucent colour; a mask fill + # would not, so this path must fall back to points. + face, _ = load_bdf_face(str(FONTS_DIR / "7x13B.bdf"), 13) + ref, rdraw, new, ndraw = _pair(draw_mode="RGBA") + for img in (ref, new): + img.paste((40, 80, 120), (0, 0, *img.size)) + _reference_draw(rdraw, 64, 32, "Blend", 1, 1, face, (255, 0, 0, 128)) + draw_bdf_text(ndraw, "Blend", 1, 1, face, (255, 0, 0, 128)) + _assert_same(ref, new, "blend") + colours = {c for _, c in new.getcolors()} + assert (255, 0, 0) not in colours and len(colours) == 2, colours # blended + + +@pytest.mark.parametrize("mode,color", [("L", 200), ("P", (255, 0, 0)), ("RGBA", (9, 8, 7, 255)), ("1", 1)]) +def test_other_canvas_modes_match_the_reference(mode, color): + face, _ = load_bdf_face(str(FONTS_DIR / "5x8.bdf"), 8) + ref, rdraw, new, ndraw = _pair(mode=mode) + _reference_draw(rdraw, 64, 32, "Mode 42", 2, 2, face, color) + draw_bdf_text(ndraw, "Mode 42", 2, 2, face, color) + _assert_same(ref, new, mode) + + +def test_a_non_mono_face_reads_the_same_bits_as_the_reference(): + # A plugin can hand draw_text a freetype.Face of a TTF: 8-bit gray glyphs + # whose pitch is not ceil(width/8). The loop read them as packed bits, and + # so must the fast path -- same (odd) pixels, not "better" ones. + face = freetype.Face(str(FONTS_DIR / "PressStart2P-Regular.ttf")) + face.set_char_size(8 * 64, 8 * 64, 72, 72) + ref, rdraw, new, ndraw = _pair() + _reference_draw(rdraw, 64, 32, "Gray", 0, 0, face, (255, 255, 255)) + draw_bdf_text(ndraw, "Gray", 0, 0, face, (255, 255, 255)) + _assert_same(ref, new, "gray face") + + +def test_errors_propagate_after_earlier_glyphs_are_drawn(): + face, _ = load_bdf_face(str(FONTS_DIR / "5x7.bdf"), 7) + _, _, img, draw = _pair() + with pytest.raises(Exception): + draw_bdf_text(draw, "A", 0, 0, face, "not-a-colour") + # Blank glyphs never touch the colour, as before. + draw_bdf_text(draw, " ", 0, 0, face, "not-a-colour") + assert img.getbbox() is None + with pytest.raises(Exception): + draw_bdf_text(draw, "A", 0, 0, object()) + + +# ------------------------------------------------------------------- callers + +def _dm_stub(img): + from src.display_manager import DisplayManager + stub = types.SimpleNamespace(width=img.width, height=img.height, + draw=ImageDraw.Draw(img), calendar_font=None) + return DisplayManager, stub + + +@pytest.mark.parametrize("name", ["5x7.bdf", "tom-thumb.bdf", "9x18B.bdf", "MatrixChunky8X.bdf"]) +def test_display_manager_and_test_harness_draw_the_reference_pixels(name): + from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager + + face, _ = load_bdf_face(str(FONTS_DIR / name), 20) # off-strike for all four + for text in STRINGS: + ref, rdraw, dm_img, _ = _pair((64, 32)) + _reference_draw(rdraw, 64, 32, text, -1, 3, face, (255, 128, 0)) + + DisplayManager, stub = _dm_stub(dm_img) + DisplayManager._draw_bdf_text(stub, text, -1, 3, (255, 128, 0), face) + _assert_same(ref, dm_img, ("DisplayManager", name, text)) + + vt = VisualTestDisplayManager(64, 32) + vt.draw_text(text, -1, 3, (255, 128, 0), font=face) + _assert_same(ref, vt.image, ("VisualTestDisplayManager", name, text)) + + +def test_harness_calendar_font_is_the_panels(): + # The harness built a bare freetype.Face with no size: ascender 0, so + # every calendar_font line drew a baseline too high, and its + # get_font_height() returned 0. + from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager + + vt = VisualTestDisplayManager(64, 32) + panel_face, _ = load_bdf_face(str(FONTS_DIR / "5x7.bdf"), 7) + assert vt.calendar_font is panel_face + assert vt.get_font_height(vt.calendar_font) == panel_face.size.height >> 6 > 0 + + +# -------------------------------------------------------------------- loader + +def test_off_strike_size_loads_the_native_strike(): + path = str(FONTS_DIR / "5x7.bdf") + face, realised = load_bdf_face(path, 10) + assert isinstance(face, freetype.Face) + assert realised == 7 and face.size.y_ppem == 7 + assert load_bdf_face(path, 7)[1] == 7 + + +def test_faces_are_cached_and_shared_by_every_loader(): + from src.element_style import _load_bdf + from src.font_manager import FontManager + + path = str(FONTS_DIR / "6x10.bdf") + face, realised = load_bdf_face(path, 12) + assert load_bdf_face(path, 12)[0] is face + assert _load_bdf(path, 12) == (face, realised) + assert FontManager({})._load_bdf_font(path, 12) is face + + +def test_touched_file_is_reloaded(tmp_path): + # The cache key carries the file's mtime, so a font re-uploaded under the + # same name is not served from a stale face. + target = tmp_path / "f.bdf" + target.write_bytes((FONTS_DIR / "5x7.bdf").read_bytes()) + first, _ = load_bdf_face(str(target), 7) + assert load_bdf_face(str(target), 7)[0] is first + os.utime(target, ns=(10**9, 10**9)) + assert load_bdf_face(str(target), 7)[0] is not first + + +@pytest.mark.skipif(sys.platform == "win32", + reason="FreeType holds the font file open; Windows refuses to overwrite it") +def test_replaced_file_is_reloaded(tmp_path): + target = tmp_path / "f.bdf" + target.write_bytes((FONTS_DIR / "5x7.bdf").read_bytes()) + first, _ = load_bdf_face(str(target), 7) + target.write_bytes((FONTS_DIR / "6x10.bdf").read_bytes()) + os.utime(target, ns=(1, 1)) + second, realised = load_bdf_face(str(target), 7) + assert second is not first and realised == 10 + + +def test_unloadable_file_raises(tmp_path): + with pytest.raises(Exception): + load_bdf_face(str(tmp_path / "missing.bdf"), 7) + bad = tmp_path / "bad.bdf" + bad.write_text("not a font") + with pytest.raises(Exception): + load_bdf_face(str(bad), 7) + + +def test_cache_is_bounded(monkeypatch): + monkeypatch.setattr(bdf_font, "_FACE_CACHE_MAX", 2) + bdf_font.clear_face_cache() + path = str(FONTS_DIR / "5x7.bdf") + for size in (7, 8, 9): + load_bdf_face(path, size) + assert len(bdf_font._face_cache) == 2 + bdf_font.clear_face_cache() + + +def test_each_thread_gets_its_own_face(): + # FreeType forbids two threads using one face at once: load_char rewrites + # the face's glyph slot. Within a thread the face is shared. + import threading + path = str(FONTS_DIR / "5x7.bdf") + here, _ = load_bdf_face(path, 7) + assert load_bdf_face(path, 7)[0] is here + other = [] + worker = threading.Thread(target=lambda: other.append(load_bdf_face(path, 7)[0])) + worker.start() + worker.join() + assert other and other[0] is not here + + +def test_native_size_prefers_pixel_size_over_point_size(): + # 6x13.bdf is defined at 75dpi: SIZE says 12 (points), PIXEL_SIZE 13. + assert read_bdf_native_size(str(FONTS_DIR / "6x13.bdf")) == 13 + assert read_bdf_native_size(str(FONTS_DIR / "nope.bdf")) is None