Metadata-Version: 2.5
Name: poikilos
Version: 1.1.0
Summary: Matplotlib themes and figure helpers
Author-email: Christian Samuel Ramirez <chrisram0403@proton.me>
License-Expression: MIT
License-File: LICENSE
Keywords: lilaq,matplotlib,plotting,rose-pine,style,theme
Requires-Python: >=3.11
Requires-Dist: matplotlib>=3.11
Provides-Extra: labels
Requires-Dist: adjusttext>=1.4.0; extra == 'labels'
Description-Content-Type: text/markdown

# poikilos

Matplotlib themes, plus five helpers for publication figures. *Poikilos* is
Greek for many-colored.

A theme is a look (fonts, spines, ticks, sizes) plus a palette (colors). Any
look takes any palette.

| Theme | Look | Palette |
| --- | --- | --- |
| `plain`, `plain-dark` | plain | matplotlib's tab10 on white or `#242424` |
| `rose-pine`, `rose-pine-moon`, `rose-pine-dawn` | plain | [Rosé Pine](https://rosepinetheme.com) |
| `lilaq`, `lilaq-moon` | lilaq | [lilaq](https://lilaq.org) 0.6.0, default and moon |
| `pgl` | pgl | seaborn's whitegrid greys, tab10 |
| `zed` | zed | Illustrator's CMYK swatches, warm black `#231F20` |

| Light | Dark |
| --- | --- |
| ![plain](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/plain.png) | ![plain-dark](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/plain-dark.png) |
| ![rose-pine-dawn](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/rose-pine-dawn.png) | ![rose-pine](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/rose-pine.png) |
| ![lilaq](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/lilaq.png) | ![lilaq-moon](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/lilaq-moon.png) |
| ![pgl](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/pgl.png) | |
| ![zed](https://raw.githubusercontent.com/ramirc0/poikilos/main/assets/gallery/zed.png) | |

[`rose-pine-moon`](https://github.com/ramirc0/poikilos/blob/main/assets/gallery/rose-pine-moon.png)
has its own gallery image.

## Install

```bash
uv add "poikilos @ git+https://github.com/ramirc0/poikilos"
uv add "poikilos[labels] @ git+https://github.com/ramirc0/poikilos"  # adds adjustText for label_points
```

Needs Python 3.11 and matplotlib 3.11 or later.

## Usage

```python
import matplotlib.pyplot as plt

import poikilos as pk

pk.use("rose-pine")

fig, ax = plt.subplots()
ax.plot([0, 1, 2], [0, 1, 0])
pk.despine(ax)
pk.save_figure(fig, "results/plot")  # writes plot.svg and plot.png
```

`use()` resets every style rcParam to matplotlib's default, then applies the
theme. It leaves the backend alone, so it works before or after importing
pyplot. Importing `poikilos` changes nothing.

For one figure in another theme, use `context()`. It restores the previous
rcParams on exit. Save inside the block, because matplotlib reads
`savefig.dpi` at save time.

```python
with pk.context("lilaq"):
    fig, ax = plt.subplots()
    ax.plot(x, y)
    pk.save_figure(fig, "results/lilaq")
```

### Mixing and overrides

`palette=` puts another palette on a theme's look. `rc=` sets any rcParams on
top. `use()`, `context()` and `rc_params()` all take both.

```python
pk.use("lilaq", palette="rose-pine-dawn")  # lilaq look, Rosé Pine Dawn colors
pk.use("plain", rc={"axes.grid": True})
```

`use()` validates everything before it touches matplotlib. A misspelled key in
`rc` raises `KeyError` and a bad value raises `ValueError`. Either way the
current rcParams stay as they were. The next `use()` resets any rcParam you set
by hand, so set them after `use()` or pass them in `rc`.

seaborn's `set_theme()` also resets rcParams. Call it before `pk.use()`.

`pk.rc_params(theme, palette=None)` returns a theme's rcParams as a dict
without applying them.

### Palettes as data

Palettes are TOML files in `src/poikilos/palettes/`. `pk.PALETTES` holds them
with every color resolved to hex:

```python
dawn = pk.PALETTES["rose-pine-dawn"]
dawn.background  # '#faf4ed'
dawn.cycle  # ('#b4637a', '#ea9d34', ...)
dawn.colors["love"]  # '#b4637a', by the palette's own name
```

`pk.THEMES` maps each theme name to its `(look, palette)` pair.

A palette sets root colors only: background, foreground, frame, ticks, grid,
legend edge, boxplot median and mean, and the cycle. It may also set
`scatter_edge`, the edge of scatter markers. A palette without it keeps
matplotlib's default, the face color. Legend face, title,
`savefig` and hatch colors inherit from those. An `rc=` override of a root
color therefore carries through to them.

## Helpers

- `despine(ax, categorical_x=False, categorical_y=False, offset=10)` fits each
  continuous axis to its data and widens it to the enclosing ticks. It then
  offsets the left and bottom spines by `offset` points and trims them to the
  end ticks. The top and
  right spines and their ticks go. Pass `categorical_x=True` for bar charts,
  `categorical_y=True` for horizontal bars, and both for heatmaps. A categorical
  axis has no spine or offset, so its labels sit by the data. Call it after
  everything on the figure is drawn. It picks ticks for the axes size after
  layout.
- `fit_legend(fig)` grows the figure by the room legends outside the axes
  take. Constrained layout otherwise keeps `figsize` and shrinks the axes, so
  a square plot turns into a rectangle. The figure ends up larger than
  `figsize`. Call it after adding every legend, before `despine`.
- `label_points(ax, points, labels, offsets=None, leader=True, **text_kwargs)`
  labels the collection
  `ax.scatter` returns. Each label sits in a box outlined like its leader
  line, or stays bare with `bbox=None`. It moves labels off each other and off
  the markers, but a label can still touch a marker. That happens most near
  the edge of the axes, since labels stay inside them. Call it last, after
  `despine` and every title and legend. Needs the `labels` extra.
  `offsets=[(dx, dy), ...]`, in points, fixes each label's place instead and
  needs no extra. `leader=False` drops the leader lines.
- `save_figure(fig, path, formats=("svg", "png"), **savefig_kwargs)` writes
  `path.svg` and `path.png`, or one file per entry in `formats`. It drops a
  suffix that names a format, such as `.svg` or `.pdf`, and keeps any other
  dots, so `x.curve` gives `x.curve.svg` and `x.curve.png`.
- `zero_line(ax, axis="y", **line_kwargs)` draws a bold line at zero, for
  values that change sign. It uses the frame color at twice the grid width.
  `axis="x"` draws x = 0 and `"both"` draws both. The limits widen to include
  zero.

## Recipes per look

Looks only set rcParams. Anything that needs code per axes is a recipe.

**plain.** Call `despine` on every axes. Without it you get matplotlib's
default frame minus the top and right spines. `despine` styles the left and
bottom spines only, so style a twin axes by hand:

```python
ax2 = ax.twinx()
ax2.plot(x, y2, clip_on=False)  # despine never sees the twin
ax2.set_ylim(0, 100)  # limits on ticks, so the spine ends on one
ax2.spines[["left", "bottom"]].set_visible(False)
ax2.spines["right"].set_visible(True)  # the look hides right spines
ax2.spines["right"].set_position(("outward", 10))
pk.despine(ax)  # last, once the twin is drawn
```

**lilaq.** lilaq's tick steps and dotted lines take a few lines of code:

```python
from matplotlib.ticker import MaxNLocator, NullLocator

ax.plot(x, y, marker="o")  # lilaq draws a dot at every data point
for axis in (ax.xaxis, ax.yaxis):
    axis.set_major_locator(MaxNLocator(7, steps=[1, 2, 5, 10]))
ax.xaxis.set_minor_locator(NullLocator())  # instead, on a categorical axis
```

lilaq rounds the step to 1, 2 or 5 and scales the tick count with the axis
length. That gives about five ticks on its default 6 × 4 cm plot, where
`nbins=7` switches steps at nearly the same data ranges. On axes of another
size, lilaq picks a different count. The dots stay out of the look
on purpose. A `marker` in `axes.prop_cycle` makes `plot(color="k")` use up a
cycle slot and beads dense lines. seaborn would also apply it unevenly.
`scatterplot` and `pointplot` drop it. `lineplot` keeps it but leaves it out of
the legend, and without `hue` takes the marker from the wrong cycle slot.

**pgl.** The Programmable Genomics Laboratory's papers use seaborn's
`whitegrid` without spines, with a dashed grid in place of tick marks. This look
follows them. Never call `despine` on it, because that brings the spines back.

```python
ax.xaxis.grid(False)  # categorical x; seaborn's categorical plots already do this
pk.zero_line(ax)  # values change sign; axis="x" for x = 0
```

Heatmaps need `ax.grid(False)`. To sign positive tick labels on a scale that
runs from − to +:

```python
from matplotlib.ticker import ScalarFormatter


class SignedFormatter(ScalarFormatter):
    def __call__(self, x, pos=None):
        text = super().__call__(x, pos)
        return f"+{text}" if x > 0 and text.strip("0.") else text


ax.xaxis.set_major_formatter(SignedFormatter())
```

It keeps matplotlib's formatting, so `+1.50` sits next to `−0.50`.

**zed.** The zed look reproduces figures drawn in Adobe Illustrator at 1:1:
Helvetica Neue, 0.75 pt spines with 5 pt inward ticks, 8 pt tick labels and
10 pt axis labels. Call `despine` with a 7.5 pt offset on every axes. Pass
scatter colors with `c=`, because `color=` also replaces the white marker edge.
matplotlib ties the scatter edge width to the line width, so pass
`linewidths=0.25` for the look's 0.25 pt edge.

```python
import numpy as np

zed = pk.PALETTES["zed"].colors
ax.scatter(x, y, c=zed["silver"], linewidths=0.25)
ax.set_yticks(np.linspace(0, 1, 6), ["0", "", "", "", "", "1"])  # label the ends only
pk.despine(ax, offset=7.5)
```

Box plots come filled, with a white median and no outliers. Set `widths=0.67`
and color each box. Take black from `zed["black"]`, because matplotlib's
`"black"` is `#000000`. The skill's `references/zed.md` has the other recipes:
direct labels, base-2 log ticks, reference lines, grey bands and panel letters.

**PDF text as TrueType.** `rc={"pdf.fonttype": 42}` embeds fonts as TrueType.
Use it only with a TrueType font. Anthropic Sans Text and New Computer Modern
are CFF (`.otf`) fonts. So is Overpass from Debian's `fonts-overpass` package.
macOS's Helvetica Neue (`HelveticaNeue.ttc`) is TrueType and works.
With them, matplotlib writes CFF outlines into a TrueType font stream. Common
viewers still render that PDF, but it breaks the PDF spec and strict checkers
flag it.

## Fonts

No fonts ship with poikilos. Each look names a fallback chain that ends in a
DejaVu family bundled with matplotlib, so a missing font falls back silently.

- plain: Anthropic Sans Text, Google Sans Flex, Arimo, Arial, DejaVu Sans.
- lilaq: New Computer Modern, Palatino Linotype, P052, DejaVu Serif. Math uses
  matplotlib's bundled Computer Modern.
- pgl: Overpass, Arial, Arimo, DejaVu Sans.
- zed: Helvetica Neue, Helvetica, Nimbus Sans, Arial, DejaVu Sans. Titles use
  the Medium weight. matplotlib 3.11 reads each face of macOS's
  `HelveticaNeue.ttc`. The fallbacks have Helvetica's shapes but no Medium.

matplotlib knows New Computer Modern as `NewComputerModern`. From
[CTAN](https://ctan.org/pkg/newcomputermodern), install only the 10 pt cuts
Typst uses: `NewCM10-Regular.otf`, `NewCM10-Italic.otf`, `NewCM10-Bold.otf`,
`NewCM10-BoldItalic.otf` and `NewCMMath-Regular.otf`. The 8 pt and Book cuts
register under the same family and weight, and matplotlib may pick any of them.

After installing a font, delete matplotlib's font cache so it rescans:

```bash
rm "$(python -c 'import matplotlib; print(matplotlib.get_cachedir())')"/fontlist-*.json
```

Check that a family resolves without falling back:

```python
from matplotlib.font_manager import FontProperties, findfont

findfont(FontProperties(family="NewComputerModern"), fallback_to_default=False)
```

The themes set `svg.fonttype: none`, so an SVG names its font instead of
embedding it. A viewer without the font shows a substitute. PNGs look the same
everywhere.

## Limits

- matplotlib hard-codes the colors of `table` cells, `quiver`, `barbs` and
  `spy`. Palettes do not reach them.
- rcParams cannot fix the size of the data area. The lilaq look's figure size
  gives about lilaq's 6 × 4 cm for a plot with a title and both axis labels.
  The zed look's figure size gives the same plot a 150 pt square.
- Mathtext draws scripts at 70 % of the text size. Illustrator's default, which
  zed copies elsewhere, is 58.3 %.
- matplotlib's `scatter` draws from its own color cycle, separate from `plot`.
- Some cycle colors have under 3:1 contrast with their background, such as two
  petroff10 colors on `lilaq-moon`. `scripts/gallery.py` prints the ratios.
  Text has at least 4.5:1 in every palette, and a test checks it.

## Development

```bash
uv sync
uv run pytest                          # includes image tests against tests/baseline/3.11
uv run ruff check && uv run ruff format --check
uv run python scripts/gallery.py       # regenerate assets/gallery
uv run python scripts/lilaq_parity.py  # compare with lilaq 0.6.0 in build/parity
```

[CHANGELOG.md](https://github.com/ramirc0/poikilos/blob/main/CHANGELOG.md) has
the version history and the versioning rules.

## License

MIT (see `LICENSE`).
