# -*- coding: utf-8 -*- """ 경량 SVG(stroke) -> PNG 래스터라이저 (cairo 불필요, PIL만 사용) - assets/icons/*.svg (viewBox 0 0 24 24, fill none, stroke currentColor, stroke-width 1.75, round cap/join) - 지원: M/m L/l H/h V/v C/c S/s A/a Z/z, , - 렌더: 고배율 supersample 후 LANCZOS 다운스케일, 라운드 캡/조인 = 두꺼운 라인 + 정점 원 - 색: stroke=맥락색(RGB). fill 없음(선 아이콘). 재사용: from icon_raster import render_icon; render_icon('ai-sparkle', (r,g,b), out_png, px=192) """ import os, re, math from PIL import Image, ImageDraw ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "icons") _num = re.compile(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?') def _nums(s): return [float(x) for x in _num.findall(s)] # ---- cubic / arc flattening ---- def _cubic(p0, p1, p2, p3, n=18): pts = [] for i in range(1, n+1): t = i/n; mt = 1-t x = mt**3*p0[0] + 3*mt*mt*t*p1[0] + 3*mt*t*t*p2[0] + t**3*p3[0] y = mt**3*p0[1] + 3*mt*mt*t*p1[1] + 3*mt*t*t*p2[1] + t**3*p3[1] pts.append((x, y)) return pts def _arc(p0, rx, ry, phi, large, sweep, p1, n=24): # endpoint -> center parameterization (SVG spec) if rx == 0 or ry == 0: return [p1] phi = math.radians(phi) cosp, sinp = math.cos(phi), math.sin(phi) dx = (p0[0]-p1[0])/2.0; dy = (p0[1]-p1[1])/2.0 x1p = cosp*dx + sinp*dy; y1p = -sinp*dx + cosp*dy rx, ry = abs(rx), abs(ry) lam = x1p*x1p/(rx*rx) + y1p*y1p/(ry*ry) if lam > 1: s = math.sqrt(lam); rx *= s; ry *= s num = rx*rx*ry*ry - rx*rx*y1p*y1p - ry*ry*x1p*x1p den = rx*rx*y1p*y1p + ry*ry*x1p*x1p co = math.sqrt(max(0.0, num/den)) if den else 0.0 if large == sweep: co = -co cxp = co*rx*y1p/ry; cyp = -co*ry*x1p/rx cx = cosp*cxp - sinp*cyp + (p0[0]+p1[0])/2.0 cy = sinp*cxp + cosp*cyp + (p0[1]+p1[1])/2.0 def ang(ux, uy, vx, vy): d = math.sqrt((ux*ux+uy*uy)*(vx*vx+vy*vy)) c = max(-1.0, min(1.0, (ux*vx+uy*vy)/d)) if d else 1.0 a = math.acos(c) if ux*vy - uy*vx < 0: a = -a return a th1 = ang(1, 0, (x1p-cxp)/rx, (y1p-cyp)/ry) dth = ang((x1p-cxp)/rx, (y1p-cyp)/ry, (-x1p-cxp)/rx, (-y1p-cyp)/ry) if not sweep and dth > 0: dth -= 2*math.pi if sweep and dth < 0: dth += 2*math.pi pts = [] for i in range(1, n+1): th = th1 + dth*i/n x = cosp*rx*math.cos(th) - sinp*ry*math.sin(th) + cx y = sinp*rx*math.cos(th) + cosp*ry*math.sin(th) + cy pts.append((x, y)) return pts # flag-aware arc arg regex: rx ry rot large sweep(single 0/1) x y (repeatable) _NN = r'[-+]?(?:\d*\.\d+|\d+\.?)' _ARC = re.compile( r'(' + _NN + r')[,\s]*(' + _NN + r')[,\s]*(' + _NN + r')[,\s]*' r'([01])[,\s]*([01])[,\s]*(' + _NN + r')[,\s]*(' + _NN + r')') def _parse_path(d): """반환: list of subpaths, each subpath = list of (x,y) polyline points.""" chunks = re.findall(r'([MmLlHhVvCcSsAaZz])([^MmLlHhVvCcSsAaZz]*)', d) subs = []; cur = [] x = y = 0.0; sx = sy = 0.0 prev_ctrl = None; prev_cmd = None for cmd, arg in chunks: rel = cmd.islower(); c = cmd.upper() if c == 'A': for m in _ARC.finditer(arg): rx, ry, rot, large, sweep, nx, ny = (float(m.group(k)) for k in range(1, 8)) if rel: nx += x; ny += y cur += _arc((x, y), rx, ry, rot, int(large), int(sweep), (nx, ny)) x, y = nx, ny prev_cmd = c; prev_ctrl = None; continue vals = _nums(arg) if c == 'Z': cur.append((sx, sy)); x, y = sx, sy subs.append(cur); cur = [] prev_cmd = c; prev_ctrl = None; continue j = 0 first_pair = True while j < len(vals) or (c in ('M', 'L', 'C', 'S', 'H', 'V') and False): if c == 'M': nx, ny = vals[j], vals[j+1]; j += 2 if rel: nx += x; ny += y if first_pair: if cur: subs.append(cur) cur = [(nx, ny)]; sx, sy = nx, ny else: cur.append((nx, ny)) x, y = nx, ny; first_pair = False elif c == 'L': nx, ny = vals[j], vals[j+1]; j += 2 if rel: nx += x; ny += y cur.append((nx, ny)); x, y = nx, ny elif c == 'H': nx = vals[j]; j += 1 if rel: nx += x cur.append((nx, y)); x = nx elif c == 'V': ny = vals[j]; j += 1 if rel: ny += y cur.append((x, ny)); y = ny elif c == 'C': x1, y1, x2, y2, nx, ny = vals[j:j+6]; j += 6 if rel: x1+=x; y1+=y; x2+=x; y2+=y; nx+=x; ny+=y cur += _cubic((x, y), (x1, y1), (x2, y2), (nx, ny)) prev_ctrl = (x2, y2); x, y = nx, ny elif c == 'S': x2, y2, nx, ny = vals[j:j+4]; j += 4 if rel: x2+=x; y2+=y; nx+=x; ny+=y if prev_cmd in ('C', 'S') and prev_ctrl: x1 = 2*x - prev_ctrl[0]; y1 = 2*y - prev_ctrl[1] else: x1, y1 = x, y cur += _cubic((x, y), (x1, y1), (x2, y2), (nx, ny)) prev_ctrl = (x2, y2); x, y = nx, ny else: break if j >= len(vals): break prev_cmd = c if c not in ('C', 'S'): prev_ctrl = None if cur: subs.append(cur) return subs def _circle_pts(cx, cy, r, n=48): return [(cx + r*math.cos(2*math.pi*i/n), cy + r*math.sin(2*math.pi*i/n)) for i in range(n+1)] def _rect_pts(x, y, w, h, rx=0, transform=None, n=6): rx = min(rx, w/2, h/2) if rx else 0 pts = [] if rx <= 0: pts = [(x, y), (x+w, y), (x+w, y+h), (x, y+h), (x, y)] else: def arc(cx, cy, a0, a1): return [(cx+rx*math.cos(math.radians(a)), cy+rx*math.sin(math.radians(a))) for a in [a0 + (a1-a0)*k/n for k in range(n+1)]] pts += [(x+rx, y)] pts += [(x+w-rx, y)] pts += arc(x+w-rx, y+rx, -90, 0) pts += [(x+w, y+h-rx)] pts += arc(x+w-rx, y+h-rx, 0, 90) pts += [(x+rx, y+h)] pts += arc(x+rx, y+h-rx, 90, 180) pts += [(x, y+rx)] pts += arc(x+rx, y+rx, 180, 270) if transform: m = re.match(r'rotate\(\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*\)', transform) if m: ang = math.radians(float(m.group(1))); ox = float(m.group(2)); oy = float(m.group(3)) ca, sa = math.cos(ang), math.sin(ang) pts = [((px-ox)*ca-(py-oy)*sa+ox, (px-ox)*sa+(py-oy)*ca+oy) for px, py in pts] return pts def _extract(svg_text): """SVG -> list of polylines (in 24-unit viewBox coords).""" polys = [] for m in re.finditer(r']*\bd="([^"]+)"', svg_text): polys += _parse_path(m.group(1)) for m in re.finditer(r']*>', svg_text): a = m.group(0) cx = re.search(r'cx="([-\d.]+)"', a); cy = re.search(r'cy="([-\d.]+)"', a); r = re.search(r'r="([-\d.]+)"', a) if cx and cy and r: polys.append(_circle_pts(float(cx.group(1)), float(cy.group(1)), float(r.group(1)))) for m in re.finditer(r']*>', svg_text): a = m.group(0) def g(k, d=0.0): mm = re.search(k+r'="([-\d.]+)"', a); return float(mm.group(1)) if mm else d tr = re.search(r'transform="([^"]+)"', a) polys.append(_rect_pts(g('x'), g('y'), g('width'), g('height'), g('rx', 0), tr.group(1) if tr else None)) return polys _cache = {} def render_icon(name, rgb, out_png, px=192, stroke_w=1.75, pad=1.0): """name: 파일명(확장자 무관). rgb: (r,g,b) 0-255. out_png 저장. 이미 있으면 재사용.""" key = (name, tuple(rgb), px) if key in _cache and os.path.exists(out_png): return out_png path = os.path.join(ICON_DIR, name if name.endswith('.svg') else name+'.svg') with open(path, 'r', encoding='utf-8') as f: svg = f.read() polys = _extract(svg) SS = 4 # supersample W = px*SS scale = W/24.0 img = Image.new('RGBA', (W, W), (0, 0, 0, 0)) dr = ImageDraw.Draw(img) col = (int(rgb[0]), int(rgb[1]), int(rgb[2]), 255) lw = max(1, int(round(stroke_w*scale))) rcap = lw/2.0 for poly in polys: if len(poly) < 2: continue sp = [(px_*scale, py_*scale) for px_, py_ in poly] dr.line(sp, fill=col, width=lw, joint='curve') # round caps/joins via dots at each vertex for (vx, vy) in sp: dr.ellipse([vx-rcap, vy-rcap, vx+rcap, vy+rcap], fill=col) img = img.resize((px, px), Image.LANCZOS) img.save(out_png) _cache[key] = out_png return out_png if __name__ == "__main__": # 테스트: 20종 x 1색 몽타주 outdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_iconpng") os.makedirs(outdir, exist_ok=True) names = sorted(f[:-4] for f in os.listdir(ICON_DIR) if f.endswith('.svg')) cols = 5 cell = 96 mont = Image.new('RGBA', (cols*cell, ((len(names)+cols-1)//cols)*cell), (255, 255, 255, 255)) from PIL import ImageDraw as _ID md = _ID.Draw(mont) for i, nm in enumerate(names): outp = os.path.join(outdir, nm+".png") render_icon(nm, (0x00, 0x66, 0xB3), outp, px=72) ic = Image.open(outp) cx = (i % cols)*cell + 12; cy = (i//cols)*cell + 6 mont.alpha_composite(ic, (cx, cy)) md.text((cx, cy+74), nm[:14], fill=(60, 60, 60)) mont.convert('RGB').save(os.path.join(outdir, "_montage.png")) print("OK", len(names), "icons ->", os.path.join(outdir, "_montage.png"))