Last active 8 hours ago

mga's Avatar 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})")
Newer Older