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
13 changes: 8 additions & 5 deletions src/odl_renderer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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 .transforms import apply_transform_region, has_transform
from .types import DataProvider, DrawingContext, ElementType

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -123,8 +123,9 @@ async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[s
"""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
pointing the context at it, so the handler is used unchanged. Only the region
the element occupies is then rotated/mirrored and alpha-composited onto the
base image (cost proportional to the element, not the whole canvas). Mutating
``ctx.img`` in place preserves any ``ctx.pos_y`` flow updates the handler
makes (e.g. text auto-flow).
"""
Expand All @@ -136,14 +137,16 @@ async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[s
finally:
ctx.img = base

layer = apply_transform(
result = apply_transform_region(
layer,
rotation=element.get("rotation"),
mirror=element.get("mirror"),
pivot=element.get("pivot"),
coords=ctx.coords,
)
base.alpha_composite(layer)
if result is not None:
transformed, offset = result
base.alpha_composite(transformed, offset)


_FALSY_VISIBLE_STRINGS = frozenset({"false", ""})
Expand Down
68 changes: 44 additions & 24 deletions src/odl_renderer/elements/media.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import functools
import logging
from typing import Any

Expand All @@ -15,6 +16,35 @@
_LOGGER = logging.getLogger(__name__)


@functools.lru_cache(maxsize=32)
def _render_qr_image(
data: str,
boxsize: int,
border: int,
fill: tuple[int, int, int],
back: tuple[int, int, int],
) -> Image.Image:
"""Render (and cache) a QR code image.

Generating a QR code costs ~5 ms, almost all in the library's best-mask-pattern
search, and e-paper dashboards re-render the same QR (Wi-Fi creds, a URL) on
every update. Caching by the inputs that determine the pixels turns the
steady-state cost to ~0. The returned image must not be mutated by callers
(it is composited read-only).
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=boxsize,
border=border,
)
qr.add_data(data)
qr.make(fit=True)
qr_img = qr.make_image(fill_color=fill, back_color=back)
rgba: Image.Image = qr_img.convert("RGBA")
return rgba


@element_handler(ElementType.QRCODE, requires=["x", "y", "data"])
async def draw_qrcode(ctx: DrawingContext, element: dict[str, Any]) -> None:
"""Draw QR code element.
Expand Down Expand Up @@ -45,21 +75,8 @@ async def draw_qrcode(ctx: DrawingContext, element: dict[str, Any]) -> None:
boxsize = element.get("boxsize", 2)

try:
# Create QR code instance
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=boxsize,
border=border,
)

# Add data and generate QR code
qr.add_data(element["data"])
qr.make(fit=True)

# Create QR code image (convert RGBA to RGB for fill/back colors)
qr_img = qr.make_image(fill_color=color[:3], back_color=bgcolor[:3])
qr_img = qr_img.convert("RGBA")
# Render (or reuse a cached) QR code image for these exact inputs.
qr_img = _render_qr_image(str(element["data"]), boxsize, border, color[:3], bgcolor[:3])

# Paste QR code onto main image
ctx.img.paste(qr_img, (x, y), qr_img)
Expand Down Expand Up @@ -123,16 +140,19 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict[str, Any]) ->
if source_img.size != target_size:
source_img = source_img.resize(target_size)

# Convert to RGBA
# Convert to RGBA and composite in place — avoids allocating and blending
# a full-canvas temporary image three times per dlimg. Clip to the canvas
# first so a partially off-canvas image doesn't raise (alpha_composite
# requires the source to fit within the destination).
source_img = source_img.convert("RGBA")

# Create temporary image for composition
temp_img = Image.new("RGBA", ctx.img.size)
temp_img.paste(source_img, (pos_x, pos_y), source_img)

# Composite images
img_composite = Image.alpha_composite(ctx.img, temp_img)
ctx.img.paste(img_composite, (0, 0))
crop_left = max(0, -pos_x)
crop_top = max(0, -pos_y)
crop_right = min(source_img.width, ctx.img.width - pos_x)
crop_bottom = min(source_img.height, ctx.img.height - pos_y)
if crop_right > crop_left and crop_bottom > crop_top:
if (crop_left, crop_top, crop_right, crop_bottom) != (0, 0, source_img.width, source_img.height):
source_img = source_img.crop((crop_left, crop_top, crop_right, crop_bottom))
ctx.img.alpha_composite(source_img, (pos_x + crop_left, pos_y + crop_top))

# Update vertical position
ctx.pos_y = pos_y + target_size[1]
Expand Down
31 changes: 28 additions & 3 deletions src/odl_renderer/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@
# Assets directory (bundled fonts)
_ASSETS_DIR = Path(__file__).parent / "assets"

# Module-level cache of loaded fonts keyed by (resolved_path, size), shared across
# every FontManager instance. core.generate_image() builds a fresh FontManager per
# call, so without this the truetype file would be re-read from disk on every render
# (blocking I/O on the event loop — several ms per render on an SD card). Mirrors the
# module-level MDI font cache in elements/icons.py.
_truetype_cache: dict[Tuple[str, int], ImageFont.FreeTypeFont] = {}


def _load_truetype(path: str, size: int) -> ImageFont.FreeTypeFont:
"""Load a TrueType font, reusing the process-wide cache when possible.

Args:
path: Absolute path to the font file.
size: Font size in pixels.

Returns:
The loaded (and cached) font object.
"""
key = (path, size)
cached = _truetype_cache.get(key)
if cached is None:
cached = ImageFont.truetype(path, size)
_truetype_cache[key] = cached
return cached


class FontManager:
"""Manages font loading and caching.
Expand Down Expand Up @@ -83,7 +108,7 @@ def _load_font(self, font_spec: str, size: int) -> ImageFont.FreeTypeFont:
if not os.path.exists(font_spec):
raise ValueError(f"Font file not found: {font_spec}")
try:
return ImageFont.truetype(font_spec, size)
return _load_truetype(font_spec, size)
except (OSError, IOError) as err:
raise ValueError(f"Failed to load font from {font_spec}: {err}") from err

Expand All @@ -95,15 +120,15 @@ def _load_font(self, font_spec: str, size: int) -> ImageFont.FreeTypeFont:
candidate = os.path.join(directory, font_name)
if os.path.isfile(candidate):
try:
return ImageFont.truetype(candidate, size)
return _load_truetype(candidate, size)
except (OSError, IOError) as err:
_LOGGER.warning("Failed to load font from %s: %s", candidate, err)

# Fall back to built-in assets directory
asset_path = _ASSETS_DIR / font_name
if asset_path.exists():
try:
return ImageFont.truetype(str(asset_path), size)
return _load_truetype(str(asset_path), size)
except (OSError, IOError) as err:
raise ValueError(f"Failed to load built-in font '{font_name}': {err}") from err

Expand Down
87 changes: 76 additions & 11 deletions src/odl_renderer/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,51 @@

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any

from PIL import Image

if TYPE_CHECKING:
from .coordinates import CoordinateParser

# Extra transparent margin (px) kept around the element when cropping before a
# transform, so the BICUBIC rotation kernel (±2 px support) samples the same
# neighborhood it would on the full canvas — keeping the output pixel-identical.
_CROP_MARGIN = 2


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 _transform_layer(
layer: Image.Image,
rotation: float | int | None,
mirror: str | None,
px: float,
py: float,
) -> Image.Image:
"""Apply mirror then rotation to *layer* about the pivot ``(px, py)``.

``(px, py)`` are in *layer*-local coordinates. The output keeps the input size.
"""
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_transform(
layer: Image.Image,
*,
Expand Down Expand Up @@ -54,20 +86,53 @@ def apply_transform(
return layer

px, py = _resolve_pivot(pivot, bbox, coords)
return _transform_layer(layer, rotation, mirror, px, py)

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,
)
def apply_transform_region(
layer: Image.Image,
*,
rotation: float | int | None = None,
mirror: str | None = None,
pivot: Any = None,
coords: CoordinateParser | None = None,
) -> tuple[Image.Image, tuple[int, int]] | None:
"""Transform only the region of *layer* the element occupies.

return layer
Same result as :func:`apply_transform` followed by an ``alpha_composite`` of the
full layer, but the mirror/rotate run on a crop around the element instead of the
whole canvas — so the cost is proportional to the element size, not the canvas.

Mirror and rotation are both isometries about the pivot, so every non-transparent
pixel stays within distance ``R`` (the farthest bbox corner from the pivot) of it.
A crop of that radius (plus a resampling margin), clipped to the canvas, therefore
contains all source *and* transformed content, and transforming it about the
translated pivot yields pixels identical to the full-canvas path.

Returns:
``(transformed_crop, (offset_x, offset_y))`` to composite the crop at, or
``None`` if the layer is empty or the element lies entirely off-canvas.
"""
bbox = layer.getbbox()
if bbox is None:
return None

px, py = _resolve_pivot(pivot, bbox, coords)

left, top, right, bottom = bbox
radius = max(math.hypot(cx - px, cy - py) for cx in (left, right) for cy in (top, bottom))
reach = math.ceil(radius) + _CROP_MARGIN

ox = max(0, math.floor(px - reach))
oy = max(0, math.floor(py - reach))
ex = min(layer.width, math.ceil(px + reach))
ey = min(layer.height, math.ceil(py + reach))
if ex <= ox or ey <= oy:
return None

crop = layer.crop((ox, oy, ex, ey))
transformed = _transform_layer(crop, rotation, mirror, px - ox, py - oy)
return transformed, (ox, oy)


def _apply_mirror(layer: Image.Image, mirror: str, px: float, py: float) -> Image.Image:
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/test_qrcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ async def test_empty_qrcode_data(self):
image = await generate_image(width=200, height=200, elements=[E.qrcode("", x=100, y=100, size=100)])

assert image.size == (200, 200)


def test_qr_image_cache_reuses_render():
"""Identical QR inputs reuse a cached rendered image (B5)."""
from odl_renderer.elements.media import _render_qr_image

a = _render_qr_image("https://example.org", 2, 1, (0, 0, 0), (255, 255, 255))
b = _render_qr_image("https://example.org", 2, 1, (0, 0, 0), (255, 255, 255))
assert a is b # cache hit — no regeneration

c = _render_qr_image("https://different.org", 2, 1, (0, 0, 0), (255, 255, 255))
assert c is not a # different data → distinct render
15 changes: 15 additions & 0 deletions tests/unit/test_fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,18 @@ def test_no_font_dirs_behaves_as_before(self):
manager = FontManager()
font = manager.get_font("ppb", 16)
assert isinstance(font, ImageFont.FreeTypeFont)


def test_module_font_cache_shared_across_managers():
"""The process-wide truetype cache is reused across FontManager instances (B4)."""
m1 = FontManager()
m2 = FontManager()
f1 = m1.get_font("ppb", 16)
f2 = m2.get_font("ppb", 16)
# A fresh FontManager per generate_image() must not re-read the font from disk.
assert f1 is f2


def test_module_font_cache_keys_on_size():
m = FontManager()
assert m.get_font("ppb", 16) is not m.get_font("ppb", 24)
Loading
Loading