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
26 changes: 26 additions & 0 deletions src/odl_renderer/coordinates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
from typing import Any


def coerce_number(value: Any, default: float = 0.0) -> float:
"""Coerce a scalar numeric value to a number, tolerating string input.

Home Assistant templates render everything to strings, so numeric element
fields (widths, counts, progress, …) frequently arrive as ``"50"`` rather
than ``50``. Accepts ints, floats and numeric strings; returns *default* for
``None`` or non-numeric input. Percentages are NOT handled here — use
:class:`CoordinateParser` for dimension fields that support ``"50%"``.

Args:
value: The raw value (number or string).
default: Value returned when *value* cannot be parsed.

Returns:
The parsed number, or *default*.
"""
if isinstance(value, bool):
return default
if isinstance(value, (int, float)):
return value
try:
return float(str(value).strip())
except (ValueError, TypeError):
return default


class CoordinateParser:
"""Helper class for parsing coordinates with percentage support.

Expand Down
2 changes: 1 addition & 1 deletion src/odl_renderer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[s
base.alpha_composite(layer)


_FALSY_VISIBLE_STRINGS = frozenset({"false", ""})
_FALSY_VISIBLE_STRINGS = frozenset({"false", "", "0", "no", "off", "none"})


def _coerce_visible(value: Any) -> bool:
Expand Down
11 changes: 6 additions & 5 deletions src/odl_renderer/elements/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from PIL import ImageDraw, ImageFont

from odl_renderer.colors import BLACK
from odl_renderer.coordinates import coerce_number
from odl_renderer.registry import element_handler
from odl_renderer.types import DrawingContext, ElementType

Expand Down Expand Up @@ -97,10 +98,10 @@ async def draw_icon(ctx: DrawingContext, element: dict[str, Any]) -> None:
raise ValueError(f"Icon '{name}' not found. Search icons at https://pictogrammers.com/library/mdi/")
char = chr(int(codepoint, 16))

font = _get_mdi_font(element["size"])
font = _get_mdi_font(ctx.coords.parse_size(element["size"], is_width=False))
color = ctx.colors.resolve(element.get("color") or element.get("fill", "black")) or BLACK
anchor = element.get("anchor", "la")
stroke_width = element.get("stroke_width", 0)
stroke_width = int(coerce_number(element.get("stroke_width", 0), 0))
stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white"))

draw = ImageDraw.Draw(ctx.img)
Expand Down Expand Up @@ -144,11 +145,11 @@ async def draw_icon_sequence(ctx: DrawingContext, element: dict[str, Any]) -> No
x_start = ctx.coords.parse_x(element["x"])
y_start = ctx.coords.parse_y(element["y"])

size = element["size"]
spacing = element.get("spacing", size // 4)
size = ctx.coords.parse_size(element["size"], is_width=False)
spacing = int(coerce_number(element.get("spacing", size // 4), size // 4))
color = ctx.colors.resolve(element.get("color") or element.get("fill", "black")) or BLACK
anchor = element.get("anchor", "la")
stroke_width = element.get("stroke_width", 0)
stroke_width = int(coerce_number(element.get("stroke_width", 0), 0))
stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white"))
direction = element.get("direction", "right")

Expand Down
10 changes: 7 additions & 3 deletions src/odl_renderer/elements/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from resizeimage import resizeimage # type: ignore[import-untyped]

from odl_renderer.colors import BLACK
from odl_renderer.coordinates import coerce_number
from odl_renderer.media_loader import load_image
from odl_renderer.registry import element_handler
from odl_renderer.types import DrawingContext, ElementType
Expand Down Expand Up @@ -41,8 +42,8 @@ async def draw_qrcode(ctx: DrawingContext, element: dict[str, Any]) -> None:
# Get QR code properties
color = ctx.colors.resolve(element.get("color", "black")) or BLACK
bgcolor = ctx.colors.resolve(element.get("bgcolor", "white")) or BLACK
border = element.get("border", 1)
boxsize = element.get("boxsize", 2)
border = int(coerce_number(element.get("border", 1), 1))
boxsize = int(coerce_number(element.get("boxsize", 2), 2))

try:
# Create QR code instance
Expand Down Expand Up @@ -99,7 +100,10 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict[str, Any]) ->
# Get image properties
pos_x = ctx.coords.parse_x(element["x"])
pos_y = ctx.coords.parse_y(element["y"])
target_size = (element["xsize"], element["ysize"])
target_size = (
ctx.coords.parse_size(element["xsize"], is_width=True),
ctx.coords.parse_size(element["ysize"], is_width=False),
)
rotate = element.get("rotate", 0)
resize_method = element.get("resize_method", "stretch")

Expand Down
48 changes: 30 additions & 18 deletions src/odl_renderer/elements/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from PIL import ImageDraw

from odl_renderer.colors import BLACK
from odl_renderer.coordinates import coerce_number
from odl_renderer.registry import element_handler
from odl_renderer.types import DrawingContext, ElementType

Expand Down Expand Up @@ -36,10 +37,10 @@ async def draw_line(ctx: DrawingContext, element: dict[str, Any]) -> None:

# Get line properties
fill = ctx.colors.resolve(element.get("fill", "black"))
width = element.get("width", 1)
width = int(coerce_number(element.get("width", 1), 1))
dashed = element.get("dashed", False)
dash_length = element.get("dash_length", 5)
space_length = element.get("space_length", 3)
dash_length = int(coerce_number(element.get("dash_length", 5), 5))
space_length = int(coerce_number(element.get("space_length", 3), 3))

x_start = ctx.coords.parse_x(element["x_start"])
x_end = ctx.coords.parse_x(element["x_end"])
Expand Down Expand Up @@ -74,8 +75,8 @@ async def draw_rectangle(ctx: DrawingContext, element: dict[str, Any]) -> None:
# Get rectangle properties
rect_fill = ctx.colors.resolve(element.get("fill"))
rect_outline = ctx.colors.resolve(element.get("outline", "black"))
rect_width = element.get("width", 1)
radius = element.get("radius", 10 if "corners" in element else 0)
rect_width = int(coerce_number(element.get("width", 1), 1))
radius = int(coerce_number(element.get("radius", 10 if "corners" in element else 0)))
corners = get_rounded_corners(element.get("corners", "all" if "radius" in element else ""))

# Draw rectangle
Expand Down Expand Up @@ -111,30 +112,40 @@ async def draw_rectangle_pattern(ctx: DrawingContext, element: dict[str, Any]) -
# Get pattern properties
fill = ctx.colors.resolve(element.get("fill"))
outline = ctx.colors.resolve(element.get("outline", "black"))
width = element.get("width", 1)
radius = element.get("radius", 10 if "corners" in element else 0)
width = int(coerce_number(element.get("width", 1), 1))
radius = int(coerce_number(element.get("radius", 10 if "corners" in element else 0)))
corners = get_rounded_corners(element.get("corners", "all" if "radius" in element else ""))

max_y = element["y_start"]
# Parse geometry once (supports numeric strings and percentages from templates).
x_start = ctx.coords.parse_x(element["x_start"])
y_start = ctx.coords.parse_y(element["y_start"])
x_size = ctx.coords.parse_size(element["x_size"], is_width=True)
y_size = ctx.coords.parse_size(element["y_size"], is_width=False)
x_offset = ctx.coords.parse_size(element["x_offset"], is_width=True)
y_offset = ctx.coords.parse_size(element["y_offset"], is_width=False)
x_repeat = int(coerce_number(element["x_repeat"]))
y_repeat = int(coerce_number(element["y_repeat"]))

max_y = y_start

# Draw rectangle grid
for x in range(element["x_repeat"]):
for y in range(element["y_repeat"]):
for x in range(x_repeat):
for y in range(y_repeat):
# Calculate rectangle position
x_pos = element["x_start"] + x * (element["x_offset"] + element["x_size"])
y_pos = element["y_start"] + y * (element["y_offset"] + element["y_size"])
x_pos = x_start + x * (x_offset + x_size)
y_pos = y_start + y * (y_offset + y_size)

# Draw individual rectangle
draw.rounded_rectangle(
(x_pos, y_pos, x_pos + element["x_size"], y_pos + element["y_size"]),
(x_pos, y_pos, x_pos + x_size, y_pos + y_size),
fill=fill,
outline=outline,
width=width,
radius=radius,
corners=corners,
)

max_y = max(max_y, y_pos + element["y_size"])
max_y = max(max_y, y_pos + y_size)

ctx.pos_y = max_y

Expand Down Expand Up @@ -185,17 +196,18 @@ async def draw_circle(ctx: DrawingContext, element: dict[str, Any]) -> None:
# Get circle properties
fill = ctx.colors.resolve(element.get("fill"))
outline = ctx.colors.resolve(element.get("outline", "black"))
width = element.get("width", 1)
width = int(coerce_number(element.get("width", 1), 1))
radius = ctx.coords.parse_size(element["radius"], is_width=True)

# Draw circle
draw.ellipse(
[(x - element["radius"], y - element["radius"]), (x + element["radius"], y + element["radius"])],
[(x - radius, y - radius), (x + radius, y + radius)],
fill=fill,
outline=outline,
width=width,
)

ctx.pos_y = y + element["radius"]
ctx.pos_y = y + radius


@element_handler(ElementType.ELLIPSE, requires=["x_start", "x_end", "y_start", "y_end"])
Expand All @@ -220,7 +232,7 @@ async def draw_ellipse(ctx: DrawingContext, element: dict[str, Any]) -> None:
# Get ellipse properties
fill = ctx.colors.resolve(element.get("fill"))
outline = ctx.colors.resolve(element.get("outline", "black"))
width = element.get("width", 1)
width = int(coerce_number(element.get("width", 1), 1))

# Draw ellipse
draw.ellipse([(x_start, y_start), (x_end, y_end)], fill=fill, outline=outline, width=width)
Expand Down
21 changes: 13 additions & 8 deletions src/odl_renderer/elements/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from PIL import ImageDraw, ImageFont

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

Expand Down Expand Up @@ -34,7 +35,7 @@ async def draw_text(ctx: DrawingContext, element: dict[str, Any]) -> None:

x = ctx.coords.parse_x(element["x"])
if "y" not in element:
y = ctx.pos_y + element.get("y_padding", 10)
y = ctx.pos_y + int(coerce_number(element.get("y_padding", 10), 10))
else:
y = ctx.coords.parse_y(element["y"])
# Get text properties
Expand All @@ -47,12 +48,14 @@ async def draw_text(ctx: DrawingContext, element: dict[str, Any]) -> None:
default_color = ctx.colors.resolve(element.get("color", "black"))
anchor = element.get("anchor")
spacing = element.get("spacing", 5)
stroke_width = element.get("stroke_width", 0)
stroke_width = int(coerce_number(element.get("stroke_width", 0), 0))
stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white"))

# Process text content
text = str(element["value"])
max_width = element.get("max_width")
if max_width is not None:
max_width = ctx.coords.parse_size(max_width, is_width=True)

# Handle text wrapping if max_width is specified
final_text = text
Expand Down Expand Up @@ -182,14 +185,15 @@ async def draw_multiline(ctx: DrawingContext, element: dict[str, Any]) -> None:
draw.fontmode = "1"

# Get text properties
size = element.get("size", 20)
size = ctx.coords.parse_size(element.get("size", 20), is_width=False)
font_name = element.get("font", "ppb.ttf")
font = ctx.fonts.get_font(font_name, size)
color = ctx.colors.resolve(element.get("color", "black"))
align = element.get("align", "left")
anchor = element.get("anchor", "lm")
stroke_width = element.get("stroke_width", 0)
stroke_width = int(coerce_number(element.get("stroke_width", 0), 0))
stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white"))
offset_y = int(coerce_number(element["offset_y"]))

x = ctx.coords.parse_x(element["x"])
# Support both 'y' (standard) and 'start_y' (legacy) for backward compatibility
Expand All @@ -198,10 +202,11 @@ async def draw_multiline(ctx: DrawingContext, element: dict[str, Any]) -> None:
elif "start_y" in element:
current_y = ctx.coords.parse_y(element["start_y"])
else:
current_y = ctx.pos_y + element.get("y_padding", 10)
current_y = ctx.pos_y + int(coerce_number(element.get("y_padding", 10), 10))

# Split text using delimiter
lines = element["value"].replace("\n", "").split(element["delimiter"])
# Split text using delimiter. str()-coerce the value because HA templates may
# render a non-string (e.g. a number) here.
lines = str(element["value"]).replace("\n", "").split(element["delimiter"])

max_y = current_y
for line in lines:
Expand Down Expand Up @@ -232,7 +237,7 @@ async def draw_multiline(ctx: DrawingContext, element: dict[str, Any]) -> None:
stroke_width=stroke_width,
stroke_fill=stroke_fill,
)
current_y += element["offset_y"]
current_y += offset_y
max_y = current_y

ctx.pos_y = max_y
Expand Down
15 changes: 8 additions & 7 deletions src/odl_renderer/elements/visualizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from PIL import ImageDraw

from odl_renderer.coordinates import coerce_number
from odl_renderer.registry import element_handler
from odl_renderer.types import DrawingContext, ElementType

Expand Down Expand Up @@ -562,10 +563,10 @@ async def draw_plot(ctx: DrawingContext, element: dict[str, Any]) -> None:
raise ValueError("plot element requires a data_provider in DrawingContext")

draw = ImageDraw.Draw(ctx.img)
x_start = element.get("x_start", 0)
y_start = element.get("y_start", 0)
x_end = element.get("x_end", ctx.img.width - 1 - x_start)
y_end = element.get("y_end", ctx.img.height - 1 - y_start)
x_start = ctx.coords.parse_x(element.get("x_start", 0))
y_start = ctx.coords.parse_y(element.get("y_start", 0))
x_end = ctx.coords.parse_x(element["x_end"]) if "x_end" in element else ctx.img.width - 1 - x_start
y_end = ctx.coords.parse_y(element["y_end"]) if "y_end" in element else ctx.img.height - 1 - y_start
font_name = element.get("font", "ppb.ttf")

duration_seconds = float(element.get("duration", 60 * 60 * 24))
Expand Down Expand Up @@ -663,12 +664,12 @@ async def draw_progress_bar(ctx: DrawingContext, element: dict[str, Any]) -> Non
x_end = ctx.coords.parse_x(element["x_end"])
y_end = ctx.coords.parse_y(element["y_end"])

progress = min(100, max(0, element["progress"])) # Clamp to 0-100
progress = min(100, max(0, coerce_number(element["progress"]))) # Clamp to 0-100
direction = element.get("direction", "right")
background = ctx.colors.resolve(element.get("background", "white"))
fill = ctx.colors.resolve(element.get("fill", "red"))
outline = ctx.colors.resolve(element.get("outline", "black"))
width = element.get("width", 1)
width = int(coerce_number(element.get("width", 1), 1))
show_percentage = element.get("show_percentage", False)
font_name = element.get("font_name", "ppb.ttf")

Expand Down Expand Up @@ -702,7 +703,7 @@ async def draw_progress_bar(ctx: DrawingContext, element: dict[str, Any]) -> Non
font_size = min(y_end - y_start - 4, x_end - x_start - 4, 20)
font = ctx.fonts.get_font(font_name, font_size)

percentage_text = f"{progress}%"
percentage_text = f"{progress:g}%"

# Get text dimensions
text_bbox = draw.textbbox((0, 0), percentage_text, font=font)
Expand Down
Loading
Loading