diff --git a/plugins.json b/plugins.json index 926231b4..066caa73 100644 --- a/plugins.json +++ b/plugins.json @@ -409,10 +409,10 @@ "plugin_path": "plugins/ledmatrix-leaderboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.2.3" + "latest_version": "1.3.0" }, { "id": "ledmatrix-flights", diff --git a/plugins/ledmatrix-leaderboard/README.md b/plugins/ledmatrix-leaderboard/README.md index 74dfe28f..624bafc1 100644 --- a/plugins/ledmatrix-leaderboard/README.md +++ b/plugins/ledmatrix-leaderboard/README.md @@ -38,6 +38,52 @@ A plugin for LEDMatrix that displays scrolling leaderboards and standings for mu - `max_duration`: Maximum display duration (30-600 seconds, default: 300) - `loop`: Continuously loop the leaderboard (default: true) +### Appearance (`global.appearance`) + +The leaderboard renders for a 1:1 LED matrix, where a partially-lit pixel is a +visibly dim LED rather than a smooth edge. These options control that: + +- `pixel_perfect_text` (default `true`): draw text with anti-aliasing off, so + every glyph pixel is fully on or fully off. Set to `false` for the older, + softer look. +- `crisp_logos` (default `true`): give logos hard edges instead of a ring of + half-lit pixels. +- `text_outline` (default `true`): black outline behind text so it stays + readable where it sits near a logo. +- `logo_scale` (default `1.0`): logo height as a fraction of the panel height. + `1.0` fits the panel exactly; anything above `1.0` crops the top and bottom of + every logo. +- `font_size` (default `0` = pick a size for the panel height): sizes are + snapped to the font's pixel grid — multiples of 8 for Press Start 2P — because + off-grid sizes are what make pixel fonts look blurry. + +### How many teams are shown + +Each league's `top_teams` sets how far down the standings to go. **Set it to `0` +to show every team the league returns** (all 32 NFL teams, the full AP Top 25, +and so on). + +A longer list needs proportionally more time on screen. The display controller +gives the plugin `min(plugin cap, core cap)` seconds and then moves on +mid-scroll, so a list longer than that budget simply stops partway through — +which looks like the leaderboard cutting off at an arbitrary team. The relevant +settings: + +| Setting | Where | Default | +|---|---|---| +| `global.dynamic_duration.max_duration_seconds` | this plugin | 600 | +| `global.dynamic_duration.controller_cap_seconds` | this plugin | 600 | +| `display.dynamic_duration.max_duration_seconds` | LEDMatrix core config | 180 | + +The **lowest** of the three wins, so the core's 180s default is usually the one +that decides it. All 32 NFL teams is roughly 3,200px of ticker: about 240s at +the default 15 px/s, or about 36s at 100 px/s. + +If the content will not fit the budget, the plugin logs a warning at startup +naming which cap is limiting it and roughly how much of the list will not be +reached. Raise that cap, increase the scroll speed +(`global.display.scroll_speed` / `scroll_delay`), or lower `top_teams`. + ### Per-League Settings #### NFL Configuration diff --git a/plugins/ledmatrix-leaderboard/config_schema.json b/plugins/ledmatrix-leaderboard/config_schema.json index 28ddcb59..e1dbef97 100644 --- a/plugins/ledmatrix-leaderboard/config_schema.json +++ b/plugins/ledmatrix-leaderboard/config_schema.json @@ -228,6 +228,45 @@ "type": "boolean", "default": false, "description": "Continuously loop the leaderboard" + }, + "appearance": { + "type": "object", + "description": "Pixel-perfect rendering options for text and logos", + "properties": { + "pixel_perfect_text": { + "type": "boolean", + "default": true, + "description": "Render text with hard pixel edges. Disable only if you prefer the older anti-aliased (softer, blurrier) look" + }, + "crisp_logos": { + "type": "boolean", + "default": true, + "description": "Give logos hard edges instead of a ring of half-lit pixels" + }, + "text_outline": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Draw a black outline around text so it stays readable over logos" + }, + "logo_scale": { + "x-advanced": true, + "type": "number", + "default": 1.0, + "minimum": 0.5, + "maximum": 1.5, + "description": "Logo height as a fraction of the panel height. 1.0 fits the panel exactly; above 1.0 crops the top and bottom of every logo" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 32, + "description": "Font size in pixels. 0 picks a size that suits the panel height. Values are snapped to the font's pixel grid (multiples of 8 for Press Start 2P) to keep text sharp" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -246,9 +285,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 32, - "description": "Number of top NFL teams to display" + "description": "Number of top NFL teams to display. 0 shows every team the standings return (up to 32). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -264,9 +303,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 30, - "description": "Number of top NBA teams to display" + "description": "Number of top NBA teams to display. 0 shows every team the standings return (up to 30). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -282,9 +321,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 30, - "description": "Number of top MLB teams to display" + "description": "Number of top MLB teams to display. 0 shows every team the standings return (up to 30). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -300,9 +339,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 130, - "description": "Number of top NCAA Football teams to display" + "description": "Number of top NCAA Football teams to display. 0 shows every team the standings return (up to 130). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -323,9 +362,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 32, - "description": "Number of top NHL teams to display" + "description": "Number of top NHL teams to display. 0 shows every team the standings return (up to 32). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -341,9 +380,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 350, - "description": "Number of top NCAA Men's Basketball teams to display" + "description": "Number of top NCAA Men's Basketball teams to display. 0 shows every team the standings return (up to 350). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -364,9 +403,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 60, - "description": "Number of top NCAA Men's Hockey teams to display" + "description": "Number of top NCAA Men's Hockey teams to display. 0 shows every team the standings return (up to 60). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -387,9 +426,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 350, - "description": "Number of top NCAA Women's Basketball teams to display" + "description": "Number of top NCAA Women's Basketball teams to display. 0 shows every team the standings return (up to 350). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -410,9 +449,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 350, - "description": "Number of top NCAA Baseball teams to display" + "description": "Number of top NCAA Baseball teams to display. 0 shows every team the standings return (up to 350). Long lists need a matching display duration - see the README" }, "season": { "x-advanced": true, @@ -444,4 +483,4 @@ "required": [ "enabled" ] -} \ No newline at end of file +} diff --git a/plugins/ledmatrix-leaderboard/data_fetcher.py b/plugins/ledmatrix-leaderboard/data_fetcher.py index 2a5d96ab..cf85cf9e 100644 --- a/plugins/ledmatrix-leaderboard/data_fetcher.py +++ b/plugins/ledmatrix-leaderboard/data_fetcher.py @@ -52,8 +52,25 @@ def fetch_standings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]] else: standings = self._fetch_teams_data(league_config) - # Apply top_teams limit centrally so config changes take effect immediately + # Apply top_teams limit centrally so config changes take effect immediately. + # 0 (or any non-positive value) means "show every team the API returned". top_teams = league_config.get('top_teams', 10) + try: + top_teams = int(top_teams) + except (TypeError, ValueError): + top_teams = 10 + + if top_teams <= 0: + self.logger.info( + "Showing all %d teams for %s (top_teams=0)", len(standings), league_key + ) + return standings + + if len(standings) < top_teams: + self.logger.info( + "Requested top %d teams for %s but only %d available", + top_teams, league_key, len(standings) + ) return standings[:top_teams] def _fetch_ncaa_fb_rankings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index 991e2df9..5b1a2012 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -3,14 +3,33 @@ Handles all image creation and rendering for the scrolling leaderboard display. Includes logo loading, text drawing with outlines, and layout calculations. + +Rendering is pixel-perfect by design: an LED matrix has no sub-pixels, so any +anti-aliased grey a glyph or logo edge lands on shows up as a dim, smeared LED. +Two things keep the output crisp: + +- Text is drawn with ``fontmode = "1"``, so a glyph pixel is either fully lit or + fully off no matter what font/size is configured. Default sizes are + additionally snapped to the font's own pixel grid (8px for Press Start 2P, + 7px for the 4x6 fallback) so glyphs land on whole pixels. +- Logos are downscaled with LANCZOS for detail, then their alpha is thresholded + so the edges are hard instead of a ring of half-lit pixels. """ +import math import os import logging from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont +# Pillow compatibility: Image.Resampling.LANCZOS arrived in Pillow 9.1. +# Fall back to the module-level constant for older versions. +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: # pragma: no cover - Pillow < 9.1 + RESAMPLE_FILTER = Image.LANCZOS + # Try to import logo downloader try: from src.logo_downloader import download_missing_logo @@ -31,67 +50,217 @@ class ImageRenderer: MARCH_MADNESS_LOGO_PATH = 'assets/sports/ncaa_logos/MARCH_MADNESS.png' - def __init__(self, display_height: int, logger: Optional[logging.Logger] = None): + #: Fonts whose glyphs are drawn on a fixed pixel grid. Rendering them at a + #: size that is not a whole multiple of the grid forces the rasteriser to + #: split single design pixels across screen pixels, which is what makes the + #: text look blurry on the panel. + PIXEL_FONT_GRIDS = { + 'PressStart2P-Regular.ttf': 8, + '4x6-font.ttf': 7, + } + + PRIMARY_FONT = 'assets/fonts/PressStart2P-Regular.ttf' + FALLBACK_FONT = 'assets/fonts/4x6-font.ttf' + + #: Horizontal gaps, in pixels, used by the team layout. + RANK_LOGO_GAP = 4 + LOGO_TEXT_GAP = 4 + TEAM_GAP = 12 + #: Width of the league logo column, and the gap after it. + LEAGUE_LOGO_WIDTH = 64 + LEAGUE_LOGO_GAP = 10 + #: Blank space between one league's last team and the next league's logo. + LEAGUE_SPACING = 40 + + def __init__(self, display_height: int, logger: Optional[logging.Logger] = None, + appearance: Optional[Dict[str, Any]] = None): """ Initialize image renderer. - + Args: display_height: Height of the display in pixels logger: Optional logger instance + appearance: Optional appearance overrides (see config_schema.json + ``global.appearance``) """ self.display_height = display_height self.logger = logger or logging.getLogger(__name__) + + appearance = appearance or {} + self.pixel_perfect_text = bool(appearance.get('pixel_perfect_text', True)) + self.crisp_logos = bool(appearance.get('crisp_logos', True)) + self.text_outline = bool(appearance.get('text_outline', True)) + self.logo_scale = self._clamp_float(appearance.get('logo_scale', 1.0), 0.5, 1.5, 1.0) + self.font_size_override = self._clamp_int(appearance.get('font_size', 0), 0, 32, 0) + + self.font_path = self._resolve_font_path() + self.font_grid = self.PIXEL_FONT_GRIDS.get( + os.path.basename(self.font_path or ''), 0 + ) self.fonts = self._load_fonts() - + + @staticmethod + def _clamp_float(value: Any, low: float, high: float, default: float) -> float: + try: + return max(low, min(high, float(value))) + except (TypeError, ValueError): + return default + + @staticmethod + def _clamp_int(value: Any, low: int, high: int, default: int) -> int: + try: + return max(low, min(high, int(value))) + except (TypeError, ValueError): + return default + + def _resolve_font_path(self) -> Optional[str]: + """Pick the best available font file, preferring Press Start 2P.""" + for candidate in (self.PRIMARY_FONT, self.FALLBACK_FONT): + if os.path.exists(candidate): + return candidate + return None + + def _snap_font_size(self, requested: int) -> int: + """ + Round a font size to the font's pixel grid. + + Press Start 2P only rasterises without anti-aliasing at multiples of 8 + (8, 16, 24...); the 4x6 fallback at multiples of 7. Off-grid sizes are + what produced the blurry text this renderer used to show. + """ + if self.font_grid <= 0: + return max(1, requested) + snapped = int(round(requested / self.font_grid)) * self.font_grid + return max(self.font_grid, snapped) + + def _auto_font_size(self) -> int: + """Choose an on-grid font size that suits the panel height.""" + if self.font_size_override: + return self._snap_font_size(self.font_size_override) + + grid = self.font_grid or 8 + # Aim for text about a quarter of the panel height, then snap to the + # grid. 32px panels land on one grid step, 64px panels on two. + target = max(grid, int(self.display_height / 4)) + return self._snap_font_size(target) + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: - """Load fonts for the leaderboard display.""" + """ + Load fonts for the leaderboard display. + + All sizes are snapped to the font's pixel grid so glyph edges land on + whole panel pixels. + """ fonts = {} - try: - # Try to load the Press Start 2P font first - fonts['small'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6) - fonts['medium'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['large'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - fonts['xlarge'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 14) - self.logger.info("Successfully loaded Press Start 2P font") - except IOError: - self.logger.warning("Press Start 2P font not found, trying 4x6 font") + base = self._auto_font_size() + grid = self.font_grid or 8 + sizes = { + 'small': max(grid, base - grid) if base > grid else base, + 'medium': base, + 'large': base, + 'xlarge': base, + } + + if self.font_path: try: - fonts['small'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - fonts['medium'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['large'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 10) - fonts['xlarge'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12) - self.logger.info("Successfully loaded 4x6 font") - except IOError: - self.logger.warning("4x6 font not found, using default PIL font") - default_font = ImageFont.load_default() - fonts = { - 'small': default_font, - 'medium': default_font, - 'large': default_font, - 'xlarge': default_font - } - except Exception as e: - self.logger.error(f"Error loading fonts: {e}") - default_font = ImageFont.load_default() - fonts = { - 'small': default_font, - 'medium': default_font, - 'large': default_font, - 'xlarge': default_font - } - return fonts - - def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, - font: ImageFont.FreeTypeFont, fill: tuple = (255, 255, 255), - outline_color: tuple = (0, 0, 0)): - """Draw text with a black outline for better readability on LED matrix.""" + for key, size in sizes.items(): + fonts[key] = ImageFont.truetype(self.font_path, size) + self.logger.info( + "Loaded %s at pixel-aligned sizes %s (grid=%spx)", + os.path.basename(self.font_path), sizes, self.font_grid or 'n/a' + ) + return fonts + except (IOError, OSError) as e: + self.logger.warning("Could not load %s: %s", self.font_path, e) + except Exception as e: # pragma: no cover - defensive + self.logger.error("Error loading fonts: %s", e) + + self.logger.warning("No pixel font available, using default PIL font") + default_font = ImageFont.load_default() + return {key: default_font for key in ('small', 'medium', 'large', 'xlarge')} + + def _text_advance(self, text: str, font: ImageFont.FreeTypeFont) -> int: + """Width a string occupies for layout purposes, in whole pixels.""" + try: + return int(math.ceil(font.getlength(text))) + except AttributeError: # pragma: no cover - very old Pillow + bbox = font.getbbox(text) + return int(bbox[2] - bbox[0]) + + def _text_ink_box(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]: + """Return (ink_top, ink_height) for a string relative to the draw origin.""" + try: + bbox = font.getbbox(text) + except AttributeError: # pragma: no cover - very old Pillow + return 0, getattr(font, 'size', self.display_height // 4) + return int(bbox[1]), int(bbox[3] - bbox[1]) + + def _text_baseline_y(self, text: str, font: ImageFont.FreeTypeFont, height: int) -> int: + """ + Y to pass to ``draw.text`` so the string's ink is vertically centred. + + ``draw.text`` positions by the ascender line, not by the ink box, so the + ink offset has to be subtracted or the text sits low and can clip off + the bottom of the panel. + """ + ink_top, ink_height = self._text_ink_box(text, font) + return (height - ink_height) // 2 - ink_top + + def _make_draw(self, image: Image.Image) -> ImageDraw.ImageDraw: + """ + Get a draw context that rasterises text without anti-aliasing. + + ``fontmode = "1"`` switches Pillow to 1-bit glyph rendering, so every + text pixel is either fully lit or off. Left on the default the panel + shows partially-lit LEDs around every glyph, which is what made this + display read as blurry. + """ + draw = ImageDraw.Draw(image) + if self.pixel_perfect_text: + draw.fontmode = "1" + return draw + + def _draw_text(self, draw: ImageDraw.ImageDraw, text: str, position: tuple, + font: ImageFont.FreeTypeFont, fill: tuple = (255, 255, 255), + outline_color: tuple = (0, 0, 0)): + """Draw text, optionally with a black outline for contrast over logos.""" + if not text: + return + x, y = position - # Draw outline - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - # Draw text + if self.text_outline: + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), + (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) - + + def _prepare_logo(self, logo: Image.Image, max_width: int, + max_height: int) -> Optional[Image.Image]: + """ + Scale a logo to fit ``max_width`` x ``max_height`` with hard edges. + + Aspect ratio is preserved (squashing a non-square logo into a square box + is its own kind of blur), and the alpha channel is thresholded so the + edge is a clean on/off boundary rather than a ring of half-lit pixels. + """ + if logo is None or max_width <= 0 or max_height <= 0: + return None + try: + logo = logo.convert('RGBA') + scale = min(max_width / logo.width, max_height / logo.height) + target_w = max(1, int(logo.width * scale)) + target_h = max(1, int(logo.height * scale)) + logo = logo.resize((target_w, target_h), RESAMPLE_FILTER) + + if self.crisp_logos: + r, g, b, a = logo.split() + a = a.point(lambda p: 255 if p >= 128 else 0) + logo = Image.merge('RGBA', (r, g, b, a)) + return logo + except Exception as e: + self.logger.error("Error preparing logo: %s", e) + return None + def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: """Get team logo from the configured directory, downloading if missing.""" if not team_abbr or not logo_dir: @@ -105,7 +274,7 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st return logo else: self.logger.warning(f"Logo not found at path: {logo_path}") - + # Try to download the missing logo if league: self.logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") @@ -114,12 +283,12 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st logo = Image.open(logo_path) self.logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") return logo - + return None except Exception as e: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None - + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: """Get league logo from the configured path.""" if not league_logo_path: @@ -135,140 +304,150 @@ def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: except Exception as e: self.logger.error(f"Error loading league logo: {e}") return None - + + def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: + """ + Resolve every league/team into a measured, draw-ready layout. + + Measuring and drawing used to be two separate passes over the same data, + which let them disagree — the width pass always budgeted for a team logo + while the draw pass skipped it when the file was missing, so the strip + ended up either padded with dead space or clipped short of the last + team. Resolving logos once here means the measured width is exactly the + drawn width. + + Returns: + (layout, total_width) where total_width is the exact content width. + """ + height = self.display_height + logo_box = max(1, int(height * self.logo_scale)) + layout = [] + total_width = 0 + + for league_data in leaderboard_data: + league_key = league_data['league'] + league_config = league_data['league_config'] + + league_logo_path = league_config.get('league_logo') + if league_data.get('is_tournament') and league_key in ('ncaam_basketball', 'ncaaw_basketball'): + if os.path.exists(self.MARCH_MADNESS_LOGO_PATH): + league_logo_path = self.MARCH_MADNESS_LOGO_PATH + league_logo = self._prepare_logo( + self._get_league_logo(league_logo_path), self.LEAGUE_LOGO_WIDTH, height + ) + + teams = [] + teams_width = 0 + for i, team in enumerate(league_data['teams']): + number_text = self._get_number_text(league_key, league_config, team, i) + number_width = self._text_advance(number_text, self.fonts['xlarge']) + + team_text = team.get('abbreviation', '') + text_width = self._text_advance(team_text, self.fonts['large']) + + team_logo = self._prepare_logo( + self._get_team_logo(league_key, team.get('id'), team_text, + league_config.get('logo_dir')), + logo_box, logo_box + ) + + width = number_width + text_width + self.TEAM_GAP + self.LOGO_TEXT_GAP + if team_logo: + width += self.RANK_LOGO_GAP + team_logo.width + + teams.append({ + 'number_text': number_text, + 'number_width': number_width, + 'text': team_text, + 'text_width': text_width, + 'logo': team_logo, + 'width': width, + }) + teams_width += width + + league_width = self.LEAGUE_LOGO_WIDTH + self.LEAGUE_LOGO_GAP + teams_width + layout.append({ + 'key': league_key, + 'logo': league_logo, + 'teams': teams, + 'width': league_width, + }) + total_width += league_width + self.LEAGUE_SPACING + + return layout, total_width + def create_leaderboard_image(self, leaderboard_data: List[Dict[str, Any]]) -> Optional[Image.Image]: """ Create the scrolling leaderboard image. - + Args: leaderboard_data: List of league data dictionaries with teams - + Returns: PIL Image containing the full scrolling leaderboard, or None on error """ if not leaderboard_data: self.logger.warning("No leaderboard data available") return None - + try: height = self.display_height - spacing = 40 # Spacing between leagues - - # Calculate total width needed - total_width = 0 - for league_data in leaderboard_data: - league_key = league_data['league'] - league_config = league_data['league_config'] - teams = league_data['teams'] - - league_logo_width = 64 - teams_width = 0 - logo_size = int(height * 1.2) - - for i, team in enumerate(teams): - number_text = self._get_number_text(league_key, league_config, team, i) - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] - - team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - - team_width = number_width + 4 + logo_size + 4 + text_width + 12 - teams_width += team_width - - league_width = league_logo_width + teams_width + 20 - total_width += league_width + spacing - - # Create the main image + layout, total_width = self._build_layout(leaderboard_data) + if total_width <= 0: + self.logger.warning("Leaderboard layout measured zero width") + return None + leaderboard_image = Image.new('RGB', (total_width, height), (0, 0, 0)) - draw = ImageDraw.Draw(leaderboard_image) - + draw = self._make_draw(leaderboard_image) + current_x = 0 - for league_idx, league_data in enumerate(leaderboard_data): - league_key = league_data['league'] - league_config = league_data['league_config'] - teams = league_data['teams'] - - self.logger.info(f"Drawing League {league_idx+1} ({league_key}) starting at x={current_x}px") - - # Draw league logo (swap to March Madness logo during tournament) - league_logo_path = league_config['league_logo'] - if league_data.get('is_tournament') and league_key in ('ncaam_basketball', 'ncaaw_basketball'): - if os.path.exists(self.MARCH_MADNESS_LOGO_PATH): - league_logo_path = self.MARCH_MADNESS_LOGO_PATH - league_logo = self._get_league_logo(league_logo_path) + for league_idx, league in enumerate(layout): + self.logger.info( + "Drawing League %d (%s) at x=%dpx, %d teams, %dpx wide", + league_idx + 1, league['key'], current_x, len(league['teams']), league['width'] + ) + + league_logo = league['logo'] if league_logo: - logo_height = height - 4 - logo_width = int(logo_height * league_logo.width / league_logo.height) - logo_x = current_x + (64 - logo_width) // 2 - logo_y = 2 - league_logo = league_logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS) - leaderboard_image.paste(league_logo, (logo_x, logo_y), - league_logo if league_logo.mode == 'RGBA' else None) - - # Move to team section - current_x += 64 + 10 - team_x = current_x - logo_size = int(height * 1.2) - - # Draw teams - for i, team in enumerate(teams): - number_text = self._get_number_text(league_key, league_config, team, i) - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] - number_height = number_bbox[3] - number_bbox[1] - number_y = (height - number_height) // 2 - self._draw_text_with_outline(draw, number_text, (team_x, number_y), - self.fonts['xlarge'], fill=(255, 255, 0)) - - # Draw team logo - team_logo = self._get_team_logo(league_key, team.get('id'), - team['abbreviation'], league_config['logo_dir']) + logo_x = current_x + (self.LEAGUE_LOGO_WIDTH - league_logo.width) // 2 + logo_y = (height - league_logo.height) // 2 + leaderboard_image.paste(league_logo, (logo_x, logo_y), league_logo) + + team_x = current_x + self.LEAGUE_LOGO_WIDTH + self.LEAGUE_LOGO_GAP + + for team in league['teams']: + number_text = team['number_text'] + number_y = self._text_baseline_y(number_text, self.fonts['xlarge'], height) + self._draw_text(draw, number_text, (team_x, number_y), + self.fonts['xlarge'], fill=(255, 255, 0)) + + text_x = team_x + team['number_width'] + team_logo = team['logo'] if team_logo: - team_logo = team_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) - logo_x = team_x + number_width + 4 - logo_y_pos = (height - logo_size) // 2 - leaderboard_image.paste(team_logo, (logo_x, logo_y_pos), - team_logo if team_logo.mode == 'RGBA' else None) - - # Draw team abbreviation - team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - text_x = logo_x + logo_size + 4 - text_y = (height - text_height) // 2 - self._draw_text_with_outline(draw, team_text, (text_x, text_y), - self.fonts['large'], fill=(255, 255, 255)) - - team_width = number_width + 4 + logo_size + 4 + text_width + 12 - else: - # Fallback if no logo - team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - text_x = team_x + number_width + 4 - text_y = (height - text_height) // 2 - self._draw_text_with_outline(draw, team_text, (text_x, text_y), - self.fonts['large'], fill=(255, 255, 255)) - team_width = number_width + 4 + text_width + 12 - - team_x += team_width - - current_x = team_x + 20 + spacing - - # Calculate actual content width - actual_content_width = current_x - (20 + spacing) - - self.logger.info(f"Created leaderboard image: {total_width}px wide (actual: {actual_content_width}px)") + logo_x = text_x + self.RANK_LOGO_GAP + logo_y_pos = (height - team_logo.height) // 2 + leaderboard_image.paste(team_logo, (logo_x, logo_y_pos), team_logo) + text_x = logo_x + team_logo.width + + text_x += self.LOGO_TEXT_GAP + text_y = self._text_baseline_y(team['text'], self.fonts['large'], height) + self._draw_text(draw, team['text'], (text_x, text_y), + self.fonts['large'], fill=(255, 255, 255)) + + team_x += team['width'] + + current_x = team_x + self.LEAGUE_SPACING + + self.logger.info( + "Created leaderboard image: %dpx wide, %d league(s), %d team(s)", + total_width, len(layout), sum(len(l['teams']) for l in layout) + ) return leaderboard_image - + except Exception as e: self.logger.error(f"Error creating leaderboard image: {e}") return None - + def _get_number_text(self, league_key: str, league_config: Dict[str, Any], team: Dict[str, Any], index: int) -> str: """Get the number/ranking text to display for a team.""" @@ -289,4 +468,3 @@ def _get_number_text(self, league_key: str, league_config: Dict[str, Any], return f"{index+1}." else: return f"{index+1}." - diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index bb76567b..cc3400f9 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -56,6 +56,9 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.display_duration = self.global_config.get('display_duration', 30) # Scroll speed configuration - prefer display object (granular control), fallback to scroll_pixels_per_second for backward compatibility + # Seeded so get_info()/logging never touch an unset attribute on the + # time-based path, which does not assign scroll_speed. + self.scroll_speed = self.global_config.get('scroll_speed', 1.0) display_config = self.global_config.get('display', {}) if display_config and ('scroll_speed' in display_config or 'scroll_delay' in display_config): # New format: use display object for granular control @@ -89,9 +92,10 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.request_timeout = self.global_config.get('request_timeout', 30) # Initialize components + self.appearance = self.global_config.get('appearance', {}) or {} self.league_config = LeagueConfig(config, self.logger) self.data_fetcher = DataFetcher(cache_manager, self.logger, self.request_timeout) - self.image_renderer = ImageRenderer(self.display_height, self.logger) + self.image_renderer = ImageRenderer(self.display_height, self.logger, self.appearance) # Initialize scroll helper self.scroll_helper = ScrollHelper(self.display_width, self.display_height, self.logger) @@ -335,9 +339,10 @@ def _create_leaderboard_image(self) -> None: self.scroll_helper.set_scrolling_image(leaderboard_image) # Dynamic duration is automatically calculated by set_scrolling_image() self._cycle_complete = False - + self.logger.info(f"Created leaderboard image: {leaderboard_image.width}x{leaderboard_image.height}") self.logger.info(f"Dynamic duration: {self.scroll_helper.get_dynamic_duration()}s") + self._warn_if_content_will_be_truncated(leaderboard_image.width) else: self.logger.error("Failed to create leaderboard image") self.scroll_helper.clear_cache() @@ -346,6 +351,97 @@ def _create_leaderboard_image(self) -> None: self.logger.error(f"Error creating leaderboard image: {e}") self.scroll_helper.clear_cache() + #: The core's own fallback when display.dynamic_duration.max_duration_seconds + #: is unset (DEFAULT_DYNAMIC_DURATION_CAP in src/display_controller.py). + CORE_DEFAULT_DYNAMIC_CAP = 180.0 + + def _core_dynamic_cap(self) -> float: + """ + Read the core's global dynamic-duration cap. + + The display controller uses ``min(plugin cap, global cap)``, so the + global value is frequently the one that decides how much of the ticker + is actually reached. It cannot be read through ``self.global_config`` + here because this plugin reassigns that to its own config slice, so the + core's config managers are consulted the same way BasePlugin does. + """ + for owner in (self.plugin_manager, self.cache_manager): + config_manager = getattr(owner, 'config_manager', None) + if config_manager is None: + continue + try: + core_config = config_manager.get_config() + except Exception: + # An unreadable core config must not stop the plugin loading; + # fall through to the next source, then to the documented + # default. Logged rather than swallowed so a persistently + # broken config manager is diagnosable. + self.logger.debug( + "Could not read core config from %s", type(owner).__name__, + exc_info=True, + ) + continue + if not isinstance(core_config, dict) or not core_config: + continue + cap = (core_config.get('display', {}) + .get('dynamic_duration', {}) + .get('max_duration_seconds')) + try: + cap = float(cap) + except (TypeError, ValueError): + return self.CORE_DEFAULT_DYNAMIC_CAP + return cap if cap > 0 else float('inf') + return self.CORE_DEFAULT_DYNAMIC_CAP + + def _effective_pixels_per_second(self) -> float: + """Resolve the configured scroll speed to pixels per second.""" + if getattr(self, 'scroll_pixels_per_second', None): + return float(self.scroll_pixels_per_second) + if self.scroll_delay and self.scroll_delay > 0: + return float(self.scroll_speed) / float(self.scroll_delay) + return float(self.scroll_speed) * 100.0 + + def _warn_if_content_will_be_truncated(self, image_width: int) -> None: + """ + Warn when the ticker is longer than the display controller will show. + + The controller caps a plugin's dynamic duration at + ``min(plugin cap, core global cap)`` and moves on when that expires, + mid-scroll. On a long list — a full 32-team league, say — that reads as + the leaderboard simply cutting off partway through, with no error + anywhere to explain it. Surfacing the arithmetic makes the fix obvious. + """ + if not self.dynamic_duration_enabled: + return + + try: + pixels_per_second = self._effective_pixels_per_second() + if pixels_per_second <= 0: + return + + required = (image_width + self.display_width) / pixels_per_second + required *= (1.0 + self.duration_buffer) + + core_cap = self._core_dynamic_cap() + budget = min(self.max_duration, self.dynamic_duration_cap, core_cap) + if required <= budget: + return + + shown_px = budget * pixels_per_second + limiter = ("the core's display.dynamic_duration.max_duration_seconds" + if core_cap <= min(self.max_duration, self.dynamic_duration_cap) + else "this plugin's global.dynamic_duration settings") + self.logger.warning( + "Leaderboard content (%dpx) needs %.0fs to scroll at %.0f px/s but the " + "duration budget is only %.0fs (limited by %s) - roughly the last %.0f%% " + "of the list will not be reached before the display moves on. Raise that " + "cap, increase the scroll speed, or lower top_teams.", + image_width, required, pixels_per_second, budget, limiter, + max(0.0, 100.0 * (1.0 - shown_px / max(image_width, 1))), + ) + except Exception as e: # pragma: no cover - diagnostics only + self.logger.debug("Could not evaluate content duration budget: %s", e) + def _display_fallback_message(self) -> None: """Display a fallback message when no data is available.""" try: @@ -583,15 +679,19 @@ def get_info(self) -> Dict[str, Any]: """Return plugin info for web UI.""" info = super().get_info() + fetched_counts = {d['league']: len(d['teams']) for d in self.leaderboard_data} leagues_config = {} for league_key in self.league_config.get_enabled_leagues(): league_config = self.league_config.get_league_config(league_key) if league_config: + top_teams = league_config.get('top_teams', 10) leagues_config[league_key] = { 'enabled': True, - 'top_teams': league_config.get('top_teams', 10) + 'top_teams': top_teams, + 'show_all': not top_teams or top_teams <= 0, + 'teams_displayed': fetched_counts.get(league_key, 0) } - + info.update({ 'total_teams': sum(len(d['teams']) for d in self.leaderboard_data), 'enabled_leagues': self.league_config.get_enabled_leagues(), @@ -604,6 +704,13 @@ def get_info(self) -> Dict[str, Any]: 'min_duration': self.min_duration, 'max_duration': self.max_duration, 'leagues_config': leagues_config, + 'appearance': { + 'pixel_perfect_text': self.image_renderer.pixel_perfect_text, + 'crisp_logos': self.image_renderer.crisp_logos, + 'text_outline': self.image_renderer.text_outline, + 'logo_scale': self.image_renderer.logo_scale, + 'font_size': self.image_renderer.fonts['large'].size, + }, 'scroll_info': self.scroll_helper.get_scroll_info() if self.scroll_helper else None }) return info diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 49bf591b..ee200369 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.2.3", + "version": "1.3.0", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "version": "1.3.0", + "released": "2026-08-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Pixel-perfect rendering: text is drawn with anti-aliasing off and at font sizes snapped to the pixel grid, and logos get hard alpha edges and are fitted inside the panel instead of being scaled to 120% and cropped top and bottom. Layout is now measured and drawn in a single pass so the strip width matches its contents. top_teams accepts 0 to show every team a league returns, and the plugin warns when the list is longer than the display-duration budget will reach. New global.appearance options: pixel_perfect_text, crisp_logos, text_outline, logo_scale, font_size." + }, { "version": "1.2.3", "released": "2026-07-31", @@ -73,7 +79,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/ledmatrix-leaderboard/test/fixtures/mock.json b/plugins/ledmatrix-leaderboard/test/fixtures/mock.json new file mode 100644 index 00000000..952cfafb --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test/fixtures/mock.json @@ -0,0 +1,109 @@ +{ + "leaderboard_nfl_standings": { + "standings": [ + { + "name": "Kansas City Chiefs", + "id": "1", + "abbreviation": "KC", + "wins": 12, + "losses": 2, + "ties": 0, + "win_percentage": 0.857, + "record_summary": "12-2" + }, + { + "name": "Buffalo Bills", + "id": "2", + "abbreviation": "BUF", + "wins": 11, + "losses": 3, + "ties": 0, + "win_percentage": 0.786, + "record_summary": "11-3" + }, + { + "name": "Philadelphia Eagles", + "id": "3", + "abbreviation": "PHI", + "wins": 11, + "losses": 3, + "ties": 0, + "win_percentage": 0.786, + "record_summary": "11-3" + }, + { + "name": "Detroit Lions", + "id": "4", + "abbreviation": "DET", + "wins": 10, + "losses": 4, + "ties": 0, + "win_percentage": 0.714, + "record_summary": "10-4" + }, + { + "name": "Baltimore Ravens", + "id": "5", + "abbreviation": "BAL", + "wins": 10, + "losses": 4, + "ties": 0, + "win_percentage": 0.714, + "record_summary": "10-4" + }, + { + "name": "San Francisco 49ers", + "id": "6", + "abbreviation": "SF", + "wins": 9, + "losses": 5, + "ties": 0, + "win_percentage": 0.643, + "record_summary": "9-5" + }, + { + "name": "Green Bay Packers", + "id": "7", + "abbreviation": "GB", + "wins": 9, + "losses": 5, + "ties": 0, + "win_percentage": 0.643, + "record_summary": "9-5" + }, + { + "name": "Houston Texans", + "id": "8", + "abbreviation": "HOU", + "wins": 8, + "losses": 6, + "ties": 0, + "win_percentage": 0.571, + "record_summary": "8-6" + }, + { + "name": "Los Angeles Rams", + "id": "9", + "abbreviation": "LAR", + "wins": 8, + "losses": 6, + "ties": 0, + "win_percentage": 0.571, + "record_summary": "8-6" + }, + { + "name": "Minnesota Vikings", + "id": "10", + "abbreviation": "MIN", + "wins": 7, + "losses": 7, + "ties": 0, + "win_percentage": 0.5, + "record_summary": "7-7" + } + ], + "timestamp": 1735689600.0, + "league": "nfl", + "level": 1 + } +} diff --git a/plugins/ledmatrix-leaderboard/test/harness.json b/plugins/ledmatrix-leaderboard/test/harness.json new file mode 100644 index 00000000..7d40df23 --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test/harness.json @@ -0,0 +1,20 @@ +{ + "_comment": "Renders the leaderboard from a fixed NFL standings fixture instead of the live ESPN API, so the harness exercises the real drawing path (fonts, logos, layout) rather than only the no-data fallback. Only NFL is enabled; the other default-on leagues would need network. No freeze_time: nothing in this plugin's output depends on the clock.", + "config": { + "enabled": true, + "enabled_sports": { + "nfl": { + "enabled": true, + "top_teams": 10 + }, + "ncaa_fb": { + "enabled": false + }, + "ncaam_hockey": { + "enabled": false + } + }, + "global": {} + }, + "mock_data": "test/fixtures/mock.json" +} diff --git a/plugins/ledmatrix-leaderboard/test_pixel_perfect.py b/plugins/ledmatrix-leaderboard/test_pixel_perfect.py new file mode 100644 index 00000000..838d2bb1 --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test_pixel_perfect.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Regression tests for pixel-perfect leaderboard rendering. + +The leaderboard draws pixel fonts and team logos on a 1:1 LED matrix, where +there is no sub-pixel to hide a blend in: a partially-lit pixel is a visibly +dim LED, and a row of them around every glyph and logo is what made this +display read as blurry. Three properties keep it crisp, and each is asserted +here: + +1. Text uses ``fontmode = "1"`` — no anti-aliased greys at any font size. +2. Default font sizes land on the font's pixel grid (multiples of 8 for + Press Start 2P), so glyph pixels map 1:1 onto panel pixels. +3. Logos fit inside the panel and get hard alpha edges instead of a halo. + +A fourth test covers the layout: the measured strip width has to match what is +actually drawn, or the ticker ends up padded with dead space or clipped short +of the last team. + +Run from a LEDMatrix (core) checkout so the bundled fonts/logos resolve: + cd /path/to/LEDMatrix && python -m pytest \\ + /path/to/ledmatrix-plugins/plugins/ledmatrix-leaderboard/test_pixel_perfect.py +""" + +import logging +import os +import sys + +import pytest + +pytest.importorskip("PIL") +from PIL import Image # noqa: E402 + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from image_renderer import ImageRenderer # noqa: E402 + +LOGGER = logging.getLogger("test_pixel_perfect") + +TEAMS = ["KC", "BUF", "PHI", "DET", "BAL", "SF", "GB", "HOU", "LAR", "MIN"] + + +def _teams(count=len(TEAMS)): + return [ + {"name": abbr, "id": str(i + 1), "abbreviation": abbr, "record_summary": "10-2"} + for i, abbr in enumerate(TEAMS[:count]) + ] + + +def _league_data(logo_dir="assets/sports/nfl_logos"): + return [{ + "league": "nfl", + "league_config": { + "logo_dir": logo_dir, + "league_logo": os.path.join(logo_dir, "nfl.png") if logo_dir else "", + }, + "teams": _teams(), + }] + + +def _has_pixel_font(): + return os.path.exists(ImageRenderer.PRIMARY_FONT) or os.path.exists( + ImageRenderer.FALLBACK_FONT + ) + + +requires_font = pytest.mark.skipif( + not _has_pixel_font(), + reason="run from a core checkout so assets/fonts/ resolves", +) + + +@requires_font +@pytest.mark.parametrize("height", [32, 64, 96]) +def test_text_has_no_antialiased_pixels(height): + """Every lit text pixel is fully on — no dim anti-aliasing fringe. + + Logos are excluded (logo_dir="") because a real logo legitimately contains + many colours; only the text is required to be two-valued. + """ + renderer = ImageRenderer(height, LOGGER) + image = renderer.create_leaderboard_image(_league_data(logo_dir="")) + assert image is not None + + import numpy as np + pixels = np.array(image).reshape(-1, 3) + lit = pixels[pixels.sum(axis=1) > 0] + assert len(lit) > 0, "nothing was drawn" + + allowed = {(255, 255, 0), (255, 255, 255)} # rank yellow, abbreviation white + partial = [tuple(c) for c in lit if tuple(c) not in allowed] + assert not partial, ( + f"{len(partial)} anti-aliased text pixels at height {height}, " + f"e.g. {sorted(set(partial))[:5]}" + ) + + +@requires_font +def test_pixel_perfect_text_can_be_disabled(): + """Turning the option off restores the old anti-aliased rendering.""" + renderer = ImageRenderer(32, LOGGER, {"pixel_perfect_text": False}) + image = renderer.create_leaderboard_image(_league_data(logo_dir="")) + assert image is not None + draw = renderer._make_draw(Image.new("RGB", (8, 8))) + assert draw.fontmode != "1" + + +@requires_font +@pytest.mark.parametrize("height,expected", [(32, 8), (64, 16), (96, 24)]) +def test_default_font_sizes_land_on_the_pixel_grid(height, expected): + renderer = ImageRenderer(height, LOGGER) + if not renderer.font_grid: + pytest.skip("no grid-based pixel font available") + for key, font in renderer.fonts.items(): + assert font.size % renderer.font_grid == 0, ( + f"font '{key}' size {font.size} is off the {renderer.font_grid}px grid" + ) + if os.path.basename(renderer.font_path) == "PressStart2P-Regular.ttf": + assert renderer.fonts["large"].size == expected + + +@requires_font +def test_font_size_override_is_snapped_to_the_grid(): + renderer = ImageRenderer(32, LOGGER, {"font_size": 14}) + if not renderer.font_grid: + pytest.skip("no grid-based pixel font available") + assert renderer.fonts["large"].size % renderer.font_grid == 0 + + +@requires_font +@pytest.mark.parametrize("height", [32, 64]) +def test_nothing_is_drawn_past_the_panel_edge(height): + """Logos used to be scaled to 120% of the panel and cropped top and bottom.""" + renderer = ImageRenderer(height, LOGGER) + image = renderer.create_leaderboard_image(_league_data()) + assert image is not None + box = image.getbbox() + assert box is not None, "nothing was drawn" + assert box[1] >= 0 and box[3] <= height, ( + f"content spans y={box[1]}..{box[3]} on a {height}px panel" + ) + + +def test_logo_alpha_is_thresholded(): + """Crisp mode leaves no semi-transparent halo around a logo.""" + renderer = ImageRenderer(32, LOGGER) + source = Image.new("RGBA", (256, 256), (0, 0, 0, 0)) + for y in range(256): # a soft vertical alpha ramp + for x in range(256): + source.putpixel((x, y), (255, 0, 0, x)) + + crisp = renderer._prepare_logo(source, 32, 32) + assert crisp is not None + assert set(crisp.split()[3].tobytes()) <= {0, 255} + + renderer.crisp_logos = False + soft = renderer._prepare_logo(source, 32, 32) + assert len(set(soft.split()[3].tobytes())) > 2 + + +def test_logo_preserves_aspect_ratio(): + renderer = ImageRenderer(32, LOGGER) + wide = Image.new("RGBA", (200, 100), (255, 0, 0, 255)) + fitted = renderer._prepare_logo(wide, 32, 32) + assert fitted.size == (32, 16) + + +@requires_font +@pytest.mark.parametrize("logo_dir", ["assets/sports/nfl_logos", ""]) +def test_measured_width_matches_drawn_content(logo_dir): + """The strip is exactly as wide as what gets drawn into it. + + Measuring and drawing were previously two separate passes that budgeted for + a team logo differently when the file was missing, so the strip came out + either padded with dead space or clipped short of the last team. + """ + renderer = ImageRenderer(32, LOGGER) + data = _league_data(logo_dir=logo_dir) + if logo_dir and not os.path.isdir(logo_dir): + pytest.skip("core logo assets not reachable") + + image = renderer.create_leaderboard_image(data) + assert image is not None + box = image.getbbox() + + # The only blank after the last abbreviation is that team's own trailing + # gap plus the inter-league gap (+1 for the glyph's right side bearing). A + # mismatch between the two passes shows up as a much larger gap, or as ink + # running right up to the edge. + trailing_blank = image.width - box[2] + budget = ImageRenderer.TEAM_GAP + ImageRenderer.LEAGUE_SPACING + 2 + assert 0 < trailing_blank <= budget, ( + f"{trailing_blank}px of blank after the last team " + f"(strip={image.width}px, ink ends at {box[2]}px)" + ) + + +@requires_font +def test_all_teams_are_drawn(): + """Every team handed to the renderer appears in the strip.""" + renderer = ImageRenderer(32, LOGGER) + layout, total_width = renderer._build_layout(_league_data(logo_dir="")) + assert len(layout) == 1 + assert len(layout[0]["teams"]) == len(TEAMS) + assert [t["text"] for t in layout[0]["teams"]] == TEAMS + assert total_width > 0 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))