From 0b7115514aa1789c69164269ec2fc13f42d31e2d Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 30 Jun 2026 08:37:21 +0900 Subject: [PATCH 1/4] feat: bug fixes, widget elements, deferred dithering, pixel code sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes - plot: default y_end derived from y_start (was x_start) — wrong vertical extent - diagram: accept float bar values; clear error on non-positive max; require x/y/height - plot: stop mutating the caller's history list in place - dlimg: use startswith for url scheme detection (was substring `in`) - group: wrap child handler errors with element context, like the top loop - colors: support #RGB shorthand; warn (not silent white) on unknown color names New elements (widgets.py) - legend: color-swatch <-> label rows (the companion pie/plot lacked) - star_rating: full/half/empty stars - battery: vector battery gauge with proportional fill + low-color threshold Dithering model - Quantization is now deferred: elements draw in full color and the whole image is mapped to the palette once at the end; `dither` picks halftone vs flat. Output is always strictly on-palette; in-palette colors stay crisp. - Per-element `dither: true/false` override on any element (isolated + quantized at draw time), shared via a new dispatch module so `group` children get it too. Code element sizing (predictable, pixel-based) - barcode: `width`/`height` scale to a pixel box (was mm + DPI only); expose `dpi`; route colors through the palette - qrcode/datamatrix: `width`/`height` fit to a square pixel box (crisp, integer upscale) Docs/examples/tests - README: deferred-mapping + per-element dither sections, ESL(3-color)/label(2-color) dithering samples, widget + code-sizing reference updates - new example generators (dither labels) and element previews - tests: 97 passing (deferred/per-element dither, widgets, code pixel sizing, fixed-bug regressions) Co-Authored-By: Claude Opus 4.8 --- README.md | 131 ++++++++++++++++++++-- examples/dither_esl_3color.png | Bin 0 -> 4221 bytes examples/dither_label_2color.png | Bin 0 -> 3712 bytes examples/elements/battery.png | Bin 0 -> 592 bytes examples/elements/diagram.png | Bin 406 -> 404 bytes examples/elements/legend.png | Bin 0 -> 645 bytes examples/elements/star_rating.png | Bin 0 -> 493 bytes examples/generate_dither_labels.py | 128 +++++++++++++++++++++ examples/generate_element_previews.py | 33 ++++++ examples/generate_showcase.py | 52 ++++++++- examples/showcase.png | Bin 9991 -> 12628 bytes src/imagespec/colors.py | 51 ++++++--- src/imagespec/context.py | 11 +- src/imagespec/core.py | 27 ++--- src/imagespec/dispatch.py | 58 ++++++++++ src/imagespec/elements/__init__.py | 1 + src/imagespec/elements/charts.py | 20 +++- src/imagespec/elements/codes.py | 73 ++++++++++-- src/imagespec/elements/layout.py | 26 ++--- src/imagespec/elements/media.py | 14 +-- src/imagespec/elements/widgets.py | 155 ++++++++++++++++++++++++++ tests/test_colors.py | 11 ++ tests/test_dither.py | 130 +++++++++++++++++++++ tests/test_elements.py | 9 ++ tests/test_errors.py | 45 ++++++++ tests/test_features.py | 106 ++++++++++++++++++ tests/test_plot.py | 16 +++ 27 files changed, 1013 insertions(+), 84 deletions(-) create mode 100644 examples/dither_esl_3color.png create mode 100644 examples/dither_label_2color.png create mode 100644 examples/elements/battery.png create mode 100644 examples/elements/legend.png create mode 100644 examples/elements/star_rating.png create mode 100644 examples/generate_dither_labels.py create mode 100644 src/imagespec/dispatch.py create mode 100644 src/imagespec/elements/widgets.py diff --git a/README.md b/README.md index 63c0651..cf182cc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ standalone. ## Status -✅ **26 elements** (21 ported + 5 new) rendering, with a 74-test suite. +✅ **29 elements** (21 ported + 8 new) rendering, with an 86-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. @@ -47,7 +47,9 @@ components over to it. `"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 device `red` becomes black; on 4-color a blue `#1e90ff` becomes white; on 7-color it stays - blue. + blue. Elements are drawn in full color and this mapping is applied to the whole + image once at the end of `render()` (dithered or flat, per `dither` — see + *Dithering*). - **Merged behaviour.** Where the two sources differed, the superset wins: - qrcode gains `eclevel` (niimbot) - **Device-dependent rotation** (`rotate_mode`), *not* unified — both behaviours @@ -81,7 +83,7 @@ python examples/smoke_test.py ```bash pip install -e ".[dev,datamatrix]" -pytest # 74 tests: every element, palettes, rotation, dither, errors +pytest # 86 tests: every element, palettes, rotation, dither, errors ruff check . && ruff format --check . # lint + format python -m build # build sdist + wheel (bundles fonts/icons) ``` @@ -138,10 +140,14 @@ All ported from the original renderers (superset behaviour where they differed): | ![](examples/elements/sparkline.png) | `sparkline` | charts | **new** — compact axis-less line from inline values | | ![](examples/elements/rich_text.png) | `rich_text` | text | **new** — inline spans: icon + text + color on one line | | ![](examples/elements/group.png) | `group` | layout | **new** — container: child elements at an offset, clipped, optionally rotated | +| ![](examples/elements/legend.png) | `legend` | widgets | **new** — color-swatch ↔ label rows (vertical/horizontal) for `pie`/`plot` | +| ![](examples/elements/star_rating.png) | `star_rating` | widgets | **new** — full/half/empty stars for rating labels | +| ![](examples/elements/battery.png) | `battery` | widgets | **new** — vector battery gauge with proportional fill | -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). +Plus enhancements: `render(..., dither=True)` dithers the whole output and **any +element** can carry its own `dither: true`/`false` to override it just for itself +(Floyd–Steinberg to palette); `dlimg` also gained `circle`/`mask` (circular crop); +`text_fit` fits text into a fixed box (shrink / ellipsis / wrap). ## Payload Specification & Element Reference @@ -236,23 +242,57 @@ Payloads are specified as a list (sequence) of dictionary elements, which can be - **`qrcode`**: Generates a QR Code. - `x`, `y` (int top-left, required) - `data` (string, required) - - `boxsize` (int module pixel size, default: `2`) + - `boxsize` (int pixels per module, default: `2`), **or** `width`/`height` (int px + box — the code is scaled square & crisp to fit it, for predictable layout) - `color`, `bgcolor`, `border`, `eclevel` (`"l"`, `"m"`, `"q"`, `"h"`, optional) - **`barcode`**: Generates a standard linear barcode. - `x`, `y` (int top-left, required) - `data` (string, required) - `code` (string format, e.g. `"code128"`, `"ean13"`, default: `"code128"`) - - `module_width` (float, default: `0.2`), `module_height` (float, default: `7.0`) - - `quiet_zone` (float padding, default: `6.5`), `font_size`, `write_text` (bool, optional) + - **Sizing (easy, pixel-based):** `width` and/or `height` (int px) — scales the + barcode to fit that box at `(x, y)`, like `dlimg`/`icon`. Give one to scale + proportionally, or both for an exact box. This is the recommended way to place + a barcode predictably. + - **Sizing (physical, advanced):** when `width`/`height` are omitted it uses + python-barcode's millimetre options — `module_width` (float mm, default `0.2`), + `module_height` (float mm, default `7.0`), `quiet_zone` (float mm, default `6.5`) + rendered at `dpi` (int, default `300`). Pixel size then depends on the DPI. + - `color`, `bgcolor` (quantized to the palette), `font_size`, `text_distance`, + `write_text` (bool, default `true`) — optional. +- **`datamatrix`**: Generates a DataMatrix 2D code (needs the `datamatrix` extra). + - `x`, `y`, `data` (required) + - `boxsize` (int pixels per cell, default: `2`), **or** `width`/`height` (int px box + — scaled square & crisp to fit, like `qrcode`) + - `color`, `bgcolor` (optional) - **`icon`**: Renders a vector icon from Material Design Icons. - `x`, `y` (int top-left, required) - `value` (string slug, e.g. `"mdi:home"`, required) - `size` (int, default: `24`), `color` (optional) - **`dlimg`**: Downloads and renders an external image (with fit and dithering). - - `x`, `y`, `width`, `height` (int box, required) - - `url` (http/https or base64 data: url, required) + - `x`, `y`, `xsize`, `ysize` (int box, required) + - `url` (http/https or base64 data: url, required; local paths need `RenderContext(allow_local_images=True)`) - `mode` (`"stretch"`, `"fit"`, `"fill"`, `"contain"`, default: `"stretch"`) - - `dither` (bool, optional), `mask` (`"circle"`, `"rounded"`, optional) + - `rotate` (int degrees, optional), `timeout` (seconds, default: `30`) + - `dither` (bool, optional), `mask` (`"circle"`, optional; or `circle: true`) + +#### Widgets +- **`legend`**: Draws color-swatch ↔ label rows (companion to `pie`/`plot`). + - `x`, `y` (int top-left, required) + - `items` (required) — list of `{"label": ..., "color": ..., "icon": ...}` dicts, or a `"label,color;label,color"` string + - `orientation` (`"vertical"` / `"horizontal"`, default: `"vertical"`) + - `shape` (`"square"` / `"circle"` / `"line"`, default: `"square"`) + - `size` (font size, default: `12`), `swatch_size`, `gap`, `spacing` (optional) +- **`star_rating`**: Renders a star rating. + - `x`, `y` (int top-left, required) + - `rating` (float, required), `max` (int stars, default: `5`) + - `size` (int, default: `16`), `color` (filled, default: `"orange"`), `empty_color`, `spacing` + - `half` (bool half-stars, default: `true`) +- **`battery`**: Vector battery gauge with a proportional fill. + - `x`, `y`, `width`, `height` (int box, required) + - `level` (int/float `0`-`100`, required) + - `fill`, `background`, `outline`, `radius`, `padding` (optional) + - `low_threshold` (int, default: `20`), `low_color` (swap fill at/below threshold, optional) + - `show_percentage` (bool, optional), `text_color` (optional) ### Dithering @@ -273,6 +313,73 @@ Dithering creates a natural halftone pattern that simulates smooth shading and e Dithering is useful when you have solid color regions (like pie slices or bar diagrams) in colors outside your device palette (e.g. orange on a black/white screen). Dithering simulates these colors with dot patterns to help distinguish segments, though it introduces some edge noise. ![Chart Dithering Comparison](examples/dither_comparison_chart.png) +#### How palette mapping works +Every element is drawn in **full color**, and the whole image is mapped to +`context.palette` **once at the end** of `render()`. The `dither` flag only picks +*how* that single mapping happens: + +- `dither=True` → Floyd–Steinberg halftone: off-palette fills become + distinguishable dot patterns (an `orange`/`green`/`blue` pie on a 2-color panel + renders as three different textures instead of collapsing into one black blob). +- `dither=False` (default) → flat nearest color. + +Either way the output is **strictly on-palette**. Because mapping is deferred, +in-palette colors (e.g. black text on white) stay crisp under dithering — +Floyd–Steinberg diffuses no error when a pixel already equals a palette color — +while the text guidance above still applies to *off-palette* text you choose to +dither. + +```python +ctx = RenderContext(palette="bw") +img = render(payload, 296, 128, dither=True, context=ctx) +# orange/green/blue pie slices -> different dot patterns, not one black blob +``` + +#### Per-element `dither` override +Any element may carry its own `dither: true`/`false` to override the global flag +just for itself — so you can dither only the parts that benefit (photos, charts) +and keep the rest flat (labels, QR codes), in a single render: + +```yaml +- type: dlimg # this photo -> halftone + url: "https://…/photo.png" + xsize: 100 + ysize: 100 + dither: true +- type: pie # this chart -> halftone (segments stay distinguishable) + x: 60; y: 60; radius: 40 + values: "Gas,30,orange;Water,25,blue;Elec,45,red" + dither: true +- type: text # left flat regardless of the global flag + x: 10; y: 110 + value: "Energy mix" +``` + +An element with an explicit `dither` is rendered in isolation and mapped to the +palette immediately (then composited in payload order), so its choice survives +the final whole-image pass. Elements without the key follow the global `dither` +argument. (QR/barcode and black text are pure palette colors, so they stay crisp +under the global flag anyway — set `dither: false` only for *off-palette* content +you want kept solid.) + +#### Device samples +Both labels below mix crisp content (text, QR, barcode) with charts authored in +off-palette colors and marked `dither: true` — the charts become halftones so +their segments stay distinguishable, while everything else stays sharp. + +**Electronic shelf label — 3-color (black / white / red):** + +![ESL 3-color dithering sample](examples/dither_esl_3color.png) + +**Label printer — 2-color (black / white):** + +![Label printer 2-color dithering sample](examples/dither_label_2color.png) + +Regenerate them with: +```bash +python examples/generate_dither_labels.py +``` + To run the dithering comparison generator yourself: ```bash python examples/compare_dither.py diff --git a/examples/dither_esl_3color.png b/examples/dither_esl_3color.png new file mode 100644 index 0000000000000000000000000000000000000000..ca633a9b422108a6a02bdec9856f2b94a25e93e2 GIT binary patch literal 4221 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 TMrxm400000NkvXXu0mjfsq-_J literal 0 HcmV?d00001 diff --git a/examples/dither_label_2color.png b/examples/dither_label_2color.png new file mode 100644 index 0000000000000000000000000000000000000000..ed81e427dd533316051b3ea8bebd8f9e63f99ddd GIT binary patch literal 3712 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 KYgKAEM*kOk;UL2R literal 0 HcmV?d00001 diff --git a/examples/elements/battery.png b/examples/elements/battery.png new file mode 100644 index 0000000000000000000000000000000000000000..1ba0503023483aa7b5ae7c959524a05145c1ba39 GIT binary patch literal 592 zcmeAS@N?(olHy`uVBq!ia0vp^(||aDg9%8MT#}YyU|XrB1sb0|%;prSU=iKo-i}&(4uBa)JUatG>UAIE<&-cs%^OagFmI(BVJeufa6V&0( z_0dC7PVYSu4oS|G%VkiIef=%@3bnSFvc-E=y0jm@it_Q}8pYp8HjR z^@_O%!A72+Ql_@;bjG@fd~3n#pI1J+XRT?H41Qr=UwXObW|LxBZT3Vp5`nTw| z!@O&PX34+Ko`qqy_lJ)3~(=a#N#4nMl*s*I`nLC(TAHpc`)f5vFa@LYSY9NqcnX>Ph&V(XTt zz7J1UbS`@~NlV@|>sK!q^T&st0cpEZ!YyvZ$wmLnTGJOPVs?J1Z|LjpYV-E#_ZLs? zPncHw@b;-Pv+nggzqB{&pV_){!|vV~g|}Pmr@g;p@jLTfjHZqJ{P~A&a&2DI`p5tp z1M@0c7wAqe=KFf3&m?P$)Qvy0Sn3<=R_uM)V!8ItgFx$MQsPP6%HHU~YsqU%#Z`eR Og~8L+&t;ucLK6U#Iv`Edo z>9WoCFKfwE&W}DF=}tDnk7f#-Z>dl_Vyt-Y^{Ls)NjHwp?U-$EusUXHk=VqC(gMbc z^MFcseEWN76;o;crO1gjvb?MBuRKw$|7v>Wtqhg)iPu%@A&NT6Gb8Wcp5K=H_KEQ3 z7ZLWBCr$EF(-irae4M-dclWx*Th1}Rb(Yz>d}qv|`+djPN(zD_FHil{}73JQL^kwUhm9p+0tgNQKE*y`}d_U*_`{Tw{QQ6>;yV=36T~*4Fi(^Q|oVRxm@--O zb4uA>U(X<$>SQDQXr{pVmI}2a#)|VeKl*f(+kbj?MrY2QlRZ(neU<9&6B{K&I?`e4 zitp<`Io0^i*NSW61sTDDFV>>f#=qX)*8J}|&urJE#S^RK>k*29hTe|6w*61$jgDU3 zZI0hV6O@CWo;u?+&*QVDU%dXg4flSmw2peR`^D`gr&V*J=g<7*A5owb%lGMx@TW~u z^>1hW*{yf)ZvGyn)bGlwd+Ip+N-T|^T%R@P@h(xmi4v|wzrKELw~sw}Re#gxWSMOT nZ49Ox|2m?9&q0E&i=@t336jkNQQow^kCrvF z7L_Xq^hk_ww<@ij)Cdf?Ll64D*{h2??pwKMemgiIkFB3L@9whsMTb2sS}XrnsoqjM ztfKkm&D+MdLw4@}IL{uMv+=`<4gYoJZ44%s)s<W+9ono@wcUX1?>?m5RymfNA(X-?l#l8Mrfd;Ym;%54<@4G)J*!Q~6B>vsrD0z<@ zxu`7_ttEeADjpthv|3($`Pq*%Ld}xT4n1h8_%T;H2z!JhQbD(s|H8PS-4bRAy};zf N;OXk;vd$@?2>@(}CjbBd literal 0 HcmV?d00001 diff --git a/examples/elements/star_rating.png b/examples/elements/star_rating.png new file mode 100644 index 0000000000000000000000000000000000000000..d04742394a1b0c9ece5daa6c0f6cc4ad6b31c32c GIT binary patch literal 493 zcmeAS@N?(olHy`uVBq!ia0vp^(||aDg9%8MT#}YyU|`(h>EaktG3V`^gL#Jx1XzWy ztUvSLbblt_RDmF&&AYc<@v|y+w&;KGQLl_|&DyQ5Cv56|v!#fLJ_=Nnqm-Ce$@RT7 zYD%Nz8YdgABcEs7bXETr#C$lSwIWTUMt`4X>yLcq?O_~SxIV6#y5HMFck=wHrPmLv zox3NLyC!xjkB^p)dGG4k3)u70xw0E&TkjsI5fyv3V!zhK#%ODKNuECu>l-Kjyno1J zx<~JR-v@GHpEo`*YFzr(r>3t-IbUE$`(IYwHfHbJGan!Cna*TVP;JfD!!pn8NyLKo zF3BrTO7+gO@M-XTD`bAW`AU7|!SIv`F-Di1`Yo3psO&BIlU>04ePepg@wwh=1#Y7I z>~?(VIDg=v<(ja>^wZJNv)QIS-*ELu+_N6Tf(pLl=UP9pe~9UDZ> dithered halftone + { + "type": "pie", + "x": 175, + "y": 78, + "radius": 34, + "inner_radius": 15, + "values": "Roast,40,#6f4e37;Acidity,30,#c8a45d;Body,30,#3b2f2f", + "outline": "black", + "dither": True, + }, + {"type": "text", "x": 175, "y": 114, "value": "Flavor", "size": 10, "color": "black", "anchor": "ma"}, + # price-trend sparkline (off-palette green) -> dithered + { + "type": "sparkline", + "x": 225, + "y": 40, + "width": 60, + "height": 30, + "values": [16.5, 15.9, 15.0, 14.2, 13.5, 12.99], + "color": "#2ca02c", + "fill": "#aec7e8", + "width_line": 2, + "dot_last": True, + "dither": True, + }, + {"type": "qrcode", "x": 232, "y": 78, "data": "https://store/p/organic-coffee", "boxsize": 1, "border": 1}, + ] + return render(payload, width=296, height=128, background="white", dither=False, context=ctx) + + +def label_2color(): + """A product/shipping label on a 2-color (black/white) label printer, 360x180.""" + ctx = RenderContext(palette=PALETTE_BW) + payload = [ + {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": 359, "y_end": 179, "outline": "black", "width": 2}, + {"type": "text", "x": 10, "y": 8, "value": "WAREHOUSE A — DAILY", "size": 16, "color": "black"}, + {"type": "line", "x_start": 10, "y_start": 30, "x_end": 350, "y_end": 30, "fill": "black", "width": 1}, + # bar chart of throughput, off-palette blue -> dithered to a BW halftone + { + "type": "diagram", + "x": 8, + "y": 38, + "width": 200, + "height": 95, + "margin": 14, + "bars": {"values": "Mon,40;Tue,72;Wed,55;Thu,88", "color": "#1f77b4", "legend_color": "black"}, + "dither": True, + }, + # stock gauge in off-palette orange -> dithered + { + "type": "gauge", + "x": 285, + "y": 78, + "radius": 34, + "progress": 67, + "fill": "#ff7f0e", + "outline": "black", + "width": 9, + "show_value": True, + "size": 18, + "dither": True, + }, + {"type": "text", "x": 285, "y": 116, "value": "Stock", "size": 11, "color": "black", "anchor": "ma"}, + # crisp barcode + id (never dithered); pixel-sized into a 210x34 box + { + "type": "barcode", + "x": 14, + "y": 140, + "data": "4006381333931", + "code": "ean13", + "width": 210, + "height": 34, + "write_text": True, + }, + {"type": "text", "x": 250, "y": 150, "value": "SKU 4471-A", "size": 13, "color": "black"}, + ] + return render(payload, width=360, height=180, background="white", dither=False, context=ctx) + + +def main(): + os.makedirs("examples", exist_ok=True) + esl_3color().save("examples/dither_esl_3color.png") + print("Saved examples/dither_esl_3color.png") + label_2color().save("examples/dither_label_2color.png") + print("Saved examples/dither_label_2color.png") + + +if __name__ == "__main__": + main() diff --git a/examples/generate_element_previews.py b/examples/generate_element_previews.py index 2addab2..694363c 100644 --- a/examples/generate_element_previews.py +++ b/examples/generate_element_previews.py @@ -319,6 +319,39 @@ def main(): "xlegend": None, "yaxis": None, }, + "legend": { + "type": "legend", + "x": 20, + "y": 12, + "items": [ + {"label": "Gas", "color": "red"}, + {"label": "Water", "color": "blue"}, + {"label": "Solar", "color": "orange"}, + ], + "size": 15, + "swatch_size": 15, + }, + "star_rating": { + "type": "star_rating", + "x": 12, + "y": 28, + "rating": 3.5, + "max": 5, + "size": 25, + "color": "orange", + }, + "battery": { + "type": "battery", + "x": 25, + "y": 25, + "width": 100, + "height": 35, + "level": 65, + "fill": "green", + "outline": "black", + "show_percentage": True, + "text_color": "white", + }, } for name, el in samples.items(): diff --git a/examples/generate_showcase.py b/examples/generate_showcase.py index 6ae8a58..dd1ff15 100644 --- a/examples/generate_showcase.py +++ b/examples/generate_showcase.py @@ -27,7 +27,7 @@ def main(): "x_start": 0, "y_start": 0, "x_end": 639, - "y_end": 383, + "y_end": 499, "outline": "black", "width": 2, "radius": 12, @@ -94,7 +94,7 @@ def main(): "show_percentage": True, }, # Memory Sparkline - {"type": "text", "x": 25, "y": 178, "value": "Memory Usage (24h)", "size": 9, "color": "gray"}, + {"type": "text", "x": 25, "y": 178, "value": "Memory Usage (24h)", "size": 9, "color": "black"}, { "type": "sparkline", "x": 25, @@ -131,7 +131,7 @@ def main(): }, {"type": "text", "x": 280, "y": 152, "value": "Utility Ratio", "size": 9, "anchor": "ma"}, # Small Sparkline - {"type": "text", "x": 330, "y": 98, "value": "Power (24h)", "size": 8, "color": "gray"}, + {"type": "text", "x": 330, "y": 98, "value": "Power (24h)", "size": 8, "color": "black"}, { "type": "sparkline", "x": 330, @@ -258,9 +258,53 @@ def main(): "align": "center", "row_height": 20, }, + # --- 6. Card E: Status, Ratings & Power (Bottom) --- + { + "type": "rectangle", + "x_start": 15, + "y_start": 385, + "x_end": 625, + "y_end": 485, + "outline": "black", + "width": 1, + "radius": 8, + }, + {"type": "text", "x": 25, "y": 392, "value": "Status, Ratings & Power", "size": 12, "color": "black"}, + # Legend (left) + {"type": "text", "x": 25, "y": 412, "value": "Sources", "size": 9, "color": "black"}, + { + "type": "legend", + "x": 25, + "y": 426, + "items": [ + {"label": "Gas", "color": "orange"}, + {"label": "Water", "color": "blue"}, + {"label": "Elec", "color": "red"}, + ], + "size": 12, + "swatch_size": 12, + }, + # Star rating (center) + {"type": "text", "x": 250, "y": 412, "value": "Customer Rating", "size": 9, "color": "black"}, + {"type": "star_rating", "x": 250, "y": 428, "rating": 4.5, "max": 5, "size": 26, "color": "orange"}, + {"type": "text", "x": 250, "y": 462, "value": "4.5 / 5.0", "size": 10, "color": "black"}, + # Battery (right) + {"type": "text", "x": 440, "y": 412, "value": "Battery", "size": 9, "color": "black"}, + { + "type": "battery", + "x": 440, + "y": 428, + "width": 160, + "height": 40, + "level": 80, + "fill": "green", + "outline": "black", + "show_percentage": True, + "text_color": "white", + }, ] - img = render(payload, width=640, height=384, background="white", dither=False, context=ctx) + img = render(payload, width=640, height=500, background="white", dither=False, context=ctx) os.makedirs("examples", exist_ok=True) output_path = "examples/showcase.png" img.save(output_path) diff --git a/examples/showcase.png b/examples/showcase.png index c3c03197d8a2260b45f7af3a59d89a76110c3628..2de5e63bf30e8dbadcbb55efcc4a254f7314b8e6 100644 GIT binary patch literal 12628 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

