diff --git a/architecture/brand-marks.md b/architecture/brand-marks.md index f635d3b..606469f 100644 --- a/architecture/brand-marks.md +++ b/architecture/brand-marks.md @@ -20,3 +20,8 @@ additionally get a 1280×640 `social-card.svg|png` — a two-panel og:image (green mark panel + cream name/tagline/url), built with the same frame + symbols and the `fit_text`/`wrap_text` helpers. Taglines are the canonical `profile/README.md` one-liners. + +All generated PNGs are palette-quantized in `raster.py` (`_quantize_png`, +Pillow FASTOCTREE, `_PNG_COLORS` palette) so the committed binaries are +indexed-colour and compact; alpha is preserved for the transparent marks. +SVGs are left as generated. diff --git a/brand/README.md b/brand/README.md index a232961..8b00179 100644 --- a/brand/README.md +++ b/brand/README.md @@ -50,6 +50,12 @@ Repos with a docs site also get a `social-card.svg` + `social-card.png` and docs URL on cream. The docs-site repos are listed in `brand/build/projects.py::DOCS_REPOS`. +PNGs are palette-quantized at build time (`brand/build/raster.py`, Pillow +FASTOCTREE) — indexed-colour, with no visible change (the art is flat-colour). +The large assets (social cards, 1024px marks) shrink ~70–80% vs raw +`rsvg-convert` output; the tiny favicons less. Regenerate with +`uv run python -m brand.build.render`. + ## Deferred (not in this kit) The header nav logo redesign is a follow-up. diff --git a/brand/build/raster.py b/brand/build/raster.py index 90f19bc..0a9c61f 100644 --- a/brand/build/raster.py +++ b/brand/build/raster.py @@ -2,6 +2,21 @@ import subprocess from pathlib import Path +_PNG_COLORS = 32 # palette size; flat art needs few (8 already looked clean; 32 = headroom) + + +def _quantize_png(path: Path, colors: int = _PNG_COLORS) -> None: + """Re-save a PNG as an indexed-palette image — visually lossless for flat + art. FASTOCTREE preserves alpha, so it is correct for both the opaque + social cards and the transparent marks. No-op if Pillow is unavailable.""" + try: + from PIL import Image + except ModuleNotFoundError: + return + im = Image.open(path).convert("RGBA") + q = im.quantize(colors=colors, method=Image.Quantize.FASTOCTREE) + q.save(path, format="PNG", optimize=True) + def export_png( svg_path: Path, @@ -20,4 +35,5 @@ def export_png( args += ["-h", str(height)] args += [str(svg_path), "-o", str(png_path)] subprocess.run(args, check=True) + _quantize_png(png_path) return True diff --git a/brand/org/apple-touch-icon-180.png b/brand/org/apple-touch-icon-180.png index d9a248f..5d18f40 100644 Binary files a/brand/org/apple-touch-icon-180.png and b/brand/org/apple-touch-icon-180.png differ diff --git a/brand/org/avatar-1024.png b/brand/org/avatar-1024.png index bc99995..2722b4b 100644 Binary files a/brand/org/avatar-1024.png and b/brand/org/avatar-1024.png differ diff --git a/brand/org/avatar-circle-1024.png b/brand/org/avatar-circle-1024.png index a735d01..20c39aa 100644 Binary files a/brand/org/avatar-circle-1024.png and b/brand/org/avatar-circle-1024.png differ diff --git a/brand/org/favicon-16.png b/brand/org/favicon-16.png index d5273d7..c432e1c 100644 Binary files a/brand/org/favicon-16.png and b/brand/org/favicon-16.png differ diff --git a/brand/org/favicon-32.png b/brand/org/favicon-32.png index 213ee60..3103071 100644 Binary files a/brand/org/favicon-32.png and b/brand/org/favicon-32.png differ diff --git a/brand/org/favicon-48.png b/brand/org/favicon-48.png index e954d35..0fbb64e 100644 Binary files a/brand/org/favicon-48.png and b/brand/org/favicon-48.png differ diff --git a/brand/org/social-card-green.png b/brand/org/social-card-green.png index 37eef6c..0d23f93 100644 Binary files a/brand/org/social-card-green.png and b/brand/org/social-card-green.png differ diff --git a/brand/org/social-card.png b/brand/org/social-card.png index 5b64619..869874c 100644 Binary files a/brand/org/social-card.png and b/brand/org/social-card.png differ diff --git a/brand/org/social-square-green.png b/brand/org/social-square-green.png index d4a79c9..75b5563 100644 Binary files a/brand/org/social-square-green.png and b/brand/org/social-square-green.png differ diff --git a/brand/org/social-square.png b/brand/org/social-square.png index dec5db5..454a294 100644 Binary files a/brand/org/social-square.png and b/brand/org/social-square.png differ diff --git a/brand/projects/db-retry/mark-1024.png b/brand/projects/db-retry/mark-1024.png index b78163a..2805038 100644 Binary files a/brand/projects/db-retry/mark-1024.png and b/brand/projects/db-retry/mark-1024.png differ diff --git a/brand/projects/db-retry/mark-512.png b/brand/projects/db-retry/mark-512.png index d553d50..57bb47c 100644 Binary files a/brand/projects/db-retry/mark-512.png and b/brand/projects/db-retry/mark-512.png differ diff --git a/brand/projects/eof-fixer/mark-1024.png b/brand/projects/eof-fixer/mark-1024.png index 5cb2683..567dcb9 100644 Binary files a/brand/projects/eof-fixer/mark-1024.png and b/brand/projects/eof-fixer/mark-1024.png differ diff --git a/brand/projects/eof-fixer/mark-512.png b/brand/projects/eof-fixer/mark-512.png index b88b676..f2db0e3 100644 Binary files a/brand/projects/eof-fixer/mark-512.png and b/brand/projects/eof-fixer/mark-512.png differ diff --git a/brand/projects/fastapi-sqlalchemy-template/mark-1024.png b/brand/projects/fastapi-sqlalchemy-template/mark-1024.png index 4c5e42a..a75ec99 100644 Binary files a/brand/projects/fastapi-sqlalchemy-template/mark-1024.png and b/brand/projects/fastapi-sqlalchemy-template/mark-1024.png differ diff --git a/brand/projects/fastapi-sqlalchemy-template/mark-512.png b/brand/projects/fastapi-sqlalchemy-template/mark-512.png index 3c0743f..b77e90c 100644 Binary files a/brand/projects/fastapi-sqlalchemy-template/mark-512.png and b/brand/projects/fastapi-sqlalchemy-template/mark-512.png differ diff --git a/brand/projects/faststream-concurrent-aiokafka/mark-1024.png b/brand/projects/faststream-concurrent-aiokafka/mark-1024.png index 4f79910..2bf50c3 100644 Binary files a/brand/projects/faststream-concurrent-aiokafka/mark-1024.png and b/brand/projects/faststream-concurrent-aiokafka/mark-1024.png differ diff --git a/brand/projects/faststream-concurrent-aiokafka/mark-512.png b/brand/projects/faststream-concurrent-aiokafka/mark-512.png index 2f01630..79fe26e 100644 Binary files a/brand/projects/faststream-concurrent-aiokafka/mark-512.png and b/brand/projects/faststream-concurrent-aiokafka/mark-512.png differ diff --git a/brand/projects/faststream-outbox/mark-1024.png b/brand/projects/faststream-outbox/mark-1024.png index a719020..046f5dc 100644 Binary files a/brand/projects/faststream-outbox/mark-1024.png and b/brand/projects/faststream-outbox/mark-1024.png differ diff --git a/brand/projects/faststream-outbox/mark-512.png b/brand/projects/faststream-outbox/mark-512.png index 4dd3c8e..07e6875 100644 Binary files a/brand/projects/faststream-outbox/mark-512.png and b/brand/projects/faststream-outbox/mark-512.png differ diff --git a/brand/projects/faststream-outbox/social-card.png b/brand/projects/faststream-outbox/social-card.png index d20c472..ed87a5d 100644 Binary files a/brand/projects/faststream-outbox/social-card.png and b/brand/projects/faststream-outbox/social-card.png differ diff --git a/brand/projects/faststream-redis-timers/mark-1024.png b/brand/projects/faststream-redis-timers/mark-1024.png index df941a7..1227fe8 100644 Binary files a/brand/projects/faststream-redis-timers/mark-1024.png and b/brand/projects/faststream-redis-timers/mark-1024.png differ diff --git a/brand/projects/faststream-redis-timers/mark-512.png b/brand/projects/faststream-redis-timers/mark-512.png index 42e62dd..c189425 100644 Binary files a/brand/projects/faststream-redis-timers/mark-512.png and b/brand/projects/faststream-redis-timers/mark-512.png differ diff --git a/brand/projects/faststream-redis-timers/social-card.png b/brand/projects/faststream-redis-timers/social-card.png index db0fd54..820f10e 100644 Binary files a/brand/projects/faststream-redis-timers/social-card.png and b/brand/projects/faststream-redis-timers/social-card.png differ diff --git a/brand/projects/httpware/mark-1024.png b/brand/projects/httpware/mark-1024.png index 07f5309..9d644ce 100644 Binary files a/brand/projects/httpware/mark-1024.png and b/brand/projects/httpware/mark-1024.png differ diff --git a/brand/projects/httpware/mark-512.png b/brand/projects/httpware/mark-512.png index 445b15b..8333f05 100644 Binary files a/brand/projects/httpware/mark-512.png and b/brand/projects/httpware/mark-512.png differ diff --git a/brand/projects/httpware/social-card.png b/brand/projects/httpware/social-card.png index 2982796..c926775 100644 Binary files a/brand/projects/httpware/social-card.png and b/brand/projects/httpware/social-card.png differ diff --git a/brand/projects/lite-bootstrap/mark-1024.png b/brand/projects/lite-bootstrap/mark-1024.png index 9d29e58..ec497b0 100644 Binary files a/brand/projects/lite-bootstrap/mark-1024.png and b/brand/projects/lite-bootstrap/mark-1024.png differ diff --git a/brand/projects/lite-bootstrap/mark-512.png b/brand/projects/lite-bootstrap/mark-512.png index 1ab50c6..8666bf2 100644 Binary files a/brand/projects/lite-bootstrap/mark-512.png and b/brand/projects/lite-bootstrap/mark-512.png differ diff --git a/brand/projects/lite-bootstrap/social-card.png b/brand/projects/lite-bootstrap/social-card.png index 59aa169..865bd5d 100644 Binary files a/brand/projects/lite-bootstrap/social-card.png and b/brand/projects/lite-bootstrap/social-card.png differ diff --git a/brand/projects/litestar-sqlalchemy-template/mark-1024.png b/brand/projects/litestar-sqlalchemy-template/mark-1024.png index 4c5e42a..a75ec99 100644 Binary files a/brand/projects/litestar-sqlalchemy-template/mark-1024.png and b/brand/projects/litestar-sqlalchemy-template/mark-1024.png differ diff --git a/brand/projects/litestar-sqlalchemy-template/mark-512.png b/brand/projects/litestar-sqlalchemy-template/mark-512.png index 3c0743f..b77e90c 100644 Binary files a/brand/projects/litestar-sqlalchemy-template/mark-512.png and b/brand/projects/litestar-sqlalchemy-template/mark-512.png differ diff --git a/brand/projects/modern-di-fastapi/mark-1024.png b/brand/projects/modern-di-fastapi/mark-1024.png index 7f5d032..c10b950 100644 Binary files a/brand/projects/modern-di-fastapi/mark-1024.png and b/brand/projects/modern-di-fastapi/mark-1024.png differ diff --git a/brand/projects/modern-di-fastapi/mark-512.png b/brand/projects/modern-di-fastapi/mark-512.png index 57315b3..18da078 100644 Binary files a/brand/projects/modern-di-fastapi/mark-512.png and b/brand/projects/modern-di-fastapi/mark-512.png differ diff --git a/brand/projects/modern-di-faststream/mark-1024.png b/brand/projects/modern-di-faststream/mark-1024.png index d94bb1c..dc415a2 100644 Binary files a/brand/projects/modern-di-faststream/mark-1024.png and b/brand/projects/modern-di-faststream/mark-1024.png differ diff --git a/brand/projects/modern-di-faststream/mark-512.png b/brand/projects/modern-di-faststream/mark-512.png index 102352d..7cf2e62 100644 Binary files a/brand/projects/modern-di-faststream/mark-512.png and b/brand/projects/modern-di-faststream/mark-512.png differ diff --git a/brand/projects/modern-di-litestar/mark-1024.png b/brand/projects/modern-di-litestar/mark-1024.png index 4960020..1e4049a 100644 Binary files a/brand/projects/modern-di-litestar/mark-1024.png and b/brand/projects/modern-di-litestar/mark-1024.png differ diff --git a/brand/projects/modern-di-litestar/mark-512.png b/brand/projects/modern-di-litestar/mark-512.png index 8c58d59..ae87c4f 100644 Binary files a/brand/projects/modern-di-litestar/mark-512.png and b/brand/projects/modern-di-litestar/mark-512.png differ diff --git a/brand/projects/modern-di-pytest/mark-1024.png b/brand/projects/modern-di-pytest/mark-1024.png index 8f712b4..848af04 100644 Binary files a/brand/projects/modern-di-pytest/mark-1024.png and b/brand/projects/modern-di-pytest/mark-1024.png differ diff --git a/brand/projects/modern-di-pytest/mark-512.png b/brand/projects/modern-di-pytest/mark-512.png index ffb1647..836c1e4 100644 Binary files a/brand/projects/modern-di-pytest/mark-512.png and b/brand/projects/modern-di-pytest/mark-512.png differ diff --git a/brand/projects/modern-di-typer/mark-1024.png b/brand/projects/modern-di-typer/mark-1024.png index 92660a6..4aac8a1 100644 Binary files a/brand/projects/modern-di-typer/mark-1024.png and b/brand/projects/modern-di-typer/mark-1024.png differ diff --git a/brand/projects/modern-di-typer/mark-512.png b/brand/projects/modern-di-typer/mark-512.png index 969a8f5..1019eda 100644 Binary files a/brand/projects/modern-di-typer/mark-512.png and b/brand/projects/modern-di-typer/mark-512.png differ diff --git a/brand/projects/modern-di/mark-1024.png b/brand/projects/modern-di/mark-1024.png index 32a3c73..a00538b 100644 Binary files a/brand/projects/modern-di/mark-1024.png and b/brand/projects/modern-di/mark-1024.png differ diff --git a/brand/projects/modern-di/mark-512.png b/brand/projects/modern-di/mark-512.png index 90bfd2d..9d726d2 100644 Binary files a/brand/projects/modern-di/mark-512.png and b/brand/projects/modern-di/mark-512.png differ diff --git a/brand/projects/modern-di/social-card.png b/brand/projects/modern-di/social-card.png index 6864e3f..49e6ffe 100644 Binary files a/brand/projects/modern-di/social-card.png and b/brand/projects/modern-di/social-card.png differ diff --git a/brand/projects/semvertag/mark-1024.png b/brand/projects/semvertag/mark-1024.png index 32df472..445dcbd 100644 Binary files a/brand/projects/semvertag/mark-1024.png and b/brand/projects/semvertag/mark-1024.png differ diff --git a/brand/projects/semvertag/mark-512.png b/brand/projects/semvertag/mark-512.png index edf82b9..3636027 100644 Binary files a/brand/projects/semvertag/mark-512.png and b/brand/projects/semvertag/mark-512.png differ diff --git a/brand/projects/semvertag/social-card.png b/brand/projects/semvertag/social-card.png index 9353581..14a6722 100644 Binary files a/brand/projects/semvertag/social-card.png and b/brand/projects/semvertag/social-card.png differ diff --git a/brand/projects/that-depends/mark-1024.png b/brand/projects/that-depends/mark-1024.png index 54cef5f..9861838 100644 Binary files a/brand/projects/that-depends/mark-1024.png and b/brand/projects/that-depends/mark-1024.png differ diff --git a/brand/projects/that-depends/mark-512.png b/brand/projects/that-depends/mark-512.png index e2ae6e1..6c1752b 100644 Binary files a/brand/projects/that-depends/mark-512.png and b/brand/projects/that-depends/mark-512.png differ diff --git a/brand/projects/that-depends/social-card.png b/brand/projects/that-depends/social-card.png index f5a2ec1..6ab02ea 100644 Binary files a/brand/projects/that-depends/social-card.png and b/brand/projects/that-depends/social-card.png differ diff --git a/docs/assets/social-card-green.png b/docs/assets/social-card-green.png index 37eef6c..0d23f93 100644 Binary files a/docs/assets/social-card-green.png and b/docs/assets/social-card-green.png differ diff --git a/planning/changes/2026-06-30.03-png-optimization/design.md b/planning/changes/2026-06-30.03-png-optimization/design.md new file mode 100644 index 0000000..9b574c1 --- /dev/null +++ b/planning/changes/2026-06-30.03-png-optimization/design.md @@ -0,0 +1,130 @@ +--- +summary: Brand PNGs palette-quantized in the build (Pillow FASTOCTREE) — committed indexed-colour, ~70-80% smaller, visually lossless. +--- + +# Design: Brand PNG optimization + +## Summary + +Fold a palette-quantization pass into the brand build so every generated PNG is +committed in a compact indexed form. The art is flat 2–3 colour, so quantizing +`rsvg-convert`'s 32-bit RGBA output to a small palette is **visually lossless** +and cuts each PNG ~70–80% (social cards ~55 KB → ~13 KB, `mark-1024` ~25 KB → +~8 KB; total committed PNGs ~1.0 MB → ~0.3 MB). Done with **Pillow** (a pip/uv +dependency — no Node, no required system binary), inside `raster.py` so the +committed artifact is always the optimized one. SVGs are deliberately left alone. + +## Motivation + +The brand kit commits 52 PNGs (~1.0 MB). `rsvg-convert` emits 32-bit RGBA with +default zlib — uncompressed for flat art. Research and a measured test showed +palette quantization is the one meaningful win: a flat-colour 1280×640 card drops +from 55 KB to ~10–15 KB with no visible change (palette-8 was already +indistinguishable by eye; transparent marks quantized with FASTOCTREE preserve +alpha with no edge fringing — both verified on cream and dark backgrounds). + +This is **repo-hygiene, not performance**: the assets already clear every social +scraper limit (Facebook 8 MB, X 5 MB) by 12×+ and gzip well over the wire. The +value is a smaller repo and smaller cards copied into the 7 docs repos +(~50 KB → ~13 KB each). + +## Non-goals + +- **SVG optimization.** Our SVGs are machine-generated with no editor cruft and + glyph paths are integer font-units; the served SVGs (favicon/mark/wordmark/ + lockup) are 0.7–12 KB and gzip well, and the big `social-card.svg` is a build + source, not served. Not worth adding `svgo` (Node) or `scour` for a few hundred + bytes. +- **External optimizers.** No `pngquant`/`oxipng` dependency; Pillow already + captures the dominant win uv-natively. (A future change could use them + optionally for a few extra percent — out of scope.) +- **og:image aspect ratio.** Cards are 1280×640 (2:1) vs the canonical + 1200×630; that's a separate design question, not compression. Untouched. +- **Re-copying optimized cards into the 7 docs repos.** A follow-up (see + Operations); not done in this change. + +## Design + +### 1. Quantization in `raster.py` + +`export_png` currently shells out to `rsvg-convert` and returns `bool`. Add a +post-write step that re-saves the PNG palette-quantized: + +```python +_PNG_COLORS = 32 # palette size; flat art needs few. 8 already looked clean; 32 = headroom. + + +def _quantize_png(path: Path, colors: int = _PNG_COLORS) -> None: + """Re-save a PNG as an indexed-palette image (visually lossless for flat art). + FASTOCTREE preserves alpha, so it is correct for both the opaque social cards + and the transparent marks. No-op (leaves the RGBA file) if Pillow is absent.""" + try: + from PIL import Image + except ModuleNotFoundError: + return + im = Image.open(path).convert("RGBA") + q = im.quantize(colors=colors, method=Image.Quantize.FASTOCTREE) + q.save(path, format="PNG", optimize=True) +``` + +`export_png` calls `_quantize_png(png_path)` after a successful `rsvg-convert`. +If `rsvg-convert` is absent there's no PNG and nothing to quantize (unchanged +behaviour). Pillow is a committed dependency, so in normal builds/CI every PNG is +quantized; the `try/except` keeps the build working if it's ever missing. + +This is the only code change to the pipeline — `render.py`, `geometry.py`, +`symbols.py`, `projects.py`, and all SVG output are untouched. Because `export_png` +is the single chokepoint for every PNG (org marks, project marks, lockups PNGs, +social cards), one edit covers them all. + +### 2. Dependency + +Add `pillow` to the `dev` dependency group in `pyproject.toml` (beside +`fonttools`, which the brand build already needs). This repo **tracks** `uv.lock` +(it's the site app, not a distributed package, and `uv.lock` is not in +`.gitignore`), so the lock update is committed alongside. + +### 3. Regenerate & commit + +Run `uv run python -m brand.build.render` to rewrite every PNG under `brand/org/` +and `brand/projects/` in quantized form, and commit the smaller binaries. + +## Operations + +After this merges, the optimized cards differ from the ~50 KB copies currently in +the **7 open docs-repo PRs** (`modern-di#248`, `that-depends#219`, +`lite-bootstrap#141`, `httpware#86`, `faststream-redis-timers#54`, +`faststream-outbox#120`, `semvertag#43`). Recommended follow-up (separate, per +repo): re-copy `brand/projects//social-card.png` into each branch so the +docs sites ship the small card. Not part of this change. + +## Out of scope + +- SVG optimization; external PNG optimizers; the og:image aspect ratio; the docs-repo card refresh (above). + +## Testing + +Render into a tmp dir and assert, with Pillow: + +- A social card (`brand/projects/modern-di/social-card.png`) opens in mode `"P"` + (indexed), is 1280×640, and is below a ceiling (e.g. `< 20_000` bytes). +- A transparent mark (`brand/projects/modern-di/mark-1024.png`) opens in mode + `"P"`, is 1024×1024, **still carries transparency** (`"transparency" in info`), + and is below a ceiling (e.g. `< 15_000` bytes). +- Guard against fidelity regressions cheaply: assert the quantized card's colour + count is small (`len(Image.open(card).convert("RGB").getcolors(maxcolors=100000)) <= _PNG_COLORS + small slack`) — i.e. it really is palette-reduced, not silently left RGBA. + +Plus: full `uv run pytest` green; `uv run python -m brand.build.render` runs +clean; `just check-planning` OK. + +## Risk + +- **Alpha handling on transparent marks.** Naïve MEDIANCUT drops alpha; the + design uses `FASTOCTREE`, verified to preserve per-index alpha with no edge + fringing on light and dark backgrounds. Likelihood low, impact medium if wrong; + the test asserts transparency is retained. +- **Over-aggressive palette banding.** 32 colours is conservative for ≤5-colour + art; verified visually lossless. If a future richer mark ever bands, raise + `_PNG_COLORS`. Likelihood low. +- **Determinism.** FASTOCTREE is deterministic for a given input, so re-running + the build reproduces identical bytes — no spurious diffs. Likelihood low. diff --git a/planning/changes/2026-06-30.03-png-optimization/plan.md b/planning/changes/2026-06-30.03-png-optimization/plan.md new file mode 100644 index 0000000..e7ea88f --- /dev/null +++ b/planning/changes/2026-06-30.03-png-optimization/plan.md @@ -0,0 +1,233 @@ +# brand PNG optimization — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every generated brand PNG is committed palette-quantized (~70–80% +smaller, visually lossless), via a Pillow step in `raster.py`. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `brand-png-optimize` (already created) + +**Commit strategy:** per-task commits. + +## Global constraints + +- **Pillow** is the only new dependency, added to the `dev` dependency group in + `pyproject.toml` (beside `fonttools`). No Node, no required system binary. + `uv.lock` is git-ignored — don't commit it. +- Quantize uses `Image.Quantize.FASTOCTREE` (preserves alpha for transparent + marks AND works for opaque cards), `colors = 32` (constant `_PNG_COLORS`). +- Only `brand/build/raster.py` changes in the pipeline; **no** changes to + `render.py`, `geometry.py`, `symbols.py`, `projects.py`, or any SVG output. +- The quantize step degrades gracefully (no-op) if Pillow is import-unavailable, + mirroring the existing `rsvg-convert`-optional pattern. +- Regenerate and commit all PNGs (`uv run python -m brand.build.render`); the + committed artifact is always the optimized one. +- CI gate is `just` (= `check-planning` + `pytest`); ruff is not a CI gate. +- Imports at module level (the one allowed exception is the in-function + `from PIL import Image` guarded by `try/except ModuleNotFoundError`, which is + the graceful-skip mechanism — keep it local so a missing Pillow can't break + module import); annotate function args; `# ty: ignore` not `# type: ignore`. + +--- + +### Task 1: Palette-quantize PNGs in the build + +**Files:** +- Modify: `pyproject.toml` (add `pillow` to the `dev` group) +- Modify: `brand/build/raster.py` +- Create: `tests/test_pngopt.py` +- Regenerate: `brand/org/**.png`, `brand/projects/**/*.png` + +**Interfaces:** +- Consumes: `projects.render_projects(out_dir: Path | None = None) -> list[Path]` + (writes `mark.svg`, `mark-512.png`, `mark-1024.png`, `lockup.svg`, and — for + `DOCS_REPOS` — `social-card.svg/png` per repo). +- Produces: `raster._quantize_png(path: Path, colors: int = _PNG_COLORS) -> None`; + `export_png` now writes a palette-quantized PNG. + +- [ ] **Step 1: Add Pillow to the dev dependency group** + + In `pyproject.toml`, under `[dependency-groups]` `dev`, add `pillow`: + + ```toml + [dependency-groups] + dev = [ + "fonttools>=4.63.0", + "pillow>=11.0.0", + "pytest>=9.1.1", + ] + ``` + + Then sync: `uv sync`. Expected: Pillow installed (no lock file is committed — + `uv.lock` is git-ignored). + +- [ ] **Step 2: Write the failing test** + + ```python + # tests/test_pngopt.py + from pathlib import Path + + from PIL import Image + + from brand.build import projects as p + + + def _render(tmp: Path) -> None: + p.render_projects(out_dir=tmp) + + + def test_social_card_png_is_quantized_and_small(tmp_path: Path) -> None: + _render(tmp_path) + card = tmp_path / "modern-di" / "social-card.png" + im = Image.open(card) + assert im.mode == "P", f"expected indexed palette, got {im.mode}" + assert im.size == (1280, 640) + assert card.stat().st_size < 20_000, card.stat().st_size + + + def test_transparent_mark_png_keeps_alpha_and_is_small(tmp_path: Path) -> None: + _render(tmp_path) + mark = tmp_path / "modern-di" / "mark-1024.png" + im = Image.open(mark) + assert im.mode == "P", f"expected indexed palette, got {im.mode}" + assert im.size == (1024, 1024) + assert "transparency" in im.info, "transparent mark lost its alpha" + assert mark.stat().st_size < 15_000, mark.stat().st_size + + + def test_card_palette_is_actually_reduced(tmp_path: Path) -> None: + _render(tmp_path) + card = tmp_path / "modern-di" / "social-card.png" + colors = Image.open(card).convert("RGB").getcolors(maxcolors=100_000) + assert colors is not None and len(colors) <= 40, ( + f"card should be palette-reduced; found {None if colors is None else len(colors)} colours" + ) + ``` + +- [ ] **Step 3: Run the test to verify it fails** + + Run: `uv run pytest tests/test_pngopt.py -q` + Expected: FAIL — the PNGs are currently RGBA (`im.mode == "RGBA"`, not `"P"`) + and far larger than the ceilings (`rsvg-convert` output ~25–55 KB). + +- [ ] **Step 4: Implement the quantize step in `raster.py`** + + Read `brand/build/raster.py` first. Add the constant + helper near the top + (after the imports) and call the helper at the end of `export_png` after a + successful `rsvg-convert`: + + ```python + _PNG_COLORS = 32 # palette size; flat art needs few (8 already looked clean; 32 = headroom) + + + def _quantize_png(path: Path, colors: int = _PNG_COLORS) -> None: + """Re-save a PNG as an indexed-palette image — visually lossless for flat + art. FASTOCTREE preserves alpha, so it is correct for both the opaque + social cards and the transparent marks. No-op if Pillow is unavailable.""" + try: + from PIL import Image + except ModuleNotFoundError: + return + im = Image.open(path).convert("RGBA") + q = im.quantize(colors=colors, method=Image.Quantize.FASTOCTREE) + q.save(path, format="PNG", optimize=True) + ``` + + `export_png`'s signature is + `export_png(svg_path, png_path, *, width=None, height=None) -> bool`, so the + output path is `png_path`. After the `subprocess.run([...], check=True)` that + writes the PNG and immediately before `return True`, add: + + ```python + _quantize_png(png_path) + ``` + +- [ ] **Step 5: Run the test to verify it passes** + + Run: `uv run pytest tests/test_pngopt.py -q` + Expected: PASS (3 tests) — PNGs are mode `P`, transparent mark keeps + `transparency`, sizes under the ceilings. + +- [ ] **Step 6: Regenerate the committed PNGs and commit** + + ```bash + uv run python -m brand.build.render + git add pyproject.toml brand/build/raster.py tests/test_pngopt.py brand/org brand/projects + ``` + Sanity-check the shrinkage before committing: + ```bash + du -sh brand/projects/*/social-card.png | sort -h | tail -3 # each should be ~10-15 KB now + ``` + Then commit: + ```bash + git commit -m "feat(brand): palette-quantize generated PNGs (Pillow FASTOCTREE)" + ``` + +--- + +### Task 2: Docs + finalize + +**Files:** +- Modify: `brand/README.md` +- Modify: `architecture/brand-marks.md` +- Modify: `planning/changes/2026-06-30.03-png-optimization/design.md` (summary) + +- [ ] **Step 1: Note it in `brand/README.md`** + + In the `## Per-project marks (brand/projects/)` subsection (or the Outputs + area), add a sentence: + + ```markdown + PNGs are palette-quantized at build time (`brand/build/raster.py`, Pillow + FASTOCTREE) — indexed-colour and ~70–80% smaller than raw `rsvg-convert` + output, with no visible change (the art is flat-colour). Regenerate with + `uv run python -m brand.build.render`. + ``` + +- [ ] **Step 2: Note it in `architecture/brand-marks.md`** + + Append to the per-project/marks section: + + ```markdown + All generated PNGs are palette-quantized in `raster.py` (`_quantize_png`, + Pillow FASTOCTREE, `_PNG_COLORS` palette) so the committed binaries are + indexed-colour and compact; alpha is preserved for the transparent marks. + SVGs are left as generated. + ``` + +- [ ] **Step 3: Finalize the bundle summary** + + Set the `summary:` in this bundle's `design.md` to the realized result, e.g.: + `summary: Brand PNGs palette-quantized in the build (Pillow FASTOCTREE) — committed indexed-colour, ~70-80% smaller, visually lossless.` + +- [ ] **Step 4: Verify** + + Run: `uv run pytest -q` → all green. + Run: `just check-planning` → `planning: OK`. + Run: `uv run python -m brand.build.render` → clean; `git status` shows no + unexpected changes (PNGs already committed in Task 1 reproduce identically — + FASTOCTREE is deterministic). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/README.md architecture/brand-marks.md planning/changes/2026-06-30.03-png-optimization/design.md + git commit -m "docs(brand): document PNG quantization" + ``` + +--- + +## Notes for the executor + +- After both tasks: push the branch and open a PR (do not local-merge); watch CI. +- Do not touch SVGs or add `pngquant`/`oxipng`/`svgo` — out of scope. +- If `git status` after the final `render` shows PNG churn, FASTOCTREE + non-determinism is the suspect — investigate before merging (the design expects + byte-identical reproduction). +- The 7 open docs-repo PRs still carry the large cards; refreshing them is a + separate follow-up (noted in the spec's Operations), not part of this branch. diff --git a/pyproject.toml b/pyproject.toml index 398e332..c2b289c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ [dependency-groups] dev = [ "fonttools>=4.63.0", + "pillow>=11.0.0", "pytest>=9.1.1", ] diff --git a/tests/test_pngopt.py b/tests/test_pngopt.py new file mode 100644 index 0000000..064ac4d --- /dev/null +++ b/tests/test_pngopt.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from PIL import Image + +from brand.build import projects as p + + +def _render(tmp: Path) -> None: + p.render_projects(out_dir=tmp) + + +def test_social_card_png_is_quantized_and_small(tmp_path: Path) -> None: + _render(tmp_path) + card = tmp_path / "modern-di" / "social-card.png" + im = Image.open(card) + assert im.mode == "P", f"expected indexed palette, got {im.mode}" + assert im.size == (1280, 640) + assert card.stat().st_size < 20_000, card.stat().st_size + + +def test_transparent_mark_png_keeps_alpha_and_is_small(tmp_path: Path) -> None: + _render(tmp_path) + mark = tmp_path / "modern-di" / "mark-1024.png" + im = Image.open(mark) + assert im.mode == "P", f"expected indexed palette, got {im.mode}" + assert im.size == (1024, 1024) + assert "transparency" in im.info, "transparent mark lost its alpha" + assert mark.stat().st_size < 15_000, mark.stat().st_size + + +def test_card_palette_is_actually_reduced(tmp_path: Path) -> None: + _render(tmp_path) + card = tmp_path / "modern-di" / "social-card.png" + colors = Image.open(card).convert("RGBA").getcolors(maxcolors=100_000) + assert colors is not None and len(colors) <= 40, ( + f"card should be palette-reduced; found {None if colors is None else len(colors)} colours" + ) diff --git a/uv.lock b/uv.lock index 5f8fa19..cf2b712 100644 --- a/uv.lock +++ b/uv.lock @@ -407,6 +407,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "fonttools" }, + { name = "pillow" }, { name = "pytest" }, ] @@ -419,6 +420,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "fonttools", specifier = ">=4.63.0" }, + { name = "pillow", specifier = ">=11.0.0" }, { name = "pytest", specifier = ">=9.1.1" }, ] @@ -449,6 +451,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0"