Skip to content

Render

render

Rendering helpers for deux keys, cards, and touchscreen.

BackgroundLayer

Own the SVG source, parsed XML root, and rasterised outputs for a background.

A single BackgroundLayer encapsulates one background SVG and its derived artefacts: the parsed element tree, rasterised full image, and (for touchstrip backgrounds) per-panel tiles. Two kinds are supported:

"touchstrip" A full-width SVG that is rasterised and sliced into per-panel tiles. Requires panel_count, panel_width, and panel_height.

"key" A single-key SVG that is rasterised and encoded to device image bytes. Requires key_size and key_image_format.

Parameters:

Name Type Description Default
kind ``"touchstrip"`` or ``"key"``

Which surface this background belongs to.

required
panel_count int

Number of panels (touchstrip only).

0
panel_width int

Width of each panel in pixels (touchstrip only).

0
panel_height int

Height of each panel in pixels (touchstrip only).

0
key_size tuple[int, int]

(width, height) of a key (key only).

(0, 0)
key_image_format str

Device image format for key encoding (key only).

'JPEG'
Source code in src/deux/render/background_layer.py
class BackgroundLayer:
    """Own the SVG source, parsed XML root, and rasterised outputs for a background.

    A single ``BackgroundLayer`` encapsulates one background SVG and its
    derived artefacts: the parsed element tree, rasterised full image,
    and (for touchstrip backgrounds) per-panel tiles.  Two *kinds* are
    supported:

    ``"touchstrip"``
        A full-width SVG that is rasterised and sliced into per-panel
        tiles.  Requires *panel_count*, *panel_width*, and
        *panel_height*.

    ``"key"``
        A single-key SVG that is rasterised and encoded to device image
        bytes.  Requires *key_size* and *key_image_format*.

    Parameters
    ----------
    kind : ``"touchstrip"`` or ``"key"``
        Which surface this background belongs to.
    panel_count : int, optional
        Number of panels (touchstrip only).
    panel_width : int, optional
        Width of each panel in pixels (touchstrip only).
    panel_height : int, optional
        Height of each panel in pixels (touchstrip only).
    key_size : tuple[int, int], optional
        ``(width, height)`` of a key (key only).
    key_image_format : str, optional
        Device image format for key encoding (key only).
    """

    def __init__(
        self,
        kind: Literal["touchstrip", "key"],
        *,
        panel_count: int = 0,
        panel_width: int = 0,
        panel_height: int = 0,
        key_size: tuple[int, int] = (0, 0),
        key_image_format: str = "JPEG",
    ) -> None:
        self._kind = kind
        self._svg: bytes | None = None
        self._svg_root: ET.Element | None = None

        # Touchstrip state
        self._panel_count = panel_count
        self._panel_width = panel_width
        self._panel_height = panel_height
        self._tiles: list[bytes] | None = None

        # Key state
        self._key_size = key_size
        self._key_image_format = key_image_format
        self._key_image: bytes | None = None

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def kind(self) -> Literal["touchstrip", "key"]:
        """The surface kind this layer targets."""
        return self._kind

    @property
    def svg(self) -> bytes | None:
        """Raw SVG source bytes, or ``None`` if unset."""
        return self._svg

    @property
    def svg_root(self) -> ET.Element | None:
        """Parsed SVG root element, or ``None`` if unset."""
        return self._svg_root

    @property
    def tiles(self) -> list[bytes] | None:
        """Pre-sliced touchstrip tiles as PNG bytes (shallow copy), or ``None``.

        Only meaningful for ``kind="touchstrip"``.
        """
        if self._tiles is None:
            return None
        return list(self._tiles)

    def tile(self, index: int) -> bytes | None:
        """Return the cached background tile for panel *index*, or ``None``.

        Parameters
        ----------
        index : int
            Panel index (0-based).

        Returns
        -------
        bytes or None
            PNG-encoded tile bytes, or ``None`` if no background SVG is set
            or *index* is out of range.
        """
        if self._tiles is None or not 0 <= index < len(self._tiles):
            return None
        return self._tiles[index]

    @property
    def key_image(self) -> bytes | None:
        """Pre-rendered key background image bytes, or ``None``.

        Only meaningful for ``kind="key"``.
        """
        return self._key_image

    @property
    def has_svg(self) -> bool:
        """Whether a background SVG is currently set."""
        return self._svg is not None

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def set_svg(self, svg_data: bytes, *, trusted: bool = False) -> None:
        """Set the background SVG and rasterise immediately.

        Parameters
        ----------
        svg_data : bytes
            Raw SVG content as UTF-8 bytes.
        trusted : bool, default=False
            When ``True``, uses stdlib ``ET.fromstring`` (suitable for
            bundled/trusted SVGs).  When ``False``, uses the safe parser
            that strips potentially dangerous elements.
        """
        self._svg = svg_data
        if trusted:
            self._svg_root = ET.fromstring(svg_data)  # noqa: S314 — trusted
        else:
            self._svg_root = safe_fromstring(svg_data)
        self._rasterize()

    def set_svg_deferred(self, svg_data: bytes, *, trusted: bool = False) -> None:
        """Parse the SVG without rasterising (for async workflows).

        Call :meth:`rasterize` separately (e.g. via ``asyncio.to_thread``)
        to perform the CPU-bound rasterisation step.

        Parameters
        ----------
        svg_data : bytes
            Raw SVG content as UTF-8 bytes.
        trusted : bool, default=False
            When ``True``, uses stdlib ``ET.fromstring``.
        """
        self._svg = svg_data
        if trusted:
            self._svg_root = ET.fromstring(svg_data)  # noqa: S314 — trusted
        else:
            self._svg_root = safe_fromstring(svg_data)

    def rasterize(self) -> None:
        """Public entry point for rasterisation.

        Useful for offloading to a thread via ``asyncio.to_thread``.
        No-op if no SVG is set.
        """
        self._rasterize()

    def clear(self) -> None:
        """Remove the background SVG and all derived artefacts."""
        self._svg = None
        self._svg_root = None
        self._tiles = None
        self._key_image = None

    def invalidate(self) -> None:
        """Re-rasterize the current SVG (e.g. after stylesheet changes).

        No-op if no SVG is set.
        """
        if self._svg is not None:
            self._rasterize()

    # ------------------------------------------------------------------
    # Internal rasterisation
    # ------------------------------------------------------------------

    def _rasterize(self) -> None:
        """Rasterize the SVG according to the layer's *kind*.

        For ``"touchstrip"``, produces a full-width image and slices it
        into per-panel tiles.  For ``"key"``, produces an encoded key
        image ready to push to the device.
        """
        if self._svg is None:
            self._tiles = None
            self._key_image = None
            return

        if self._kind == "touchstrip":
            self._rasterize_touchstrip()
        else:
            self._rasterize_key()

    def _rasterize_touchstrip(self) -> None:
        """Rasterize and slice a touchstrip background SVG using Pillow."""
        assert self._svg is not None  # noqa: S101 — invariant

        t0 = time.perf_counter()
        total_width = self._panel_width * self._panel_count
        total_height = self._panel_height

        full_img = _svg_to_image(self._svg, total_width, total_height, mode="RGB")

        tiles: list[bytes] = []
        for i in range(self._panel_count):
            x0 = i * self._panel_width
            tile = full_img.crop((x0, 0, x0 + self._panel_width, total_height))
            buf = io.BytesIO()
            tile.save(buf, format="PNG")
            tiles.append(buf.getvalue())

        self._tiles = tiles
        elapsed = (time.perf_counter() - t0) * 1000.0
        _perf_logger.debug(
            "_rasterize_touchstrip %dx%d -> %d tiles %.1fms",
            total_width, total_height, self._panel_count, elapsed,
        )
        logger.debug(
            "Background SVG rasterized: %dx%d -> %d tiles of %dx%d",
            total_width,
            total_height,
            self._panel_count,
            self._panel_width,
            self._panel_height,
        )

    def _rasterize_key(self) -> None:
        """Rasterize a key background SVG to encoded device bytes."""
        assert self._svg is not None  # noqa: S101 — invariant

        t0 = time.perf_counter()
        key_w, key_h = self._key_size
        fmt = "jpeg" if self._key_image_format.upper() == "JPEG" else "bmp"
        self._key_image = _rasterize_svg(
            self._svg, key_w, key_h, output_format=fmt
        )
        elapsed = (time.perf_counter() - t0) * 1000.0
        _perf_logger.debug("_rasterize_key %dx%d fmt=%s %.1fms", key_w, key_h, fmt, elapsed)
        logger.debug("Key background SVG rasterized: %dx%d", key_w, key_h)

