# Phase D: 이미지 처리 — 실행 상세

> MDX 콘텐츠의 이미지 참조(`![alt](/assets/images/DX1.png)`)를 감지하고,
> 로컬 이미지 파일의 크기를 측정하여 배치 판단에 활용.
> 서버가 localhost에서 돌므로 로컬 파일 접근 가능.
> 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.

---

## 실행 순서

```
D-0 (이미지 경로 입력 UI + API) ← 선행 필수
  → D-1 (Pillow 유틸리티 + 이미지 추출)
    → D-2 + D-3 (비율 기반 배치 — 프롬프트에 정보 전달)
    → D-4 (텍스트 포함 도표 축소 방지)
  → D-5 (HTML에 이미지 삽입 — base64 또는 절대 경로)
```

---

## D-0: 이미지 경로 입력 UI + API 파라미터 (선행 작업)

### 현재 상태
- static/index.html:176~179 — `fetch('/api/generate', { body: JSON.stringify({ content }) })` → 텍스트만 전송
- src/main.py:35~36 — `SlideRequest` 모델에 `content: str`만
- src/pipeline.py:27~29 — `generate_slide(content, manual_layout)` → base_path 없음

### 작업

**1) static/index.html — generate() 함수 수정**
- 텍스트에서 `![...](...)` 패턴 감지 (정규식)
- 발견 시: "이미지가 포함된 콘텐츠입니다. 이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요" 팝업 (prompt)
- 미발견 시: base_path 없이 기존처럼 전송
- API 요청 body: `{ content, base_path }` (base_path는 선택)

```javascript
// 이미지 참조 감지
const imagePattern = /!\[.*?\]\((.*?)\)/g;
const hasImages = imagePattern.test(content);
let basePath = '';
if (hasImages) {
    basePath = prompt(
        '이미지가 포함된 콘텐츠입니다.\n' +
        '이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요.\n' +
        '예: D:\\ad-hoc\\kei\\content'
    ) || '';
}
// API 전송
body: JSON.stringify({ content, base_path: basePath })
```

**2) src/main.py — SlideRequest 모델 확장**
```python
class SlideRequest(BaseModel):
    content: str
    base_path: str = ""  # 이미지 기준 폴더 (선택)
```

**3) src/main.py — generate 엔드포인트에서 base_path 전달**
```python
async for event in generate_slide(req.content, base_path=req.base_path):
```

**4) src/pipeline.py — generate_slide() 시그니처 확장**
```python
async def generate_slide(
    content: str,
    manual_layout: dict[str, Any] | None = None,
    base_path: str = "",
) -> AsyncIterator[dict[str, str]]:
```

### 하드코딩 점검
- 없음. 사용자가 경로를 직접 입력. 코드에 경로 고정값 없음 ✅

### 충돌/회귀
- SlideRequest에 base_path 추가: 기본값 `""` → 기존 요청(base_path 없는)과 호환 ✅
- generate_slide() 시그니처에 base_path 추가: 기본값 `""` → 기존 호출과 호환 ✅
- index.html generate() 함수: 이미지 없으면 기존과 동일 동작 ✅

### 수정 파일
- `static/index.html` — generate() 함수
- `src/main.py` — SlideRequest + generate 엔드포인트
- `src/pipeline.py` — generate_slide() 시그니처

---

## D-1: Pillow 이미지 크기 읽기 유틸리티

### 현재 상태
- Pillow import/사용 전무. pyproject.toml에도 없음. src/utils/ 디렉토리 없음.

### 작업

**1) pyproject.toml에 Pillow 추가**
```toml
dependencies = [
    ...
    "Pillow>=10.0",
]
```

**2) src/image_utils.py 신규 제작**
```python
"""이미지 크기 측정 유틸리티."""
from pathlib import Path
from PIL import Image

def get_image_sizes(content: str, base_path: str) -> list[dict]:
    """콘텐츠에서 이미지 참조를 추출하고 로컬 파일 크기를 측정한다.

    Args:
        content: MDX/텍스트 콘텐츠
        base_path: 이미지 파일 기준 폴더 경로

    Returns:
        [{"path": "/assets/images/DX1.png", "width": 800, "height": 600,
          "ratio": 1.33, "orientation": "landscape"}]
    """
    import re
    if not base_path:
        return []

    base = Path(base_path)
    images = []

    for match in re.finditer(r'!\[.*?\]\((.*?)\)', content):
        rel_path = match.group(1).strip()
        # 상대 경로 해석
        abs_path = base / rel_path.lstrip('/')

        if abs_path.exists() and abs_path.suffix.lower() in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
            try:
                with Image.open(abs_path) as img:
                    w, h = img.size  # 헤더만 읽음
                ratio = w / h if h > 0 else 1.0
                orientation = "landscape" if ratio > 1.2 else ("portrait" if ratio < 0.8 else "square")
                images.append({
                    "path": rel_path,
                    "width": w,
                    "height": h,
                    "ratio": round(ratio, 2),
                    "orientation": orientation,
                })
            except Exception:
                images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "unknown"})
        else:
            images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "not_found"})

    return images
```

### 하드코딩 점검
- ratio 기준 1.2, 0.8: CLAUDE.md 원문 "가로형(ratio > 1.2)", "세로형(ratio < 0.8)" — 문서 기준값이므로 하드코딩 아님 ✅
- 이미지 확장자 목록: 웹 표준 이미지 포맷. 변할 일 없음 ✅

### 충돌/회귀
- 신규 파일 추가만. 기존 코드 변경 없음 ✅
- Pillow는 `Image.open()` 시 헤더만 읽음 (전체 디코딩 안 함) → 성능 안전 ✅

### 수정 파일
- `pyproject.toml` — Pillow 의존성 추가
- 신규 `src/image_utils.py`

---

## D-2 + D-3: 비율 기반 배치 판단

### 현재 상태
- 비율 기반 배치 판단 코드 없음
- B-4에서 1단계 images[] 필드 추가했지만, 실제 크기 정보는 없음

### 작업
pipeline.py에서 D-1 유틸리티 호출 → 이미지 크기/비율 정보를 2단계 Step B와 4단계 Sonnet에 전달

```python
# pipeline.py generate_slide() 내, 2단계 전에:
from src.image_utils import get_image_sizes

image_sizes = get_image_sizes(content, base_path)
if image_sizes:
    # analysis에 이미지 크기 정보 추가
    analysis["image_sizes"] = image_sizes
```

design_director.py `create_layout_concept()`에서 user_prompt에 이미지 정보 포함:
```python
# 이미지 크기 정보가 있으면 프롬프트에 포함
if analysis.get("image_sizes"):
    image_info = "\n".join(
        f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
        for img in analysis["image_sizes"]
    )
    user_prompt += f"\n\n## 이미지 크기 정보\n{image_info}\n"
```

→ Sonnet(팀장)이 이 정보를 보고 "가로형 → 전체 너비", "세로형 → 텍스트 옆" 등을 판단.

### 하드코딩 점검
- 비율 판단: AI(팀장)가 orientation 정보를 보고 결정 ✅
- 코드는 크기 정보를 전달만. 배치 결정은 AI ✅

### 충돌/회귀
- pipeline.py: 기존 흐름 사이에 이미지 측정 삽입. base_path 없으면 빈 리스트 → 영향 없음 ✅
- design_director.py: user_prompt에 텍스트 추가만. 이미지 없으면 추가 안 함 ✅
- analysis에 image_sizes 추가: 기존 코드는 `.get()` 패턴 → 안전 ✅

### 수정 파일
- `src/pipeline.py` — generate_slide()에서 get_image_sizes() 호출
- `src/design_director.py` — create_layout_concept()에서 이미지 정보 프롬프트 포함

---

