diff --git a/README.md b/README.md
index da27d39..d32f405 100644
--- a/README.md
+++ b/README.md
@@ -1,190 +1,38 @@
-# exteraGram Plugin Base
+
+
+
-A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
-as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
+
exteraGram Plugin Base
-## How exteraGram plugins actually work
+**TL;DR:** a template repo for writing [exteraGram](https://exteragram.app) plugins as normal
+multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin`
+file deployed to your phone or published to Releases.
-A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
-`/data/user/0/com.exteragram.messenger/files/plugins/.py`. The distribution
-convention names the same content `.plugin`.
+## Description
-The app reads plugin metadata **statically via AST** — it looks for top-level constant
-assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
-`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
-file. Only `__id__` and `__name__` are required.
+exteraGram loads every plugin from exactly one Python file. This template lets you split a
+plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
+into that single file, an offline harness verifies the result without a device, ADB scripts
+push it to a connected phone and reload it, and CI publishes every version to the repo's
+Releases tab.
-The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
-BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
-hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
-`on_app_event`) are all called by the host; hooks must be registered inside
-`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
-`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
-`HookResult()` to pass through. Settings rows returned from `create_settings` come from
-`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
-`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
-redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
+## Features
-This repo does not use the official Elyx/ElyxBuilder archive format — see
-`docs/sdk-notes.md` for why.
+None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
+toolchain works. This repo is meant to be used as a **template** for creating other plugins:
+click "Use this template" (or clone it), then replace `plugin.json` and `src/` with your own.
-## Repo layout
+## Documentation
-```
-plugin.json single source of truth for plugin metadata
-src/ the plugin, written as an ordinary Python package
- main.py entry module — exactly one BasePlugin subclass, absolute imports only
- greeting/ sample package (proves relative + cross-module imports survive bundling)
- textutil.py sample top-level sibling module
-tools/
- bundle.py compiles plugin.json + src/ into dist/.plugin
- devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
- verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
- release.py publishes a built bundle to a Gitea release (used by CI)
-.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
-scripts/ ADB debug-loop automation and the git hooks (see below)
-docs/sdk-notes.md condensed SDK API surface gathered from the official docs
-```
+- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
+- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
+- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
+- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
+- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
-## The three commands that matter
+## Quick start
```bash
-python3 tools/bundle.py --release # compile src/ -> dist/.plugin
-python3 tools/verify_bundle.py dist/.plugin # offline proof the bundle works, no device needed
-bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
+python3 tools/bundle.py --check # once after cloning: enables the git hooks
+bash scripts/deploy.sh --enable # build + push to a connected device + enable
```
-
-`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
-into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
-a private import hook (scoped to a per-module `__builtins__` dict, never touching
-`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
-files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
-resolves breakpoints against your real source files; `--release` embeds repo-relative paths
-for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
-Its first run in a clone also enables the repo's git hooks (see
-[Git hooks and versioning](#git-hooks-and-versioning)).
-
-## Device prerequisites
-
-- exteraGram 12.5.1+.
-- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
-- USB debugging enabled, device authorized (`adb devices`).
-
-Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
-app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
-`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
-socket forwarded with `adb forward`, and the app writes the file into its own storage.
-
-## ADB automation
-
-All scripts live in `scripts/`, are runnable from any working directory, and honor the
-standard `ANDROID_SERIAL` env var for device selection.
-
-- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
- `tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
-- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
- plugin to the device, optionally enables it, reloads it.
-- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
- (uses `inotifywait` if available, otherwise polls).
-- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
- raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
-- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
- tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
- `pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
- `plugin.json` if it's missing.
-- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
- for editor autocomplete.
-
-## Git hooks and versioning
-
-`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
-(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
-`plugin.json` afterwards, because commits of a file selection (`git commit -- `, which
-some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
-
-**The hooks enable themselves.** Git never activates hooks from repository content, so a
-fresh clone, or a repo created from this template, starts without them. The first run of
-`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
-workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
-includes `--check`, so after cloning run
-
-```bash
-python3 tools/bundle.py --check
-```
-
-once, before your first commit. A commit made before any build runs without the hooks.
-`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
-purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
-never fails a build over it. To turn the hooks off for a clone, run
-`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
-until the next build.
-
-The bump is skipped when:
-
-- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
- `[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
-- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
- for commits written in an editor, since their message doesn't exist yet when the hook runs,
-- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
- minor or major release),
-- a merge, rebase, cherry-pick or revert is in progress.
-
-Because every version change publishes a release (see below), a normal commit + push to
-`main` ships a new patch release. Use a stop word for commits that should not.
-
-## Publishing a release
-
-`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
-Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
-by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
-version straight out of `plugin.json`, so it keeps working after you rename the plugin.
-
-The `version` field in `plugin.json` is the single source of truth:
-
-| Push to `main` with… | Result |
-| --- | --- |
-| a new `version` | creates tag `v` and a new release |
-| the same `version` | rebuilds the existing release, replacing its attachment |
-
-So every push leaves a downloadable build attached to the current version, and bumping
-`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
-so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
-tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
-
-Every path runs `--check`, builds with `--release`, and gates the upload on
-`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
-replaces the attachment rather than piling up duplicates, and refreshes the release notes to
-name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
-that first introduced that version.
-
-Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
-token with the `write:repository` scope (which covers releases). Set it once under
-**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
-repo-level secret of the same name takes precedence if one repo ever needs different
-credentials.
-
-The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
-runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
-`env:` entry of the same name, so the org secret would be silently ignored.
-
-If the org secret does not reach a repository the publish still works: `release.py` tries
-`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
-releases in its own repository, and prints which one it used. An organisation secret is only
-exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
-repo unexpectedly falls back.
-
-If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
-releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
-scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
-you want when publishing needs to reach beyond this repo.
-
-## Writing your own plugin
-
-1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
- version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
-2. Edit `plugin.json` (id, name, description, etc.).
-3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
- only — it's executed at plugin-module top level, not inside a package).
-4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin`
- (substitute your `id` from `plugin.json`).
-5. `bash scripts/deploy.sh --enable` with a device connected.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..8f49826
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,65 @@
+# Development
+
+## Writing your own plugin
+
+1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
+ version on commit (see [Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
+2. Edit `plugin.json` (id, name, description, etc.).
+3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
+ only — it's executed at plugin-module top level, not inside a package).
+4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin`
+ (substitute your `id` from `plugin.json`).
+5. `bash scripts/deploy.sh --enable` with a device connected.
+
+## Repo layout
+
+```
+plugin.json single source of truth for plugin metadata
+src/ the plugin, written as an ordinary Python package
+ main.py entry module — exactly one BasePlugin subclass, absolute imports only
+ greeting/ sample package (proves relative + cross-module imports survive bundling)
+ textutil.py sample top-level sibling module
+tools/
+ bundle.py compiles plugin.json + src/ into dist/.plugin
+ devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
+ verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
+ release.py publishes a built bundle to a Gitea release (used by CI)
+ icon_png.py renders images/icon.svg to a square PNG
+images/icon.svg the plugin icon, source of truth for any PNG export
+.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
+scripts/ ADB debug-loop automation and the git hooks
+docs/ these notes + sdk-notes.md, the condensed SDK API surface
+```
+
+## The three commands that matter
+
+```bash
+python3 tools/bundle.py --release # compile src/ -> dist/.plugin
+python3 tools/verify_bundle.py dist/.plugin # offline proof the bundle works, no device needed
+bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
+```
+
+`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
+into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
+a private import hook (scoped to a per-module `__builtins__` dict, never touching
+`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
+files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
+resolves breakpoints against your real source files; `--release` embeds repo-relative paths
+for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
+Its first run in a clone also enables the repo's git hooks (see
+[Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
+
+## Exporting the icon as PNG
+
+`tools/icon_png.py` renders `images/icon.svg` to `images/icon.png`, 512x512 by default.
+`--resolution` sets the size of both sides, so `--resolution 1024` gives 1024x1024:
+
+```bash
+python3 tools/icon_png.py # images/icon.png, 512x512
+python3 tools/icon_png.py --resolution 1024 # images/icon.png, 1024x1024
+python3 tools/icon_png.py some.svg -o out.png
+```
+
+The output is always PNG; any other `-o` extension is rejected. The script itself is stdlib
+only and hands the rendering to the first renderer it finds: the `cairosvg` Python package,
+`rsvg-convert` (librsvg), ImageMagick or Inkscape. Install any one of them.
diff --git a/docs/device-and-adb.md b/docs/device-and-adb.md
new file mode 100644
index 0000000..79193dd
--- /dev/null
+++ b/docs/device-and-adb.md
@@ -0,0 +1,32 @@
+# Device setup and ADB automation
+
+## Device prerequisites
+
+- exteraGram 12.5.1+.
+- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
+- USB debugging enabled, device authorized (`adb devices`).
+
+Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
+app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
+`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
+socket forwarded with `adb forward`, and the app writes the file into its own storage.
+
+## ADB automation
+
+All scripts live in `scripts/`, are runnable from any working directory, and honor the
+standard `ANDROID_SERIAL` env var for device selection.
+
+- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
+ `tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
+- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
+ plugin to the device, optionally enables it, reloads it.
+- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
+ (uses `inotifywait` if available, otherwise polls).
+- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
+ raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
+- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
+ tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
+ `pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
+ `plugin.json` if it's missing.
+- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
+ for editor autocomplete.
diff --git a/docs/how-plugins-work.md b/docs/how-plugins-work.md
new file mode 100644
index 0000000..8f89645
--- /dev/null
+++ b/docs/how-plugins-work.md
@@ -0,0 +1,26 @@
+# How exteraGram plugins work
+
+## How exteraGram plugins actually work
+
+A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
+`/data/user/0/com.exteragram.messenger/files/plugins/.py`. The distribution
+convention names the same content `.plugin`.
+
+The app reads plugin metadata **statically via AST** — it looks for top-level constant
+assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
+`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
+file. Only `__id__` and `__name__` are required.
+
+The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
+BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
+hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
+`on_app_event`) are all called by the host; hooks must be registered inside
+`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
+`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
+`HookResult()` to pass through. Settings rows returned from `create_settings` come from
+`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
+`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
+redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
+
+This repo does not use the official Elyx/ElyxBuilder archive format — see
+[`sdk-notes.md`](sdk-notes.md) for why.
diff --git a/docs/versioning-and-releases.md b/docs/versioning-and-releases.md
new file mode 100644
index 0000000..3163bf7
--- /dev/null
+++ b/docs/versioning-and-releases.md
@@ -0,0 +1,84 @@
+# Versioning and releases
+
+## Git hooks and versioning
+
+`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
+(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
+`plugin.json` afterwards, because commits of a file selection (`git commit -- `, which
+some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
+
+**The hooks enable themselves.** Git never activates hooks from repository content, so a
+fresh clone, or a repo created from this template, starts without them. The first run of
+`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
+workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
+includes `--check`, so after cloning run
+
+```bash
+python3 tools/bundle.py --check
+```
+
+once, before your first commit. A commit made before any build runs without the hooks.
+`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
+purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
+never fails a build over it. To turn the hooks off for a clone, run
+`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
+until the next build.
+
+The bump is skipped when:
+
+- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
+ `[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
+- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
+ for commits written in an editor, since their message doesn't exist yet when the hook runs,
+- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
+ minor or major release),
+- a merge, rebase, cherry-pick or revert is in progress.
+
+Because every version change publishes a release (see [Publishing a release](#publishing-a-release)), a normal commit + push to
+`main` ships a new patch release. Use a stop word for commits that should not.
+
+## Publishing a release
+
+`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
+Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
+by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
+version straight out of `plugin.json`, so it keeps working after you rename the plugin.
+
+The `version` field in `plugin.json` is the single source of truth:
+
+| Push to `main` with… | Result |
+| --- | --- |
+| a new `version` | creates tag `v` and a new release |
+| the same `version` | rebuilds the existing release, replacing its attachment |
+
+So every push leaves a downloadable build attached to the current version, and bumping
+`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
+so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
+tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
+
+Every path runs `--check`, builds with `--release`, and gates the upload on
+`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
+replaces the attachment rather than piling up duplicates, and refreshes the release notes to
+name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
+that first introduced that version.
+
+Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
+token with the `write:repository` scope (which covers releases). Set it once under
+**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
+repo-level secret of the same name takes precedence if one repo ever needs different
+credentials.
+
+The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
+runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
+`env:` entry of the same name, so the org secret would be silently ignored.
+
+If the org secret does not reach a repository the publish still works: `release.py` tries
+`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
+releases in its own repository, and prints which one it used. An organisation secret is only
+exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
+repo unexpectedly falls back.
+
+If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
+releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
+scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
+you want when publishing needs to reach beyond this repo.
diff --git a/images/icon.svg b/images/icon.svg
new file mode 100644
index 0000000..ccb8fe5
--- /dev/null
+++ b/images/icon.svg
@@ -0,0 +1,35 @@
+
diff --git a/tools/icon_png.py b/tools/icon_png.py
new file mode 100644
index 0000000..fd5fc2c
--- /dev/null
+++ b/tools/icon_png.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Renders images/icon.svg to a square PNG (512x512 by default).
+
+Stdlib only: the actual rasterising is delegated to the first renderer found,
+in this order: the cairosvg Python package, rsvg-convert (librsvg),
+ImageMagick (magick/convert), Inkscape. Install any one of them.
+
+Usage:
+ tools/icon_png.py # images/icon.svg -> images/icon.png, 512x512
+ tools/icon_png.py --resolution 1024 # 1024x1024
+ tools/icon_png.py other.svg -o out.png # explicit input/output
+
+The output is always PNG, always x.
+"""
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import List, Optional
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_SVG = REPO_ROOT / "images" / "icon.svg"
+DEFAULT_RESOLUTION = 512
+
+
+def fail(message: str) -> "None":
+ print(f"error: {message}", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def cli_command(svg: Path, png: Path, size: int) -> Optional[List[str]]:
+ """Returns the command line for the first installed CLI renderer, if any."""
+ if shutil.which("rsvg-convert"):
+ return ["rsvg-convert", "-w", str(size), "-h", str(size), "-f", "png",
+ "-o", str(png), str(svg)]
+ magick = shutil.which("magick") or shutil.which("convert")
+ if magick:
+ # A high density rasterises the vector sharply before scaling down;
+ # "!" forces the exact size.
+ return [magick, "-background", "none", "-density", "1200", str(svg),
+ "-resize", f"{size}x{size}!", f"png:{png}"]
+ if shutil.which("inkscape"):
+ return ["inkscape", str(svg), "--export-type=png", f"--export-filename={png}",
+ "-w", str(size), "-h", str(size)]
+ return None
+
+
+def render(svg: Path, png: Path, size: int) -> str:
+ """Renders svg into png at size x size, returning the renderer's name."""
+ try:
+ import cairosvg # type: ignore[import-not-found]
+ except ImportError:
+ pass
+ else:
+ cairosvg.svg2png(url=str(svg), write_to=str(png),
+ output_width=size, output_height=size)
+ return "cairosvg"
+
+ command = cli_command(svg, png, size)
+ if command is None:
+ fail("no SVG renderer found; install one of: cairosvg (pip), "
+ "rsvg-convert (librsvg), ImageMagick, Inkscape")
+ result = subprocess.run(command, capture_output=True, text=True)
+ if result.returncode != 0:
+ fail(f"{Path(command[0]).name} failed:\n{result.stderr.strip()}")
+ return Path(command[0]).name
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Render an SVG icon to a square PNG.")
+ parser.add_argument("svg", nargs="?", type=Path, default=DEFAULT_SVG,
+ help="input SVG (default: images/icon.svg)")
+ parser.add_argument("-r", "--resolution", type=int, default=DEFAULT_RESOLUTION,
+ help=f"width and height in pixels (default: {DEFAULT_RESOLUTION})")
+ parser.add_argument("-o", "--output", type=Path,
+ help="output PNG (default: the input path with a .png suffix)")
+ args = parser.parse_args()
+
+ if args.resolution <= 0:
+ fail("--resolution must be a positive integer")
+ if not args.svg.is_file():
+ fail(f"{args.svg} not found")
+ png = args.output or args.svg.with_suffix(".png")
+ if png.suffix.lower() != ".png":
+ fail(f"output must be a .png file, got {png}")
+ png.parent.mkdir(parents=True, exist_ok=True)
+
+ renderer = render(args.svg, png, args.resolution)
+ print(f"{png} ({args.resolution}x{args.resolution}, via {renderer})")
+
+
+if __name__ == "__main__":
+ main()