diff --git a/src/odl_renderer/colors.py b/src/odl_renderer/colors.py index d9a200c..4de3dc1 100644 --- a/src/odl_renderer/colors.py +++ b/src/odl_renderer/colors.py @@ -1,3 +1,19 @@ +import logging + +_LOGGER = logging.getLogger(__name__) + +# Tokens we've already warned about, so an unknown color in a dashboard that +# re-renders on every update logs once rather than on every frame. +_warned_color_tokens: set[str] = set() + + +def _warn_unresolved_color(token: str, reason: str) -> None: + """Log once that *token* could not be resolved and will render as white.""" + if token not in _warned_color_tokens: + _warned_color_tokens.add(token) + _LOGGER.warning("Color %r %s; rendering as white", token, reason) + + # Color constants with alpha channel WHITE = (255, 255, 255, 255) BLACK = (0, 0, 0, 255) @@ -115,8 +131,10 @@ def _parse_hex(hex_val: str) -> tuple[int, int, int, int]: b = int(hex_val[4:6], 16) a = int(hex_val[6:8], 16) else: + _warn_unresolved_color(f"#{hex_val}", "is not a valid hex color (expected 3, 4, 6, or 8 digits)") return WHITE except ValueError: + _warn_unresolved_color(f"#{hex_val}", "contains non-hex characters") return WHITE return r, g, b, a @@ -131,4 +149,8 @@ def _resolve_named(self, color_str: str) -> tuple[int, int, int, int]: return YELLOW if self.accent_color == "yellow" else RED if color_str in ("half_accent", "ha"): return HALF_YELLOW if self.accent_color == "yellow" else HALF_RED - return NAMED_COLORS.get(color_str, WHITE) + resolved = NAMED_COLORS.get(color_str) + if resolved is None: + _warn_unresolved_color(color_str, "is not a known color name") + return WHITE + return resolved diff --git a/src/odl_renderer/elements/shapes.py b/src/odl_renderer/elements/shapes.py index 77150fd..60fd369 100644 --- a/src/odl_renderer/elements/shapes.py +++ b/src/odl_renderer/elements/shapes.py @@ -299,6 +299,14 @@ def draw_dashed_line( dy = y2 - y1 line_length = (dx**2 + dy**2) ** 0.5 + # A zero-length line (start == end, easily produced by templated coordinates) + # has no direction to step along and would divide by zero below. Draw the single + # point if a dash starts there, then stop. + if line_length == 0: + if dash_length > 0: + draw.line([(x1, y1), (x2, y2)], fill=fill, width=width) + return + step_x = dx / line_length step_y = dy / line_length diff --git a/tests/integration/test_shapes.py b/tests/integration/test_shapes.py index 3e2855f..9c3bca2 100644 --- a/tests/integration/test_shapes.py +++ b/tests/integration/test_shapes.py @@ -25,6 +25,15 @@ async def test_dashed_line(self): ) assert image.size == (200, 100) + async def test_zero_length_dashed_line_does_not_crash(self): + """A dashed line with start == end must not divide by zero (finding A4).""" + image = await generate_image( + width=200, + height=100, + elements=[E.line(x_start=50, y_start=50, x_end=50, y_end=50, dashed=True)], + ) + assert image.size == (200, 100) + async def test_line_colors(self): for color in ["black", "red", "#FF0000"]: image = await generate_image( diff --git a/tests/unit/test_colors.py b/tests/unit/test_colors.py index f4eef91..7ec56a3 100644 --- a/tests/unit/test_colors.py +++ b/tests/unit/test_colors.py @@ -143,3 +143,41 @@ def test_accent_color_default_red(self): resolver = ColorResolver() assert resolver.resolve("accent") == RED assert resolver.resolve("half_accent") == HALF_RED + + +class TestUnresolvedColorWarnings: + """Unknown names and malformed hex render white but should warn once (finding A11).""" + + def test_unknown_name_warns_once(self, caplog): + import logging + + from odl_renderer import colors + + colors._warned_color_tokens.discard("balck") + resolver = ColorResolver() + with caplog.at_level(logging.WARNING, logger="odl_renderer.colors"): + assert resolver.resolve("balck") == WHITE + assert resolver.resolve("balck") == WHITE # second call must not re-warn + warnings = [r for r in caplog.records if "balck" in r.getMessage()] + assert len(warnings) == 1 + + def test_malformed_hex_warns_once(self, caplog): + import logging + + from odl_renderer import colors + + colors._warned_color_tokens.discard("#12345") + resolver = ColorResolver() + with caplog.at_level(logging.WARNING, logger="odl_renderer.colors"): + assert resolver.resolve("#12345") == WHITE + assert resolver.resolve("#12345") == WHITE + warnings = [r for r in caplog.records if "#12345" in r.getMessage()] + assert len(warnings) == 1 + + def test_known_color_does_not_warn(self, caplog): + import logging + + resolver = ColorResolver() + with caplog.at_level(logging.WARNING, logger="odl_renderer.colors"): + assert resolver.resolve("red") == RED + assert not caplog.records