Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,95 @@ All notable changes to memeplotlib are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] — 2026-04-28

This release rewires the rendering pipeline around the memegen API. By
default, memes are now composed **server-side** by memegen and fetched as a
finished image; a new client-side **Pillow** backend handles custom local
images and any feature the API can't express (per-line `fontsize`, custom
outlines, ``**text_kwargs``, per-line overrides). The legacy
matplotlib-text rendering is preserved as `backend="matplotlib"` for
explicit opt-in.

### Breaking

- **Default rendering pipeline changed.** `meme()` calls now hit
`https://api.memegen.link/images/<id>/<line_1>/.../<line_n>.<ext>?...`
and `imshow` the response — output appearance, fonts, and stroke shape
may differ from v0.2 / v0.4 baselines. To preserve exact previous
behaviour, pass `backend="matplotlib"` or set
`config["backend"] = "matplotlib"`.
- **memegen never honours custom outlines or per-line `fontsize`.** Under
`backend="auto"`, passing `outline_color`, `outline_width`, `fontsize`,
any `**text_kwargs`, or per-line overrides routes to the Pillow
backend automatically. Under `backend="memegen"` they are silently
ignored.
- **Image baselines under `tests/baseline/` were regenerated.** Local
forks pinning the old baselines should regenerate after upgrading.

### Added