kind property

kind: Literal['touchstrip', 'key']

The surface kind this layer targets.

svg property

svg: bytes | None

Raw SVG source bytes, or None if unset.

svg_root property

svg_root: Element | None

Parsed SVG root element, or None if unset.

tiles property

tiles: list[bytes] | None

Pre-sliced touchstrip tiles as PNG bytes (shallow copy), or None.

Only meaningful for kind="touchstrip".

key_image property

key_image: bytes | None

Pre-rendered key background image bytes, or None.

Only meaningful for kind="key".

has_svg property

has_svg: bool

Whether a background SVG is currently set.

tile

tile(index: int) -> bytes | None

Return the cached background tile for panel index, or None.

Parameters:

Name Type Description Default
index int

Panel index (0-based).

required

Returns:

Type Description
bytes or None

PNG-encoded tile bytes, or None if no background SVG is set or index is out of range.

Source code in src/deux/render/background_layer.py
def tile(self, index: int) -> bytes | None:
    """Return the cached background tile for panel *index*, or ``None``.

    Parameters
    ----------
    index : int
        Panel index (0-based).

    Returns
    -------
    bytes or None
        PNG-encoded tile bytes, or ``None`` if no background SVG is set
        or *index* is out of range.
    """
    if self._tiles is None or not 0 <= index < len(self._tiles):
        return None
    return self._tiles[index]

set_svg

set_svg(svg_data: bytes, *, trusted: bool = False) -> None

Set the background SVG and rasterise immediately.

Parameters:

Name Type Description Default
svg_data bytes

Raw SVG content as UTF-8 bytes.

required
trusted bool

When True, uses stdlib ET.fromstring (suitable for bundled/trusted SVGs). When False, uses the safe parser that strips potentially dangerous elements.

False
Source code in src/deux/render/background_layer.py
def set_svg(self, svg_data: bytes, *, trusted: bool = False) -> None:
    """Set the background SVG and rasterise immediately.

    Parameters
    ----------
    svg_data : bytes
        Raw SVG content as UTF-8 bytes.
    trusted : bool, default=False
        When ``True``, uses stdlib ``ET.fromstring`` (suitable for
        bundled/trusted SVGs).  When ``False``, uses the safe parser
        that strips potentially dangerous elements.
    """
    self._svg = svg_data
    if trusted:
        self._svg_root = ET.fromstring(svg_data)  # noqa: S314 — trusted
    else:
        self._svg_root = safe_fromstring(svg_data)
    self._rasterize()

set_svg_deferred

set_svg_deferred(svg_data: bytes, *, trusted: bool = False) -> None

Parse the SVG without rasterising (for async workflows).

Call :meth:rasterize separately (e.g. via asyncio.to_thread) to perform the CPU-bound rasterisation step.

Parameters:

Name Type Description Default
svg_data bytes

Raw SVG content as UTF-8 bytes.

required
trusted bool

When True, uses stdlib ET.fromstring.

False
Source code in src/deux/render/background_layer.py
def set_svg_deferred(self, svg_data: bytes, *, trusted: bool = False) -> None:
    """Parse the SVG without rasterising (for async workflows).

    Call :meth:`rasterize` separately (e.g. via ``asyncio.to_thread``)
    to perform the CPU-bound rasterisation step.

    Parameters
    ----------
    svg_data : bytes
        Raw SVG content as UTF-8 bytes.
    trusted : bool, default=False
        When ``True``, uses stdlib ``ET.fromstring``.
    """
    self._svg = svg_data
    if trusted:
        self._svg_root = ET.fromstring(svg_data)  # noqa: S314 — trusted
    else:
        self._svg_root = safe_fromstring(svg_data)

rasterize

rasterize() -> None

Public entry point for rasterisation.

Useful for offloading to a thread via asyncio.to_thread. No-op if no SVG is set.

Source code in src/deux/render/background_layer.py
def rasterize(self) -> None:
    """Public entry point for rasterisation.

    Useful for offloading to a thread via ``asyncio.to_thread``.
    No-op if no SVG is set.
    """
    self._rasterize()

clear

clear() -> None

Remove the background SVG and all derived artefacts.

Source code in src/deux/render/background_layer.py
def clear(self) -> None:
    """Remove the background SVG and all derived artefacts."""
    self._svg = None
    self._svg_root = None
    self._tiles = None
    self._key_image = None

invalidate

invalidate() -> None

Re-rasterize the current SVG (e.g. after stylesheet changes).

No-op if no SVG is set.

Source code in src/deux/render/background_layer.py
def invalidate(self) -> None:
    """Re-rasterize the current SVG (e.g. after stylesheet changes).

    No-op if no SVG is set.
    """
    if self._svg is not None:
        self._rasterize()

RenderingContext dataclass

Immutable-ish bag of rendering state carried through the pipeline.

Parameters:

Name Type Description Default
theme Theme or None

The resolved theme for this render pass. None means "use the system-wide default".

None
stylesheet str or None

CSS stylesheet text derived from theme. When set, this overrides the module-level _stylesheet holder in :mod:~deux.render.svg_rasterize for the duration of the render.

None

Examples:

::

from deux.render.context import RenderingContext
from deux.render.theme import Theme

theme = Theme.from_color(255, 0, 128)
ctx = RenderingContext(theme=theme, stylesheet=theme.css)
Source code in src/deux/render/context.py
@dataclass
class RenderingContext:
    """Immutable-ish bag of rendering state carried through the pipeline.

    Parameters
    ----------
    theme : Theme or None
        The resolved theme for this render pass.  ``None`` means
        "use the system-wide default".
    stylesheet : str or None
        CSS stylesheet text derived from *theme*.  When set, this
        overrides the module-level ``_stylesheet`` holder in
        :mod:`~deux.render.svg_rasterize` for the duration of the
        render.

    Examples
    --------
    ::

        from deux.render.context import RenderingContext
        from deux.render.theme import Theme

        theme = Theme.from_color(255, 0, 128)
        ctx = RenderingContext(theme=theme, stylesheet=theme.css)
    """

    theme: Theme | None = None
    stylesheet: str | None = None

    @classmethod
    def from_theme(cls, theme: Theme) -> RenderingContext:
        """Create a context from a :class:`Theme`.

        The stylesheet is derived automatically from *theme.css*.

        Parameters
        ----------
        theme : Theme
            The theme to derive the context from.

        Returns
        -------
        RenderingContext
            A new context with *theme* and its CSS.
        """
        return cls(theme=theme, stylesheet=theme.css)

    def resolve_stylesheet(self) -> str | None:
        """Return the effective stylesheet.

        If an explicit stylesheet was provided it is returned.
        Otherwise, if a theme is set, its CSS is used.  Returns
        ``None`` when neither is available.

        Returns
        -------
        str or None
            The CSS stylesheet text, or ``None``.
        """
        if self.stylesheet is not None:
            return self.stylesheet
        if self.theme is not None:
            return self.theme.css
        return None

from_theme classmethod

