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
17 changes: 16 additions & 1 deletion src/odl_renderer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
"""Drawcustom - Pure image rendering from drawing instructions."""

from .colors import BLACK, BLUE, GREEN, HALF_BLACK, HALF_RED, HALF_YELLOW, RED, WHITE, YELLOW, ColorResolver
from .colors import (
BLACK,
BLUE,
DARK_GRAY,
GREEN,
HALF_BLACK,
HALF_RED,
HALF_YELLOW,
LIGHT_GRAY,
RED,
WHITE,
YELLOW,
ColorResolver,
)
from .coordinates import CoordinateParser
from .core import generate_image, should_show_element
from .fonts import FontManager
Expand All @@ -26,6 +39,8 @@
"HALF_BLACK",
"HALF_RED",
"HALF_YELLOW",
"DARK_GRAY",
"LIGHT_GRAY",
"BLUE",
"GREEN",
]
108 changes: 84 additions & 24 deletions src/odl_renderer/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,68 @@
WHITE = (255, 255, 255, 255)
BLACK = (0, 0, 0, 255)
HALF_BLACK = (127, 127, 127, 255)
DARK_GRAY = (64, 64, 64, 255)
LIGHT_GRAY = (191, 191, 191, 255)
RED = (255, 0, 0, 255)
HALF_RED = (255, 127, 127, 255)
YELLOW = (255, 255, 0, 255)
HALF_YELLOW = (255, 255, 127, 255)
BLUE = (0, 0, 255, 255)
GREEN = (0, 255, 0, 255)

# Single source of truth for statically-resolvable named colors.
# Add new named colors here only: the resolver and the inline text-markup
# allowlist are both derived from this mapping.
NAMED_COLORS: dict[str, tuple[int, int, int, int]] = {
"black": BLACK,
"b": BLACK,
"white": WHITE,
"w": WHITE,
"dark_gray": DARK_GRAY,
"darkgray": DARK_GRAY,
"dkgray": DARK_GRAY,
"half_black": HALF_BLACK,
"hb": HALF_BLACK,
"gray": HALF_BLACK,
"grey": HALF_BLACK,
"light_gray": LIGHT_GRAY,
"lightgray": LIGHT_GRAY,
"ltgray": LIGHT_GRAY,
"half_white": LIGHT_GRAY,
"hw": LIGHT_GRAY,
"red": RED,
"r": RED,
"half_red": HALF_RED,
"hr": HALF_RED,
"yellow": YELLOW,
"y": YELLOW,
"half_yellow": HALF_YELLOW,
"hy": HALF_YELLOW,
"blue": BLUE,
"bl": BLUE,
"green": GREEN,
"gr": GREEN,
"g": GREEN,
}

# Accent aliases resolve dynamically from the configured accent color,
# so they are valid tokens but resolved separately from NAMED_COLORS.
ACCENT_ALIASES: tuple[str, ...] = ("accent", "a", "half_accent", "ha")

# Hex token forms accepted as colors (longest-first for unambiguous matching).
_HEX_TOKENS: tuple[str, ...] = (
r"#[0-9A-Fa-f]{8}",
r"#[0-9A-Fa-f]{6}",
r"#[0-9A-Fa-f]{4}",
r"#[0-9A-Fa-f]{3}",
)

# Regex alternation of every valid color token (named + accent + hex).
# Named tokens are sorted longest-first so aliases like "grey" match before
# "gr". Derived once and shared by inline text markup so adding a color in
# NAMED_COLORS automatically extends the markup allowlist too.
COLOR_TOKEN_PATTERN: str = "|".join(sorted([*NAMED_COLORS, *ACCENT_ALIASES], key=len, reverse=True) + list(_HEX_TOKENS))


class ColorResolver:
"""Resolves color inputs to RGBA tuples."""
Expand All @@ -21,54 +76,59 @@ def resolve(self, color: str | None) -> tuple[int, int, int, int] | None:
if color is None:
return None

color_str = str(color).lower()
color_str = str(color).strip().lower()

# Hex color support: #RGB or #RRGGBB
# Hex color support: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA
if color_str.startswith("#"):
return self._parse_hex(color_str[1:])

return self._resolve_named(color_str)

