Skip to main content

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
lilaq, lilaq-moon lilaq lilaq 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 plain-dark
rose-pine-dawn rose-pine
lilaq lilaq-moon
pgl
zed

rose-pine-moon has its own gallery image.

Install

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

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.

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.

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:

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:

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:

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.

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 +:

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.

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, 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:

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

Check that a family resolves without falling back:

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

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 has the version history and the versioning rules.

License

MIT (see LICENSE).

Metadata

Release files for poikilos 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for poikilos 1.1.0
File Size Uploaded Provenance
poikilos-1.1.0.tar.gz 585.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for poikilos 1.1.0
File Interpreter ABI Platform
poikilos-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 608.7 kB

Release files / poikilos-1.1.0.tar.gz

Download URL poikilos-1.1.0.tar.gz
Size 585.7 kB
Tags Source
SHA-256 checksum
How to use checksums
3414fffd7b43a213ea4a6c4be2d2b81ece722161f18958194c83adaa3b446e56
BLAKE2b-256 checksum
How to use checksums
cae5d2968e01955a11cf8e531e14a586a248159f1d49ed160c08cdf19a5b01a1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / poikilos-1.1.0-py3-none-any.whl

Download URL poikilos-1.1.0-py3-none-any.whl
Size 23.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2c5ac4e70f32be05e3535b48a355974d0d927aeb0aec4fd60635f9d64cb3f7cd
BLAKE2b-256 checksum
How to use checksums
2c08d532b39f007ffd3bb870ded7cefa2166ce133873d325847c4dff36c16a7b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for poikilos 1.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page