diff --git a/insta-lab/app/design_importer.py b/insta-lab/app/design_importer.py new file mode 100644 index 0000000..0ef8b9f --- /dev/null +++ b/insta-lab/app/design_importer.py @@ -0,0 +1,101 @@ +"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성. + +CLI (이 phase 이후 추가): python -m app.design_importer +""" + +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//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}" + ) diff --git a/insta-lab/tests/test_design_importer.py b/insta-lab/tests/test_design_importer.py new file mode 100644 index 0000000..81a719a --- /dev/null +++ b/insta-lab/tests/test_design_importer.py @@ -0,0 +1,103 @@ +"""design_importer 회귀 테스트.""" +import json +import os +import tempfile +from pathlib import Path + +import pytest + +from app import design_importer + + +@pytest.fixture +def tmp_theme(tmp_path): + """templates//pages/ 구조를 가진 임시 디렉토리.""" + pages = tmp_path / "minimal" / "pages" + pages.mkdir(parents=True) + return tmp_path / "minimal" + + +def _touch(pages_dir: Path, names: list[str]): + for n in names: + (pages_dir / n).write_bytes(b"") # 매핑 테스트는 dimension 검증 안 함 + + +def test_auto_page_mapping_with_cover_and_cta(tmp_theme): + """cover 키워드 → 1, cta 키워드 → 10, 나머지는 알파벳 순 2~9.""" + _touch(tmp_theme / "pages", [ + "insta_card_start.png", # start → page 1 (cover priority) + "insta_card_keyword.png", + "insta_card_highlight.png", + "insta_card_observation.png", + "insta_card_memo.png", + "insta_card_oneline.png", + "insta_card_checklist.png", + "insta_card_study.png", + "insta_card_cta.png", # cta → page 10 + "insta_card_finish.png", # finish은 cta가 이미 채워 본문 풀로 + ]) + mapping = design_importer._resolve_page_mapping(tmp_theme / "pages") + assert mapping["insta_card_start.png"] == 1 + assert mapping["insta_card_cta.png"] == 10 + # 본문 풀 (남은 8장)은 알파벳 정렬: checklist, finish, highlight, keyword, memo, observation, oneline, study + body_pages = {p: n for n, p in mapping.items() if 2 <= p <= 9} + assert body_pages[2] == "insta_card_checklist.png" + assert body_pages[3] == "insta_card_finish.png" + assert body_pages[9] == "insta_card_study.png" + assert set(mapping.values()) == set(range(1, 11)) + + +def test_explicit_order_json_overrides_auto_mapping(tmp_theme): + """_order.json이 있으면 자동 매핑보다 우선.""" + pages = tmp_theme / "pages" + _touch(pages, [ + "insta_card_start.png", + "insta_card_cta.png", + "insta_card_finish.png", + ] + [f"insta_card_body{i}.png" for i in range(1, 8)]) + (pages / "_order.json").write_text(json.dumps({ + "insta_card_start.png": 1, + "insta_card_finish.png": 10, # cta 대신 finish를 page 10으로 + "insta_card_cta.png": 5, # cta를 본문 한가운데로 강제 + "insta_card_body1.png": 2, + "insta_card_body2.png": 3, + "insta_card_body3.png": 4, + "insta_card_body4.png": 6, + "insta_card_body5.png": 7, + "insta_card_body6.png": 8, + "insta_card_body7.png": 9, + }), encoding="utf-8") + mapping = design_importer._resolve_page_mapping(pages) + assert mapping["insta_card_finish.png"] == 10 + assert mapping["insta_card_cta.png"] == 5 + assert mapping["insta_card_start.png"] == 1 + + +def test_validates_exactly_ten_pngs(tmp_theme): + """PNG가 정확히 10장이 아니면 ValueError.""" + _touch(tmp_theme / "pages", [f"x{i}.png" for i in range(5)]) # 5장 + with pytest.raises(ValueError, match="10"): + design_importer._resolve_page_mapping(tmp_theme / "pages") + + +def _make_png(path: Path, size: tuple[int, int]) -> None: + """size 픽셀의 단색 PNG를 생성.""" + from PIL import Image + Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG") + + +def test_validate_images_accepts_1080x1350(tmp_theme): + pages = tmp_theme / "pages" + for i in range(10): + _make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350)) + # 예외 없이 통과해야 함 + design_importer._validate_images(pages) + + +def test_validate_images_rejects_wrong_dimensions(tmp_theme): + pages = tmp_theme / "pages" + for i in range(10): + size = (800, 800) if i == 5 else (1080, 1350) + _make_png(pages / f"insta_card_{i:02d}.png", size) + with pytest.raises(ValueError, match="1080x1350"): + design_importer._validate_images(pages)