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.
Files changed (46) hide show
  1. imagespec-0.1.0/LICENSE +21 -0
  2. imagespec-0.1.0/MANIFEST.in +6 -0
  3. imagespec-0.1.0/PKG-INFO +214 -0
  4. imagespec-0.1.0/README.md +181 -0
  5. imagespec-0.1.0/docs/migration.md +158 -0
  6. imagespec-0.1.0/examples/smoke_test.py +43 -0
  7. imagespec-0.1.0/pyproject.toml +65 -0
  8. imagespec-0.1.0/setup.cfg +4 -0
  9. imagespec-0.1.0/src/imagespec/__init__.py +57 -0
  10. imagespec-0.1.0/src/imagespec/colors.py +157 -0
  11. imagespec-0.1.0/src/imagespec/context.py +103 -0
  12. imagespec-0.1.0/src/imagespec/core.py +117 -0
  13. imagespec-0.1.0/src/imagespec/dither.py +33 -0
  14. imagespec-0.1.0/src/imagespec/elements/__init__.py +18 -0
  15. imagespec-0.1.0/src/imagespec/elements/charts.py +361 -0
  16. imagespec-0.1.0/src/imagespec/elements/codes.py +114 -0
  17. imagespec-0.1.0/src/imagespec/elements/layout.py +49 -0
  18. imagespec-0.1.0/src/imagespec/elements/media.py +192 -0
  19. imagespec-0.1.0/src/imagespec/elements/shapes.py +219 -0
  20. imagespec-0.1.0/src/imagespec/elements/text.py +457 -0
  21. imagespec-0.1.0/src/imagespec/exceptions.py +13 -0
  22. imagespec-0.1.0/src/imagespec/fonts/NotoSansKR-Regular.ttf +0 -0
  23. imagespec-0.1.0/src/imagespec/fonts/README.md +20 -0
  24. imagespec-0.1.0/src/imagespec/fonts/ppb.ttf +0 -0
  25. imagespec-0.1.0/src/imagespec/icons/README.md +13 -0
  26. imagespec-0.1.0/src/imagespec/icons/materialdesignicons-webfont.ttf +0 -0
  27. imagespec-0.1.0/src/imagespec/icons/materialdesignicons-webfont_meta.json +118127 -0
  28. imagespec-0.1.0/src/imagespec/registry.py +37 -0
  29. imagespec-0.1.0/src/imagespec/resolvers.py +91 -0
  30. imagespec-0.1.0/src/imagespec/state.py +32 -0
  31. imagespec-0.1.0/src/imagespec/utils.py +59 -0
  32. imagespec-0.1.0/src/imagespec.egg-info/PKG-INFO +214 -0
  33. imagespec-0.1.0/src/imagespec.egg-info/SOURCES.txt +44 -0
  34. imagespec-0.1.0/src/imagespec.egg-info/dependency_links.txt +1 -0
  35. imagespec-0.1.0/src/imagespec.egg-info/requires.txt +15 -0
  36. imagespec-0.1.0/src/imagespec.egg-info/top_level.txt +1 -0
  37. imagespec-0.1.0/tests/conftest.py +56 -0
  38. imagespec-0.1.0/tests/test_colors.py +74 -0
  39. imagespec-0.1.0/tests/test_core.py +64 -0
  40. imagespec-0.1.0/tests/test_dither.py +29 -0
  41. imagespec-0.1.0/tests/test_elements.py +143 -0
  42. imagespec-0.1.0/tests/test_errors.py +82 -0
  43. imagespec-0.1.0/tests/test_features.py +97 -0
  44. imagespec-0.1.0/tests/test_plot.py +36 -0
  45. imagespec-0.1.0/tests/test_resolvers.py +44 -0
  46. imagespec-0.1.0/tests/test_text_fit.py +99 -0
@@ -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.
@@ -0,0 +1,6 @@
1
+ include LICENSE README.md
2
+ recursive-include src/imagespec/fonts *.ttf *.otf *.json *.md
3
+ recursive-include src/imagespec/icons *.ttf *.otf *.json *.md
4
+ recursive-include tests *.py
5
+ recursive-include examples *.py
6
+ recursive-include docs *.md
@@ -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]})")