Lucide2img.py
· 1.3 KiB · Python
Raw
#!/usr/bin/env python3
"""
lucide2img — fetch an icon from lucide.dev and render it as a square avatar image.
Examples
--------
python lucide2img.py camera
python lucide2img.py github -r 1024 --bg "#0d1117" --fg white -f webp
python lucide2img.py heart --bg "#ffe4e6" --fg "#e11d48" --radius 0.5 -o me.png
python lucide2img.py rocket --bg transparent # transparent PNG
Icon names use lucide's kebab-case (e.g. "arrow-right"); "Arrow Right" and
"arrow_right" are normalized automatically. Browse names at
https://lucide.dev/icons. Note that brand logos (e.g. github) were split out
of Lucide's core set -- use simpleicons2img.py for those.
Requires: cairosvg + pillow (only for raster output; svg output needs neither).
"""
from icon2img import IconSource, run
LUCIDE = IconSource(
key="lucide",
prog_name="lucide2img",
description="Fetch a lucide.dev icon and render it as a square avatar image.",
url_template=(
"https://raw.githubusercontent.com/lucide-icons/lucide/main/icons/{name}.svg"
),
render_mode="stroke",
browse_url="https://lucide.dev/icons",
icon_arg_help="lucide icon name, e.g. 'camera' or 'arrow-right'",
slug_style="kebab",
default_stroke_width=2.0,
not_found_hint="check the spelling",
)
if __name__ == "__main__":
run(LUCIDE)
| 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) |
| 39 |
Simpleuicons2img.py
· 1.6 KiB · Python
Raw
#!/usr/bin/env python3
"""
simpleicons2img — fetch a brand logo from Simple Icons (simpleicons.org) and
render it as a square avatar image.
Examples
--------
python simpleicons2img.py python
python simpleicons2img.py github --bg "#0d1117" --fg white -r 1024 -f webp
python simpleicons2img.py spotify --fg "#1DB954" --radius 0.5 -o spotify.png
python simpleicons2img.py x --bg transparent # transparent PNG
Names use Simple Icons "slugs": lowercase, no spaces (e.g. "Adobe Photoshop"
-> "adobephotoshop", "GitHub" -> "github"). Inputs are lowercased and spaces
stripped automatically; for anything unusual look up the slug at
https://simpleicons.org.
Note on colors: Simple Icons logos are single-color shapes, so `--fg` sets the
whole logo color (default black). This tool fetches the uncolored source SVGs
directly from the Simple Icons project so recoloring is exact; the
cdn.simpleicons.org endpoint bakes a color in, which would override --fg.
Requires: cairosvg + pillow (only for raster output; svg output needs neither).
"""
from icon2img import IconSource, run
SIMPLE_ICONS = IconSource(
key="simpleicons",
prog_name="simpleicons2img",
description="Fetch a Simple Icons brand logo and render it as a square avatar.",
url_template=(
"https://raw.githubusercontent.com/simple-icons/simple-icons/master/"
"icons/{name}.svg"
),
render_mode="fill",
browse_url="https://simpleicons.org",
icon_arg_help="Simple Icons slug, e.g. 'python' or 'github'",
slug_style="compact",
not_found_hint="look up the exact slug",
)
if __name__ == "__main__":
run(SIMPLE_ICONS)
| 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) |
| 45 |
icon2img.py
· 9.6 KiB · Python
Raw
#!/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"<svg\b([^>]*)>(.*)</svg>", 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'<rect x="{_fmt(-margin)}" y="{_fmt(-margin)}" '
f'width="{_fmt(total)}" height="{_fmt(total)}" '
f'rx="{rx}" ry="{rx}" fill="{bg}" stroke="none"/>'
)
# 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'<svg xmlns="http://www.w3.org/2000/svg" width="{res}" height="{res}" '
f'viewBox="{view_box}" {paint}>{rect}{inner}</svg>'
)
# --------------------------------------------------------------------------
# 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: <icon>.<format>)")
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})")
| 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 |