from_theme(theme: Theme) -> RenderingContext

Create a context from a :class:Theme.

The stylesheet is derived automatically from theme.css.

Parameters:

Name Type Description Default
theme Theme

The theme to derive the context from.

required

Returns:

Type Description
RenderingContext

A new context with theme and its CSS.

Source code in src/deux/render/context.py
@classmethod
def from_theme(cls, theme: Theme) -> RenderingContext:
    """Create a context from a :class:`Theme`.

    The stylesheet is derived automatically from *theme.css*.

    Parameters
    ----------
    theme : Theme
        The theme to derive the context from.

    Returns
    -------
    RenderingContext
        A new context with *theme* and its CSS.
    """
    return cls(theme=theme, stylesheet=theme.css)

resolve_stylesheet

resolve_stylesheet() -> str | None

Return the effective stylesheet.

If an explicit stylesheet was provided it is returned. Otherwise, if a theme is set, its CSS is used. Returns None when neither is available.

Returns:

Type Description
str or None

The CSS stylesheet text, or None.

Source code in src/deux/render/context.py
def resolve_stylesheet(self) -> str | None:
    """Return the effective stylesheet.

    If an explicit stylesheet was provided it is returned.
    Otherwise, if a theme is set, its CSS is used.  Returns
    ``None`` when neither is available.

    Returns
    -------
    str or None
        The CSS stylesheet text, or ``None``.
    """
    if self.stylesheet is not None:
        return self.stylesheet
    if self.theme is not None:
        return self.theme.css
    return None

SurfaceBackgrounds

Bases: TypedDict

Mapping of surface type to raw SVG bytes.

A TypedDict with three optional keys; only those matching the device's hardware capabilities are populated.

Keys

key : bytes Raw SVG bytes used as the default background for individual key images. touchscreen : bytes Raw SVG bytes used as the default background for the full touchscreen strip (Stream Deck+ family). screen : bytes Raw SVG bytes used as the default background for the secondary information screen (Stream Deck Neo and similar).

Source code in src/deux/render/defaults/__init__.py
class SurfaceBackgrounds(TypedDict, total=False):
    """Mapping of surface type to raw SVG bytes.

    A ``TypedDict`` with three optional keys; only those matching the
    device's hardware capabilities are populated.

    Keys
    ----
    key : bytes
        Raw SVG bytes used as the default background for individual key
        images.
    touchscreen : bytes
        Raw SVG bytes used as the default background for the full
        touchscreen strip (Stream Deck+ family).
    screen : bytes
        Raw SVG bytes used as the default background for the secondary
        information screen (Stream Deck Neo and similar).
    """

    key: bytes
    touchscreen: bytes
    screen: bytes

ImageFetchError

Bases: DeuxError

Raised when a remote image cannot be fetched or decoded.

Source code in src/deux/render/image_fetch.py
class ImageFetchError(DeuxError):
    """Raised when a remote image cannot be fetched or decoded."""

RenderMetrics

Computed rendering metrics for a specific device.

All fields are derived from the device's :class:~deux.runtime.capabilities.DeviceCapabilities — there are no model-specific defaults. Panel dimensions are computed without margins or gaps; consumers are responsible for any spacing they want to apply in their own SVG/layout.

Parameters:

Name Type Description Default
caps DeviceCapabilities

Device capabilities to derive metrics from.

required

Attributes:

Name Type Description
key_size tuple[int, int]

(width, height) of a single key image in pixels.

key_image_format str

Native key image format expected by the device (e.g. "JPEG" or "BMP").

key_count int

Total number of physical keys on the device.

touchscreen_width int

Width of the touchscreen surface in pixels, or 0 if the device has no touchscreen.

touchscreen_height int

Height of the touchscreen surface in pixels, or 0 if the device has no touchscreen.

panel_count int

Number of logical touchscreen panels (typically one per dial on Stream Deck+), or 0 on devices without a touchscreen.

panel_width int

Width of a single touchscreen panel in pixels. 0 when the device has no touchscreen or no panels.

panel_height int

Height of a single touchscreen panel in pixels. 0 when the device has no touchscreen.

screen_width int

Width of the secondary information screen in pixels, or 0 if absent.

screen_height int

Height of the secondary information screen in pixels, or 0 if absent.

dial_count int

Number of rotary encoders (dials) on the device.

Source code in src/deux/render/metrics.py
class RenderMetrics:
    """Computed rendering metrics for a specific device.

    All fields are derived from the device's
    :class:`~deux.runtime.capabilities.DeviceCapabilities` — there are
    no model-specific defaults. Panel dimensions are computed without
    margins or gaps; consumers are responsible for any spacing they want
    to apply in their own SVG/layout.

    Parameters
    ----------
    caps : DeviceCapabilities
        Device capabilities to derive metrics from.

    Attributes
    ----------
    key_size : tuple[int, int]
        ``(width, height)`` of a single key image in pixels.
    key_image_format : str
        Native key image format expected by the device (e.g. ``"JPEG"``
        or ``"BMP"``).
    key_count : int
        Total number of physical keys on the device.
    touchscreen_width : int
        Width of the touchscreen surface in pixels, or ``0`` if the
        device has no touchscreen.
    touchscreen_height : int
        Height of the touchscreen surface in pixels, or ``0`` if the
        device has no touchscreen.
    panel_count : int
        Number of logical touchscreen panels (typically one per dial on
        Stream Deck+), or ``0`` on devices without a touchscreen.
    panel_width : int
        Width of a single touchscreen panel in pixels. ``0`` when the
        device has no touchscreen or no panels.
    panel_height : int
        Height of a single touchscreen panel in pixels. ``0`` when the
        device has no touchscreen.
    screen_width : int
        Width of the secondary information screen in pixels, or ``0``
        if absent.
    screen_height : int
        Height of the secondary information screen in pixels, or ``0``
        if absent.
    dial_count : int
        Number of rotary encoders (dials) on the device.
    """

    def __init__(self, caps: DeviceCapabilities) -> None:
        """Derive rendering metrics from device capabilities.

        Parameters
        ----------
        caps : DeviceCapabilities
            Capabilities of the device the metrics apply to. All
            attributes are computed eagerly from this object; the
            capabilities reference is retained for later inspection.
        """
        self._caps = caps

        self.key_size: tuple[int, int] = (caps.key_pixel_width, caps.key_pixel_height)
        self.key_image_format = caps.key_image_format
        self.key_count = caps.key_count

        self.touchscreen_width = caps.touchscreen_width
        self.touchscreen_height = caps.touchscreen_height
        self.panel_count = caps.panel_count

        if caps.has_touchscreen and self.panel_count > 0:
            self.panel_width = caps.touchscreen_width // self.panel_count
            self.panel_height = caps.touchscreen_height
        else:
            self.panel_width = 0
            self.panel_height = 0

        self.screen_width = caps.screen_width
        self.screen_height = caps.screen_height

        self.dial_count = caps.dial_count

__init__

__init__(caps: DeviceCapabilities) -> None

Derive rendering metrics from device capabilities.

Parameters:

Name Type Description Default
caps DeviceCapabilities

Capabilities of the device the metrics apply to. All attributes are computed eagerly from this object; the capabilities reference is retained for later inspection.

required
Source code in src/deux/render/metrics.py
def __init__(self, caps: DeviceCapabilities) -> None:
    """Derive rendering metrics from device capabilities.

    Parameters
    ----------
    caps : DeviceCapabilities
        Capabilities of the device the metrics apply to. All
        attributes are computed eagerly from this object; the
        capabilities reference is retained for later inspection.
    """
    self._caps = caps

    self.key_size: tuple[int, int] = (caps.key_pixel_width, caps.key_pixel_height)
    self.key_image_format = caps.key_image_format
    self.key_count = caps.key_count

    self.touchscreen_width = caps.touchscreen_width
    self.touchscreen_height = caps.touchscreen_height
    self.panel_count = caps.panel_count

    if caps.has_touchscreen and self.panel_count > 0:
        self.panel_width = caps.touchscreen_width // self.panel_count
        self.panel_height = caps.touchscreen_height
    else:
        self.panel_width = 0
        self.panel_height = 0

    self.screen_width = caps.screen_width
    self.screen_height = caps.screen_height

    self.dial_count = caps.dial_count

