Render images from a declarative YAML/dict spec — shapes, text, charts, QR/barcodes — for e-paper ESL tags and label printers.
Project description
imagespec
Render images from a declarative YAML/dict spec — shapes, text, charts, QR/barcodes — for e-paper ESL tags and label printers.
This is the shared rendering core extracted from
hass-gicisky and
hass-niimbot. Both integrations had
near-identical renderers that had drifted apart; imagespec unifies them and
removes the Home Assistant dependency so the engine can be reused and tested
standalone.
Status
✅ 26 elements (21 ported + 5 new) rendering, with a 74-test suite. Architecture (HA-decoupled context, registry dispatch, device-specific rotation
- palette) is in place. Remaining work is packaging polish and switching the two components over to it.
Design
-
No framework dependency. The core never imports Home Assistant. Anything host-specific is injected through
RenderContext:font_resolver(name) -> path | None— e.g. an integration'shass.config.path("www/fonts")lookup.history_provider(entity_ids, start, end) -> states— for theplotelement (HA recorder). Optional.palette— the device's supported colors (see below).
-
Registry dispatch. Each element
typeis a handler registered with@element("type")inimagespec/elements/, replacing the original giantif/elifchain. Adding an element = adding a function. -
RenderState. Threaded through handlers; holds the (reassignable)imgand thepos_yflow cursor. -
Device-dependent palette (
RenderContext.palette), not unified — panels support different colors. Define it as a list of the colors the device supports (names, HEX, or RGBA tuples):RenderContext(palette=["black", "white", "red"]) # names RenderContext(palette=["#000000", "#ffffff", "#ff0000"]) # HEX RenderContext(palette=[(0, 0, 0), (255, 255, 255)]) # RGBA tuples
Shorthand names are optional convenience for common panels:
"2"/"bw","3"/"bwr","4","7"/"acep". Any requested color in a payload is then quantized to the nearest color in this list — on a 2-color deviceredbecomes black; on 4-color a blue#1e90ffbecomes white; on 7-color it stays blue. -
Merged behaviour. Where the two sources differed, the superset wins:
- qrcode gains
eclevel(niimbot)
- qrcode gains
-
Device-dependent rotation (
rotate_mode), not unified — both behaviours are kept because they are physically different:"canvas"(gicisky): background/canvas rotates; output stayswidth×height(fixed-resolution e-ink panel)."image"(niimbot): drawing rotates; output dimensions swap (variable-size label printer).
Usage
from imagespec import render, RenderContext
ctx = RenderContext(
font_resolver=my_font_lookup, # optional
history_provider=my_history_lookup, # optional, only for `plot`
)
image = render(payload, width=296, height=128, rotate=0,
background="white", context=ctx) # -> PIL.Image (RGB)
Run the smoke test (no fonts required):
pip install -e .
python examples/smoke_test.py
Development & testing
pip install -e ".[dev,datamatrix]"
pytest # 74 tests: every element, palettes, rotation, dither, errors
ruff check . && ruff format --check . # lint + format
python -m build # build sdist + wheel (bundles fonts/icons)
CI runs on every push/PR (.github/workflows/ci.yml): ruff lint+format, the test
suite on Python 3.11/3.12/3.13, and a build that asserts the bundled fonts/icons
are present in the wheel. Pushing a v* tag triggers
.github/workflows/release.yml to build and publish to PyPI (trusted publishing).
The test matrix (tests/test_elements.py) asserts it covers every registered
element type, so adding a new @element(...) without a sample fails the suite —
keeping coverage exhaustive by construction.
Robustness built in:
- Each handler error is wrapped with element context — you get
error rendering element #3 (type 'text'): ..., not a raw PIL traceback. render()validatesrotate/rotate_mode/size and rejects non-dict elements; unknown element types are warned-and-skipped.dlimgonly allowshttp(s)/data:URLs by default; local paths requireRenderContext(allow_local_images=True). Network failures becomeRenderError.- Clear errors for missing required args, invalid barcode symbology, malformed
polygonpoints, and adiagramtoo small for its bars.
Elements
All ported from the original renderers (superset behaviour where they differed):
| Element | Module | Notes |
|---|---|---|
line |
shapes | + dashed lines |
rectangle |
shapes | |
rectangle_pattern |
shapes | |
circle |
shapes | |
ellipse |
shapes | |
arc |
shapes | |
polygon |
shapes | |
gauge |
shapes | |
text |
text | + rotation, background box |
text_box |
text | |
multiline |
text | |
new_multiline |
text | fit-to-width/height autosize (niimbot) |
text_fit |
text | fit text into a fixed box: shrink font / ellipsis / wrap |
table |
text | |
qrcode |
codes | + eclevel (niimbot) |
barcode |
codes | |
datamatrix |
codes | optional dep pyStrich (imagespec[datamatrix]) |
icon |
media | Material Design Icons; needs bundled icons/ assets |
dlimg |
media | + fit modes (stretch/fit/fill/contain) |
diagram |
charts | bar chart |
plot |
charts | needs history_provider; + area_fill, xlegend |
progress_bar |
charts | + rounded corners |
pie |
charts | new — pie / donut (inner_radius) |
sparkline |
charts | new — compact axis-less line from inline values |
rich_text |
text | new — inline spans: icon + text + color on one line |
group |
layout | new — container: child elements at an offset, clipped, optionally rotated |
Plus enhancements: dlimg gained dither (Floyd–Steinberg to palette) and
circle/mask (circular crop); render(..., dither=True) dithers the whole
output; text_fit fits text into a fixed box (shrink / ellipsis / wrap).
Fonts & assets
Bundled in the package (offline baseline):
icons/materialdesignicons-webfont.ttf+_meta.json— required byicon.fonts/NotoSansKR-Regular.ttf(default) andfonts/ppb.ttf(niimbot default).
Anything else is resolved at runtime, in order: font_resolver (host) →
bundled font of the same basename → bundled default. Helpers in
imagespec.resolvers:
directory_resolver(dir)— look up fonts in a host directory (e.g.www/fonts).caching_resolver(cache_dir, sources)— download on first use, cache to disk, reuse offline (internet needed only once per font).chain_resolvers(a, b, ...)— try several in order.
This is why the core bundles only the essentials (~11 MB) and not gicisky's
full 74 MB font set — decorative fonts are better downloaded-and-cached or served
from www/fonts.
Open decisions
- Default font.
NotoSansKR-Regular.ttf(gicisky) vsppb.ttf(niimbot). Default is Noto; bundle both so existing payloads render unchanged.
Resolved: rotation is now a per-device
rotate_mode("canvas"for gicisky,"image"for niimbot), andRenderState.canvas_width/heightalways reflect the actual drawing surface — soplot/diagramdefault extents are consistent in both modes.
Integrating back into the components
Replace each component's renderer with a thin adapter (see
docs/migration.md) and add to manifest.json:
"requirements": ["imagespec==0.1.0"]
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file imagespec-0.1.0.tar.gz.
File metadata
- Download URL: imagespec-0.1.0.tar.gz
- Upload date:
- Size: 4.1 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
09f6b65751b0d81d6d664254f919fe9f4ad8b7dc4ffb8a69dec0a674e6efdf15
|
|
| MD5 |
a1ee37dffe0ec2f5ebb9aeb384df24c2
|
|
| BLAKE2b-256 |
69a34a282c81ac7af4353a058f9ede4a44e512f096a396353bbb3c670684da8b
|
Provenance
The following attestation bundles were made for imagespec-0.1.0.tar.gz:
Publisher:
release.yml on eigger/imagespec
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
imagespec-0.1.0.tar.gz -
Subject digest:
09f6b65751b0d81d6d664254f919fe9f4ad8b7dc4ffb8a69dec0a674e6efdf15 - Sigstore transparency entry: 2003990092
- Sigstore integration time:
-
Permalink:
eigger/imagespec@f950002bcd855e55f2697062a0c4d928a41304b5 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/eigger
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f950002bcd855e55f2697062a0c4d928a41304b5 -
Trigger Event:
push
-
Statement type:
File details
Details for the file imagespec-0.1.0-py3-none-any.whl.
File metadata
- Download URL: imagespec-0.1.0-py3-none-any.whl
- Upload date:
- Size: 4.1 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5795ff70d142df57b9ab626b43b508696464666e4c7f20909b799415c50958b1
|
|
| MD5 |
467cecb12f3c6b3a03c89e4ccec7540d
|
|
| BLAKE2b-256 |
23ca08f6817bf103f8e56b261eb1c5768cce93f6804312fb44588b16b574f60a
|
Provenance
The following attestation bundles were made for imagespec-0.1.0-py3-none-any.whl:
Publisher:
release.yml on eigger/imagespec
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
imagespec-0.1.0-py3-none-any.whl -
Subject digest:
5795ff70d142df57b9ab626b43b508696464666e4c7f20909b799415c50958b1 - Sigstore transparency entry: 2003990171
- Sigstore integration time:
-
Permalink:
eigger/imagespec@f950002bcd855e55f2697062a0c4d928a41304b5 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/eigger
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f950002bcd855e55f2697062a0c4d928a41304b5 -
Trigger Event:
push
-
Statement type: