diff --git a/CLAUDE.md b/CLAUDE.md index ae06300..0f9290b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,7 @@ docker compose up -d - `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드 - `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`) - `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`) +- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates//card.html.j2`가 없으면 자동으로 default 폴백 - `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝 **카테고리 시드 키워드** @@ -482,6 +483,17 @@ docker compose up -d - 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시 - `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송 +**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)** +- `insta-lab/app/templates//pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates//card.html.j2` 자동 생성 +- CLI: `docker exec insta-lab python -m app.design_importer ` +- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9 +- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용) +- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer) +- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`) +- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError +- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=` 추가 + `docker compose restart insta-lab` +- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준) + **insta-lab API 목록** | 메서드 | 경로 | 설명 | diff --git a/docker-compose.yml b/docker-compose.yml index ae035e1..2b94689 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,7 @@ services: - YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-} - INSTA_DATA_PATH=/app/data - CARD_TEMPLATE_DIR=/app/app/templates + - INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} volumes: - ${RUNTIME_PATH}/data/insta:/app/data diff --git a/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md b/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md new file mode 100644 index 0000000..78c4a3a --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md @@ -0,0 +1,1019 @@ +# insta-lab Design Importer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 사용자가 `insta-lab/app/templates//pages/`에 둔 카드 디자인 PNG 10장을 Claude Sonnet Vision으로 분석해 단일 `card.html.j2`를 자동 생성하고, 새 theme를 env로 활성화해 카드 렌더에 반영한다. + +**Architecture:** 신규 `design_importer.py` 모듈이 (a) 파일명 자동 매핑 (b) 이미지 검증 (c) Vision API 호출 (d) Jinja sanity render (e) HTML 저장 + 백업까지 처리. CLI 진입점 `python -m app.design_importer `. 카드 렌더는 `INSTA_DEFAULT_THEME` env로 어떤 theme의 HTML을 쓸지 결정하며, theme HTML이 없으면 default로 폴백. + +**Tech Stack:** Python 3.12, anthropic 0.52 (Claude Sonnet Vision), Jinja2, Pillow (이미지 dimension 검증), pytest. + +**Spec reference:** `docs/superpowers/specs/2026-05-17-insta-design-importer-design.md` + +--- + +## File Structure + +### Files to create + +| Path | Responsibility | +|------|----------------| +| `insta-lab/app/design_importer.py` | 페이지 매핑 + 이미지 검증 + Vision 호출 + Jinja 검증 + 저장. CLI `__main__` 포함 | +| `insta-lab/tests/test_design_importer.py` | 6 케이스 (매핑 / 검증 / Vision mock / Jinja 폴백) | + +### Files to modify + +| Path | Change | +|------|--------| +| `insta-lab/app/config.py` | `INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")` 추가 | +| `insta-lab/app/main.py` | `_bg_create_slate`에서 `render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")` | +| `insta-lab/app/card_renderer.py` | `render_slate` 시작부에 template 파일 존재 가드 (없으면 default 폴백) | +| `insta-lab/tests/test_card_renderer.py` | 폴백 가드 테스트 1개 추가 | +| `docker-compose.yml` | insta-lab env에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가 | +| `CLAUDE.md` | section 9 insta-lab에 design_importer + INSTA_DEFAULT_THEME + CLI 항목 추가 | + +--- + +## Conventions + +- 작업 디렉토리: `C:\Users\jaeoh\Desktop\workspace\web-backend`. 모든 commit은 feat 브랜치 `feat/insta-design-importer`에서. +- TDD 엄수: failing test → 실패 확인 → 구현 → 통과 확인 → commit. +- Windows-safe SQLite cleanup 불필요 (DB 미사용 모듈). +- Anthropic Vision은 모든 테스트에서 mock — 실제 API 호출 금지. +- Pillow 이미지 dimension 검증 시 실제 PNG fixture는 가벼운 1080×1350 단색 PNG로 임시 생성. + +--- + +## Task 0: Branch + scaffold + +**Files:** +- No code changes. + +- [ ] **Step 1: 현재 main 동기화 확인** + +```bash +cd /c/Users/jaeoh/Desktop/workspace/web-backend +git checkout main +git pull --ff-only origin main +git log --oneline -3 +``` +Expected: 최근 commit이 `077d411` 또는 그 이후 (spec commit `ecf1f64` 포함). + +- [ ] **Step 2: feat 브랜치 생성** + +```bash +git checkout -b feat/insta-design-importer +``` +Expected: `Switched to a new branch 'feat/insta-design-importer'` + +- [ ] **Step 3: insta-lab 기존 테스트 baseline** + +```bash +cd insta-lab && pytest -q 2>&1 | tail -3 +``` +Expected: 모두 PASS. 통과 못 하면 baseline부터 fix 후 plan 진행. + +--- + +## Task 1: `_resolve_page_mapping` (파일명 자동 매핑 + `_order.json` override) + +**Files:** +- Create: `insta-lab/app/design_importer.py` (이 task에서 페이지 매핑 부분만) +- Create: `insta-lab/tests/test_design_importer.py` (이 task에서 매핑 테스트 3건만) + +- [ ] **Step 1: failing test 작성** `insta-lab/tests/test_design_importer.py` + +```python +"""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") +``` + +- [ ] **Step 2: 테스트 실패 확인** + +```bash +cd insta-lab && pytest tests/test_design_importer.py -v +``` +Expected: ImportError (design_importer 모듈 없음). + +- [ ] **Step 3: 구현** — `insta-lab/app/design_importer.py` 생성 + +```python +"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성. + +CLI: python -m app.design_importer +""" + +import json +import logging +import re +from pathlib import Path +from typing import Dict, List + +logger = logging.getLogger(__name__) + +# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1 +_COVER_KEYWORDS = ("cover", "start", "intro") +# 페이지 10 (CTA) 키워드 우선순위 +_CTA_KEYWORDS = ("cta", "outro", "finish", "end") + + +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.json override + 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 중복), 자동 매핑으로 폴백: keys=%s values=%s", + sorted(mapping.keys()), sorted(mapping.values()), + ) + + # 자동 매핑 + remaining = list(pngs) + cover_pick = _pick_by_keywords(remaining, _COVER_KEYWORDS) + cta_pick = _pick_by_keywords(remaining, _CTA_KEYWORDS) if cover_pick != _CTA_KEYWORDS_FIRST_MATCH else None + # 위 한 줄은 명확하지 않으니 다음처럼 풀어쓰자 + return _build_mapping(pngs) + + +_CTA_KEYWORDS_FIRST_MATCH = object() # sentinel, 사용 안 함 + + +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) + + # 남은 파일을 알파벳 정렬 후 비어있는 페이지 (2~9 우선, 1·10이 비었으면 거기도) + 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 +``` + +> 위 `_pick_by_keywords` 호출이 한 줄에 잘못 들어간 sentinel은 무시하고 `_build_mapping`만 사용. (다음 step에서 정리.) + +- [ ] **Step 4: 잘못된 sentinel 라인 제거** — 위 구현에서 `cover_pick = ...` 라인부터 `_CTA_KEYWORDS_FIRST_MATCH = object()`까지 삭제하고 `_resolve_page_mapping`이 바로 `_build_mapping(pngs)`을 호출하도록 정리. 최종 형태: + +```python +def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]: + 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, keywords): + 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): + mapping = {} + 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 +``` + +- [ ] **Step 5: 테스트 통과 확인** + +```bash +cd insta-lab && pytest tests/test_design_importer.py -v +``` +Expected: 3 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py +git commit -m "feat(insta-lab): design_importer page mapping (자동 + _order.json override)" +``` + +--- + +## Task 2: `_validate_images` (이미지 dimension 검증) + +**Files:** +- Modify: `insta-lab/app/design_importer.py` +- Modify: `insta-lab/tests/test_design_importer.py` + +- [ ] **Step 1: failing test 추가** + +`tests/test_design_importer.py` 끝에 추가: + +```python +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) +``` + +- [ ] **Step 2: 실패 확인** + +```bash +cd insta-lab && pytest tests/test_design_importer.py::test_validate_images_accepts_1080x1350 -v +``` +Expected: AttributeError on `_validate_images`. + +- [ ] **Step 3: 구현 — `design_importer.py`에 함수 추가** + +```python +from PIL import Image # 파일 상단 import 섹션에 추가 + +_EXPECTED_SIZE = (1080, 1350) + + +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}" + ) +``` + +- [ ] **Step 4: 통과 확인** + +```bash +cd insta-lab && pytest tests/test_design_importer.py -v +``` +Expected: 5 PASS (Task 1의 3개 + 신규 2개). + +- [ ] **Step 5: Commit** + +```bash +git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py +git commit -m "feat(insta-lab): design_importer image dimension 검증 (1080x1350)" +``` + +--- + +## Task 3: `import_design_theme` (Vision 호출 + Jinja sanity + 저장) + +**Files:** +- Modify: `insta-lab/app/design_importer.py` +- Modify: `insta-lab/tests/test_design_importer.py` + +- [ ] **Step 1: failing test 추가** + +`tests/test_design_importer.py` 끝에 추가: + +```python +def test_import_design_theme_writes_html_via_mocked_vision(tmp_theme, monkeypatch): + """Vision mock이 정상 HTML 반환 시 card.html.j2 파일이 저장되고 결과 dict 반환.""" + pages = tmp_theme / "pages" + names = [ + "insta_card_start.png", + "insta_card_cta.png", + ] + [f"insta_card_body{i}.png" for i in range(8)] + for n in names: + _make_png(pages / n, (1080, 1350)) + + fake_html = """ +{% if page_no == 1 %}
{{ headline }}
{% endif %} +{% if page_no >= 2 and page_no <= 9 %}
{{ headline }}

