Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/odl_renderer/colors.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
8 changes: 8 additions & 0 deletions src/odl_renderer/elements/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions tests/integration/test_shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading