From bdb3737c4585297ef90b9d0c8a68148720473463 Mon Sep 17 00:00:00 2001 From: Beesquit Date: Fri, 4 Sep 2026 17:15:42 +0300 Subject: [PATCH] Initial Commit --- .gitignore | 6 + README.md | 80 ++++ fetch_snap.py | 1235 ++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 16 + uv.lock | 108 +++++ 5 files changed, 1445 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 fetch_snap.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6830369 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +.ruff_cache/ +*.egg-info/ +dist/ +fetch.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..8acce20 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# fetch-snap +Absolutely worst piece of code, completely generated by AI to complete one single task - make fastfetch pictures. + +## Purpose +Render fastfetch output to an image (png, jpg, webp, gif) without losing any colors. + +## Install +Requires [uv](https://docs.astral.sh/uv/) and fastfetch. + +```sh +uv sync +``` + +## Usage +```sh +uv run fetch-snap # fetch.png in the current directory +uv run fetch-snap -o snap.png # pick output file +``` + +The output format is taken from the file extension, or set with `-f`: +```sh +uv run fetch-snap -f jpg -o out.jpg +uv run fetch-snap -f webp -o out.webp +uv run fetch-snap -f gif -o out.gif +``` + +### Options +| Flag | Description | +| --- | --- | +| `-o, --output` | Output file (default: `fetch.`) | +| `-f, --format` | Image format: `png`, `jpg`, `webp`, `gif` | +| `--icon` | Fetch icon (logo), passed to fastfetch `--logo`, e.g. `ascii`, `nerd_font` | +| `--theme` | Kitty color theme name or `.conf` path | +| `--prompt` | Real shell prompt line: `top`, `bottom` or `both` | +| `--shell` | Fallback prompt style if the real prompt cannot be captured: `bash`, `zsh`, `fish`, `nu`, `pwsh` | +| `--raw` | Run the command directly, without shell alias expansion | +| `--bg` | Background color, e.g. `#1e1e2e` (default: dark gray, or the theme background) | +| `--scale` | Render scale, multiplies font size and padding (default: 2) | +| `--font` | Font path or fontconfig family name, e.g. `"JetBrainsMono Nerd Font"` | +| `--font-size` | Font size in px (default: 16) | +| `--padding` | Padding around the text in px (default: 14) | +| `--fastfetch` | Command to run instead of `fastfetch` | + +### Examples +```sh +# nerd font icons with a kitty theme and prompt lines +uv run fetch-snap --icon nerd_font --theme nord --prompt both -o snap.png + +# custom background color and a sharper 4x render +uv run fetch-snap --bg "#241f31" --scale 4 -o snap.png + +# use your own pretty fastfetch alias (aliases are expanded by default) +uv run fetch-snap -o snap.png + +# skip alias expansion and call fastfetch directly +uv run fetch-snap --raw -o snap.png + +# prompt two ways +uv run fetch-snap --prompt top --shell zsh -o snap.png +uv run fetch-snap --prompt bottom --shell fish -o snap.png +``` + +## Notes +- The output is captured through a pty so fastfetch keeps its colors. The + real terminal is detected (process tree, like `detect_os.py` does) and the + capture names itself after it, so modules like `terminalfont` and + `{$MY_TERMINAL}`-style configs show your actual terminal and font. +- Without `--raw`, the command runs through an interactive shell, so your + `.bashrc` aliases apply. +- `--prompt` renders your real shell prompt: the shell itself is started + interactively in a pty and the prompt it prints is captured, colors + included. If that fails, a synthetic prompt is used instead. With `top` or + `both`, the typed command line (e.g. `fastfetch --logo nerd_font`) is shown + after the prompt at the top; the bottom only gets the prompt. +- If no `--theme` is given, colors are read from `~/.config/kitty/kitty.conf` + and your terminal's background/foreground. +- Fonts: a nerd font is preferred when installed; missing glyphs fall back + per-character to any font that covers them, so nerd icons and emoji render + even if your main font lacks them. +- The resolved fonts are printed to stderr, e.g. `font: .../SomeNerdFont.ttf`. diff --git a/fetch_snap.py b/fetch_snap.py new file mode 100755 index 0000000..55242ac --- /dev/null +++ b/fetch_snap.py @@ -0,0 +1,1235 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["pillow>=10"] +# /// +"""Render fastfetch output to an image (png, jpg, webp, gif). Colors are kept.""" + +from __future__ import annotations + +import argparse +import getpass +import os +import pty +import re +import select +import shlex +import shutil +import signal +import socket +import subprocess +import sys +import termios +import time +import tty +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import cast, final + +from PIL import Image, ImageDraw, ImageFont + +Color = int | tuple[int, int, int] +RGB = tuple[int, int, int] + +CACHE_DIR = Path.home() / ".cache" / "fetch-snap" +ALIAS_ENV = "_FSC_ALIAS" +DEFAULT_BG: RGB = (18, 18, 18) +DEFAULT_FG: RGB = (230, 230, 230) + +CAPTURE_COLS = 160 +CAPTURE_QUIET = 1.5 +CAPTURE_DEADLINE = 15.0 +PROMPT_QUIET = 0.3 +PROMPT_DEADLINE = 3.0 +OSC_PROBE_TIMEOUT = 0.25 +SCAN_DEPTH = 10 + +# ---------------------------------------------------------------- palette + +STANDARD: list[RGB] = [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), +] + + +def xterm256(n: int) -> RGB: + if n < 16: + return STANDARD[n] + if n <= 231: + n -= 16 + steps = (0, 95, 135, 175, 215, 255) + return steps[n // 36], steps[(n // 6) % 6], steps[n % 6] + v = 8 + (n - 232) * 10 + return v, v, v + + +# ---------------------------------------------------------------- ansi model + + +@dataclass +class Attr: + fg: Color | None = None + bg: Color | None = None + bold: bool = False + inverse: bool = False + + +@dataclass +class Seg: + text: str + attr: Attr + + +@dataclass +class Theme: + fg: Color | None = None + bg: Color | None = None + colors: list[Color | None] = field(default_factory=lambda: [None] * 16) + extended: dict[int, Color] = field(default_factory=dict) + + @property + def background(self) -> RGB: + return self.bg if isinstance(self.bg, tuple) else DEFAULT_BG + + @property + def foreground(self) -> RGB: + return self.fg if isinstance(self.fg, tuple) else DEFAULT_FG + + +# ANSI escape grammar: CSI = ESC [ params/intermediates final +ANSI_TOKEN_RE = re.compile( + r"\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1bP[^\x1b]*(?:\x1b\\)?|\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)|[\s\S]" +) + + +def apply_sgr(a: Attr, body: str) -> None: + if not body: + a.fg = a.bg = None + a.bold = a.inverse = False + return + parts = body.split(";") + i = 0 + while i < len(parts): + v = int(parts[i]) if parts[i] else None + if v is None: + i += 1 + continue + if v == 0: + a.fg = a.bg = None + a.bold = a.inverse = False + elif v == 1: + a.bold = True + elif v == 22: + a.bold = False + elif v == 7: + a.inverse = True + elif v == 27: + a.inverse = False + elif 30 <= v <= 37: + a.fg = v - 30 + elif 90 <= v <= 97: + a.fg = v - 90 + 8 + elif 40 <= v <= 47: + a.bg = v - 40 + elif 100 <= v <= 107: + a.bg = v - 100 + 8 + elif v == 39: + a.fg = None + elif v == 49: + a.bg = None + elif v in (38, 48) and i + 2 < len(parts) and parts[i + 1] == "5": + if v == 38: + a.fg = int(parts[i + 2]) + else: + a.bg = int(parts[i + 2]) + i += 3 + continue + elif v in (38, 48) and i + 4 < len(parts) and parts[i + 1] == "2": + rgb = (int(parts[i + 2]), int(parts[i + 3]), int(parts[i + 4])) + if v == 38: + a.fg = rgb + else: + a.bg = rgb + i += 5 + continue + i += 1 + + +def parse_ansi(text: str) -> list[list[Seg]]: + lines: list[list[Seg]] = [] + curline: list[Seg] = [] + plain = "" + attr = Attr() + + def emit() -> None: + nonlocal plain + if plain: + curline.append(Seg(plain, replace(attr))) + plain = "" + + for tok in ANSI_TOKEN_RE.findall(text): + if tok.startswith("\x1b["): + if tok.endswith("m"): + emit() + apply_sgr(attr, tok[2:-1]) + continue + if tok.startswith(("\x1bP", "\x1b]")): + continue + if tok == "\r": + continue + if tok == "\n": + emit() + lines.append(curline) + curline = [] + continue + plain += tok + emit() + if curline: + lines.append(curline) + while lines and not lines[-1]: + lines.pop() + return lines + + +# ---------------------------------------------------------------- themes + + +def parse_color(s: str) -> Color | None: + s = s.strip() + if s.startswith("#") and len(s) == 7: + try: + return (int(s[1:3], 16), int(s[3:5], 16), int(s[5:7], 16)) + except ValueError: + return None + if s.startswith("#") and len(s) == 4: + try: + return (int(s[1] * 2, 16), int(s[2] * 2, 16), int(s[3] * 2, 16)) + except ValueError: + return None + if s.startswith("rgb:"): + parts = s[4:].split("/") + if len(parts) == 3 and all(parts): + try: + return ( + int(parts[0][:2], 16), + int(parts[1][:2], 16), + int(parts[2][:2], 16), + ) + except ValueError: + return None + return None + + +def kitty_config_dir() -> Path: + k = os.environ.get("KITTY_CONFIG_DIRECTORY") + return Path(k).expanduser() if k else Path.home() / ".config" / "kitty" + + +def load_kitty_conf(theme: Theme, path: Path) -> None: + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, _, val = line.partition(" ") + if not val: + continue + key, val = key.strip(), val.strip() + if key == "background": + c = parse_color(val) + if c is not None: + theme.bg = c + elif key == "foreground": + c = parse_color(val) + if c is not None: + theme.fg = c + elif key.startswith("color"): + try: + idx = int(key[5:]) + except ValueError: + continue + c = parse_color(val) + if c is None: + continue + if idx < 16: + theme.colors[idx] = c + else: + theme.extended[idx] = c + + +def find_theme_path(name: str) -> Path: + p = Path(name) + if p.is_file(): + return p + dirs = [ + kitty_config_dir() / "themes", + kitty_config_dir(), + Path.home() / ".local/share/kitty/themes", + Path("/usr/share/kitty/themes"), + ] + for d in dirs: + for cand in (p, Path(name + ".conf")): + f = d / cand + if f.is_file(): + return f + sys.exit(f"kitty theme not found: {name}") + + +def probe_terminal_colors() -> tuple[RGB | None, RGB | None]: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return None, None + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + tty.setcbreak(fd) + buf = b"" + try: + sys.stdout.write("\x1b]10;?\x07\x1b]11;?\x07") + sys.stdout.flush() + end = time.monotonic() + OSC_PROBE_TIMEOUT + while time.monotonic() < end: + r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic())) + if not r: + break + chunk = os.read(fd, 1024) + if not chunk: + break + buf += chunk + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + fg = bg = None + m = re.search(rb"\x1b]10;rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)", buf) + if m: + fg = (int(m.group(1)[:2], 16), int(m.group(2)[:2], 16), int(m.group(3)[:2], 16)) + m = re.search(rb"\x1b]11;rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)", buf) + if m: + bg = (int(m.group(1)[:2], 16), int(m.group(2)[:2], 16), int(m.group(3)[:2], 16)) + return fg, bg + + +def build_theme(name: str | None, bg: str | None) -> Theme: + theme = Theme() + if name: + load_kitty_conf(theme, find_theme_path(name)) + else: + conf = kitty_config_dir() / "kitty.conf" + if conf.is_file(): + load_kitty_conf(theme, conf) + fg, bgc = probe_terminal_colors() + if fg is not None: + theme.fg = fg + if bgc is not None: + theme.bg = bgc + if bg: + c = parse_color(bg) + if c is None: + sys.exit(f"invalid background color: {bg}") + theme.bg = c + return theme + + +# ---------------------------------------------------------------- terminal + +# Multiplexers fork new sessions exactly like a terminal would, so the +# session boundary alone cannot tell them apart from a real terminal. +PTY_MUX = {"tmux", "screen", "zellij"} + +NAME_RE = re.compile(r"[A-Za-z0-9._+-]{1,40}") + + +def _proc_info(pid: int) -> tuple[str, int, int] | None: + try: + with open(f"/proc/{pid}/stat") as f: + parts = f.read().split() + comm = parts[1].strip("()") + ppid = int(parts[3]) + with open(f"/proc/{pid}/sessionid") as f: + session = int(f.read()) + return comm, ppid, session + except (OSError, ValueError, IndexError): + return None + + +def detect_terminal() -> str: + if os.environ.get("KITTY_WINDOW_ID"): + return "kitty" + prog = os.environ.get("TERM_PROGRAM") + if prog: + return prog + sid = os.getsid(0) + pid = os.getppid() + for _ in range(SCAN_DEPTH): + if pid <= 1: + break + info = _proc_info(pid) + if info is None: + break + comm, ppid, psid = info + if psid != sid: + if comm in PTY_MUX: + pid = ppid + continue + return comm + pid = ppid + return "terminal" + + +TERM_SHELL_SCRIPT = '#!/bin/sh\n"${SHELL:-/bin/bash}" "$1" "$2"\n:\n' + + +def term_shell(name: str) -> str | None: + if not NAME_RE.fullmatch(name): + return None + d = CACHE_DIR / "w" + try: + d.mkdir(parents=True, exist_ok=True) + link = d / name + link.unlink(missing_ok=True) + link.write_text(TERM_SHELL_SCRIPT) + link.chmod(0o755) + return str(link) + except OSError: + return None + + +def term_alias(name: str, site: str | None) -> str | None: + if not name or not NAME_RE.fullmatch(name): + return None + try: + exe = os.readlink("/proc/self/exe") + root = Path(exe).resolve().parent.parent + tree = CACHE_DIR / "uvpy" + if not tree.is_dir(): + shutil.copytree(root, tree, symlinks=True, ignore_dangling_symlinks=True) + target = tree / "bin" / name + if not target.is_file(): + if target.exists(): + target.unlink() + shutil.copy2(exe, target) + target.chmod(0o755) + cfg = tree / "pyvenv.cfg" + if not cfg.is_file(): + cfg.write_text(f"home = {Path(exe).resolve().parent}\n") + if site: + sp = ( + tree + / "lib" + / f"python{sys.version_info[0]}.{sys.version_info[1]}" + / "site-packages" + ) + sp.parent.mkdir(parents=True, exist_ok=True) + if sp.is_symlink() and os.readlink(sp) == site: + pass + else: + if sp.exists(): + shutil.rmtree(sp) + sp.symlink_to(site, target_is_directory=True) + return str(target) + except OSError: + return None + + +def terminal_version(name: str) -> tuple[int, int, int] | None: + bin_ = shutil.which(name) + if not bin_: + return None + try: + run = subprocess.run( + [bin_, "--version"], check=False, capture_output=True, text=True, timeout=5 + ) + except (OSError, subprocess.TimeoutExpired): + return None + m = re.search(r"(\d+)\.(\d+)\.(\d+)", run.stdout or "") + if not m: + return None + return ( + min(99, int(m.group(1))), + min(99, int(m.group(2))), + min(99, int(m.group(3))), + ) + + +# ---------------------------------------------------------------- pty capture + + +def kitty_terminal_font() -> tuple[str, str]: + family = size = None + conf = kitty_config_dir() / "kitty.conf" + if conf.is_file(): + for line in conf.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if family is None and line.startswith("font_family "): + family = line.split(None, 1)[1].strip() + elif size is None and line.startswith("font_size "): + size = line.split(None, 1)[1].strip() + return family or "monospace", size or "11" + + +@final +class QueryResponder: + """Answers terminal protocol queries fastfetch emits into the pty.""" + + def __init__( + self, + fd: int, + font: tuple[str, str], + da_version: tuple[int, int, int] | None, + ) -> None: + self._fd = fd + self._font = font + self._da_version = da_version + self._pos = 0 + + def feed(self, buf: bytes) -> None: + family, size = self._font + searched = self._pos + while True: + di = buf.find(b"\x1bP+q", searched) + ci = buf.find(b"\x1b[>c", searched) + if di < 0 and ci < 0: + self._pos = max(self._pos, len(buf) - 8) + return + if ci < 0 or (di >= 0 and di < ci): + end = buf.find(b"\x1b\\", di + 4) + if end < 0: + self._pos = max(self._pos, di) + return + resp = b"" + for qid in buf[di + 4 : end].decode("ascii", "replace").split(";"): + try: + dec = bytes.fromhex(qid).decode("utf-8", "replace") + except ValueError: + continue + if dec == "kitty-query-font_family": + resp += ( + b"\x1bP1+r" + + (dec + "=" + family).encode().hex().encode() + + b"\x1b\\" + ) + elif dec == "kitty-query-font_size": + resp += ( + b"\x1bP1+r" + + (dec + "=" + size).encode().hex().encode() + + b"\x1b\\" + ) + if resp: + try: + os.write(self._fd, resp) + except OSError: + pass + searched = end + 2 + else: + if self._da_version: + ver = "".join(f"{v:02d}" for v in self._da_version) + try: + os.write(self._fd, b"\x1b[>1;" + ver.encode() + b";0c") + except OSError: + pass + searched = ci + 4 + + +def read_pty( + fd: int, responder: QueryResponder | None, quiet: float, deadline: float +) -> bytes: + buf = b"" + tail = time.monotonic() + while time.monotonic() < deadline: + r, _, _ = select.select([fd], [], [], 0.05) + if not r: + if buf and time.monotonic() - tail > quiet: + break + continue + try: + chunk = os.read(fd, 65536) + except OSError: + break + if not chunk: + break + buf += chunk + tail = time.monotonic() + if responder: + responder.feed(buf) + return buf + + +def reap_child(pid: int, fd: int) -> None: + try: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + except OSError: + pass + try: + os.close(fd) + except OSError: + pass + + +def capture_prompt() -> str | None: + shell = os.environ.get("SHELL") or "bash" + if not shell or not Path(shell).is_file(): + return None + try: + pid, fd = pty.fork() + except OSError: + return None + if pid == 0: + try: + os.environ["TERM"] = os.environ.get("TERM") or "xterm-256color" + os.execv(shell, [shell, "-i"]) + except OSError: + os._exit(127) + buf = read_pty( + fd, None, quiet=PROMPT_QUIET, deadline=time.monotonic() + PROMPT_DEADLINE + ) + reap_child(pid, fd) + if not buf: + return None + text = buf.replace(b"\r", b"").decode("utf-8", "replace") + text = re.sub(r"\x1b\][^\x1b\x07]*(\x07|\x1b\\)", "", text) + lines = text.split("\n") + clean = [re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", ln) for ln in lines] + for i in range(len(clean) - 1, -1, -1): + if clean[i].strip(): + return lines[i] + return None + + +def pty_capture(terminal: str, argv: list[str]) -> str | None: + if not os.environ.get(ALIAS_ENV): + try: + own = os.path.basename(os.readlink("/proc/self/exe")) + except OSError: + own = terminal + if own != terminal: + sps = [p for p in sys.path if p.endswith("site-packages")] + alias = term_alias(terminal, sps[0] if sps else None) + if alias: + os.environ[ALIAS_ENV] = "1" + os.environ["PYTHONHOME"] = str(Path(alias).resolve().parent.parent) + try: + os.execv(alias, [alias] + sys.argv) + except OSError: + pass + if terminal != "kitty": + os.environ.pop("KITTY_WINDOW_ID", None) + try: + pid, fd = pty.fork() + except OSError: + return None + if pid == 0: + try: + os.environ.setdefault("KITTY_INSTALLATION_DIR", "kitty") + if terminal == "kitty": + os.environ.setdefault("KITTY_WINDOW_ID", "1") + os.execv(argv[0], argv) + except OSError: + os._exit(127) + try: + attrs = termios.tcgetattr(fd) + attrs[3] = attrs[3] & ~termios.ECHO + termios.tcsetattr(fd, termios.TCSANOW, attrs) + except (termios.error, OSError): + pass + responder = QueryResponder( + fd, + font=kitty_terminal_font(), + da_version=terminal_version(terminal) if terminal == "foot" else None, + ) + buf = read_pty( + fd, responder, quiet=CAPTURE_QUIET, deadline=time.monotonic() + CAPTURE_DEADLINE + ) + reap_child(pid, fd) + return buf.replace(b"\r", b"").decode("utf-8", "replace") + + +def fetch_output(fetch: str, icon: str | None, raw: bool) -> str: + cmd = [os.path.expanduser(p) for p in shlex.split(fetch)] or ["fastfetch"] + if icon: + cmd += ["--logo", icon] + joined = shlex.join(cmd) + shell = os.environ.get("SHELL") or "bash" + inner = f"stty cols {CAPTURE_COLS}; " + joined + " ; :" + terminal = detect_terminal() + wrapper = term_shell(terminal) + if wrapper: + argv = [wrapper] + (["-ic", inner] if not raw else ["-c", inner]) + out = pty_capture(terminal, argv) + if out: + return out + argv = [shell] + (["-ic", inner] if not raw else ["-c", inner]) + out = pty_capture(terminal, argv) + if out: + return out + sc = shutil.which("script") + if sc: + run = subprocess.run( + [sc, "-qec", inner, "/dev/null"], check=False, capture_output=True + ) + data = run.stdout.replace(b"\r", b"") + if data.strip(): + return data.decode("utf-8", "replace") + run = subprocess.run(cmd, check=False, capture_output=True) + data = run.stdout.replace(b"\r", b"") + if not data.strip(): + sys.exit( + "fastfetch produced no output: " + + run.stderr.decode("utf-8", "replace").strip() + ) + return data.decode("utf-8", "replace") + + +# ---------------------------------------------------------------- fonts + +FONT_NAMES = ["DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "monospace"] +FONT_PATHS = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "/usr/share/fonts/liberation/LiberationMono-Regular.ttf", + "/usr/share/fonts/noto/NotoSansMono-Regular.ttf", +] +BOLD_FONT_PATHS = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", + "/usr/share/fonts/liberation/LiberationMono-Bold.ttf", + "/usr/share/fonts/noto/NotoSansMono-Bold.ttf", +] + + +@dataclass +class FontSet: + base: ImageFont.FreeTypeFont + bold: ImageFont.FreeTypeFont | None + base_path: str | None + bold_path: str | None + fallbacks: list[str] + + +def scan_font_dirs() -> list[str]: + found: list[str] = [] + dirs = [ + Path.home() / ".local/share/fonts", + Path.home() / ".fonts", + Path("/usr/share/fonts"), + Path("/usr/local/share/fonts"), + ] + for d in dirs: + if not d.is_dir(): + continue + try: + for f in sorted(d.rglob("*")): + if f.suffix.lower() in (".ttf", ".otf"): + found.append(str(f)) + except OSError: + continue + return found + + +def fc_match(pattern: str) -> str | None: + try: + out = subprocess.run( + ["fc-match", "-f", "%{file}", pattern], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + got = out.stdout.strip() + return got or None + except (OSError, subprocess.TimeoutExpired): + return None + + +def charset_ranges(path: str | None) -> list[tuple[int, int]] | None: + if not path: + return None + try: + out = subprocess.run( + ["fc-query", "--format", "%{charset}", path], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return None + ranges = [] + for part in out.stdout.strip().split(): + if "-" in part: + a, b = part.split("-", 1) + ranges.append((int(a, 16), int(b, 16))) + else: + ranges.append((int(part, 16), int(part, 16))) + return ranges or None + + +def covers(ranges: list[tuple[int, int]] | None, ch: str) -> bool: + if not ranges: + return True + cp = ord(ch) + lo, hi = 0, len(ranges) - 1 + while lo <= hi: + mid = (lo + hi) // 2 + a, b = ranges[mid] + if cp < a: + hi = mid - 1 + elif cp > b: + lo = mid + 1 + else: + return True + return False + + +def try_font( + cands: list[str], size: float +) -> tuple[ImageFont.FreeTypeFont | None, str | None]: + for c in cands: + try: + return ImageFont.truetype(c, size), c + except OSError: + continue + return None, None + + +def font_path_of(name: str | None) -> str | None: + if name and Path(name).suffix.lower() in (".ttf", ".otf"): + return name + return fc_match(name) if name else None + + +def resolve_fonts(size: float, path: str | None) -> FontSet: + scanned = scan_font_dirs() + nerds = sorted( + f for f in scanned if "Nerd" in Path(f).name or "NerdFont" in Path(f).name + ) + nerd_mono = [f for f in nerds if "Mono" in f] + nerd_rest = [f for f in nerds if "Mono" not in f] + others = [f for f in scanned if f not in nerds] + explicit = [] + if path: + if Path(path).is_file(): + explicit.append(path) + else: + f = fc_match(path) + if f: + explicit.append(f) + cands = explicit + nerd_mono + nerd_rest + for pat in ("monospace", "mono"): + m = fc_match(pat) + if m and m not in cands: + cands.append(m) + cands += FONT_PATHS + FONT_NAMES + others + base, base_name = try_font(cands, size) + if base is None: + sys.exit("cannot load a font, pass --font ") + base_path = font_path_of(base_name) + bold_cands = [] + if base_path: + p = Path(base_path) + stem = p.stem.replace("-Regular", "").replace("Regular", "") + for cand in (f"{stem}-Bold", f"{stem}Bold", p.stem + "-Bold", p.stem + "Bold"): + bold_cands.append(str(p.with_name(cand + p.suffix))) + for pat in ("monospace:bold", "mono:bold"): + m = fc_match(pat) + if m and m not in bold_cands: + bold_cands.append(m) + bold_cands += BOLD_FONT_PATHS + bold, bold_name = try_font(bold_cands, size) + bold_path = font_path_of(bold_name) if bold is not None else None + fallbacks: list[str] = [] + for fam in ( + "Symbols Nerd Font", + "JetBrainsMono Nerd Font", + "FiraCode Nerd Font", + "Noto Sans Symbols 2", + "Noto Sans Symbols", + "Symbola", + "Unifont", + ): + f = fc_match(fam) + if f and f not in fallbacks: + fallbacks.append(f) + fallbacks += nerds + others + FONT_PATHS + dedup: list[str] = [] + for f in fallbacks: + if f not in dedup: + dedup.append(f) + if base_path: + dedup = [f for f in dedup if f != base_path] + return FontSet(base, bold, base_path, bold_path, dedup) + + +@final +class FontPicker: + """Picks a glyph-matching font per character, with lazy caches.""" + + def __init__(self, fonts: FontSet, size: float) -> None: + self.base = fonts.base + self.bold = fonts.bold + self.size = size + self.fallbacks = fonts.fallbacks + self.primary_cs = charset_ranges(fonts.base_path) + self.bold_cs = charset_ranges(fonts.bold_path) or self.primary_cs + self._fonts: dict[str, ImageFont.FreeTypeFont] = {} + self._cs: dict[str, list[tuple[int, int]] | None] = {} + if fonts.base_path: + self._cs[fonts.base_path] = self.primary_cs + + def covers(self, ch: str, is_bold: bool) -> bool: + return covers(self.bold_cs if is_bold else self.primary_cs, ch) + + def pick(self, ch: str, is_bold: bool) -> ImageFont.FreeTypeFont: + if self.bold is not None and is_bold and covers(self.bold_cs, ch): + return self.bold + if covers(self.primary_cs, ch): + return self.base + fb = self._fallback(ch) + return fb or (self.bold if (self.bold is not None and is_bold) else self.base) + + def _fallback(self, ch: str) -> ImageFont.FreeTypeFont | None: + base_path = next((p for p in self._cs if p), None) + m = fc_match(f":charset={ord(ch):X}") + cands = ([m] if m and m != base_path else []) + self.fallbacks + for cand in cands: + if cand not in self._cs: + self._cs[cand] = charset_ranges(cand) + if not covers(self._cs[cand], ch): + continue + f = self._fonts.get(cand) + if f is None: + f = try_font([cand], self.size)[0] + if f is None: + continue + self._fonts[cand] = f + return f + return None + + +# ---------------------------------------------------------------- rendering + + +def resolve_color(c: Color | None, bold: bool, theme: Theme) -> RGB: + if c is None: + return theme.foreground + if isinstance(c, tuple): + return c + idx = c + if bold and idx < 8: + idx += 8 + if idx < 16: + return cast(RGB, theme.colors[idx] or STANDARD[idx]) + return cast(RGB, theme.extended.get(idx) or xterm256(idx)) + + +def render( + lines: list[list[Seg]], + fonts: FontSet, + theme: Theme, + pad: int, + size: float, +) -> Image.Image: + base = fonts.base + metrics = base.getmetrics() + line_h = metrics[0] + metrics[1] + 2 + widths = [] + for line in lines: + w = 0.0 + for seg in line: + f = fonts.bold if (seg.attr.bold and fonts.bold is not None) else base + w += f.getlength(seg.text) + widths.append(w) + picker = FontPicker(fonts, size) + img = Image.new( + "RGB", + (int(max(widths)) + 1 + 2 * pad, line_h * len(lines) + 2 * pad), + theme.background, + ) + d = ImageDraw.Draw(img) + for li, line in enumerate(lines): + top = pad + li * line_h + baseline = pad + li * line_h + metrics[0] + 1 + x = float(pad) + for seg in line: + a = seg.attr + is_bold = a.bold and fonts.bold is not None + f = fonts.bold if is_bold else base + if f is None: + f = base + fg = resolve_color(a.fg, a.bold, theme) + bgc = None if a.bg is None else resolve_color(a.bg, False, theme) + if a.inverse: + fg, bgc = bgc, fg + if fg is None: + fg = theme.background + need_fb = any(not picker.covers(ch, is_bold) for ch in seg.text) + if not need_fb: + w = f.getlength(seg.text) + if w <= 0: + continue + if bgc is not None: + d.rectangle([x, top, x + w, top + line_h], fill=bgc) + if a.bold and fonts.bold is None: + d.text( + (x, baseline), + seg.text, + font=base, + fill=fg, + anchor="ls", + stroke_width=1, + ) + d.text((x, baseline), seg.text, font=f, fill=fg, anchor="ls") + x += w + else: + wl = [] + for ch in seg.text: + ff = picker.pick(ch, is_bold) + wl.append((ff, ff.getlength(ch))) + total = sum(w for _, w in wl) + if bgc is not None: + d.rectangle([x, top, x + total, top + line_h], fill=bgc) + xc = x + for ch, (ff, w) in zip(seg.text, wl): + if a.bold and fonts.bold is None and ff is base: + d.text( + (xc, baseline), + ch, + font=base, + fill=fg, + anchor="ls", + stroke_width=1, + ) + d.text((xc, baseline), ch, font=ff, fill=fg, anchor="ls") + xc += w + x += total + return img + + +def save_image(img: Image.Image, path: str, fmt: str) -> None: + if fmt == "gif": + img.convert("P", palette=Image.ADAPTIVE, colors=256).save(path, format="GIF") # pyright: ignore[reportAttributeAccessIssue] + elif fmt == "jpg": + img.save(path, format="JPEG", quality=92) + elif fmt == "webp": + img.save(path, format="WEBP", lossless=True) + else: + img.save(path, format="PNG") + + +# ---------------------------------------------------------------- prompt + + +@dataclass +class PromptParts: + user: str + host: str + dir: str + marker: str + style: str + + +def prompt_parts(shell: str | None) -> PromptParts: + try: + user = getpass.getuser() + except OSError: + user = "user" + try: + host = socket.gethostname().split(".")[0] + except OSError: + host = "localhost" + home = os.path.expanduser("~") + cwd = os.getcwd() + if cwd == home: + dir_ = "~" + elif cwd.startswith(home + os.sep): + dir_ = "~" + cwd[len(home) :] + else: + dir_ = cwd + style = (shell or os.environ.get("SHELL", "") or "bash").strip().lower() + style = style.rsplit("/", 1)[-1] + marker = "#" if user == "root" else "$" + if style.startswith("fish"): + return PromptParts(user, host, dir_, ">", "plain") + if style.startswith("zsh"): + return PromptParts(user, host, dir_, "%", "plain") + if style.startswith("nu"): + return PromptParts("", "", dir_, ">", "nu") + if style.startswith("pwsh") or "powershell" in style: + return PromptParts("", "", dir_, ">", "pwsh") + return PromptParts(user, host, dir_, marker, "bracket") + + +def make_prompt(shell: str | None) -> list[Seg]: + p = prompt_parts(shell) + + def bright(i: int) -> Attr: + return Attr(fg=i + 8, bold=True) + + if p.style == "nu": + return [Seg(p.dir, bright(4)), Seg("> ", Attr())] + if p.style == "pwsh": + return [Seg("PS ", bright(4)), Seg(p.dir, Attr()), Seg("> ", Attr())] + if p.style == "plain": + return [ + Seg(f"{p.user}@{p.host}", bright(2)), + Seg(f" {p.dir}", bright(4)), + Seg(p.marker + " ", Attr()), + ] + return [ + Seg("[", Attr()), + Seg(f"{p.user}@{p.host}", bright(2)), + Seg(f" {p.dir}]", bright(4)), + Seg(p.marker + " ", Attr()), + ] + + +def command_line(fetch: str, icon: str | None) -> list[Seg]: + cmd = [fetch] + if icon: + cmd += ["--logo", icon] + return [Seg(shlex.join(cmd), Attr())] + + +def prompt_lines(prompt_text: str | None) -> list[list[Seg]] | None: + if not prompt_text: + return None + lines = parse_ansi(prompt_text) + while lines and not lines[-1]: + lines.pop() + return lines or None + + +# ---------------------------------------------------------------- cli + + +class Args(argparse.Namespace): + output: str | None + format: str | None + icon: str | None + theme: str | None + prompt: str | None + shell: str | None + raw: bool + bg: str | None + scale: float + font_size: int + font: str | None + padding: int + fastfetch: str + + +def parse_args(argv: list[str] | None) -> Args: + p = argparse.ArgumentParser( + prog="fetch-snap", + description="Render fastfetch output to an image, keeping all colors.", + ) + p.add_argument( + "-o", + "--output", + help="output file (default: fetch.); extension picks the format", + ) + p.add_argument( + "-f", + "--format", + choices=["png", "jpg", "webp", "gif"], + help="image format (default: png)", + ) + p.add_argument( + "--icon", + help="fetch icon (logo) to use, passed to fastfetch --logo, e.g. ascii, nerd_font", + ) + p.add_argument( + "--theme", + help="kitty color theme name (searched in kitty theme dirs) or a .conf file path", + ) + p.add_argument( + "--prompt", + choices=["top", "bottom", "both"], + help="add a real shell prompt line above and/or below the output, captured from $SHELL", + ) + p.add_argument( + "--shell", + help="fallback prompt style if the real prompt cannot be captured: bash, zsh, fish, nu, pwsh (default: $SHELL)", + ) + p.add_argument( + "--raw", + action="store_true", + help="run the fastfetch command directly, do not expand shell aliases", + ) + p.add_argument( + "--bg", + help="background color, e.g. #1e1e2e (default: dark gray, or the theme background)", + ) + p.add_argument( + "--scale", + type=float, + default=2, + help="render scale, multiplies font size and padding (default: 2)", + ) + p.add_argument( + "--font-size", + type=int, + default=16, + help="font size in px at scale 1 (default: 16)", + ) + p.add_argument("--font", help="path to a ttf/otf font") + p.add_argument( + "--padding", + type=int, + default=14, + help="padding around the text in px (default: 14)", + ) + p.add_argument( + "--fastfetch", + default="fastfetch", + help="command to run (default: fastfetch), run through an interactive shell unless --raw", + ) + return cast(Args, p.parse_args(argv)) + + +def resolve_output(output: str | None, fmt: str | None) -> tuple[str, str]: + if not fmt: + ext = Path(output).suffix[1:].lower() if output else "" + fmt = ext if ext in ("png", "jpg", "jpeg", "webp", "gif") else "png" + if fmt == "jpeg": + fmt = "jpg" + return output or f"fetch.{fmt}", fmt + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + theme = build_theme(args.theme, args.bg) + text = fetch_output(args.fastfetch, args.icon, args.raw) + lines = parse_ansi(text) + prompt = prompt_lines(capture_prompt() if args.prompt else None) + if args.prompt in ("top", "both"): + top = list(prompt or [make_prompt(args.shell)]) + top[-1] = top[-1] + command_line(args.fastfetch, args.icon) + lines[:0] = top + if args.prompt in ("bottom", "both"): + lines.extend(prompt or [make_prompt(args.shell)]) + size = args.font_size * args.scale + pad = round(args.padding * args.scale) + fonts = resolve_fonts(size, args.font) + if fonts.base_path: + print(f"font: {fonts.base_path}", file=sys.stderr) + if fonts.bold_path and fonts.bold_path != fonts.base_path: + print(f"bold: {fonts.bold_path}", file=sys.stderr) + img = render(lines, fonts, theme, pad, size) + out, fmt = resolve_output(args.output, args.format) + save_image(img, out, fmt) + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..95e398f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "fetch-snap" +version = "0.2.0" +description = "Render fastfetch output to an image (png, jpg, webp, gif)" +requires-python = ">=3.10" +dependencies = ["pillow>=10"] + +[project.scripts] +fetch-snap = "fetch_snap:main" + +[tool.setuptools] +py-modules = ["fetch_snap"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f909462 --- /dev/null +++ b/uv.lock @@ -0,0 +1,108 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "fetch-snap" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "pillow" }, +] + +[package.metadata] +requires-dist = [{ name = "pillow", specifier = ">=10" }] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +]