From 98de71b54701ac0648f9658bc7ec3d80ba802a73 Mon Sep 17 00:00:00 2001 From: Brian Keegan Date: Tue, 28 Apr 2026 16:49:38 -0400 Subject: [PATCH] Render via memegen API; fall back to Pillow for custom images and per-line styling Rewires the rendering pipeline around three backends (auto / memegen / pillow / matplotlib). Memes from the memegen catalogue are now composed server-side via /images//.../.?... URLs and `imshow`n; custom local images and any feature memegen can't express (per-line fontsize, custom outlines, **text_kwargs, per-line overrides) route to a new client-side Pillow renderer. The legacy matplotlib `Axes.text` + patheffects path is preserved as `backend="matplotlib"`. References jacebrowning/memegen#993 for the URL grammar and adds a docs/url_construction.rst guide adapted for memeplotlib users. - New `_url.py` (build_memegen_url, OverlaySpec, MEMEGEN_FONT_ALIASES) and `_pillow.py` (TTF resolution, multiline shrink-to-fit, stroke-aware drawing). - `meme()` uses sentinel detection to know which knobs the caller passed so `auto` can route correctly. New params: backend, extension, width, height, layout, background, overlays, template_style. - `Meme.line(index, text, *, fontsize, color, font, position)` for per-line overrides; `Meme.with_backend(...)` chainable setter. - `Template` carries lines_count / overlays_count / styles / is_memegen. - `Template.get_image()` now respects config["cache_enabled"] (latent bug). - CLI gains --backend / --ext / --width / --height / --layout / --background / --template-style. - MCP `meme` tool accepts the new knobs. - Version bumped to 0.5.0 across pyproject.toml, __init__.py, and both conda recipes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 89 +++++++ CLAUDE.md | 69 ++++- README.md | 69 ++++- conda-recipe/meta.yaml | 2 +- docs/api.rst | 6 + docs/conventions.rst | 38 ++- docs/index.rst | 1 + docs/url_construction.rst | 154 ++++++++++++ packaging/conda-forge/meta.yaml | 2 +- pyproject.toml | 3 +- src/memeplotlib/__init__.py | 17 +- src/memeplotlib/__main__.py | 50 +++- src/memeplotlib/_api.py | 152 ++++++++--- src/memeplotlib/_config.py | 38 +++ src/memeplotlib/_mcp.py | 42 +++- src/memeplotlib/_meme.py | 104 +++++++- src/memeplotlib/_pillow.py | 305 ++++++++++++++++++++++ src/memeplotlib/_rendering.py | 432 ++++++++++++++++++++++++++------ src/memeplotlib/_template.py | 37 ++- src/memeplotlib/_url.py | 239 ++++++++++++++++++ tests/conftest.py | 40 +++ tests/test_backends.py | 266 ++++++++++++++++++++ tests/test_url.py | 137 ++++++++++ 23 files changed, 2146 insertions(+), 146 deletions(-) create mode 100644 docs/url_construction.rst create mode 100644 src/memeplotlib/_pillow.py create mode 100644 src/memeplotlib/_url.py create mode 100644 tests/test_backends.py create mode 100644 tests/test_url.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b983561..c3a590f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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///.../.?...` + 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 diff --git a/CLAUDE.md b/CLAUDE.md index 21a3800..0844f7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 diff --git a/README.md b/README.md index 6be952e..7ee5bbf 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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/`); blank backgrounds and rendered memes + alike are cached on disk. +2. **Memegen backend** (default for memegen IDs): a fully-formed rendering + URL (`/images///.../.?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 diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index efafffb..683045e 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -1,5 +1,5 @@ {% set name = "memeplotlib" %} -{% set version = "0.2.0" %} +{% set version = "0.5.0" %} package: name: {{ name|lower }} diff --git a/docs/api.rst b/docs/api.rst index 974d154..28e330b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -12,6 +12,8 @@ Top-level functions .. autofunction:: rc_context +.. autofunction:: build_memegen_url + Classes -------- @@ -31,6 +33,10 @@ Classes :members: :show-inheritance: +.. autoclass:: OverlaySpec + :members: + :show-inheritance: + Singletons and exceptions -------------------------- diff --git a/docs/conventions.rst b/docs/conventions.rst index 59146bd..d5b57e2 100644 --- a/docs/conventions.rst +++ b/docs/conventions.rst @@ -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 ------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 8a73383..ee4eeb8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,6 +36,7 @@ Contents tutorial user_guide + url_construction conventions api auto_examples/index diff --git a/docs/url_construction.rst b/docs/url_construction.rst new file mode 100644 index 0000000..08c8617 --- /dev/null +++ b/docs/url_construction.rst @@ -0,0 +1,154 @@ +Constructing memegen URLs +========================== + +When ``memeplotlib`` renders a memegen-catalogue meme it does so by +asking the upstream API to compose the image — same path-and-query +grammar documented for ``api.memegen.link``. This page is the +``memeplotlib``-flavoured version of the unified guide proposed in +`jacebrowning/memegen#993 `_. + +You normally don't need to write a URL by hand — :func:`memeplotlib.meme` +forwards every relevant knob — but the builder is a public API +(:func:`memeplotlib.build_memegen_url`) for agents and scripts that need +deterministic URLs. + +URL shape +--------- + +.. code-block:: + + {api_base}/images/{template_id}/{line_1}/{line_2}/.../{line_n}.{ext}?{query} + +Each ``line_i`` is encoded so it survives a URL path. Empty slots are +encoded as ``_`` to preserve slot ordering. Up to ``Template.lines_count`` +slots are accepted; trailing empty slots may be omitted but middle empty +slots must remain. + +Escape table +------------ + +Memegen uses a tilde-escape scheme for characters that are illegal or +ambiguous in a URL path. ``memeplotlib`` ships +:func:`memeplotlib._text.encode_text_for_url` which applies it. + +========== ========= +Character Encoding +========== ========= +space ``_`` +``_`` ``__`` +``-`` ``--`` +``"`` ``''`` +``?`` ``~q`` +``&`` ``~a`` +``%`` ``~p`` +``#`` ``~h`` +``/`` ``~s`` +``\`` ``~b`` +``<`` ``~l`` +``>`` ``~g`` +newline ``~n`` +========== ========= + +Empty caption slots become a single ``_`` rather than the empty string. +Emoji and HTML aliases (``:thumbsup:``, ``:fire:``) are passed through +verbatim — memegen resolves them server-side. + +Query parameters +---------------- + +Every parameter below is optional. ``memeplotlib`` only emits a query +string when at least one parameter is supplied. + +================ ================================================================ +``style=`` Template-specific style name (e.g. ``maga`` for ``ds``) **or** an + arbitrary image URL for an ad-hoc overlay. May appear multiple + times when overlays are stacked. +``font=`` Memegen font alias. The closed set + (:data:`memeplotlib._url.MEMEGEN_FONT_ALIASES`) is + ``impact``, ``thick``, ``thin``, ``tiny``, ``comic``, + ``notosans``, ``kalam``, ``he``, ``jp``, ``tw``, + ``default``. Fonts outside that set fall through to the + Pillow backend automatically under ``backend="auto"``. +``color=`` ``"fg"`` or ``"fg,bg"``. HTML names or hex codes. +``width=`` Output width in pixels. +``height=`` Output height in pixels. +``layout=`` Alternate layout (e.g. ``top``). +``background=`` Custom background URL. Composes with ``style=`` overlays. +``center=`` ``"x,y"`` overlay anchor (``[0, 1]`` floats). Paired with an + ``style=`` overlay. +``scale=`` Overlay scale factor. Paired with an ``style=`` overlay. +================ ================================================================ + +Output formats +-------------- + +The path extension selects the format: + +- ``.png`` — default; lossless transparency. +- ``.jpg`` / ``.jpeg`` — smaller, lossy. +- ``.gif`` — animated when the template's blank is animated; static + background + animated text otherwise. +- ``.webp`` — modern lossy/lossless hybrid. + +Set with ``meme(..., extension="jpg")`` or ``config["extension"] = "jpg"``. + +Worked examples +--------------- + +A plain caption pair: + +.. code-block:: python + + from memeplotlib import build_memegen_url + + build_memegen_url( + "buzz", + ["memes", "memes everywhere"], + api_base="https://api.memegen.link", + ) + # → https://api.memegen.link/images/buzz/memes/memes_everywhere.png + +A template style plus dimensions plus a two-colour caption: + +.. code-block:: python + + build_memegen_url( + "ds", + ["the dress is black and blue.", "the dress is gold and white."], + api_base="https://api.memegen.link", + template_style="maga", + font="comic", + color="white,black", + width=600, + extension="jpg", + ) + # → /images/ds/the_dress_is_black_and_blue./the_dress_is_gold_and_white..jpg + # ?style=maga&font=comic&color=white,black&width=600 + +A custom-background ad-hoc overlay: + +.. code-block:: python + + build_memegen_url( + "fine", + ["this is fine"], + api_base="https://api.memegen.link", + background="https://example.com/bg.png", + overlays=[{"style": "https://example.com/dog.png", + "center": (0.5, 0.7), "scale": 0.4}], + ) + +Notes for automated clients +--------------------------- + +- Bootstrap with ``GET /templates/`` once and cache. Per-template metadata + (``Template.from_memegen``) hits ``GET /templates/``. +- ``Template.example`` is the canonical "guaranteed-valid URL" smoke test + — useful for asserting that a template id is live without composing a + caption yourself. +- Memegen normalises raw POSTed text to the escape forms above and returns + the canonical URL. ``memeplotlib`` performs the same encoding locally + via :func:`encode_text_for_url`. +- Behaviour when ``lines`` exceeds ``Template.lines_count``: extra path + segments are ignored by memegen. ``memeplotlib`` does not pre-truncate; + pass exactly the slots you intend. diff --git a/packaging/conda-forge/meta.yaml b/packaging/conda-forge/meta.yaml index a986fac..843f9a7 100644 --- a/packaging/conda-forge/meta.yaml +++ b/packaging/conda-forge/meta.yaml @@ -14,7 +14,7 @@ # retired in favor of conda-forge/feedstocks/memeplotlib-feedstock. {% set name = "memeplotlib" %} -{% set version = "0.2.0" %} +{% set version = "0.5.0" %} package: name: {{ name|lower }} diff --git a/pyproject.toml b/pyproject.toml index ed1e255..256cec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "memeplotlib" -version = "0.2.0" +version = "0.5.0" description = "Memes with Python's matplotlib" readme = "README.md" license = "MIT" @@ -107,6 +107,7 @@ markers = [ "integration: marks integration tests (require network)", "mpl_image_compare: pytest-mpl image comparison test", "allow_network: opt out of the offline-only autouse fixture", + "uses_default_backend: opt out of the legacy matplotlib-backend fixture", ] [tool.coverage.run] diff --git a/src/memeplotlib/__init__.py b/src/memeplotlib/__init__.py index 57c5060..bd1d2db 100644 --- a/src/memeplotlib/__init__.py +++ b/src/memeplotlib/__init__.py @@ -1,7 +1,8 @@ -"""memeplotlib -- Memes with Python's matplotlib. +"""memeplotlib -- Memes with Python's matplotlib + memegen. -Create image macro memes using matplotlib for rendering and the memegen API -for template discovery. +Render image-macro memes by either letting the memegen API compose them +server-side (the default) or falling back to a local Pillow renderer for +custom images and per-line styling that the API can't express. Quick start:: @@ -9,21 +10,29 @@ fig, ax = memes.meme("buzz", "memes", "memes everywhere") + # Force the local Pillow renderer for fine-grained control: + fig, ax = memes.meme( + "buzz", "memes", "memes everywhere", + fontsize=48, outline_width=4, + ) """ from memeplotlib._api import meme, memify from memeplotlib._config import MemeplotlibConfig, config, rc_context from memeplotlib._meme import Meme from memeplotlib._template import Template, TemplateRegistry +from memeplotlib._url import OverlaySpec, build_memegen_url -__version__ = "0.2.0" +__version__ = "0.5.0" __all__ = [ "Meme", "MemeplotlibConfig", + "OverlaySpec", "Template", "TemplateRegistry", "__version__", + "build_memegen_url", "config", "meme", "memify", diff --git a/src/memeplotlib/__main__.py b/src/memeplotlib/__main__.py index 1acd171..e93cd6e 100644 --- a/src/memeplotlib/__main__.py +++ b/src/memeplotlib/__main__.py @@ -53,6 +53,37 @@ def _build_parser() -> argparse.ArgumentParser: ) sp.add_argument("--fontsize", type=float, default=None, help="Font size in points.") sp.add_argument("--dpi", type=int, default=None, help="DPI for the rendered output.") + sp.add_argument( + "--backend", + default=None, + choices=["auto", "memegen", "pillow", "matplotlib"], + help="Rendering backend (default: 'auto').", + ) + sp.add_argument( + "--ext", + dest="extension", + default=None, + choices=["png", "jpg", "jpeg", "gif", "webp"], + help="Output format requested from memegen (default: png).", + ) + sp.add_argument( + "--width", type=int, default=None, help="Output width in pixels (memegen)." + ) + sp.add_argument( + "--height", type=int, default=None, help="Output height in pixels (memegen)." + ) + sp.add_argument("--layout", default=None, help="Memegen layout (e.g. 'top').") + sp.add_argument( + "--background", + default=None, + help="Custom background URL forwarded to memegen.", + ) + sp.add_argument( + "--template-style", + dest="template_style", + default=None, + help="Memegen template-specific style (e.g. 'maga').", + ) return parser @@ -122,7 +153,9 @@ def _cmd_info(template_id: str) -> int: print(f"ID: {tmpl.id}") print(f"Name: {tmpl.name}") print(f"URL: {tmpl.image_url}") - print(f"Lines: {len(tmpl.text_positions)}") + print(f"Lines: {tmpl.lines_count}") + print(f"Overlays: {tmpl.overlays_count}") + print(f"Styles: {', '.join(tmpl.styles) or '(default)'}") print(f"Keywords: {', '.join(tmpl.keywords) or '(none)'}") if tmpl.example: print(f"Example: {' / '.join(tmpl.example)}") @@ -137,7 +170,20 @@ def _cmd_meme(args: argparse.Namespace) -> int: from memeplotlib._api import meme kwargs: dict[str, Any] = {"show": False, "savefig": args.out} - for key in ("font", "color", "style", "fontsize", "dpi"): + for key in ( + "font", + "color", + "style", + "fontsize", + "dpi", + "backend", + "extension", + "width", + "height", + "layout", + "background", + "template_style", + ): value = getattr(args, key, None) if value is not None: kwargs[key] = value diff --git a/src/memeplotlib/_api.py b/src/memeplotlib/_api.py index d870a05..e3d287e 100644 --- a/src/memeplotlib/_api.py +++ b/src/memeplotlib/_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, Any @@ -11,6 +12,7 @@ from memeplotlib._config import config from memeplotlib._rendering import render_meme, render_memify from memeplotlib._template import _resolve_template +from memeplotlib._url import OverlaySpec if TYPE_CHECKING: from matplotlib.axes import Axes @@ -20,15 +22,29 @@ _cache = TemplateCache() +# Sentinel used to detect whether the caller explicitly supplied a value. +# We need this because `None` is also a legal user-supplied default for +# several knobs and the default-from-config behaviour was already in place. +_UNSET: Any = object() + + def meme( template: str, *lines: str, - font: str | None = None, - color: str | None = None, - outline_color: str | None = None, - outline_width: float | None = None, - fontsize: float | None = None, - style: str | None = None, + font: Any = _UNSET, + color: Any = _UNSET, + outline_color: Any = _UNSET, + outline_width: Any = _UNSET, + fontsize: Any = _UNSET, + style: Any = _UNSET, + backend: str | None = None, + extension: str | None = None, + width: int | None = None, + height: int | None = None, + layout: str | None = None, + background: str | None = None, + overlays: Sequence[OverlaySpec] | None = None, + template_style: str | None = None, show: bool = False, savefig: str | Path | None = None, figsize: tuple[float, float] | None = None, @@ -38,11 +54,11 @@ def meme( ) -> tuple[Figure, Axes]: """Create a meme from a template with text lines. - This is the main entry point for creating memes. The template can be: - - - A memegen template ID (e.g., ``"buzz"``, ``"drake"``, ``"doge"``) - - A local file path to an image - - An HTTP(S) URL to an image + The default ``backend="auto"`` selects the memegen rendering API for + memegen-catalogue templates, and falls back to a Pillow client-side + renderer for custom local images or whenever the user supplies a + feature memegen can't express (per-line ``fontsize``, custom outline, + ``Axes.text`` kwargs). Parameters ---------- @@ -50,36 +66,54 @@ def meme( Template identifier -- memegen ID, file path, or URL. *lines : str Text lines for each text position (top, bottom, etc.). - font : str or None, optional - Font family name (default: ``"impact"``). - color : str or None, optional - Text fill color (default: ``"white"``). - outline_color : str or None, optional - Text outline color (default: ``"black"``). - outline_width : float or None, optional - Outline stroke width (default: 2.0). - fontsize : float or None, optional - Font size in points. Auto-calculated if ``None``. - style : str or None, optional - Text transform -- ``"upper"``, ``"lower"``, or ``"none"`` - (default: ``"upper"``). + font : str, optional + Font family name (default ``config["font"]``). + color : str, optional + Text fill color (default ``config["color"]``). + outline_color : str, optional + Text outline color. Passing a non-default value under + ``backend="auto"`` forces the Pillow backend, since memegen renders + a hard-coded black stroke. + outline_width : float, optional + Outline stroke width. Passing a non-default value under + ``backend="auto"`` forces the Pillow backend. + fontsize : float, optional + Explicit font size in points. Forces the Pillow backend under + ``backend="auto"`` (memegen always auto-fits). + style : str, optional + Text transform -- ``"upper"``, ``"lower"``, or ``"none"``. + backend : str, optional + ``"auto"`` (default), ``"memegen"``, ``"pillow"``, or + ``"matplotlib"`` (legacy, draws captions with + :meth:`matplotlib.axes.Axes.text`). + extension : str, optional + Output format requested from memegen -- ``"png"``, ``"jpg"``, + ``"gif"``, or ``"webp"``. + width, height : int, optional + Output dimensions for memegen-rendered images. + layout : str, optional + memegen layout (e.g. ``"top"``). + background : str, optional + Custom background image URL for memegen. + overlays : sequence of dict, optional + Ad-hoc overlay placements forwarded to memegen as repeated + ``style=`` / ``center=`` / ``scale=`` query params. + template_style : str, optional + memegen template-specific style (e.g. ``"maga"`` for ``"ds"``). show : bool, optional - Whether to call :func:`matplotlib.pyplot.show` after rendering - (default: ``False``). The matplotlib convention is to return the - Axes for further customization rather than displaying implicitly. + Whether to call :func:`matplotlib.pyplot.show` after rendering. savefig : str, Path, or None, optional Path to save the meme image to. figsize : tuple of (float, float) or None, optional Figure size as ``(width, height)`` in inches. dpi : int or None, optional - Dots per inch (default: 150). + Dots per inch. ax : Axes or None, optional - Existing matplotlib Axes to render onto. If ``None``, a new figure - and axes are created. + Existing matplotlib Axes to render onto. **text_kwargs - Additional keyword arguments forwarded to :meth:`Axes.text` for each - rendered caption (e.g. ``alpha``, ``rotation``). User values take - precedence over the meme-specific defaults. + Additional keyword arguments forwarded to :meth:`Axes.text` under + the ``"matplotlib"`` and ``"pillow"`` backends. Passing any + ``text_kwargs`` under ``backend="auto"`` forces the Pillow backend. Returns ------- @@ -97,22 +131,55 @@ def meme( ... "drake", "writing tests", "shipping to prod", ... font="impact", color="yellow", ... ) + + >>> fig, ax = memes.meme( # doctest: +SKIP + ... "buzz", "hello", "world", + ... fontsize=48, # forces pillow backend under auto + ... ) """ tmpl = _resolve_template(template) + # Resolve sentinels: detect whether the caller passed each knob, then + # collapse to the effective value (config default if not). + user_outline_color = outline_color is not _UNSET + user_outline_width = outline_width is not _UNSET + user_fontsize = fontsize is not _UNSET + + eff_font = font if font is not _UNSET else None + eff_color = color if color is not _UNSET else None + eff_outline_color = outline_color if user_outline_color else None + eff_outline_width = outline_width if user_outline_width else None + eff_fontsize = fontsize if user_fontsize else None + eff_style = style if style is not _UNSET else None + + # Force the pillow path when memegen can't honour the request: + # explicit fontsize, non-default outline, or extra Axes.text kwargs. + force_pillow = bool(user_fontsize or user_outline_color or user_outline_width or text_kwargs) + + backend_val = backend if backend is not None else config["backend"] + fig, ax_out = render_meme( tmpl, list(lines), ax=ax, figsize=figsize, dpi=dpi, - font=font, - color=color, - outline_color=outline_color, - outline_width=outline_width, - fontsize=fontsize, - style=style, + font=eff_font, + color=eff_color, + outline_color=eff_outline_color, + outline_width=eff_outline_width, + fontsize=eff_fontsize, + style=eff_style, cache=_cache, + backend=backend_val, + extension=extension, + width=width, + height=height, + layout=layout, + background=background, + overlays=overlays, + template_style=template_style, + force_pillow=force_pillow, **text_kwargs, ) @@ -149,6 +216,13 @@ def memify( Overlays bold, outlined text on top of any matplotlib figure -- useful for turning plots, charts, or other visualizations into memes. + .. note:: + + ``memify`` always renders text with matplotlib's ``Axes.text`` + (the figure isn't a memegen template, so the API doesn't apply). + The ``backend`` parameter on :func:`meme` is intentionally not + exposed here. + Parameters ---------- fig : matplotlib.figure.Figure diff --git a/src/memeplotlib/_config.py b/src/memeplotlib/_config.py index 6745c58..eb2fda9 100644 --- a/src/memeplotlib/_config.py +++ b/src/memeplotlib/_config.py @@ -31,10 +31,14 @@ DEFAULT_IMAGE_TIMEOUT = 15 DEFAULT_MAX_RETRIES = 2 DEFAULT_RETRY_BACKOFF = 0.5 +DEFAULT_BACKEND = "auto" +DEFAULT_EXTENSION = "png" IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} _VALID_STYLES = {"upper", "lower", "none"} +_VALID_BACKENDS = {"auto", "memegen", "pillow", "matplotlib"} +_VALID_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} def _check_str(name: str) -> Callable[[Any], str]: @@ -79,6 +83,28 @@ def validator(v: Any) -> int: return validator +def _check_optional_non_negative_int(name: str) -> Callable[[Any], int | None]: + def validator(v: Any) -> int | None: + if v is None: + return None + if isinstance(v, bool) or not isinstance(v, int): + raise ValueError(f"{name!r} must be an int or None, got {type(v).__name__}") + if v < 0: + raise ValueError(f"{name!r} must be non-negative, got {v}") + return int(v) + + return validator + + +def _check_choice(name: str, choices: set[str]) -> Callable[[Any], str]: + def validator(v: Any) -> str: + if v not in choices: + raise ValueError(f"{name!r} must be one of {sorted(choices)}, got {v!r}") + return str(v) + + return validator + + def _check_style(name: str) -> Callable[[Any], str]: def validator(v: Any) -> str: if v not in _VALID_STYLES: @@ -112,6 +138,12 @@ def validator(v: Any) -> bool: "image_timeout": _check_non_negative_int("image_timeout"), "max_retries": _check_non_negative_int("max_retries"), "retry_backoff": _check_non_negative_float("retry_backoff"), + "backend": _check_choice("backend", _VALID_BACKENDS), + "extension": _check_choice("extension", _VALID_EXTENSIONS), + "width": _check_optional_non_negative_int("width"), + "height": _check_optional_non_negative_int("height"), + "layout": _check_optional_str("layout"), + "background": _check_optional_str("background"), } @@ -130,6 +162,12 @@ def validator(v: Any) -> bool: "image_timeout": DEFAULT_IMAGE_TIMEOUT, "max_retries": DEFAULT_MAX_RETRIES, "retry_backoff": DEFAULT_RETRY_BACKOFF, + "backend": DEFAULT_BACKEND, + "extension": DEFAULT_EXTENSION, + "width": None, + "height": None, + "layout": None, + "background": None, } diff --git a/src/memeplotlib/_mcp.py b/src/memeplotlib/_mcp.py index 23d526a..b93357b 100644 --- a/src/memeplotlib/_mcp.py +++ b/src/memeplotlib/_mcp.py @@ -69,6 +69,14 @@ def render_meme_tool( top: str | None = None, bottom: str | None = None, out_path: str | None = None, + backend: str | None = None, + extension: str | None = None, + width: int | None = None, + height: int | None = None, + template_style: str | None = None, + font: str | None = None, + color: str | None = None, + fontsize: float | None = None, ) -> dict[str, str]: """Render a meme and return path + base64. @@ -82,6 +90,20 @@ def render_meme_tool( Bottom caption text. out_path : str, optional Where to save the PNG. If None, a tempfile is used. + backend : str, optional + Override the renderer (``"auto"``, ``"memegen"``, ``"pillow"``, + ``"matplotlib"``). + extension : str, optional + Output format requested from memegen (``"png"``, ``"jpg"``, + ``"gif"``, ``"webp"``). + width, height : int, optional + Memegen output dimensions. + template_style : str, optional + Memegen template-specific style (e.g. ``"maga"``). + font, color : str, optional + Caption styling. + fontsize : float, optional + Explicit font size; forces the Pillow backend under ``"auto"``. Returns ------- @@ -98,7 +120,25 @@ def render_meme_tool( fd, out_path = tempfile.mkstemp(prefix="memeplotlib-", suffix=".png") os.close(fd) - _meme(template, *lines, savefig=out_path, show=False) + kwargs: dict[str, Any] = {"savefig": out_path, "show": False} + if backend is not None: + kwargs["backend"] = backend + if extension is not None: + kwargs["extension"] = extension + if width is not None: + kwargs["width"] = width + if height is not None: + kwargs["height"] = height + if template_style is not None: + kwargs["template_style"] = template_style + if font is not None: + kwargs["font"] = font + if color is not None: + kwargs["color"] = color + if fontsize is not None: + kwargs["fontsize"] = fontsize + + _meme(template, *lines, **kwargs) return {"path": out_path, "base64_png": _encode_png(out_path)} diff --git a/src/memeplotlib/_meme.py b/src/memeplotlib/_meme.py index 2d24662..35f05cf 100644 --- a/src/memeplotlib/_meme.py +++ b/src/memeplotlib/_meme.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, Any @@ -10,7 +11,8 @@ from memeplotlib._cache import TemplateCache from memeplotlib._config import config from memeplotlib._rendering import render_meme -from memeplotlib._template import Template, _resolve_template +from memeplotlib._template import Template, TextPosition, _resolve_template +from memeplotlib._url import OverlaySpec if TYPE_CHECKING: from matplotlib.axes import Axes @@ -39,6 +41,9 @@ class Meme: Font size in points. style : str or None, optional Text transform -- ``"upper"``, ``"lower"``, or ``"none"``. + backend : str or None, optional + Override the rendering backend (``"auto"``, ``"memegen"``, + ``"pillow"``, ``"matplotlib"``). ``None`` uses ``config["backend"]``. Examples -------- @@ -50,6 +55,9 @@ class Meme: >>> m.bottom("shipping to prod") # doctest: +SKIP >>> fig, ax = m.render() # doctest: +SKIP >>> m.save("output.png") # doctest: +SKIP + + >>> # Per-line override forces the Pillow backend. + >>> Meme("buzz").top("hi").line(1, "world", fontsize=48).save("out.png") # doctest: +SKIP """ def __init__( @@ -62,6 +70,14 @@ def __init__( outline_width: float | None = None, fontsize: float | None = None, style: str | None = None, + backend: str | None = None, + extension: str | None = None, + width: int | None = None, + height: int | None = None, + layout: str | None = None, + background: str | None = None, + overlays: Sequence[OverlaySpec] | None = None, + template_style: str | None = None, ): self._template_str: str | None = None self._template: Template | None = None @@ -78,6 +94,15 @@ def __init__( self._outline_width = outline_width self._fontsize = fontsize self._style = style + self._backend = backend + self._extension = extension + self._width = width + self._height = height + self._layout = layout + self._background = background + self._overlays = list(overlays) if overlays else None + self._template_style = template_style + self._per_line_overrides: dict[int, dict[str, object]] = {} self._fig: Figure | None = None self._ax: Axes | None = None self._cache = TemplateCache() @@ -145,6 +170,71 @@ def text(self, index: int, text: str) -> Meme: self._lines[index] = text return self + def line( + self, + index: int, + text: str, + *, + fontsize: float | None = None, + color: str | None = None, + font: str | None = None, + position: TextPosition | None = None, + ) -> Meme: + """Set text and per-line styling overrides for a single slot. + + Passing any of ``fontsize``, ``color``, ``font``, or ``position`` + forces the Pillow backend on render (memegen has no equivalent). + + Parameters + ---------- + index : int + Zero-based line index. + text : str + The text to place at the given slot. + fontsize : float, optional + Override font size for this slot. + color : str, optional + Override fill colour for this slot. + font : str, optional + Override font family for this slot. + position : TextPosition, optional + Override the text-box position metadata for this slot. + + Returns + ------- + Meme + Self, for method chaining. + """ + self.text(index, text) + override: dict[str, object] = {} + if fontsize is not None: + override["fontsize"] = fontsize + if color is not None: + override["color"] = color + if font is not None: + override["font"] = font + if position is not None: + override["position"] = position + if override: + self._per_line_overrides[index] = override + return self + + def with_backend(self, backend: str) -> Meme: + """Set the rendering backend. + + Parameters + ---------- + backend : str + One of ``"auto"``, ``"memegen"``, ``"pillow"``, ``"matplotlib"``. + + Returns + ------- + Meme + Self, for method chaining. + """ + self._backend = backend + return self + def render( self, ax: Axes | None = None, @@ -168,6 +258,8 @@ def render( The rendered matplotlib Figure and Axes. """ template = self._get_template() + force_pillow = bool(self._per_line_overrides) + backend_val = self._backend if self._backend is not None else config["backend"] fig, ax_out = render_meme( template, self._lines, @@ -181,6 +273,16 @@ def render( fontsize=self._fontsize, style=self._style, cache=self._cache, + backend=backend_val, + extension=self._extension, + width=self._width, + height=self._height, + layout=self._layout, + background=self._background, + overlays=self._overlays, + template_style=self._template_style, + force_pillow=force_pillow, + per_line_overrides=self._per_line_overrides or None, ) self._fig = fig self._ax = ax_out diff --git a/src/memeplotlib/_pillow.py b/src/memeplotlib/_pillow.py new file mode 100644 index 0000000..ad8da4d --- /dev/null +++ b/src/memeplotlib/_pillow.py @@ -0,0 +1,305 @@ +"""Pillow-based meme renderer. + +Used as the fallback path for the ``meme()`` dispatcher when the memegen +rendering API can't express the requested operation: + +- The template is a custom local file or arbitrary URL (memegen has no + rendered endpoint for it). +- The user supplied an explicit ``fontsize`` (memegen always auto-fits). +- The user supplied a non-default ``outline_color`` / ``outline_width`` + (memegen draws a hard-coded black stroke). +- The user supplied custom ``TextPosition`` overrides or ``Axes.text`` + kwargs (memegen has no equivalent). + +The rendered image is a NumPy RGBA array which the dispatcher then displays +in matplotlib via ``ax.imshow``. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from memeplotlib._template import TextPosition +from memeplotlib._text import apply_style + +if TYPE_CHECKING: + pass + + +_FONTS_DIR = Path(__file__).parent / "fonts" + +# User-friendly font name → expected TTF filenames searched in `_FONTS_DIR` +# and on standard system paths. The lookup falls back gracefully if a font +# is missing; the bundled Anton font is the final fallback. +_PILLOW_FONT_FILES: dict[str, list[str]] = { + "impact": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf"], + "anton": ["Anton-Regular.ttf"], + "arial": ["Arial.ttf", "arial.ttf"], + "comic": ["Comic Sans MS.ttf", "comic.ttf", "ComicSansMS.ttf"], + "times": ["Times New Roman.ttf", "times.ttf"], + "courier": ["Courier New.ttf", "courier.ttf", "cour.ttf"], +} + +_SYSTEM_FONT_DIRS = [ + Path("/Library/Fonts"), + Path("/System/Library/Fonts"), + Path("/System/Library/Fonts/Supplemental"), + Path("/usr/share/fonts"), + Path("/usr/local/share/fonts"), + Path("C:/Windows/Fonts"), + Path.home() / ".fonts", + Path.home() / "Library/Fonts", +] + +_FIT_MIN_FONTSIZE = 8 +_FIT_MAX_ITERATIONS = 24 +_FIT_SHRINK_FACTOR = 0.92 +_WRAP_BASE_CHARS = 28 + + +def _resolve_font_path(font: str) -> Path: + """Find a TTF file for *font*. Falls back to the bundled Anton font. + + Parameters + ---------- + font : str + User-facing font name (case-insensitive). + + Returns + ------- + pathlib.Path + Path to a TTF file. Always succeeds — the bundled Anton font is + the final fallback. + """ + candidates = _PILLOW_FONT_FILES.get(font.lower(), [f"{font}.ttf"]) + + search_dirs: list[Path] = [_FONTS_DIR, *_SYSTEM_FONT_DIRS] + for fname in candidates: + for dirpath in search_dirs: + if not dirpath.is_dir(): + continue + candidate = dirpath / fname + if candidate.is_file(): + return candidate + # Recursive search one level deep on Linux/macOS where fonts + # are organised into subdirectories. + for nested in dirpath.glob(f"*/{fname}"): + if nested.is_file(): + return nested + + # Final fallback: bundled Anton. + bundled = _FONTS_DIR / "Anton-Regular.ttf" + if bundled.is_file(): + return bundled + + raise FileNotFoundError( + f"Could not find a TTF for font {font!r}. The bundled Anton font is " + f"missing from {_FONTS_DIR}; this typically means the wheel was built " + f"without `force-include` of the fonts directory." + ) + + +def _measure(font: ImageFont.FreeTypeFont, text: str) -> tuple[int, int]: + """Return ``(width, height)`` of *text* rendered with *font*.""" + # Pillow ≥ 9.2 exposes `getbbox` which is multiline-aware via `Draw.textbbox`. + # For consistency we rasterise into a throwaway draw and use textbbox. + dummy = Image.new("RGBA", (1, 1)) + draw = ImageDraw.Draw(dummy) + bbox = draw.multiline_textbbox((0, 0), text, font=font, align="center") + return int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1]) + + +def _wrap_for_box(text: str, font: ImageFont.FreeTypeFont, box_width: int) -> str: + """Word-wrap *text* greedily to fit *box_width* in pixels.""" + if "\n" in text: + return text + + # Estimate average char width to pick a starting wrap width, then refine. + avg_w = max(1, _measure(font, "abcdefghijklmnopqrstuvwxyz")[0] // 26) + chars_per_line = max(_WRAP_BASE_CHARS // 4, box_width // max(1, avg_w)) + + wrapped = textwrap.fill(text, width=chars_per_line) + + # If still too wide, shrink wrap width until it fits. + while chars_per_line > 4 and _measure(font, wrapped)[0] > box_width: + chars_per_line = max(4, chars_per_line - 2) + wrapped = textwrap.fill(text, width=chars_per_line) + + return wrapped + + +def _fit_font_to_box( + text: str, + font_path: Path, + box: tuple[int, int], + initial: int, +) -> tuple[ImageFont.FreeTypeFont, str]: + """Iteratively shrink the font size until *text* fits in *box*. + + Parameters + ---------- + text : str + Text to render (already style-transformed). + font_path : pathlib.Path + Path to the TTF file. + box : tuple of (int, int) + Available ``(width, height)`` in pixels. + initial : int + Starting font size in pixels. + + Returns + ------- + tuple + ``(font, wrapped_text)``. + """ + box_w, box_h = box + size = max(_FIT_MIN_FONTSIZE, int(initial)) + font = ImageFont.truetype(str(font_path), size=size) + wrapped = _wrap_for_box(text, font, box_w) + + for _ in range(_FIT_MAX_ITERATIONS): + w, h = _measure(font, wrapped) + if w <= box_w and h <= box_h: + return font, wrapped + if size <= _FIT_MIN_FONTSIZE: + return font, wrapped + size = max(_FIT_MIN_FONTSIZE, int(size * _FIT_SHRINK_FACTOR)) + font = ImageFont.truetype(str(font_path), size=size) + wrapped = _wrap_for_box(text, font, box_w) + + return font, wrapped + + +def _draw_caption( + img: Image.Image, + text: str, + pos: TextPosition, + *, + font: str, + color: str, + outline_color: str, + outline_width: float, + fontsize: float | None, + style: str, +) -> None: + """Draw a single caption onto *img* in-place.""" + if not text: + return + + display_text = apply_style(text, style) + + img_w, img_h = img.size + box_w = max(1, int(round(pos.scale_x * img_w))) + box_h = max(1, int(round(pos.scale_y * img_h))) + box_x = int(round(pos.anchor_x * img_w)) + box_y = int(round(pos.anchor_y * img_h)) + + initial_size = ( + int(round(fontsize)) if fontsize is not None else max(_FIT_MIN_FONTSIZE, box_h // 2) + ) + + font_path = _resolve_font_path(font) + pil_font, wrapped = _fit_font_to_box( + display_text, font_path, (box_w, box_h), initial=initial_size + ) + + text_w, text_h = _measure(pil_font, wrapped) + + if pos.align == "left": + anchor_x = box_x + align = "left" + elif pos.align == "right": + anchor_x = box_x + box_w - text_w + align = "right" + else: + anchor_x = box_x + (box_w - text_w) // 2 + align = "center" + anchor_y = box_y + (box_h - text_h) // 2 + + # Stroke width in pixels. The matplotlib path-effect uses + # `outline_width * 2` as the visual stroke; we mirror that here so + # rough visual parity holds across backends. + stroke = max(0, int(round(outline_width * 2))) + + draw = ImageDraw.Draw(img) + draw.multiline_text( + (anchor_x, anchor_y), + wrapped, + font=pil_font, + fill=color, + stroke_width=stroke, + stroke_fill=outline_color, + align=align, + ) + + +def render_pillow( + image: np.ndarray, + lines: list[str], + positions: list[TextPosition], + *, + font: str, + color: str, + outline_color: str, + outline_width: float, + fontsize: float | None, + style: str, + per_line_overrides: dict[int, dict[str, object]] | None = None, +) -> np.ndarray: + """Composite caption text onto *image* using Pillow. + + Parameters + ---------- + image : numpy.ndarray + Background image as an RGBA array of shape ``(H, W, 4)``. + lines : list of str + Caption text per slot. + positions : list of TextPosition + Caption box per slot. + font, color, outline_color, outline_width, fontsize, style : + Default styling used for every slot unless overridden. + per_line_overrides : dict, optional + Mapping of ``line_index → {field: value}`` to override styling + on a per-slot basis. Supported fields: ``font``, ``color``, + ``fontsize``, ``position``. + + Returns + ------- + numpy.ndarray + New RGBA image with captions drawn on top. + """ + overrides = per_line_overrides or {} + img = Image.fromarray(image).convert("RGBA") + + for i, text in enumerate(lines): + if not text: + continue + if i >= len(positions): + break + + line_override = overrides.get(i, {}) + eff_font = str(line_override.get("font", font)) + eff_color = str(line_override.get("color", color)) + raw_fs = line_override.get("fontsize", fontsize) + eff_fontsize: float | None = None if raw_fs is None else float(raw_fs) # type: ignore[arg-type] + pos_override = line_override.get("position") + eff_pos = pos_override if isinstance(pos_override, TextPosition) else positions[i] + + _draw_caption( + img, + text, + eff_pos, + font=eff_font, + color=eff_color, + outline_color=outline_color, + outline_width=outline_width, + fontsize=eff_fontsize, + style=style, + ) + + return np.array(img) diff --git a/src/memeplotlib/_rendering.py b/src/memeplotlib/_rendering.py index 4f9f669..558551b 100644 --- a/src/memeplotlib/_rendering.py +++ b/src/memeplotlib/_rendering.py @@ -1,7 +1,25 @@ -"""Matplotlib rendering pipeline for meme images.""" +"""Rendering pipeline for meme images. + +Three backends share a single dispatcher (:func:`render_meme`): + +- ``"memegen"`` — build a memegen rendering URL via + :func:`memeplotlib._url.build_memegen_url`, fetch the rendered image, and + display it. Server-side rendering only; honours ``style`` / + ``font`` / ``color`` / ``width`` / ``height`` / ``layout`` / + ``background`` / ``overlays`` / ``template_style``. +- ``"pillow"`` — fetch the blank, draw captions client-side with + ``PIL.ImageDraw``. Honours per-line ``fontsize``, custom outlines, and + custom ``TextPosition`` overrides. +- ``"matplotlib"`` — legacy path, draws captions with ``Axes.text`` plus + ``patheffects.Stroke``. Kept for backwards compatibility. + +The dispatcher's ``"auto"`` policy picks ``"memegen"`` for memegen +templates with no client-only knobs and ``"pillow"`` otherwise. +""" from __future__ import annotations +import io import textwrap import threading import warnings @@ -9,15 +27,26 @@ from typing import TYPE_CHECKING, Any, cast import matplotlib.pyplot as plt +import numpy as np from matplotlib import patheffects from matplotlib.font_manager import FontProperties, findfont, fontManager +from PIL import Image from memeplotlib._cache import TemplateCache from memeplotlib._config import DEFAULT_FIGSIZE_WIDTH, config -from memeplotlib._template import DEFAULT_TEXT_POSITIONS, Template, TextPosition +from memeplotlib._pillow import render_pillow +from memeplotlib._template import ( + DEFAULT_TEXT_POSITIONS, + Template, + TextPosition, + _get_session, +) from memeplotlib._text import apply_style +from memeplotlib._url import OverlaySpec, build_memegen_url, memegen_font_for if TYPE_CHECKING: + from collections.abc import Sequence + from matplotlib.axes import Axes from matplotlib.backend_bases import RendererBase from matplotlib.figure import Figure, SubFigure @@ -352,70 +381,15 @@ def _smart_wrap(text: str, box_width_frac: float) -> str: # --- Main rendering functions --- -def render_meme( - template: Template, - lines: list[str], - ax: Axes | None = None, - figsize: tuple[float, float] | None = None, - dpi: int | None = None, - font: str | None = None, - color: str | None = None, - outline_color: str | None = None, - outline_width: float | None = None, - fontsize: float | None = None, - style: str | None = None, - cache: TemplateCache | None = None, - **text_kwargs: Any, +def _figure_for_image( + img: np.ndarray, + ax: Axes | None, + figsize: tuple[float, float] | None, + dpi: int, ) -> tuple[Figure, Axes]: - """Render a meme image using matplotlib. - - Parameters - ---------- - template : Template - The Template to render. - lines : list of str - Text lines for each text position. - ax : Axes or None, optional - Existing axes to render onto. A new figure is created if ``None``. - figsize : tuple of (float, float) or None, optional - Figure size in inches ``(width, height)``. - dpi : int or None, optional - Dots per inch for rendering. - font : str or None, optional - Font family name. - color : str or None, optional - Text fill color. - outline_color : str or None, optional - Text outline color. - outline_width : float or None, optional - Outline stroke width. - fontsize : float or None, optional - Font size in points (auto if ``None``). - style : str or None, optional - Text style (``"upper"``, ``"lower"``, ``"none"``). - cache : TemplateCache or None, optional - Template cache for image retrieval. - **text_kwargs - Additional keyword arguments forwarded to :meth:`Axes.text` for each - rendered caption. - - Returns - ------- - tuple of (Figure, Axes) - The matplotlib Figure and Axes containing the rendered meme. - """ - # Apply defaults from config - dpi = dpi if dpi is not None else config["dpi"] - font = font or config["font"] - color = color or config["color"] - outline_color = outline_color or config["outline_color"] - outline_width = outline_width if outline_width is not None else config["outline_width"] - style = style or config["style"] - - # Load the background image - img = template.get_image(cache=cache) + """Create or reuse a Figure/Axes pair sized to *img*'s aspect ratio.""" h, w = img.shape[:2] - aspect = w / h + aspect = w / max(1, h) if ax is None: if figsize is None: @@ -427,28 +401,140 @@ def render_meme( parent_fig = ax.get_figure() if parent_fig is None: raise RuntimeError("Provided ax has no associated Figure") - # Drawing onto existing axes always renders into a real Figure (not a - # SubFigure) at runtime, but the type checker can't know that. fig = cast("Figure", parent_fig) - # Display the background image edge-to-edge ax.imshow(img, aspect="auto") ax.axis("off") fig.subplots_adjust(left=0, right=1, top=1, bottom=0) + return fig, ax + + +def _fetch_rendered_image(url: str, cache: TemplateCache | None) -> np.ndarray: + """Fetch a memegen-rendered image (PNG/JPG/GIF/WebP) as an RGBA array.""" + if cache is not None and config["cache_enabled"]: + cached = cache.get_image(url) + if cached is not None: + return cached + + resp = _get_session().get(url, timeout=config["image_timeout"]) + resp.raise_for_status() + image_bytes = resp.content + img = np.array(Image.open(io.BytesIO(image_bytes)).convert("RGBA")) + + if cache is not None and config["cache_enabled"]: + cache.set_image(url, image_bytes) + + return img + + +def _render_via_memegen( + template: Template, + lines: list[str], + *, + ax: Axes | None, + figsize: tuple[float, float] | None, + dpi: int, + font: str, + color: str, + style: str, + extension: str, + width: int | None, + height: int | None, + layout: str | None, + background: str | None, + overlays: Sequence[OverlaySpec] | None, + template_style: str | None, + cache: TemplateCache | None, +) -> tuple[Figure, Axes]: + """Render via the memegen API and ``imshow`` the result.""" + # Style transforms (upper/lower/none) are client-side; apply before + # encoding so memegen receives the final visible string. + transformed = [apply_style(line, style) if line else line for line in lines] + + memegen_font = memegen_font_for(font) + url = build_memegen_url( + template.id, + transformed, + api_base=config["api_base"], + extension=extension, + template_style=template_style, + font=memegen_font, + color=color, + width=width, + height=height, + layout=layout, + background=background, + overlays=overlays, + ) + + img = _fetch_rendered_image(url, cache) + return _figure_for_image(img, ax, figsize, dpi) + + +def _render_via_pillow( + template: Template, + lines: list[str], + *, + ax: Axes | None, + figsize: tuple[float, float] | None, + dpi: int, + font: str, + color: str, + outline_color: str, + outline_width: float, + fontsize: float | None, + style: str, + per_line_overrides: dict[int, dict[str, object]] | None, + cache: TemplateCache | None, +) -> tuple[Figure, Axes]: + """Render via ``PIL.ImageDraw`` and ``imshow`` the result.""" + blank = template.get_image(cache=cache) + composed = render_pillow( + blank, + lines, + template.text_positions, + font=font, + color=color, + outline_color=outline_color, + outline_width=outline_width, + fontsize=fontsize, + style=style, + per_line_overrides=per_line_overrides, + ) + return _figure_for_image(composed, ax, figsize, dpi) + + +def _render_via_matplotlib( + template: Template, + lines: list[str], + *, + ax: Axes | None, + figsize: tuple[float, float] | None, + dpi: int, + font: str, + color: str, + outline_color: str, + outline_width: float, + fontsize: float | None, + style: str, + cache: TemplateCache | None, + text_kwargs: dict[str, Any], +) -> tuple[Figure, Axes]: + """Legacy renderer: draw captions with matplotlib's ``Axes.text``.""" + img = template.get_image(cache=cache) + fig, ax_out = _figure_for_image(img, ax, figsize, dpi) - # Draw text for each line positions = template.text_positions for i, text in enumerate(lines): if not text or i >= len(positions): continue pos = positions[i] - # Center of the text box in axes coordinates (y flipped: mpl 0=bottom) x = pos.anchor_x + pos.scale_x / 2 y = 1.0 - (pos.anchor_y + pos.scale_y / 2) _draw_meme_text( - ax, + ax_out, text, x, y, @@ -462,7 +548,211 @@ def render_meme( **text_kwargs, ) - return fig, ax + return fig, ax_out + + +def render_meme( + template: Template, + lines: list[str], + ax: Axes | None = None, + figsize: tuple[float, float] | None = None, + dpi: int | None = None, + font: str | None = None, + color: str | None = None, + outline_color: str | None = None, + outline_width: float | None = None, + fontsize: float | None = None, + style: str | None = None, + cache: TemplateCache | None = None, + backend: str = "auto", + extension: str | None = None, + width: int | None = None, + height: int | None = None, + layout: str | None = None, + background: str | None = None, + overlays: Sequence[OverlaySpec] | None = None, + template_style: str | None = None, + force_pillow: bool = False, + per_line_overrides: dict[int, dict[str, object]] | None = None, + **text_kwargs: Any, +) -> tuple[Figure, Axes]: + """Render a meme using the configured backend. + + The ``"auto"`` backend selects ``"memegen"`` when the template originates + from the memegen catalog and the user requested no client-only feature; + otherwise ``"pillow"``. Pass ``backend="matplotlib"`` for the legacy + ``Axes.text`` rendering retained for backwards compatibility. + + Parameters + ---------- + template : Template + Resolved template. + lines : list of str + Caption text per slot. + ax : Axes or None, optional + Existing axes to render onto. + figsize : tuple of (float, float) or None, optional + Figure size in inches. + dpi : int or None, optional + Dots per inch. + font : str or None, optional + Font family name. + color : str or None, optional + Text fill color. + outline_color, outline_width : optional + Stroke parameters (Pillow / matplotlib backends only). + fontsize : float or None, optional + Explicit font size; forces the Pillow backend under ``"auto"``. + style : str or None, optional + Text transform: ``"upper"``, ``"lower"``, or ``"none"``. + cache : TemplateCache or None, optional + Cache instance for image retrieval. + backend : str, optional + ``"auto"``, ``"memegen"``, ``"pillow"``, or ``"matplotlib"``. + extension : str or None, optional + Output format requested from memegen (``"png"``, ``"jpg"``, + ``"gif"``, ``"webp"``). Defaults to ``config["extension"]``. + width, height : int or None, optional + Output dimensions for memegen. + layout : str or None, optional + memegen layout (e.g. ``"top"``). + background : str or None, optional + Custom background image URL for memegen. + overlays : sequence of OverlaySpec or None, optional + Ad-hoc overlay placements for memegen. + template_style : str or None, optional + memegen template-specific style name (e.g. ``"maga"``). + force_pillow : bool, optional + When ``True``, ``backend="auto"`` resolves to ``"pillow"``. + per_line_overrides : dict, optional + Per-line ``{font, color, fontsize, position}`` overrides for the + Pillow renderer. + **text_kwargs + Forwarded to :meth:`Axes.text` (matplotlib backend only). + + Returns + ------- + tuple of (Figure, Axes) + The matplotlib Figure and Axes containing the rendered meme. + """ + dpi_val = dpi if dpi is not None else config["dpi"] + font_val = font or config["font"] + color_val = color or config["color"] + outline_color_val = outline_color or config["outline_color"] + outline_width_val = outline_width if outline_width is not None else config["outline_width"] + style_val = style or config["style"] + extension_val = extension or config["extension"] + width_val = width if width is not None else config["width"] + height_val = height if height is not None else config["height"] + layout_val = layout if layout is not None else config["layout"] + background_val = background if background is not None else config["background"] + + # `auto` first delegates to ``config["backend"]`` (which may itself be + # ``"auto"``) so callers reach into the same global override. + backend_input = backend + if backend_input == "auto": + backend_input = config["backend"] + + chosen = _select_backend( + backend_input, + template, + font=font_val, + force_pillow=force_pillow, + text_kwargs=text_kwargs, + per_line_overrides=per_line_overrides, + ) + + if chosen == "memegen": + return _render_via_memegen( + template, + lines, + ax=ax, + figsize=figsize, + dpi=dpi_val, + font=font_val, + color=color_val, + style=style_val, + extension=extension_val, + width=width_val, + height=height_val, + layout=layout_val, + background=background_val, + overlays=overlays, + template_style=template_style, + cache=cache, + ) + + if chosen == "pillow": + return _render_via_pillow( + template, + lines, + ax=ax, + figsize=figsize, + dpi=dpi_val, + font=font_val, + color=color_val, + outline_color=outline_color_val, + outline_width=outline_width_val, + fontsize=fontsize, + style=style_val, + per_line_overrides=per_line_overrides, + cache=cache, + ) + + return _render_via_matplotlib( + template, + lines, + ax=ax, + figsize=figsize, + dpi=dpi_val, + font=font_val, + color=color_val, + outline_color=outline_color_val, + outline_width=outline_width_val, + fontsize=fontsize, + style=style_val, + cache=cache, + text_kwargs=text_kwargs, + ) + + +def _select_backend( + requested: str, + template: Template, + *, + font: str, + force_pillow: bool, + text_kwargs: dict[str, Any], + per_line_overrides: dict[int, dict[str, object]] | None, +) -> str: + """Resolve ``backend="auto"`` to a concrete backend name.""" + if requested != "auto": + if requested not in {"memegen", "pillow", "matplotlib"}: + raise ValueError( + f"Unknown backend {requested!r}. Must be one of: " + f"'auto', 'memegen', 'pillow', 'matplotlib'." + ) + if requested == "memegen" and not template.is_memegen: + warnings.warn( + "backend='memegen' requested but template is not from the " + "memegen catalog; falling back to 'pillow'.", + UserWarning, + stacklevel=3, + ) + return "pillow" + return requested + + if force_pillow: + return "pillow" + if not template.is_memegen: + return "pillow" + if per_line_overrides: + return "pillow" + if text_kwargs: + return "pillow" + if memegen_font_for(font) is None: + return "pillow" + return "memegen" def render_memify( diff --git a/src/memeplotlib/_template.py b/src/memeplotlib/_template.py index 88ab118..59ce4b0 100644 --- a/src/memeplotlib/_template.py +++ b/src/memeplotlib/_template.py @@ -97,6 +97,20 @@ class Template: Search keywords associated with the template. example : list of str, optional Example text lines for the template. + lines_count : int, optional + Maximum number of caption slots the template supports (mirrors + the memegen ``lines`` field). Defaults to ``2``. + overlays_count : int, optional + Number of template-defined overlay slots (mirrors the memegen + ``overlays`` field). Defaults to ``0``. + styles : list of str, optional + Template-specific style names (mirrors the memegen ``styles`` + field). Defaults to ``[]``. + is_memegen : bool, optional + ``True`` if this template was constructed from memegen API + metadata; ``False`` for custom local files / arbitrary URLs. + Used by the rendering dispatcher to decide whether the memegen + backend is even reachable. """ id: str @@ -107,6 +121,10 @@ class Template: ) keywords: list[str] = field(default_factory=list) example: list[str] = field(default_factory=list) + lines_count: int = 2 + overlays_count: int = 0 + styles: list[str] = field(default_factory=list) + is_memegen: bool = False def __post_init__(self) -> None: # Lazy-loaded image cache; not a public dataclass field. @@ -168,6 +186,13 @@ def _from_api_data(cls, data: dict[str, Any], api_base: str) -> Template: lines_count = max(1, int(data.get("lines", 2))) except (TypeError, ValueError): lines_count = 2 + try: + overlays_count = max(0, int(data.get("overlays", 0))) + except (TypeError, ValueError): + overlays_count = 0 + styles_raw = data.get("styles", []) or [] + styles = [str(s) for s in styles_raw if isinstance(s, str)] + if lines_count <= 2: text_positions = list(DEFAULT_TEXT_POSITIONS) else: @@ -191,6 +216,10 @@ def _from_api_data(cls, data: dict[str, Any], api_base: str) -> Template: text_positions=text_positions, keywords=keywords, example=example_lines, + lines_count=lines_count, + overlays_count=overlays_count, + styles=styles, + is_memegen=True, ) @classmethod @@ -252,6 +281,8 @@ def from_image( name=name or template_id, image_url=image_url, text_positions=text_positions, + lines_count=lines, + is_memegen=False, ) def get_image(self, cache: TemplateCache | None = None) -> np.ndarray: @@ -272,8 +303,10 @@ def get_image(self, cache: TemplateCache | None = None) -> np.ndarray: if self._image_array is not None: return self._image_array + cache_enabled = bool(config["cache_enabled"]) + # Check cache - if cache is not None: + if cache is not None and cache_enabled: cached = cache.get_image(self.image_url) if cached is not None: self._image_array = cached @@ -287,7 +320,7 @@ def get_image(self, cache: TemplateCache | None = None) -> np.ndarray: img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") # Cache the downloaded image - if cache is not None: + if cache is not None and cache_enabled: cache.set_image(self.image_url, image_bytes) else: img = Image.open(self.image_url).convert("RGBA") diff --git a/src/memeplotlib/_url.py b/src/memeplotlib/_url.py new file mode 100644 index 0000000..91f4303 --- /dev/null +++ b/src/memeplotlib/_url.py @@ -0,0 +1,239 @@ +"""memegen API URL construction. + +Builds rendering URLs for the memegen API following the path/query rules +described in the public docs and unified by jacebrowning/memegen #993. + +The URL shape is:: + + {api_base}/images/{template_id}/{line_1}/{line_2}/.../{line_n}.{ext}?... + +Each line segment is encoded via :func:`memeplotlib._text.encode_text_for_url`, +with the special case that an empty line is rendered as a single underscore +(``_``) so memegen preserves slot ordering for non-trailing empty slots. + +Query parameters cover: + +- ``style`` — template style name (e.g. ``"maga"`` for ``ds``) or an arbitrary + overlay image URL +- ``font`` — memegen-side font alias (e.g. ``"impact"``, ``"thick"``, + ``"comic"``); see :data:`MEMEGEN_FONT_ALIASES` +- ``color`` — ``"fg"`` or ``"fg,bg"`` (HTML name or hex) +- ``width`` / ``height`` — output dimensions in pixels +- ``layout`` — alternate layout (e.g. ``"top"``) +- ``background`` — custom background image URL +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict +from urllib.parse import quote + +from memeplotlib._text import encode_text_for_url + +if TYPE_CHECKING: + from collections.abc import Sequence + + +# memegen-side font aliases. Distinct from the matplotlib/Pillow font map in +# `_rendering._FONT_MAP` because memegen accepts a specific named set served by +# the upstream `/fonts/` endpoint. Keys are the user-facing names accepted by +# memeplotlib; values are the strings memegen recognises in the `font=` query. +MEMEGEN_FONT_ALIASES: dict[str, str] = { + "impact": "impact", + "thick": "thick", + "thin": "thin", + "tiny": "tiny", + "comic": "comic", + "notosans": "notosans", + "kalam": "kalam", + "he": "he", + "jp": "jp", + "tw": "tw", + "default": "impact", +} + + +class OverlaySpec(TypedDict, total=False): + """An ad-hoc overlay placement passed alongside a memegen render.""" + + style: str + """Overlay image URL, or a template style name.""" + center: tuple[float, float] + """Overlay anchor as ``(x, y)`` fractions in the range ``[0, 1]``.""" + scale: float + """Overlay scale factor.""" + + +_VALID_EXTENSIONS = frozenset({"png", "jpg", "jpeg", "gif", "webp"}) + + +def memegen_font_for(font: str) -> str | None: + """Return the memegen font alias for *font*, or ``None`` if unsupported. + + Parameters + ---------- + font : str + User-facing font name (case-insensitive). + + Returns + ------- + str or None + The memegen-side alias, or ``None`` if the font has no memegen + equivalent (in which case the caller should fall back to the + Pillow renderer). + + Examples + -------- + >>> memegen_font_for("impact") + 'impact' + >>> memegen_font_for("Comic") + 'comic' + >>> memegen_font_for("arial") is None + True + """ + return MEMEGEN_FONT_ALIASES.get(font.lower()) + + +def _encode_segment(line: str) -> str: + """Encode a single line for a memegen URL path. + + Empty strings become ``_`` so memegen still allocates the slot. Non-empty + strings are passed through :func:`encode_text_for_url`. + """ + if not line: + return "_" + return encode_text_for_url(line) + + +def _format_query_value(value: object) -> str: + """Format a query-parameter value preserving memegen's tilde escapes. + + memegen's URL escape table uses ``~q`` / ``~a`` / etc. We must NOT + percent-encode the tilde, so this helper quotes everything except the + safe set ``~,/:`` (the comma is significant for ``color=fg,bg``). + """ + return quote(str(value), safe="~,/:") + + +def build_memegen_url( + template_id: str, + lines: Sequence[str], + *, + api_base: str, + extension: str = "png", + template_style: str | None = None, + font: str | None = None, + color: str | None = None, + width: int | None = None, + height: int | None = None, + layout: str | None = None, + background: str | None = None, + overlays: Sequence[OverlaySpec] | None = None, +) -> str: + """Construct a memegen rendering URL. + + Parameters + ---------- + template_id : str + memegen template identifier (e.g. ``"buzz"``, ``"drake"``). + lines : sequence of str + Caption lines in slot order. Empty strings become ``_`` to preserve + the slot. + api_base : str + Base URL of the memegen API (e.g. ``"https://api.memegen.link"``). + extension : str, optional + Output format. One of ``"png"``, ``"jpg"``, ``"jpeg"``, ``"gif"``, + ``"webp"``. Default ``"png"``. + template_style : str or None, optional + Template-specific style (e.g. ``"maga"``). May also be an arbitrary + image URL for ad-hoc overlays. + font : str or None, optional + memegen font alias (see :data:`MEMEGEN_FONT_ALIASES`). + color : str or None, optional + ``"fg"`` or ``"fg,bg"`` colour spec (HTML name or hex). + width : int or None, optional + Output width in pixels. + height : int or None, optional + Output height in pixels. + layout : str or None, optional + Alternate layout (e.g. ``"top"``). + background : str or None, optional + Custom background image URL. + overlays : sequence of OverlaySpec or None, optional + Ad-hoc overlay placements. + + Returns + ------- + str + Fully-formed memegen rendering URL. + + Raises + ------ + ValueError + If *extension* is unsupported. + + Examples + -------- + >>> build_memegen_url( + ... "buzz", ["memes", "memes everywhere"], + ... api_base="https://api.memegen.link", + ... ) + 'https://api.memegen.link/images/buzz/memes/memes_everywhere.png' + + >>> build_memegen_url( + ... "ds", ["a", "b"], + ... api_base="https://api.memegen.link", + ... template_style="maga", + ... font="comic", + ... color="white,black", + ... width=600, + ... ) # doctest: +ELLIPSIS + 'https://api.memegen.link/images/ds/a/b.png?style=maga&font=comic&...' + """ + ext = extension.lower().lstrip(".") + if ext not in _VALID_EXTENSIONS: + raise ValueError( + f"Unsupported extension {extension!r}. " f"Must be one of: {sorted(_VALID_EXTENSIONS)}" + ) + + base = api_base.rstrip("/") + encoded = [_encode_segment(line) for line in lines] + path = "/".join(encoded) if encoded else "_" + + url = f"{base}/images/{template_id}/{path}.{ext}" + + # Build query string preserving insertion order (style, font, color first + # to match the docs' usual presentation; remaining params follow). + params: list[tuple[str, str]] = [] + if template_style is not None: + params.append(("style", template_style)) + if font is not None: + params.append(("font", font)) + if color is not None: + params.append(("color", color)) + if width is not None: + params.append(("width", str(int(width)))) + if height is not None: + params.append(("height", str(int(height)))) + if layout is not None: + params.append(("layout", layout)) + if background is not None: + params.append(("background", background)) + + if overlays: + for ov in overlays: + ov_style = ov.get("style") + if ov_style is not None: + params.append(("style", ov_style)) + ov_center = ov.get("center") + if ov_center is not None: + params.append(("center", f"{ov_center[0]},{ov_center[1]}")) + ov_scale = ov.get("scale") + if ov_scale is not None: + params.append(("scale", str(ov_scale))) + + if params: + query = "&".join(f"{k}={_format_query_value(v)}" for k, v in params) + url = f"{url}?{query}" + + return url diff --git a/tests/conftest.py b/tests/conftest.py index dc2a4dd..1174129 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + import matplotlib import numpy as np import pytest @@ -12,6 +14,44 @@ from memeplotlib._template import DEFAULT_TEXT_POSITIONS, Template # noqa: E402 +# Tests that pre-date the v0.5.0 backend split assume the old blank-image- +# fetch + matplotlib draw flow. Force ``backend="matplotlib"`` for those +# legacy tests via a config snapshot fixture; new tests for the memegen +# and pillow backends opt in explicitly with ``backend=...``. +@pytest.fixture(autouse=True) +def _legacy_matplotlib_backend(request): + """Default tests to the legacy matplotlib backend. + + Tests opt out by adding ``@pytest.mark.uses_default_backend``; in that + case ``config["backend"]`` is left at its default (``"auto"``) so the + test exercises the new dispatcher. + """ + from memeplotlib import config + + if request.node.get_closest_marker("uses_default_backend"): + yield + return + + original = config["backend"] + config["backend"] = "matplotlib" + try: + yield + finally: + config["backend"] = original + + +def memegen_rendered_pattern(template_id: str = ".+") -> re.Pattern[str]: + """Regex matching any memegen rendered URL for *template_id*. + + Useful with ``responses.add(..., url=memegen_rendered_pattern("buzz"))`` + when a test exercises the memegen backend and the exact URL depends on + the caption text or query parameters. + """ + return re.compile( + rf"https://api\.memegen\.link/images/{template_id}/[^?]+\.(png|jpg|gif|webp)(\?.*)?" + ) + + @pytest.fixture def sample_image() -> np.ndarray: """A small 200x300 RGBA test image.""" diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..f7612a0 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,266 @@ +"""Tests for the memegen / pillow rendering backends and the dispatcher.""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest +import responses +from PIL import Image + +import memeplotlib as memes +from memeplotlib import meme +from memeplotlib._rendering import _select_backend +from memeplotlib._template import Template +from tests.conftest import memegen_rendered_pattern + +API_BASE = "https://api.memegen.link" + +pytestmark = pytest.mark.uses_default_backend + + +@pytest.fixture +def fake_png_bytes(): + rng = np.random.default_rng(seed=7) + img = Image.fromarray(rng.integers(0, 255, (80, 160, 3), dtype=np.uint8)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +@pytest.fixture +def buzz_metadata_response(): + """Register a memegen catalogue + per-template metadata mock.""" + responses.add(responses.GET, f"{API_BASE}/templates/", json=[]) + responses.add( + responses.GET, + f"{API_BASE}/templates/buzz", + json={ + "id": "buzz", + "name": "Buzz Lightyear", + "lines": 2, + "overlays": 0, + "styles": [], + "blank": f"{API_BASE}/images/buzz.png", + "keywords": [], + "example": {"text": []}, + }, + ) + + +@pytest.fixture(autouse=True) +def _isolate_registry_and_cache(tmp_path, monkeypatch): + import memeplotlib._template as t + from memeplotlib import config + + monkeypatch.setitem(config, "cache_dir", str(tmp_path / "cache")) + monkeypatch.setitem(config, "cache_enabled", False) + original = t._registry + t._registry = None + yield + t._registry = original + + +class TestBackendSelection: + def _tmpl(self, *, is_memegen: bool): + return Template( + id="x", + name="x", + image_url="https://example.com/x.png", + is_memegen=is_memegen, + ) + + def test_explicit_backend_passes_through(self): + assert ( + _select_backend( + "pillow", + self._tmpl(is_memegen=True), + font="impact", + force_pillow=False, + text_kwargs={}, + per_line_overrides=None, + ) + == "pillow" + ) + + def test_unknown_backend_raises(self): + with pytest.raises(ValueError, match="Unknown backend"): + _select_backend( + "qubit", + self._tmpl(is_memegen=True), + font="impact", + force_pillow=False, + text_kwargs={}, + per_line_overrides=None, + ) + + def test_memegen_falls_back_when_template_not_memegen(self): + with pytest.warns(UserWarning, match="not from the memegen catalog"): + chosen = _select_backend( + "memegen", + self._tmpl(is_memegen=False), + font="impact", + force_pillow=False, + text_kwargs={}, + per_line_overrides=None, + ) + assert chosen == "pillow" + + def test_auto_picks_memegen_for_memegen_template(self): + chosen = _select_backend( + "auto", + self._tmpl(is_memegen=True), + font="impact", + force_pillow=False, + text_kwargs={}, + per_line_overrides=None, + ) + assert chosen == "memegen" + + def test_auto_picks_pillow_for_custom_template(self): + chosen = _select_backend( + "auto", + self._tmpl(is_memegen=False), + font="impact", + force_pillow=False, + text_kwargs={}, + per_line_overrides=None, + ) + assert chosen == "pillow" + + def test_auto_picks_pillow_when_force_pillow(self): + chosen = _select_backend( + "auto", + self._tmpl(is_memegen=True), + font="impact", + force_pillow=True, + text_kwargs={}, + per_line_overrides=None, + ) + assert chosen == "pillow" + + def test_auto_picks_pillow_for_unknown_font(self): + chosen = _select_backend( + "auto", + self._tmpl(is_memegen=True), + font="papyrus", # not in MEMEGEN_FONT_ALIASES + force_pillow=False, + text_kwargs={}, + per_line_overrides=None, + ) + assert chosen == "pillow" + + def test_auto_picks_pillow_with_text_kwargs(self): + chosen = _select_backend( + "auto", + self._tmpl(is_memegen=True), + font="impact", + force_pillow=False, + text_kwargs={"alpha": 0.5}, + per_line_overrides=None, + ) + assert chosen == "pillow" + + def test_auto_picks_pillow_with_per_line_overrides(self): + chosen = _select_backend( + "auto", + self._tmpl(is_memegen=True), + font="impact", + force_pillow=False, + text_kwargs={}, + per_line_overrides={1: {"fontsize": 48}}, + ) + assert chosen == "pillow" + + +class TestMemeMemegenBackend: + @responses.activate + def test_meme_calls_memegen_render_url(self, buzz_metadata_response, fake_png_bytes, tmp_path): + # The dispatcher constructs an `/images/buzz/.png` URL; mock + # the whole pattern so any caption text returns the same fake PNG. + responses.add( + responses.GET, + memegen_rendered_pattern("buzz"), + body=fake_png_bytes, + content_type="image/png", + ) + out = tmp_path / "out.png" + fig, ax = meme("buzz", "memes", "everywhere", savefig=out, backend="auto") + assert out.exists() + # The blank URL must NOT have been fetched under the memegen backend. + called = [str(c.request.url) for c in responses.calls] + assert any("/images/buzz/" in u for u in called) + assert not any(u.endswith("/images/buzz.png") for u in called) + + @responses.activate + def test_meme_with_template_style_and_dimensions( + self, buzz_metadata_response, fake_png_bytes, tmp_path + ): + responses.add( + responses.GET, + memegen_rendered_pattern("buzz"), + body=fake_png_bytes, + content_type="image/png", + ) + meme( + "buzz", + "hi", + "world", + template_style="x", + width=600, + height=400, + extension="jpg", + backend="memegen", + savefig=tmp_path / "x.jpg", + ) + assert any( + "style=x" in str(c.request.url) and "width=600" in str(c.request.url) + for c in responses.calls + ) + + @responses.activate + def test_fontsize_forces_pillow_under_auto( + self, buzz_metadata_response, fake_png_bytes, tmp_path + ): + # When the user passes fontsize=, auto must fall through to pillow, + # which fetches the blank (NOT a rendered URL). + responses.add( + responses.GET, + f"{API_BASE}/images/buzz.png", + body=fake_png_bytes, + content_type="image/png", + ) + meme("buzz", "hello", fontsize=48, savefig=tmp_path / "p.png") + called = [str(c.request.url) for c in responses.calls] + assert any(u.endswith("/images/buzz.png") for u in called) + # No rendered-URL request. + assert not any("/images/buzz/" in u for u in called) + + +class TestMemeCustomTemplate: + @responses.activate + def test_custom_url_uses_pillow(self, fake_png_bytes, tmp_path): + responses.add( + responses.GET, + "https://example.com/foo.png", + body=fake_png_bytes, + content_type="image/png", + ) + meme( + "https://example.com/foo.png", + "hello", + "world", + savefig=tmp_path / "out.png", + ) + called = [str(c.request.url) for c in responses.calls] + # The custom URL was fetched; nothing routed to memegen. + assert any(u == "https://example.com/foo.png" for u in called) + assert not any("api.memegen.link" in u for u in called) + + +class TestBuildMemegenUrlReexport: + def test_reexported(self): + assert hasattr(memes, "build_memegen_url") + url = memes.build_memegen_url("buzz", ["hi"], api_base="https://api.memegen.link") + assert url == "https://api.memegen.link/images/buzz/hi.png" diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 0000000..b298a62 --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,137 @@ +"""Tests for the memegen URL builder.""" + +from __future__ import annotations + +import pytest + +from memeplotlib._url import ( + MEMEGEN_FONT_ALIASES, + build_memegen_url, + memegen_font_for, +) + +API_BASE = "https://api.memegen.link" + + +class TestBuildMemegenUrl: + def test_basic_url(self): + url = build_memegen_url("buzz", ["hello", "world"], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/hello/world.png" + + def test_spaces_become_underscores(self): + url = build_memegen_url("buzz", ["hello world", "memes everywhere"], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/hello_world/memes_everywhere.png" + + def test_empty_line_becomes_underscore(self): + url = build_memegen_url("ds", ["a", "", "c"], api_base=API_BASE) + # Middle empty slot must remain to preserve slot ordering. + assert url == "https://api.memegen.link/images/ds/a/_/c.png" + + def test_single_empty_line(self): + url = build_memegen_url("buzz", [""], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/_.png" + + def test_no_lines_uses_underscore(self): + url = build_memegen_url("buzz", [], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/_.png" + + def test_special_chars_escaped(self): + url = build_memegen_url("buzz", ["what?", "100% & rising"], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/what~q/100~p_~a_rising.png" + + def test_slash_escaped(self): + url = build_memegen_url("buzz", ["a/b"], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/a~sb.png" + + def test_underscore_doubled(self): + url = build_memegen_url("buzz", ["a_b"], api_base=API_BASE) + assert url == "https://api.memegen.link/images/buzz/a__b.png" + + def test_extension_jpg(self): + url = build_memegen_url("buzz", ["hello"], api_base=API_BASE, extension="jpg") + assert url.endswith("/hello.jpg") + + def test_extension_with_leading_dot(self): + url = build_memegen_url("buzz", ["hello"], api_base=API_BASE, extension=".webp") + assert url.endswith(".webp") + + def test_invalid_extension_raises(self): + with pytest.raises(ValueError, match="Unsupported extension"): + build_memegen_url("buzz", ["hi"], api_base=API_BASE, extension="bmp") + + def test_template_style(self): + url = build_memegen_url("ds", ["a", "b"], api_base=API_BASE, template_style="maga") + assert url == "https://api.memegen.link/images/ds/a/b.png?style=maga" + + def test_font_color_dimensions(self): + url = build_memegen_url( + "buzz", + ["a", "b"], + api_base=API_BASE, + font="comic", + color="white,black", + width=600, + height=400, + ) + # Order is style → font → color → width → height per the builder. + assert url == ( + "https://api.memegen.link/images/buzz/a/b.png" + "?font=comic&color=white,black&width=600&height=400" + ) + + def test_layout_and_background(self): + url = build_memegen_url( + "buzz", + ["a", "b"], + api_base=API_BASE, + layout="top", + background="https://example.com/bg.png", + ) + assert "layout=top" in url + assert "background=" in url + # The background URL gets quoted but ":/" should remain readable. + assert "https://example.com/bg.png" in url + + def test_overlays(self): + url = build_memegen_url( + "ds", + ["a", "b"], + api_base=API_BASE, + overlays=[{"style": "https://x/y.png", "center": (0.5, 0.5), "scale": 1.2}], + ) + assert "style=https://x/y.png" in url + assert "center=0.5,0.5" in url + assert "scale=1.2" in url + + def test_tilde_not_percent_encoded(self): + # `~` is the marker for memegen escapes; must not be % encoded by the + # query-string builder. + url = build_memegen_url( + "buzz", ["a?b"], api_base=API_BASE, color="red", template_style="x~y" + ) + assert "~" in url + assert "%7E" not in url + + def test_api_base_with_trailing_slash(self): + url = build_memegen_url("buzz", ["a"], api_base="https://api.memegen.link/") + assert "memegen.link//" not in url + assert url == "https://api.memegen.link/images/buzz/a.png" + + +class TestMemegenFontFor: + def test_known_alias(self): + assert memegen_font_for("impact") == "impact" + + def test_case_insensitive(self): + assert memegen_font_for("Comic") == "comic" + + def test_default_alias(self): + assert memegen_font_for("default") == "impact" + + def test_unknown_font_returns_none(self): + assert memegen_font_for("arial") is None + + def test_alias_table_has_canonical_set(self): + # Sanity check that the documented set is present. + for name in ("impact", "thick", "thin", "tiny", "comic", "notosans", "kalam"): + assert name in MEMEGEN_FONT_ALIASES