## D-4: 텍스트 포함 도표 → 과도한 축소 방지

### 현재 상태
- B-4에서 images[].has_text 필드 추가 (1단계 Kei가 판단)
- 이 정보를 기반으로 "축소하지 마라" 가이드 없음

### 작업
D-2/D-3의 이미지 정보 전달 시, has_text 정보도 함께 포함:

```python
# pipeline.py에서 1단계 analysis의 images[]와 D-1의 image_sizes를 결합
for img_size in image_sizes:
    # 1단계에서 판단한 has_text 정보 매칭
    for kei_img in analysis.get("images", []):
        if kei_img.get("description", "") in img_size.get("path", ""):
            img_size["has_text"] = kei_img.get("has_text", False)
```

→ Sonnet 프롬프트에 "has_text: true인 이미지는 텍스트가 포함된 도표이므로 과도하게 축소하지 마라" 가이드

### 하드코딩 점검
- 축소 여부: AI가 판단 ✅
- has_text: 1단계 Kei가 판단 ✅

### 충돌/회귀
- 기존 데이터에 필드 추가만. 없으면 무시됨 ✅

### 수정 파일
- `src/pipeline.py`

---

## D-5: 슬라이드 HTML에 이미지 삽입

### 현재 상태
- 이미지 블록(image-row, image-side-text 등)은 `{{ img.src }}` 슬롯에 경로를 넣지만
- 다운로드 HTML에서 상대 경로는 깨짐 (로컬 파일이므로)

### 작업
렌더링 완료 후, HTML의 이미지 src를 base64 data URI로 변환:

```python
# renderer.py 또는 pipeline.py에서 최종 HTML 후처리
import base64, re

def embed_images(html: str, base_path: str) -> str:
    """HTML의 이미지 src를 base64 data URI로 변환."""
    if not base_path:
        return html

    base = Path(base_path)

    def replace_src(match):
        src = match.group(1)
        abs_path = base / src.lstrip('/')
        if abs_path.exists():
            mime = 'image/png' if abs_path.suffix == '.png' else 'image/jpeg'
            data = base64.b64encode(abs_path.read_bytes()).decode()
            return f'src="data:{mime};base64,{data}"'
        return match.group(0)

    return re.sub(r'src="(/[^"]+\.(?:png|jpg|jpeg|gif|webp))"', replace_src, html)
```

### 하드코딩 점검
- MIME 타입: 확장자 기반 표준 매핑. 하드코딩 아님 ✅
- 이미지 확장자: D-1과 동일 목록 ✅

### 충돌/회귀
- 최종 HTML 후처리. 이미지 없으면 정규식 매칭 없음 → 기존과 동일 ✅
- base_path 없으면 함수 즉시 반환 → 안전 ✅

### 수정 파일
- `src/image_utils.py` (embed_images 함수 추가) 또는 `src/pipeline.py`

---

## 수정 파일 총괄

| 파일 | 항목 | 변경 성격 |
|------|------|----------|
| `static/index.html` | D-0 | generate() 함수에 이미지 감지 + 경로 입력 팝업 |
| `src/main.py` | D-0 | SlideRequest에 base_path 추가 + 엔드포인트 전달 |
| `src/pipeline.py` | D-0, D-2~D-4 | generate_slide() 시그니처 + 이미지 크기 측정 + 프롬프트 전달 |
| `pyproject.toml` | D-1 | Pillow 의존성 추가 |
| 신규 `src/image_utils.py` | D-1, D-5 | get_image_sizes() + embed_images() |
| `src/design_director.py` | D-2, D-3 | user_prompt에 이미지 크기/비율 정보 포함 |

---

## 검증 체크리스트

