240 lines
9.7 KiB
Python
240 lines
9.7 KiB
Python
# -*- 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)
|
|
- 지원: <path d> M/m L/l H/h V/v C/c S/s A/a Z/z, <circle>, <rect rx transform=rotate>
|
|
- 렌더: 고배율 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'<path[^>]*\bd="([^"]+)"', svg_text):
|
|
polys += _parse_path(m.group(1))
|
|
for m in re.finditer(r'<circle[^>]*>', 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'<rect[^>]*>', 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"))
|