_X6i+>Z=stEX|Z-*<>Ga(dBn%NKjyxYhjd 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 rg8 #RRGGBB + return (int(h[0] * 2, 16), int(h[1] * 2, 16), int(h[2] * 2, 16), 255) + if len(h) >= 6: + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255) + except ValueError: + return None + return None + + def _normalize_color(c): """Resolve one palette entry to an RGBA tuple. - Accepts a color name ("red"), a HEX string ("#ff0000"), or an + Accepts a color name ("red"), a HEX string ("#ff0000" or "#f00"), or an ``(r, g, b)`` / ``(r, g, b, a)`` tuple/list. """ if isinstance(c, (tuple, list)): @@ -78,10 +91,10 @@ def _normalize_color(c): if s in NAMED_COLORS: return NAMED_COLORS[s] if s.startswith("#"): - h = s.lstrip("#") - if len(h) >= 6: - return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255) - raise ValueError(f"Unknown palette color {c!r} (not a known name or #RRGGBB)") + rgba = _parse_hex(s) + if rgba is not None: + return rgba + raise ValueError(f"Unknown palette color {c!r} (not a known name or #RGB/#RRGGBB)") def get_palette(spec): @@ -105,20 +118,21 @@ def get_palette(spec): def _requested_rgb(color): - """Resolve a color name or ``#RRGGBB`` to an RGBA tuple, or ``None``.""" + """Resolve a color name or ``#RGB``/``#RRGGBB`` to an RGBA tuple, or ``None``. + + An unrecognized value falls back to ``white`` (the renderer never raises on a + bad element color) but logs a warning so typos are diagnosable. + """ if color is None: return None s = str(color).strip().lower() if s in NAMED_COLORS: return NAMED_COLORS[s] if s.startswith("#"): - try: - h = s.lstrip("#") - if len(h) >= 6: - return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255) - except ValueError: - pass - return white + rgba = _parse_hex(s) + if rgba is not None: + return rgba + _LOGGER.warning("Unknown color %r — falling back to white. Use a known name or #RGB/#RRGGBB.", color) return white @@ -134,6 +148,17 @@ def nearest_in_palette(rgba, palette): return best +def resolve_color(color): + """Resolve a color name or ``#RGB``/``#RRGGBB`` to its **true** RGBA tuple. + + Unlike :func:`quantize_color`, this does *not* snap to a device palette — it + is used by deferred-quantization rendering, where every element is drawn in + full color and the whole image is quantized/dithered once at the end. + Returns ``None`` for a ``None`` input (meaning "no fill"). + """ + return _requested_rgb(color) + + def quantize_color(color, palette): """Map a requested color to the nearest color the device supports. diff --git a/src/imagespec/context.py b/src/imagespec/context.py index 7ceed1f..46e3392 100644 --- a/src/imagespec/context.py +++ b/src/imagespec/context.py @@ -21,7 +21,7 @@ from PIL import ImageFont -from .colors import DEFAULT_PALETTE, get_palette, quantize_color +from .colors import DEFAULT_PALETTE, get_palette, resolve_color from .exceptions import RenderError FontResolver = Callable[[str], str | None] @@ -59,8 +59,13 @@ def __post_init__(self): self.palette = get_palette(self.palette) def color(self, value): - """Quantize a requested color to this device's palette (or ``None``).""" - return quantize_color(value, self.palette) + """Resolve a requested color to its **true** RGBA (or ``None``). + + Handlers always draw in full color; palette mapping (dithered or flat + nearest, per the ``dither`` flag) happens once for the whole image at the + end of :func:`~imagespec.core.render`. + """ + return resolve_color(value) def resolve_font_path(self, name: str | None) -> str: """Resolve a payload font name to an absolute file path. diff --git a/src/imagespec/core.py b/src/imagespec/core.py index b6bc5fe..3ef238a 100644 --- a/src/imagespec/core.py +++ b/src/imagespec/core.py @@ -10,10 +10,9 @@ # Importing the elements package registers all handlers via @element(...). from . import elements # noqa: E402,F401 (side-effect import) from .context import RenderContext +from .dispatch import render_element from .exceptions import RenderError -from .registry import get_handler from .state import RenderState -from .utils import should_show _LOGGER = logging.getLogger(__name__) @@ -61,9 +60,11 @@ def render( background: Background color name or ``#RRGGBB`` (mapped via :func:`get_index_color`). dither: - If True, Floyd–Steinberg dither the final image to ``context.palette`` - (better for photos/logos on limited-color panels). Per-image dithering is - also available on the ``dlimg`` element. + Elements are always drawn in full color and mapped to ``context.palette`` + once at the end. ``dither`` picks *how*: True → Floyd–Steinberg halftone + (off-palette fills become distinguishable dot patterns — better for + photos/charts on limited-color panels); False (default) → flat nearest + color. Either way the output is strictly on-palette. context: Host-supplied :class:`RenderContext` (fonts, history, ...). """ @@ -93,14 +94,8 @@ def render( raise RenderError(f"each payload element must be a dict, got {type(element).__name__}") etype = element.get("type", "") _LOGGER.debug("type: %s", etype) - if not should_show(element): - continue - handler = get_handler(etype) - if handler is None: - _LOGGER.warning("Unknown element type '%s' — skipping.", etype) - continue try: - handler(state, element) + render_element(state, element) except RenderError: raise # already descriptive (names the element type / missing arg) except Exception as exc: # noqa: BLE001 — add element context, then surface @@ -110,8 +105,8 @@ def render( if rotate in (90, 180, 270): img = img.rotate(-rotate, expand=True) result = img.convert("RGB") - if dither: - from .dither import dither_to_palette + # Elements are drawn in true color; map the whole image to the device palette + # once here — Floyd–Steinberg halftone when `dither`, flat nearest otherwise. + from .dither import dither_to_palette - result = dither_to_palette(result, context.palette) - return result + return dither_to_palette(result, context.palette, dither=dither) diff --git a/src/imagespec/dispatch.py b/src/imagespec/dispatch.py new file mode 100644 index 0000000..72c42f3 --- /dev/null +++ b/src/imagespec/dispatch.py @@ -0,0 +1,58 @@ +"""Single-element dispatch, shared by the render loop and the ``group`` container. + +Kept in its own module (importing only the registry/utils, never the element +handlers) so both :mod:`imagespec.core` and :mod:`imagespec.elements.layout` can +use it without an import cycle. +""" + +from __future__ import annotations + +import logging + +from PIL import Image + +from .registry import get_handler +from .utils import should_show + +_LOGGER = logging.getLogger(__name__) + + +def _draw_isolated(state, element, handler, dither: bool) -> None: + """Render one element on its own layer and map it to the palette now. + + Used for a per-element ``dither`` override: the element is drawn in isolation, + quantized (dithered halftone or flat nearest) to the device palette, then + composited. Because the result is already palette colors, the final + whole-image pass in :func:`~imagespec.core.render` leaves it untouched — so + this element keeps its chosen treatment regardless of the global ``dither`` + flag, and payload (z-)order is preserved. + """ + from .dither import dither_to_palette + + base = state.img + state.img = Image.new("RGBA", base.size, (0, 0, 0, 0)) + handler(state, element) # handler may reassign state.img (rotation/composite) + layer = state.img.convert("RGBA") + state.img = base + alpha = layer.split()[-1] + quant = dither_to_palette(layer, state.context.palette, dither=dither).convert("RGBA") + quant.putalpha(alpha) # only the pixels the element actually drew + base.alpha_composite(quant) + + +def render_element(state, element: dict) -> None: + """Dispatch a single element: visibility, handler lookup, per-element dither. + + Raises whatever the handler raises (the caller adds element index/context). + """ + if not should_show(element): + return + etype = element.get("type", "") + handler = get_handler(etype) + if handler is None: + _LOGGER.warning("Unknown element type '%s' — skipping.", etype) + return + if "dither" in element: + _draw_isolated(state, element, handler, bool(element["dither"])) + else: + handler(state, element) diff --git a/src/imagespec/elements/__init__.py b/src/imagespec/elements/__init__.py index ad60b47..fa5d1d5 100644 --- a/src/imagespec/elements/__init__.py +++ b/src/imagespec/elements/__init__.py @@ -15,4 +15,5 @@ media, # noqa: F401 shapes, # noqa: F401 text, # noqa: F401 + widgets, # noqa: F401 ) diff --git a/src/imagespec/elements/charts.py b/src/imagespec/elements/charts.py index 3c07b8c..c6def9f 100644 --- a/src/imagespec/elements/charts.py +++ b/src/imagespec/elements/charts.py @@ -99,6 +99,7 @@ def sparkline(state: RenderState, element: dict) -> None: @element("diagram") def diagram(state: RenderState, element: dict) -> None: + require(element, ["x", "y", "height"], "diagram") draw = ImageDraw.Draw(state.img) draw.fontmode = "1" pos_x, pos_y = element["x"], element["y"] @@ -125,7 +126,11 @@ def diagram(state: RenderState, element: dict) -> None: font = state.context.font(element.get("font"), bars_cfg.get("legend_size", 10)) legend_color = state.context.color(bars_cfg.get("legend_color", "black")) bar_color = state.context.color(bars_cfg["color"]) - max_val = max(int(b.split(",", 1)[1]) for b in bars) + max_val = max(float(b.split(",", 1)[1]) for b in bars) + if max_val <= 0: + raise RenderError( + f"diagram: bar values must include a positive maximum, got max {max_val} — check 'bars.values'." + ) height_factor = (height - offset) / max_val for bar_pos, bar in enumerate(bars): name, value = bar.split(",", 1) @@ -139,7 +144,7 @@ def diagram(state: RenderState, element: dict) -> None: ) draw.rectangle( [ - (x_pos, pos_y + height - offset - (height_factor * int(value))), + (x_pos, pos_y + height - offset - (height_factor * float(value))), (x_pos + bar_width, pos_y + height - offset), ], fill=bar_color, @@ -199,7 +204,7 @@ def plot(state: RenderState, element: dict) -> None: x_start = element.get("x_start", 0) y_start = element.get("y_start", 0) x_end = element.get("x_end", state.canvas_width - 1 - x_start) - y_end = element.get("y_end", state.canvas_height - 1 - x_start) + y_end = element.get("y_end", state.canvas_height - 1 - y_start) width = x_end - x_start + 1 height = y_end - y_start + 1 duration = timedelta(seconds=element.get("duration", 60 * 60 * 24)) @@ -251,10 +256,13 @@ def plot(state: RenderState, element: dict) -> None: if p["entity"] not in all_states: raise RenderError("no recorded data found for " + p["entity"]) states = all_states[p["entity"]] - state_obj = states[0] - states[0] = {"state": state_obj.state, "last_changed": str(state_obj.last_changed)} + # The provider yields the first sample as a State-like object (with + # .state/.last_changed attrs) and the rest as dicts; normalize the head + # without mutating the caller's list. + head = states[0] + normalized = [{"state": head.state, "last_changed": str(head.last_changed)}, *states[1:]] states = [ - (datetime.fromisoformat(s["last_changed"]), float(s["state"])) for s in states if is_decimal(s["state"]) + (datetime.fromisoformat(s["last_changed"]), float(s["state"])) for s in normalized if is_decimal(s["state"]) ] lo, hi = min_max([s[1] for s in states]) min_v = min(min_v or lo, lo) diff --git a/src/imagespec/elements/codes.py b/src/imagespec/elements/codes.py index 5ab0be0..aa6d30d 100644 --- a/src/imagespec/elements/codes.py +++ b/src/imagespec/elements/codes.py @@ -1,8 +1,8 @@ -"""Machine-readable codes: qrcode. +"""Machine-readable codes: qrcode, barcode, datamatrix. Merged behaviour: keeps gicisky's defaults while adding niimbot's ``eclevel`` -(error-correction level) option. ``barcode`` and ``datamatrix`` are listed in -the README porting checklist. +(error-correction level) option on ``qrcode``. ``datamatrix`` needs the optional +``pyStrich`` dependency (``imagespec[datamatrix]``). """ from __future__ import annotations @@ -27,8 +27,32 @@ } +def _fit_square_code(img: Image.Image, width, height) -> Image.Image: + """Scale a square 2D code (QR/DataMatrix) to fit a ``width``×``height`` box. + + Square aspect is preserved (the code fits *within* the box). NEAREST keeps + modules crisp (pure 2-color); when enlarging, an integer scale factor is used + so every module stays the same size — important for scanner reliability. + """ + if width is None and height is None: + return img + target = min(int(v) for v in (width, height) if v is not None) + side = img.size[0] + if target == side: + return img + if target > side: + scale = target // side # integer upscale -> uniform modules, fits the box + return img.resize((side * scale, side * scale), Image.NEAREST) + return img.resize((target, target), Image.NEAREST) # shrink to fit + + @element("qrcode") def qrcode_element(state: RenderState, element: dict) -> None: + """QR code. + + Size by either ``boxsize`` (pixels per module) or, for predictable layout, a + pixel ``width``/``height`` box — the code is scaled (square, crisp) to fit it. + """ require(element, ["x", "y", "data"], "qrcode") data = str(element["data"]) pos_x = element["x"] @@ -51,16 +75,29 @@ def qrcode_element(state: RenderState, element: dict) -> None: fill = state.context.color(color) back = state.context.color(bgcolor) imgqr = qr.make_image(fill_color=fill, back_color=back).convert("RGBA") + imgqr = _fit_square_code(imgqr, element.get("width"), element.get("height")) state.img.paste(imgqr, (pos_x, pos_y), imgqr) +def _hex(rgba) -> str: + """RGBA tuple -> ``#rrggbb`` string (what python-barcode's writer expects).""" + r, g, b = (rgba or (0, 0, 0, 255))[:3] + return f"#{r:02x}{g:02x}{b:02x}" + + @element("barcode") def barcode(state: RenderState, element: dict) -> None: + """Linear barcode. + + Sizing: the easy path is pixel-based — give ``width`` and/or ``height`` and + the barcode is scaled to fit that box at ``(x, y)`` (predictable layout, like + ``dlimg``/``icon``). Without them it falls back to python-barcode's physical + millimetre options (``module_width``/``module_height``/``quiet_zone`` at + ``dpi``), whose pixel size depends on the DPI and is harder to place. + """ require(element, ["x", "y", "data"], "barcode") data = str(element["data"]) pos_x, pos_y = element["x"], element["y"] - color = element.get("color", "black") - bgcolor = element.get("bgcolor", "white") code = element.get("code", "code128") options = { "module_width": float(element.get("module_width", 0.2)), @@ -68,9 +105,10 @@ def barcode(state: RenderState, element: dict) -> None: "quiet_zone": float(element.get("quiet_zone", 6.5)), "font_size": int(element.get("font_size", 5)), "text_distance": float(element.get("text_distance", 5.0)), - "background": bgcolor, - "foreground": color, + "background": _hex(state.context.color(element.get("bgcolor", "white"))), + "foreground": _hex(state.context.color(element.get("color", "black"))), "write_text": element.get("write_text", True), + "dpi": int(element.get("dpi", 300)), } try: barcode_format = barcode_lib.get_barcode_class(code) @@ -83,11 +121,31 @@ def barcode(state: RenderState, element: dict) -> None: raise RenderError(f"barcode: cannot encode {data!r} as {code}: {exc}") from exc buffer.seek(0) imagebc = Image.open(buffer).convert("RGBA") + + # Pixel-exact sizing: scale the native render to fit `width`/`height` (NEAREST + # keeps bars pure black/white — no gray edges to dither into noise). + target_w = element.get("width") + target_h = element.get("height") + if target_w is not None or target_h is not None: + w0, h0 = imagebc.size + if target_w is not None and target_h is not None: + new_size = (int(target_w), int(target_h)) + elif target_w is not None: + new_size = (int(target_w), max(1, round(h0 * (int(target_w) / w0)))) + else: + new_size = (max(1, round(w0 * (int(target_h) / h0))), int(target_h)) + imagebc = imagebc.resize(new_size, Image.NEAREST) + state.img.paste(imagebc, (pos_x, pos_y), imagebc) @element("datamatrix") def datamatrix(state: RenderState, element: dict) -> None: + """DataMatrix 2D code. + + Size by ``boxsize`` (pixels per cell) or a pixel ``width``/``height`` box — + like ``qrcode``, the code is scaled (square, crisp) to fit it. + """ require(element, ["x", "y", "data"], "datamatrix") try: from pystrich.datamatrix import DataMatrixEncoder @@ -111,4 +169,5 @@ def datamatrix(state: RenderState, element: dict) -> None: new_data = [target_color if px[0] < 128 else target_bg for px in dm_image.getdata()] dm_image.putdata(new_data) + dm_image = _fit_square_code(dm_image, element.get("width"), element.get("height")) state.img.paste(dm_image, (pos_x, pos_y), dm_image) diff --git a/src/imagespec/elements/layout.py b/src/imagespec/elements/layout.py index 74c7369..7d39728 100644 --- a/src/imagespec/elements/layout.py +++ b/src/imagespec/elements/layout.py @@ -8,15 +8,13 @@ from __future__ import annotations -import logging - from PIL import Image -from ..registry import element, get_handler +from ..dispatch import render_element +from ..exceptions import RenderError +from ..registry import element from ..state import RenderState -from ..utils import require, should_show - -_LOGGER = logging.getLogger(__name__) +from ..utils import require @element("group") @@ -31,16 +29,16 @@ def group(state: RenderState, element: dict) -> None: sub = Image.new("RGBA", (gw, gh), (0, 0, 0, 0)) substate = RenderState(img=sub, canvas_width=gw, canvas_height=gh, context=state.context) - for child in element["elements"]: + for idx, child in enumerate(element["elements"]): if not isinstance(child, dict): continue - if not should_show(child): - continue - handler = get_handler(child.get("type", "")) - if handler is None: - _LOGGER.warning("group: unknown element type '%s' — skipping.", child.get("type")) - continue - handler(substate, child) + ctype = child.get("type", "") + try: + render_element(substate, child) + except RenderError: + raise # already descriptive + except Exception as exc: # noqa: BLE001 — add child context, then surface + raise RenderError(f"group: error rendering child #{idx} (type '{ctype}'): {exc}") from exc result = substate.img if rotate in (90, 180, 270): diff --git a/src/imagespec/elements/media.py b/src/imagespec/elements/media.py index 204f927..26a3cca 100644 --- a/src/imagespec/elements/media.py +++ b/src/imagespec/elements/media.py @@ -136,14 +136,14 @@ def dlimg(state: RenderState, element: dict) -> None: rotate2 = element.get("rotate", 0) fit_mode = element.get("mode", "stretch") - if "http://" in url or "https://" in url: + if url.startswith(("http://", "https://")): try: response = requests.get(url, timeout=element.get("timeout", 30)) response.raise_for_status() except requests.RequestException as exc: raise RenderError(f"dlimg: failed to fetch {url}: {exc}") from exc imgdl = Image.open(io.BytesIO(response.content)) - elif "data:" in url: + elif url.startswith("data:"): s = url[5:] if not s or "," not in s: raise RenderError("dlimg: invalid data url") @@ -171,13 +171,9 @@ def dlimg(state: RenderState, element: dict) -> None: imgdl = imgdl.rotate(-rotate2, expand=True) imgdl = _resize_image(imgdl, xsize, ysize, fit_mode).convert("RGBA") - # Optional Floyd–Steinberg dithering to the device palette (keeps alpha). - if element.get("dither"): - from ..dither import dither_to_palette - - alpha = imgdl.split()[-1] - imgdl = dither_to_palette(imgdl, state.context.palette).convert("RGBA") - imgdl.putalpha(alpha) + # `dither` is handled generically by the render dispatcher (it isolates and + # quantizes any element carrying a `dither` flag), so there is nothing + # image-specific to do here. # Optional circular crop (avatar style). if element.get("mask") == "circle" or element.get("circle"): diff --git a/src/imagespec/elements/widgets.py b/src/imagespec/elements/widgets.py new file mode 100644 index 0000000..ad43016 --- /dev/null +++ b/src/imagespec/elements/widgets.py @@ -0,0 +1,155 @@ +"""Small composite widgets: legend, star_rating, battery. + +These fill gaps the primitive set didn't cover well: + +* ``legend`` — color-swatch ↔ label rows, the companion ``pie``/``plot`` lack + (``pie`` parses segment labels but only a legend actually draws them). +* ``star_rating`` — filled/half/empty stars for review or rating labels. +* ``battery`` — a vector battery gauge (proportional fill) for ESL status. + +All are built from existing primitives/icons — no new dependencies. +""" + +from __future__ import annotations + +from PIL import ImageDraw + +from ..exceptions import RenderError +from ..registry import element +from ..state import RenderState +from ..utils import require + + +def _parse_legend_items(items): + """Normalize ``items`` to a list of ``(label, color, icon)`` triples. + + Accepts a list of dicts (``{"label": ..., "color": ..., "icon": ...}``) or a + ``"label,color;label,color"`` string (matching the ``pie`` value syntax). + """ + out = [] + if isinstance(items, str): + for part in items.split(";"): + if not part.strip(): + continue + bits = [b.strip() for b in part.split(",")] + if len(bits) < 2: + raise RenderError(f"legend: bad item {part!r} — expected 'label,color'") + out.append((bits[0], bits[1], None)) + return out + for it in items: + out.append((str(it.get("label", "")), it.get("color", "black"), it.get("icon"))) + return out + + +@element("legend") +def legend(state: RenderState, element: dict) -> None: + """Draw a color-swatch legend (vertical or horizontal).""" + require(element, ["x", "y", "items"], "legend") + items = _parse_legend_items(element["items"]) + x, y = element["x"], element["y"] + size = element.get("size", 12) + swatch = element.get("swatch_size", size) + gap = element.get("gap", 6) + spacing = element.get("spacing", 4) + orientation = element.get("orientation", "vertical") + shape = element.get("shape", "square") + text_color = state.context.color(element.get("color", "black")) + font = state.context.font(element.get("font"), size) + + d = ImageDraw.Draw(state.img) + d.fontmode = "1" + line_h = max(swatch, size) + cursor_x, cursor_y = x, y + for label, color, icon in items: + cy = cursor_y + line_h / 2 + col = state.context.color(color) + if icon is not None: + from .media import mdi_char, mdi_font + + glyph = mdi_char(icon, state.context.icons_dir) + d.text((cursor_x, cy), glyph, font=mdi_font(state.context.icons_dir, swatch), fill=col, anchor="lm") + elif shape == "circle": + d.ellipse([(cursor_x, cy - swatch / 2), (cursor_x + swatch, cy + swatch / 2)], fill=col) + elif shape == "line": + d.line([(cursor_x, cy), (cursor_x + swatch, cy)], fill=col, width=max(2, swatch // 3)) + else: # square + d.rectangle([(cursor_x, cy - swatch / 2), (cursor_x + swatch, cy + swatch / 2)], fill=col) + label_x = cursor_x + swatch + gap + d.text((label_x, cy), label, font=font, fill=text_color, anchor="lm") + if orientation == "horizontal": + cursor_x = label_x + font.getlength(label) + spacing + size + else: + cursor_y += line_h + spacing + + +@element("star_rating") +def star_rating(state: RenderState, element: dict) -> None: + """Render ``rating`` of ``max`` stars (full / optional half / empty).""" + require(element, ["x", "y", "rating"], "star_rating") + from .media import mdi_char, mdi_font + + rating = float(element["rating"]) + max_stars = int(element.get("max", 5)) + size = element.get("size", 16) + spacing = element.get("spacing", 2) + use_half = element.get("half", True) + fill = state.context.color(element.get("color", "orange")) + empty_fill = state.context.color(element.get("empty_color", element.get("color", "orange"))) + + font = mdi_font(state.context.icons_dir, size) + d = ImageDraw.Draw(state.img) + d.fontmode = "1" + x, y = element["x"], element["y"] + for i in range(max_stars): + threshold = i + 1 + if rating >= threshold: + name, col = "mdi:star", fill + elif use_half and rating >= threshold - 0.5: + name, col = "mdi:star-half-full", fill + else: + name, col = "mdi:star-outline", empty_fill + glyph = mdi_char(name, state.context.icons_dir) + d.text((x, y), glyph, font=font, fill=col, anchor="la") + x += size + spacing + + +@element("battery") +def battery(state: RenderState, element: dict) -> None: + """Vector battery gauge with a proportional fill and terminal nub.""" + require(element, ["x", "y", "width", "height", "level"], "battery") + x, y = element["x"], element["y"] + w, h = element["width"], element["height"] + level = max(0.0, min(100.0, float(element["level"]))) + outline = state.context.color(element.get("outline", "black")) + bg = state.context.color(element.get("background", "white")) + fill = state.context.color(element.get("fill", "black")) + border = element.get("width_outline", 1) + radius = element.get("radius", 2) + pad = element.get("padding", 2) + + low_threshold = element.get("low_threshold", 20) + if "low_color" in element and level <= low_threshold: + fill = state.context.color(element["low_color"]) + + nub_w = element.get("nub_width", max(2, w // 12)) + nub_h = element.get("nub_height", max(2, h // 2)) + body_w = w - nub_w + + d = ImageDraw.Draw(state.img) + d.rounded_rectangle([(x, y), (x + body_w, y + h)], fill=bg, outline=outline, width=border, radius=radius) + d.rectangle([(x + body_w, y + (h - nub_h) // 2), (x + w, y + (h + nub_h) // 2)], fill=outline) + + inner_w = body_w - 2 * pad + fill_w = inner_w * (level / 100) + if fill_w > 0: + d.rounded_rectangle([(x + pad, y + pad), (x + pad + fill_w, y + h - pad)], fill=fill, radius=max(0, radius - 1)) + + if element.get("show_percentage", False): + d.fontmode = "1" + font = state.context.font(element.get("font"), element.get("size", max(6, h - 2 * pad - 2))) + text = f"{int(round(level))}%" + tb = d.textbbox((0, 0), text, font=font) + tx = x + (body_w - (tb[2] - tb[0])) / 2 + ty = y + (h - (tb[3] - tb[1])) / 2 + # use the background color over the filled portion for contrast + d.text((tx, ty), text, font=font, fill=state.context.color(element.get("text_color", "white")), anchor="lt") diff --git a/tests/test_colors.py b/tests/test_colors.py index e32bd9d..f8ef2fb 100644 --- a/tests/test_colors.py +++ b/tests/test_colors.py @@ -30,6 +30,17 @@ def test_unknown_name_falls_back_white(): assert quantize_color("chartreuse", PALETTE_4) == white +def test_shorthand_hex_3digit(): + # #f00 should expand to #ff0000 -> red on a 4-color device + assert quantize_color("#f00", PALETTE_4) == red + assert quantize_color("#fff", PALETTE_BW) == white + assert quantize_color("#000", PALETTE_BW) == black + + +def test_get_palette_accepts_3digit_hex(): + assert get_palette(["#000", "#fff", "#f00"]) == [black, white, red] + + def test_get_palette_from_names(): assert get_palette(["black", "white", "red"]) == [black, white, red] diff --git a/tests/test_dither.py b/tests/test_dither.py index 6ef780c..9fbaa8c 100644 --- a/tests/test_dither.py +++ b/tests/test_dither.py @@ -27,3 +27,133 @@ def test_render_dither_param_smoke(): ctx = RenderContext(palette="bw") img = render([], 20, 20, background="white", dither=True, context=ctx) assert img.mode == "RGB" and img.size == (20, 20) + + +# --- Palette mapping is deferred to the end; `dither` picks halftone vs flat --- + +# orange fill, no outline — keeps the interior a single authored color +_ORANGE_FILL = { + "type": "rectangle", + "x_start": 0, + "y_start": 0, + "x_end": 39, + "y_end": 39, + "fill": "orange", + "outline": None, +} + + +def _colors(img): + return {c for _, c in img.getcolors(maxcolors=1 << 24)} + + +def _interior(img): + return {img.getpixel((x, y)) for x in range(5, 35) for y in range(5, 35)} + + +def test_dither_true_halftones_offpalette_fill(): + # orange on a black/white device with dither => a black/white halftone + ctx = RenderContext(palette="bw") + img = render([_ORANGE_FILL], 40, 40, background="white", dither=True, context=ctx) + assert _interior(img) == {(0, 0, 0), (255, 255, 255)} # mixed dots = halftone + + +def test_dither_false_is_flat_nearest(): + # no dithering: a flat nearest-color fill (single solid palette color) + ctx = RenderContext(palette="bw") + img = render([_ORANGE_FILL], 40, 40, background="white", dither=False, context=ctx) + interior = _interior(img) + assert len(interior) == 1 and interior <= {(0, 0, 0), (255, 255, 255)} + + +def test_output_always_on_palette_even_without_dither(): + # off-palette fill + off-palette text, no dither -> still strictly on-palette + ctx = RenderContext(palette="bw") + els = [_ORANGE_FILL, {"type": "text", "x": 2, "y": 2, "value": "Hi", "size": 14, "color": "red"}] + img = render(els, 60, 40, background="white", dither=False, context=ctx) + assert _colors(img) <= {(0, 0, 0), (255, 255, 255)} + + +def test_inpalette_text_crisp_under_dither(): + # black text on white stays crisp under dithering (both are palette colors, + # so Floyd–Steinberg diffuses no error — no edge noise). + ctx = RenderContext(palette="bw") + el = {"type": "text", "x": 2, "y": 2, "value": "Hi", "size": 16, "color": "black"} + img = render([el], 60, 24, background="white", dither=True, context=ctx) + cols = _colors(img) + assert cols <= {(0, 0, 0), (255, 255, 255)} + assert (0, 0, 0) in cols # text actually drawn + + +# --- Per-element `dither` override (any element; defaults to the global flag) --- + + +def test_per_element_dither_true_overrides_global_off(): + # global dithering off, but this fill opts in -> it alone becomes a halftone + ctx = RenderContext(palette="bw") + el = dict(_ORANGE_FILL, dither=True) + img = render([el], 40, 40, background="white", dither=False, context=ctx) + assert _interior(img) == {(0, 0, 0), (255, 255, 255)} + + +def test_per_element_dither_false_overrides_global_on(): + # global dithering on, but this fill opts out -> it stays a flat solid color + ctx = RenderContext(palette="bw") + el = dict(_ORANGE_FILL, dither=False) + img = render([el], 40, 40, background="white", dither=True, context=ctx) + assert len(_interior(img)) == 1 + + +def test_per_element_dither_is_selective_within_one_render(): + # one dithered fill (left) + one flat fill (right) in the same image + ctx = RenderContext(palette="bw") + left = { + "type": "rectangle", + "x_start": 0, + "y_start": 0, + "x_end": 19, + "y_end": 39, + "fill": "orange", + "outline": None, + "dither": True, + } + right = { + "type": "rectangle", + "x_start": 20, + "y_start": 0, + "x_end": 39, + "y_end": 39, + "fill": "orange", + "outline": None, + "dither": False, + } + img = render([left, right], 40, 40, background="white", dither=False, context=ctx) + left_region = {img.getpixel((x, y)) for x in range(2, 18) for y in range(5, 35)} + right_region = {img.getpixel((x, y)) for x in range(22, 38) for y in range(5, 35)} + assert left_region == {(0, 0, 0), (255, 255, 255)} # halftone + assert len(right_region) == 1 # flat + + +def test_per_element_dither_works_inside_group(): + ctx = RenderContext(palette="bw") + grp = { + "type": "group", + "x": 0, + "y": 0, + "width": 40, + "height": 40, + "elements": [ + { + "type": "rectangle", + "x_start": 0, + "y_start": 0, + "x_end": 39, + "y_end": 39, + "fill": "orange", + "outline": None, + "dither": True, + } + ], + } + img = render([grp], 40, 40, background="white", dither=False, context=ctx) + assert _interior(img) == {(0, 0, 0), (255, 255, 255)} diff --git a/tests/test_elements.py b/tests/test_elements.py index ab44a53..9193486 100644 --- a/tests/test_elements.py +++ b/tests/test_elements.py @@ -117,6 +117,15 @@ def _samples(data_url): {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": 20, "y_end": 20, "outline": "black"} ], }, + "legend": { + "type": "legend", + "x": 0, + "y": 0, + "items": [{"label": "a", "color": "black"}, {"label": "b", "color": "red"}], + "size": 8, + }, + "star_rating": {"type": "star_rating", "x": 0, "y": 0, "rating": 3.5, "max": 5, "size": 8}, + "battery": {"type": "battery", "x": 0, "y": 0, "width": 30, "height": 14, "level": 60}, } diff --git a/tests/test_errors.py b/tests/test_errors.py index ce02bf6..862eea4 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -80,3 +80,48 @@ def test_plot_without_history_provider_raises(ctx): el = {"type": "plot", "data": [{"entity": "sensor.x"}]} with pytest.raises(RenderError): render([el], 40, 40, context=ctx) + + +def test_diagram_missing_required_args(ctx): + el = {"type": "diagram", "x": 0, "bars": {"values": "a,1", "color": "black"}} + with pytest.raises(RenderError) as exc: + render([el], 40, 40, context=ctx) + assert "height" in str(exc.value) + + +def test_diagram_accepts_float_bar_values(ctx): + el = { + "type": "diagram", + "x": 0, + "y": 0, + "height": 35, + "width": 60, + "bars": {"values": "a,1.5;b,2.5", "color": "black", "margin": 4}, + } + assert render([el], 80, 40, context=ctx).size == (80, 40) + + +def test_diagram_nonpositive_max_raises(ctx): + el = { + "type": "diagram", + "x": 0, + "y": 0, + "height": 35, + "width": 60, + "bars": {"values": "a,0;b,0", "color": "black", "margin": 4}, + } + with pytest.raises(RenderError): + render([el], 80, 40, context=ctx) + + +def test_group_child_error_wrapped_with_context(ctx): + el = { + "type": "group", + "x": 0, + "y": 0, + "elements": [{"type": "rectangle", "x_start": "oops", "y_start": 0, "x_end": 9, "y_end": 9}], + } + with pytest.raises(RenderError) as exc: + render([el], 20, 20, context=ctx) + msg = str(exc.value) + assert "group" in msg and "rectangle" in msg diff --git a/tests/test_features.py b/tests/test_features.py index e70ce9c..94e3a2e 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -95,3 +95,109 @@ def test_sparkline_renders(ctx): "dot_last": True, } assert render([el], 60, 30, context=ctx).size == (60, 30) + + +def test_legend_draws_swatches(bw_ctx): + el = { + "type": "legend", + "x": 0, + "y": 0, + "items": [{"label": "a", "color": "black"}], + "size": 10, + "swatch_size": 10, + } + img = render([el], 40, 20, background="white", context=bw_ctx) + # the swatch square (top-left) should be black + assert img.getpixel((5, 5)) == (0, 0, 0) + + +def test_legend_accepts_string_items(ctx): + el = {"type": "legend", "x": 0, "y": 0, "items": "gas,red;water,blue", "size": 8} + assert render([el], 60, 30, context=ctx).size == (60, 30) + + +def test_star_rating_partial(ctx): + el = {"type": "star_rating", "x": 0, "y": 0, "rating": 3, "max": 5, "size": 12, "color": "red"} + img = render([el], 70, 16, background="white", context=ctx) + # at least some star pixels are drawn (red on a 4-color device stays red) + assert any(img.getpixel((x, y)) == (255, 0, 0) for x in range(70) for y in range(16)) + + +def test_battery_fill_proportional(bw_ctx): + el = { + "type": "battery", + "x": 0, + "y": 0, + "width": 40, + "height": 16, + "level": 50, + "fill": "black", + "background": "white", + "padding": 2, + } + img = render([el], 50, 20, background="white", context=bw_ctx) + # left half (filled) has black; far right interior (empty) stays white + assert img.getpixel((6, 8)) == (0, 0, 0) + assert img.getpixel((30, 8)) == (255, 255, 255) + + +def test_battery_low_color(ctx): + el = { + "type": "battery", + "x": 0, + "y": 0, + "width": 40, + "height": 16, + "level": 10, + "low_threshold": 20, + "low_color": "red", + "padding": 2, + } + img = render([el], 50, 20, background="white", context=ctx) + assert any(img.getpixel((x, 8)) == (255, 0, 0) for x in range(40)) + + +def test_barcode_pixel_sizing_fits_box(bw_ctx): + # width/height scale the barcode into a pixel box at (x, y) + el = { + "type": "barcode", + "x": 10, + "y": 10, + "data": "12345670", + "code": "code128", + "width": 120, + "height": 40, + "write_text": False, + } + img = render([el], 150, 70, background="white", context=bw_ctx) + black = [(x, y) for y in range(70) for x in range(150) if img.getpixel((x, y)) == (0, 0, 0)] + xs = [p[0] for p in black] + ys = [p[1] for p in black] + assert black # bars drawn + # stays within the requested box (x: 10..130, y: 10..50), pure black/white + assert min(xs) >= 10 and max(xs) <= 130 + assert min(ys) >= 10 and max(ys) <= 50 + assert {c for _, c in img.getcolors(maxcolors=1 << 24)} <= {(0, 0, 0), (255, 255, 255)} + + +def test_barcode_width_controls_extent(bw_ctx): + # a wider target box yields a wider barcode + def extent(w): + el = {"type": "barcode", "x": 5, "y": 5, "data": "9876543", "width": w, "height": 30, "write_text": False} + img = render([el], w + 60, 50, background="white", context=bw_ctx) + xs = [x for y in range(50) for x in range(w + 60) if img.getpixel((x, y)) == (0, 0, 0)] + return max(xs) - min(xs) + + assert extent(160) > extent(90) + + +def test_qrcode_pixel_box_fits_and_square(bw_ctx): + el = {"type": "qrcode", "x": 5, "y": 5, "data": "https://example.com/p/1", "width": 60, "height": 60} + img = render([el], 80, 80, background="white", context=bw_ctx) + black = [(x, y) for y in range(80) for x in range(80) if img.getpixel((x, y)) == (0, 0, 0)] + xs = [p[0] for p in black] + ys = [p[1] for p in black] + assert black + # fits within the 5..65 box on both axes and is (near-)square + assert max(xs) <= 65 and max(ys) <= 65 + assert abs((max(xs) - min(xs)) - (max(ys) - min(ys))) <= 1 diff --git a/tests/test_plot.py b/tests/test_plot.py index b7ce430..2a74592 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -27,6 +27,22 @@ def test_plot_multiple_series(history_ctx): assert render([el], 200, 100, context=history_ctx).size == (200, 100) +def test_plot_default_y_end_uses_y_start_not_x_start(history_ctx): + # Regression: the default y_end must derive from y_start, not x_start. A + # large x_start used to wrongly collapse the plot's vertical extent. + el = { + "type": "plot", + "x_start": 60, + "data": [{"entity": "sensor.temp", "color": "red"}], + "ylegend": None, + "yaxis": None, + } + img = render([el], 200, 100, context=history_ctx) + # with a full-height plot the line reaches into the lower half of the canvas + lower_half_drawn = any(img.getpixel((x, y)) != (255, 255, 255) for y in range(60, 100) for x in range(60, 140)) + assert lower_half_drawn + + def test_plot_missing_entity_data_raises(history_ctx): # provider returns data only for requested ids; ask handler to require a # series with no data by monkeypatching the provider to an empty dict. From 8c72cc87a65fa62360579bfb53919c208b20c983 Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 30 Jun 2026 08:39:31 +0900 Subject: [PATCH 2/4] ci: bump actions/checkout to v5 (Node 24) actions/checkout@v4 runs on the deprecated Node.js 20 runtime; v5 targets Node.js 24, clearing the deprecation warnings. The other actions (setup-python@v6, upload/download-artifact@v5) are already on Node 24. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30c6405..6098e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-python@v6 @@ -32,7 +32,7 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-python@v6 @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest needs: [lint, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-python@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb82bd4..f7fd138 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-python@v6 From 939fc8b4f7b9000dbc4a4a0433b667368b12b59a Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 30 Jun 2026 08:41:12 +0900 Subject: [PATCH 3/4] chore: bump version to 0.2.0 Feature release: new widget elements (legend/star_rating/battery), deferred palette mapping with per-element dither override, and pixel-based sizing for barcode/qrcode/datamatrix. Includes a behavior change (render output is now always palette-mapped), warranting a minor bump. Updates the version in pyproject.toml and the illustrative pins in README / docs/migration.md, and refreshes the test count (97). Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++--- docs/migration.md | 6 +++--- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cf182cc..781951e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ standalone. ## Status -✅ **29 elements** (21 ported + 8 new) rendering, with an 86-test suite. +✅ **29 elements** (21 ported + 8 new) rendering, with a 97-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. @@ -83,7 +83,7 @@ python examples/smoke_test.py ```bash pip install -e ".[dev,datamatrix]" -pytest # 86 tests: every element, palettes, rotation, dither, errors +pytest # 97 tests: every element, palettes, rotation, dither, errors ruff check . && ruff format --check . # lint + format python -m build # build sdist + wheel (bundles fonts/icons) ``` @@ -421,5 +421,5 @@ Replace each component's renderer with a thin adapter (see [`docs/migration.md`](docs/migration.md)) and add to `manifest.json`: ```json -"requirements": ["imagespec==0.1.0"] +"requirements": ["imagespec==0.2.0"] ``` diff --git a/docs/migration.md b/docs/migration.md index 379aad8..33afd8c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -20,14 +20,14 @@ element types are warned-and-skipped rather than raising. ```json "requirements": [ - "imagespec[datamatrix] @ git+https://github.com/eigger/imagespec.git@v0.1.0" + "imagespec[datamatrix] @ git+https://github.com/eigger/imagespec.git@v0.2.0" ] ``` - The `[datamatrix]` extra pulls in `pyStrich` (only needed for the `datamatrix` element); drop it if a component never uses datamatrix. -- Pin to a **tag** (`@v0.1.0`), not a branch, so installs are reproducible. -- If/when published to PyPI, this simplifies to `"imagespec[datamatrix]==0.1.0"`. +- Pin to a **tag** (`@v0.2.0`), not a branch, so installs are reproducible. +- If/when published to PyPI, this simplifies to `"imagespec[datamatrix]==0.2.0"`. **Remove the now-duplicated deps.** `qrcode[pil]`, `python-barcode` and `pyStrich` become transitive deps of `imagespec`, so delete them from each component's diff --git a/pyproject.toml b/pyproject.toml index bd3c76d..9365af7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "imagespec" -version = "0.1.1" +version = "0.2.0" description = "Render images from a declarative YAML/dict spec — shapes, text, charts, QR/barcodes — for e-paper ESL tags and label printers." readme = "README.md" requires-python = ">=3.11" From 963bc6bb852b2501738b1ad6ec614af7a2b084bd Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 30 Jun 2026 08:44:52 +0900 Subject: [PATCH 4/4] ci: move artifact actions to Node 24 majors (upload v7, download v8) upload-artifact@v5 and download-artifact@v5 still run on the deprecated Node.js 20 runtime (verified via their action.yml `using:` field). The first Node 24 majors are upload-artifact@v7 and download-artifact@v8; bump to those to clear the remaining deprecation warnings. checkout@v5 and setup-python@v6 are already on Node 24. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6098e21..2930ea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: print("MISSING from wheel:", missing); sys.exit(1) print("All bundled assets present in", wheel) PY - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7fd138..c7351df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - run: pip install build twine - run: python -m build - run: twine check dist/* - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/* @@ -37,7 +37,7 @@ jobs: permissions: id-token: write # required for trusted publishing steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v8 with: name: dist path: dist