diff --git a/README.md b/README.md index 821c89e..11c435b 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,9 @@ When `y` is omitted, elements stack automatically: each element is placed below ### Anchors -Used by `text`, `icon`, and `icon_sequence` to set which point of the element aligns to the given `x`/`y` coordinates. +Used by `text`, `icon`, and `icon_sequence` to set which point of the element aligns to the given `x`/`y` coordinates, and by `pivot` (see [rotation](#rotation)/[mirror](#mirror)) to set the transform origin. -`text` uses PIL anchor format — horizontal axis first, then vertical: +All of them use the PIL anchor format — horizontal axis first, then vertical: | Code | Meaning | |------|-----------------| @@ -146,23 +146,11 @@ Used by `text`, `icon`, and `icon_sequence` to set which point of the element al | `mb` | Middle-bottom | | `rb` | Right-bottom | -`icon` and `icon_sequence` use a different format for corner anchors — vertical axis first: - -| Code | Meaning | -|------|-----------------| -| `tl` | Top-left | -| `tr` | Top-right | -| `mt` | Middle-top | -| `lm` | Left-middle | -| `mm` | Middle (center) | -| `rm` | Right-middle | -| `bl` | Bottom-left | -| `br` | Bottom-right | -| `mb` | Middle-bottom | +`icon` and `icon_sequence` default to `la` (left-ascender) rather than `lt`; otherwise the codes are identical. ### The `visible` field -Every element type accepts an optional `visible` field. When `false`, the element is skipped entirely (no rendering, no position update). Defaults to `true`. +Every element type accepts an optional `visible` field. When falsy, the element is skipped entirely (no rendering, no position update). Defaults to `true`. ```yaml { @@ -174,6 +162,95 @@ Every element type accepts an optional `visible` field. When `false`, the elemen } ``` +String values are coerced, which matters when `visible` is produced by a Home Assistant template (templates render to strings). The string `"false"` (case-insensitive) and empty/whitespace-only strings hide the element; any other non-empty string shows it. So a template that renders `"false"` hides the element as expected — rendering down to an empty string is no longer required. + +### `rotation` + +Every element type accepts an optional `rotation` (degrees, **positive = clockwise**, arbitrary values allowed) and an optional `pivot` controlling the point it rotates about. The element is rendered, then rotated in place and composited back, so any element type works. + +| Field | Required | Default | Notes | +|------------|----------|------------------|------------------------------------------------------------------------------------| +| `rotation` | no | `0` | Degrees, positive = clockwise | +| `pivot` | no | `"mm"` (center) | [Anchor](#anchors) keyword relative to the element (e.g. `lt`, `mm`, `rb`), **or** `[x, y]` canvas coords (percentages allowed, e.g. `["50%", "50%"]`) | + +> Not to be confused with the `image`/`dlimg`-only `rotate` field, which rotates the *source image before fitting it to the box*. Use `rotation` to tilt any rendered element on the canvas. + +```yaml +[ + { + "type": "text", + "value": "Rotated", + "x": 148, + "y": 64, + "size": 30, + "anchor": "mm", + "rotation": 45 + }, + # Illustration only — the gray "ghost" of the original orientation: + { + "type": "text", + "value": "Rotated", + "x": 148, + "y": 64, + "size": 30, + "anchor": "mm", + "color": "gray" + }, + # Illustration only — a dot marking the pivot (the element's center): + { + "type": "circle", + "x": 148, + "y": 64, + "radius": 3, + "fill": "red" + } +] +``` + +Only the first element matters for usage — the gray "ghost" shows the un-rotated original and the red dot marks the pivot (`pivot` defaults to the element's center; set e.g. `"pivot": "lt"` or `"pivot": [148, 64]` to move it). + +![rotation example](https://raw.githubusercontent.com/OpenDisplay/odl-renderer/main/docs/screenshots/rotation.png) + +### `mirror` + +Every element type accepts an optional `mirror` to flip it about its `pivot`: `"h"` (horizontal), `"v"` (vertical), or `"hv"` (both). Unknown values are ignored. + +| Field | Required | Default | Notes | +|----------|----------|-----------------|------------------------------------------------| +| `mirror` | no | — | `"h"`, `"v"`, or `"hv"` (case-insensitive) | +| `pivot` | no | `"mm"` (center) | Same as `rotation`: anchor keyword or `[x, y]` | + +```yaml +[ + { + "type": "polygon", + "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], + "fill": "red", + "mirror": "h" + }, + # Illustration only — the original outline and the flip axis: + { + "type": "polygon", + "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], + "outline": "gray", + "width": 1 + }, + { + "type": "line", + "x_start": 85, + "y_start": 20, + "x_end": 85, + "y_end": 108, + "fill": "red", + "width": 1 + } +] +``` + +The filled red shape is mirrored; the gray outline shows the original and the vertical red line marks the flip axis (the element's center by default). + +![mirror example](https://raw.githubusercontent.com/OpenDisplay/odl-renderer/main/docs/screenshots/mirror.png) + --- ## Text diff --git a/docs/screenshots/mirror.png b/docs/screenshots/mirror.png new file mode 100644 index 0000000..7476a6b Binary files /dev/null and b/docs/screenshots/mirror.png differ diff --git a/docs/screenshots/rotation.png b/docs/screenshots/rotation.png new file mode 100644 index 0000000..6caa7df Binary files /dev/null and b/docs/screenshots/rotation.png differ diff --git a/scripts/generate_screenshots.py b/scripts/generate_screenshots.py index ee96da6..22a2844 100644 --- a/scripts/generate_screenshots.py +++ b/scripts/generate_screenshots.py @@ -83,10 +83,13 @@ def parse_element_examples(readme: str) -> dict[str, dict]: async def render_element(name: str, config: dict, session: aiohttp.ClientSession) -> None: """Render a single element config to a PNG file.""" + # An example may be a single element dict or a list of elements (used by the + # annotated rotation/mirror demos, where helper elements illustrate the pivot). + elements = config if isinstance(config, list) else [config] image = await generate_image( width=CANVAS_WIDTH, height=CANVAS_HEIGHT, - elements=[config], + elements=elements, background="white", session=session, data_provider=MockDataProvider(), diff --git a/src/odl_renderer/core.py b/src/odl_renderer/core.py index 2555f16..20bc71c 100644 --- a/src/odl_renderer/core.py +++ b/src/odl_renderer/core.py @@ -13,6 +13,7 @@ from .elements import debug, icons, media, shapes, text, visualizations # noqa: F401 from .fonts import FontManager from .registry import get_all_handlers +from .transforms import apply_transform, has_transform from .types import DataProvider, DrawingContext, ElementType _LOGGER = logging.getLogger(__name__) @@ -84,7 +85,7 @@ async def generate_image( # Process each element for i, element in enumerate(elements): # Skip hidden elements - if not element.get("visible", True): + if not _coerce_visible(element.get("visible", True)): continue try: @@ -96,7 +97,10 @@ async def generate_image( # Get the appropriate handler and call it handler = draw_handlers.get(element_type) if handler: - await handler(ctx, element) + if has_transform(element): + await _render_transformed(ctx, handler, element) + else: + await handler(ctx, element) else: error_msg = f"No handler found for element type: {element_type}" _LOGGER.warning(error_msg) @@ -115,6 +119,59 @@ async def generate_image( return img +async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[str, Any]) -> None: + """Render an element onto its own layer, transform it, then composite back. + + The element is drawn onto a transparent full-canvas layer by temporarily + pointing the context at it, so the handler is used unchanged. The layer is + then rotated/mirrored and alpha-composited onto the base image. Mutating + ``ctx.img`` in place preserves any ``ctx.pos_y`` flow updates the handler + makes (e.g. text auto-flow). + """ + base = ctx.img + layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) + ctx.img = layer + try: + await handler(ctx, element) + finally: + ctx.img = base + + layer = apply_transform( + layer, + rotation=element.get("rotation"), + mirror=element.get("mirror"), + pivot=element.get("pivot"), + coords=ctx.coords, + ) + base.alpha_composite(layer) + + +_FALSY_VISIBLE_STRINGS = frozenset({"false", ""}) + + +def _coerce_visible(value: Any) -> bool: + """Coerce a `visible` field value to a boolean. + + Lenient by design: Home Assistant templates render to strings, so values + like "false"/"False" must read as hidden even though any non-empty string + is normally truthy. Whitespace-only strings fold to False, preserving the + "render an empty string to hide" workaround. + + Args: + value: Raw `visible` value (bool, str, number, or other). + + Returns: + bool: True if the element should be shown, False if hidden. + """ + if isinstance(value, str): + return value.strip().lower() not in _FALSY_VISIBLE_STRINGS + if value is None: + return False + if isinstance(value, bool): + return value + return bool(value) + + def should_show_element(element: dict[str, Any]) -> bool: """Check if an element should be displayed. @@ -127,4 +184,4 @@ def should_show_element(element: dict[str, Any]) -> bool: Returns: bool: True if the element should be displayed, False otherwise """ - return bool(element.get("visible", True)) + return _coerce_visible(element.get("visible", True)) diff --git a/src/odl_renderer/transforms.py b/src/odl_renderer/transforms.py new file mode 100644 index 0000000..ef76801 --- /dev/null +++ b/src/odl_renderer/transforms.py @@ -0,0 +1,134 @@ +"""Generic per-element transforms (rotation, mirror) applied centrally. + +An element is rendered onto its own transparent full-canvas layer, then the +layer is transformed and composited back onto the base image. This keeps every +element handler unchanged: any element type gains `rotation`/`mirror` support +for free. + +Both transforms act around a pivot point. By default the pivot is the element's +rendered visual center (its bounding box center), so the element rotates/mirrors +in place. The pivot can be overridden per element. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from PIL import Image + +if TYPE_CHECKING: + from .coordinates import CoordinateParser + + +def has_transform(element: dict[str, Any]) -> bool: + """Return True if an element requests a rotation or mirror transform.""" + return bool(element.get("rotation")) or bool(element.get("mirror")) + + +def apply_transform( + layer: Image.Image, + *, + rotation: float | int | None = None, + mirror: str | None = None, + pivot: Any = None, + coords: CoordinateParser | None = None, +) -> Image.Image: + """Apply mirror then rotation to a transparent element layer. + + Args: + layer: Transparent RGBA layer containing a single rendered element. + rotation: Degrees, positive = clockwise. None/0 skips rotation. + mirror: "h", "v", or "hv" (case-insensitive). None/"" skips mirror. + pivot: Optional pivot. Either an anchor keyword (e.g. "tl", "mm", "br") + resolved against the element bbox, or an [x, y] canvas-coordinate + pair parsed via ``coords``. Defaults to the bbox center. + coords: CoordinateParser, required only to resolve an [x, y] pivot. + + Returns: + The transformed layer (same size as the input). If the layer is empty + (nothing was drawn), it is returned unchanged. + """ + bbox = layer.getbbox() + if bbox is None: + # Nothing was drawn — no transform to apply, nothing to composite. + return layer + + px, py = _resolve_pivot(pivot, bbox, coords) + + if mirror: + layer = _apply_mirror(layer, mirror, px, py) + + if rotation: + # PIL rotates counter-clockwise; negate so positive = clockwise. + layer = layer.rotate( + -float(rotation), + resample=Image.Resampling.BICUBIC, + center=(px, py), + expand=False, + ) + + return layer + + +def _apply_mirror(layer: Image.Image, mirror: str, px: float, py: float) -> Image.Image: + """Mirror a layer about the vertical line x=px and/or horizontal line y=py. + + Uses an affine transform so the flip happens about an arbitrary pivot axis + rather than the image center. Unknown mirror codes are ignored (lenient). + """ + code = mirror.strip().lower() + flip_h = "h" in code + flip_v = "v" in code + if not (flip_h or flip_v): + return layer + + # Affine maps output (x, y) -> input (a*x + b*y + c, d*x + e*y + f). + # Horizontal flip about x=px: input_x = 2*px - x. Vertical about y=py: input_y = 2*py - y. + a = -1.0 if flip_h else 1.0 + c = 2.0 * px if flip_h else 0.0 + e = -1.0 if flip_v else 1.0 + f = 2.0 * py if flip_v else 0.0 + + return layer.transform( + layer.size, + Image.Transform.AFFINE, + (a, 0.0, c, 0.0, e, f), + resample=Image.Resampling.NEAREST, + fillcolor=(0, 0, 0, 0), + ) + + +def _resolve_pivot( + pivot: Any, + bbox: tuple[int, int, int, int], + coords: CoordinateParser | None, +) -> tuple[float, float]: + """Resolve a pivot spec to absolute canvas coordinates. + + An [x, y] pair is parsed as canvas coordinates (percentages supported); a + string is treated as a bbox-relative anchor keyword. Anything else (or a + missing ``coords`` for the coordinate form) falls back to the bbox center. + """ + if isinstance(pivot, (list, tuple)) and len(pivot) == 2 and coords is not None: + return float(coords.parse_x(pivot[0])), float(coords.parse_y(pivot[1])) + + key = pivot if isinstance(pivot, str) else "mm" + return _anchor_point(key, bbox) + + +def _anchor_point(key: str, bbox: tuple[int, int, int, int]) -> tuple[float, float]: + """Resolve a two-letter anchor keyword to a bbox point. + + Uses the same Pillow anchor format as text: the first character selects the + horizontal edge (``l``/``m``/``r``) and the second the vertical edge + (``t``/``m``/``b``). Missing or unrecognized characters default to the + middle, so ``"mm"`` (or ``""``) is the center. + """ + left, top, right, bottom = bbox + x_by = {"l": left, "m": (left + right) / 2, "r": right} + y_by = {"t": top, "m": (top + bottom) / 2, "b": bottom} + + lowered = key.strip().lower() + h = lowered[0] if len(lowered) > 0 else "m" + v = lowered[1] if len(lowered) > 1 else "m" + return float(x_by.get(h, (left + right) / 2)), float(y_by.get(v, (top + bottom) / 2)) diff --git a/tests/integration/test_transforms.py b/tests/integration/test_transforms.py new file mode 100644 index 0000000..3a2be01 --- /dev/null +++ b/tests/integration/test_transforms.py @@ -0,0 +1,74 @@ +"""End-to-end tests for generic rotation/mirror transforms via generate_image.""" + +import pytest +from PIL import ImageChops + +from odl_renderer import generate_image + + +def _ink_bbox(image): + """Bounding box of non-background (non-white) pixels.""" + from PIL import Image + + bg = Image.new("RGB", image.size, "white") + return ImageChops.difference(image.convert("RGB"), bg).getbbox() + + +async def _render(element, size=(120, 120)): + return await generate_image(size[0], size[1], [element], background="white") + + +@pytest.mark.asyncio +class TestVisibleStrings: + async def test_string_false_hides(self): + shown = await _render({"type": "text", "value": "Hi", "x": 10, "y": 10, "size": 24}) + hidden = await _render({"type": "text", "value": "Hi", "x": 10, "y": 10, "size": 24, "visible": "false"}) + assert _ink_bbox(shown) is not None + assert _ink_bbox(hidden) is None + + async def test_string_true_shows(self): + image = await _render({"type": "text", "value": "Hi", "x": 10, "y": 10, "size": 24, "visible": "true"}) + assert _ink_bbox(image) is not None + + +@pytest.mark.asyncio +class TestRotation: + async def test_rotation_changes_layout(self): + base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + rotated = await _render({**base, "rotation": 90}) + assert _ink_bbox(plain) != _ink_bbox(rotated) + + async def test_rotation_zero_is_noop(self): + base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + rot0 = await _render({**base, "rotation": 0}) + assert ImageChops.difference(plain.convert("RGB"), rot0.convert("RGB")).getbbox() is None + + async def test_pivot_changes_result(self): + base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40, "rotation": 90} + center = await _render(base) + corner = await _render({**base, "pivot": "lt"}) + assert ImageChops.difference(center.convert("RGB"), corner.convert("RGB")).getbbox() is not None + + async def test_coordinate_pivot_renders(self): + image = await _render( + {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40, "rotation": 90, "pivot": ["50%", "50%"]} + ) + assert image.size == (120, 120) + + +@pytest.mark.asyncio +class TestMirror: + async def test_horizontal_mirror_flips_content(self): + base = {"type": "text", "value": "Rq", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + mirrored = await _render({**base, "mirror": "h"}) + # Bounding box is preserved (flip about its own center) but content differs. + assert ImageChops.difference(plain.convert("RGB"), mirrored.convert("RGB")).getbbox() is not None + + async def test_unknown_mirror_is_noop(self): + base = {"type": "text", "value": "Rq", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + weird = await _render({**base, "mirror": "diagonal"}) + assert ImageChops.difference(plain.convert("RGB"), weird.convert("RGB")).getbbox() is None diff --git a/tests/unit/test_transforms.py b/tests/unit/test_transforms.py new file mode 100644 index 0000000..a92533a --- /dev/null +++ b/tests/unit/test_transforms.py @@ -0,0 +1,118 @@ +"""Unit tests for the generic transform module.""" + +from PIL import Image, ImageChops + +from odl_renderer.coordinates import CoordinateParser +from odl_renderer.transforms import ( + _anchor_point, + _resolve_pivot, + apply_transform, + has_transform, +) + + +def _layer_with_box(size=(100, 100), box=(10, 10, 40, 30), color=(0, 0, 0, 255)): + """Transparent RGBA layer with one opaque rectangle drawn on it.""" + layer = Image.new("RGBA", size, (0, 0, 0, 0)) + from PIL import ImageDraw + + ImageDraw.Draw(layer).rectangle(box, fill=color) + return layer + + +class TestHasTransform: + def test_none(self): + assert has_transform({"type": "text"}) is False + + def test_zero_rotation_is_noop(self): + assert has_transform({"rotation": 0}) is False + + def test_rotation(self): + assert has_transform({"rotation": 90}) is True + + def test_mirror(self): + assert has_transform({"mirror": "h"}) is True + + +class TestAnchorPoint: + # Pillow text format: first char horizontal (l/m/r), second vertical (t/m/b). + def test_center_default(self): + assert _anchor_point("mm", (10, 20, 30, 40)) == (20.0, 30.0) + + def test_top_left(self): + assert _anchor_point("lt", (10, 20, 30, 40)) == (10.0, 20.0) + + def test_bottom_right(self): + assert _anchor_point("rb", (10, 20, 30, 40)) == (30.0, 40.0) + + def test_middle_top(self): + # Regression: the 'm' in "mt" must not collapse to center. + assert _anchor_point("mt", (10, 20, 30, 40)) == (20.0, 20.0) + + def test_middle_bottom(self): + assert _anchor_point("mb", (10, 20, 30, 40)) == (20.0, 40.0) + + def test_left_middle(self): + assert _anchor_point("lm", (10, 20, 30, 40)) == (10.0, 30.0) + + def test_empty_and_invalid_fall_back_to_center(self): + assert _anchor_point("", (10, 20, 30, 40)) == (20.0, 30.0) + assert _anchor_point("??", (10, 20, 30, 40)) == (20.0, 30.0) + + +class TestResolvePivot: + def test_default_is_bbox_center(self): + assert _resolve_pivot(None, (0, 0, 100, 50), None) == (50.0, 25.0) + + def test_anchor_keyword(self): + assert _resolve_pivot("lt", (0, 0, 100, 50), None) == (0.0, 0.0) + + def test_coord_pair_pixels(self): + coords = CoordinateParser(200, 100) + assert _resolve_pivot([20, 30], (0, 0, 10, 10), coords) == (20.0, 30.0) + + def test_coord_pair_percent(self): + coords = CoordinateParser(200, 100) + assert _resolve_pivot(["50%", "50%"], (0, 0, 10, 10), coords) == (100.0, 50.0) + + +class TestApplyTransform: + def test_empty_layer_unchanged(self): + layer = Image.new("RGBA", (50, 50), (0, 0, 0, 0)) + out = apply_transform(layer, rotation=45) + assert out.getbbox() is None + + def test_rotation_changes_pixels(self): + layer = _layer_with_box(box=(10, 10, 60, 20)) # wide bar + rotated = apply_transform(layer, rotation=90) + assert ImageChops.difference(layer, rotated).getbbox() is not None + + def test_180_rotation_is_involutive(self): + layer = _layer_with_box(box=(10, 10, 40, 20)) + once = apply_transform(layer, rotation=180) + twice = apply_transform(once, rotation=180) + # Two 180° turns about the same bbox center return to the original. + assert ImageChops.difference(layer, twice).getbbox() is None + + def test_horizontal_mirror_is_involutive(self): + layer = _layer_with_box(box=(10, 10, 40, 20)) + once = apply_transform(layer, mirror="h") + twice = apply_transform(once, mirror="h") + assert ImageChops.difference(layer, twice).getbbox() is None + + def test_mirror_moves_asymmetric_content(self): + # An L-shape is asymmetric about its bbox center, so a horizontal flip + # must actually move pixels (a single rectangle would be a no-op). + from PIL import ImageDraw + + layer = Image.new("RGBA", (100, 100), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + draw.rectangle((10, 10, 20, 60), fill=(0, 0, 0, 255)) # vertical stem + draw.rectangle((10, 50, 60, 60), fill=(0, 0, 0, 255)) # bottom foot to the right + mirrored = apply_transform(layer, mirror="h") + assert ImageChops.difference(layer, mirrored).getbbox() is not None + + def test_unknown_mirror_code_is_noop(self): + layer = _layer_with_box() + out = apply_transform(layer, mirror="x") + assert ImageChops.difference(layer, out).getbbox() is None diff --git a/tests/unit/test_visible.py b/tests/unit/test_visible.py new file mode 100644 index 0000000..84727dc --- /dev/null +++ b/tests/unit/test_visible.py @@ -0,0 +1,49 @@ +"""Unit tests for `visible` field coercion (core._coerce_visible).""" + +import pytest + +from odl_renderer.core import _coerce_visible + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Booleans pass through. + (True, True), + (False, False), + # Templated string forms — the motivating case. + ("true", True), + ("True", True), + ("TRUE", True), + ("false", False), + ("False", False), + ("FALSE", False), + # Empty / whitespace-only strings hide (preserves the HA workaround). + ("", False), + (" ", False), + ("\n \t", False), + # Surrounding whitespace is stripped before comparison. + (" false ", False), + (" true ", True), + # Narrowed set: only "false"/empty are falsy. Other words stay truthy + # so a value that happens to be "no"/"0"/"none" is not silently hidden. + ("no", True), + ("off", True), + ("0", True), + ("none", True), + ("null", True), + ("1", True), + ("yes", True), + ("anything", True), + # Numbers via bool(). + (0, False), + (1, True), + (2, True), + (0.0, False), + (2.5, True), + # None hides. + (None, False), + ], +) +def test_coerce_visible(value, expected): + assert _coerce_visible(value) is expected diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_mirrored_polygon.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_mirrored_polygon.png new file mode 100644 index 0000000..a1699ea Binary files /dev/null and b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_mirrored_polygon.png differ diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png new file mode 100644 index 0000000..7b8aeeb Binary files /dev/null and b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png differ diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png new file mode 100644 index 0000000..ae22beb Binary files /dev/null and b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png differ diff --git a/tests/visual/test_transform_rendering.py b/tests/visual/test_transform_rendering.py new file mode 100644 index 0000000..1d0cbdb --- /dev/null +++ b/tests/visual/test_transform_rendering.py @@ -0,0 +1,69 @@ +"""Visual regression tests for rotation/mirror transforms. + +Local-only (excluded from CI due to cross-platform font rendering). These lock +in the visual correctness of the geometric transforms, complementing the +behavioral assertions in tests/integration/test_transforms.py. +""" + +from io import BytesIO + +import pytest + +from odl_renderer import generate_image + + +def _png_bytes(image): + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +@pytest.mark.asyncio +class TestTransformVisualRegression: + async def test_rotated_text(self, snapshot_png, ppb_font): + image = await generate_image( + width=120, + height=120, + background="white", + elements=[ + {"type": "text", "value": "Rotate", "x": 60, "y": 60, "font": ppb_font, "size": 28, "rotation": 90}, + ], + ) + assert _png_bytes(image) == snapshot_png + + async def test_rotated_text_pivot_left_top(self, snapshot_png, ppb_font): + image = await generate_image( + width=120, + height=120, + background="white", + elements=[ + { + "type": "text", + "value": "Pivot", + "x": 60, + "y": 60, + "font": ppb_font, + "size": 28, + "rotation": 45, + "pivot": "lt", + }, + ], + ) + assert _png_bytes(image) == snapshot_png + + async def test_mirrored_polygon(self, snapshot_png): + # Asymmetric L-shape makes the horizontal flip visually obvious. + image = await generate_image( + width=120, + height=120, + background="white", + elements=[ + { + "type": "polygon", + "points": [[20, 20], [40, 20], [40, 80], [90, 80], [90, 100], [20, 100]], + "fill": "red", + "mirror": "h", + }, + ], + ) + assert _png_bytes(image) == snapshot_png diff --git a/uv.lock b/uv.lock index 03b1662..63b3511 100644 --- a/uv.lock +++ b/uv.lock @@ -807,7 +807,7 @@ wheels = [ [[package]] name = "odl-renderer" -version = "0.5.7" +version = "0.5.9" source = { editable = "." } dependencies = [ { name = "aiohttp" },