Last active 7 hours ago

Lucide2img.py Raw
1#!/usr/bin/env python3
2"""
3lucide2img — fetch an icon from lucide.dev and render it as a square avatar image.
4
5Examples
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
12Icon names use lucide's kebab-case (e.g. "arrow-right"); "Arrow Right" and
13"arrow_right" are normalized automatically. Browse names at
14https://lucide.dev/icons. Note that brand logos (e.g. github) were split out
15of Lucide's core set -- use simpleicons2img.py for those.
16
17Requires: cairosvg + pillow (only for raster output; svg output needs neither).
18"""
19
20from icon2img import IconSource, run
21
22LUCIDE = 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
37if __name__ == "__main__":
38 run(LUCIDE)
39
Simpleuicons2img.py Raw
1#!/usr/bin/env python3
2"""
3simpleicons2img — fetch a brand logo from Simple Icons (simpleicons.org) and
4render it as a square avatar image.
5
6Examples
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
13Names use Simple Icons "slugs": lowercase, no spaces (e.g. "Adobe Photoshop"
14-> "adobephotoshop", "GitHub" -> "github"). Inputs are lowercased and spaces
15stripped automatically; for anything unusual look up the slug at
16https://simpleicons.org.
17
18Note on colors: Simple Icons logos are single-color shapes, so `--fg` sets the
19whole logo color (default black). This tool fetches the uncolored source SVGs
20directly from the Simple Icons project so recoloring is exact; the
21cdn.simpleicons.org endpoint bakes a color in, which would override --fg.
22
23Requires: cairosvg + pillow (only for raster output; svg output needs neither).
24"""
25
26from icon2img import IconSource, run
27
28SIMPLE_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
43if __name__ == "__main__":
44 run(SIMPLE_ICONS)
45
icon2img.py Raw
1#!/usr/bin/env python3
2"""
3icon2img — shared engine for turning a 24x24 icon/logo SVG into a square
4raster (or vector) image suitable for a profile picture / avatar.
5
6This 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
9calls `run(source)`.
10
11Two 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
16from __future__ import annotations
17
18import argparse
19import io
20import re
21import urllib.error
22import urllib.request
23from 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.
30PIL_FORMATS = {
31 "jpg": "JPEG", "jpeg": "JPEG", "webp": "WEBP", "bmp": "BMP",
32 "tiff": "TIFF", "tif": "TIFF", "gif": "GIF", "ico": "ICO",
33}
34NO_ALPHA = {"JPEG", "BMP"} # formats that can't keep transparency
35ALL_FORMATS = ["png", "svg"] + sorted(PIL_FORMATS)
36
37
38# --------------------------------------------------------------------------
39# Source description
40# --------------------------------------------------------------------------
41@dataclass(frozen=True)
42class 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
55def 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# --------------------------------------------------------------------------
65def 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# --------------------------------------------------------------------------
85def _fmt(x: float) -> str:
86 """Compact float formatting for SVG attributes."""
87 return f"{x:.4f}".rstrip("0").rstrip(".")
88
89
90def 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# --------------------------------------------------------------------------
135def 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# --------------------------------------------------------------------------
170def 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
184def _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
191def _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
200def 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
228def 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