{{ body }}

{% endif %} +{% if page_no == 10 %}
{{ headline }}

{{ cta }}

{% endif %} +""" + + def fake_vision_call(images_with_pages, theme_name): + return {"html": fake_html, "tokens": 12345, "summary": "test summary"} + + monkeypatch.setattr(design_importer, "_call_vision", fake_vision_call) + + result = design_importer.import_design_theme(str(tmp_theme)) + assert result["theme_name"] == "minimal" + assert "card.html.j2" in result["html_path"] + assert (tmp_theme / "card.html.j2").exists() + assert (tmp_theme / "card.html.j2").read_text(encoding="utf-8") == fake_html + assert "insta_card_start.png" in result["page_mapping"] + assert result["tokens_used"] == 12345 + + +def test_import_design_theme_raises_on_jinja_parse_failure(tmp_theme, monkeypatch): + """Vision이 깨진 Jinja 반환 시 ValueError + .error.txt 보존.""" + pages = tmp_theme / "pages" + for i in range(10): + _make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350)) + + broken_html = "
{% if page_no == 1 unclosed" + + monkeypatch.setattr(design_importer, "_call_vision", + lambda imgs, name: {"html": broken_html, "tokens": 100, "summary": ""}) + + with pytest.raises(ValueError, match="Jinja"): + design_importer.import_design_theme(str(tmp_theme)) + assert (tmp_theme / "card.html.j2.error.txt").exists() + + +def test_import_design_theme_backs_up_existing_html(tmp_theme, monkeypatch): + """기존 card.html.j2가 있으면 .bak.YYYYMMDD-HHMMSS로 백업 후 새로 작성.""" + pages = tmp_theme / "pages" + for i in range(10): + _make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350)) + (tmp_theme / "card.html.j2").write_text("OLD HTML", encoding="utf-8") + + monkeypatch.setattr(design_importer, "_call_vision", + lambda imgs, name: {"html": "
{{ headline }}
", "tokens": 50, "summary": ""}) + + design_importer.import_design_theme(str(tmp_theme)) + # .bak.* 파일이 생성되었어야 함 + backups = list(tmp_theme.glob("card.html.j2.bak.*")) + assert len(backups) == 1 + assert backups[0].read_text(encoding="utf-8") == "OLD HTML" + # 새 파일은 새 내용 + assert "headline" in (tmp_theme / "card.html.j2").read_text(encoding="utf-8") +``` + +- [ ] **Step 2: 실패 확인** + +```bash +cd insta-lab && pytest tests/test_design_importer.py -v +``` +Expected: 새 3개 test가 ImportError/AttributeError로 실패. + +- [ ] **Step 3: 구현 — `design_importer.py`에 함수 추가** + +파일 상단 import 추가: +```python +import base64 +import datetime +from typing import Any, Dict, List, Tuple + +from anthropic import Anthropic +from jinja2 import Environment, BaseLoader, TemplateSyntaxError + +from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET +``` + +함수 추가 (파일 끝에): +```python +_VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다. + +입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트가 박혀있음) + 파일명 → 페이지 번호 매핑. +출력: 단일 Jinja2 HTML 파일 본문 (코드펜스·설명 텍스트 금지). + +핵심 제약 — placeholder 텍스트 마스킹: +PNG에는 디자인 placeholder 텍스트가 이미 그려져 있다. 동적 카피로 교체할 때 +원본 텍스트가 비치면 안 된다. 각 텍스트 영역마다 두 layer를 그려라: + (a) 마스킹 박스: position: absolute로 placeholder 영역과 같은 좌표. + background는 그 영역 주변 픽셀 색 (카드 배경색)에서 추출. padding 8px 여유. + (b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표. + font-size·font-weight·color는 원본 placeholder의 스타일을 모방. + {{ headline }} / {{ body }} / {{ cta }} Jinja 변수 사용. + +페이지 종류별 영역 가이드: +- page 1 (cover): 메인 headline 1개 영역 +- page 2~9 (body): headline 영역 + body 영역 +- page 10 (cta): headline + body + cta 영역 + +요구사항: +- 컨테이너 width 1080px, height 1350px +- 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드 +- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조 +- 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본 +- 텍스트 영역은 word-wrap: break-word + overflow: hidden (동적 카피가 길어도 마스킹 박스 밖으로 안 새도록) +- HTML 에