"""경기 공유용 OG 카드 이미지 동적 생성 — 유저가 투표한 픽 스코어를 그려서 반환. 공유 URL 에 ?sa=&sb= (팀A/팀B 픽 스코어) 가 붙어오면, 그 점수로 카드를 그린다. share.py 가 og:image 를 여기로 가리키고, 카카오/트위터 등 크롤러가 이 PNG 를 가져간다. 스코어가 없으면 'VS' 카드. 한국은 항상 좌측(프론트/공유 규칙과 동일). 폰트: 컨테이너에 fonts-nanum 설치(Dockerfile). 없으면 기본 폰트로 폴백(한글 깨질 수 있음). """ from __future__ import annotations import io from fastapi import APIRouter, Depends from fastapi.responses import Response from PIL import Image, ImageDraw, ImageFont from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..models import Match from ..schedule_data import TEAMS router = APIRouter(prefix="/api/og", tags=["og"]) _KOR_NAME = {code: t["shortName"] for code, t in TEAMS.items()} # fonts-nanum 에 항상 포함되는 Bold 만 사용(ExtraBold 는 패키지에 없을 수 있어 폴백→깨짐). _FONT_BOLD = "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf" _FONT_REG = "/usr/share/fonts/truetype/nanum/NanumGothic.ttf" def _font_any(size: int) -> "ImageFont.FreeTypeFont": """Bold → Regular → 기본 순으로 시도(경로 누락 시에도 한글 유지).""" for path in (_FONT_BOLD, _FONT_REG): try: return ImageFont.truetype(path, size) except Exception: # noqa: BLE001 continue return ImageFont.load_default() W, H = 1200, 630 BG = (14, 17, 22) WHITE = (240, 244, 248) GREEN = (61, 224, 138) SUB = (150, 160, 172) def _label(code: str | None) -> str: if not code: return "" return _KOR_NAME.get(code, code) def _parse_codes(match_id: str) -> tuple[str | None, str | None]: parts = match_id.split("_") return (parts[1], parts[2]) if len(parts) >= 3 else (None, None) @router.get("/match/{match_id}.png") async def og_match_png( match_id: str, sa: int | None = None, sb: int | None = None, db: AsyncSession = Depends(get_db), ) -> Response: m = await db.get(Match, match_id) if m: a_code, b_code = m.team_a_code, m.team_b_code la, lb = m.team_a_short, m.team_b_short else: a_code, b_code = _parse_codes(match_id) la, lb = _label(a_code), _label(b_code) # 한국 항상 좌측 — 라벨과 스코어를 함께 뒤집는다. if b_code == "KOR" and a_code != "KOR": la, lb = lb, la sa, sb = sb, sa has_score = sa is not None and sb is not None score_txt = f"{sa} : {sb}" if has_score else "VS" img = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(img) f_team = _font_any(120) f_score = _font_any(134) def text_w(txt: str, font: ImageFont.FreeTypeFont) -> int: box = d.textbbox((0, 0), txt, font=font) return box[2] - box[0] # 중앙에 결과만: 한국 2 : 0 남아공 (스코어 그린) — 가로·세로 정중앙 gap = 50 wa, ws, wb = text_w(la, f_team), text_w(score_txt, f_score), text_w(lb, f_team) total = wa + gap + ws + gap + wb x = (W - total) / 2 score_h = d.textbbox((0, 0), score_txt, font=f_score)[3] team_h = d.textbbox((0, 0), la or "가", font=f_team)[3] y_score = (H - score_h) // 2 y_team = (H - team_h) // 2 d.text((x, y_team), la, font=f_team, fill=WHITE) x += wa + gap d.text((x, y_score), score_txt, font=f_score, fill=GREEN) x += ws + gap d.text((x, y_team), lb, font=f_team, fill=WHITE) buf = io.BytesIO() img.save(buf, format="PNG") return Response( content=buf.getvalue(), media_type="image/png", headers={"Cache-Control": "public, max-age=600"}, )