RenderProfiler

Collect wall-clock timings for named rendering steps.

Each profiler instance tracks a single logical operation (e.g. "render_screen_complete") and records sub-step timings via the :meth:step context manager. Steps may be nested: a child profiler can be attached to record finer-grained breakdowns.

Parameters:

Name Type Description Default
name str

Human-readable name for the operation being profiled.

required
parent RenderProfiler or None

Parent profiler for nested timing trees. When set, this profiler's summary is included in the parent's log output.

None
Source code in src/deux/render/profiler.py
class RenderProfiler:
    """Collect wall-clock timings for named rendering steps.

    Each profiler instance tracks a single logical operation (e.g.
    ``"render_screen_complete"``) and records sub-step timings via the
    :meth:`step` context manager.  Steps may be nested: a child profiler
    can be attached to record finer-grained breakdowns.

    Parameters
    ----------
    name : str
        Human-readable name for the operation being profiled.
    parent : RenderProfiler or None, optional
        Parent profiler for nested timing trees.  When set, this
        profiler's summary is included in the parent's log output.
    """

    def __init__(self, name: str, *, parent: RenderProfiler | None = None) -> None:
        self._name = name
        self._parent = parent
        self._steps: list[tuple[str, float, RenderProfiler | None]] = []
        self._total_ms: float = 0.0
        self._active = logger.isEnabledFor(logging.DEBUG)

    @property
    def name(self) -> str:
        """The operation name for this profiler."""
        return self._name

    @property
    def active(self) -> bool:
        """Whether profiling is active (DEBUG logging enabled)."""
        return self._active

    @property
    def steps(self) -> list[tuple[str, float, RenderProfiler | None]]:
        """Recorded steps as ``(name, elapsed_ms, child_profiler)`` tuples."""
        return list(self._steps)

    @property
    def total_ms(self) -> float:
        """Total elapsed time in milliseconds (set after :meth:`finish`)."""
        return self._total_ms

    @contextmanager
    def step(self, name: str) -> Generator[RenderProfiler, None, None]:
        """Time a named sub-step.

        Yields a child :class:`RenderProfiler` that can be used for
        further nesting.  The child's timings are automatically attached
        to this profiler's step record.

        Parameters
        ----------
        name : str
            Human-readable name for the sub-step.

        Yields
        ------
        RenderProfiler
            A child profiler for recording nested sub-steps.
        """
        child = RenderProfiler(name, parent=self)
        if not self._active:
            yield child
            return
        t0 = time.perf_counter()
        yield child
        elapsed_ms = (time.perf_counter() - t0) * 1000.0
        child._total_ms = elapsed_ms
        self._steps.append((name, elapsed_ms, child if child._steps else None))

    def finish(self, elapsed_ms: float | None = None) -> None:
        """Mark the profiler as finished and set the total elapsed time.

        Parameters
        ----------
        elapsed_ms : float or None, optional
            Total elapsed time.  If ``None``, the sum of recorded steps
            is used.
        """
        if elapsed_ms is not None:
            self._total_ms = elapsed_ms
        else:
            self._total_ms = sum(ms for _, ms, _ in self._steps)

    def log(self) -> None:
        """Log the collected timings at DEBUG level.

        Produces a tree-formatted summary, for example::

            render_screen_complete 142.3ms
              prefetch_icons      12.1ms
              render_all_keys     89.4ms
                render_phase      71.2ms
                push_phase        18.2ms
              render_touchscreen  38.7ms
              render_info_screen   2.1ms
        """
        if not self._active:
            return
        lines = self._format_lines(indent=0)
        logger.debug("\n".join(lines))

    def _format_lines(self, indent: int = 0) -> list[str]:
        """Build human-readable timing lines with tree connectors.

        Parameters
        ----------
        indent : int, default=0
            Current indentation level (number of spaces).

        Returns
        -------
        list[str]
            Formatted lines for logging.
        """
        prefix = " " * indent
        lines: list[str] = []

        if indent == 0:
            lines.append(f"{self._name} {self._total_ms:.1f}ms")
        for i, (step_name, elapsed, child) in enumerate(self._steps):
            is_last = i == len(self._steps) - 1
            connector = "\u2514\u2500" if is_last else "\u251c\u2500"
            lines.append(f"{prefix}  {connector} {step_name} {elapsed:.1f}ms")
            if child and child._steps:
                lines.extend(child._format_lines(indent=indent + 4))
        return lines

name property

name: str

The operation name for this profiler.

active property

active: bool

Whether profiling is active (DEBUG logging enabled).

steps property

steps: list[tuple[str, float, RenderProfiler | None]]

Recorded steps as (name, elapsed_ms, child_profiler) tuples.

total_ms property

total_ms: float

Total elapsed time in milliseconds (set after :meth:finish).

step

step(name: str) -> Generator[RenderProfiler, None, None]

Time a named sub-step.

Yields a child :class:RenderProfiler that can be used for further nesting. The child's timings are automatically attached to this profiler's step record.

Parameters:

Name Type Description Default
name str

Human-readable name for the sub-step.

required

Yields:

Type Description
RenderProfiler

A child profiler for recording nested sub-steps.

Source code in src/deux/render/profiler.py
@contextmanager
def step(self, name: str) -> Generator[RenderProfiler, None, None]:
    """Time a named sub-step.

    Yields a child :class:`RenderProfiler` that can be used for
    further nesting.  The child's timings are automatically attached
    to this profiler's step record.

    Parameters
    ----------
    name : str
        Human-readable name for the sub-step.

    Yields
    ------
    RenderProfiler
        A child profiler for recording nested sub-steps.
    """
    child = RenderProfiler(name, parent=self)
    if not self._active:
        yield child
        return
    t0 = time.perf_counter()
    yield child
    elapsed_ms = (time.perf_counter() - t0) * 1000.0
    child._total_ms = elapsed_ms
    self._steps.append((name, elapsed_ms, child if child._steps else None))

finish

finish(elapsed_ms: float | None = None) -> None

Mark the profiler as finished and set the total elapsed time.

Parameters:

Name Type Description Default
elapsed_ms float or None

Total elapsed time. If None, the sum of recorded steps is used.

None
Source code in src/deux/render/profiler.py
def finish(self, elapsed_ms: float | None = None) -> None:
    """Mark the profiler as finished and set the total elapsed time.

    Parameters
    ----------
    elapsed_ms : float or None, optional
        Total elapsed time.  If ``None``, the sum of recorded steps
        is used.
    """
    if elapsed_ms is not None:
        self._total_ms = elapsed_ms
    else:
        self._total_ms = sum(ms for _, ms, _ in self._steps)

log

log() -> None

Log the collected timings at DEBUG level.

Produces a tree-formatted summary, for example::

render_screen_complete 142.3ms
  prefetch_icons      12.1ms
  render_all_keys     89.4ms
    render_phase      71.2ms
    push_phase        18.2ms
  render_touchscreen  38.7ms
  render_info_screen   2.1ms
Source code in src/deux/render/profiler.py
def log(self) -> None:
    """Log the collected timings at DEBUG level.

    Produces a tree-formatted summary, for example::

        render_screen_complete 142.3ms
          prefetch_icons      12.1ms
          render_all_keys     89.4ms
            render_phase      71.2ms
            push_phase        18.2ms
          render_touchscreen  38.7ms
          render_info_screen   2.1ms
    """
    if not self._active:
        return
    lines = self._format_lines(indent=0)
    logger.debug("\n".join(lines))

