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 +57 -0
- imagespec/colors.py +157 -0
- imagespec/context.py +103 -0
- imagespec/core.py +117 -0
- imagespec/dither.py +33 -0
- imagespec/elements/__init__.py +18 -0
- imagespec/elements/charts.py +361 -0
- imagespec/elements/codes.py +114 -0
- imagespec/elements/layout.py +49 -0
- imagespec/elements/media.py +192 -0
- imagespec/elements/shapes.py +219 -0
- imagespec/elements/text.py +457 -0
- imagespec/exceptions.py +13 -0
- imagespec/fonts/NotoSansKR-Regular.ttf +0 -0
- imagespec/fonts/README.md +20 -0
- imagespec/fonts/ppb.ttf +0 -0
- imagespec/icons/README.md +13 -0
- imagespec/icons/materialdesignicons-webfont.ttf +0 -0
- imagespec/icons/materialdesignicons-webfont_meta.json +118127 -0
- imagespec/registry.py +37 -0
- imagespec/resolvers.py +91 -0
- imagespec/state.py +32 -0
- imagespec/utils.py +59 -0
- imagespec-0.1.0.dist-info/METADATA +214 -0
- imagespec-0.1.0.dist-info/RECORD +28 -0
- imagespec-0.1.0.dist-info/WHEEL +5 -0
- imagespec-0.1.0.dist-info/licenses/LICENSE +21 -0
- imagespec-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|