| 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})")
|
| 243 |
|