diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30c6405..2930ea9 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 @@ -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 cb82bd4..c7351df 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 @@ -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 diff --git a/README.md b/README.md index 63c0651..781951e 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 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. @@ -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 # 97 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 @@ -314,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/examples/dither_esl_3color.png b/examples/dither_esl_3color.png new file mode 100644 index 0000000..ca633a9 Binary files /dev/null and b/examples/dither_esl_3color.png differ diff --git a/examples/dither_label_2color.png b/examples/dither_label_2color.png new file mode 100644 index 0000000..ed81e42 Binary files /dev/null and b/examples/dither_label_2color.png differ diff --git a/examples/elements/battery.png b/examples/elements/battery.png new file mode 100644 index 0000000..1ba0503 Binary files /dev/null and b/examples/elements/battery.png differ diff --git a/examples/elements/diagram.png b/examples/elements/diagram.png index da74f2b..1683477 100644 Binary files a/examples/elements/diagram.png and b/examples/elements/diagram.png differ diff --git a/examples/elements/legend.png b/examples/elements/legend.png new file mode 100644 index 0000000..641b3d0 Binary files /dev/null and b/examples/elements/legend.png differ diff --git a/examples/elements/star_rating.png b/examples/elements/star_rating.png new file mode 100644 index 0000000..d047423 Binary files /dev/null and b/examples/elements/star_rating.png differ diff --git a/examples/generate_dither_labels.py b/examples/generate_dither_labels.py new file mode 100644 index 0000000..94f93f9 --- /dev/null +++ b/examples/generate_dither_labels.py @@ -0,0 +1,128 @@ +"""Generate dithering sample labels for the two target device classes: + +* Electronic shelf label (ESL) — 3-color black/white/red panel. +* Label printer — 2-color black/white. + +Both mix crisp content (text, QR, barcode) with charts in off-palette colors that +are dithered (``dither: true``) into halftone patterns, so chart segments stay +distinguishable on a limited-color device while text/codes stay sharp. + +Run from the repository root: + python examples/generate_dither_labels.py +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +from imagespec import RenderContext, render +from imagespec.colors import PALETTE_BW, PALETTE_BWR + + +def esl_3color(): + """A grocery shelf label on a 3-color (black/white/red) ESL panel, 296x128.""" + ctx = RenderContext(palette=PALETTE_BWR) + payload = [ + {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": 295, "y_end": 127, "outline": "black", "width": 2}, + # red header band + {"type": "rectangle", "x_start": 2, "y_start": 2, "x_end": 293, "y_end": 24, "fill": "red", "outline": None}, + {"type": "text", "x": 8, "y": 6, "value": "WEEKLY DEAL", "size": 15, "color": "white"}, + {"type": "text", "x": 200, "y": 7, "value": "Aisle 7", "size": 12, "color": "white"}, + # product + price + {"type": "text", "x": 8, "y": 30, "value": "Organic Coffee", "size": 18, "color": "black"}, + {"type": "text", "x": 8, "y": 52, "value": "Fair Trade · 500g", "size": 11, "color": "black"}, + {"type": "text", "x": 8, "y": 74, "value": "$12.99", "size": 34, "color": "red"}, + {"type": "text", "x": 8, "y": 112, "value": "$25.98 / kg", "size": 11, "color": "black"}, + # donut "flavor profile" in off-palette browns -> 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 c3c0319..2de5e63 100644 Binary files a/examples/showcase.png and b/examples/showcase.png differ 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" diff --git a/src/imagespec/colors.py b/src/imagespec/colors.py index 66ec05d..eb7b7a5 100644 --- a/src/imagespec/colors.py +++ b/src/imagespec/colors.py @@ -62,10 +62,23 @@ DEFAULT_PALETTE = PALETTE_4 +def _parse_hex(s): + """Parse a ``#RGB`` or ``#RRGGBB`` string to RGBA, or ``None`` if malformed.""" + h = s.lstrip("#") + try: + if len(h) == 3: # shorthand #RGB -> #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.