#!/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''
)
# --------------------------------------------------------------------------
# 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})")