RasterizeError

Bases: DeuxError

Raised when SVG rasterisation fails.

Source code in src/deux/render/svg_rasterize.py
class RasterizeError(DeuxError):
    """Raised when SVG rasterisation fails."""

Theme

An immutable colour theme derived from a single primary colour.

A theme contains a primary RGB colour, a font family, the derived 18-class CSS palette, and the complete CSS stylesheet string ready for :func:~deux.set_svg_stylesheet.

Instances are created via the factory class methods :meth:default, :meth:from_color, or :meth:from_random.

Parameters:

Name Type Description Default
primary tuple[int, int, int]

Primary colour as (r, g, b) with channels in 0–255.

required
font_family str

CSS font-family name (e.g. "Inter").

_DEFAULT_FONT_FAMILY

Attributes:

Name Type Description
primary tuple[int, int, int]

The primary RGB colour.

font_family str

The configured font family name.

palette dict[str, str]

Mapping of CSS class name to hex colour (18 entries).

css str

Complete CSS stylesheet string.

Source code in src/deux/render/theme.py
class Theme:
    """An immutable colour theme derived from a single primary colour.

    A theme contains a primary RGB colour, a font family, the derived
    18-class CSS palette, and the complete CSS stylesheet string ready
    for :func:`~deux.set_svg_stylesheet`.

    Instances are created via the factory class methods
    :meth:`default`, :meth:`from_color`, or :meth:`from_random`.

    Parameters
    ----------
    primary : tuple[int, int, int]
        Primary colour as ``(r, g, b)`` with channels in 0–255.
    font_family : str
        CSS font-family name (e.g. ``"Inter"``).

    Attributes
    ----------
    primary : tuple[int, int, int]
        The primary RGB colour.
    font_family : str
        The configured font family name.
    palette : dict[str, str]
        Mapping of CSS class name to hex colour (18 entries).
    css : str
        Complete CSS stylesheet string.
    """

    __slots__ = ("_primary", "_font_family", "_palette", "_css")

    def __init__(
        self,
        primary: tuple[int, int, int],
        font_family: str = _DEFAULT_FONT_FAMILY,
    ) -> None:
        self._primary = primary
        self._font_family = font_family
        self._palette = _generate_palette(
            (float(primary[0]), float(primary[1]), float(primary[2]))
        )
        self._css = _palette_to_css(self._palette, self._font_family)

    # --- public properties ---

    @property
    def primary(self) -> tuple[int, int, int]:
        """The primary RGB colour."""
        return self._primary

    @property
    def font_family(self) -> str:
        """The configured font family name."""
        return self._font_family

    @property
    def palette(self) -> dict[str, str]:
        """Mapping of CSS class name to hex colour string (18 entries)."""
        return dict(self._palette)

    @property
    def css(self) -> str:
        """Complete CSS stylesheet string ready for SVG rasterisation."""
        return self._css

    # --- factory class methods ---

    @classmethod
    def default(cls) -> Theme:
        """Create the default DeUX theme.

        Uses ``rgb(39, 87, 179)`` as the primary colour and ``Inter``
        as the font family.

        Returns
        -------
        Theme
            The default theme instance.
        """
        return cls(_DEFAULT_PRIMARY, _DEFAULT_FONT_FAMILY)

    @classmethod
    def from_color(
        cls,
        r: int,
        g: int,
        b: int,
        *,
        font_family: str = _DEFAULT_FONT_FAMILY,
    ) -> Theme:
        """Create a theme from a specific primary RGB colour.

        Parameters
        ----------
        r, g, b : int
            Primary colour channels (0–255).
        font_family : str, default="Inter"
            CSS font-family name.

        Returns
        -------
        Theme
            A new theme instance.
        """
        return cls((r, g, b), font_family)

    @classmethod
    def from_random(cls, *, font_family: str = _DEFAULT_FONT_FAMILY) -> Theme:
        """Create a theme from a random primary colour.

        Picks a random hue with moderate saturation and value to
        ensure readable, visually appealing palettes.

        Parameters
        ----------
        font_family : str, default="Inter"
            CSS font-family name.

        Returns
        -------
        Theme
            A new theme instance with a random primary colour.
        """
        hue = random.random()
        saturation = random.uniform(0.45, 0.85)
        value = random.uniform(0.35, 0.75)
        r, g, b = colorsys.hsv_to_rgb(hue, saturation, value)
        primary = (round(r * 255), round(g * 255), round(b * 255))
        logger.debug("Random primary colour: rgb%s", primary)
        return cls(primary, font_family)

    def __repr__(self) -> str:
        r, g, b = self._primary
        return f"Theme(primary=({r}, {g}, {b}), font_family={self._font_family!r})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Theme):
            return NotImplemented
        return self._primary == other._primary and self._font_family == other._font_family

primary property

primary: tuple[int, int, int]

The primary RGB colour.

font_family property

font_family: str

The configured font family name.

palette property

palette: dict[str, str]

Mapping of CSS class name to hex colour string (18 entries).

css property

css: str

Complete CSS stylesheet string ready for SVG rasterisation.

default classmethod

default() -> Theme

Create the default DeUX theme.

Uses rgb(39, 87, 179) as the primary colour and Inter as the font family.

Returns:

Type Description
Theme

The default theme instance.

Source code in src/deux/render/theme.py
@classmethod
def default(cls) -> Theme:
    """Create the default DeUX theme.

    Uses ``rgb(39, 87, 179)`` as the primary colour and ``Inter``
    as the font family.

    Returns
    -------
    Theme
        The default theme instance.
    """
    return cls(_DEFAULT_PRIMARY, _DEFAULT_FONT_FAMILY)

from_color classmethod

from_color(r: int, g: int, b: int, *, font_family: str = _DEFAULT_FONT_FAMILY) -> Theme

Create a theme from a specific primary RGB colour.

Parameters:

Name Type Description Default
r int

Primary colour channels (0–255).

required
g int

Primary colour channels (0–255).

required
b int

Primary colour channels (0–255).

required
font_family str

CSS font-family name.

"Inter"

Returns:

Type Description
Theme

A new theme instance.

Source code in src/deux/render/theme.py
@classmethod
def from_color(
    cls,
    r: int,
    g: int,
    b: int,
    *,
    font_family: str = _DEFAULT_FONT_FAMILY,
) -> Theme:
    """Create a theme from a specific primary RGB colour.

    Parameters
    ----------
    r, g, b : int
        Primary colour channels (0–255).
    font_family : str, default="Inter"
        CSS font-family name.

    Returns
    -------
    Theme
        A new theme instance.
    """
    return cls((r, g, b), font_family)

from_random classmethod

from_random(*, font_family: str = _DEFAULT_FONT_FAMILY) -> Theme

Create a theme from a random primary colour.

Picks a random hue with moderate saturation and value to ensure readable, visually appealing palettes.

Parameters:

Name Type Description Default
font_family str

CSS font-family name.

"Inter"

Returns:

Type Description
Theme

A new theme instance with a random primary colour.

Source code in src/deux/render/theme.py
@classmethod
def from_random(cls, *, font_family: str = _DEFAULT_FONT_FAMILY) -> Theme:
    """Create a theme from a random primary colour.

    Picks a random hue with moderate saturation and value to
    ensure readable, visually appealing palettes.

    Parameters
    ----------
    font_family : str, default="Inter"
        CSS font-family name.

    Returns
    -------
    Theme
        A new theme instance with a random primary colour.
    """
    hue = random.random()
    saturation = random.uniform(0.45, 0.85)
    value = random.uniform(0.35, 0.75)
    r, g, b = colorsys.hsv_to_rgb(hue, saturation, value)
    primary = (round(r * 255), round(g * 255), round(b * 255))
    logger.debug("Random primary colour: rgb%s", primary)
    return cls(primary, font_family)

