imagespec 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- imagespec-0.1.0/MANIFEST.in +6 -0
- imagespec-0.1.0/PKG-INFO +214 -0
- imagespec-0.1.0/README.md +181 -0
- imagespec-0.1.0/docs/migration.md +158 -0
- imagespec-0.1.0/examples/smoke_test.py +43 -0
- imagespec-0.1.0/pyproject.toml +65 -0
- imagespec-0.1.0/setup.cfg +4 -0
- imagespec-0.1.0/src/imagespec/__init__.py +57 -0
- imagespec-0.1.0/src/imagespec/colors.py +157 -0
- imagespec-0.1.0/src/imagespec/context.py +103 -0
- imagespec-0.1.0/src/imagespec/core.py +117 -0
- imagespec-0.1.0/src/imagespec/dither.py +33 -0
- imagespec-0.1.0/src/imagespec/elements/__init__.py +18 -0
- imagespec-0.1.0/src/imagespec/elements/charts.py +361 -0
- imagespec-0.1.0/src/imagespec/elements/codes.py +114 -0
- imagespec-0.1.0/src/imagespec/elements/layout.py +49 -0
- imagespec-0.1.0/src/imagespec/elements/media.py +192 -0
- imagespec-0.1.0/src/imagespec/elements/shapes.py +219 -0
- imagespec-0.1.0/src/imagespec/elements/text.py +457 -0
- imagespec-0.1.0/src/imagespec/exceptions.py +13 -0
- imagespec-0.1.0/src/imagespec/fonts/NotoSansKR-Regular.ttf +0 -0
- imagespec-0.1.0/src/imagespec/fonts/README.md +20 -0
- imagespec-0.1.0/src/imagespec/fonts/ppb.ttf +0 -0
- imagespec-0.1.0/src/imagespec/icons/README.md +13 -0
- imagespec-0.1.0/src/imagespec/icons/materialdesignicons-webfont.ttf +0 -0
- imagespec-0.1.0/src/imagespec/icons/materialdesignicons-webfont_meta.json +118127 -0
- imagespec-0.1.0/src/imagespec/registry.py +37 -0
- imagespec-0.1.0/src/imagespec/resolvers.py +91 -0
- imagespec-0.1.0/src/imagespec/state.py +32 -0
- imagespec-0.1.0/src/imagespec/utils.py +59 -0
- imagespec-0.1.0/src/imagespec.egg-info/PKG-INFO +214 -0
- imagespec-0.1.0/src/imagespec.egg-info/SOURCES.txt +44 -0
- imagespec-0.1.0/src/imagespec.egg-info/dependency_links.txt +1 -0
- imagespec-0.1.0/src/imagespec.egg-info/requires.txt +15 -0
- imagespec-0.1.0/src/imagespec.egg-info/top_level.txt +1 -0
- imagespec-0.1.0/tests/conftest.py +56 -0
- imagespec-0.1.0/tests/test_colors.py +74 -0
- imagespec-0.1.0/tests/test_core.py +64 -0
- imagespec-0.1.0/tests/test_dither.py +29 -0
- imagespec-0.1.0/tests/test_elements.py +143 -0
- imagespec-0.1.0/tests/test_errors.py +82 -0
- imagespec-0.1.0/tests/test_features.py +97 -0
- imagespec-0.1.0/tests/test_plot.py +36 -0
- imagespec-0.1.0/tests/test_resolvers.py +44 -0
- imagespec-0.1.0/tests/test_text_fit.py +99 -0
imagespec-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eigger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
imagespec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imagespec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render images from a declarative YAML/dict spec — shapes, text, charts, QR/barcodes — for e-paper ESL tags and label printers.
|
|
5
|
+
Author: eigger
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eigger/imagespec
|
|
8
|
+
Project-URL: Repository, https://github.com/eigger/imagespec
|
|
9
|
+
Keywords: image,render,yaml,spec,e-paper,esl,label,pillow
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pillow>=10.0
|
|
21
|
+
Requires-Dist: qrcode[pil]>=7.4
|
|
22
|
+
Requires-Dist: python-barcode>=0.15
|
|
23
|
+
Requires-Dist: requests>=2.25
|
|
24
|
+
Provides-Extra: datamatrix
|
|
25
|
+
Requires-Dist: pyStrich>=0.8; extra == "datamatrix"
|
|
26
|
+
Provides-Extra: yaml
|
|
27
|
+
Requires-Dist: PyYAML>=6.0; extra == "yaml"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
31
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# imagespec
|
|
35
|
+
|
|
36
|
+
Render images from a declarative **YAML/dict spec** — shapes, text, charts,
|
|
37
|
+
QR/barcodes — for e-paper ESL tags and label printers.
|
|
38
|
+
|
|
39
|
+
This is the shared rendering core extracted from
|
|
40
|
+
[`hass-gicisky`](https://github.com/eigger/hass-gicisky) and
|
|
41
|
+
[`hass-niimbot`](https://github.com/eigger/hass-niimbot). Both integrations had
|
|
42
|
+
near-identical renderers that had drifted apart; `imagespec` unifies them and
|
|
43
|
+
removes the Home Assistant dependency so the engine can be reused and tested
|
|
44
|
+
standalone.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
✅ **26 elements** (21 ported + 5 new) rendering, with a 74-test suite.
|
|
49
|
+
Architecture (HA-decoupled context, registry dispatch, device-specific rotation
|
|
50
|
+
+ palette) is in place. Remaining work is packaging polish and switching the two
|
|
51
|
+
components over to it.
|
|
52
|
+
|
|
53
|
+
## Design
|
|
54
|
+
|
|
55
|
+
- **No framework dependency.** The core never imports Home Assistant. Anything
|
|
56
|
+
host-specific is injected through `RenderContext`:
|
|
57
|
+
- `font_resolver(name) -> path | None` — e.g. an integration's
|
|
58
|
+
`hass.config.path("www/fonts")` lookup.
|
|
59
|
+
- `history_provider(entity_ids, start, end) -> states` — for the `plot`
|
|
60
|
+
element (HA recorder). Optional.
|
|
61
|
+
- `palette` — the device's supported colors (see below).
|
|
62
|
+
- **Registry dispatch.** Each element `type` is a handler registered with
|
|
63
|
+
`@element("type")` in `imagespec/elements/`, replacing the original giant
|
|
64
|
+
`if/elif` chain. Adding an element = adding a function.
|
|
65
|
+
- **`RenderState`.** Threaded through handlers; holds the (reassignable) `img`
|
|
66
|
+
and the `pos_y` flow cursor.
|
|
67
|
+
- **Device-dependent palette** (`RenderContext.palette`), *not* unified — panels
|
|
68
|
+
support different colors. Define it as a **list of the colors the device
|
|
69
|
+
supports** (names, HEX, or RGBA tuples):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
RenderContext(palette=["black", "white", "red"]) # names
|
|
73
|
+
RenderContext(palette=["#000000", "#ffffff", "#ff0000"]) # HEX
|
|
74
|
+
RenderContext(palette=[(0, 0, 0), (255, 255, 255)]) # RGBA tuples
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Shorthand names are optional convenience for common panels: `"2"`/`"bw"`,
|
|
78
|
+
`"3"`/`"bwr"`, `"4"`, `"7"`/`"acep"`. Any requested color in a payload is then
|
|
79
|
+
quantized to the nearest color in this list — on a 2-color device `red`
|
|
80
|
+
becomes black; on 4-color a blue `#1e90ff` becomes white; on 7-color it stays
|
|
81
|
+
blue.
|
|
82
|
+
- **Merged behaviour.** Where the two sources differed, the superset wins:
|
|
83
|
+
- qrcode gains `eclevel` (niimbot)
|
|
84
|
+
- **Device-dependent rotation** (`rotate_mode`), *not* unified — both behaviours
|
|
85
|
+
are kept because they are physically different:
|
|
86
|
+
- `"canvas"` (gicisky): background/canvas rotates; output stays `width×height`
|
|
87
|
+
(fixed-resolution e-ink panel).
|
|
88
|
+
- `"image"` (niimbot): drawing rotates; output dimensions swap (variable-size
|
|
89
|
+
label printer).
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from imagespec import render, RenderContext
|
|
95
|
+
|
|
96
|
+
ctx = RenderContext(
|
|
97
|
+
font_resolver=my_font_lookup, # optional
|
|
98
|
+
history_provider=my_history_lookup, # optional, only for `plot`
|
|
99
|
+
)
|
|
100
|
+
image = render(payload, width=296, height=128, rotate=0,
|
|
101
|
+
background="white", context=ctx) # -> PIL.Image (RGB)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Run the smoke test (no fonts required):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e .
|
|
108
|
+
python examples/smoke_test.py
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Development & testing
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[dev,datamatrix]"
|
|
115
|
+
pytest # 74 tests: every element, palettes, rotation, dither, errors
|
|
116
|
+
ruff check . && ruff format --check . # lint + format
|
|
117
|
+
python -m build # build sdist + wheel (bundles fonts/icons)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
CI runs on every push/PR (`.github/workflows/ci.yml`): ruff lint+format, the test
|
|
121
|
+
suite on Python 3.11/3.12/3.13, and a build that asserts the bundled fonts/icons
|
|
122
|
+
are present in the wheel. Pushing a `v*` tag triggers
|
|
123
|
+
`.github/workflows/release.yml` to build and publish to PyPI (trusted publishing).
|
|
124
|
+
|
|
125
|
+
The test matrix (`tests/test_elements.py`) asserts it covers *every* registered
|
|
126
|
+
element type, so adding a new `@element(...)` without a sample fails the suite —
|
|
127
|
+
keeping coverage exhaustive by construction.
|
|
128
|
+
|
|
129
|
+
**Robustness built in:**
|
|
130
|
+
|
|
131
|
+
- Each handler error is wrapped with element context — you get
|
|
132
|
+
`error rendering element #3 (type 'text'): ...`, not a raw PIL traceback.
|
|
133
|
+
- `render()` validates `rotate`/`rotate_mode`/size and rejects non-dict elements;
|
|
134
|
+
unknown element types are warned-and-skipped.
|
|
135
|
+
- `dlimg` only allows `http(s)`/`data:` URLs by default; local paths require
|
|
136
|
+
`RenderContext(allow_local_images=True)`. Network failures become `RenderError`.
|
|
137
|
+
- Clear errors for missing required args, invalid barcode symbology, malformed
|
|
138
|
+
`polygon` points, and a `diagram` too small for its bars.
|
|
139
|
+
|
|
140
|
+
## Elements
|
|
141
|
+
|
|
142
|
+
All ported from the original renderers (superset behaviour where they differed):
|
|
143
|
+
|
|
144
|
+
| Element | Module | Notes |
|
|
145
|
+
|---------------------|-----------|-------|
|
|
146
|
+
| `line` | shapes | + dashed lines |
|
|
147
|
+
| `rectangle` | shapes | |
|
|
148
|
+
| `rectangle_pattern` | shapes | |
|
|
149
|
+
| `circle` | shapes | |
|
|
150
|
+
| `ellipse` | shapes | |
|
|
151
|
+
| `arc` | shapes | |
|
|
152
|
+
| `polygon` | shapes | |
|
|
153
|
+
| `gauge` | shapes | |
|
|
154
|
+
| `text` | text | + rotation, background box |
|
|
155
|
+
| `text_box` | text | |
|
|
156
|
+
| `multiline` | text | |
|
|
157
|
+
| `new_multiline` | text | fit-to-width/height autosize (niimbot) |
|
|
158
|
+
| `text_fit` | text | fit text into a fixed box: shrink font / ellipsis / wrap |
|
|
159
|
+
| `table` | text | |
|
|
160
|
+
| `qrcode` | codes | + `eclevel` (niimbot) |
|
|
161
|
+
| `barcode` | codes | |
|
|
162
|
+
| `datamatrix` | codes | optional dep `pyStrich` (`imagespec[datamatrix]`) |
|
|
163
|
+
| `icon` | media | Material Design Icons; needs bundled `icons/` assets |
|
|
164
|
+
| `dlimg` | media | + fit modes (stretch/fit/fill/contain) |
|
|
165
|
+
| `diagram` | charts | bar chart |
|
|
166
|
+
| `plot` | charts | needs `history_provider`; + area_fill, xlegend |
|
|
167
|
+
| `progress_bar` | charts | + rounded corners |
|
|
168
|
+
| `pie` | charts | **new** — pie / donut (`inner_radius`) |
|
|
169
|
+
| `sparkline` | charts | **new** — compact axis-less line from inline values |
|
|
170
|
+
| `rich_text` | text | **new** — inline spans: icon + text + color on one line |
|
|
171
|
+
| `group` | layout | **new** — container: child elements at an offset, clipped, optionally rotated |
|
|
172
|
+
|
|
173
|
+
Plus enhancements: `dlimg` gained `dither` (Floyd–Steinberg to palette) and
|
|
174
|
+
`circle`/`mask` (circular crop); `render(..., dither=True)` dithers the whole
|
|
175
|
+
output; `text_fit` fits text into a fixed box (shrink / ellipsis / wrap).
|
|
176
|
+
|
|
177
|
+
## Fonts & assets
|
|
178
|
+
|
|
179
|
+
Bundled in the package (offline baseline):
|
|
180
|
+
|
|
181
|
+
- `icons/materialdesignicons-webfont.ttf` + `_meta.json` — required by `icon`.
|
|
182
|
+
- `fonts/NotoSansKR-Regular.ttf` (default) and `fonts/ppb.ttf` (niimbot default).
|
|
183
|
+
|
|
184
|
+
Anything else is resolved at runtime, in order: `font_resolver` (host) →
|
|
185
|
+
bundled font of the same basename → bundled default. Helpers in
|
|
186
|
+
`imagespec.resolvers`:
|
|
187
|
+
|
|
188
|
+
- `directory_resolver(dir)` — look up fonts in a host directory (e.g. `www/fonts`).
|
|
189
|
+
- `caching_resolver(cache_dir, sources)` — **download on first use, cache to
|
|
190
|
+
disk, reuse offline** (internet needed only once per font).
|
|
191
|
+
- `chain_resolvers(a, b, ...)` — try several in order.
|
|
192
|
+
|
|
193
|
+
This is why the core bundles only the essentials (~11 MB) and **not** gicisky's
|
|
194
|
+
full 74 MB font set — decorative fonts are better downloaded-and-cached or served
|
|
195
|
+
from `www/fonts`.
|
|
196
|
+
|
|
197
|
+
## Open decisions
|
|
198
|
+
|
|
199
|
+
- **Default font.** `NotoSansKR-Regular.ttf` (gicisky) vs `ppb.ttf` (niimbot).
|
|
200
|
+
Default is Noto; bundle both so existing payloads render unchanged.
|
|
201
|
+
|
|
202
|
+
> Resolved: rotation is now a per-device `rotate_mode` (`"canvas"` for gicisky,
|
|
203
|
+
> `"image"` for niimbot), and `RenderState.canvas_width/height` always reflect
|
|
204
|
+
> the actual drawing surface — so `plot`/`diagram` default extents are
|
|
205
|
+
> consistent in both modes.
|
|
206
|
+
|
|
207
|
+
## Integrating back into the components
|
|
208
|
+
|
|
209
|
+
Replace each component's renderer with a thin adapter (see
|
|
210
|
+
[`docs/migration.md`](docs/migration.md)) and add to `manifest.json`:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
"requirements": ["imagespec==0.1.0"]
|
|
214
|
+
```
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# imagespec
|
|
2
|
+
|
|
3
|
+
Render images from a declarative **YAML/dict spec** — shapes, text, charts,
|
|
4
|
+
QR/barcodes — for e-paper ESL tags and label printers.
|
|
5
|
+
|
|
6
|
+
This is the shared rendering core extracted from
|
|
7
|
+
[`hass-gicisky`](https://github.com/eigger/hass-gicisky) and
|
|
8
|
+
[`hass-niimbot`](https://github.com/eigger/hass-niimbot). Both integrations had
|
|
9
|
+
near-identical renderers that had drifted apart; `imagespec` unifies them and
|
|
10
|
+
removes the Home Assistant dependency so the engine can be reused and tested
|
|
11
|
+
standalone.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
✅ **26 elements** (21 ported + 5 new) rendering, with a 74-test suite.
|
|
16
|
+
Architecture (HA-decoupled context, registry dispatch, device-specific rotation
|
|
17
|
+
+ palette) is in place. Remaining work is packaging polish and switching the two
|
|
18
|
+
components over to it.
|
|
19
|
+
|
|
20
|
+
## Design
|
|
21
|
+
|
|
22
|
+
- **No framework dependency.** The core never imports Home Assistant. Anything
|
|
23
|
+
host-specific is injected through `RenderContext`:
|
|
24
|
+
- `font_resolver(name) -> path | None` — e.g. an integration's
|
|
25
|
+
`hass.config.path("www/fonts")` lookup.
|
|
26
|
+
- `history_provider(entity_ids, start, end) -> states` — for the `plot`
|
|
27
|
+
element (HA recorder). Optional.
|
|
28
|
+
- `palette` — the device's supported colors (see below).
|
|
29
|
+
- **Registry dispatch.** Each element `type` is a handler registered with
|
|
30
|
+
`@element("type")` in `imagespec/elements/`, replacing the original giant
|
|
31
|
+
`if/elif` chain. Adding an element = adding a function.
|
|
32
|
+
- **`RenderState`.** Threaded through handlers; holds the (reassignable) `img`
|
|
33
|
+
and the `pos_y` flow cursor.
|
|
34
|
+
- **Device-dependent palette** (`RenderContext.palette`), *not* unified — panels
|
|
35
|
+
support different colors. Define it as a **list of the colors the device
|
|
36
|
+
supports** (names, HEX, or RGBA tuples):
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
RenderContext(palette=["black", "white", "red"]) # names
|
|
40
|
+
RenderContext(palette=["#000000", "#ffffff", "#ff0000"]) # HEX
|
|
41
|
+
RenderContext(palette=[(0, 0, 0), (255, 255, 255)]) # RGBA tuples
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Shorthand names are optional convenience for common panels: `"2"`/`"bw"`,
|
|
45
|
+
`"3"`/`"bwr"`, `"4"`, `"7"`/`"acep"`. Any requested color in a payload is then
|
|
46
|
+
quantized to the nearest color in this list — on a 2-color device `red`
|
|
47
|
+
becomes black; on 4-color a blue `#1e90ff` becomes white; on 7-color it stays
|
|
48
|
+
blue.
|
|
49
|
+
- **Merged behaviour.** Where the two sources differed, the superset wins:
|
|
50
|
+
- qrcode gains `eclevel` (niimbot)
|
|
51
|
+
- **Device-dependent rotation** (`rotate_mode`), *not* unified — both behaviours
|
|
52
|
+
are kept because they are physically different:
|
|
53
|
+
- `"canvas"` (gicisky): background/canvas rotates; output stays `width×height`
|
|
54
|
+
(fixed-resolution e-ink panel).
|
|
55
|
+
- `"image"` (niimbot): drawing rotates; output dimensions swap (variable-size
|
|
56
|
+
label printer).
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from imagespec import render, RenderContext
|
|
62
|
+
|
|
63
|
+
ctx = RenderContext(
|
|
64
|
+
font_resolver=my_font_lookup, # optional
|
|
65
|
+
history_provider=my_history_lookup, # optional, only for `plot`
|
|
66
|
+
)
|
|
67
|
+
image = render(payload, width=296, height=128, rotate=0,
|
|
68
|
+
background="white", context=ctx) # -> PIL.Image (RGB)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Run the smoke test (no fonts required):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install -e .
|
|
75
|
+
python examples/smoke_test.py
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Development & testing
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e ".[dev,datamatrix]"
|
|
82
|
+
pytest # 74 tests: every element, palettes, rotation, dither, errors
|
|
83
|
+
ruff check . && ruff format --check . # lint + format
|
|
84
|
+
python -m build # build sdist + wheel (bundles fonts/icons)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
CI runs on every push/PR (`.github/workflows/ci.yml`): ruff lint+format, the test
|
|
88
|
+
suite on Python 3.11/3.12/3.13, and a build that asserts the bundled fonts/icons
|
|
89
|
+
are present in the wheel. Pushing a `v*` tag triggers
|
|
90
|
+
`.github/workflows/release.yml` to build and publish to PyPI (trusted publishing).
|
|
91
|
+
|
|
92
|
+
The test matrix (`tests/test_elements.py`) asserts it covers *every* registered
|
|
93
|
+
element type, so adding a new `@element(...)` without a sample fails the suite —
|
|
94
|
+
keeping coverage exhaustive by construction.
|
|
95
|
+
|
|
96
|
+
**Robustness built in:**
|
|
97
|
+
|
|
98
|
+
- Each handler error is wrapped with element context — you get
|
|
99
|
+
`error rendering element #3 (type 'text'): ...`, not a raw PIL traceback.
|
|
100
|
+
- `render()` validates `rotate`/`rotate_mode`/size and rejects non-dict elements;
|
|
101
|
+
unknown element types are warned-and-skipped.
|
|
102
|
+
- `dlimg` only allows `http(s)`/`data:` URLs by default; local paths require
|
|
103
|
+
`RenderContext(allow_local_images=True)`. Network failures become `RenderError`.
|
|
104
|
+
- Clear errors for missing required args, invalid barcode symbology, malformed
|
|
105
|
+
`polygon` points, and a `diagram` too small for its bars.
|
|
106
|
+
|
|
107
|
+
## Elements
|
|
108
|
+
|
|
109
|
+
All ported from the original renderers (superset behaviour where they differed):
|
|
110
|
+
|
|
111
|
+
| Element | Module | Notes |
|
|
112
|
+
|---------------------|-----------|-------|
|
|
113
|
+
| `line` | shapes | + dashed lines |
|
|
114
|
+
| `rectangle` | shapes | |
|
|
115
|
+
| `rectangle_pattern` | shapes | |
|
|
116
|
+
| `circle` | shapes | |
|
|
117
|
+
| `ellipse` | shapes | |
|
|
118
|
+
| `arc` | shapes | |
|
|
119
|
+
| `polygon` | shapes | |
|
|
120
|
+
| `gauge` | shapes | |
|
|
121
|
+
| `text` | text | + rotation, background box |
|
|
122
|
+
| `text_box` | text | |
|
|
123
|
+
| `multiline` | text | |
|
|
124
|
+
| `new_multiline` | text | fit-to-width/height autosize (niimbot) |
|
|
125
|
+
| `text_fit` | text | fit text into a fixed box: shrink font / ellipsis / wrap |
|
|
126
|
+
| `table` | text | |
|
|
127
|
+
| `qrcode` | codes | + `eclevel` (niimbot) |
|
|
128
|
+
| `barcode` | codes | |
|
|
129
|
+
| `datamatrix` | codes | optional dep `pyStrich` (`imagespec[datamatrix]`) |
|
|
130
|
+
| `icon` | media | Material Design Icons; needs bundled `icons/` assets |
|
|
131
|
+
| `dlimg` | media | + fit modes (stretch/fit/fill/contain) |
|
|
132
|
+
| `diagram` | charts | bar chart |
|
|
133
|
+
| `plot` | charts | needs `history_provider`; + area_fill, xlegend |
|
|
134
|
+
| `progress_bar` | charts | + rounded corners |
|
|
135
|
+
| `pie` | charts | **new** — pie / donut (`inner_radius`) |
|
|
136
|
+
| `sparkline` | charts | **new** — compact axis-less line from inline values |
|
|
137
|
+
| `rich_text` | text | **new** — inline spans: icon + text + color on one line |
|
|
138
|
+
| `group` | layout | **new** — container: child elements at an offset, clipped, optionally rotated |
|
|
139
|
+
|
|
140
|
+
Plus enhancements: `dlimg` gained `dither` (Floyd–Steinberg to palette) and
|
|
141
|
+
`circle`/`mask` (circular crop); `render(..., dither=True)` dithers the whole
|
|
142
|
+
output; `text_fit` fits text into a fixed box (shrink / ellipsis / wrap).
|
|
143
|
+
|
|
144
|
+
## Fonts & assets
|
|
145
|
+
|
|
146
|
+
Bundled in the package (offline baseline):
|
|
147
|
+
|
|
148
|
+
- `icons/materialdesignicons-webfont.ttf` + `_meta.json` — required by `icon`.
|
|
149
|
+
- `fonts/NotoSansKR-Regular.ttf` (default) and `fonts/ppb.ttf` (niimbot default).
|
|
150
|
+
|
|
151
|
+
Anything else is resolved at runtime, in order: `font_resolver` (host) →
|
|
152
|
+
bundled font of the same basename → bundled default. Helpers in
|
|
153
|
+
`imagespec.resolvers`:
|
|
154
|
+
|
|
155
|
+
- `directory_resolver(dir)` — look up fonts in a host directory (e.g. `www/fonts`).
|
|
156
|
+
- `caching_resolver(cache_dir, sources)` — **download on first use, cache to
|
|
157
|
+
disk, reuse offline** (internet needed only once per font).
|
|
158
|
+
- `chain_resolvers(a, b, ...)` — try several in order.
|
|
159
|
+
|
|
160
|
+
This is why the core bundles only the essentials (~11 MB) and **not** gicisky's
|
|
161
|
+
full 74 MB font set — decorative fonts are better downloaded-and-cached or served
|
|
162
|
+
from `www/fonts`.
|
|
163
|
+
|
|
164
|
+
## Open decisions
|
|
165
|
+
|
|
166
|
+
- **Default font.** `NotoSansKR-Regular.ttf` (gicisky) vs `ppb.ttf` (niimbot).
|
|
167
|
+
Default is Noto; bundle both so existing payloads render unchanged.
|
|
168
|
+
|
|
169
|
+
> Resolved: rotation is now a per-device `rotate_mode` (`"canvas"` for gicisky,
|
|
170
|
+
> `"image"` for niimbot), and `RenderState.canvas_width/height` always reflect
|
|
171
|
+
> the actual drawing surface — so `plot`/`diagram` default extents are
|
|
172
|
+
> consistent in both modes.
|
|
173
|
+
|
|
174
|
+
## Integrating back into the components
|
|
175
|
+
|
|
176
|
+
Replace each component's renderer with a thin adapter (see
|
|
177
|
+
[`docs/migration.md`](docs/migration.md)) and add to `manifest.json`:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
"requirements": ["imagespec==0.1.0"]
|
|
181
|
+
```
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Migrating the components onto imagespec
|
|
2
|
+
|
|
3
|
+
This describes how `hass-gicisky` and `hass-niimbot` will later drop their own
|
|
4
|
+
renderers (`renderer.py` / `imagegen.py`) and call `imagespec` instead. **Not
|
|
5
|
+
done yet** — this is the plan to follow when the swap happens.
|
|
6
|
+
|
|
7
|
+
The strategy: keep a **thin adapter** with the same public function name each
|
|
8
|
+
component already exposes, so nothing else in the component changes. The
|
|
9
|
+
adapter's only jobs are to build a `RenderContext` (font lookup + history +
|
|
10
|
+
palette) and translate `imagespec.RenderError` into `HomeAssistantError`.
|
|
11
|
+
|
|
12
|
+
All 26 element types are implemented, so no payloads will regress; unknown
|
|
13
|
+
element types are warned-and-skipped rather than raising.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. Declare the dependency in `manifest.json`
|
|
18
|
+
|
|
19
|
+
`imagespec` ships from GitHub. Use a PEP 508 direct reference pinned to a tag:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
"requirements": [
|
|
23
|
+
"imagespec[datamatrix] @ git+https://github.com/eigger/imagespec.git@v0.1.0"
|
|
24
|
+
]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- The `[datamatrix]` extra pulls in `pyStrich` (only needed for the `datamatrix`
|
|
28
|
+
element); drop it if a component never uses datamatrix.
|
|
29
|
+
- Pin to a **tag** (`@v0.1.0`), not a branch, so installs are reproducible.
|
|
30
|
+
- If/when published to PyPI, this simplifies to `"imagespec[datamatrix]==0.1.0"`.
|
|
31
|
+
|
|
32
|
+
**Remove the now-duplicated deps.** `qrcode[pil]`, `python-barcode` and `pyStrich`
|
|
33
|
+
become transitive deps of `imagespec`, so delete them from each component's
|
|
34
|
+
`manifest.json`. (Leaving them in causes no conflict — the existing pins
|
|
35
|
+
`qrcode[pil]==7.4.2`, `python-barcode==0.15.1`, `pyStrich==0.10` all satisfy
|
|
36
|
+
imagespec's ranges — but removing them is the point of consolidating.)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 2. A shared adapter helper
|
|
41
|
+
|
|
42
|
+
Both adapters build a `RenderContext` the same way; only `default_font` and
|
|
43
|
+
`palette` differ. Factor it once:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
from homeassistant.exceptions import HomeAssistantError
|
|
49
|
+
from homeassistant.components.recorder.history import get_significant_states
|
|
50
|
+
|
|
51
|
+
from imagespec import RenderContext, RenderError, render
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _make_context(hass, *, default_font, palette):
|
|
55
|
+
def font_resolver(name):
|
|
56
|
+
# check www/fonts; return None to fall back to imagespec's bundled fonts
|
|
57
|
+
cand = os.path.join(hass.config.path("www/fonts"), os.path.basename(name))
|
|
58
|
+
return cand if os.path.exists(cand) else None
|
|
59
|
+
|
|
60
|
+
def history_provider(entity_ids, start, end):
|
|
61
|
+
return get_significant_states(
|
|
62
|
+
hass, start_time=start, entity_ids=list(entity_ids),
|
|
63
|
+
significant_changes_only=False, minimal_response=True, no_attributes=False,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return RenderContext(
|
|
67
|
+
font_resolver=font_resolver,
|
|
68
|
+
history_provider=history_provider,
|
|
69
|
+
default_font=default_font,
|
|
70
|
+
palette=palette, # list of supported colors (names/hex/rgba) or "2"/"4"/"7" shorthand
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 3. gicisky — `renderer.py`
|
|
77
|
+
|
|
78
|
+
`render_image(entity_id, device, service, hass)` derives the canvas size from
|
|
79
|
+
`device` and uses the **`canvas`** rotation mode (fixed-resolution ESL panel):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
def render_image(entity_id, device, service, hass):
|
|
83
|
+
try:
|
|
84
|
+
return render(
|
|
85
|
+
payload=service.data.get("payload", ""),
|
|
86
|
+
width=device.width,
|
|
87
|
+
height=device.height,
|
|
88
|
+
rotate=int(service.data.get("rotate", 0)),
|
|
89
|
+
rotate_mode="canvas", # ESL panel: fixed resolution, background rotates
|
|
90
|
+
background=service.data.get("background", "white"),
|
|
91
|
+
context=_make_context(hass, default_font="NotoSansKR-Regular.ttf",
|
|
92
|
+
palette=device.palette), # "2"/"4"/"7" per model
|
|
93
|
+
)
|
|
94
|
+
except RenderError as err:
|
|
95
|
+
raise HomeAssistantError(str(err)) from err
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Also narrow the `from .renderer import *` in `__init__.py` to
|
|
99
|
+
`from .renderer import render_image` once the old helper soup is gone.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 4. niimbot — `imagegen.py`
|
|
104
|
+
|
|
105
|
+
`customimage(entity_id, service, hass)` takes the canvas size from `service.data`
|
|
106
|
+
and uses the **`image`** rotation mode (variable-size label printer):
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
def customimage(entity_id, service, hass):
|
|
110
|
+
try:
|
|
111
|
+
return render(
|
|
112
|
+
payload=service.data.get("payload", ""),
|
|
113
|
+
width=service.data.get("width", 400),
|
|
114
|
+
height=service.data.get("height", 240),
|
|
115
|
+
rotate=service.data.get("rotate", 0),
|
|
116
|
+
rotate_mode="image", # label printer: variable size, drawing rotates
|
|
117
|
+
background=service.data.get("background", "white"),
|
|
118
|
+
context=_make_context(hass, default_font="ppb.ttf", palette="bw"),
|
|
119
|
+
)
|
|
120
|
+
except RenderError as err:
|
|
121
|
+
raise HomeAssistantError(str(err)) from err
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 5. Per-device specifics (don't unify these away)
|
|
127
|
+
|
|
128
|
+
| Concern | gicisky | niimbot |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `rotate_mode` | `"canvas"` (output stays W×H) | `"image"` (output dims swap) |
|
|
131
|
+
| `palette` | varies by model (`"2"`/`"4"`/`"7"`) | usually `"bw"` |
|
|
132
|
+
| `default_font` | `NotoSansKR-Regular.ttf` | `ppb.ttf` |
|
|
133
|
+
| canvas size | from `device.width/height` | from `service.data["width"/"height"]` |
|
|
134
|
+
|
|
135
|
+
Colors in a payload are auto-quantized to the device `palette`, so the same
|
|
136
|
+
payload renders correctly on a 2/4/7-color panel without changes.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 6. Optional cleanups enabled by the move
|
|
141
|
+
|
|
142
|
+
- **Delete bundled fonts** from each component that `imagespec` already ships
|
|
143
|
+
(`NotoSansKR-Regular.ttf`, `ppb.ttf`, the MDI webfont + meta). Decorative fonts
|
|
144
|
+
the components still need can stay in `www/fonts`, or be downloaded on demand
|
|
145
|
+
via `imagespec.resolvers.caching_resolver` (download-once, cache offline).
|
|
146
|
+
- **Image quality**: pass `dither=True` to `render(...)`, or `"dither": true` on a
|
|
147
|
+
`dlimg`, for Floyd–Steinberg dithering on limited-color panels.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Swap checklist (per component)
|
|
152
|
+
|
|
153
|
+
1. Add the `imagespec[...] @ git+...@vX.Y.Z` requirement; remove qrcode/barcode/
|
|
154
|
+
pyStrich.
|
|
155
|
+
2. Replace the body of `render_image` / `customimage` with the adapter above.
|
|
156
|
+
3. Delete the old rendering code and duplicated font/icon assets.
|
|
157
|
+
4. Smoke-test the component's existing example payloads (every element type is
|
|
158
|
+
supported, so they should render unchanged).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Minimal runnable check that the core renders without Home Assistant.
|
|
2
|
+
|
|
3
|
+
Uses only font-free elements (shapes + qrcode) so it works before any fonts are
|
|
4
|
+
bundled. Run from the repo root:
|
|
5
|
+
|
|
6
|
+
pip install -e .
|
|
7
|
+
python examples/smoke_test.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from imagespec import RenderContext, render
|
|
13
|
+
|
|
14
|
+
payload = [
|
|
15
|
+
{
|
|
16
|
+
"type": "rectangle",
|
|
17
|
+
"x_start": 2,
|
|
18
|
+
"y_start": 2,
|
|
19
|
+
"x_end": 293,
|
|
20
|
+
"y_end": 125,
|
|
21
|
+
"outline": "black",
|
|
22
|
+
"width": 2,
|
|
23
|
+
"radius": 8,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"type": "line",
|
|
27
|
+
"x_start": 10,
|
|
28
|
+
"y_start": 30,
|
|
29
|
+
"x_end": 286,
|
|
30
|
+
"y_end": 30,
|
|
31
|
+
"fill": "red",
|
|
32
|
+
"width": 1,
|
|
33
|
+
"dash": [4, 3],
|
|
34
|
+
},
|
|
35
|
+
{"type": "circle", "x": 250, "y": 70, "radius": 20, "outline": "black", "width": 2},
|
|
36
|
+
{"type": "gauge", "x": 60, "y": 75, "radius": 28, "progress": 65, "fill": "red", "outline": "black", "width": 6},
|
|
37
|
+
{"type": "qrcode", "x": 150, "y": 50, "data": "https://example.com", "boxsize": 2},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
ctx = RenderContext() # no font_resolver / history needed for this payload
|
|
41
|
+
img = render(payload, width=296, height=128, rotate=0, background="white", context=ctx)
|
|
42
|
+
img.save("smoke_test.png")
|
|
43
|
+
print(f"OK - wrote smoke_test.png ({img.size[0]}x{img.size[1]})")
|