- **memegen URL builder.** New public function
`memeplotlib.build_memegen_url(template_id, lines, *, api_base, ...)` —
see [docs/url_construction.rst](docs/url_construction.rst) for the full
grammar (escape table, query parameters, font / style / overlay
reference). Adapted from
[jacebrowning/memegen#993](https://github.com/jacebrowning/memegen/issues/993).
- **`backend` parameter** on `meme()` and `Meme`. Values: `"auto"`
(default), `"memegen"`, `"pillow"`, `"matplotlib"`. Also a
`Meme.with_backend(...)` chainable setter.
- **memegen knobs on `meme()`**: `template_style`, `extension`, `width`,
`height`, `layout`, `background`, `overlays`. Mirrors of the same
query parameters memegen accepts.
- **Per-line overrides** via `Meme.line(index, text, *, fontsize=None,
color=None, font=None, position=None)`. Using any override forces the
Pillow backend.
- **Pillow backend** (`memeplotlib._pillow.render_pillow`) with
TTF resolution from the bundled Anton font and standard system font
paths, multiline shrink-to-fit via `ImageDraw.textbbox`, and
stroke-aware caption drawing.
- **Template metadata fields**: `lines_count`, `overlays_count`,
`styles`, `is_memegen`. The CLI `info` subcommand now surfaces all
three.
- **CLI flags**: `--backend`, `--ext`, `--width`, `--height`, `--layout`,
`--background`, `--template-style` on the `meme` / `create`
subcommand.
- **MCP `meme` tool** accepts `backend`, `extension`, `width`, `height`,
`template_style`, `font`, `color`, `fontsize`.
- **Config keys**: `backend`, `extension`, `width`, `height`, `layout`,
`background`. Validated by `_VALIDATORS` like every existing key.
- **`OverlaySpec` `TypedDict`** for ad-hoc overlay placements
(`{style, center, scale}`).
- **`memegen_rendered_pattern(template_id)` test helper** in
`tests/conftest.py` for regex-mocking the dynamic rendered URLs.
- **`uses_default_backend` pytest marker** for tests that opt out of
the legacy-matplotlib autouse fixture.

### Fixed

- **`Template.get_image()` now respects `config["cache_enabled"]`.**
Previously it always consulted the cache instance regardless of the
setting — tests that disabled caching via config could still read
stale data from the user's real cache directory.

### Migration

```python
# Before (v0.4): matplotlib drew the captions client-side.
memes.meme("buzz", "memes", "memes everywhere")

# After (v0.5): memegen renders server-side by default.
# Pin the old behaviour with backend="matplotlib":
memes.meme("buzz", "memes", "memes everywhere", backend="matplotlib")

# Or globally:
memes.config["backend"] = "matplotlib"

# Existing fontsize / outline knobs still work — under backend="auto",
# they transparently route through the Pillow backend instead:
memes.meme("buzz", "hello", fontsize=48, outline_color="red")
```

## [0.2.0] — 2026-04-28

This release modernizes the public API to match scientific-Python
Expand Down
69 changes: 58 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,65 @@ re-exported from `memeplotlib/__init__.py`. The source lives under

| Module | Role |
|---|---|
| `_api.py` | Functional API: `meme()`, `memify()`. |
| `_meme.py` | OO API: `Meme` builder with chainable `top/bottom/text` and a `render/show/save` cycle. |
| `_template.py` | `Template`, `TemplateRegistry`, memegen client (retry-aware via `requests.Session` + `urllib3.Retry`). |
| `_rendering.py` | matplotlib-side rendering: bundled font registration, text fitting, outline drawing, `render_meme`, `render_memify`. |
| `_text.py` | Text styling helpers (`upper`/`lower`/`none`) and memegen URL encoding. |
| `_api.py` | Functional API: `meme()`, `memify()`. Sentinel-based detection of user-supplied knobs feeds the dispatcher's `auto`-backend selector. |
| `_meme.py` | OO API: `Meme` builder with chainable `top/bottom/text/line/with_backend` and a `render/show/save` cycle. |
| `_template.py` | `Template`, `TemplateRegistry`, memegen client (retry-aware via `requests.Session` + `urllib3.Retry`). `Template` now carries `lines_count`, `overlays_count`, `styles`, `is_memegen`. |
| `_url.py` | memegen URL builder: `build_memegen_url`, `OverlaySpec`, `MEMEGEN_FONT_ALIASES`, `memegen_font_for`. |
| `_rendering.py` | Three-backend dispatcher (`render_meme` / `render_memify`): server-side memegen URL fetch, Pillow client-side draw, and the legacy matplotlib `Axes.text` path. Backend auto-selection lives in `_select_backend`. |
| `_pillow.py` | Pillow renderer: TTF resolution, multiline shrink-to-fit via `ImageDraw.textbbox`, stroke-aware caption drawing. |
| `_text.py` | Text styling helpers (`upper`/`lower`/`none`) and memegen URL encoding (`encode_text_for_url`). |
| `_config.py` | RcParams-style `MemeplotlibConfig` mapping + `rc_context` context manager. |
| `_cache.py` | Two-level cache: in-memory LRU + disk cache via `platformdirs`. |
| `__main__.py` | Argparse CLI: `memeplotlib {list,search,info,create}`. |
| `_mcp.py` | (Phase 8) MCP server using the official `mcp` SDK. |
| `_cache.py` | Two-level cache: in-memory LRU + disk cache via `platformdirs`. Caches both blanks and memegen-rendered URLs (keyed by URL hash). |
| `__main__.py` | Argparse CLI: `memeplotlib {list,search,info,meme}`. |
| `_mcp.py` | MCP server using the official `mcp` SDK. |
| `fonts/Anton-Regular.ttf` | Bundled SIL OFL display font, registered at import time. |

## API contract — match this for new code

- **`ax=None` pattern.** Public rendering functions accept `ax: Axes | None = None` and create a new figure/axes when `None`. Mirrors `seaborn` and `pandas.plot`.
- **Return `(Figure, Axes)`** (or just `Axes` for single-axes helpers). Do **not** call `plt.show()` implicitly. `meme()` and `memify()` default to `show=False`.
- **Forward `**kwargs` to `Axes.text`** for caption rendering. User values override the meme-specific defaults.
- **`Meme` class is chainable** — `top/bottom/text` return `self`. Don't break this.
- **`Meme` class is chainable** — `top/bottom/text/line/with_backend` return `self`. Don't break this.
- **`config` is a `MutableMapping`**, not an attribute namespace. Use `config["font"] = ...`, never `config.font = ...`. For scoped overrides, use `with rc_context({"font": "comic"}):`.
- **`config` keys are validated**: setting an unknown key raises `KeyError`; setting a wrong-typed value raises `ValueError`. The set of keys is fixed in `_config._VALIDATORS`.
- **NumPy-format docstrings** for every public function and method. Sections in this order: Parameters, Returns, Raises, Notes, Examples. Same-line summaries (`"""Do X."""\n\nLong body...`) are the matplotlib convention and are excluded from `numpydoc validate` (GL01).
- **Type hints on every public signature**, `mypy --strict src/memeplotlib` clean. Use `# type: ignore[code]` only for matplotlib internals that strict typing genuinely cannot express, with a one-line comment explaining why.

### Rendering backends — match this for new code

- **Three backends**: `"memegen"` (default for memegen catalogue templates),
`"pillow"` (default for custom images / when client-only knobs are
passed), `"matplotlib"` (legacy, opt-in). The `"auto"` policy picks
between the first two; `"matplotlib"` must be requested explicitly.
- **Sentinel pattern in `meme()`**: every knob that should influence
backend selection (`outline_color`, `outline_width`, `fontsize`) defaults
to the module-private `_UNSET` sentinel — never `None` — so the
dispatcher can tell "user passed it" from "user accepted the default".
- **Forward `**kwargs` to `Axes.text`** under the matplotlib backend only.
Passing any `**text_kwargs` under `backend="auto"` forces the Pillow
fallback; under `backend="memegen"` they are silently ignored (memegen
has no equivalent).
- **memegen never honours custom outlines**: any non-default
`outline_color` / `outline_width` forces the Pillow backend under
`auto`. memegen always renders a hard-coded black stroke.
- **Per-line overrides** belong on `Meme.line(index, text, ...)`, not
`Meme.text(index, text)`. `text()` keeps its existing
`(index, text)` signature; `line()` extends it with kwarg overrides
and forces the Pillow backend.
- **Memegen font set is a closed set** — `_url.MEMEGEN_FONT_ALIASES`
enumerates names memegen accepts. `memegen_font_for(font)` returns
`None` for fonts memegen can't render; the dispatcher then routes to
Pillow.
- **memegen URL escapes**: build URLs via `build_memegen_url`, never
string-concatenate paths. Empty lines are encoded as `_` to preserve
slot ordering. Tilde escapes (`~q`, `~a`, ...) must NOT be
percent-encoded by query-param quoting; `_format_query_value` keeps
`~,/:` safe.
- **Backend selection respects `config["backend"]`**: `render_meme` first
collapses `backend="auto"` to `config["backend"]`, then runs the
heuristic in `_select_backend`. Tests can pin behaviour by setting
`config["backend"] = "matplotlib"` (the legacy autouse fixture in
`tests/conftest.py` does exactly this).

## Test conventions

- Tests live in `tests/`, mirroring `src/memeplotlib/` modules.
Expand Down Expand Up @@ -129,7 +166,17 @@ memeplotlib-mcp # reads JSON-RPC on stdin
- **`cache_enabled = True` in tests will read from the user's real
cache directory.** Tests touching the registry should
`monkeypatch.setitem(config, "cache_enabled", False)` and supply a
`tmp_path` cache dir.
`tmp_path` cache dir. `Template.get_image()` honours
`config["cache_enabled"]` so disabling at the config level is
sufficient — you do not also need to construct a fresh `TemplateCache`.
- **memegen rendered URLs are dynamic** — the path embeds caption text,
so test fixtures should mock with regex (use the
`memegen_rendered_pattern("buzz")` helper from
`tests/conftest.py`) rather than hard-coding the full URL.
- **The legacy matplotlib-backend autouse fixture** in
`tests/conftest.py` pins `config["backend"] = "matplotlib"` for every
test. New tests that need to exercise the memegen / pillow paths
must opt in with `@pytest.mark.uses_default_backend`.

## Branching / PRs

Expand Down
69 changes: 61 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

![memeplotlib logotype](/docs/_static/logo.png)

Memes with Python's matplotlib. Create image macro memes using matplotlib for
rendering and the [memegen](https://github.com/jacebrowning/memegen) API for
template discovery.
Memes with Python's matplotlib. Create image-macro memes by either letting
the [memegen](https://github.com/jacebrowning/memegen) API render them
server-side (the default) or falling back to a local Pillow renderer when
the API can't express what you want — custom local images, explicit per-line
font sizes, custom outlines, or per-line position overrides.

## Installation

Expand Down Expand Up @@ -95,6 +97,48 @@ memes.config["fontsize"] = 96
memes.meme("buzz", "rotated", rotation=15, alpha=0.8)
```

## Backends

`memeplotlib` ships three rendering backends. The default `"auto"` policy
picks the best fit; pass `backend="..."` to override.

| Backend | What it does | Honours |
| ------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `memegen` | Builds a memegen rendering URL and `imshow`s the response. No client-side text drawing. | `font`, `color`, `style`, `template_style`, `width`, `height`, `layout`, `background`, `overlays`, `extension` (`png`/`jpg`/`gif`/`webp`) |
| `pillow` | Downloads the blank, draws captions client-side with `PIL.ImageDraw`. Used for custom local images and any feature memegen can't express. | All caption styling including per-line `fontsize`, custom `outline_color` / `outline_width`, and per-line overrides via `Meme.line(...)`. |
| `matplotlib` | Legacy: draws captions with `Axes.text` + `patheffects.Stroke`. Kept for backwards compatibility. | All caption styling **plus** `**kwargs` forwarded to `Axes.text` (e.g. `rotation`, `alpha`). |

`backend="auto"` selects:

- **`memegen`** — when the template comes from the memegen catalogue and the
caller didn't pass `fontsize`, a non-default `outline_color` /
`outline_width`, `**text_kwargs`, or per-line overrides.
- **`pillow`** — for custom local images / arbitrary URLs, or when any of
the above client-only features were requested.

```python
# Default: memegen renders this server-side.
memes.meme("buzz", "memes", "memes everywhere", template_style="default",
font="impact", width=600)

# Pillow fallback — explicit fontsize forces it.
memes.meme("/path/to/photo.jpg", "top", "bottom", fontsize=48,
outline_color="red", outline_width=4)

# Per-line overrides force the Pillow backend.
from memeplotlib import Meme
Meme("buzz").top("hi").line(1, "world", fontsize=72, color="yellow").save("out.png")

# Legacy matplotlib path (preserves old behaviour exactly):
memes.meme("buzz", "rotated", rotation=15, alpha=0.8, backend="matplotlib")
```

See [`docs/url_construction.rst`](docs/url_construction.rst) for the full
memegen URL grammar — escape table, query parameters, font / style / overlay
reference — adapted from [jacebrowning/memegen #993][issue-993].

[issue-993]: https://github.com/jacebrowning/memegen/issues/993

## Use from agents

```bash
Expand All @@ -121,11 +165,20 @@ sphinx-build -W docs docs/_build

## How it works

1. Templates are fetched from the [memegen API](https://api.memegen.link)
(blank background images + metadata) and cached locally.
2. Text is rendered with matplotlib's text system using
`patheffects.Stroke` for the classic outlined meme look.
3. The bundled Anton font (Impact-like, SIL OFL licensed) is used as a
1. Template metadata comes from the [memegen API](https://api.memegen.link)
(`/templates/`, `/templates/<id>`); blank backgrounds and rendered memes
alike are cached on disk.
2. **Memegen backend** (default for memegen IDs): a fully-formed rendering
URL (`/images/<id>/<line_1>/.../<line_n>.<ext>?style=...&font=...`) is
built via `memeplotlib.build_memegen_url`. The composed image is fetched
and displayed with `Axes.imshow`.
3. **Pillow backend** (default for custom images, or when client-only
features are requested): the blank is fetched once, then captions are
drawn with `PIL.ImageDraw` using stroke-aware text rendering and a
shrink-to-fit loop, and the composed RGBA array is `imshow`n.
4. **Matplotlib backend** (legacy, opt-in): captions are drawn with
`Axes.text` plus `patheffects.Stroke` for the classic outlined look.
5. The bundled Anton font (Impact-like, SIL OFL licensed) is used as a
fallback for systems where Impact isn't installed.

## Related projects
Expand Down
2 changes: 1 addition & 1 deletion conda-recipe/meta.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% set name = "memeplotlib" %}
{% set version = "0.2.0" %}
{% set version = "0.5.0" %}

package:
name: {{ name|lower }}
Expand Down
6 changes: 6 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Top-level functions

.. autofunction:: rc_context

.. autofunction:: build_memegen_url

Classes
--------

Expand All @@ -31,6 +33,10 @@ Classes
:members:
:show-inheritance:

.. autoclass:: OverlaySpec
:members:
:show-inheritance:

Singletons and exceptions
--------------------------

Expand Down
38 changes: 34 additions & 4 deletions docs/conventions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,46 @@ you want auto-display in a script.
Forward ``**kwargs`` to ``Axes.text``
--------------------------------------

Both :func:`~memeplotlib.meme` and :func:`~memeplotlib.memify` accept
extra ``**kwargs`` that are passed through to :meth:`Axes.text` for each
:func:`~memeplotlib.memify` and the ``backend="matplotlib"`` /
``backend="pillow"`` paths of :func:`~memeplotlib.meme` accept extra
``**kwargs`` that are passed through to :meth:`Axes.text` for each
rendered caption. This lets you tweak ``alpha``, ``rotation``, ``zorder``,
or any other matplotlib text parameter without library-side support:

.. code-block:: python

memes.meme("buzz", "rotated", rotation=15, alpha=0.8)
memes.meme("buzz", "rotated", rotation=15, alpha=0.8, backend="matplotlib")

User-supplied kwargs override the meme-specific defaults.
Under ``backend="auto"``, passing any ``**text_kwargs`` forces the Pillow
backend. memegen has no equivalent for arbitrary ``Axes.text`` arguments
so they cannot be honoured by the server-side renderer.

Backends
---------

memeplotlib ships three rendering backends:

``"memegen"``
Build a memegen rendering URL via
:func:`~memeplotlib.build_memegen_url` and ``imshow`` the response.
Honours every memegen parameter — ``font``, ``color``, ``style``,
``template_style``, ``width``, ``height``, ``layout``, ``background``,
``overlays``, ``extension``.

``"pillow"``
Download the blank, draw captions client-side with
:class:`PIL.ImageDraw.ImageDraw`. Honours per-line ``fontsize``, custom
outlines, and per-line overrides via :meth:`Meme.line`.

``"matplotlib"``
Legacy: draw captions with :meth:`Axes.text` plus
:class:`patheffects.Stroke`. Forwards ``**kwargs`` to ``Axes.text``.

The default ``"auto"`` selects ``"memegen"`` for memegen-catalogue
templates with no client-only knobs, and ``"pillow"`` otherwise.
Passing any of ``fontsize``, a non-default ``outline_color`` /
``outline_width``, ``**text_kwargs``, or per-line overrides forces
Pillow under ``auto``. See :doc:`url_construction` for the URL grammar.

``rc_context`` for scoped config overrides
-------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Contents

tutorial
user_guide
url_construction
conventions
api
auto_examples/index
Expand Down
Loading