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
109 changes: 93 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|------|-----------------|
Expand All @@ -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
{
Expand All @@ -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
Expand Down
Binary file added docs/screenshots/mirror.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/rotation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion scripts/generate_screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
63 changes: 60 additions & 3 deletions src/odl_renderer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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.

Expand All @@ -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))
134 changes: 134 additions & 0 deletions src/odl_renderer/transforms.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading