Metadata-Version: 2.4
Name: odl-renderer
Version: 0.5.2
Summary: Pure image rendering from drawing instructions for e-paper displays
Project-URL: Homepage, https://github.com/g4bri3lDev/odl-renderer
Project-URL: Repository, https://github.com/g4bri3lDev/odl-renderer
Project-URL: Issues, https://github.com/g4bri3lDev/odl-renderer/issues
Project-URL: Documentation, https://opendisplay.org/protocol/open-display-language.html
Author-email: g4bri3lDev <admin@g4bri3l.de>
License: MIT
License-File: LICENSE
Keywords: e-paper,eink,image,odl,open-display-language,opendisplay,rendering
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.13.4
Requires-Dist: pillow>=12.0.0
Requires-Dist: qrcode[pil]>=7.4.2
Requires-Dist: resize-image>=0.4.0
Provides-Extra: dev
Requires-Dist: mypy>=1.20.0; extra == 'dev'
Requires-Dist: prek>=0.3.4; extra == 'dev'
Requires-Dist: ruff>=0.15.10; extra == 'dev'
Provides-Extra: property
Requires-Dist: hypothesis>=6.151.12; extra == 'property'
Provides-Extra: test
Requires-Dist: pytest-asyncio>=1.3.0; extra == 'test'
Requires-Dist: pytest-cov>=7.1.0; extra == 'test'
Requires-Dist: pytest-xdist>=3.8.0; extra == 'test'
Requires-Dist: pytest>=9.0.3; extra == 'test'
Requires-Dist: time-machine>=2.9.0; extra == 'test'
Provides-Extra: visual
Requires-Dist: imagehash>=4.3.2; extra == 'visual'
Requires-Dist: syrupy>=5.1.0; extra == 'visual'
Description-Content-Type: text/markdown

[![PyPI version](https://img.shields.io/pypi/v/odl-renderer.svg)](https://pypi.org/project/odl-renderer/)
[![Python versions](https://img.shields.io/pypi/pyversions/odl-renderer.svg)](https://pypi.org/project/odl-renderer/)
[![Lint](https://github.com/g4bri3lDev/odl-renderer/actions/workflows/lint.yml/badge.svg)](https://github.com/g4bri3lDev/odl-renderer/actions/workflows/lint.yml)
[![Tests](https://github.com/g4bri3lDev/odl-renderer/actions/workflows/test.yml/badge.svg)](https://github.com/g4bri3lDev/odl-renderer/actions/workflows/test.yml)[![codecov](https://codecov.io/gh/g4bri3lDev/odl-renderer/branch/main/graph/badge.svg)](https://codecov.io/gh/g4bri3lDev/odl-renderer)

# odl-renderer

Python renderer for the [OpenDisplay Language (ODL)](https://opendisplay.org/protocol/open-display-language.html).

**odl-renderer** is a standalone Python library for generating images from [ODL](https://opendisplay.org/protocol/open-display-language.html) drawing instructions. Originally extracted from the OpenEPaperLink Home Assistant integration, it provides a clean, async API for rendering text, shapes, icons, QR codes, and more to PIL images.

## Features

- **Pure Rendering**: No dependencies on Home Assistant or other frameworks
- **17 Element Types**: Text, shapes, icons, QR codes, images, progress bars, and more
- **Async/Await**: Modern async API for efficient image generation
- **Flexible Input**: Accepts fonts as PIL objects, file paths, or built-in names
- **Full Color Output**: Returns PIL Image objects in full RGB/RGBA (caller handles dithering)
- **Percentage-Based Coordinates**: Position elements using percentages or absolute pixels
- **Template-Ready**: All values are plain data (templates expanded by caller)

## Installation

```bash
uv add odl-renderer
# or
pip install odl-renderer
```

## Quickstart

```python
import asyncio
from odl_renderer import generate_image

async def main():
    image = await generate_image(
        width=296,
        height=128,
        elements=[
            {
                "type": "text",
                "value": "Hello World",
                "x": "50%",
                "y": 50,
                "font": "ppb",
                "size": 24,
                "color": "black",
                "anchor": "mm",
            },
            {
                "type": "rectangle",
                "x_start": 10,
                "y_start": 10,
                "x_end": 100,
                "y_end": 50,
                "fill": "red",
                "outline": "black",
                "width": 2,
            },
        ],
        background="white",
        accent_color="red",
    )
    image.save("output.png")

asyncio.run(main())
```

## Element Types

| Category | Types |
|---|---|
| Text | [`text`](#text), [`multiline`](#multiline) |
| Shapes | [`rectangle`](#rectangle), [`rectangle_pattern`](#rectangle_pattern), [`circle`](#circle), [`ellipse`](#ellipse), [`polygon`](#polygon), [`arc`](#arc), [`line`](#line) |
| Icons | [`icon`](#icon), [`icon_sequence`](#icon_sequence) |
| Media | [`dlimg`](#dlimg), [`qrcode`](#qrcode) |
| Visualizations | [`progress_bar`](#progress_bar), [`plot`](#plot), [`diagram`](#diagram) |
| Debug | [`debug_grid`](#debug_grid) |

---

## Reference

### Colors

| Value | Description |
|---|---|
| `"black"` / `"b"` | Black |
| `"white"` / `"w"` | White |
| `"red"` / `"r"` | Red |
| `"yellow"` / `"y"` | Yellow |
| `"blue"` / `"bl"` | Blue |
| `"green"` / `"gr"` / `"g"` | Green |
| `"gray"` / `"grey"` | Mid-gray |
| `"half_black"` / `"hb"` | Half-tone black |
| `"half_red"` / `"hr"` | Half-tone red |
| `"half_yellow"` / `"hy"` | Half-tone yellow |
| `"accent"` / `"a"` | Accent color (configured via `accent_color` param) |
| `"half_accent"` / `"ha"` | Half-tone accent |
| `"#RGB"` / `"#RRGGBB"` | Hex color |

### Coordinates

Coordinates accept either absolute integers (`10`, `256`) or percentage strings (`"50%"`, `"100%"`).

When `y` is omitted, elements stack automatically: each element is placed below the previous one using `pos_y + y_padding` (default padding: 10px).

### Anchors

Used by `text`, `icon`, and `icon_sequence` to set which point of the element aligns to the given `x`/`y` coordinates.

`text` uses PIL anchor format — horizontal axis first, then vertical:

| Code | Meaning |
|---|---|
| `lt` | Left-top |
| `mt` | Middle-top |
| `rt` | Right-top |
| `lm` | Left-middle |
| `mm` | Middle (center) |
| `rm` | Right-middle |
| `lb` | Left-bottom |
| `mb` | Middle-bottom |
| `rb` | Right-bottom |

`icon` and `icon_sequence` use a different format for corner anchors — vertical axis first:

| Code | Meaning |
|---|---|
| `tl` | Top-left |
| `tr` | Top-right |
| `mt` | Middle-top |
| `lm` | Left-middle |
| `mm` | Middle (center) |
| `rm` | Right-middle |
| `bl` | Bottom-left |
| `br` | Bottom-right |
| `mb` | Middle-bottom |

### The `visible` field

Every element type accepts an optional `visible` field. When `false`, the element is skipped entirely (no rendering, no position update). Defaults to `true`.

```python
{"type": "text", "value": "Hidden", "x": 10, "y": 10, "visible": False}
```

---

## Text

### `text`

Single-line text with optional wrapping, truncation, stroke, and inline color markup.

| Field | Required | Default | Notes |
|---|---|---|---|
| `value` | yes | — | Text content |
| `x` | yes | — | X position |
| `y` | no | auto | Y position; auto-stacks if omitted |
| `font` | no | `"ppb"` | Built-in name (`"ppb"`, `"rbm"`), file path, or PIL Font object |
| `size` | no | `20` | Font size in pixels |
| `color` | no | `"black"` | Text color |
| `anchor` | no | `"lt"` / `"la"` | Anchor point; defaults to `"la"` when text wraps to multiple lines |
| `max_width` | no | — | Wrap text at this pixel width |
| `truncate` | no | `false` | Truncate with `…` instead of wrapping |
| `align` | no | `"left"` | `"left"`, `"center"`, or `"right"` |
| `spacing` | no | `5` | Line spacing when wrapped |
| `stroke_width` | no | `0` | Outline width |
| `stroke_fill` | no | `"white"` | Outline color |
| `parse_colors` | no | `false` | Enable `[red]text[/red]` inline color markup |
| `y_padding` | no | `10` | Extra space above when auto-stacking |

```python
{
    "type": "text",
    "value": "Hello",
    "x": "50%",
    "y": 20,
    "size": 32,
    "anchor": "mt",
}
```

**Inline color markup** (requires `parse_colors: true`): wrap text in `[color]...[/color]` tags. Accepts all named colors, short names, and hex (`[#ff0000]red text[/#ff0000]`).

---

### `multiline`

Fixed-line text split by a delimiter, each line placed at a fixed vertical offset.

| Field | Required | Default | Notes |
|---|---|---|---|
| `value` | yes | — | Full text content |
| `x` | yes | — | X position |
| `delimiter` | yes | — | Character used to split lines (e.g. `"\|"`) |
| `offset_y` | yes | — | Pixels between each line |
| `y` | no | auto | Y of first line |
| `font` | no | `"ppb"` | Font |
| `size` | no | `20` | Font size |
| `color` | no | `"black"` | Text color |
| `anchor` | no | `"lm"` | Anchor point |
| `align` | no | `"left"` | Text alignment |
| `stroke_width` | no | `0` | Outline width |
| `stroke_fill` | no | `"white"` | Outline color |
| `parse_colors` | no | `false` | Inline color markup |

```python
{
    "type": "multiline",
    "value": "Line 1|Line 2|Line 3",
    "x": 10,
    "y": 10,
    "delimiter": "|",
    "offset_y": 24,
    "size": 18,
}
```

---

## Shapes

### `rectangle`

Rectangle with optional fill, outline, and rounded corners.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x_start` | yes | — | Left edge |
| `y_start` | yes | — | Top edge |
| `x_end` | yes | — | Right edge |
| `y_end` | yes | — | Bottom edge |
| `fill` | no | — | Fill color |
| `outline` | no | `"black"` | Border color |
| `width` | no | `1` | Border width |
| `corners` | no | — | Which corners to round: `"all"`, `"top_left"`, `"top_right"`, `"bottom_left"`, `"bottom_right"` (comma-separated) |
| `radius` | no | `10` | Corner radius; only applies when `corners` is set |

```python
{
    "type": "rectangle",
    "x_start": 0,
    "y_start": 0,
    "x_end": "100%",
    "y_end": 40,
    "fill": "black",
}
```

---

### `rectangle_pattern`

Repeating grid of rectangles — useful for dot matrices, grids, and decorative patterns.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x_start` | yes | — | Starting X |
| `y_start` | yes | — | Starting Y |
| `x_size` | yes | — | Width of each rectangle |
| `y_size` | yes | — | Height of each rectangle |
| `x_repeat` | yes | — | Number of columns |
| `y_repeat` | yes | — | Number of rows |
| `x_offset` | yes | — | Horizontal gap between rectangles |
| `y_offset` | yes | — | Vertical gap between rectangles |
| `fill` | no | — | Fill color |
| `outline` | no | `"black"` | Border color |
| `width` | no | `1` | Border width |
| `corners` / `radius` | no | — | Same as `rectangle` |

```python
{
    "type": "rectangle_pattern",
    "x_start": 10,
    "y_start": 10,
    "x_size": 8,
    "y_size": 8,
    "x_repeat": 5,
    "y_repeat": 3,
    "x_offset": 4,
    "y_offset": 4,
    "fill": "black",
}
```

---

### `circle`

Circle defined by center point and radius.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x` | yes | — | Center X |
| `y` | yes | — | Center Y |
| `radius` | yes | — | Radius in pixels |
| `fill` | no | — | Fill color |
| `outline` | no | `"black"` | Border color |
| `width` | no | `1` | Border width |

```python
{
    "type": "circle",
    "x": 50,
    "y": 50,
    "radius": 20,
    "fill": "red",
    "outline": "black",
}
```

---

### `ellipse`

Ellipse defined by a bounding box.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x_start` | yes | — | Left edge |
| `y_start` | yes | — | Top edge |
| `x_end` | yes | — | Right edge |
| `y_end` | yes | — | Bottom edge |
| `fill` | no | — | Fill color |
| `outline` | no | `"black"` | Border color |
| `width` | no | `1` | Border width |

```python
{
    "type": "ellipse",
    "x_start": 10,
    "y_start": 10,
    "x_end": 100,
    "y_end": 60,
    "fill": "yellow",
}
```

---

### `polygon`

Arbitrary polygon defined by a list of vertices.

| Field | Required | Default | Notes |
|---|---|---|---|
| `points` | yes | — | List of `[x, y]` pairs |
| `fill` | no | — | Fill color |
| `outline` | no | `"black"` | Border color |

```python
{
    "type": "polygon",
    "points": [[10, 50], [50, 10], [90, 50], [50, 90]],
    "fill": "blue",
}
```

---

### `arc`

Arc or pie slice defined by center, radius, and angle range.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x` | yes | — | Center X |
| `y` | yes | — | Center Y |
| `radius` | yes | — | Radius in pixels |
| `start_angle` | yes | — | Start angle in degrees (0 = right, clockwise) |
| `end_angle` | yes | — | End angle in degrees |
| `fill` | no | — | Fill color (creates a pie slice) |
| `outline` | no | `"black"` | Border color |
| `width` | no | `1` | Border width |

```python
{
    "type": "arc",
    "x": 50,
    "y": 50,
    "radius": 30,
    "start_angle": 0,
    "end_angle": 270,
    "outline": "black",
}
```

---

### `line`

Straight line between two points with optional dashing.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x_start` | yes | — | Start X |
| `x_end` | yes | — | End X |
| `y_start` | no | auto | Start Y |
| `y_end` | no | `y_start` | End Y (horizontal line if omitted) |
| `fill` | no | `"black"` | Line color |
| `width` | no | `1` | Line width |
| `dashed` | no | `false` | Enable dashed style |
| `dash_length` | no | `5` | Length of each dash |
| `space_length` | no | `3` | Gap between dashes |

```python
{
    "type": "line",
    "x_start": 0,
    "y_start": 64,
    "x_end": "100%",
    "y_end": 64,
    "fill": "black",
}
```

---

## Icons

Icons use the bundled [Material Design Icons](https://pictogrammers.com/library/mdi/) font (7,000+ icons). Pass the icon name with or without the `mdi:` prefix.

### `icon`

Single MDI icon.

| Field | Required | Default | Notes |
|---|---|---|---|
| `value` | yes | — | Icon name, e.g. `"home"` or `"mdi:home"` |
| `x` | yes | — | X position |
| `y` | yes | — | Y position |
| `size` | yes | — | Icon size in pixels |
| `color` | no | `"black"` | Icon color |
| `anchor` | no | `"mm"` | Anchor point |

```python
{
    "type": "icon",
    "value": "mdi:thermometer",
    "x": "50%",
    "y": "50%",
    "size": 48,
    "anchor": "mm",
}
```

---

### `icon_sequence`

Row or column of MDI icons.

| Field | Required | Default | Notes |
|---|---|---|---|
| `icons` | yes | — | List of icon names |
| `x` | yes | — | Starting X |
| `y` | yes | — | Starting Y |
| `size` | yes | — | Icon size in pixels |
| `direction` | no | `"right"` | `"right"`, `"left"`, `"up"`, or `"down"` |
| `spacing` | no | `size / 4` | Gap between icons |
| `color` | no | `"black"` | Icon color |
| `anchor` | no | `"mm"` | Anchor point |

```python
{
    "type": "icon_sequence",
    "icons": ["mdi:weather-sunny", "mdi:weather-cloudy", "mdi:weather-rainy"],
    "x": 10,
    "y": 10,
    "size": 32,
}
```

---

## Media

### `dlimg`

Image from a URL, file path, data URI, bytes, or PIL Image object. Resized to fit the specified dimensions.

> **Note:** Entity IDs are not resolved — the caller must pass a URL, file path, or image object directly.

| Field | Required | Default | Notes |
|---|---|---|---|
| `url` | yes | — | Source: HTTP URL, file path, data URI, `bytes`, or `PIL.Image` |
| `x` | yes | — | X position |
| `y` | yes | — | Y position |
| `xsize` | yes | — | Target width |
| `ysize` | yes | — | Target height |
| `rotate` | no | `0` | Rotation in degrees |
| `resize_method` | no | `"stretch"` | `"stretch"`, `"crop"`, `"cover"`, or `"contain"` |

```python
{
    "type": "dlimg",
    "url": "https://example.com/photo.jpg",
    "x": 0,
    "y": 0,
    "xsize": 200,
    "ysize": 150,
    "resize_method": "cover",
}
```

---

### `qrcode`

QR code generated from any text or URL.

| Field | Required | Default | Notes |
|---|---|---|---|
| `data` | yes | — | QR code content |
| `x` | yes | — | X position |
| `y` | yes | — | Y position |
| `color` | no | `"black"` | Foreground color |
| `bgcolor` | no | `"white"` | Background color |
| `border` | no | `1` | Quiet zone size in modules |
| `boxsize` | no | `2` | Pixel size of each module |

```python
{
    "type": "qrcode",
    "data": "https://opendisplay.org",
    "x": "50%",
    "y": "50%",
}
```

---

## Visualizations

### `progress_bar`

Horizontal or vertical progress bar with optional percentage label.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x_start` | yes | — | Left edge |
| `y_start` | yes | — | Top edge |
| `x_end` | yes | — | Right edge |
| `y_end` | yes | — | Bottom edge |
| `progress` | yes | — | Fill level 0–100 (clamped automatically) |
| `direction` | no | `"right"` | `"right"`, `"left"`, `"up"`, or `"down"` |
| `fill` | no | `"red"` | Progress fill color |
| `background` | no | `"white"` | Empty track color |
| `outline` | no | `"black"` | Border color |
| `width` | no | `1` | Border width |
| `show_percentage` | no | `false` | Overlay percentage text |
| `font_name` | no | `"ppb"` | Font for percentage text |

```python
{
    "type": "progress_bar",
    "x_start": 10,
    "y_start": 50,
    "x_end": 200,
    "y_end": 70,
    "progress": 72,
    "fill": "accent",
    "show_percentage": True,
}
```

---

### `plot`

Time-series line chart with configurable axes, grid, and legends. Requires a `DataProvider` in the drawing context to supply historical data.

| Field | Required | Default | Notes |
|---|---|---|---|
| `data` | yes | — | List of series configs (see below) |
| `x_start` | no | `0` | Chart left edge |
| `y_start` | no | `0` | Chart top edge |
| `x_end` | no | image width | Chart right edge |
| `y_end` | no | image height | Chart bottom edge |
| `duration` | no | `86400` | Time window in seconds |
| `low` / `high` | no | auto | Y-axis range override |
| `font` | no | `"ppb"` | Font for labels |

<details>
<summary>Series, axis, and legend options</summary>

**Each series in `data`:**

| Field | Default | Notes |
|---|---|---|
| `entity` | required | Data source identifier passed to `DataProvider` |
| `color` | required | Line color |
| `width` | `2` | Line width |
| `smooth` | `false` | Smooth the line |
| `line_style` | `"solid"` | `"solid"`, `"dashed"`, or `"dotted"` |
| `show_points` | `false` | Draw data points |
| `span_gaps` | `false` | Connect across missing values |

**`yaxis`** (optional dict):

| Field | Default |
|---|---|
| `tick_every` | required |
| `color` | `"black"` |
| `grid` | `true` |
| `grid_style` | `"dotted"` |

**`xaxis`** (optional dict):

| Field | Default |
|---|---|
| `color` | `"black"` |
| `grid` | `true` |
| `grid_style` | `"dotted"` |

**`ylegend`** (optional dict):

| Field | Default |
|---|---|
| `position` | `"left"` |
| `size` | `10` |
| `color` | `"black"` |

**`xlegend`** (optional dict):

| Field | Default |
|---|---|
| `interval` | required |
| `format` | `"%H:%M"` |
| `position` | `"bottom"` |
| `size` | `10` |
| `snap_to_hours` | `true` |

</details>

```python
{
    "type": "plot",
    "data": [{"entity": "sensor.temperature", "color": "red"}],
    "yaxis": {"tick_every": 5},
    "xlegend": {"interval": 3600},
}
```

---

### `diagram`

Simple bar chart with labeled axes.

| Field | Required | Default | Notes |
|---|---|---|---|
| `x` | yes | — | X position |
| `height` | yes | — | Diagram height |
| `width` | no | image width | Diagram width |
| `margin` | no | `20` | Margin from edges |
| `bars` | no | — | Bar chart config (see below) |

**`bars` config:**

| Field | Notes |
|---|---|
| `values` | Semicolon-separated `"label,value"` pairs: `"Mon,10;Tue,20;Wed,15"` |
| `color` | Bar fill color |
| `font` | Font name |
| `legend_size` | Label font size |
| `legend_color` | Label color |

```python
{
    "type": "diagram",
    "x": 0,
    "height": 100,
    "bars": {
        "values": "Mon,10;Tue,20;Wed,15",
        "color": "black",
    },
}
```

---

## Debug

### `debug_grid`

Renders a coordinate grid over the image. Useful during layout development.

| Field | Required | Default | Notes |
|---|---|---|---|
| `spacing` | no | `20` | Grid cell size in pixels |
| `line_color` | no | `"black"` | Grid line color |
| `dashed` | no | `true` | Use dashed lines |
| `dash_length` | no | `2` | Dash segment length |
| `space_length` | no | `4` | Gap between dashes |
| `show_labels` | no | `true` | Show coordinate labels |
| `label_step` | no | `spacing * 2` | Interval between labels |
| `label_color` | no | `"black"` | Label color |
| `label_font_size` | no | `12` | Label font size |
| `font` | no | `"ppb"` | Font for labels |

```python
{
    "type": "debug_grid",
    "spacing": 20,
    "line_color": "gray",
}
```