get_default_backgrounds

get_default_backgrounds(vid: int, pid: int) -> SurfaceBackgrounds

Return default background SVGs for a device identified by VID:PID.

Looks up the bundled manifest to find the correct SVG files for each surface type (key, touchscreen, screen) supported by the device.

Parameters:

Name Type Description Default
vid int

USB vendor ID (e.g. 0x0FD9 for Elgato).

required
pid int

USB product ID.

required

Returns:

Type Description
SurfaceBackgrounds

Mapping of surface type to raw SVG bytes. Returns an empty dict if no defaults are defined for the given VID:PID.

Examples:

::

backgrounds = get_default_backgrounds(0x0FD9, 0x0084)
if "key" in backgrounds:
    key_svg = backgrounds["key"]
Source code in src/deux/render/defaults/__init__.py
def get_default_backgrounds(vid: int, pid: int) -> SurfaceBackgrounds:
    """Return default background SVGs for a device identified by VID:PID.

    Looks up the bundled manifest to find the correct SVG files for
    each surface type (key, touchscreen, screen) supported by the
    device.

    Parameters
    ----------
    vid : int
        USB vendor ID (e.g. ``0x0FD9`` for Elgato).
    pid : int
        USB product ID.

    Returns
    -------
    SurfaceBackgrounds
        Mapping of surface type to raw SVG bytes.  Returns an empty
        dict if no defaults are defined for the given VID:PID.

    Examples
    --------
    ::

        backgrounds = get_default_backgrounds(0x0FD9, 0x0084)
        if "key" in backgrounds:
            key_svg = backgrounds["key"]
    """
    if not _manifest_loaded:
        try:
            _load_manifest()
        except Exception:
            logger.warning("Failed to load default backgrounds manifest", exc_info=True)
            return SurfaceBackgrounds()

    surfaces = _device_map.get((vid, pid))
    if surfaces is None:
        return SurfaceBackgrounds()

    result = SurfaceBackgrounds()
    for surface_type, filename in surfaces.items():
        try:
            result[surface_type] = _read_svg(filename)  # type: ignore[literal-required]
        except Exception:
            logger.warning(
                "Failed to load default background %s for %04x:%04x",
                filename,
                vid,
                pid,
                exc_info=True,
            )

    return result

list_supported_devices

list_supported_devices() -> list[tuple[int, int]]

Return all VID:PID pairs that have default backgrounds.

Returns:

Type Description
list[tuple[int, int]]

Sorted list of (vendor_id, product_id) tuples.

Source code in src/deux/render/defaults/__init__.py
def list_supported_devices() -> list[tuple[int, int]]:
    """Return all VID:PID pairs that have default backgrounds.

    Returns
    -------
    list[tuple[int, int]]
        Sorted list of ``(vendor_id, product_id)`` tuples.
    """
    if not _manifest_loaded:
        try:
            _load_manifest()
        except Exception:
            logger.warning("Failed to load default backgrounds manifest", exc_info=True)
            return []

    return sorted(_device_map.keys())

fetch_image

fetch_image(url: str) -> Image.Image

Fetch a remote image by URL and return it as a PIL Image.

The result is cached in-process; subsequent calls with the same URL return the cached :class:~PIL.Image.Image immediately. Negative lookups (network failure, invalid image data) are also cached so we do not retry broken URLs on every render.

Parameters:

Name Type Description Default
url str

Fully-qualified HTTP(S) URL pointing to an image resource.

required

Returns:

Type Description
Image

The decoded image.

Raises:

Type Description
ImageFetchError

If the URL is malformed, the network request fails, or the response cannot be decoded as a valid image.

SSRFError

If the URL resolves to a private/loopback/link-local address and private URLs have not been explicitly allowed via :func:deux.set_allow_private_urls.

Source code in src/deux/render/image_fetch.py
def fetch_image(url: str) -> Image.Image:
    """Fetch a remote image by URL and return it as a PIL Image.

    The result is cached in-process; subsequent calls with the same URL
    return the cached :class:`~PIL.Image.Image` immediately.  Negative
    lookups (network failure, invalid image data) are also cached so we
    do not retry broken URLs on every render.

    Parameters
    ----------
    url : str
        Fully-qualified HTTP(S) URL pointing to an image resource.

    Returns
    -------
    PIL.Image.Image
        The decoded image.

    Raises
    ------
    ImageFetchError
        If the URL is malformed, the network request fails, or the
        response cannot be decoded as a valid image.
    SSRFError
        If the URL resolves to a private/loopback/link-local address
        and private URLs have not been explicitly allowed via
        :func:`deux.set_allow_private_urls`.
    """
    _validate_url(url)
    check_url(url)

    with _cache_lock:
        if url in _cache:
            cached = _cache[url]
            if cached is None:
                raise ImageFetchError(f"Image at '{url}' previously failed to load")
            return cached.copy()

    try:
        data = _http_get_bytes(url)
    except (urllib.error.URLError, OSError) as exc:
        with _cache_lock:
            _cache[url] = None
        raise ImageFetchError(f"Failed to fetch image from '{url}': {exc}") from exc

    try:
        img = Image.open(io.BytesIO(data))
        fmt = (img.format or "").upper()
        if fmt not in ALLOWED_FORMATS:
            raise ImageFetchError(
                f"Image format '{fmt}' from '{url}' is not allowed. "
                f"Supported formats: {sorted(ALLOWED_FORMATS)}"
            )
        img.load()  # force full decode so errors surface now
    except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
        with _cache_lock:
            _cache[url] = None
        raise ImageFetchError(
            f"Response from '{url}' is not a valid image: {exc}"
        ) from exc

    with _cache_lock:
        _cache[url] = img

    logger.debug("Fetched image from '%s' (%d bytes, %s)", url, len(data), img.size)
    return img.copy()

clear_image_cache

clear_image_cache() -> None

Drop all cached images. Primarily intended for tests.

Source code in src/deux/render/image_fetch.py
def clear_cache() -> None:
    """Drop all cached images.  Primarily intended for tests."""
    with _cache_lock:
        _cache.clear()

render_blank_key

render_blank_key(key_size: tuple[int, int], image_format: str = 'JPEG') -> bytes

Render a blank key image at the given size.

Parameters:

Name Type Description Default
key_size tuple[int, int]

Key dimensions (width, height) in pixels.

required
image_format str

Image encoding format ("JPEG" or "BMP").

'JPEG'

Returns:

Type Description
bytes

Encoded blank-key image bytes.

Source code in src/deux/render/key_renderer.py
def render_blank_key(
    key_size: tuple[int, int],
    image_format: str = "JPEG",
) -> bytes:
    """Render a blank key image at the given size.

    Parameters
    ----------
    key_size
        Key dimensions ``(width, height)`` in pixels.
    image_format
        Image encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded blank-key image bytes.
    """
    return render_key_image(key_size=key_size, image_format=image_format)

render_key_image

render_key_image(key_size: tuple[int, int], icon: bytes | Image | None = None, background: str = 'black', image_format: str = 'JPEG') -> bytes

Render an image for a Stream Deck key.

The icon (if any) is resized to fill the entire key, edge-to-edge — the library does not impose margins or padding. Callers that want spacing should bake it into the source icon.

Parameters:

Name Type Description Default
key_size tuple[int, int]

Key dimensions (width, height) in pixels.

required
icon bytes | Image | None

Optional icon: encoded image bytes (PNG/JPEG) or a PIL.Image.Image. Resized to key_size if it does not already match.

None
background str

Background colour name (used when icon is None or has alpha).

'black'
image_format str

Image encoding format ("JPEG" or "BMP").

'JPEG'

Returns:

Type Description
bytes

Encoded image bytes ready to send to the device.

Source code in src/deux/render/key_renderer.py
def render_key_image(
    key_size: tuple[int, int],
    icon: bytes | Image.Image | None = None,
    background: str = "black",
    image_format: str = "JPEG",
) -> bytes:
    """Render an image for a Stream Deck key.

    The icon (if any) is resized to fill the entire key, edge-to-edge —
    the library does not impose margins or padding. Callers that want
    spacing should bake it into the source icon.

    Parameters
    ----------
    key_size
        Key dimensions ``(width, height)`` in pixels.
    icon
        Optional icon: encoded image bytes (PNG/JPEG) or a
        ``PIL.Image.Image``. Resized to ``key_size`` if it does not
        already match.
    background
        Background colour name (used when *icon* is ``None`` or has alpha).
    image_format
        Image encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded image bytes ready to send to the device.
    """
    t0 = time.perf_counter()
    key_w, key_h = key_size
    r, g, b = _parse_color(background)
    img = Image.new("RGB", (key_w, key_h), (r, g, b))

    if icon is not None:
        icon_img: Image.Image
        if isinstance(icon, bytes):
            icon_img = Image.open(io.BytesIO(icon))
        elif isinstance(icon, Image.Image):
            icon_img = icon
        else:
            raise TypeError(f"Unsupported icon type: {type(icon)}")

        if icon_img.size != (key_w, key_h):
            icon_img = icon_img.resize((key_w, key_h), Image.Resampling.LANCZOS)

        if icon_img.mode == "RGBA":
            img.paste(icon_img, (0, 0), icon_img)
        else:
            img = icon_img.convert("RGB")

    result = _encode_image_bytes(img, image_format)
    elapsed = (time.perf_counter() - t0) * 1000.0
    _perf_logger.debug("render_key_image %dx%d fmt=%s %.1fms", key_w, key_h, image_format, elapsed)
    return result

render_profiler

render_profiler(name: str) -> RenderProfiler

Create a new :class:RenderProfiler instance.

Convenience factory that mirrors the common usage pattern.

Parameters:

Name Type Description Default
name str

Operation name for the profiler.

required

Returns:

Type Description
RenderProfiler

A new profiler instance.

Source code in src/deux/render/profiler.py
def render_profiler(name: str) -> RenderProfiler:
    """Create a new :class:`RenderProfiler` instance.

    Convenience factory that mirrors the common usage pattern.

    Parameters
    ----------
    name : str
        Operation name for the profiler.

    Returns
    -------
    RenderProfiler
        A new profiler instance.
    """
    return RenderProfiler(name)

render_info_screen

render_info_screen(image: Image | None, width: int, height: int, background: str = 'black', image_format: str = 'JPEG') -> bytes

Render an info screen image.

Parameters:

Name Type Description Default
image Image | None

Optional PIL Image to display. If None, a blank black screen is rendered.

required
width int

Screen width in pixels.

required
height int

Screen height in pixels.

required
background str

Background colour.

'black'
image_format str

Encoding format ("JPEG" or "BMP").

'JPEG'

Returns:

Type Description
bytes

Encoded image bytes.

Source code in src/deux/render/screen_renderer.py
def render_info_screen(
    image: Image.Image | None,
    width: int,
    height: int,
    background: str = "black",
    image_format: str = "JPEG",
) -> bytes:
    """Render an info screen image.

    Parameters
    ----------
    image
        Optional PIL Image to display. If ``None``, a blank
        black screen is rendered.
    width
        Screen width in pixels.
    height
        Screen height in pixels.
    background
        Background colour.
    image_format
        Encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded image bytes.
    """
    canvas = Image.new("RGB", (width, height), background)

    if image is not None:
        if image.size != (width, height):
            image = image.resize((width, height), Image.Resampling.LANCZOS)
        if image.mode == "RGBA":
            canvas.paste(image, (0, 0), image)
        else:
            canvas.paste(image, (0, 0))

    buf = io.BytesIO()
    fmt = image_format.upper()
    if fmt == "BMP":
        canvas.save(buf, format="BMP")
    else:
        canvas.save(buf, format="JPEG", quality=90)
    return buf.getvalue()

clear_svg_cache

clear_svg_cache() -> None

Clear the SVG rasterisation cache.

Removes all cached rasterised images and resets the hit/miss counters. Call this after changing the global stylesheet or theme to ensure stale images are not served.

Source code in src/deux/render/svg_rasterize.py
def clear_svg_cache() -> None:
    """Clear the SVG rasterisation cache.

    Removes all cached rasterised images and resets the hit/miss
    counters.  Call this after changing the global stylesheet or
    theme to ensure stale images are not served.
    """
    _raster_cache.clear()
    logger.debug("SVG rasterisation cache cleared")

get_svg_stylesheet

get_svg_stylesheet() -> str | None

Return the currently active application-wide CSS stylesheet.

Returns:

Type Description
str or None

The CSS text set via :func:set_svg_stylesheet, or None if no stylesheet is active.

Source code in src/deux/render/svg_rasterize.py
def get_svg_stylesheet() -> str | None:
    """Return the currently active application-wide CSS stylesheet.

    Returns
    -------
    str or None
        The CSS text set via :func:`set_svg_stylesheet`, or ``None``
        if no stylesheet is active.
    """
    return _stylesheet.css

load_svg_stylesheet

load_svg_stylesheet(path: str | Path) -> None

Load a CSS stylesheet from a file and set it as the active stylesheet.

This is a convenience wrapper around :func:set_svg_stylesheet that reads the CSS text from path and applies it application-wide.

Parameters:

Name Type Description Default
path str or Path

Path to a CSS file (UTF-8 encoded).

required

Raises:

Type Description
FileNotFoundError

If path does not exist.

IsADirectoryError

If path is a directory.

Examples:

::

from deux import load_svg_stylesheet

load_svg_stylesheet("assets/theme.css")
Source code in src/deux/render/svg_rasterize.py
def load_svg_stylesheet(path: str | Path) -> None:
    """Load a CSS stylesheet from a file and set it as the active stylesheet.

    This is a convenience wrapper around :func:`set_svg_stylesheet` that
    reads the CSS text from *path* and applies it application-wide.

    Parameters
    ----------
    path : str or Path
        Path to a CSS file (UTF-8 encoded).

    Raises
    ------
    FileNotFoundError
        If *path* does not exist.
    IsADirectoryError
        If *path* is a directory.

    Examples
    --------
    ::

        from deux import load_svg_stylesheet

        load_svg_stylesheet("assets/theme.css")
    """
    css = Path(path).read_text(encoding="utf-8")
    set_svg_stylesheet(css)

set_svg_stylesheet

set_svg_stylesheet(css: str | None) -> None

Set an application-wide CSS stylesheet for SVG rasterisation.

The stylesheet is applied to every SVG before rasterisation by injecting a <style> element before any existing <style> elements in the SVG, so that per-package styles can override the application defaults.

Pass None to clear the stylesheet.

Parameters:

Name Type Description Default
css str or None

Raw CSS text, or None to remove a previously set stylesheet.

required

Examples:

::

from deux import set_svg_stylesheet

set_svg_stylesheet(""".text-primary { color: #ff0000; }""")
Source code in src/deux/render/svg_rasterize.py
def set_svg_stylesheet(css: str | None) -> None:
    """Set an application-wide CSS stylesheet for SVG rasterisation.

    The stylesheet is applied to every SVG before rasterisation by
    injecting a ``<style>`` element **before** any existing ``<style>``
    elements in the SVG, so that per-package styles can override the
    application defaults.

    Pass ``None`` to clear the stylesheet.

    Parameters
    ----------
    css : str or None
        Raw CSS text, or ``None`` to remove a previously set stylesheet.

    Examples
    --------
    ::

        from deux import set_svg_stylesheet

        set_svg_stylesheet(\"\"\".text-primary { color: #ff0000; }\"\"\")
    """
    if css == _stylesheet.css:
        return
    _stylesheet.css = css
    clear_svg_cache()
    logger.debug(
        "SVG stylesheet %s",
        "cleared" if css is None else f"set ({len(css)} chars)",
    )