- [ ] D-0: 이미지 없는 텍스트 → 팝업 안 뜸. 기존과 동일 동작
- [ ] D-0: 이미지 있는 MDX → 팝업 뜨고 경로 입력 → API에 base_path 전달
- [ ] D-0: 팝업에서 취소 → base_path="" → 이미지 처리 스킵 (에러 없음)
- [ ] D-1: Pillow로 이미지 크기 측정. 파일 없으면 width=0, orientation="not_found"
- [ ] D-2/D-3: Sonnet 프롬프트에 이미지 크기/orientation 정보 포함
- [ ] D-4: has_text=true 이미지에 대해 "축소 금지" 가이드 전달
- [ ] D-5: 다운로드 HTML에서 이미지가 base64로 삽입되어 보임
- [ ] 이미지 없는 기존 콘텐츠: 전체 파이프라인 기존과 동일하게 동작 (회귀 없음)

---

## 수정 이력

| 날짜 | 내용 |
|------|------|
| 2026-03-25 | 초안. D-0(선행 UI) 추가. D-5(HTML 이미지 삽입) 추가. 6개 항목. |
| 2026-03-25 | Phase D 전체 구현 완료. 검증 통과. |

## 구현 완료 확인

| 항목 | 검증 결과 |
|------|----------|
| D-0 | SlideRequest에 base_path="" 기본값 추가. generate_slide()에 base_path 파라미터 추가. index.html에 이미지 감지+팝업. 기존 요청(base_path 없는) 호환 ✅ |
| D-1 | pyproject.toml에 Pillow>=10.0 추가. src/image_utils.py 신규 (get_image_sizes + embed_images). Pillow 10.4.0 설치 확인. base_path 없으면 빈 리스트 반환(안전) |
| D-2+D-3 | pipeline.py에서 get_image_sizes() 호출 → analysis["image_sizes"] 저장. design_director.py user_prompt에 이미지 크기/orientation/배치 가이드 포함. 이미지 없으면 추가 안 함(기존 동일) |
| D-4 | design_director.py에서 has_text=true 이미지에 "(텍스트 포함 도표 — 과도한 축소 금지)" 가이드 자동 추가 |
| D-5 | pipeline.py에서 최종 HTML 반환 전 embed_images(html, base_path) 호출. base_path 없으면 원본 그대로 반환(안전) |

### D-0 구현 결과
- `src/main.py`: SlideRequest에 `base_path: str = ""` 추가. generate 엔드포인트에서 `base_path=req.base_path` 전달.
- `src/pipeline.py`: generate_slide() 시그니처에 `base_path: str = ""` 추가.
- `static/index.html`: generate() 함수에서 `![...](...)` 패턴 감지 → prompt()로 경로 입력 → API에 `{ content, base_path }` 전송. 취소 시 `base_path=""` → 이미지 처리 스킵.

### D-1 구현 결과
- `pyproject.toml`: `"Pillow>=10.0"` 의존성 추가.
- `src/image_utils.py` 신규:
  - `get_image_sizes(content, base_path)`: MDX에서 `![alt](path)` 추출 → base_path + 상대경로 해석 → Pillow `Image.open().size` → {path, width, height, ratio, orientation} 반환
  - `embed_images(html, base_path)`: HTML의 `src="/...png"` → `src="data:image/png;base64,..."` 변환
  - 파일 미발견/에러 시 orientation="not_found"/"error" → 에러 없이 계속 진행

### D-2+D-3+D-4 구현 결과
- `src/pipeline.py`: 1단계 완료 직후 `get_image_sizes()` 호출 → `analysis["image_sizes"]` 저장
- `src/design_director.py`: user_prompt에 이미지 정보 섹션 추가 — 각 이미지의 크기/orientation + "가로형 → 전체 너비", "세로형 → 텍스트 옆", "텍스트 포함 도표 → 축소 금지" 가이드
- has_text 정보는 1단계 Kei의 images[].has_text와 D-1 크기 정보가 결합됨

### D-5 구현 결과
- `src/pipeline.py`: yield result 직전에 `embed_images(html, base_path)` 호출
- base_path 없으면 원본 HTML 그대로 반환 (기존과 동일)
