102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||
|
||
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Dict, List
|
||
|
||
from PIL import Image
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||
_COVER_KEYWORDS = ("cover", "start", "intro")
|
||
# 페이지 10 (CTA) 키워드 우선순위
|
||
_CTA_KEYWORDS = ("cta", "outro", "finish", "end")
|
||
|
||
_EXPECTED_SIZE = (1080, 1350)
|
||
|
||
|
||
def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]:
|
||
"""templates/<theme>/pages/ 안의 PNG 10장을 page 1~10에 매핑.
|
||
|
||
우선순위:
|
||
1. `_order.json` 있으면 그 매핑 그대로 사용 (검증 통과 시 반환)
|
||
2. 자동 매핑:
|
||
- _COVER_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 1
|
||
- _CTA_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 10
|
||
- 남은 8장은 알파벳 정렬 → page 2~9
|
||
"""
|
||
pages_dir = Path(pages_dir)
|
||
pngs = sorted([p.name for p in pages_dir.glob("*.png")])
|
||
if len(pngs) != 10:
|
||
raise ValueError(
|
||
f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}"
|
||
)
|
||
|
||
order_path = pages_dir / "_order.json"
|
||
if order_path.exists():
|
||
try:
|
||
mapping = json.loads(order_path.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e)
|
||
else:
|
||
if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)):
|
||
return {k: int(v) for k, v in mapping.items()}
|
||
logger.warning(
|
||
"_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백"
|
||
)
|
||
|
||
return _build_mapping(pngs)
|
||
|
||
|
||
def _pick_by_keywords(names: List[str], keywords: tuple) -> str | None:
|
||
"""names 중 keywords의 우선순위에 따라 첫 매치 파일명 반환 (없으면 None)."""
|
||
lower_names = [(n, n.lower()) for n in names]
|
||
for kw in keywords:
|
||
for orig, low in lower_names:
|
||
if kw in low:
|
||
return orig
|
||
return None
|
||
|
||
|
||
def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||
"""자동 매핑 알고리즘 본체."""
|
||
mapping: Dict[str, int] = {}
|
||
remaining = list(pngs)
|
||
|
||
cover = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||
if cover:
|
||
mapping[cover] = 1
|
||
remaining.remove(cover)
|
||
|
||
cta = _pick_by_keywords(remaining, _CTA_KEYWORDS)
|
||
if cta:
|
||
mapping[cta] = 10
|
||
remaining.remove(cta)
|
||
|
||
remaining_sorted = sorted(remaining)
|
||
free_pages = sorted(set(range(1, 11)) - set(mapping.values()))
|
||
for name, page in zip(remaining_sorted, free_pages):
|
||
mapping[name] = page
|
||
|
||
return mapping
|
||
|
||
|
||
def _validate_images(pages_dir: Path) -> None:
|
||
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError."""
|
||
pages_dir = Path(pages_dir)
|
||
bad = []
|
||
for png_path in sorted(pages_dir.glob("*.png")):
|
||
with Image.open(png_path) as img:
|
||
if img.size != _EXPECTED_SIZE:
|
||
bad.append((png_path.name, img.size))
|
||
if bad:
|
||
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||
raise ValueError(
|
||
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
|
||
)
|