231 lines
7.3 KiB
Python
231 lines
7.3 KiB
Python
#!/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())
|