#!/usr/bin/env python3 """ 공식 길통이+차로(ai-mascot.png)로 Electron 앱 아이콘 생성 - build/icon.png (1024) - build/icon.icns (macOS) - build/icon.ico (Windows) """ from __future__ import annotations import plistlib import shutil import struct import subprocess import sys import tempfile import zlib from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BUILD = ROOT / "build" SRC_CANDIDATES = [ ROOT.parent / "chatbotApp-chatbot2.0-agent" / "web" / "public" / "images" / "ai-mascot.png", ROOT.parent / "chatbotApp-chatbot2.0-agent" / "public-ai" / "images" / "ai-mascot.png", ] # EX 브랜드 블루 BG = (36, 107, 235, 255) WHITE = (255, 255, 255, 255) def _venv_python(venv: Path) -> Path: if sys.platform == "win32": return venv / "Scripts" / "python.exe" py = venv / "bin" / "python3" return py if py.exists() else venv / "bin" / "python" def _in_venv(venv: Path) -> bool: """Homebrew venv의 bin/python은 시스템 바이너리 symlink라 executable.resolve() 비교는 불가.""" try: return Path(sys.prefix).resolve() == venv.resolve() except OSError: return False def _ensure_pip(py: Path) -> None: try: subprocess.check_call([str(py), "-m", "pip", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: print("[build-icons] ensurepip으로 pip 부트스트랩…", flush=True) subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"]) def _pip_install_pillow(py: Path) -> None: _ensure_pip(py) cmd = [str(py), "-m", "pip", "install", "Pillow", "-q"] try: subprocess.check_call(cmd) except subprocess.CalledProcessError: print("[build-icons] pip 재시도 (ensurepip 후)…", flush=True) subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"]) subprocess.check_call(cmd) def require_pillow(): try: from PIL import Image, ImageDraw # noqa: F401 return except ImportError: pass venv = ROOT / ".venv-icons" py = _venv_python(venv) if not py.exists(): print("[build-icons] venv 생성 중…", flush=True) subprocess.check_call([sys.executable, "-m", "venv", str(venv)]) py = _venv_python(venv) print("[build-icons] Pillow 설치 중…", flush=True) _pip_install_pillow(py) # 현재 프로세스에 Pillow가 없으면 venv로 재실행 (sys.prefix로 판별) if not _in_venv(venv): raise SystemExit(subprocess.call([str(py), str(Path(__file__).resolve()), *sys.argv[1:]])) from PIL import Image, ImageDraw # noqa: F401 def rounded_rect_mask(size: int, radius: int): from PIL import Image, ImageDraw mask = Image.new("L", (size, size), 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255) return mask def make_icon_png(src: Path, out: Path, size: int = 1024) -> None: from PIL import Image, ImageDraw, ImageFilter mascot = Image.open(src).convert("RGBA") # 흰 배경 제거에 가깝게: 거의 흰 픽셀을 투명 처리 pixels = mascot.load() w, h = mascot.size for y in range(h): for x in range(w): r, g, b, a = pixels[x, y] if r > 245 and g > 245 and b > 245: pixels[x, y] = (r, g, b, 0) canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) # macOS 아이콘용 라운드 사각 (약 22% radius) radius = int(size * 0.223) draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=BG) # 안쪽 밝은 원형 배지 badge = Image.new("RGBA", (size, size), (0, 0, 0, 0)) bd = ImageDraw.Draw(badge) pad = int(size * 0.08) bd.ellipse((pad, pad, size - pad, size - pad), fill=(255, 255, 255, 235)) canvas = Image.alpha_composite(canvas, badge) # 마스코트 배치 (여백 확보) max_side = int(size * 0.78) mw, mh = mascot.size scale = min(max_side / mw, max_side / mh) nw, nh = int(mw * scale), int(mh * scale) mascot = mascot.resize((nw, nh), Image.Resampling.LANCZOS) ox = (size - nw) // 2 oy = (size - nh) // 2 + int(size * 0.02) canvas.alpha_composite(mascot, (ox, oy)) # 마스크로 모서리 정리 mask = rounded_rect_mask(size, radius) out_img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) out_img.paste(canvas, (0, 0), mask=mask) out.parent.mkdir(parents=True, exist_ok=True) out_img.save(out, "PNG") print(f"[build-icons] ✅ {out} ({size}x{size})") def build_icns(png_1024: Path, icns_path: Path) -> None: sizes = [ (16, "icon_16x16.png"), (32, "diana.k@example.org"), (32, "icon_32x32.png"), (64, "ivan.p@example.net"), (128, "icon_128x128.png"), (256, "wendy.h@example.net"), (256, "icon_256x256.png"), (512, "wendy.h@example.net"), (512, "icon_512x512.png"), (1024, "walt.e@example.net"), ] iconset = BUILD / "icon.iconset" if iconset.exists(): shutil.rmtree(iconset) iconset.mkdir(parents=True) from PIL import Image base = Image.open(png_1024).convert("RGBA") for px, name in sizes: img = base.resize((px, px), Image.Resampling.LANCZOS) img.save(iconset / name, "PNG") if icns_path.exists(): icns_path.unlink() subprocess.check_call(["iconutil", "-c", "icns", str(iconset), "-o", str(icns_path)]) shutil.rmtree(iconset) print(f"[build-icons] ✅ {icns_path}") def _png_chunk(tag: bytes, data: bytes) -> bytes: return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) def rgba_to_png_bytes(img) -> bytes: """Pillow 이미지 → PNG bytes (ICO용).""" import io buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() def build_ico(png_1024: Path, ico_path: Path) -> None: """여러 해상도 PNG를 담은 .ico 생성 (electron-builder는 256 필수).""" from PIL import Image sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] base = Image.open(png_1024).convert("RGBA") # Pillow: 원본에서 sizes= 로 리사이즈한 멀티 엔트리 ICO 생성 # (append_images + 작은 첫 프레임은 16x16만 남는 버그 회피) base.save(ico_path, format="ICO", sizes=sizes) print(f"[build-icons] ✅ {ico_path}") def main() -> int: require_pillow() src = next((p for p in SRC_CANDIDATES if p.exists()), None) if not src: print("[build-icons] ai-mascot.png 없음", file=sys.stderr) return 1 BUILD.mkdir(parents=True, exist_ok=True) png = BUILD / "icon.png" make_icon_png(src, png, 1024) # electron-builder 호환 별칭 shutil.copy2(png, BUILD / "icon-1024.png") if sys.platform == "darwin": try: build_icns(png, BUILD / "icon.icns") except Exception as e: print(f"[build-icons] icns 실패 (png만 사용): {e}", file=sys.stderr) else: print("[build-icons] icns는 macOS에서만 생성합니다") try: build_ico(png, BUILD / "icon.ico") except Exception as e: print(f"[build-icons] ico 실패: {e}", file=sys.stderr) return 1 print("[build-icons] 완료") return 0 if __name__ == "__main__": raise SystemExit(main())