Files
LEDMatrix/src/common/display_helper.py
Chuck c6b79e11d5 fix: Codacy round-2 — urllib3 CVEs, missed JS/Python issues (#336)
urllib3 CVEs (10 Trivy findings):
  plugin-repos/march-madness/requirements.txt: bump urllib3>=1.26.0 to
  >=2.2.2 to address CVE-2021-33503, CVE-2023-43804, CVE-2023-45803,
  CVE-2024-37891, and 2025-2026 decompression/redirect CVEs.

Missed code fixes from round-1:
  display_helper.py: remove unused draw=ImageDraw.Draw(img) — the method
  delegates to _draw_centered_text which creates its own draw context.
  custom-feeds.js:334: one bare removeCustomFeedRow(this) was missed by
  the earlier replace_all; changed to window.removeCustomFeedRow(this).
  app.js: add htmx to /* global */ declaration — htmx.ajax() is called
  at lines 146 and 172 but htmx was only declared in the extension files.
  timezone-selector.js:215: second unused catch (e) → catch {} missed
  when we fixed line 361 in round-1.

Bandit B110 annotations (3 new except/pass blocks from newer PRs):
  start.py: hostname -I IP parsing — non-critical startup info.
  display_controller.py: scroll_helper.get_portion_at — optional method.
  display_manager.py: canvas reset during cleanup — best-effort.

41 confirmed false positives suppressed via Codacy API:
  35x pyflakes in test/, plugin-repos/, scripts/ — not production code
  Flask 0.0.0.0, os.execvp, Bandit B603, vendor ESLint, already-fixed
  Biome noPrototypeBuiltins.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:04:21 -04:00

315 lines
11 KiB
Python

"""
Display Helper
Handles common display operations and layouts for LED matrix displays.
Extracted from LEDMatrix core to provide reusable functionality for plugins.
"""
import logging
from typing import Any, Dict, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
class DisplayHelper:
"""
Helper class for common display operations and layouts.
Provides functionality for:
- Creating base images and overlays
- Common layout patterns (scorebug, ticker, etc.)
- Image compositing and manipulation
- Display dimension utilities
"""
def __init__(self, display_width: int, display_height: int,
logger: Optional[logging.Logger] = None):
"""
Initialize the DisplayHelper.
Args:
display_width: Width of the LED matrix display
display_height: Height of the LED matrix display
logger: Optional logger instance
"""
self.display_width = display_width
self.display_height = display_height
self.logger = logger or logging.getLogger(__name__)
def create_base_image(self, background_color: Tuple[int, int, int] = (0, 0, 0),
mode: str = 'RGB') -> Image.Image:
"""
Create a base image for the display.
Args:
background_color: Background color (R, G, B)
mode: Image mode ('RGB', 'RGBA', etc.)
Returns:
PIL Image object
"""
return Image.new(mode, (self.display_width, self.display_height), background_color)
def create_overlay(self, background_color: Tuple[int, int, int, int] = (0, 0, 0, 0)) -> Image.Image:
"""
Create an overlay image for compositing.
Args:
background_color: Background color with alpha (R, G, B, A)
Returns:
PIL Image object with alpha channel
"""
return Image.new('RGBA', (self.display_width, self.display_height), background_color)
def composite_images(self, base_image: Image.Image, overlay_image: Image.Image) -> Image.Image:
"""
Composite overlay onto base image.
Args:
base_image: Base image (RGB or RGBA)
overlay_image: Overlay image (should be RGBA)
Returns:
Composited image
"""
if base_image.mode != 'RGBA':
base_image = base_image.convert('RGBA')
if overlay_image.mode != 'RGBA':
overlay_image = overlay_image.convert('RGBA')
return Image.alpha_composite(base_image, overlay_image)
def draw_scorebug_layout(self, game_data: Dict[str, Any],
fonts: Dict[str, ImageFont.ImageFont],
home_logo: Optional[Image.Image] = None,
away_logo: Optional[Image.Image] = None) -> Image.Image:
"""
Draw a standard scorebug layout for sports games.
Args:
game_data: Dictionary containing game information
fonts: Dictionary of loaded fonts
home_logo: Home team logo (optional)
away_logo: Away team logo (optional)
Returns:
PIL Image with scorebug layout
"""
# Create base image and overlay
main_img = self.create_base_image()
overlay = self.create_overlay()
draw = ImageDraw.Draw(overlay)
# Extract game data
home_score = str(game_data.get('home_score', '0'))
away_score = str(game_data.get('away_score', '0'))
home_abbr = game_data.get('home_abbr', 'HOME')
away_abbr = game_data.get('away_abbr', 'AWAY')
status_text = game_data.get('status_text', '')
period_text = game_data.get('period_text', '')
clock = game_data.get('clock', '')
# Draw logos if provided
if home_logo and away_logo:
self._draw_logos(main_img, home_logo, away_logo)
# Draw status/period text (top center)
if status_text or period_text:
status_display = f"{period_text} {status_text}".strip()
if status_display:
self._draw_centered_text(draw, status_display,
fonts.get('time', fonts.get('status')),
y_position=1)
# Draw clock if available
if clock:
self._draw_centered_text(draw, clock, fonts.get('time'), y_position=1)
# Draw scores (center)
score_text = f"{away_score}-{home_score}"
self._draw_centered_text(draw, score_text, fonts.get('score'),
y_position=self.display_height // 2 - 3)
# Draw team abbreviations (bottom)
if away_abbr:
self._draw_text_with_outline(draw, away_abbr, (0, self.display_height - 12),
fonts.get('team'))
if home_abbr:
text_width = draw.textlength(home_abbr, font=fonts.get('team'))
self._draw_text_with_outline(draw, home_abbr,
(self.display_width - text_width, self.display_height - 12),
fonts.get('team'))
# Composite and return
final_img = self.composite_images(main_img, overlay)
return final_img.convert('RGB')
def draw_ticker_layout(self, text: str, font: ImageFont.ImageFont,
background_color: Tuple[int, int, int] = (0, 0, 0),
text_color: Tuple[int, int, int] = (255, 255, 255),
scroll_speed: int = 1) -> Image.Image:
"""
Draw a ticker/scrolling text layout.
Args:
text: Text to display
font: Font to use
background_color: Background color
text_color: Text color
scroll_speed: Pixels to scroll per frame
Returns:
PIL Image with ticker layout
"""
img = self.create_base_image(background_color)
draw = ImageDraw.Draw(img)
# Start text off-screen to the right
x_position = self.display_width
# Draw text
self._draw_text_with_outline(draw, text, (x_position, self.display_height // 2 - 6),
font, fill=text_color)
return img
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
background_color: Tuple[int, int, int] = (0, 0, 0),
text_color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
"""
Draw centered text on the display.
Args:
text: Text to display
font: Font to use
background_color: Background color
text_color: Text color
Returns:
PIL Image with centered text
"""
img = self.create_base_image(background_color)
draw = ImageDraw.Draw(img)
# Calculate center position
text_width = draw.textlength(text, font=font)
text_height = 12 # Approximate height
x = (self.display_width - text_width) // 2
y = (self.display_height - text_height) // 2
# Draw text
self._draw_text_with_outline(draw, text, (x, y), font, fill=text_color)
return img
def draw_error_message(self, message: str = "Error") -> Image.Image:
"""
Draw a simple error message.
Args:
message: Error message to display
Returns:
PIL Image with error message
"""
img = self.create_base_image((50, 0, 0)) # Dark red background
# Use default font
font = ImageFont.load_default()
# Draw centered error message
self._draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
return img
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
"""
Draw a no data message.
Args:
message: Message to display
Returns:
PIL Image with no data message
"""
img = self.create_base_image((0, 0, 0))
font = ImageFont.load_default()
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
return img
def get_display_dimensions(self) -> Tuple[int, int]:
"""
Get display dimensions.
Returns:
(width, height) tuple
"""
return (self.display_width, self.display_height)
def is_portrait(self) -> bool:
"""
Check if display is in portrait orientation.
Returns:
True if height > width
"""
return self.display_height > self.display_width
def is_landscape(self) -> bool:
"""
Check if display is in landscape orientation.
Returns:
True if width > height
"""
return self.display_width > self.display_height
def get_center_position(self) -> Tuple[int, int]:
"""
Get center position of the display.
Returns:
(x, y) center position
"""
return (self.display_width // 2, self.display_height // 2)
def _draw_logos(self, img: Image.Image, home_logo: Image.Image, away_logo: Image.Image) -> None:
"""Draw team logos on the image."""
center_y = self.display_height // 2
# Home logo (right side)
if home_logo:
home_x = self.display_width - home_logo.width + 10
home_y = center_y - (home_logo.height // 2)
img.paste(home_logo, (home_x, home_y), home_logo)
# Away logo (left side)
if away_logo:
away_x = -10
away_y = center_y - (away_logo.height // 2)
img.paste(away_logo, (away_x, away_y), away_logo)
def _draw_centered_text(self, draw: ImageDraw.ImageDraw, text: str,
font: ImageFont.ImageFont, y_position: int) -> None:
"""Draw centered text at specified y position."""
text_width = draw.textlength(text, font=font)
x = (self.display_width - text_width) // 2
self._draw_text_with_outline(draw, text, (x, y_position), font)
def _draw_text_with_outline(self, draw: ImageDraw.ImageDraw, text: str,
position: Tuple[int, int], font: ImageFont.ImageFont,
fill: Tuple[int, int, int] = (255, 255, 255),
outline_color: Tuple[int, int, int] = (0, 0, 0)) -> None:
"""Draw text with outline for better readability."""
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 main text
draw.text((x, y), text, font=font, fill=fill)