kintex/docs/deliverables/제안서/_gen/icon_raster.py
zio ba90b54d81 feat(proposal): G2B-standard 7-chapter proposal deck (97 slides, QA-approved)
- Restructure to KONEPS (조달청 협상계약 별지5호) standard TOC: I general / II strategy-methodology / III tech-function / IV performance-quality / V PM / VI support / VII misc
- 9 new slides: staffing M/M, quantitative eval, standard framework, PM methodology, handover/training, SM/SLA, incident/BCP, managerial security, subcontracting
- Scoring alignment updated to tech 90 (70 qual + 20 quant) / price 10
- Nanobanana win-theme dual placement (II applied-tech + III function)
- Appendix D screen gallery (19 Stitch drafts) with AI-draft disclosure
- QA PASS: RTM 142/142 (3 fixes: F-008/F-010/F-026), no secrets, honesty labels verified
- Preserve rebuild toolchain + source docs in deliverables/제안서/_gen (16 files)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:44:44 +09:00

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"))