#!/usr/bin/env python3 """ icon2img — shared engine for turning a 24x24 icon/logo SVG into a square raster (or vector) image suitable for a profile picture / avatar. This module is used by the thin CLI wrappers `lucide2img.py` and `simpleicons2img.py`. Each wrapper only describes its icon *source* (URL, how to recolor, how to slugify names) via an `IconSource` and then calls `run(source)`. Two recolor modes are supported because the two libraries draw differently: - "stroke": Lucide icons are outline strokes -> we set the SVG `stroke`. - "fill": Simple Icons are solid shapes -> we set the SVG `fill`. """ from __future__ import annotations import argparse import io import re import urllib.error import urllib.request from dataclasses import dataclass # -------------------------------------------------------------------------- # Formats # -------------------------------------------------------------------------- # Rasterized via Pillow (name -> PIL format string). # "svg" (vector passthrough) and "png" (cairosvg native) are handled specially. PIL_FORMATS = { "jpg": "JPEG", "jpeg": "JPEG", "webp": "WEBP", "bmp": "BMP", "tiff": "TIFF", "tif": "TIFF", "gif": "GIF", "ico": "ICO", } NO_ALPHA = {"JPEG", "BMP"} # formats that can't keep transparency ALL_FORMATS = ["png", "svg"] + sorted(PIL_FORMATS) # -------------------------------------------------------------------------- # Source description # -------------------------------------------------------------------------- @dataclass(frozen=True) class IconSource: key: str # short id, e.g. "lucide" prog_name: str # CLI program name, e.g. "lucide2img" description: str # one-line CLI description url_template: str # str with a single {name} placeholder render_mode: str # "stroke" or "fill" browse_url: str # where to find valid icon names icon_arg_help: str # help text for the positional icon argument slug_style: str = "kebab" # "kebab" (spaces->'-') or "compact" (strip spaces) default_stroke_width: float = 2.0 not_found_hint: str = "check the name" def normalize_name(name: str, style: str) -> str: n = name.strip().lower() if style == "compact": # Simple Icons slugs remove spaces return re.sub(r"\s+", "", n) return re.sub(r"[\s_]+", "-", n) # Lucide uses kebab-case # -------------------------------------------------------------------------- # Fetch # -------------------------------------------------------------------------- def fetch_svg(source: IconSource, name: str) -> str: url = source.url_template.format(name=name) req = urllib.request.Request(url, headers={"User-Agent": "icon2img/1.0"}) try: with urllib.request.urlopen(req, timeout=20) as resp: return resp.read().decode("utf-8") except urllib.error.HTTPError as e: if e.code == 404: raise SystemExit( f"error: no {source.key} icon named '{name}'.\n" f" {source.not_found_hint}: {source.browse_url}" ) raise SystemExit(f"error: failed to fetch icon ({e.code} {e.reason})") except urllib.error.URLError as e: raise SystemExit(f"error: network problem fetching icon: {e.reason}") # -------------------------------------------------------------------------- # SVG rewriting # -------------------------------------------------------------------------- def _fmt(x: float) -> str: """Compact float formatting for SVG attributes.""" return f"{x:.4f}".rstrip("0").rstrip(".") def build_svg(source: IconSource, src: str, res: int, bg: str, fg: str, padding: float, radius: float, stroke_width: float | None) -> str: """Rewrite a raw 24x24 icon SVG into a padded, colored square canvas.""" m = re.search(r"]*)>(.*)", src, re.DOTALL | re.IGNORECASE) if not m: raise SystemExit("error: fetched file did not look like an SVG") root_attrs, inner = m.group(1), m.group(2) # Recolor: any explicit currentColor becomes the requested foreground. inner = inner.replace("currentColor", fg) # Padding is expressed in the icon's 24-unit space by growing the viewBox. total = 24.0 / (1.0 - 2.0 * padding) margin = (total - 24.0) / 2.0 view_box = f"{_fmt(-margin)} {_fmt(-margin)} {_fmt(total)} {_fmt(total)}" transparent_bg = bg.lower() in {"none", "transparent", ""} rect = "" if not transparent_bg: rx = _fmt(radius * total) rect = ( f'' ) # Paint attributes differ per library. if source.render_mode == "stroke": if stroke_width is None: sw = re.search(r'stroke-width\s*=\s*"([^"]+)"', root_attrs) stroke_width = float(sw.group(1)) if sw else source.default_stroke_width paint = (f'fill="none" stroke="{fg}" stroke-width="{_fmt(stroke_width)}" ' f'stroke-linecap="round" stroke-linejoin="round"') else: # "fill" paint = f'fill="{fg}" stroke="none"' return ( f'{rect}{inner}' ) # -------------------------------------------------------------------------- # Render # -------------------------------------------------------------------------- def render(svg: str, res: int, fmt: str, out_path: str, bg: str) -> None: if fmt == "svg": with open(out_path, "w", encoding="utf-8") as f: f.write(svg) return import cairosvg # imported lazily so `svg` output needs no cairo png_bytes = cairosvg.svg2png( bytestring=svg.encode("utf-8"), output_width=res, output_height=res ) if fmt == "png": with open(out_path, "wb") as f: f.write(png_bytes) return from PIL import Image img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") pil_fmt = PIL_FORMATS[fmt] if pil_fmt in NO_ALPHA: from PIL import ImageColor flat_bg = "white" if bg.lower() in {"none", "transparent", ""} else bg canvas = Image.new("RGB", img.size, ImageColor.getrgb(flat_bg)) canvas.paste(img, mask=img.split()[3]) canvas.save(out_path, pil_fmt) else: img.save(out_path, pil_fmt) # -------------------------------------------------------------------------- # CLI plumbing # -------------------------------------------------------------------------- def resolve_format(args_format: str | None, out_path: str | None) -> str: if args_format: fmt = args_format.lower().lstrip(".") elif out_path and "." in out_path.rsplit("/", 1)[-1]: fmt = out_path.rsplit(".", 1)[-1].lower() else: fmt = "png" if fmt not in ALL_FORMATS: raise SystemExit( f"error: unsupported format '{fmt}'. choose from: {', '.join(ALL_FORMATS)}" ) return fmt def _positive_int(v: str) -> int: n = int(v) if n <= 0: raise argparse.ArgumentTypeError("resolution must be a positive integer") return n def _unit_fraction(lo: float, hi: float): def _check(v: str) -> float: f = float(v) if not (lo <= f <= hi): raise argparse.ArgumentTypeError(f"value must be between {lo} and {hi}") return f return _check def build_parser(source: IconSource) -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog=source.prog_name, description=source.description, epilog=f"Browse icon names at {source.browse_url}", ) p.add_argument("icon", help=source.icon_arg_help) p.add_argument("-o", "--output", help="output file path (default: .)") p.add_argument("-r", "--resolution", type=_positive_int, default=512, help="square size in pixels (default: 512)") p.add_argument("--bg", default="white", help="background color: name, #hex, or 'transparent' (default: white)") p.add_argument("--fg", default="black", help="foreground/icon color: name or #hex (default: black)") p.add_argument("-f", "--format", choices=ALL_FORMATS, help="output format (default: png, or inferred from -o extension)") p.add_argument("--padding", type=_unit_fraction(0.0, 0.45), default=0.18, help="empty margin around the icon, 0-0.45 (default: 0.18)") p.add_argument("--radius", type=_unit_fraction(0.0, 0.5), default=0.0, help="corner rounding of background, 0=square .5=circle (default: 0)") # Only meaningful for stroke-based icons (Lucide). if source.render_mode == "stroke": p.add_argument("--stroke-width", type=float, default=None, help=f"override line thickness " f"(default is {source.default_stroke_width})") return p def run(source: IconSource, argv: list[str] | None = None) -> None: args = build_parser(source).parse_args(argv) stroke_width = getattr(args, "stroke_width", None) fmt = resolve_format(args.format, args.output) name = normalize_name(args.icon, source.slug_style) out_path = args.output or f"{name}.{fmt}" src = fetch_svg(source, name) svg = build_svg(source, src, args.resolution, args.bg, args.fg, args.padding, args.radius, stroke_width) render(svg, args.resolution, fmt, out_path, args.bg) print(f"saved {out_path} ({args.resolution}x{args.resolution} {fmt}, " f"{source.key}='{name}', bg={args.bg}, fg={args.fg})")