# 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 그대로 반환 (기존과 동일)