@staticmethod
def _parse_hex(hex_val: str) -> tuple[int, int, int, int]:
"""Parse hex color string to RGBA tuple."""
"""Parse hex color string to RGBA tuple.

Supports #RGB and #RGBA shorthand (each nibble doubled) and the
full #RRGGBB and #RRGGBBAA forms. Alpha defaults to fully opaque
when not provided. Invalid length or content falls back to white.
"""
hex_val = hex_val.strip()
try:
if len(hex_val) == 3:
r = int(hex_val[0] * 2, 16)
g = int(hex_val[1] * 2, 16)
b = int(hex_val[2] * 2, 16)
a = 255
elif len(hex_val) == 4:
r = int(hex_val[0] * 2, 16)
g = int(hex_val[1] * 2, 16)
b = int(hex_val[2] * 2, 16)
a = int(hex_val[3] * 2, 16)
elif len(hex_val) == 6:
r = int(hex_val[0:2], 16)
g = int(hex_val[2:4], 16)
b = int(hex_val[4:6], 16)
a = 255
elif len(hex_val) == 8:
r = int(hex_val[0:2], 16)
g = int(hex_val[2:4], 16)
b = int(hex_val[4:6], 16)
a = int(hex_val[6:8], 16)
else:
return WHITE
except ValueError:
return WHITE
return r, g, b, 255
return r, g, b, a

def _resolve_named(self, color_str: str) -> tuple[int, int, int, int]:
"""Resolve named color to RGBA tuple."""
if color_str in ("black", "b"):
return BLACK
if color_str in ("white", "w"):
return WHITE
if color_str in ("half_black", "hb", "gray", "grey", "half_white", "hw"):
return HALF_BLACK
"""Resolve named color to RGBA tuple.

Accent aliases resolve dynamically from the configured accent color;
all other names are looked up in NAMED_COLORS. Unknown names fall
back to white.
"""
if color_str in ("accent", "a"):
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
if color_str in ("red", "r"):
return RED
if color_str in ("half_red", "hr"):
return HALF_RED
if color_str in ("yellow", "y"):
return YELLOW
if color_str in ("half_yellow", "hy"):
return HALF_YELLOW
if color_str in ("blue", "bl"):
return BLUE
if color_str in ("green", "gr", "g"):
return GREEN
return WHITE
return NAMED_COLORS.get(color_str, WHITE)
9 changes: 4 additions & 5 deletions src/odl_renderer/elements/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from PIL import ImageDraw, ImageFont

from odl_renderer.colors import COLOR_TOKEN_PATTERN
from odl_renderer.registry import element_handler
from odl_renderer.types import DrawingContext, ElementType, TextSegment

Expand Down Expand Up @@ -275,11 +276,9 @@ def parse_colored_text(text: str) -> List[TextSegment]:

segments = []
current_pos = 0
pattern = (
r"\[(black|b|white|w|red|r|yellow|y|blue|bl|green|gr|g|accent|a|"
r"half_black|half_white|half_red|half_yellow|half_accent|gray|grey|"
r"hb|hw|hr|hy|ha|#[0-9A-Fa-f]{3}|#[0-9A-Fa-f]{6})\](.*?)\[/\1\]"
)
# Valid color tokens (names + hex) are derived centrally in colors.py,
# so new named colors need no change here.
pattern = r"\[(" + COLOR_TOKEN_PATTERN + r")\](.*?)\[/\1\]"

for match in re.finditer(pattern, text, re.DOTALL):
# Add any text before the match with default color
Expand Down
40 changes: 35 additions & 5 deletions tests/unit/test_colors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import pytest

from odl_renderer.colors import BLACK, BLUE, GREEN, HALF_BLACK, HALF_RED, HALF_YELLOW, RED, WHITE, YELLOW, ColorResolver
from odl_renderer.colors import (
BLACK,
BLUE,
DARK_GRAY,
GREEN,
HALF_BLACK,
HALF_RED,
HALF_YELLOW,
LIGHT_GRAY,
RED,
WHITE,
YELLOW,
ColorResolver,
)


