#!/usr/bin/env python3 """링크 공유 미리보기 이미지(og:image 1200x630 JPEG)와 파비콘 세트를 만든다. 카카오톡·슬랙·iMessage 에 주소를 붙였을 때 회색 링크 아이콘 대신 이 이미지와 아이콘이 나오게 한다. # AI Discovery 랜딩(webforai.kr). 결과: public/og-discovery.jpg, public/discovery-favicon-*.png 외 # public/ 은 infinith-demo 배포와 공용이라 파일명에 접두어를 붙이고, 링크 태그는 VITE_SITE=discovery 빌드에만 넣는다. python3 scripts/make_share_assets.py discovery --out public # 서포터즈 사이트. 워커 build 단계가 부른다. 결과: /public/og.jpg, favicon.ico 외 python3 scripts/make_share_assets.py supporters --site ~/supporters-builds/oracle/site 서포터즈 입력: src/data/site.json(shareNameEn·buildingImage·heroImage), src/data/factSheet.json(shortName), vercel.json build.env.SITE_URL(하단 주소 표기). 사진은 heroImage, 없으면 buildingImage. 사진이 없으면 사진 칸 없이 글자 판만 만든다. 서포터즈 이미지는 해외 환자용이라 영어로 낸다. 파비콘은 병원 로고 대신 영문 상호 첫 글자를 쓴다. 가로형 로고는 16px 에서 읽히지 않는다. """ import argparse import json import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageOps W, H = 1200, 630 INK = (10, 17, 40) # #0A1128 INDIGO = (29, 0, 36) # #1D0024 VIOLET = (79, 29, 161) # #4F1DA1 NAVY = (2, 19, 65) # #021341 LIGHT = (247, 248, 252) # #F7F8FC SLATE = (71, 85, 105) # #475569 LAVENDER = (233, 213, 255) # #E9D5FF, 어두운 배경 전용 FONT_DIRS = [Path.home() / "Library/Fonts", Path("/Library/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] def font(weight: str, size: int) -> ImageFont.FreeTypeFont: for d in FONT_DIRS: for name in (f"Pretendard-{weight}.otf", f"Pretendard-{weight}.ttf"): for p in [d / name, *d.rglob(name)] if d.exists() else []: if p.exists(): return ImageFont.truetype(str(p), size) for fallback in ("/System/Library/Fonts/AppleSDGothicNeo.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc"): if Path(fallback).exists(): return ImageFont.truetype(fallback, size) sys.exit("한글 글꼴을 찾지 못했습니다. Pretendard 를 설치하세요.") def gradient(size, start, end, diagonal=True): w, h = size base = Image.new("RGB", size, start) top = Image.new("RGB", size, end) mask = Image.new("L", size) md = mask.load() for y in range(h): for x in range(0, w, 1 if w <= 64 else 2): t = ((x / max(w - 1, 1)) + (y / max(h - 1, 1))) / 2 if diagonal else y / max(h - 1, 1) v = int(255 * t) md[x, y] = v if w > 64 and x + 1 < w: md[x + 1, y] = v return Image.composite(top, base, mask) def wrap(draw, text, fnt, max_w): """한국어 어절 단위 줄바꿈(keep-all). 줄바꿈 문자는 그대로 존중한다.""" lines = [] for para in text.split("\n"): cur = "" for word in para.split(" "): trial = f"{cur} {word}".strip() if draw.textlength(trial, font=fnt) <= max_w or not cur: cur = trial else: lines.append(cur) cur = word lines.append(cur) return lines def save_favicons(out: Path, label: str, prefix: str = ""): """상호 첫 글자(또는 AI) 모노그램. 둥근 정사각 그라디언트 위 흰 글자.""" out.mkdir(parents=True, exist_ok=True) def icon(px, radius_ratio=0.22, full_bleed=False): s = px * 4 bg = gradient((s, s), VIOLET, NAVY) mask = Image.new("L", (s, s), 0) ImageDraw.Draw(mask).rounded_rectangle([0, 0, s - 1, s - 1], radius=0 if full_bleed else int(s * radius_ratio), fill=255) img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) img.paste(bg, (0, 0), mask) d = ImageDraw.Draw(img) size = int(s * (0.62 if len(label) == 1 else 0.46)) f = font("Bold", size) box = d.textbbox((0, 0), label, font=f) tw, th = box[2] - box[0], box[3] - box[1] d.text(((s - tw) / 2 - box[0], (s - th) / 2 - box[1]), label, font=f, fill="white") return img.resize((px, px), Image.LANCZOS) written = [] for name, px, bleed in [("favicon-32.png", 32, False), ("favicon-192.png", 192, False), ("favicon-512.png", 512, False), ("apple-touch-icon.png", 180, True)]: icon(px, full_bleed=bleed).save(out / f"{prefix}{name}") written.append(f"{prefix}{name}") icon(256).save(out / f"{prefix}favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)]) written.append(f"{prefix}favicon.ico") return written def discovery(out: Path): img = Image.new("RGB", (W, H), LIGHT) d = ImageDraw.Draw(img) # 왼쪽 세로 띠 img.paste(gradient((16, H), VIOLET, NAVY, diagonal=False), (0, 0)) x = 96 d.text((x, 92), "INFINITH AI Discovery", font=font("SemiBold", 30), fill=VIOLET) title = font("ExtraBold", 84) d.text((x, 170), "AI가 추천하는 병원은", font=title, fill=INDIGO) d.text((x, 272), "따로 있습니다.", font=title, fill=VIOLET) sub = font("Medium", 34) d.text((x, 410), "고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다.", font=sub, fill=SLATE) d.text((x, 458), "답변에 인용될 근거를 실측해 진단하고, 8주 안에 구축합니다.", font=sub, fill=SLATE) d.line([(x, 540), (W - 96, 540)], fill=(226, 232, 240), width=2) d.text((x, 556), "webforai.kr", font=font("SemiBold", 28), fill=INDIGO) img.save(out / "og-discovery.jpg", quality=88, optimize=True, progressive=True) return ["og-discovery.jpg", *save_favicons(out, "AI", prefix="discovery-")] def playfair(size: int) -> ImageFont.FreeTypeFont: for d in FONT_DIRS: p = d / "PlayfairDisplay-Bold.ttf" if p.exists(): return ImageFont.truetype(str(p), size) return font("ExtraBold", size) def supporters(site: Path): """해외 환자가 받는 링크라 영어로 낸다. 병원 영문 표기는 site.json shareNameEn(병원 공식 영문 표기, briefs 에서 전달)만 쓴다. 값이 없으면 한국어 상호를 그대로 쓴다. 영문 이름을 지어내지 않는다.""" data = site / "src/data" s = json.loads((data / "site.json").read_text()) fact = json.loads((data / "factSheet.json").read_text()) short_ko = (fact.get("shortName") or "").strip() or (s.get("siteName") or "").replace(" 서포터즈", "").strip() name = (s.get("shareNameEn") or "").strip() or short_ko if not name: sys.exit("site.json shareNameEn 과 factSheet.json shortName 이 모두 비어 있습니다.") url = "" vj = site / "vercel.json" if vj.exists(): url = json.loads(vj.read_text()).get("build", {}).get("env", {}).get("SITE_URL", "") host = url.replace("https://", "").replace("http://", "").rstrip("/") photo = None for key in ("heroImage", "buildingImage"): src = (s.get(key) or {}).get("src") if isinstance(s.get(key), dict) else None if src and (site / "public" / src.lstrip("/")).exists(): photo = site / "public" / src.lstrip("/") break img = Image.new("RGB", (W, H), INK) panel_w = 700 if photo else W img.paste(gradient((panel_w, H), VIOLET, NAVY), (0, 0)) if photo: ph = ImageOps.fit(Image.open(photo).convert("RGB"), (W - panel_w, H), Image.LANCZOS, centering=(0.5, 0.4)) img.paste(ph, (panel_w, 0)) d = ImageDraw.Draw(img) x, max_w = 72, panel_w - 72 * 2 d.text((x, 78), "SUPPORTERS · FOR INTERNATIONAL PATIENTS", font=font("SemiBold", 24), fill=LAVENDER) name_f = playfair(72) while max(d.textlength(w, font=name_f) for w in name.split(" ")) > max_w and name_f.size > 44: name_f = playfair(name_f.size - 4) y = 150 for line in wrap(d, name, name_f, max_w): d.text((x, y), line, font=name_f, fill="white") y += int(name_f.size * 1.18) y += 30 title_f = font("SemiBold", 42) for line in wrap(d, "Answers to the questions\npatients ask first.", title_f, max_w): d.text((x, y), line, font=title_f, fill="white") y += int(title_f.size * 1.32) d.text((x, H - 96), "Recovery planner · Getting here · Clinic facts", font=font("Medium", 26), fill=LAVENDER) if host: d.text((x, H - 58), host, font=font("SemiBold", 24), fill="white") pub = site / "public" # JPEG 로 낸다. WhatsApp 등 일부 미리보기는 300KB 를 넘는 이미지를 버린다. img.save(pub / "og.jpg", quality=86, optimize=True, progressive=True) if (pub / "og.png").exists(): (pub / "og.png").unlink() # 템플릿 기본(Astro) 파비콘 제거. 로고 기반 favicon.svg 가 있어도 16px 에서 읽히지 않아 모노그램으로 통일한다. for old in ("favicon.svg",): if (pub / old).exists(): (pub / old).unlink() return ["og.jpg", *save_favicons(pub, name[0].upper())] def main(): ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="mode", required=True) a = sub.add_parser("discovery"); a.add_argument("--out", default="public") b = sub.add_parser("supporters"); b.add_argument("--site", required=True) args = ap.parse_args() if args.mode == "discovery": out = Path(args.out); out.mkdir(parents=True, exist_ok=True) files = discovery(out) else: files = supporters(Path(args.site).expanduser()) print("공유 이미지·파비콘: " + ", ".join(files)) if __name__ == "__main__": main()