mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
With scroll_card.date_format "weekday", a Friday 8 PM ET game read "Sat Oct 2" on the scroll and Vegas cards. Cause: the extractor prints the "M/D" in the plugin's resolved zone (its own setting, then the global one, then the system zone), but the card is handed only the plugin's config. Its timezone ships as "", so card_tzinfo fell back to UTC and the weekday belonged to the UTC date: the next day for evening games in the Americas, the previous day for morning games east of UTC (Auckland, Kiritimati). Fix: every zone is within a day of UTC, so the printed date is the start's UTC date or a neighbour of it. _format_date_as now takes the game and names the weekday of whichever of those days has the printed month and day, falling back to the zone-based weekday only when the start cannot place the date (no offset, unparseable, or more than a day away). The switch-mode scorebug shares the formatter and passes the game too, so the twins stay identical; it already used the resolved zone and draws what it drew before. Public signatures are unchanged. Tests cover US DST end, New Year's Eve, both sides of the date line, NZ DST start and UTC+14. The twins test's weekday pin is updated: the drawn date now agrees, and only the bare weekday helpers still differ. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
553 lines
23 KiB
Python
553 lines
23 KiB
Python
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
|
|
|
The eight scoreboards each carried byte-identical copies of the functions
|
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
|
made eight times, and a new scoreboard started by copying them a ninth.
|
|
|
|
Everything here is a **free function taking explicit arguments**, not a base
|
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
|
method and delegates the body, so the call sites and the override points are
|
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
|
rather than attributes -- the helper never reaches back into the caller.
|
|
|
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
|
(`not desired`) instead of football's: they agree on every real input, and
|
|
the extra guard only stops a None size raising TypeError.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, Optional, Tuple
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from src.common.font_layout import ( # noqa: F401 - re-exported, see below
|
|
FONT_NAME_ALIASES, FONT_PIXEL_GRID, crisp_size,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
|
"side_is_favorite", "side_score", "favorite_result",
|
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
|
"resolve_font_size", "unshare_element_fonts",
|
|
]
|
|
|
|
#: Which customization element owns each font key, for colour resolution.
|
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
|
"score": "score_text",
|
|
"time": "period_text",
|
|
"team": "team_name",
|
|
"status": "status_text",
|
|
"detail": "detail_text",
|
|
"rank": "rank_text",
|
|
}
|
|
|
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
|
"win": (0, 255, 0),
|
|
"loss": (255, 0, 0),
|
|
"tie": (255, 200, 0),
|
|
}
|
|
|
|
# Re-exported rather than defined: the grid tables and the snapping rule are
|
|
# properties of the font files, which the display core needs too (it loads the
|
|
# same two faces in DisplayManager._load_fonts). They live in
|
|
# src/common/font_layout.py so there is one definition; they stay in this
|
|
# module's namespace and __all__ so the eight scoreboards that delegate to
|
|
# `sports_card.crisp_size` are untouched.
|
|
|
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
|
default: Any = None) -> Any:
|
|
"""Read one key from the scroll_card config block."""
|
|
block = (config or {}).get("scroll_card")
|
|
if isinstance(block, dict) and block.get(key) is not None:
|
|
return block.get(key)
|
|
return default
|
|
|
|
|
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
|
|
|
|
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Colour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
|
default: Tuple[int, int, int] = (255, 255, 255),
|
|
mode: Optional[str] = None):
|
|
"""Per-element text colour from customization.<element>.text_color.
|
|
|
|
Delegates to src.element_style.element_color, which also resolves the
|
|
element under the names plugins actually use (the layout block says
|
|
`score` where the style block says `score_text`) and honours a per-mode
|
|
override. Hex strings are accepted.
|
|
"""
|
|
from src.element_style import element_color as _shared
|
|
return _shared(config, element, default, mode)
|
|
|
|
|
|
def resolve_font_color(config: Optional[Dict[str, Any]],
|
|
fonts: Optional[Dict[str, Any]], font,
|
|
default: Tuple[int, int, int],
|
|
element_for_font: Dict[str, str],
|
|
mode: Optional[str] = None):
|
|
"""Colour for whichever element owns this face.
|
|
|
|
Identity matching is a stand-in for the element name, used where the draw
|
|
site only ever received a font. Prefer ``element=`` on the draw call; this
|
|
is the fallback for the sites that have not been annotated yet.
|
|
|
|
One object can legitimately belong to several elements -- a size resolver
|
|
can land two of them on the same face, and a BDF face cannot be un-shared
|
|
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
|
|
Ambiguity is therefore narrowed before it is given up on: among the
|
|
elements sharing a face, a single configured colour is the only thing the
|
|
user can have meant, and several that agree mean the same thing. Only a
|
|
genuine disagreement falls back to *default* -- otherwise an element
|
|
drawn in any of the shipped bitmap fonts could lose a colour the user set.
|
|
|
|
The element vocabulary is a parameter because the two callers disagree
|
|
about it -- the mixin's map says ``team_text`` where this module's says
|
|
``team_name`` -- and quietly re-pointing either at the other's names would
|
|
change which colour setting a live install honours.
|
|
"""
|
|
try:
|
|
fonts = fonts or {}
|
|
matches = [element for key, element in element_for_font.items()
|
|
if fonts.get(key) is font]
|
|
if len(matches) == 1:
|
|
return element_color(config, matches[0], default, mode)
|
|
if len(matches) > 1:
|
|
configured = []
|
|
for element in matches:
|
|
# None as the default makes it come back when unconfigured.
|
|
colour = element_color(config, element, None, mode) # type: ignore[arg-type]
|
|
if colour is not None and colour not in configured:
|
|
configured.append(colour)
|
|
if len(configured) == 1:
|
|
return configured[0]
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
return default
|
|
|
|
|
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
|
font, default: Tuple[int, int, int] = (255, 255, 255),
|
|
mode: Optional[str] = None):
|
|
"""Colour for whichever element owns this face, by this module's map."""
|
|
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
|
|
mode)
|
|
|
|
|
|
def coerce_rgb(value, fallback):
|
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
|
# iterate into three digits and yield a colour rather than the fallback.
|
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
|
return fallback
|
|
try:
|
|
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
return (r, g, b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Favourite teams
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
|
"""Favorite teams that apply to this game.
|
|
|
|
Both sources are used. Games carry the league manager's *resolved*
|
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
|
appear expanded; the config is read as well so an edit takes effect on
|
|
already-fetched games, and so hand-built game dicts (tests, other
|
|
callers) still work.
|
|
"""
|
|
favorites = list(game.get("favorite_teams") or [])
|
|
league_config = config.get(str(game.get("league", "") or ""))
|
|
if isinstance(league_config, dict):
|
|
favorites += list(league_config.get("favorite_teams") or [])
|
|
else:
|
|
favorites += list(config.get("favorite_teams") or [])
|
|
return favorites
|
|
|
|
|
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
|
"""Is the home/away side of this game a favorite team?
|
|
|
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
|
payload shapes, and matches on the ESPN id too, because a couple of
|
|
leagues (NRL) key favorites by id where abbreviations collide.
|
|
"""
|
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
|
team = game.get(f"{side}_team")
|
|
if isinstance(team, dict):
|
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
|
for value in candidates:
|
|
if value is not None and str(value).strip().upper() in favorites:
|
|
return True
|
|
return False
|
|
|
|
|
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
|
"""Numeric score for one side, from either payload shape."""
|
|
raw = None
|
|
team = game.get(f"{side}_team")
|
|
if isinstance(team, dict) and team.get("score") is not None:
|
|
raw = team.get("score")
|
|
if raw is None:
|
|
raw = game.get(f"{side}_score")
|
|
try:
|
|
return int(float(str(raw).strip()))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
|
"""Say how the favorite team did in a finished game.
|
|
|
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
|
to root for: no favorites configured, neither side is a favorite, or
|
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
|
flagging in red. Also None when the scores are not usable numbers.
|
|
"""
|
|
favorites = {
|
|
str(team).strip().upper()
|
|
for team in favorite_teams_for(config, game)
|
|
if str(team).strip()
|
|
}
|
|
if not favorites:
|
|
return None
|
|
|
|
home_fav = side_is_favorite(game, "home", favorites)
|
|
away_fav = side_is_favorite(game, "away", favorites)
|
|
if home_fav == away_fav:
|
|
return None
|
|
|
|
home_score = side_score(game, "home")
|
|
away_score = side_score(game, "away")
|
|
if home_score is None or away_score is None:
|
|
return None
|
|
|
|
if home_score == away_score:
|
|
return "tie"
|
|
favorite_score, other_score = (
|
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
|
)
|
|
return "win" if favorite_score > other_score else "loss"
|
|
|
|
|
|
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
|
try:
|
|
settings = (config.get("customization") or {}).get(
|
|
"favorite_result_colors"
|
|
) or {}
|
|
if not settings.get("enabled", False):
|
|
return default
|
|
result = favorite_result(config, game)
|
|
if result is None:
|
|
return default
|
|
return coerce_rgb(
|
|
settings.get(f"{result}_color"),
|
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
|
)
|
|
except Exception:
|
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
|
return default
|
|
|
|
|
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
|
game_type: str, default=None):
|
|
"""Fill color for a game card's score. Only finished games are tinted.
|
|
|
|
The default is the configured score colour rather than a flat white,
|
|
so customization.score_text.text_color shows on games the favourite
|
|
tint does not apply to. The tint still wins where it applies.
|
|
"""
|
|
if default is None:
|
|
default = element_color(config, 'score_text')
|
|
if game_type != "recent":
|
|
return default
|
|
return recent_score_color(config, logger, game, default)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Date and time
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
|
configured = (config or {}).get("timezone")
|
|
if configured:
|
|
try:
|
|
return ZoneInfo(configured)
|
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
|
# config should fall back to UTC, not blank the card.
|
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
|
return timezone.utc
|
|
|
|
|
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
|
game: Optional[Dict]) -> str:
|
|
"""Weekday abbreviation from the game's start time, or ''."""
|
|
if not game:
|
|
return ""
|
|
raw = game.get("start_time_utc") or game.get("start_time")
|
|
if not raw:
|
|
return ""
|
|
try:
|
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
|
str(raw).replace("Z", "+00:00"))
|
|
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
|
except (ValueError, TypeError):
|
|
return ""
|
|
|
|
|
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
|
game: Optional[Dict] = None) -> str:
|
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
|
raw = str(date_text or "").strip()
|
|
if not raw:
|
|
return ""
|
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
|
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game),
|
|
game=game)
|
|
|
|
|
|
def _printed_weekday(game: Optional[Dict], month: int, day: int) -> str:
|
|
"""The weekday of the date a card prints as month/day, or '' if unknown.
|
|
|
|
The extractor prints "M/D" in the plugin's resolved zone (its own setting,
|
|
else the global one, else the system zone). The card cannot see that zone:
|
|
it is handed the plugin's config, whose ``timezone`` ships as "", so
|
|
card_tzinfo answers UTC and an evening kickoff in the Americas got the
|
|
next day's weekday ("Sat Oct 2" for a Friday game). Every zone is within
|
|
a day of UTC, so the printed date is the start's UTC date or a neighbour
|
|
of it; the one with that month and day is the date on the card.
|
|
"""
|
|
if not isinstance(game, dict):
|
|
return ""
|
|
raw = game.get("start_time_utc") or game.get("start_time")
|
|
if not raw:
|
|
return ""
|
|
try:
|
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
|
str(raw).replace("Z", "+00:00"))
|
|
if start.utcoffset() is None:
|
|
return "" # naive: no instant to place the date against
|
|
utc_day = start.astimezone(timezone.utc).date()
|
|
except (ValueError, TypeError, OverflowError):
|
|
return ""
|
|
for offset in (0, -1, 1):
|
|
try:
|
|
candidate = utc_day + timedelta(days=offset)
|
|
except OverflowError:
|
|
continue
|
|
if (candidate.month, candidate.day) == (month, day):
|
|
return WEEKDAY_ABBR[candidate.weekday()]
|
|
return ""
|
|
|
|
|
|
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR,
|
|
game: Optional[Dict] = None) -> str:
|
|
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
|
|
|
The body both date formatters share. They differ in which setting names the
|
|
style and in which zone the weekday is taken from (see
|
|
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
|
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
|
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
|
With *game*, the "weekday" style names the printed date's own weekday
|
|
(:func:`_printed_weekday`), and *weekday* is only the fallback for a
|
|
date its start time cannot place.
|
|
"""
|
|
if fmt == "numeric":
|
|
return raw
|
|
parts = raw.replace("-", "/").split("/")
|
|
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
|
return raw
|
|
month, day = int(parts[0]), int(parts[1])
|
|
if not 1 <= month <= 12:
|
|
return raw
|
|
name = months[month - 1]
|
|
if fmt == "numeric_day_first":
|
|
return f"{day}/{month}"
|
|
if fmt == "day_first":
|
|
return f"{day} {name}"
|
|
if fmt == "weekday":
|
|
day_name = _printed_weekday(game, month, day) or weekday()
|
|
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
|
return f"{name} {day}"
|
|
|
|
|
|
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
|
"""Return the time as-is (12h) or converted to 24h."""
|
|
raw = str(time_text or "").strip()
|
|
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
|
return raw
|
|
cleaned = raw.upper().replace(" ", "")
|
|
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
|
if not meridiem:
|
|
return raw
|
|
try:
|
|
hh, _, mm = cleaned[:-2].partition(":")
|
|
hour, minute = int(hh), int(mm or 0)
|
|
except ValueError:
|
|
return raw
|
|
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
|
return raw
|
|
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
|
return f"{hour:02d}:{minute:02d}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Font sizing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
|
#: global because each plugin declares its own defaults; keyed rather than
|
|
#: per-class because the helper has no class to hang it on.
|
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
|
|
|
|
|
def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]:
|
|
"""``{element: font_size default}`` from a config_schema.json. Raises.
|
|
|
|
The parse both schema-default lookups share. Each keeps its own cache --
|
|
this function per schema path, ``SportsCoreSharedMixin._schema_font_size``
|
|
per class -- because the lifetimes differ: a class is rebuilt when the
|
|
display service reloads a plugin, a module-level path cache is not. One
|
|
cache would change when a reloaded plugin sees an edited schema.
|
|
"""
|
|
import json
|
|
with open(schema_path) as fh:
|
|
schema = json.load(fh)
|
|
props = (schema.get('properties', {})
|
|
.get('customization', {})
|
|
.get('properties', {}))
|
|
sizes: Dict[str, int] = {}
|
|
for key, spec in props.items():
|
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
|
if size is not None:
|
|
sizes[key] = int(size)
|
|
return sizes
|
|
|
|
|
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
|
"""The font_size this plugin's config_schema.json declares, or None.
|
|
|
|
Cached per schema path. The plugins cached this on their own class; the
|
|
path is the same distinction expressed without one, so two plugins never
|
|
share an entry.
|
|
"""
|
|
if not element_key:
|
|
return None
|
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
|
if cache is None:
|
|
try:
|
|
cache = _read_schema_font_sizes(schema_path)
|
|
except Exception as exc:
|
|
# See sports_shared._schema_font_size: an unreadable schema
|
|
# silently disables the pixel-grid snap for every element.
|
|
# Built once per schema path, so this cannot repeat per frame.
|
|
logger.warning(
|
|
"could not read %s (%s: %s); font sizes will skip their "
|
|
"pixel grid snap and may render a pixel narrow",
|
|
schema_path, type(exc).__name__, exc)
|
|
cache = {}
|
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
|
return cache.get(element_key)
|
|
|
|
|
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
|
default_size, font_name, aliases=None, grid_table=None):
|
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
|
|
|
A configured size counts as a real choice only when it differs from
|
|
the schema default. The web UI writes the whole schema default block
|
|
on every save, so "font_size == schema default" carries no intent and
|
|
would otherwise pin every install to an anti-aliased size forever.
|
|
"""
|
|
configured = (element_config or {}).get('font_size')
|
|
if configured is not None:
|
|
try:
|
|
configured = int(configured)
|
|
if configured != schema_font_size(schema_path, element_key):
|
|
return configured
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
|
|
|
|
|
def unshare_element_fonts(logger, fonts, element_for_font=None):
|
|
"""Give each colourable element its own face object.
|
|
|
|
The colour a draw gets is resolved from the face it was handed, and
|
|
several of these loaders legitimately hand one object to more than one
|
|
element -- a size resolver that lands two elements on the same face, a
|
|
fallback that fills every key from one default, football's narrowing
|
|
step that deliberately shrinks the clock along with the score. Sharing
|
|
the object makes the element ambiguous and the colour unresolvable.
|
|
|
|
Re-instantiating from the same path and size gives a distinct object
|
|
with identical metrics, so nothing about the rendering changes; only
|
|
the ability to tell two elements apart does. Faces that cannot be
|
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
|
path) are left shared; resolve_font_color then picks their colour.
|
|
|
|
*element_for_font* names the font keys to consider, in order (the first
|
|
holder of a face keeps it); it defaults to this module's
|
|
:data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map,
|
|
which names different keys -- see ``resolve_font_color`` for why the two
|
|
vocabularies are kept apart.
|
|
"""
|
|
# Looked up at call time so tests can spy on the pinned loader.
|
|
from src.common.font_layout import load_truetype
|
|
if element_for_font is None:
|
|
element_for_font = ELEMENT_FOR_FONT
|
|
seen = {}
|
|
for key in element_for_font:
|
|
font = fonts.get(key)
|
|
if font is None:
|
|
continue
|
|
if id(font) not in seen:
|
|
seen[id(font)] = key
|
|
continue
|
|
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
|
if not path or not size:
|
|
continue
|
|
try:
|
|
fonts[key] = load_truetype(path, size)
|
|
except (OSError, ValueError, TypeError):
|
|
logger.debug(
|
|
"Could not un-share the %s face; it keeps the default colour", key)
|
|
return fonts
|