class TestColorResolver:
Expand Down Expand Up @@ -31,10 +44,19 @@ def test_none_returns_none(self):
("#ff0000", (255, 0, 0, 255)),
("#FF0000", (255, 0, 0, 255)),
("#Ff0000", (255, 0, 0, 255)),
# 8-digit hex with alpha
("#FF000080", (255, 0, 0, 128)),
("#FFFFFFFF", (255, 255, 255, 255)),
("#00000000", (0, 0, 0, 0)),
# 4-digit hex shorthand with alpha
("#F008", (255, 0, 0, 136)),
("#FFFF", (255, 255, 255, 255)),
# Surrounding whitespace is stripped
(" #FF0000 ", (255, 0, 0, 255)),
],
)
def test_hex_colors(self, hex_color, expected):
"""Test hex color parsing (6-digit, 3-digit, case variations)."""
"""Test hex color parsing (3/4/6/8-digit, alpha, case, whitespace)."""
resolver = ColorResolver()
assert resolver.resolve(hex_color) == expected

Expand All @@ -53,13 +75,21 @@ def test_hex_invalid_length_returns_white(self, invalid_hex):
# White
("white", WHITE),
("w", WHITE),
# Half black / gray
# Half black / mid gray
("half_black", HALF_BLACK),
("hb", HALF_BLACK),
("gray", HALF_BLACK),
("grey", HALF_BLACK),
("half_white", HALF_BLACK),
("hw", HALF_BLACK),
# Dark gray
("dark_gray", DARK_GRAY),
("darkgray", DARK_GRAY),
("dkgray", DARK_GRAY),
# Light gray (half_white fixed: was wrongly mid gray)
("light_gray", LIGHT_GRAY),
("lightgray", LIGHT_GRAY),
("ltgray", LIGHT_GRAY),
("half_white", LIGHT_GRAY),
("hw", LIGHT_GRAY),
# Red
("red", RED),
("r", RED),
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/test_text_color_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,17 @@ def test_parse_colored_text_supports_hex_tags():

assert [segment.text for segment in segments] == ["G", "H"]
assert [segment.color for segment in segments] == ["#0f0", "#00FFAA"]


def test_parse_colored_text_supports_gray_names():
segments = parse_colored_text("[ltgray]L[/ltgray][dkgray]D[/dkgray]")

assert [segment.text for segment in segments] == ["L", "D"]
assert [segment.color for segment in segments] == ["ltgray", "dkgray"]


def test_parse_colored_text_supports_8_digit_hex_tags():
segments = parse_colored_text("[#FF000080]A[/#FF000080]")

assert [segment.text for segment in segments] == ["A"]
assert [segment.color for segment in segments] == ["#FF000080"]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions tests/visual/test_color_rendering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from io import BytesIO

import pytest

from odl_renderer import generate_image
from tests.builders import ElementBuilder as E


@pytest.mark.asyncio
class TestGrayColorVisualRegression:
"""Visual regression tests for the grayscale ramp and gray text."""

async def test_gray_ramp_named(self, snapshot_png):
"""Named grayscale ramp: black -> dkgray -> gray -> ltgray -> white."""
levels = ["black", "dkgray", "gray", "ltgray", "white"]
image = await generate_image(
width=300,
height=80,
background="red",
elements=[
E.rectangle(
x_start=10 + i * 58,
y_start=10,
x_end=58 + i * 58,
y_end=70,
fill=level,
outline="black",
width=1,
)
for i, level in enumerate(levels)
],
)

buffer = BytesIO()
image.save(buffer, format="PNG")
assert buffer.getvalue() == snapshot_png

async def test_gray_ramp_hex(self, snapshot_png):
"""16-gray addressed via hex grayscale values."""
swatches = ["#000000", "#404040", "#808080", "#c0c0c0", "#ffffff"]
image = await generate_image(
width=300,
height=80,
background="red",
elements=[
E.rectangle(
x_start=10 + i * 58,
y_start=10,
x_end=58 + i * 58,
y_end=70,
fill=swatch,
outline="black",
width=1,
)
for i, swatch in enumerate(swatches)
],
)

buffer = BytesIO()
image.save(buffer, format="PNG")
assert buffer.getvalue() == snapshot_png

async def test_gray_text_inline_markup(self, snapshot_png):
"""Inline [dkgray]/[ltgray] markup tags render with their gray levels."""
image = await generate_image(
width=300,
height=80,
background="white",
elements=[
E.text(
value="[dkgray]dark[/dkgray] [gray]mid[/gray] [ltgray]light[/ltgray]",
x=10,
y=20,
size=28,
parse_colors=True,
),
],
)

buffer = BytesIO()
image.save(buffer, format="PNG")
assert buffer.getvalue() == snapshot_png
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading