diff --git a/README.md b/README.md index a65384f..db925d9 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ 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 | +| | `stack` / `row` / `column` | layout | **new** — auto-layout: packs children along an axis (gap/padding/justify/align), flexbox-style, with optional Tailwind-like `class` shorthand | | ![](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 | @@ -153,6 +154,14 @@ element** can carry its own `dither: true`/`false` to override it just for itsel Payloads are specified as a list (sequence) of dictionary elements, which can be easily authored in YAML or JSON. Each element requires a `type` string and varying geometric/styling attributes. +> [!TIP] +> **Generating payloads with an LLM?** See [`docs/authoring.md`](docs/authoring.md) — a +> **self-contained authoring guide** you can paste straight into an AI's context. It +> covers the output contract, the layout decision model (`stack`/`row`/`column` vs +> `group` vs absolute coordinates), the Tailwind-like `class` shorthand, common +> pitfalls (e.g. the YAML "one key per line" trap), device canvas sizes, and full +> worked examples — every example verified by actually rendering it. + ### Common Attributes - **Colors**: Supported color specifications include names (e.g., `"black"`, `"white"`, `"red"`, `"green"`, `"blue"`, `"orange"`, `"yellow"`) or HEX strings (e.g., `"#FF0000"`). Colors are automatically quantized to the host device's palette. - **Coordinates**: Standard 2D cartesian coordinate system starting at `(0, 0)` at the top-left corner. @@ -275,6 +284,35 @@ Payloads are specified as a list (sequence) of dictionary elements, which can be - `rotate` (int degrees, optional), `timeout` (seconds, default: `30`) - `dither` (bool, optional), `mask` (`"circle"`, optional; or `circle: true`) +#### Layout / Auto-layout +- **`group`**: Container that renders children at an offset, clipped to its box, optionally rotated. + - `x`, `y` (int offset, default `0`), `width`, `height` (int clip box, default: canvas), `rotate` (`90`/`180`/`270`, optional) + - `elements` (list of child element dicts, required) — children use coordinates **relative to the group**. +- **`stack`** (aliases **`row`** = horizontal, **`column`** = vertical): *Auto-layout* container that **packs** its children along an axis so they need no explicit coordinates — the stack measures each child's drawn size and positions it. Purely additive: children are still drawn by their normal handlers, so **any element** can be a child, and absolute-coordinate payloads outside a stack are unaffected. + - `elements` (list of child element dicts, required) + - `direction` (`"vertical"` / `"horizontal"`, default: `"vertical"`; `row`/`column` set this) + - `gap` (int px between children, default: `0`) + - `padding` (int, all sides) or `padding_x`/`padding_y`/`padding_top`/`padding_right`/`padding_bottom`/`padding_left` + - `justify` (main-axis distribution: `"start"`, `"end"`, `"center"`, `"between"`, `"around"`, `"evenly"`, default: `"start"`) + - `align` (cross-axis item alignment: `"start"`, `"end"`, `"center"`, default: `"start"`) + - `x`, `y` (int offset of the whole stack, default `0`), `width`, `height` (int box, default: canvas), `rotate` (optional) + - **Per-child layout** via the child's own `class` string or a `layout: {...}` sub-dict (kept separate from the child's drawing keys so e.g. a `diagram`'s own `margin` is never confused for a layout margin): `grow` (int, share of leftover main-axis space), `self` (`"start"`/`"end"`/`"center"` cross-axis override), `margin`/`margin_x`/`margin_y`/`margin_` (int px). + - **`class` shorthand** (optional, Tailwind-like — desugars to the keys above; explicit keys win). Only the **layout** subset is recognized; styling/colour classes are ignored (those stay on each element's own keys). + - container: `flex-row` / `flex-col`, `gap-N`, `p-N` / `px-N` / `py-N` / `pt-N` / `pr-N` / `pb-N` / `pl-N`, `justify-start|end|center|between|around|evenly`, `items-start|end|center` + - child: `grow` / `flex-1` / `grow-N`, `self-start|end|center`, `m-N` / `mx-N` / `my-N` / `mt-N` / `mr-N` / `mb-N` / `ml-N` + - **Spacing scale = real Tailwind**, not raw pixels: a unit is `N × 4px` (`gap-2` → 8px, `p-4` → 16px, `mt-0.5` → 2px). For exact pixels use the *arbitrary value* form `gap-[10]` / `p-[3px]`. Margins may be **negative** (`-mt-2` → -8px, `-ml-[3]` → -3px) for fine nudges; padding/gap cannot. + + ```yaml + - type: row # horizontal auto-layout + x: 8 + y: 8 + class: "gap-2 items-center" # gap-2 = 8px, vertically centered + elements: + - { type: icon, value: "mdi:thermometer", size: 18, color: red } + - { type: text, value: "24°C", size: 16 } # no x/y needed + - { type: battery, width: 30, height: 14, level: 72, class: "ml-2" } + ``` + #### Widgets - **`legend`**: Draws color-swatch ↔ label rows (companion to `pie`/`plot`). - `x`, `y` (int top-left, required) @@ -347,11 +385,14 @@ and keep the rest flat (labels, QR codes), in a single render: ysize: 100 dither: true - type: pie # this chart -> halftone (segments stay distinguishable) - x: 60; y: 60; radius: 40 + 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 + x: 10 + y: 110 value: "Energy mix" ``` diff --git a/docs/authoring.md b/docs/authoring.md new file mode 100644 index 0000000..c9b8219 --- /dev/null +++ b/docs/authoring.md @@ -0,0 +1,282 @@ +# Authoring guide (for LLMs & humans) + +This document is **self-contained**: paste the whole file into an AI's context +(system prompt) and it has everything needed to generate valid, well-composed +`imagespec` payloads. It focuses on the **output contract**, the **layout +model**, and **common pitfalls**. For the exhaustive per-element key list, see +the *Element Reference* in [`../README.md`](../README.md); the most common +elements are also cheat-sheeted at the bottom here. + +--- + +## 1. Output contract (read this first) + +A payload is a **list (sequence) of element dicts** — author it as YAML or JSON. + +- Every element is a **dict (mapping)** with a `type` string and that type's keys. +- The list is drawn in order: **later elements paint on top of earlier ones** (z-order). +- It is rendered by: + ```python + from imagespec import render, RenderContext + ctx = RenderContext(palette="bwr") # device colors (see §4) + img = render(payload, width=296, height=128, background="white", context=ctx) + ``` +- **Coordinates are integer pixels.** Origin `(0, 0)` is the **top-left**; `x` + grows right, `y` grows down. +- Unknown `type`s are warned-and-skipped; a missing **required** key raises a + clear error naming the element. Keep to the documented keys. + +**Do NOT** invent CSS, HTML, percentages (except where a key documents them), +or styling not listed here. Output only the payload (YAML or JSON), nothing else, +unless asked. + +--- + +## 2. The #1 YAML pitfall — never put multiple keys on one line with `;` + +YAML is **one key per line**. This is the single most common mistake: + +```yaml +# ❌ BROKEN — YAML reads x as the string "10; y: 20", and y/size never exist +- type: text + x: 10; y: 20; size: 16 + value: "Hi" + +# ✅ CORRECT +- type: text + x: 10 + y: 20 + size: 16 + value: "Hi" + +# ✅ Also correct — JSON-style flow mapping on one line (note the commas + braces) +- { type: text, x: 10, y: 20, size: 16, value: "Hi" } +``` + +Semicolons are only valid **inside quoted string values** (some elements use +them as a data separator, e.g. `values: "Gas,30,orange;Water,25,blue"`). + +--- + +## 3. Layout: pick the right tool + +There are three ways to place things. Prefer the highest-level one that fits. + +| Need | Use | Why | +|---|---|---| +| Things flow in a **row or column**, evenly spaced | **`stack`** / `row` / `column` | Auto-layout: no manual coordinates, adapts to content size | +| A **reusable/relocatable cluster** placed by absolute offset, clipped | **`group`** | Children use coords relative to the group; one `x`/`y` moves the whole thing | +| **Precise, one-off** pixel placement | plain elements with `x`/`y` | Full manual control | + +**Default to `stack`/`row`/`column` for anything list-like** (labels stacked, +icon+value pairs, toolbars, key/value rows). It removes brittle hand-computed +coordinates and is the most robust for AI generation. + +### Inside a stack, children need no coordinates + +The stack measures each child and positions it. So **omit `x`/`y` on children** +— the stack fills them in. Set spacing with the stack's `gap`/`padding` and the +child's `margin`, not by hand-placing. + +```yaml +- type: column # vertical auto-layout + x: 8 # where the whole stack sits + y: 8 + gap: 4 # px between children + elements: + - { type: text, value: "Line 1", size: 16 } # no x/y + - { type: text, value: "Line 2", size: 16 } # auto-placed below +``` + +`row` = horizontal, `column` = vertical, `stack` = vertical unless +`direction: horizontal`. + +### Stack options (structured keys — the canonical form) + +Container: +- `direction`: `vertical` (default) | `horizontal` +- `gap`: int px between children +- `padding`: int (all sides), or `padding_x` / `padding_y` / `padding_top` / `padding_right` / `padding_bottom` / `padding_left` +- `justify` (main axis): `start` | `end` | `center` | `between` | `around` | `evenly` +- `align` (cross axis): `start` | `end` | `center` +- `x`, `y` (offset of the whole stack), `width`, `height` (box; default = canvas), `rotate` (90/180/270) + +Per child (set on the child dict, under a `layout:` sub-dict to avoid clashing +with the element's own keys): +- `layout: { grow: 1 }` — take a share of leftover main-axis space (push siblings apart) +- `layout: { align: center }` — override the container's cross-axis alignment for this child +- `layout: { margin: 4 }` or `margin_x` / `margin_top` / ... — extra space around this child (may be negative to nudge) + +### Optional Tailwind-like `class` shorthand + +Anywhere you'd set the structured keys above, you may instead use a `class` +string (it desugars to those keys; **explicit keys win** on conflict). Only the +**layout** subset is recognized — styling/color classes are ignored, so colors +and fonts always stay on the element's own keys. + +- container: `flex-row` / `flex-col`, `gap-N`, `p-N` / `px-N` / `py-N` / `pt-N` / `pr-N` / `pb-N` / `pl-N`, `justify-*`, `items-*` +- child: `grow` / `flex-1` / `grow-N`, `self-start|end|center`, `m-N` / `mx-N` / `my-N` / `mt-N` / `mr-N` / `mb-N` / `ml-N` + +**Spacing scale = real Tailwind:** a unit is `N × 4px` → `gap-2` = 8px, +`p-4` = 16px, `mt-0.5` = 2px. For **exact pixels** use the arbitrary form +`gap-[10]` / `p-[3px]`. Margins may be **negative** for fine nudges +(`-mt-2` = -8px, `-ml-[3]` = -3px); padding/gap cannot. + +```yaml +- type: row + x: 8 + y: 8 + class: "gap-2 items-center" # 8px gap, vertically centered + elements: + - { type: icon, value: "mdi:fire", size: 18, color: red } + - { type: text, value: "24°C", size: 16 } + - { type: battery, width: 30, height: 14, level: 72, class: "ml-2" } +``` + +--- + +## 4. Colors & device palette + +- `RenderContext(palette=...)` is the **device's** color set. Common shorthands: + `"bw"`/`"2"` (black/white), `"bwr"`/`"3"` (black/white/red), `"4"`, `"7"`/`"acep"`. + You can also pass an explicit list: `["black", "white", "red"]`. +- You author colors freely (names like `"black"`, `"red"`, `"blue"`, or `#RRGGBB`); + the final image is **quantized to the palette**. On a 2-color panel `red` + becomes black. **Design within the device's palette** — don't rely on colors it + can't show. +- `dither: true` (whole render, or per element) turns off-palette fills into + halftone dot patterns so e.g. pie/bar segments stay distinguishable. **Avoid + dithering text** — it makes edges noisy on small screens. + +--- + +## 5. Common pitfalls checklist + +- [ ] **One key per line** in YAML (no `x: 1; y: 2`). See §2. +- [ ] Inside a `stack`/`row`/`column`, **don't give children `x`/`y`** — let the stack place them. +- [ ] Outside a stack, plain elements **need** their position keys (`text` needs `x`; rectangles need `x_start`/`y_start`/`x_end`/`y_end`). +- [ ] Use **colors the palette supports**; otherwise expect quantization (or use `dither`). +- [ ] `class` numbers are the **Tailwind 4px scale** (`gap-2` = 8px), not raw pixels — use `gap-[8]` for exact px. +- [ ] Keep content inside the canvas; overflow is **clipped**, not resized. +- [ ] `class` only affects **layout**; set color/size/font on the element itself. + +--- + +## 6. Device canvas sizes (typical) + +Pass these as `width`/`height` to `render(...)`. Exact values depend on the +panel; confirm with the device. + +| Device class | Typical `width × height` | Palette | +|---|---|---| +| 2.13" ESL tag | 250 × 122 | bw / bwr | +| 2.9" ESL tag | 296 × 128 | bw / bwr | +| 4.2" ESL tag | 400 × 300 | bwr / 7-color | +| Label printer (variable length) | width fixed, height per label | bw | + +--- + +## 7. Worked examples (each verified by rendering) + +### 7a. Price / shelf label — 296×128, palette `bwr` + +A title, a price built from an inline `row` (so `$`, amount and `/kg` sit on one +baseline), and a barcode — all stacked with a `column`. + +```yaml +- type: column + x: 8 + y: 8 + width: 280 + height: 112 + class: "gap-2" + elements: + - type: text + value: "Organic Bananas" + size: 20 + - type: row + class: "items-end gap-1" + elements: + - { type: text, value: "$", size: 22, color: red } + - { type: text, value: "3.49", size: 40, color: red } + - { type: text, value: "/kg", size: 16, class: "mb-[6]" } + - type: barcode + data: "0123456789012" + width: 200 + height: 30 + write_text: false +``` + +### 7b. Weather widget — 250×122, palette `4` + +An icon beside a `column` of city/temperature/condition. Note the children carry +no coordinates. + +```yaml +- type: row + x: 6 + y: 6 + class: "gap-3 items-center" + elements: + - { type: icon, value: "mdi:weather-partly-cloudy", size: 56 } + - type: column + class: "gap-1" + elements: + - { type: text, value: "Seoul", size: 18 } + - { type: text, value: "21 C", size: 32 } + - { type: text, value: "Partly cloudy", size: 14 } +``` + +### 7c. Dashboard rows — 296×128, palette `bwr` + +A header row using `justify-between` (title left, battery right), then a metrics +row using `justify-evenly` with two centered icon+value columns. + +```yaml +- type: row + x: 6 + y: 6 + width: 284 + class: "justify-between items-center" + elements: + - { type: text, value: "Living Room", size: 18 } + - { type: battery, width: 34, height: 16, level: 64 } +- type: row + x: 6 + y: 44 + width: 284 + class: "gap-4 justify-evenly" + elements: + - type: column + class: "items-center gap-1" + elements: + - { type: icon, value: "mdi:thermometer", size: 28, color: red } + - { type: text, value: "23 C", size: 16 } + - type: column + class: "items-center gap-1" + elements: + - { type: icon, value: "mdi:water-percent", size: 28 } + - { type: text, value: "45%", size: 16 } +``` + +--- + +## 8. Cheat-sheet — most-used elements + +Full list & all keys: *Element Reference* in [`../README.md`](../README.md). +`(req)` = required. + +- **`text`** — `x`(req), `value`(req), `y`, `size`(=12), `color`(="black"), `font`, `anchor`. *Inside a stack, omit `x`/`y`.* +- **`rectangle`** — `x_start`/`y_start`/`x_end`/`y_end`(req), `fill`, `outline`, `width`, `radius`. +- **`line`** — `x_start`/`y_start`/`x_end`/`y_end`(req), `fill`, `width`, `dash`. +- **`icon`** — `x`/`y`(req outside a stack), `value`(req, e.g. `"mdi:home"`), `size`(=24), `color`. +- **`qrcode`** — `x`/`y`(req), `data`(req), `boxsize` **or** `width`/`height` (px box), `eclevel`. +- **`barcode`** — `x`/`y`(req), `data`(req), `code`(="code128"), `width`/`height` (px box), `write_text`. +- **`dlimg`** — `x`/`y`/`xsize`/`ysize`(req), `url`(req, http(s)/data:), `mode` (stretch/fit/fill/contain), `dither`, `circle`. +- **`progress_bar`** — `x_start`/`y_start`/`x_end`/`y_end`(req), `progress` 0–100 (req), `fill`, `background`, `radius`, `show_percentage`. +- **`battery`** — `x`/`y`/`width`/`height`(req), `level` 0–100 (req), `fill`, `low_threshold`, `low_color`, `show_percentage`. +- **`pie`** — `x`/`y`(req center), `radius`(req), `values`(req, `"label,num,color;..."`), `inner_radius` (donut). +- **`sparkline`** — `x`/`y`/`width`/`height`(req), `values`(req), `fill`, `dot_last`. +- **`text_fit`** — `x`/`y`/`width`/`height`(req), `value`(req); shrinks/wraps/ellipsizes text into the box. +- **`stack`/`row`/`column`** — `elements`(req); see §3. +- **`group`** — `elements`(req), `x`/`y` offset, `width`/`height` clip box, `rotate`. diff --git a/src/imagespec/classutil.py b/src/imagespec/classutil.py new file mode 100644 index 0000000..a85a0a3 --- /dev/null +++ b/src/imagespec/classutil.py @@ -0,0 +1,152 @@ +"""Parse a Tailwind-like utility ``class`` string into layout props. + +Only the *layout* subset that makes sense for a fixed-palette, fixed-resolution +canvas is supported: flex direction, gap, padding, content/​item alignment, plus +the per-child utilities ``grow`` / ``self-*`` / margin. Styling utilities +(colours, fonts) are intentionally **not** parsed — those stay on each element's +own keys, so there is a single source of truth and no clash with palette +quantisation. Unknown tokens are ignored (Tailwind-style), so a payload that +mixes in styling classes still parses cleanly. + +**Spacing scale = real Tailwind**, not raw pixels: a numeric unit is +``N × 4px`` (Tailwind's default ``0.25rem`` step), so ``gap-2`` → 8px, +``p-4`` → 16px, ``mt-0.5`` → 2px. For exact pixels use Tailwind's *arbitrary +value* syntax — ``gap-[10]`` / ``p-[3px]`` → 10px / 3px — which bypasses the +scale. Margins may be negative (``-mt-2`` → -8px, ``-ml-[3]`` → -3px) for +fine nudges; padding/gap cannot (matches Tailwind). + +The returned dict uses flat, atomic keys so both the container resolver and the +per-child resolver can pick the ones they care about: + +* ``direction`` – ``"horizontal"`` / ``"vertical"`` +* ``gap`` – int px +* ``pl`` ``pt`` ``pr`` ``pb`` – padding per side (int px) +* ``justify`` – main-axis distribution +* ``align`` – cross-axis item alignment (from ``items-*``) +* ``self`` – per-child cross-axis override (from ``self-*``) +* ``grow`` – per-child main-axis grow factor (int) +* ``ml`` ``mt`` ``mr`` ``mb`` – per-child margin per side (int px, may be < 0) +""" + +from __future__ import annotations + +_STEP = 4 # px per spacing unit — Tailwind's 0.25rem at a 16px root + +_JUSTIFY = {"start", "end", "center", "between", "around", "evenly"} +_ALIGN = {"start", "end", "center", "stretch"} + +# prefix -> the side keys it sets (later tokens override earlier ones) +_PAD = { + "p": ("pl", "pt", "pr", "pb"), + "px": ("pl", "pr"), + "py": ("pt", "pb"), + "pt": ("pt",), + "pr": ("pr",), + "pb": ("pb",), + "pl": ("pl",), +} +_MAR = { + "m": ("ml", "mt", "mr", "mb"), + "mx": ("ml", "mr"), + "my": ("mt", "mb"), + "mt": ("mt",), + "mr": ("mr",), + "mb": ("mb",), + "ml": ("ml",), +} + + +def _to_float(s: str): + try: + return float(s) + except (TypeError, ValueError): + return None + + +def _space(val: str): + """Pixels for a spacing value: ``[N]``/``[Npx]`` arbitrary, else ``N×4`` scale.""" + if len(val) >= 2 and val[0] == "[" and val[-1] == "]": + inner = val[1:-1] + if inner.endswith("px"): + inner = inner[:-2] + n = _to_float(inner) + return None if n is None else int(round(n)) + n = _to_float(val) + return None if n is None else int(round(n * _STEP)) + + +def _apply(token: str, props: dict) -> None: + # exact-match tokens first (those whose names contain a '-') + if token == "flex-row": + props["direction"] = "horizontal" + return + if token == "flex-col": + props["direction"] = "vertical" + return + if token in ("grow", "flex-1"): + props["grow"] = 1 + return + + neg = token.startswith("-") + body = token[1:] if neg else token + prefix, sep, val = body.partition("-") + if not sep: + return # bare / unknown token — ignore + + if prefix == "justify": + if not neg and val in _JUSTIFY: + props["justify"] = val + elif prefix == "items": + if not neg and val in _ALIGN: + props["align"] = val + elif prefix == "self": + if not neg and val in _ALIGN: + props["self"] = val + elif prefix == "grow": + if neg: + return + n = _to_float(val) + if n is not None: + props["grow"] = int(n) + elif prefix == "gap": + if neg: + return # no negative gap + px = _space(val) + if px is not None: + props["gap"] = px + elif prefix in _PAD: + if neg: + return # no negative padding (matches Tailwind) + px = _space(val) + if px is not None: + for k in _PAD[prefix]: + props[k] = px + elif prefix in _MAR: + px = _space(val) + if px is not None: + v = -px if neg else px + for k in _MAR[prefix]: + props[k] = v + # anything else (text-*, bg-*, hover:*, ...) is intentionally ignored + + +def parse_class(value) -> dict: + """Parse a class string (or list of strings) into a flat layout-props dict. + + Tokens are applied in order, so a later token wins on conflict + (``"p-2 px-4"`` → left/right become 16, top/bottom stay 8). Unrecognised + tokens are skipped. + """ + if not value: + return {} + if isinstance(value, (list, tuple)): + tokens: list[str] = [] + for v in value: + tokens.extend(str(v).split()) + else: + tokens = str(value).split() + + props: dict = {} + for token in tokens: + _apply(token, props) + return props diff --git a/src/imagespec/elements/layout.py b/src/imagespec/elements/layout.py index 7d39728..b685ca0 100644 --- a/src/imagespec/elements/layout.py +++ b/src/imagespec/elements/layout.py @@ -1,15 +1,28 @@ -"""Layout containers: group. +"""Layout containers: group, stack (row/column). A ``group`` renders its child elements onto a transparent sub-canvas, then composites that at an offset. This gives reusable, relocatable sub-layouts: children use coordinates relative to the group's top-left, the group clips to its ``width × height``, and it can optionally rotate the whole sub-layout. + +A ``stack`` (aliases ``row`` / ``column``) is an *auto-layout* container: it +measures each child's drawn extent and packs them along an axis with gap, +padding, content distribution (``justify``) and cross-axis alignment +(``items`` / per-child ``self``), flexbox-style. Children therefore need no +explicit coordinates — the stack fills them in. Both the container and each +child accept a Tailwind-like ``class`` string as shorthand for these layout +props (see :mod:`imagespec.classutil`); explicit keys win over ``class``. + +Both containers are purely additive: existing payloads never mention them, and +inside them the engine only *positions* children — drawing is still delegated to +the normal element handlers, so every registered element works as a child. """ from __future__ import annotations from PIL import Image +from ..classutil import parse_class from ..dispatch import render_element from ..exceptions import RenderError from ..registry import element @@ -45,3 +58,263 @@ def group(state: RenderState, element: dict) -> None: result = result.rotate(-rotate, expand=True) state.img.alpha_composite(result, (ox, oy)) + + +# --------------------------------------------------------------------------- # +# stack (row / column) — flexbox-style auto-layout +# --------------------------------------------------------------------------- # + + +def _first(*vals): + """First non-``None`` value (``0``/``""`` count as present).""" + for v in vals: + if v is not None: + return v + return None + + +def _norm_dir(value) -> str: + s = str(value).lower() + if s in ("horizontal", "h", "row", "x"): + return "horizontal" + return "vertical" + + +def _resolve_padding(element: dict, cls: dict) -> tuple[int, int, int, int]: + """Return ``(left, top, right, bottom)`` from explicit keys then ``class``. + + Precedence per side: ``padding_`` > ``padding_x``/``padding_y`` > + ``padding`` (all) > class (``pl``/``pt``/...) > 0. + """ + pad_all = element.get("padding") + + def side(name: str, axis_key: str, cls_key: str) -> int: + v = element.get(f"padding_{name}") + if v is not None: + return int(v) + av = element.get(axis_key) + if av is not None: + return int(av) + if pad_all is not None: + return int(pad_all) + if cls_key in cls: + return int(cls[cls_key]) + return 0 + + return ( + side("left", "padding_x", "pl"), + side("top", "padding_y", "pt"), + side("right", "padding_x", "pr"), + side("bottom", "padding_y", "pb"), + ) + + +def _child_layout(child: dict) -> dict: + """Per-child layout props from the child's ``class`` and ``layout`` dict. + + Read from the child's ``class`` string and an optional ``layout`` sub-dict — + never from the child's own drawing keys (so ``margin`` on a ``diagram`` or + ``width`` on a ``sparkline`` is never mistaken for a layout instruction). + """ + cls = parse_class(child.get("class")) + lay = child.get("layout") + if not isinstance(lay, dict): + lay = {} + + grow = lay.get("grow") + if grow is None: + grow = cls.get("grow", 0) + grow = int(grow or 0) + + self_align = lay.get("align") or lay.get("self") or cls.get("self") + + m_all = lay.get("margin") + + def margin(name: str, axis_key: str, cls_key: str) -> int: + v = lay.get(f"margin_{name}") + if v is not None: + return int(v) + av = lay.get(axis_key) + if av is not None: + return int(av) + if m_all is not None: + return int(m_all) + if cls_key in cls: + return int(cls[cls_key]) + return 0 + + return { + "grow": grow, + "self": self_align, + "ml": margin("left", "margin_x", "ml"), + "mt": margin("top", "margin_y", "mt"), + "mr": margin("right", "margin_x", "mr"), + "mb": margin("bottom", "margin_y", "mb"), + } + + +def _justify_offsets(justify: str, free: float, n: int, gap: int) -> tuple[float, float]: + """Return ``(leading, spacing)`` along the main axis for ``justify-*``. + + ``leading`` is the offset before the first child; ``spacing`` is the full gap + between adjacent children (base ``gap`` plus any distributed free space). + """ + if n <= 0: + return 0.0, 0.0 + free = max(0.0, free) # overflow clamps to start; content just spills/clips + if justify == "end": + return free, gap + if justify == "center": + return free / 2, gap + if justify == "between": + return (0.0, gap) if n == 1 else (0.0, gap + free / (n - 1)) + if justify == "around": + unit = free / n + return unit / 2, gap + unit + if justify == "evenly": + unit = free / (n + 1) + return unit, gap + unit + return 0.0, gap # start (default) + + +def _blit(canvas: Image.Image, tile: Image.Image, x: int, y: int) -> None: + """Alpha-composite ``tile`` at ``(x, y)``, clipping to the canvas bounds. + + Plain ``alpha_composite`` rejects offsets that fall outside the destination; + negative margins and overflow can produce those, so we crop the tile to its + visible rectangle first (and skip it entirely if nothing is visible). + """ + cw, ch = canvas.size + tw, th = tile.size + sx, sy = max(0, -x), max(0, -y) + ex, ey = min(tw, cw - x), min(th, ch - y) + if ex <= sx or ey <= sy: + return # fully off-canvas + part = tile.crop((sx, sy, ex, ey)) if (sx, sy, ex, ey) != (0, 0, tw, th) else tile + canvas.alpha_composite(part, (x + sx, y + sy)) + + +@element("stack", "row", "column") +def stack(state: RenderState, element: dict) -> None: + require(element, ["elements"], "stack") + cls = parse_class(element.get("class")) + etype = element.get("type") + default_dir = "horizontal" if etype == "row" else "vertical" + horizontal = _norm_dir(_first(element.get("direction"), cls.get("direction"), default_dir)) == "horizontal" + + gap = int(_first(element.get("gap"), cls.get("gap"), 0)) + justify = _first(element.get("justify"), element.get("justify_content"), cls.get("justify"), "start") + align = _first(element.get("align"), element.get("align_items"), cls.get("align"), "start") + + ox = int(element.get("x", 0) or 0) + oy = int(element.get("y", 0) or 0) + cw = int(_first(element.get("width"), state.canvas_width)) + ch = int(_first(element.get("height"), state.canvas_height)) + rotate = int(element.get("rotate", 0) or 0) + + pl, pt, pr, pb = _resolve_padding(element, cls) + inner_w = max(0, cw - pl - pr) + inner_h = max(0, ch - pt - pb) + + children = [c for c in element["elements"] if isinstance(c, dict)] + + # Render each child onto its own transparent layer and measure its drawn + # extent (alpha bbox). Children need no coordinates — default x/y to 0 for the + # elements that require them; the bbox crop then normalises position so the + # stack alone controls where each tile lands. + tiles = [] + for idx, child in enumerate(children): + eff = child + if "x" not in eff or "y" not in eff: + eff = {**child} + eff.setdefault("x", 0) + eff.setdefault("y", 0) + sub = Image.new("RGBA", (max(1, inner_w), max(1, inner_h)), (0, 0, 0, 0)) + substate = RenderState(img=sub, canvas_width=inner_w, canvas_height=inner_h, context=state.context) + ctype = child.get("type", "") + try: + render_element(substate, eff) + except RenderError: + raise # already descriptive + except Exception as exc: # noqa: BLE001 — add child context, then surface + raise RenderError(f"stack: error rendering child #{idx} (type '{ctype}'): {exc}") from exc + rendered = substate.img + bbox = rendered.getbbox() + tile = rendered.crop(bbox) if bbox else None + tw, th = tile.size if tile else (0, 0) + lay = _child_layout(child) + lay.update(img=tile, w=tw, h=th) + tiles.append(lay) + + n = len(tiles) + inner_main = inner_w if horizontal else inner_h + inner_cross = inner_h if horizontal else inner_w + + def main_of(t): + return t["w"] if horizontal else t["h"] + + def cross_of(t): + return t["h"] if horizontal else t["w"] + + def m_main_lead(t): + return t["ml"] if horizontal else t["mt"] + + def m_main_trail(t): + return t["mr"] if horizontal else t["mb"] + + def m_cross_lead(t): + return t["mt"] if horizontal else t["ml"] + + def m_cross_trail(t): + return t["mb"] if horizontal else t["mr"] + + content_main = sum(main_of(t) + m_main_lead(t) + m_main_trail(t) for t in tiles) + if n > 1: + content_main += gap * (n - 1) + free = inner_main - content_main + + # Distribute leftover main-axis space to grow children; whatever they consume + # is removed from `free` so justify only spreads what remains. + total_grow = sum(t["grow"] for t in tiles if t["grow"] > 0) + grow_extra = [0] * n + if total_grow > 0 and free > 0: + handed = 0 + last = None + for i, t in enumerate(tiles): + if t["grow"] > 0: + share = int(free * t["grow"] // total_grow) + grow_extra[i] = share + handed += share + last = i + if last is not None: + grow_extra[last] += int(free) - handed # rounding remainder + free = 0 + + leading, spacing = _justify_offsets(justify, free, n, gap) + + canvas = Image.new("RGBA", (max(1, cw), max(1, ch)), (0, 0, 0, 0)) + cursor = leading + for i, t in enumerate(tiles): + slot_main = main_of(t) + grow_extra[i] + if t["img"] is not None: + main_pos = cursor + m_main_lead(t) + cfree = inner_cross - cross_of(t) - m_cross_lead(t) - m_cross_trail(t) + a = t["self"] or align + if a == "end": + cross_pos = m_cross_lead(t) + max(0, cfree) + elif a == "center": + cross_pos = m_cross_lead(t) + max(0, cfree) / 2 + else: # start / stretch (pixels can't stretch) -> start + cross_pos = m_cross_lead(t) + if horizontal: + pos = (pl + round(main_pos), pt + round(cross_pos)) + else: + pos = (pl + round(cross_pos), pt + round(main_pos)) + _blit(canvas, t["img"], *pos) + cursor += m_main_lead(t) + slot_main + m_main_trail(t) + if i < n - 1: + cursor += spacing + + if rotate in (90, 180, 270): + canvas = canvas.rotate(-rotate, expand=True) + state.img.alpha_composite(canvas, (ox, oy)) diff --git a/tests/test_elements.py b/tests/test_elements.py index 9193486..1ab4214 100644 --- a/tests/test_elements.py +++ b/tests/test_elements.py @@ -117,6 +117,37 @@ def _samples(data_url): {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": 20, "y_end": 20, "outline": "black"} ], }, + "stack": { + "type": "stack", + "x": 0, + "y": 0, + "direction": "vertical", + "gap": 2, + "elements": [ + {"type": "text", "value": "a", "size": 8}, + {"type": "text", "value": "b", "size": 8}, + ], + }, + "row": { + "type": "row", + "x": 0, + "y": 0, + "class": "gap-2 items-center", + "elements": [ + {"type": "icon", "value": "mdi:home", "size": 12}, + {"type": "text", "value": "Hi", "size": 8}, + ], + }, + "column": { + "type": "column", + "x": 0, + "y": 0, + "gap": 1, + "elements": [ + {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": 10, "y_end": 5, "fill": "black"}, + {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": 6, "y_end": 5, "fill": "red"}, + ], + }, "legend": { "type": "legend", "x": 0, diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..10c8a02 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,204 @@ +"""Auto-layout: the ``stack`` (row/column) engine and the ``class`` parser. + +These assert the *layout* behaviour — drawing of each child is still the normal +element handler's job, so we only check where tiles land. +""" + +from __future__ import annotations + +import pytest + +from imagespec import render +from imagespec.classutil import parse_class + +BLACK = (0, 0, 0) +RED = (255, 0, 0) +WHITE = (255, 255, 255) + + +def _rect(fill, w=10, h=5): + return {"type": "rectangle", "x_start": 0, "y_start": 0, "x_end": w - 1, "y_end": h - 1, "fill": fill} + + +# --------------------------------------------------------------------------- # +# class parser +# --------------------------------------------------------------------------- # + + +def test_parse_class_layout_tokens_use_tailwind_scale(): + # numeric units follow Tailwind's 4px-per-unit scale (gap-4 -> 16px) + p = parse_class("flex-col gap-4 p-2 px-3 justify-between items-center") + assert p["direction"] == "vertical" + assert p["gap"] == 16 # 4 * 4px + assert p["pt"] == 8 and p["pb"] == 8 # p-2 -> 8px (top/bottom) + assert p["pl"] == 12 and p["pr"] == 12 # px-3 -> 12px, overrides p-2 on x (later wins) + assert p["justify"] == "between" + assert p["align"] == "center" + + +def test_parse_class_arbitrary_and_fraction(): + # [N]/[Npx] bypass the scale for exact pixels; `.5` is a half-step (2px) + p = parse_class("gap-[10] px-[5px] mt-0.5") + assert p["gap"] == 10 + assert p["pl"] == 5 and p["pr"] == 5 + assert p["mt"] == 2 # 0.5 * 4px + + +def test_parse_class_negative_margin(): + p = parse_class("-ml-2 -mt-[3]") + assert p["ml"] == -8 # -(2 * 4px) + assert p["mt"] == -3 # arbitrary, negated + # negative padding/gap is rejected (matches Tailwind) + assert parse_class("-p-2 -gap-4") == {} + + +def test_parse_class_per_child_tokens(): + p = parse_class("grow self-end mt-2 mx-1") + assert p["grow"] == 1 + assert p["self"] == "end" + assert p["mt"] == 8 # 2 * 4px + assert p["ml"] == 4 and p["mr"] == 4 # 1 * 4px + + +def test_parse_class_ignores_styling_and_unknown(): + # styling utilities and unknown tokens are dropped; only layout survives + assert parse_class("text-red-500 bg-blue hover:foo font-bold grow") == {"grow": 1} + assert parse_class("") == {} + assert parse_class(None) == {} + + +def test_parse_class_accepts_list(): + assert parse_class(["flex-row", "gap-2"]) == {"direction": "horizontal", "gap": 8} + + +# --------------------------------------------------------------------------- # +# stack engine +# --------------------------------------------------------------------------- # + + +def test_column_packs_children_vertically_with_gap(ctx): + el = {"type": "column", "x": 0, "y": 0, "gap": 2, "elements": [_rect("black"), _rect("red")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == BLACK # first tile at top (rows 0-4) + assert img.getpixel((2, 5)) == WHITE # the 2px gap is background + assert img.getpixel((2, 9)) == RED # second tile pushed down by height+gap (rows 7-11) + + +def test_row_packs_children_horizontally_with_gap(ctx): + el = {"type": "row", "x": 0, "y": 0, "gap": 2, "elements": [_rect("black"), _rect("red")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == BLACK # first tile (cols 0-9) + assert img.getpixel((11, 2)) == WHITE # gap column + assert img.getpixel((13, 2)) == RED # second tile at width+gap (cols 12-21) + + +def test_stack_default_direction_is_vertical(ctx): + el = {"type": "stack", "x": 0, "y": 0, "gap": 0, "elements": [_rect("black"), _rect("red")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == BLACK + assert img.getpixel((2, 7)) == RED # stacked below, not beside + + +def test_items_center_aligns_cross_axis(ctx): + # narrow child centred horizontally in a 40px-wide column + el = {"type": "column", "x": 0, "y": 0, "align": "center", "elements": [_rect("black", w=10)]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((20, 2)) == BLACK # centred (cross-free 30 -> offset 15, cols 15-24) + assert img.getpixel((2, 2)) == WHITE # left edge empty + + +def test_class_string_drives_layout(ctx): + # configured purely through a Tailwind-like class; gap-[2] = exact 2px + el = {"type": "stack", "x": 0, "y": 0, "class": "flex-row gap-[2]", "elements": [_rect("black"), _rect("red")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == BLACK + assert img.getpixel((13, 2)) == RED # second tile at width(10)+gap(2)=12, cols 12-21 + + +def test_class_gap_uses_tailwind_scale(ctx): + # gap-2 (no brackets) = 2 * 4px = 8px between the two 10px-wide tiles + el = {"type": "row", "x": 0, "y": 0, "class": "gap-2", "elements": [_rect("black"), _rect("red")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == BLACK # cols 0-9 + assert img.getpixel((15, 2)) == WHITE # 8px gap region (cols 10-17) is empty + assert img.getpixel((19, 2)) == RED # second tile at 10+8=18, cols 18-27 + + +def test_negative_margin_nudges_tile(ctx): + # -ml pulls the second tile back over the first (overlap), clipped safely + el = { + "type": "row", + "x": 0, + "y": 0, + "gap": 0, + "elements": [_rect("black"), {**_rect("red"), "class": "-ml-1"}], + } + img = render([el], 40, 40, background="white", context=ctx) + # second tile would start at x=10 but -ml-1 (=-4px) pulls it back to x=6 + assert img.getpixel((8, 2)) == RED + + +def test_explicit_key_overrides_class(ctx): + # class says row, explicit direction says vertical -> explicit wins + el = { + "type": "stack", + "x": 0, + "y": 0, + "class": "flex-row", + "direction": "vertical", + "gap": 0, + "elements": [_rect("black"), _rect("red")], + } + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 7)) == RED # vertical (below), proving explicit beat class + + +def test_child_class_margin_shifts_tile(ctx): + # a left margin on the second row child pushes it right (ml-[4] = exact 4px) + el = { + "type": "row", + "x": 0, + "y": 0, + "gap": 0, + "elements": [_rect("black"), {**_rect("red"), "class": "ml-[4]"}], + } + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == BLACK # first tile cols 0-9 + assert img.getpixel((11, 2)) == WHITE # 4px margin gap after col 9 stays empty + assert img.getpixel((16, 2)) == RED # second tile starts at 10 + 4 margin + + +def test_padding_insets_content(ctx): + el = {"type": "column", "x": 0, "y": 0, "padding": 5, "elements": [_rect("black")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == WHITE # padding region empty + assert img.getpixel((7, 7)) == BLACK # content inset by 5px + + +def test_stack_offset_by_x_y(ctx): + el = {"type": "column", "x": 10, "y": 10, "elements": [_rect("black")]} + img = render([el], 40, 40, background="white", context=ctx) + assert img.getpixel((2, 2)) == WHITE # nothing at canvas origin + assert img.getpixel((12, 12)) == BLACK # whole stack translated to (10, 10) + + +def test_children_without_coordinates_render(ctx): + # text requires `x`; the stack supplies a default so children need no coords + el = { + "type": "column", + "x": 0, + "y": 0, + "gap": 1, + "elements": [{"type": "text", "value": "A", "size": 10}, {"type": "text", "value": "B", "size": 10}], + } + img = render([el], 40, 40, background="white", context=ctx) + assert any(img.getpixel((x, y)) == BLACK for x in range(40) for y in range(40)) + + +def test_stack_child_error_wrapped_with_context(ctx): + # a raw (non-RenderError) failure in a child is wrapped with stack/child context + el = {"type": "row", "elements": [{"type": "rectangle", "x_start": "oops", "y_start": 0, "x_end": 9, "y_end": 9}]} + with pytest.raises(Exception) as exc: + render([el], 40, 40, context=ctx) + msg = str(exc.value) + assert "stack" in msg and "rectangle" in msg