svg_cache_stats

svg_cache_stats() -> dict[str, int]

Return SVG rasterisation cache statistics.

Returns:

Type Description
dict[str, int]

Dictionary with "size", "hits", and "misses" keys.

Source code in src/deux/render/svg_rasterize.py
def svg_cache_stats() -> dict[str, int]:
    """Return SVG rasterisation cache statistics.

    Returns
    -------
    dict[str, int]
        Dictionary with ``"size"``, ``"hits"``, and ``"misses"`` keys.
    """
    return _raster_cache.stats()

get_active_theme

get_active_theme() -> Theme

Return the currently active system-wide theme.

If no theme has been explicitly set, returns :meth:Theme.default.

Returns:

Type Description
Theme

The active theme.

Source code in src/deux/render/theme.py
def get_active_theme() -> Theme:
    """Return the currently active system-wide theme.

    If no theme has been explicitly set, returns :meth:`Theme.default`.

    Returns
    -------
    Theme
        The active theme.
    """
    global _active_theme  # noqa: PLW0603
    if _active_theme is None:
        _active_theme = Theme.default()
    return _active_theme

get_default_font_family

get_default_font_family() -> str

Return the font family from the active system-wide theme.

Used by the SVG renderer for text measurement so that CSS font declarations and pixel-based text wrapping agree.

Returns:

Type Description
str

The active theme's font family name.

Source code in src/deux/render/theme.py
def get_default_font_family() -> str:
    """Return the font family from the active system-wide theme.

    Used by the SVG renderer for text measurement so that CSS font
    declarations and pixel-based text wrapping agree.

    Returns
    -------
    str
        The active theme's font family name.
    """
    return get_active_theme().font_family

set_active_theme

set_active_theme(theme: Theme | None) -> None

Set the system-wide active theme.

Also updates the global SVG stylesheet via :func:~deux.render.svg_rasterize.set_svg_stylesheet so that all subsequent SVG rasterisation uses the new theme's CSS.

Parameters:

Name Type Description Default
theme Theme or None

The theme to activate. Pass None to reset to the default theme.

required
Source code in src/deux/render/theme.py
def set_active_theme(theme: Theme | None) -> None:
    """Set the system-wide active theme.

    Also updates the global SVG stylesheet via
    :func:`~deux.render.svg_rasterize.set_svg_stylesheet` so that
    all subsequent SVG rasterisation uses the new theme's CSS.

    Parameters
    ----------
    theme : Theme or None
        The theme to activate.  Pass ``None`` to reset to the
        default theme.
    """
    global _active_theme  # noqa: PLW0603
    _active_theme = Theme.default() if theme is None else theme
    set_svg_stylesheet(_active_theme.css)
    logger.debug("Active theme set to %r", _active_theme)

compose_touchstrip

compose_touchstrip(card_tiles: list[bytes | None], *, touchscreen_width: int, touchscreen_height: int, panel_count: int, panel_width: int, background: str = 'black', bg_tiles: list[bytes] | None = None, image_format: str = 'JPEG') -> bytes

Compose card images into a single touchscreen image.

Cards are tiled edge-to-edge across the touchscreen. Card i starts at (i * panel_width, 0).

Parameters:

Name Type Description Default
card_tiles list[bytes | None]

Encoded card images (or None for blank slots).

required
touchscreen_width int

Total touchscreen width in pixels.

required
touchscreen_height int

Total touchscreen height in pixels.

required
panel_count int

Number of card zones.

required
panel_width int

Width of each card panel in pixels.

required
background str

Fill colour for the canvas where no card is drawn.

'black'
bg_tiles list[bytes] | None

Optional list of PNG-encoded background tiles (one per panel).

None
image_format str

Image encoding format ("JPEG" or "BMP").

'JPEG'

Returns:

Type Description
bytes

Encoded touchscreen image bytes.

Source code in src/deux/render/touch_renderer.py
def compose_touchstrip(
    card_tiles: list[bytes | None],
    *,
    touchscreen_width: int,
    touchscreen_height: int,
    panel_count: int,
    panel_width: int,
    background: str = "black",
    bg_tiles: list[bytes] | None = None,
    image_format: str = "JPEG",
) -> bytes:
    """Compose card images into a single touchscreen image.

    Cards are tiled edge-to-edge across the touchscreen. Card *i*
    starts at ``(i * panel_width, 0)``.

    Parameters
    ----------
    card_tiles
        Encoded card images (or ``None`` for blank slots).
    touchscreen_width
        Total touchscreen width in pixels.
    touchscreen_height
        Total touchscreen height in pixels.
    panel_count
        Number of card zones.
    panel_width
        Width of each card panel in pixels.
    background
        Fill colour for the canvas where no card is drawn.
    bg_tiles
        Optional list of PNG-encoded background tiles (one per panel).
    image_format
        Image encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded touchscreen image bytes.
    """
    t0 = time.perf_counter()
    r, g, b = _parse_color(background)
    canvas = Image.new("RGBA", (touchscreen_width, touchscreen_height), (r, g, b, 255))

    for index, card_data in enumerate(card_tiles):
        if index >= panel_count:
            break
        x_offset = index * panel_width
        tile_bytes = (
            bg_tiles[index] if bg_tiles is not None and index < len(bg_tiles) else None
        )

        panel: Image.Image | None = None
        if tile_bytes is not None:
            panel = _to_pil(tile_bytes).convert("RGBA")

        if card_data is not None:
            card_img = _to_pil(card_data).convert("RGBA")
            panel = Image.alpha_composite(panel, card_img) if panel is not None else card_img

        if panel is not None:
            canvas.paste(panel, (x_offset, 0))

    result = _encode_pil_image(canvas, image_format)
    elapsed = (time.perf_counter() - t0) * 1000.0
    _perf_logger.debug(
        "compose_touchstrip %dx%d panels=%d %.1fms",
        touchscreen_width, touchscreen_height, panel_count, elapsed,
    )
    return result

render_blank_touchscreen

render_blank_touchscreen(*, touchscreen_width: int, touchscreen_height: int, panel_count: int, panel_width: int, background: str = 'black', image_format: str = 'JPEG') -> bytes

Render a blank touch-strip image.

Parameters:

Name Type Description Default
touchscreen_width int

Total touchscreen width in pixels.

required
touchscreen_height int

Total touchscreen height in pixels.

required
panel_count int

Number of card zones.

required
panel_width int

Width of each card panel in pixels.

required
background str

Fill colour for the canvas.

'black'
image_format str

Image encoding format ("JPEG" or "BMP").

'JPEG'

Returns:

Type Description
bytes

Encoded blank touchscreen image bytes.

Source code in src/deux/render/touch_renderer.py
def render_blank_touchscreen(
    *,
    touchscreen_width: int,
    touchscreen_height: int,
    panel_count: int,
    panel_width: int,
    background: str = "black",
    image_format: str = "JPEG",
) -> bytes:
    """Render a blank touch-strip image.

    Parameters
    ----------
    touchscreen_width
        Total touchscreen width in pixels.
    touchscreen_height
        Total touchscreen height in pixels.
    panel_count
        Number of card zones.
    panel_width
        Width of each card panel in pixels.
    background
        Fill colour for the canvas.
    image_format
        Image encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded blank touchscreen image bytes.
    """
    return compose_touchstrip(
        [None] * panel_count,
        touchscreen_width=touchscreen_width,
        touchscreen_height=touchscreen_height,
        panel_count=panel_count,
        panel_width=panel_width,
        background=background,
        image_format=image_format,
    )