diff --git a/src/odl_renderer/__init__.py b/src/odl_renderer/__init__.py index e2a86f8..8b378c9 100644 --- a/src/odl_renderer/__init__.py +++ b/src/odl_renderer/__init__.py @@ -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 @@ -26,6 +39,8 @@ "HALF_BLACK", "HALF_RED", "HALF_YELLOW", + "DARK_GRAY", + "LIGHT_GRAY", "BLUE", "GREEN", ] diff --git a/src/odl_renderer/colors.py b/src/odl_renderer/colors.py index 5128984..d9a200c 100644 --- a/src/odl_renderer/colors.py +++ b/src/odl_renderer/colors.py @@ -2,6 +2,8 @@ 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) @@ -9,6 +11,59 @@ 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.""" @@ -21,9 +76,9 @@ 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:]) @@ -31,44 +86,49 @@ def resolve(self, color: str | None) -> tuple[int, int, int, int] | None: @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) diff --git a/src/odl_renderer/elements/text.py b/src/odl_renderer/elements/text.py index 9f3953e..291bddb 100644 --- a/src/odl_renderer/elements/text.py +++ b/src/odl_renderer/elements/text.py @@ -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 @@ -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 diff --git a/tests/unit/test_colors.py b/tests/unit/test_colors.py index 1698e5e..f4eef91 100644 --- a/tests/unit/test_colors.py +++ b/tests/unit/test_colors.py @@ -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: @@ -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 @@ -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), diff --git a/tests/unit/test_text_color_parsing.py b/tests/unit/test_text_color_parsing.py index 803f830..928f458 100644 --- a/tests/unit/test_text_color_parsing.py +++ b/tests/unit/test_text_color_parsing.py @@ -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"] diff --git a/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_ramp_hex.png b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_ramp_hex.png new file mode 100644 index 0000000..af46d86 Binary files /dev/null and b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_ramp_hex.png differ diff --git a/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_ramp_named.png b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_ramp_named.png new file mode 100644 index 0000000..40d02cc Binary files /dev/null and b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_ramp_named.png differ diff --git a/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png new file mode 100644 index 0000000..b132040 Binary files /dev/null and b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png differ diff --git a/tests/visual/test_color_rendering.py b/tests/visual/test_color_rendering.py new file mode 100644 index 0000000..e8bf185 --- /dev/null +++ b/tests/visual/test_color_rendering.py @@ -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 diff --git a/uv.lock b/uv.lock index 63b3511..7719464 100644 --- a/uv.lock +++ b/uv.lock @@ -807,7 +807,7 @@ wheels = [ [[package]] name = "odl-renderer" -version = "0.5.9" +version = "0.5.10" source = { editable = "." } dependencies = [ { name = "aiohttp" },