mga revised this gist 8 hours ago. Go to revision
3 files changed, 324 insertions
Lucide2img.py(file created)
| @@ -0,0 +1,38 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """ | |
| 3 | + | lucide2img — fetch an icon from lucide.dev and render it as a square avatar image. | |
| 4 | + | ||
| 5 | + | Examples | |
| 6 | + | -------- | |
| 7 | + | python lucide2img.py camera | |
| 8 | + | python lucide2img.py github -r 1024 --bg "#0d1117" --fg white -f webp | |
| 9 | + | python lucide2img.py heart --bg "#ffe4e6" --fg "#e11d48" --radius 0.5 -o me.png | |
| 10 | + | python lucide2img.py rocket --bg transparent # transparent PNG | |
| 11 | + | ||
| 12 | + | Icon names use lucide's kebab-case (e.g. "arrow-right"); "Arrow Right" and | |
| 13 | + | "arrow_right" are normalized automatically. Browse names at | |
| 14 | + | https://lucide.dev/icons. Note that brand logos (e.g. github) were split out | |
| 15 | + | of Lucide's core set -- use simpleicons2img.py for those. | |
| 16 | + | ||
| 17 | + | Requires: cairosvg + pillow (only for raster output; svg output needs neither). | |
| 18 | + | """ | |
| 19 | + | ||
| 20 | + | from icon2img import IconSource, run | |
| 21 | + | ||
| 22 | + | LUCIDE = IconSource( | |
| 23 | + | key="lucide", | |
| 24 | + | prog_name="lucide2img", | |
| 25 | + | description="Fetch a lucide.dev icon and render it as a square avatar image.", | |
| 26 | + | url_template=( | |
| 27 | + | "https://raw.githubusercontent.com/lucide-icons/lucide/main/icons/{name}.svg" | |
| 28 | + | ), | |
| 29 | + | render_mode="stroke", | |
| 30 | + | browse_url="https://lucide.dev/icons", | |
| 31 | + | icon_arg_help="lucide icon name, e.g. 'camera' or 'arrow-right'", | |
| 32 | + | slug_style="kebab", | |
| 33 | + | default_stroke_width=2.0, | |
| 34 | + | not_found_hint="check the spelling", | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | if __name__ == "__main__": | |
| 38 | + | run(LUCIDE) | |
Simpleuicons2img.py(file created)
| @@ -0,0 +1,44 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """ | |
| 3 | + | simpleicons2img — fetch a brand logo from Simple Icons (simpleicons.org) and | |
| 4 | + | render it as a square avatar image. | |
| 5 | + | ||
| 6 | + | Examples | |
| 7 | + | -------- | |
| 8 | + | python simpleicons2img.py python | |
| 9 | + | python simpleicons2img.py github --bg "#0d1117" --fg white -r 1024 -f webp | |
| 10 | + | python simpleicons2img.py spotify --fg "#1DB954" --radius 0.5 -o spotify.png | |
| 11 | + | python simpleicons2img.py x --bg transparent # transparent PNG | |
| 12 | + | ||
| 13 | + | Names use Simple Icons "slugs": lowercase, no spaces (e.g. "Adobe Photoshop" | |
| 14 | + | -> "adobephotoshop", "GitHub" -> "github"). Inputs are lowercased and spaces | |
| 15 | + | stripped automatically; for anything unusual look up the slug at | |
| 16 | + | https://simpleicons.org. | |
| 17 | + | ||
| 18 | + | Note on colors: Simple Icons logos are single-color shapes, so `--fg` sets the | |
| 19 | + | whole logo color (default black). This tool fetches the uncolored source SVGs | |
| 20 | + | directly from the Simple Icons project so recoloring is exact; the | |
| 21 | + | cdn.simpleicons.org endpoint bakes a color in, which would override --fg. | |
| 22 | + | ||
| 23 | + | Requires: cairosvg + pillow (only for raster output; svg output needs neither). | |
| 24 | + | """ | |
| 25 | + | ||
| 26 | + | from icon2img import IconSource, run | |
| 27 | + | ||
| 28 | + | SIMPLE_ICONS = IconSource( | |
| 29 | + | key="simpleicons", | |
| 30 | + | prog_name="simpleicons2img", | |
| 31 | + | description="Fetch a Simple Icons brand logo and render it as a square avatar.", | |
| 32 | + | url_template=( | |
| 33 | + | "https://raw.githubusercontent.com/simple-icons/simple-icons/master/" | |
| 34 | + | "icons/{name}.svg" | |
| 35 | + | ), | |
| 36 | + | render_mode="fill", | |
| 37 | + | browse_url="https://simpleicons.org", | |
| 38 | + | icon_arg_help="Simple Icons slug, e.g. 'python' or 'github'", | |
| 39 | + | slug_style="compact", | |
| 40 | + | not_found_hint="look up the exact slug", | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | if __name__ == "__main__": | |
| 44 | + | run(SIMPLE_ICONS) | |
icon2img.py(file created)
| @@ -0,0 +1,242 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """ | |
| 3 | + | icon2img — shared engine for turning a 24x24 icon/logo SVG into a square | |
| 4 | + | raster (or vector) image suitable for a profile picture / avatar. | |
| 5 | + | ||
| 6 | + | This module is used by the thin CLI wrappers `lucide2img.py` and | |
| 7 | + | `simpleicons2img.py`. Each wrapper only describes its icon *source* | |
| 8 | + | (URL, how to recolor, how to slugify names) via an `IconSource` and then | |
| 9 | + | calls `run(source)`. | |
| 10 | + | ||
| 11 | + | Two recolor modes are supported because the two libraries draw differently: | |
| 12 | + | - "stroke": Lucide icons are outline strokes -> we set the SVG `stroke`. | |
| 13 | + | - "fill": Simple Icons are solid shapes -> we set the SVG `fill`. | |
| 14 | + | """ | |
| 15 | + | ||
| 16 | + | from __future__ import annotations | |
| 17 | + | ||
| 18 | + | import argparse | |
| 19 | + | import io | |
| 20 | + | import re | |
| 21 | + | import urllib.error | |
| 22 | + | import urllib.request | |
| 23 | + | from dataclasses import dataclass | |
| 24 | + | ||
| 25 | + | # -------------------------------------------------------------------------- | |
| 26 | + | # Formats | |
| 27 | + | # -------------------------------------------------------------------------- | |
| 28 | + | # Rasterized via Pillow (name -> PIL format string). | |
| 29 | + | # "svg" (vector passthrough) and "png" (cairosvg native) are handled specially. | |
| 30 | + | PIL_FORMATS = { | |
| 31 | + | "jpg": "JPEG", "jpeg": "JPEG", "webp": "WEBP", "bmp": "BMP", | |
| 32 | + | "tiff": "TIFF", "tif": "TIFF", "gif": "GIF", "ico": "ICO", | |
| 33 | + | } | |
| 34 | + | NO_ALPHA = {"JPEG", "BMP"} # formats that can't keep transparency | |
| 35 | + | ALL_FORMATS = ["png", "svg"] + sorted(PIL_FORMATS) | |
| 36 | + | ||
| 37 | + | ||
| 38 | + | # -------------------------------------------------------------------------- | |
| 39 | + | # Source description | |
| 40 | + | # -------------------------------------------------------------------------- | |
| 41 | + | @dataclass(frozen=True) | |
| 42 | + | class IconSource: | |
| 43 | + | key: str # short id, e.g. "lucide" | |
| 44 | + | prog_name: str # CLI program name, e.g. "lucide2img" | |
| 45 | + | description: str # one-line CLI description | |
| 46 | + | url_template: str # str with a single {name} placeholder | |
| 47 | + | render_mode: str # "stroke" or "fill" | |
| 48 | + | browse_url: str # where to find valid icon names | |
| 49 | + | icon_arg_help: str # help text for the positional icon argument | |
| 50 | + | slug_style: str = "kebab" # "kebab" (spaces->'-') or "compact" (strip spaces) | |
| 51 | + | default_stroke_width: float = 2.0 | |
| 52 | + | not_found_hint: str = "check the name" | |
| 53 | + | ||
| 54 | + | ||
| 55 | + | def normalize_name(name: str, style: str) -> str: | |
| 56 | + | n = name.strip().lower() | |
| 57 | + | if style == "compact": # Simple Icons slugs remove spaces | |
| 58 | + | return re.sub(r"\s+", "", n) | |
| 59 | + | return re.sub(r"[\s_]+", "-", n) # Lucide uses kebab-case | |
| 60 | + | ||
| 61 | + | ||
| 62 | + | # -------------------------------------------------------------------------- | |
| 63 | + | # Fetch | |
| 64 | + | # -------------------------------------------------------------------------- | |
| 65 | + | def fetch_svg(source: IconSource, name: str) -> str: | |
| 66 | + | url = source.url_template.format(name=name) | |
| 67 | + | req = urllib.request.Request(url, headers={"User-Agent": "icon2img/1.0"}) | |
| 68 | + | try: | |
| 69 | + | with urllib.request.urlopen(req, timeout=20) as resp: | |
| 70 | + | return resp.read().decode("utf-8") | |
| 71 | + | except urllib.error.HTTPError as e: | |
| 72 | + | if e.code == 404: | |
| 73 | + | raise SystemExit( | |
| 74 | + | f"error: no {source.key} icon named '{name}'.\n" | |
| 75 | + | f" {source.not_found_hint}: {source.browse_url}" | |
| 76 | + | ) | |
| 77 | + | raise SystemExit(f"error: failed to fetch icon ({e.code} {e.reason})") | |
| 78 | + | except urllib.error.URLError as e: | |
| 79 | + | raise SystemExit(f"error: network problem fetching icon: {e.reason}") | |
| 80 | + | ||
| 81 | + | ||
| 82 | + | # -------------------------------------------------------------------------- | |
| 83 | + | # SVG rewriting | |
| 84 | + | # -------------------------------------------------------------------------- | |
| 85 | + | def _fmt(x: float) -> str: | |
| 86 | + | """Compact float formatting for SVG attributes.""" | |
| 87 | + | return f"{x:.4f}".rstrip("0").rstrip(".") | |
| 88 | + | ||
| 89 | + | ||
| 90 | + | def build_svg(source: IconSource, src: str, res: int, bg: str, fg: str, | |
| 91 | + | padding: float, radius: float, stroke_width: float | None) -> str: | |
| 92 | + | """Rewrite a raw 24x24 icon SVG into a padded, colored square canvas.""" | |
| 93 | + | m = re.search(r"<svg\b([^>]*)>(.*)</svg>", src, re.DOTALL | re.IGNORECASE) | |
| 94 | + | if not m: | |
| 95 | + | raise SystemExit("error: fetched file did not look like an SVG") | |
| 96 | + | root_attrs, inner = m.group(1), m.group(2) | |
| 97 | + | ||
| 98 | + | # Recolor: any explicit currentColor becomes the requested foreground. | |
| 99 | + | inner = inner.replace("currentColor", fg) | |
| 100 | + | ||
| 101 | + | # Padding is expressed in the icon's 24-unit space by growing the viewBox. | |
| 102 | + | total = 24.0 / (1.0 - 2.0 * padding) | |
| 103 | + | margin = (total - 24.0) / 2.0 | |
| 104 | + | view_box = f"{_fmt(-margin)} {_fmt(-margin)} {_fmt(total)} {_fmt(total)}" | |
| 105 | + | ||
| 106 | + | transparent_bg = bg.lower() in {"none", "transparent", ""} | |
| 107 | + | rect = "" | |
| 108 | + | if not transparent_bg: | |
| 109 | + | rx = _fmt(radius * total) | |
| 110 | + | rect = ( | |
| 111 | + | f'<rect x="{_fmt(-margin)}" y="{_fmt(-margin)}" ' | |
| 112 | + | f'width="{_fmt(total)}" height="{_fmt(total)}" ' | |
| 113 | + | f'rx="{rx}" ry="{rx}" fill="{bg}" stroke="none"/>' | |
| 114 | + | ) | |
| 115 | + | ||
| 116 | + | # Paint attributes differ per library. | |
| 117 | + | if source.render_mode == "stroke": | |
| 118 | + | if stroke_width is None: | |
| 119 | + | sw = re.search(r'stroke-width\s*=\s*"([^"]+)"', root_attrs) | |
| 120 | + | stroke_width = float(sw.group(1)) if sw else source.default_stroke_width | |
| 121 | + | paint = (f'fill="none" stroke="{fg}" stroke-width="{_fmt(stroke_width)}" ' | |
| 122 | + | f'stroke-linecap="round" stroke-linejoin="round"') | |
| 123 | + | else: # "fill" | |
| 124 | + | paint = f'fill="{fg}" stroke="none"' | |
| 125 | + | ||
| 126 | + | return ( | |
| 127 | + | f'<svg xmlns="http://www.w3.org/2000/svg" width="{res}" height="{res}" ' | |
| 128 | + | f'viewBox="{view_box}" {paint}>{rect}{inner}</svg>' | |
| 129 | + | ) | |
| 130 | + | ||
| 131 | + | ||
| 132 | + | # -------------------------------------------------------------------------- | |
| 133 | + | # Render | |
| 134 | + | # -------------------------------------------------------------------------- | |
| 135 | + | def render(svg: str, res: int, fmt: str, out_path: str, bg: str) -> None: | |
| 136 | + | if fmt == "svg": | |
| 137 | + | with open(out_path, "w", encoding="utf-8") as f: | |
| 138 | + | f.write(svg) | |
| 139 | + | return | |
| 140 | + | ||
| 141 | + | import cairosvg # imported lazily so `svg` output needs no cairo | |
| 142 | + | ||
| 143 | + | png_bytes = cairosvg.svg2png( | |
| 144 | + | bytestring=svg.encode("utf-8"), output_width=res, output_height=res | |
| 145 | + | ) | |
| 146 | + | ||
| 147 | + | if fmt == "png": | |
| 148 | + | with open(out_path, "wb") as f: | |
| 149 | + | f.write(png_bytes) | |
| 150 | + | return | |
| 151 | + | ||
| 152 | + | from PIL import Image | |
| 153 | + | ||
| 154 | + | img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") | |
| 155 | + | pil_fmt = PIL_FORMATS[fmt] | |
| 156 | + | ||
| 157 | + | if pil_fmt in NO_ALPHA: | |
| 158 | + | from PIL import ImageColor | |
| 159 | + | flat_bg = "white" if bg.lower() in {"none", "transparent", ""} else bg | |
| 160 | + | canvas = Image.new("RGB", img.size, ImageColor.getrgb(flat_bg)) | |
| 161 | + | canvas.paste(img, mask=img.split()[3]) | |
| 162 | + | canvas.save(out_path, pil_fmt) | |
| 163 | + | else: | |
| 164 | + | img.save(out_path, pil_fmt) | |
| 165 | + | ||
| 166 | + | ||
| 167 | + | # -------------------------------------------------------------------------- | |
| 168 | + | # CLI plumbing | |
| 169 | + | # -------------------------------------------------------------------------- | |
| 170 | + | def resolve_format(args_format: str | None, out_path: str | None) -> str: | |
| 171 | + | if args_format: | |
| 172 | + | fmt = args_format.lower().lstrip(".") | |
| 173 | + | elif out_path and "." in out_path.rsplit("/", 1)[-1]: | |
| 174 | + | fmt = out_path.rsplit(".", 1)[-1].lower() | |
| 175 | + | else: | |
| 176 | + | fmt = "png" | |
| 177 | + | if fmt not in ALL_FORMATS: | |
| 178 | + | raise SystemExit( | |
| 179 | + | f"error: unsupported format '{fmt}'. choose from: {', '.join(ALL_FORMATS)}" | |
| 180 | + | ) | |
| 181 | + | return fmt | |
| 182 | + | ||
| 183 | + | ||
| 184 | + | def _positive_int(v: str) -> int: | |
| 185 | + | n = int(v) | |
| 186 | + | if n <= 0: | |
| 187 | + | raise argparse.ArgumentTypeError("resolution must be a positive integer") | |
| 188 | + | return n | |
| 189 | + | ||
| 190 | + | ||
| 191 | + | def _unit_fraction(lo: float, hi: float): | |
| 192 | + | def _check(v: str) -> float: | |
| 193 | + | f = float(v) | |
| 194 | + | if not (lo <= f <= hi): | |
| 195 | + | raise argparse.ArgumentTypeError(f"value must be between {lo} and {hi}") | |
| 196 | + | return f | |
| 197 | + | return _check | |
| 198 | + | ||
| 199 | + | ||
| 200 | + | def build_parser(source: IconSource) -> argparse.ArgumentParser: | |
| 201 | + | p = argparse.ArgumentParser( | |
| 202 | + | prog=source.prog_name, | |
| 203 | + | description=source.description, | |
| 204 | + | epilog=f"Browse icon names at {source.browse_url}", | |
| 205 | + | ) | |
| 206 | + | p.add_argument("icon", help=source.icon_arg_help) | |
| 207 | + | p.add_argument("-o", "--output", help="output file path (default: <icon>.<format>)") | |
| 208 | + | p.add_argument("-r", "--resolution", type=_positive_int, default=512, | |
| 209 | + | help="square size in pixels (default: 512)") | |
| 210 | + | p.add_argument("--bg", default="white", | |
| 211 | + | help="background color: name, #hex, or 'transparent' (default: white)") | |
| 212 | + | p.add_argument("--fg", default="black", | |
| 213 | + | help="foreground/icon color: name or #hex (default: black)") | |
| 214 | + | p.add_argument("-f", "--format", choices=ALL_FORMATS, | |
| 215 | + | help="output format (default: png, or inferred from -o extension)") | |
| 216 | + | p.add_argument("--padding", type=_unit_fraction(0.0, 0.45), default=0.18, | |
| 217 | + | help="empty margin around the icon, 0-0.45 (default: 0.18)") | |
| 218 | + | p.add_argument("--radius", type=_unit_fraction(0.0, 0.5), default=0.0, | |
| 219 | + | help="corner rounding of background, 0=square .5=circle (default: 0)") | |
| 220 | + | # Only meaningful for stroke-based icons (Lucide). | |
| 221 | + | if source.render_mode == "stroke": | |
| 222 | + | p.add_argument("--stroke-width", type=float, default=None, | |
| 223 | + | help=f"override line thickness " | |
| 224 | + | f"(default is {source.default_stroke_width})") | |
| 225 | + | return p | |
| 226 | + | ||
| 227 | + | ||
| 228 | + | def run(source: IconSource, argv: list[str] | None = None) -> None: | |
| 229 | + | args = build_parser(source).parse_args(argv) | |
| 230 | + | stroke_width = getattr(args, "stroke_width", None) | |
| 231 | + | ||
| 232 | + | fmt = resolve_format(args.format, args.output) | |
| 233 | + | name = normalize_name(args.icon, source.slug_style) | |
| 234 | + | out_path = args.output or f"{name}.{fmt}" | |
| 235 | + | ||
| 236 | + | src = fetch_svg(source, name) | |
| 237 | + | svg = build_svg(source, src, args.resolution, args.bg, args.fg, | |
| 238 | + | args.padding, args.radius, stroke_width) | |
| 239 | + | render(svg, args.resolution, fmt, out_path, args.bg) | |
| 240 | + | ||
| 241 | + | print(f"saved {out_path} ({args.resolution}x{args.resolution} {fmt}, " | |
| 242 | + | f"{source.key}='{name}', bg={args.bg}, fg={args.fg})") | |