feat(insta-lab): design_importer page mapping (자동 + _order.json override)
This commit is contained in:
101
insta-lab/app/design_importer.py
Normal file
101
insta-lab/app/design_importer.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""사용자 디자인 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}"
|
||||
)
|
||||
103
insta-lab/tests/test_design_importer.py
Normal file
103
insta-lab/tests/test_design_importer.py
Normal file
@@ -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/<theme>/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)
|
||||
Reference in New Issue
Block a user