mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(display): pin one text layout engine, and give the 5x7 face a size Two ways a font could render differently on two machines running the same code, both found while diagnosing four plugins whose golden images passed on the machine that generated them and failed everywhere else. **Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm where the host Pillow was built with libraqm, Basic otherwise. The two round fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has whole-pixel advances, so they agree — which is why most of the fleet matched everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift cumulatively along a run, and the four plugins that draw body text in it (geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are exactly the four whose goldens travelled badly. Every core font load now goes through `src/common/font_layout.load_truetype`, which pins the Basic engine, so a render depends on the font file and the size and nothing else. Basic gives up complex-script shaping and kerning pairs; neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a host without libraqm. **Zero font height.** `DisplayManager` built the 5x7 BDF face with `freetype.Face(path)` and never called `set_char_size`, so `face.size.height` stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by `prev_y + prev_height + gap` drew two lines on top of each other. The start-up line `Calendar font size: 0 pixels` has been printing the symptom all along. `font_manager._load_bdf_font` already called `set_char_size`, so whether measurement worked depended on which path loaded the face. `DisplayManager` now sets it too, and `get_font_height()` falls back to the strike the file declares rather than returning a zero line height. Fixes ChuckBuilds/ledmatrix-plugins#397 Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): give the startup banner a rung that fits a full address at 64px CI caught what pinning the layout engine exposed rather than caused. `_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" -- the widest thing the startup banner ever shows -- measures 66px at 4x6/6px against the 62 a 64x32 panel has to give. It used to squeak in only because the measurement depended on which layout engine the host Pillow happened to have; with the engine pinned it does not, so the rung the worst case actually needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51. The fallback was wrong in the same place. When nothing in the ladder fit, it returned `self.font` -- the *widest* option, and precisely how "Initializing" came to run off the side of a 64px panel to begin with. It returns the narrowest face that loaded now. test/test_initializing_screen.py: 34 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): name the exceptions the BDF strike read can raise Codacy flagged the try/except/pass. It was already narrow in intent -- a malformed strike table on the measurement path must degrade to "size unknown" rather than take the display down -- but a bare `except Exception: pass` says neither of those things and hides a genuinely broken font behind a silent 8px fallback. It now catches what reading `available_sizes` can actually raise and logs which face failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: drop logo PNGs the render harness downloaded into the worktree These are fetched at runtime by the logo cache; they are not source, and they rode in on a `git add -A` while I was running check_plugin.py against this branch. Nothing in the change needs them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
630 lines
26 KiB
Python
630 lines
26 KiB
Python
"""
|
|
Shared per-element style resolution for plugins (the x-style-elements system).
|
|
|
|
Plugins expose user-customizable text styling — font, size, color, and x/y
|
|
pixel offsets per named element — through their ``config_schema.json``. Two
|
|
declaration forms exist in the plugin ecosystem:
|
|
|
|
- The compact ``x-style-elements`` map on the ``customization`` object
|
|
(of-the-day is the reference). ``expand_style_elements()`` turns it into
|
|
the full per-element property blocks the web-UI config form renders.
|
|
- The manual ``customization`` block: hand-written per-element objects with
|
|
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
|
|
ledmatrix-music). No expansion needed — the defaults are read as-is.
|
|
|
|
At render time a plugin builds an ``ElementStyleResolver`` from its config
|
|
and the schema-file defaults, then asks for each element's resolved style::
|
|
|
|
from src.element_style import ElementStyleResolver, defaults_from_schema_file
|
|
|
|
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
|
|
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
|
|
classic_size=8, classic_color=(255, 255, 255))
|
|
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
|
|
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
|
|
|
|
The central subtlety is what "the user set it" means. The web UI's save flow
|
|
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
|
|
object into ``config.json`` on every save, whether or not the user touched
|
|
the styling section — so a value merely being *present* in config is not an
|
|
override. A value only counts as user-forced when it genuinely differs from
|
|
the schema default for that element. When nothing is forced, ``style()``
|
|
returns exactly the ``classic_*`` values the caller passes (the plugin's
|
|
pre-customization styling), so an untouched config renders byte-identically
|
|
to the classic code path. Note the classic values and the schema defaults
|
|
may legitimately differ (e.g. football's status_text: schema declares 4x6,
|
|
the classic loader fell back to PressStart) — the schema default is the
|
|
override *reference*, the classic values are the *fallback*.
|
|
|
|
``style()`` never raises: any malformed config value degrades to the classic
|
|
style with a logged warning. Font faces are cached module-wide by
|
|
(resolved path, size), and font files resolve independently of the caller's
|
|
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
|
|
root derived from this module's own location).
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, Optional, Tuple, Union
|
|
|
|
from PIL import ImageFont
|
|
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/),
|
|
# derived from this file so fonts resolve regardless of the caller's cwd.
|
|
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
|
|
|
|
# Last-resort font when a requested file can't be found or loaded.
|
|
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
|
|
|
|
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
|
|
# in principle, but the core's own FontManager shares faces the same way.
|
|
_font_cache: Dict[Tuple[str, int], Any] = {}
|
|
|
|
# Config keys a style element block carries, in schema/UI order.
|
|
_STYLE_KEYS = ('font', 'font_size', 'text_color')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ElementStyle:
|
|
"""A fully resolved style for one named element."""
|
|
|
|
font: Any # PIL ImageFont or freetype.Face
|
|
color: Tuple[int, int, int] # resolved RGB
|
|
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
|
|
font_name: str # resolved font filename
|
|
font_size: int # resolved pixel size
|
|
user_forced: bool # font or size genuinely overridden
|
|
user_forced_color: bool # color genuinely overridden
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Font loading (cwd-independent, cached)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve_font_path(font_name: str) -> Optional[str]:
|
|
"""Locate a font file by name, independent of the caller's cwd.
|
|
|
|
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
|
|
relative to the cwd (the classic loaders' behavior, kept first so a
|
|
process running from a different checkout keeps its own fonts); then
|
|
``assets/fonts/<name>`` under the core install root. Returns an
|
|
absolute path, or None when the file doesn't exist anywhere.
|
|
"""
|
|
if not font_name or not isinstance(font_name, str):
|
|
return None
|
|
if os.path.isabs(font_name):
|
|
return font_name if os.path.isfile(font_name) else None
|
|
# A relative name must be a bare filename. font_name comes from plugin
|
|
# config, which the web UI writes; a value like "../../config/config.json"
|
|
# would otherwise escape assets/fonts/ once joined and let a config probe
|
|
# arbitrary paths for existence. os.path.basename collapses any such value
|
|
# to its last component, so a name that isn't already bare is rejected.
|
|
if os.path.basename(font_name) != font_name:
|
|
return None
|
|
candidates = (
|
|
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
|
|
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
|
|
)
|
|
for candidate in candidates:
|
|
if os.path.isfile(candidate):
|
|
return os.path.abspath(candidate)
|
|
return None
|
|
|
|
|
|
def load_font(font_name: str, size: int) -> Any:
|
|
"""Load a font by filename at a pixel size, with caching and fallback.
|
|
|
|
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
|
|
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
|
|
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
|
|
PIL's built-in default — this function never raises.
|
|
"""
|
|
try:
|
|
size = max(1, int(size))
|
|
except (TypeError, ValueError):
|
|
size = 8
|
|
|
|
path = resolve_font_path(font_name)
|
|
if path is None:
|
|
logger.warning("Font file not found: %s, using fallback", font_name)
|
|
return _load_fallback_font(size)
|
|
|
|
cache_key = (path, size)
|
|
cached = _font_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
if path.lower().endswith('.bdf'):
|
|
if freetype is None:
|
|
raise RuntimeError("freetype not available for BDF fonts")
|
|
face = freetype.Face(path)
|
|
# Character size in 1/64th points at 72dpi == pixel size.
|
|
face.set_char_size(size * 64, size * 64, 72, 72)
|
|
font: Any = face
|
|
else:
|
|
font = load_truetype(path, size)
|
|
except Exception as e:
|
|
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
|
path, size, e)
|
|
return _load_fallback_font(size)
|
|
|
|
_font_cache[cache_key] = font
|
|
return font
|
|
|
|
|
|
def _load_fallback_font(size: int) -> Any:
|
|
"""PressStart2P at the requested size, else PIL's built-in default."""
|
|
path = resolve_font_path(_FALLBACK_FONT_NAME)
|
|
if path is not None:
|
|
cache_key = (path, size)
|
|
cached = _font_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
font = load_truetype(path, size)
|
|
_font_cache[cache_key] = font
|
|
return font
|
|
except Exception as e:
|
|
logger.error("Error loading fallback font: %s", e)
|
|
return ImageFont.load_default()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Expand a ``customization.x-style-elements`` declaration into the full
|
|
per-element property blocks the web-UI config form renders.
|
|
|
|
Each declared element becomes an object with ``font`` / ``font_size`` /
|
|
``text_color`` properties (only the sub-fields the declaration carries),
|
|
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
|
|
additionally get an entry under ``customization.layout`` with
|
|
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
|
|
element blocks with the same key are left untouched.
|
|
|
|
Returns the schema unchanged (same object) when there is nothing to
|
|
expand; otherwise returns an expanded deep copy. Never raises.
|
|
"""
|
|
try:
|
|
customization = schema.get('properties', {}).get('customization')
|
|
if not isinstance(customization, dict):
|
|
return schema
|
|
declaration = customization.get('x-style-elements')
|
|
if not isinstance(declaration, dict) or not declaration:
|
|
return schema
|
|
|
|
expanded = copy.deepcopy(schema)
|
|
customization = expanded['properties']['customization']
|
|
customization.setdefault('type', 'object')
|
|
props = customization.setdefault('properties', {})
|
|
layout_props: Dict[str, Any] = {}
|
|
|
|
for element_key, spec in declaration.items():
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
if element_key not in props:
|
|
props[element_key] = _element_block_from_spec(element_key, spec)
|
|
if spec.get('offsets'):
|
|
layout_props[element_key] = _offset_block_from_spec(
|
|
element_key, spec)
|
|
|
|
if layout_props:
|
|
layout = props.setdefault('layout', {
|
|
'type': 'object',
|
|
'title': 'Layout Offsets',
|
|
'description': 'Pixel offsets applied to each element '
|
|
'(positive x moves right, positive y moves down)',
|
|
'x-advanced': True,
|
|
'properties': {},
|
|
'additionalProperties': False,
|
|
})
|
|
layout.setdefault('properties', {})
|
|
for element_key, block in layout_props.items():
|
|
layout['properties'].setdefault(element_key, block)
|
|
|
|
return expanded
|
|
except Exception as e:
|
|
logger.warning("Error expanding x-style-elements: %s", e)
|
|
return schema
|
|
|
|
|
|
def _element_block_from_spec(element_key: str,
|
|
spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Build one expanded per-element schema block from its declaration."""
|
|
properties: Dict[str, Any] = {}
|
|
order = []
|
|
|
|
font_spec = spec.get('font')
|
|
if isinstance(font_spec, dict):
|
|
font_prop: Dict[str, Any] = {
|
|
'type': 'string',
|
|
'title': 'Font Family',
|
|
'x-advanced': True,
|
|
}
|
|
if 'default' in font_spec:
|
|
font_prop['default'] = font_spec['default']
|
|
if isinstance(font_spec.get('enum'), list):
|
|
font_prop['enum'] = list(font_spec['enum'])
|
|
properties['font'] = font_prop
|
|
order.append('font')
|
|
|
|
size_spec = spec.get('size')
|
|
if isinstance(size_spec, dict):
|
|
size_prop: Dict[str, Any] = {
|
|
'type': 'integer',
|
|
'title': 'Font Size',
|
|
'description': 'Font size in pixels',
|
|
'x-advanced': True,
|
|
}
|
|
if 'default' in size_spec:
|
|
size_prop['default'] = size_spec['default']
|
|
if 'min' in size_spec:
|
|
size_prop['minimum'] = size_spec['min']
|
|
if 'max' in size_spec:
|
|
size_prop['maximum'] = size_spec['max']
|
|
properties['font_size'] = size_prop
|
|
order.append('font_size')
|
|
|
|
color_spec = spec.get('color')
|
|
if isinstance(color_spec, dict):
|
|
color_prop: Dict[str, Any] = {
|
|
'type': 'array',
|
|
'title': 'Text Color',
|
|
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
|
'minItems': 3,
|
|
'maxItems': 3,
|
|
'x-widget': 'color-picker',
|
|
}
|
|
if 'default' in color_spec:
|
|
color_prop['default'] = list(color_spec['default'])
|
|
properties['text_color'] = color_prop
|
|
order.append('text_color')
|
|
|
|
return {
|
|
'type': 'object',
|
|
'title': spec.get('title', element_key),
|
|
'x-style-managed': True,
|
|
'x-propertyOrder': order,
|
|
'additionalProperties': False,
|
|
'properties': properties,
|
|
}
|
|
|
|
|
|
def _offset_block_from_spec(element_key: str,
|
|
spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Build one layout.<element> offset block (x/y, default 0)."""
|
|
axis = {
|
|
'type': 'integer',
|
|
'default': 0,
|
|
'x-advanced': True,
|
|
}
|
|
return {
|
|
'type': 'object',
|
|
'title': spec.get('title', element_key),
|
|
'x-style-managed': True,
|
|
'additionalProperties': False,
|
|
'properties': {
|
|
'x_offset': dict(axis, title='X Offset'),
|
|
'y_offset': dict(axis, title='Y Offset'),
|
|
},
|
|
}
|
|
|
|
|
|
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Extract per-element style defaults from a config schema dict.
|
|
|
|
Understands both declaration forms: the compact ``x-style-elements``
|
|
map, and hand-written per-element blocks under
|
|
``customization.properties`` (their ``font`` / ``font_size`` /
|
|
``text_color`` property defaults). Returns a config-shaped dict::
|
|
|
|
{"customization": {"<element>": {"font": ..., "font_size": ...,
|
|
"text_color": [...]}, ...}}
|
|
|
|
Elements with no declared defaults are omitted. Never raises.
|
|
"""
|
|
elements: Dict[str, Dict[str, Any]] = {}
|
|
try:
|
|
customization = schema.get('properties', {}).get('customization')
|
|
if not isinstance(customization, dict):
|
|
return {'customization': elements}
|
|
|
|
declaration = customization.get('x-style-elements')
|
|
if isinstance(declaration, dict):
|
|
for element_key, spec in declaration.items():
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
defaults: Dict[str, Any] = {}
|
|
font_spec = spec.get('font')
|
|
if isinstance(font_spec, dict) and 'default' in font_spec:
|
|
defaults['font'] = font_spec['default']
|
|
size_spec = spec.get('size')
|
|
if isinstance(size_spec, dict) and 'default' in size_spec:
|
|
defaults['font_size'] = size_spec['default']
|
|
color_spec = spec.get('color')
|
|
if isinstance(color_spec, dict) and 'default' in color_spec:
|
|
defaults['text_color'] = list(color_spec['default'])
|
|
if defaults:
|
|
elements[element_key] = defaults
|
|
|
|
properties = customization.get('properties')
|
|
if isinstance(properties, dict):
|
|
for element_key, block in properties.items():
|
|
if element_key == 'layout' or element_key in elements:
|
|
continue
|
|
if not isinstance(block, dict):
|
|
continue
|
|
block_props = block.get('properties')
|
|
if not isinstance(block_props, dict):
|
|
continue
|
|
defaults = {}
|
|
for style_key in _STYLE_KEYS:
|
|
prop = block_props.get(style_key)
|
|
if isinstance(prop, dict) and 'default' in prop:
|
|
defaults[style_key] = prop['default']
|
|
if defaults:
|
|
elements[element_key] = defaults
|
|
except Exception as e:
|
|
logger.warning("Error extracting style defaults from schema: %s", e)
|
|
return {'customization': elements}
|
|
|
|
|
|
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
|
|
"""``defaults_from_schema`` for a schema file on disk. A missing or
|
|
malformed file yields empty defaults (with a logged warning) — every
|
|
configured value then counts as a user override, which is the safe
|
|
degradation. Never raises."""
|
|
try:
|
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
schema = json.load(f)
|
|
if not isinstance(schema, dict):
|
|
raise ValueError("schema is not a JSON object")
|
|
except Exception as e:
|
|
logger.warning("Could not read style defaults from %s: %s",
|
|
schema_path, e)
|
|
return {'customization': {}}
|
|
return defaults_from_schema(schema)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resolver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
|
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
|
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
try:
|
|
rgb = tuple(int(c) for c in value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if all(0 <= c <= 255 for c in rgb):
|
|
return rgb # type: ignore[return-value]
|
|
return None
|
|
|
|
|
|
class ElementStyleResolver:
|
|
"""Resolves per-element user styling against schema defaults.
|
|
|
|
Built from a plugin's live config dict and the defaults extracted from
|
|
its own ``config_schema.json`` (``defaults_from_schema_file``). The
|
|
config dict is held by reference as ``_config`` — consumers compare
|
|
identity (``resolver._config is not self.config``) to decide when a
|
|
resolver must be rebuilt after ``on_config_change`` swaps the dict.
|
|
|
|
A configured font/size/color counts as user-forced only when it differs
|
|
from the schema default (see module docstring); otherwise ``style()``
|
|
returns the caller's classic values verbatim, keeping untouched configs
|
|
byte-identical to pre-customization rendering.
|
|
"""
|
|
|
|
def __init__(self, config: Optional[Dict[str, Any]],
|
|
defaults: Optional[Dict[str, Any]] = None):
|
|
# Keep the exact object for identity-based invalidation, even if the
|
|
# caller hands us something odd; reads are guarded.
|
|
self._config = config
|
|
if isinstance(defaults, dict):
|
|
element_defaults = defaults.get('customization', {})
|
|
else:
|
|
element_defaults = {}
|
|
self._defaults: Dict[str, Any] = (
|
|
element_defaults if isinstance(element_defaults, dict) else {})
|
|
self._memo: Dict[Any, ElementStyle] = {}
|
|
|
|
# -- internal accessors -------------------------------------------------
|
|
|
|
def _customization(self) -> Dict[str, Any]:
|
|
config = self._config if isinstance(self._config, dict) else {}
|
|
customization = config.get('customization', {})
|
|
return customization if isinstance(customization, dict) else {}
|
|
|
|
def _element_config(self, element_key: str) -> Dict[str, Any]:
|
|
element = self._customization().get(element_key, {})
|
|
return element if isinstance(element, dict) else {}
|
|
|
|
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
|
|
defaults = self._defaults.get(element_key, {})
|
|
return defaults if isinstance(defaults, dict) else {}
|
|
|
|
# -- public API ---------------------------------------------------------
|
|
|
|
def style(self, element_key: str,
|
|
classic_font: str = _FALLBACK_FONT_NAME,
|
|
classic_size: int = 8,
|
|
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
|
|
"""Resolve one element's style. Never raises.
|
|
|
|
Args:
|
|
element_key: Key under ``config['customization']`` (e.g.
|
|
``'title_text'``).
|
|
classic_font: Font filename the plugin's classic (pre-
|
|
customization) code used for this element.
|
|
classic_size: Classic pixel size.
|
|
classic_color: Classic RGB color, or None when the caller only
|
|
cares about the font (``.color`` then falls back to the
|
|
schema default color, else white).
|
|
|
|
Returns:
|
|
ElementStyle with the loaded font face, RGB color, (x, y)
|
|
offset, and the ``user_forced`` / ``user_forced_color`` flags.
|
|
"""
|
|
try:
|
|
memo_key = (element_key, classic_font, classic_size,
|
|
_normalize_color(classic_color) or classic_color)
|
|
memoized = self._memo.get(memo_key)
|
|
if memoized is not None:
|
|
return memoized
|
|
except Exception:
|
|
memo_key = None
|
|
|
|
try:
|
|
resolved = self._resolve(element_key, classic_font,
|
|
classic_size, classic_color)
|
|
except Exception as e:
|
|
logger.warning("Error resolving style for element '%s': %s — "
|
|
"using classic style", element_key, e)
|
|
resolved = self._classic_style(classic_font, classic_size,
|
|
classic_color)
|
|
if memo_key is not None:
|
|
self._memo[memo_key] = resolved
|
|
return resolved
|
|
|
|
def offset(self, element_key: str) -> Tuple[int, int]:
|
|
"""The user's ``customization.layout.<element>`` (x, y) pixel
|
|
offset, defaulting to (0, 0). Never raises."""
|
|
return (self.offset_value(element_key, 'x_offset', 0),
|
|
self.offset_value(element_key, 'y_offset', 0))
|
|
|
|
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
|
|
"""One ``customization.layout.<element>.<axis>`` value as an int.
|
|
|
|
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
|
|
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
|
|
strings are coerced; anything else degrades to ``default``. Never
|
|
raises.
|
|
"""
|
|
try:
|
|
layout = self._customization().get('layout', {})
|
|
if not isinstance(layout, dict):
|
|
return int(default)
|
|
element = layout.get(element_key, {})
|
|
if not isinstance(element, dict):
|
|
return int(default)
|
|
value = element.get(axis, default)
|
|
if isinstance(value, bool):
|
|
return int(default)
|
|
if isinstance(value, (int, float)):
|
|
return int(value)
|
|
if isinstance(value, str):
|
|
try:
|
|
return int(float(value))
|
|
except (TypeError, ValueError):
|
|
logger.warning(
|
|
"Invalid layout offset for %s.%s: %r, using %s",
|
|
element_key, axis, value, default)
|
|
return int(default)
|
|
return int(default)
|
|
except Exception as e:
|
|
logger.warning("Error reading layout offset %s.%s: %s",
|
|
element_key, axis, e)
|
|
try:
|
|
return int(default)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
# -- resolution internals -----------------------------------------------
|
|
|
|
def _resolve(self, element_key: str, classic_font: str,
|
|
classic_size: int,
|
|
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
|
element_config = self._element_config(element_key)
|
|
element_defaults = self._element_defaults(element_key)
|
|
|
|
# Font family: forced only when it differs from the schema default
|
|
# (falling back to the classic font as the reference when the
|
|
# schema declares none).
|
|
default_font = element_defaults.get('font', classic_font)
|
|
configured_font = element_config.get('font')
|
|
font_forced = (isinstance(configured_font, str) and configured_font
|
|
and configured_font != default_font)
|
|
|
|
# Font size: same rule, with defensive int coercion.
|
|
default_size = self._coerce_size(
|
|
element_defaults.get('font_size'), None)
|
|
if default_size is None:
|
|
default_size = self._coerce_size(classic_size, 8)
|
|
configured_size = self._coerce_size(element_config.get('font_size'),
|
|
None)
|
|
size_forced = (configured_size is not None
|
|
and configured_size != default_size)
|
|
|
|
font_name = configured_font if font_forced else classic_font
|
|
font_size = configured_size if size_forced else self._coerce_size(
|
|
classic_size, 8)
|
|
user_forced = bool(font_forced or size_forced)
|
|
|
|
# Color: forced only when it differs from the schema default (or,
|
|
# absent one, from the classic color).
|
|
default_color = _normalize_color(element_defaults.get('text_color'))
|
|
configured_color = _normalize_color(element_config.get('text_color'))
|
|
reference_color = (default_color if default_color is not None
|
|
else _normalize_color(classic_color))
|
|
color_forced = (configured_color is not None
|
|
and configured_color != reference_color)
|
|
if color_forced:
|
|
color = configured_color
|
|
else:
|
|
color = (_normalize_color(classic_color) or default_color
|
|
or (255, 255, 255))
|
|
|
|
return ElementStyle(
|
|
font=load_font(font_name, font_size),
|
|
color=color,
|
|
offset=self.offset(element_key),
|
|
font_name=font_name,
|
|
font_size=font_size,
|
|
user_forced=user_forced,
|
|
user_forced_color=bool(color_forced),
|
|
)
|
|
|
|
def _classic_style(self, classic_font: str, classic_size: int,
|
|
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
|
"""The untouched fallback style — used when resolution itself
|
|
fails, so ``style()`` can keep its never-raises promise."""
|
|
size = self._coerce_size(classic_size, 8)
|
|
return ElementStyle(
|
|
font=load_font(classic_font, size),
|
|
color=_normalize_color(classic_color) or (255, 255, 255),
|
|
offset=(0, 0),
|
|
font_name=classic_font,
|
|
font_size=size,
|
|
user_forced=False,
|
|
user_forced_color=False,
|
|
)
|
|
|
|
@staticmethod
|
|
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
|
|
"""An int pixel size, or ``default`` for None/garbage."""
|
|
if value is None or isinstance(value, bool):
|
|
return default
|
|
try:
|
|
size = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return size if size > 0 else default
|