imagespec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imagespec/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """imagespec — render images from a declarative YAML/dict spec.
2
+
3
+ Public API:
4
+
5
+ from imagespec import render, RenderContext
6
+
7
+ ctx = RenderContext(font_resolver=..., history_provider=...)
8
+ image = render(payload, width=296, height=128, rotate=0,
9
+ background="white", context=ctx)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ from .colors import (
17
+ PALETTE_4,
18
+ PALETTE_7,
19
+ PALETTE_BW,
20
+ PALETTE_BWR,
21
+ PALETTES,
22
+ get_index_color,
23
+ get_palette,
24
+ quantize_color,
25
+ )
26
+ from .context import RenderContext
27
+ from .core import ROTATE_MODE_CANVAS, ROTATE_MODE_IMAGE, render
28
+ from .exceptions import RenderError
29
+ from .registry import known_types
30
+ from .resolvers import caching_resolver, chain_resolvers, directory_resolver
31
+
32
+ __all__ = [
33
+ "render",
34
+ "RenderContext",
35
+ "RenderError",
36
+ "ROTATE_MODE_CANVAS",
37
+ "ROTATE_MODE_IMAGE",
38
+ "PALETTE_BW",
39
+ "PALETTE_BWR",
40
+ "PALETTE_4",
41
+ "PALETTE_7",
42
+ "PALETTES",
43
+ "get_palette",
44
+ "quantize_color",
45
+ "get_index_color",
46
+ "known_types",
47
+ "caching_resolver",
48
+ "chain_resolvers",
49
+ "directory_resolver",
50
+ ]
51
+
52
+ # The version is defined statically in pyproject.toml.
53
+ # We read it dynamically here via importlib.metadata.
54
+ try:
55
+ __version__ = version("imagespec")
56
+ except PackageNotFoundError:
57
+ __version__ = "unknown"
imagespec/colors.py ADDED
@@ -0,0 +1,157 @@
1
+ """Color handling for limited-palette displays.
2
+
3
+ Different devices support different palettes — 2-color (black/white),
4
+ 4-color (+red/yellow), 7-color (ACeP), etc. The palette is therefore **not**
5
+ hardcoded here: it lives on :class:`~imagespec.context.RenderContext` and is
6
+ passed in per render. Any requested color (name or ``#RRGGBB``) is *quantized*
7
+ to the nearest color the device can actually show.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+ # ── Canonical named colors (RGBA) ──────────────────────────────────────────
17
+ white = (255, 255, 255, 255)
18
+ black = (0, 0, 0, 255)
19
+ red = (255, 0, 0, 255)
20
+ yellow = (255, 255, 0, 255)
21
+ green = (0, 128, 0, 255)
22
+ blue = (0, 0, 255, 255)
23
+ orange = (255, 165, 0, 255)
24
+
25
+ NAMED_COLORS = {
26
+ "white": white,
27
+ "w": white,
28
+ "black": black,
29
+ "b": black,
30
+ "red": red,
31
+ "r": red,
32
+ "yellow": yellow,
33
+ "y": yellow,
34
+ "green": green,
35
+ "g": green,
36
+ "blue": blue,
37
+ "orange": orange,
38
+ "o": orange,
39
+ }
40
+
41
+ # ── Device palettes (ordered subsets of the canonical colors) ──────────────
42
+ PALETTE_BW = [black, white] # 2-color
43
+ PALETTE_BWR = [black, white, red] # 3-color (BWR)
44
+ PALETTE_BWY = [black, white, yellow] # 3-color (BWY)
45
+ PALETTE_4 = [black, white, red, yellow] # 4-color
46
+ PALETTE_7 = [black, white, red, green, blue, yellow, orange] # 7-color (ACeP)
47
+
48
+ # Optional shorthand aliases — never required; you can always pass an explicit
49
+ # list of colors (see get_palette).
50
+ PALETTES = {
51
+ "2": PALETTE_BW,
52
+ "bw": PALETTE_BW,
53
+ "mono": PALETTE_BW,
54
+ "3": PALETTE_BWR,
55
+ "bwr": PALETTE_BWR,
56
+ "bwy": PALETTE_BWY,
57
+ "4": PALETTE_4,
58
+ "7": PALETTE_7,
59
+ "acep": PALETTE_7,
60
+ }
61
+
62
+ DEFAULT_PALETTE = PALETTE_4
63
+
64
+
65
+ def _normalize_color(c):
66
+ """Resolve one palette entry to an RGBA tuple.
67
+
68
+ Accepts a color name ("red"), a HEX string ("#ff0000"), or an
69
+ ``(r, g, b)`` / ``(r, g, b, a)`` tuple/list.
70
+ """
71
+ if isinstance(c, (tuple, list)):
72
+ if len(c) == 3:
73
+ return (int(c[0]), int(c[1]), int(c[2]), 255)
74
+ if len(c) == 4:
75
+ return (int(c[0]), int(c[1]), int(c[2]), int(c[3]))
76
+ raise ValueError(f"Invalid palette color {c!r}: expected 3 or 4 components")
77
+ s = str(c).strip().lower()
78
+ if s in NAMED_COLORS:
79
+ return NAMED_COLORS[s]
80
+ if s.startswith("#"):
81
+ h = s.lstrip("#")
82
+ if len(h) >= 6:
83
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255)
84
+ raise ValueError(f"Unknown palette color {c!r} (not a known name or #RRGGBB)")
85
+
86
+
87
+ def get_palette(spec):
88
+ """Resolve a palette specification to a list of RGBA tuples.
89
+
90
+ ``spec`` may be:
91
+
92
+ * a shorthand name/count string — ``"2"``/``"bw"``, ``"4"``, ``"7"``/``"acep"``, ...
93
+ * a list of colors, each a name (``"red"``), HEX (``"#ff0000"``), or
94
+ ``(r, g, b[, a])`` tuple — e.g. ``["black", "white", "red"]``.
95
+ """
96
+ if isinstance(spec, str):
97
+ key = spec.strip().lower()
98
+ if key not in PALETTES:
99
+ raise ValueError(f"Unknown palette '{spec}'. Use a list of colors, or one of: {sorted(PALETTES)}")
100
+ return PALETTES[key]
101
+ colors = [_normalize_color(c) for c in spec]
102
+ if not colors:
103
+ raise ValueError("palette must contain at least one color")
104
+ return colors
105
+
106
+
107
+ def _requested_rgb(color):
108
+ """Resolve a color name or ``#RRGGBB`` to an RGBA tuple, or ``None``."""
109
+ if color is None:
110
+ return None
111
+ s = str(color).strip().lower()
112
+ if s in NAMED_COLORS:
113
+ return NAMED_COLORS[s]
114
+ if s.startswith("#"):
115
+ try:
116
+ h = s.lstrip("#")
117
+ if len(h) >= 6:
118
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255)
119
+ except ValueError:
120
+ pass
121
+ return white
122
+ return white
123
+
124
+
125
+ def nearest_in_palette(rgba, palette):
126
+ """Return the palette color closest to ``rgba`` (Euclidean in RGB)."""
127
+ best = palette[0]
128
+ best_dist = float("inf")
129
+ for c in palette:
130
+ dist = (rgba[0] - c[0]) ** 2 + (rgba[1] - c[1]) ** 2 + (rgba[2] - c[2]) ** 2
131
+ if dist < best_dist:
132
+ best_dist = dist
133
+ best = c
134
+ return best
135
+
136
+
137
+ def quantize_color(color, palette):
138
+ """Map a requested color to the nearest color the device supports.
139
+
140
+ Returns ``None`` for a ``None`` input (meaning "no fill").
141
+ """
142
+ rgb = _requested_rgb(color)
143
+ if rgb is None:
144
+ return None
145
+ snapped = nearest_in_palette(rgb, palette)
146
+ if _LOGGER.isEnabledFor(logging.DEBUG) and tuple(rgb) != tuple(snapped):
147
+ _LOGGER.debug("color %s -> %s (palette size %d)", color, snapped, len(palette))
148
+ return snapped
149
+
150
+
151
+ def get_index_color(color):
152
+ """Back-compat shim: quantize against the default 4-color palette.
153
+
154
+ Prefer ``RenderContext.color(...)`` inside handlers so the device's actual
155
+ palette is used.
156
+ """
157
+ return quantize_color(color, DEFAULT_PALETTE)
imagespec/context.py ADDED
@@ -0,0 +1,103 @@
1
+ """Render context: the seam where framework-specific behaviour is injected.
2
+
3
+ The core renderer must not import Home Assistant. Anything it needs from the
4
+ host application is provided through :class:`RenderContext`:
5
+
6
+ * ``font_resolver`` — given a font name (as written in a payload), return an
7
+ absolute path to a ``.ttf``/``.otf`` file, or ``None`` to fall back to the
8
+ fonts bundled with this package. This is where an integration plugs in its
9
+ ``hass.config.path("www/fonts")`` lookup.
10
+ * ``history_provider`` — given entity ids and a ``[start, end]`` window, return
11
+ historical states for the ``plot`` element. Only required if a payload uses
12
+ ``plot``; otherwise it can be left ``None``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from collections.abc import Callable, Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+ from PIL import ImageFont
23
+
24
+ from .colors import DEFAULT_PALETTE, get_palette, quantize_color
25
+ from .exceptions import RenderError
26
+
27
+ FontResolver = Callable[[str], str | None]
28
+ HistoryProvider = Callable[[Sequence[str], Any, Any], dict[str, Any]]
29
+
30
+ _PKG_DIR = os.path.dirname(__file__)
31
+ BUNDLED_FONTS_DIR = os.path.join(_PKG_DIR, "fonts")
32
+ BUNDLED_ICONS_DIR = os.path.join(_PKG_DIR, "icons")
33
+
34
+
35
+ def _bundled_font_path(name: str) -> str | None:
36
+ path = os.path.join(BUNDLED_FONTS_DIR, os.path.basename(name))
37
+ return path if os.path.exists(path) else None
38
+
39
+
40
+ @dataclass
41
+ class RenderContext:
42
+ """Host-supplied capabilities and defaults for a render call."""
43
+
44
+ font_resolver: FontResolver | None = None
45
+ history_provider: HistoryProvider | None = None
46
+ default_font: str = "NotoSansKR-Regular.ttf"
47
+ icons_dir: str = BUNDLED_ICONS_DIR
48
+ # Security: whether `dlimg` may open local/relative filesystem paths.
49
+ # Off by default — only http(s)/data URLs are allowed unless opted in.
50
+ allow_local_images: bool = False
51
+ # Device color palette. Accepts a list of RGBA tuples, or a name/count
52
+ # string ("2"/"bw", "4", "7"/"acep", ...). See colors.PALETTES.
53
+ palette: Any = field(default_factory=lambda: DEFAULT_PALETTE)
54
+ # Cache keyed by (resolved path, size) so repeated text elements are cheap.
55
+ _font_cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = field(default_factory=dict, repr=False)
56
+
57
+ def __post_init__(self):
58
+ # Allow palette to be given as a friendly name ("7", "bw", ...).
59
+ self.palette = get_palette(self.palette)
60
+
61
+ def color(self, value):
62
+ """Quantize a requested color to this device's palette (or ``None``)."""
63
+ return quantize_color(value, self.palette)
64
+
65
+ def resolve_font_path(self, name: str | None) -> str:
66
+ """Resolve a payload font name to an absolute file path.
67
+
68
+ Order: injected resolver → bundled font of the same basename →
69
+ bundled default font.
70
+ """
71
+ name = name or self.default_font
72
+ if self.font_resolver is not None:
73
+ p = self.font_resolver(name)
74
+ if p and os.path.exists(p):
75
+ return p
76
+ p = _bundled_font_path(name)
77
+ if p:
78
+ return p
79
+ p = _bundled_font_path(self.default_font)
80
+ if p:
81
+ return p
82
+ raise RenderError(
83
+ f"Font '{name}' could not be resolved and no bundled default ('{self.default_font}') is available."
84
+ )
85
+
86
+ def font(self, name: str | None, size) -> ImageFont.FreeTypeFont:
87
+ """Return a (cached) truetype font for ``name`` at ``size``."""
88
+ path = self.resolve_font_path(name)
89
+ key = (path, int(size))
90
+ cached = self._font_cache.get(key)
91
+ if cached is None:
92
+ cached = ImageFont.truetype(path, int(size))
93
+ self._font_cache[key] = cached
94
+ return cached
95
+
96
+ def history(self, entity_ids: Sequence[str], start, end) -> dict[str, Any]:
97
+ """Fetch historical states for the ``plot`` element."""
98
+ if self.history_provider is None:
99
+ raise RenderError(
100
+ "This payload uses 'plot', which needs historical data, but the "
101
+ "RenderContext has no history_provider configured."
102
+ )
103
+ return self.history_provider(entity_ids, start, end)
imagespec/core.py ADDED
@@ -0,0 +1,117 @@
1
+ """The render loop: turn a payload + size into a PIL image."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Sequence
7
+
8
+ from PIL import Image
9
+
10
+ # Importing the elements package registers all handlers via @element(...).
11
+ from . import elements # noqa: E402,F401 (side-effect import)
12
+ from .context import RenderContext
13
+ from .exceptions import RenderError
14
+ from .registry import get_handler
15
+ from .state import RenderState
16
+ from .utils import should_show
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+ # Rotation strategies — device-dependent (see docstring below).
21
+ ROTATE_MODE_CANVAS = "canvas" # gicisky / fixed-resolution e-ink panels
22
+ ROTATE_MODE_IMAGE = "image" # niimbot / variable-size label printers
23
+ _ROTATE_MODES = (ROTATE_MODE_CANVAS, ROTATE_MODE_IMAGE)
24
+
25
+
26
+ def render(
27
+ payload: Sequence[dict],
28
+ width: int,
29
+ height: int,
30
+ *,
31
+ rotate: int = 0,
32
+ rotate_mode: str = ROTATE_MODE_CANVAS,
33
+ background="white",
34
+ dither: bool = False,
35
+ context: RenderContext,
36
+ ) -> Image.Image:
37
+ """Render ``payload`` to an ``RGB`` :class:`PIL.Image.Image`.
38
+
39
+ Parameters
40
+ ----------
41
+ payload:
42
+ Sequence of element dicts (already parsed from YAML/JSON).
43
+ width, height:
44
+ Canvas dimensions you author against. ``RenderState.canvas_width`` /
45
+ ``canvas_height`` always reflect the actual surface being drawn on, so
46
+ element coordinates are written in this frame regardless of rotation.
47
+ rotate:
48
+ 0/90/180/270 (always rotated back at the end via ``-rotate``).
49
+ rotate_mode:
50
+ How 90/270 rotation is handled — this differs by device:
51
+
52
+ * ``"canvas"`` (default, gicisky): the **background/canvas rotates**. The
53
+ working canvas is pre-swapped to ``(height, width)``, drawn on, then
54
+ rotated back, so the **output stays exactly ``width × height``** — the
55
+ right behaviour for a fixed-resolution e-ink panel.
56
+ * ``"image"`` (niimbot): the **drawing rotates**. The canvas is created
57
+ at ``width × height``, drawn on, then the whole image is rotated, so
58
+ the **output dimensions swap** — fine for a variable-size label printer.
59
+
60
+ For ``rotate`` of 0/180 the two modes are identical.
61
+ background:
62
+ Background color name or ``#RRGGBB`` (mapped via :func:`get_index_color`).
63
+ dither:
64
+ If True, Floyd–Steinberg dither the final image to ``context.palette``
65
+ (better for photos/logos on limited-color panels). Per-image dithering is
66
+ also available on the ``dlimg`` element.
67
+ context:
68
+ Host-supplied :class:`RenderContext` (fonts, history, ...).
69
+ """
70
+ rotate = int(rotate or 0)
71
+ if rotate not in (0, 90, 180, 270):
72
+ raise ValueError(f"rotate must be 0/90/180/270, got {rotate}")
73
+ if rotate_mode not in _ROTATE_MODES:
74
+ raise ValueError(f"rotate_mode must be one of {_ROTATE_MODES}, got {rotate_mode!r}")
75
+ if width <= 0 or height <= 0:
76
+ raise ValueError(f"width and height must be positive, got {width}x{height}")
77
+ bg = context.color(background)
78
+
79
+ if rotate in (90, 270) and rotate_mode == ROTATE_MODE_CANVAS:
80
+ img = Image.new("RGBA", (height, width), color=bg)
81
+ else:
82
+ img = Image.new("RGBA", (width, height), color=bg)
83
+
84
+ state = RenderState(
85
+ img=img,
86
+ canvas_width=img.width,
87
+ canvas_height=img.height,
88
+ context=context,
89
+ )
90
+
91
+ for idx, element in enumerate(payload or []):
92
+ if not isinstance(element, dict):
93
+ raise RenderError(f"each payload element must be a dict, got {type(element).__name__}")
94
+ etype = element.get("type", "")
95
+ _LOGGER.debug("type: %s", etype)
96
+ if not should_show(element):
97
+ continue
98
+ handler = get_handler(etype)
99
+ if handler is None:
100
+ _LOGGER.warning("Unknown element type '%s' — skipping.", etype)
101
+ continue
102
+ try:
103
+ handler(state, element)
104
+ except RenderError:
105
+ raise # already descriptive (names the element type / missing arg)
106
+ except Exception as exc: # noqa: BLE001 — add element context, then surface
107
+ raise RenderError(f"error rendering element #{idx} (type '{etype}'): {exc}") from exc
108
+
109
+ img = state.img
110
+ if rotate in (90, 180, 270):
111
+ img = img.rotate(-rotate, expand=True)
112
+ result = img.convert("RGB")
113
+ if dither:
114
+ from .dither import dither_to_palette
115
+
116
+ result = dither_to_palette(result, context.palette)
117
+ return result
imagespec/dither.py ADDED
@@ -0,0 +1,33 @@
1
+ """Floyd–Steinberg dithering to a device palette.
2
+
3
+ Snapping each pixel to its nearest palette color (what `quantize_color` does for
4
+ named element colors) looks bad for photos/logos on a 2–7 color panel. Dithering
5
+ trades spatial resolution for perceived color depth and looks dramatically better.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from PIL import Image
11
+
12
+
13
+ def _palette_image(palette) -> Image.Image:
14
+ """Build a 'P'-mode image whose palette is ``palette`` (list of RGBA)."""
15
+ rgbs = [tuple(c[:3]) for c in palette]
16
+ flat: list[int] = []
17
+ for c in rgbs:
18
+ flat.extend(c)
19
+ # Pad to 256 entries by repeating the first color (never introduce a new one).
20
+ pad = list(rgbs[0])
21
+ while len(flat) < 768:
22
+ flat.extend(pad)
23
+ pal_img = Image.new("P", (1, 1))
24
+ pal_img.putpalette(flat)
25
+ return pal_img
26
+
27
+
28
+ def dither_to_palette(img: Image.Image, palette, *, dither: bool = True) -> Image.Image:
29
+ """Return ``img`` quantized to ``palette`` (RGB), optionally dithered."""
30
+ rgb = img.convert("RGB")
31
+ pal_img = _palette_image(palette)
32
+ mode = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
33
+ return rgb.quantize(palette=pal_img, dither=mode).convert("RGB")
@@ -0,0 +1,18 @@
1
+ """Element handlers.
2
+
3
+ Importing this package imports each handler module for its registration
4
+ side-effects (the ``@element(...)`` decorators populate the registry).
5
+
6
+ When porting a new element category, add its module to the import list below.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from . import (
12
+ charts, # noqa: F401
13
+ codes, # noqa: F401
14
+ layout, # noqa: F401
15
+ media, # noqa: F401
16
+ shapes, # noqa: F401
17
+ text, # noqa: F401
18
+ )