# Phase O: 컨테이너 기반 레이아웃 시스템

> 작성일: 2026-03-27
> 상태: ✅ 코드 구현 완료 + 후속 정리 완료 (Step B 제거, 죽은 코드 정리, 미해결 3건 해결)
> 선행 완료: Phase N (catalog 개선, fallback 제거, topic_id 버그 수정)

---

## 핵심 원칙

**"비중이 컨테이너를 확정하고, 컨테이너가 블록을 제약하고, 블록이 콘텐츠를 제약한다."**

```
Kei 비중 판단 (본심 60%, 배경 20%)
    ↓
컨테이너 px 확정 (본심 294px, 배경 98px)
    ↓
블록 선택 시 컨테이너 크기 제약 (98px → compact 블록만)
    ↓
블록 스펙 확정 (항목 수, 폰트, 패딩, 행 수)
    ↓
편집자가 확정 스펙에 맞게 텍스트 작성
    ↓
렌더링 (컨테이너 grid로 비중 강제 반영)
```

---

## 현재 문제 (Phase N 이후에도 남은 것)

### 문제 1: 비중이 시각에 반영 안 됨
- Kei가 본심 60%, 배경 20%로 판단했지만
- 실제 렌더링에서 배경이 73%(348px), 본심이 20%(97px)
- **원인:** 블록이 자연 높이대로 렌더링되고, 비중 기반 컨테이너가 없음

### 문제 2: 블록 선택 시 컨테이너 크기를 모름
- Kei가 블록을 고를 때 "이 블록이 컨테이너에 들어가는지" 판단 불가
- 98px 컨테이너에 height_cost=large 블록이 선택됨

### 문제 3: 블록이 컨테이너에 맞게 변형되지 않음
- 같은 `dark-bullet-list`여도 98px이면 불릿 2개, 294px이면 5개여야 하는데
- 현재는 블록이 고정 형태로 렌더링됨

### 문제 4: 텍스트 분량이 컨테이너와 무관
- sidebar 490px인데 용어 정의가 한 줄짜리
- body 98px인데 문제제기가 3단 구조

---

## 변경 대상 파일 및 역할

| 파일 | 현재 역할 | Phase O 변경 |
|------|----------|------------|
| `pipeline.py` | 5단계 오케스트레이션 | 컨테이너 계산을 Step A와 A-2 사이에 삽입 |
| `space_allocator.py` | _max_chars만 계산 | **컨테이너 스펙 생성기로 확장** (px, 블록 제약, 항목수, 폰트, 글자수) |
| `design_director.py` | Step A-2에서 블록 선택 | 컨테이너 px를 Kei에게 전달 + height_cost 제약 |
| `content_editor.py` | _max_chars로 분량 제한 | 블록 스펙(항목수, 글자수/항목)을 프롬프트에 전달 |
| `renderer.py` | flex-column으로 블록 나열 | **비중 기반 grid row로 컨테이너 생성** |
| `catalog.yaml` | when/not_for 설명 | 각 블록의 height_cost를 px 범위로 구체화 |

---

## 단계별 상세 설계

### O-1. 컨테이너 스펙 계산 (`space_allocator.py` 확장)

**현재:** `allocate_height_budget()` → `{topic_id: max_height_px}` 딕셔너리만 반환

**변경:** `calculate_container_specs()` → 각 컨테이너의 완전한 스펙을 반환

```python
def calculate_container_specs(
    page_structure: dict,     # Kei의 비중 판단: {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
    topics: list[dict],       # 각 topic의 purpose, role, layer
    preset: dict,             # 프리셋 zone 정보 (budget_px, width_pct)
) -> dict[str, ContainerSpec]:
    """Kei 비중 → 컨테이너 스펙 변환.

    Returns:
        역할별 ContainerSpec 딕셔너리. 예:
        {
            "본심": ContainerSpec(
                role="본심",
                zone="body",
                topic_ids=[3],
                weight=0.6,
                height_px=294,       # zone_budget × weight_ratio
                width_px=716,        # slide_width × zone_width_pct × 0.85 (패딩 제외)
                max_height_cost="xlarge",  # 294px이면 xlarge까지 가능
                block_constraints={
                    "max_items": 7,         # 높이 기반 계산
                    "font_size_px": 15.2,   # 기본값 유지 가능
                    "padding_px": 20,       # 기본값 유지 가능
                    "max_chars_total": 800, # 높이×너비 기반 총 글자수
                },
            ),
            "배경": ContainerSpec(
                role="배경",
                zone="body",
                topic_ids=[1, 2],
                weight=0.2,
                height_px=98,
                width_px=716,
                max_height_cost="compact",  # 98px이면 compact만
                block_constraints={
                    "max_items": 3,
                    "font_size_px": 13.0,   # 줄여야 함
                    "padding_px": 10,       # 줄여야 함
                    "max_chars_total": 200,
                },
            ),
            ...
        }
    """
```

**height_cost → px 매핑:**

현재 catalog.yaml의 height_cost는 문자열(`compact`, `medium`, `large`, `xlarge`)이다.
이것을 px 범위로 매핑해야 Kei가 블록을 고를 때 컨테이너에 맞는지 판단할 수 있다.

```python
HEIGHT_COST_PX_RANGE = {
    "compact": (30, 80),     # 30~80px
    "medium":  (80, 200),    # 80~200px
    "large":   (200, 350),   # 200~350px
    "xlarge":  (350, 500),   # 350~500px
}
```

**컨테이너 높이 → 허용 height_cost 결정:**
```python
def max_allowed_height_cost(container_height_px: int) -> str:
    """컨테이너 높이에서 허용되는 최대 height_cost를 결정."""
    if container_height_px >= 350:
        return "xlarge"
    elif container_height_px >= 200:
        return "large"
    elif container_height_px >= 80:
        return "medium"
    else:
        return "compact"
```

**블록 내부 제약 계산:**
```python
def calculate_block_constraints(
    height_px: int,
    width_px: int,
    topic_count: int,      # 이 컨테이너에 들어가는 topic 수
    font_size_px: float,
    line_height: float,
    padding_px: int,
) -> dict:
    """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
    # 각 topic에 할당되는 높이
    per_topic_height = (height_px - padding_px * 2) / topic_count

    # 줄 수
    line_height_px = font_size_px * line_height
    max_lines = int(per_topic_height / line_height_px)

    # 줄당 글자 수
    chars_per_line = int((width_px - padding_px * 2) / (font_size_px * 0.95))

    # 불릿/항목 수 (한 항목 = 약 2줄)
    max_items = max(1, max_lines // 2)

    # 총 글자 수
    max_chars_total = max_lines * chars_per_line

    return {
        "max_lines": max_lines,
        "max_items": max_items,
        "chars_per_line": chars_per_line,
        "max_chars_total": max_chars_total,
        "max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
    }
```

**폰트/패딩 조정 기준:**

| 컨테이너 높이 | 폰트 크기 | 패딩 | line-height |
|-------------|---------|------|-----------|
| ≥300px | 15.2px (기본) | 20px (기본) | 1.7 (기본) |
| 150~299px | 14px | 14px | 1.6 |
| 80~149px | 13px | 10px | 1.5 |
| <80px | 12px | 8px | 1.4 |

---

### O-2. 블록 선택에 컨테이너 제약 전달 (`design_director.py`)

**현재:** `_opus_block_recommendation()`이 Kei에게 블록 후보 + 꼭지 목록을 보냄. 컨테이너 크기 정보 없음.

**변경:** 컨테이너 스펙을 Kei에게 함께 전달.

```python
# _opus_block_recommendation 프롬프트에 추가할 내용

container_text = "\n".join(
    f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
    f"허용 height_cost: {spec.max_height_cost} 이하, "
    f"최대 항목 수: {spec.block_constraints['max_items']}"
    for role, spec in container_specs.items()
    for tid in spec.topic_ids
)

prompt += (
    f"\n\n## 컨테이너 제약 (반드시 준수)\n"
    f"각 꼭지의 블록은 아래 컨테이너 안에 들어가야 한다.\n"
    f"height_cost가 컨테이너보다 크면 선택 금지.\n\n"
    f"{container_text}\n"
)
```

**코드 레벨 검증 (Kei 응답 후):**
```python
# Kei가 선택한 블록의 height_cost가 컨테이너보다 큰지 검증
for rec in kei_recommendations:
    tid = rec.get("topic_id") or rec.get("id")
    block_type = rec.get("block_type", "")

    # catalog에서 height_cost 조회
    block_height_cost = catalog_map.get(block_type, {}).get("height_cost", "medium")

    # 컨테이너의 max_height_cost 조회
    container_spec = find_container_for_topic(tid, container_specs)
    allowed = container_spec.max_height_cost

    # 제약 위반 체크
    if HEIGHT_COST_ORDER[block_height_cost] > HEIGHT_COST_ORDER[allowed]:
        logger.warning(
            f"[O-2 검증] 꼭지 {tid}: {block_type}({block_height_cost})이 "
            f"컨테이너({container_spec.height_px}px, {allowed} 이하)에 안 맞음"
        )
        # 위반 시 → Kei에게 재선택 요청 (컨테이너 제약 명시)
```

---

### O-3. 블록 스펙 확정 단계 (신규)

**현재:** 없음. 블록이 선택되면 바로 편집자에게 전달.

**변경:** Step A-2 후, Step 3 전에 **블록 스펙 확정** 단계 삽입.

이 단계는 **코드(결정론적)** — AI 호출 없음.

```python
def finalize_block_specs(
    blocks: list[dict],           # Step A-2에서 확정된 블록 목록
    container_specs: dict,        # O-1에서 계산된 컨테이너 스펙
    catalog: dict,                # catalog.yaml 데이터
) -> list[dict]:
    """각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.

    확정 항목:
    - _container_height_px: 이 블록이 쓸 수 있는 높이
    - _container_width_px: 이 블록이 쓸 수 있는 너비
    - _max_items: 최대 항목/불릿/행 수
    - _max_chars_per_item: 항목당 최대 글자 수
    - _max_chars_total: 총 최대 글자 수
    - _font_size_px: 이 컨테이너에서의 폰트 크기
    - _padding_px: 이 컨테이너에서의 패딩
    - _line_height: 이 컨테이너에서의 줄간격
    """
    for block in blocks:
        tid = block.get("topic_id")
        spec = find_container_for_topic(tid, container_specs)
        if not spec:
            continue

        block_type = block.get("type", "")
        catalog_info = catalog.get(block_type, {})

        # 이 블록이 쓸 수 있는 높이 (같은 컨테이너 안의 다른 블록과 분배)
        siblings_in_container = [b for b in blocks if find_container_for_topic(b.get("topic_id"), container_specs) == spec]
        per_block_height = spec.height_px // len(siblings_in_container)

        # 폰트/패딩 결정 (컨테이너 크기 기반)
        font_size, padding, line_h = determine_typography(per_block_height)

        # 블록별 항목 수 계산
        constraints = calculate_block_constraints(
            per_block_height, spec.width_px,
            topic_count=1,  # 이 블록 1개
            font_size_px=font_size,
            line_height=line_h,
            padding_px=padding,
        )

        # 블록 타입별 세부 조정
        schema = catalog_info.get("schema", {})
        if block_type in ("dark-bullet-list",):
            # 불릿 블록: max_items = 불릿 수
            block["_max_items"] = min(constraints["max_items"], int(schema.get("max_bullets", {}).get("body", 5)))
            block["_max_chars_per_item"] = constraints["max_chars_per_item"]
        elif block_type in ("card-numbered", "card-icon-desc"):
            # 카드 블록: max_items = 카드 수
            block["_max_items"] = constraints["max_items"]
            block["_max_chars_per_item"] = constraints["max_chars_per_item"]
        elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
            # 표 블록: max_items = 행 수
            block["_max_items"] = constraints["max_items"]
            block["_max_chars_per_item"] = constraints["max_chars_per_item"]
        elif block_type in ("comparison-2col",):
            # 비교 블록: 좌우 각각의 글자 수
            block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
        elif block_type in ("banner-gradient",):
            # 배너: 한 줄
            block["_max_chars_total"] = constraints["chars_per_line"]
        else:
            block["_max_chars_total"] = constraints["max_chars_total"]

        # 공통
        block["_container_height_px"] = per_block_height
        block["_container_width_px"] = spec.width_px
        block["_font_size_px"] = font_size
        block["_padding_px"] = padding
        block["_line_height"] = line_h
        block["_max_chars_total"] = constraints["max_chars_total"]

    return blocks
```

**typography 결정 함수:**
```python
def determine_typography(height_px: int) -> tuple[float, int, float]:
    """컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
    if height_px >= 300:
        return (15.2, 20, 1.7)   # 기본
    elif height_px >= 150:
        return (14.0, 14, 1.6)   # 약간 축소
    elif height_px >= 80:
        return (13.0, 10, 1.5)   # 축소
    else:
        return (12.0, 8, 1.4)    # 최소
```

---

### O-4. 편집자 프롬프트에 블록 스펙 전달 (`content_editor.py`)

**현재:** `_max_chars`만 전달. 항목 수, 항목당 글자 수, 폰트 크기 정보 없음.

**변경:** O-3에서 확정된 모든 스펙을 편집자에게 전달.

```python
# fill_content()에서 각 블록의 스펙을 프롬프트에 구체적으로 명시

for i, block in enumerate(blocks):
    req_text = (
        f"블록 {i+1} ({block_type}, 영역: {block.get('area')}):\n"
        f"  목적(purpose): {block.get('purpose')}\n"
        f"  필수 슬롯: {slots.get('required', [])}\n"
    )

    # O-4: 블록 스펙 (컨테이너 기반)
    container_h = block.get("_container_height_px")
    if container_h:
        max_items = block.get("_max_items", "제한 없음")
        max_chars_item = block.get("_max_chars_per_item", "제한 없음")
        max_chars_total = block.get("_max_chars_total", "제한 없음")
        font_size = block.get("_font_size_px", 15.2)

        req_text += (
            f"\n  ★ 컨테이너 제약 (절대 준수):\n"
            f"    - 컨테이너 높이: {container_h}px\n"
            f"    - 최대 항목 수: {max_items}개\n"
            f"    - 항목당 최대 글자 수: {max_chars_item}자\n"
            f"    - 총 최대 글자 수: {max_chars_total}자\n"
            f"    - 폰트 크기: {font_size}px\n"
            f"    이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라.\n"
        )
```

**sidebar 용어 정의 예시:**
```
블록 5 (card-numbered, 영역: sidebar):
  목적(purpose): 용어정의
  ★ 컨테이너 제약:
    - 컨테이너 높이: 450px (sidebar 전체)
    - 최대 항목 수: 3개
    - 항목당 최대 글자 수: 120자 ← 출처까지 넣을 수 있는 여유
    - 폰트 크기: 13px
```

**body 배경(98px) 예시:**
```
블록 2 (dark-bullet-list, 영역: body):
  목적(purpose): 근거사례
  ★ 컨테이너 제약:
    - 컨테이너 높이: 49px (배경 98px / 2 topics)
    - 최대 항목 수: 2개
    - 항목당 최대 글자 수: 40자 ← 간결하게
    - 폰트 크기: 12px
```

---

### O-5. 렌더러에서 비중 기반 grid row 생성 (`renderer.py`)

**현재:** `_group_blocks_by_area()`가 같은 area 블록을 flex-column으로 나열. 높이 비율 없음.

**변경:** body zone 안에 역할(본심/배경/결론)별 grid row를 생성하고, 각 row의 높이를 비중 기반으로 확정.

```python
def _group_blocks_by_area_with_containers(
    blocks: list[dict[str, Any]],
    container_specs: dict | None = None,
) -> list[dict[str, Any]]:
    """같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.

    container_specs가 있으면:
    - body zone 안에서 역할별 컨테이너 div를 생성
    - 각 컨테이너의 height를 비중 기반 px로 고정
    - 블록은 해당 컨테이너 안에 배치

    container_specs가 없으면:
    - 기존 flex-column 방식 (호환)
    """
    grouped = OrderedDict()
    for block in blocks:
        area = block["area"]
        if area not in grouped:
            grouped[area] = {"area": area, "blocks": []}
        grouped[area]["blocks"].append(block)

    result = []
    for area, data in grouped.items():
        block_list = data["blocks"]

        if container_specs and area == "body":
            # 비중 기반 컨테이너 생성
            # container_specs에서 이 area의 역할별 높이를 가져옴
            container_htmls = []

            # 역할 순서: 배경 → 본심 → (결론은 footer)
            role_order = ["배경", "본심"]

            for role in role_order:
                spec = container_specs.get(role)
                if not spec or spec.zone != area:
                    continue

                # 이 역할에 해당하는 블록들
                role_blocks = [
                    b for b in block_list
                    if b.get("_topic_id_role") == role or b.get("topic_id") in spec.topic_ids
                ]

                if not role_blocks:
                    continue

                inner_html = "\n".join(b["html"] for b in role_blocks)

                # 컨테이너 div: 높이 고정 + overflow visible (측정용)
                font_size = spec.block_constraints.get("font_size_px", 15.2)
                padding = spec.block_constraints.get("padding_px", 20)

                container_htmls.append(
                    f'<div class="container-{role}" style="'
                    f'height:{spec.height_px}px; '
                    f'overflow:visible; '
                    f'font-size:{font_size}px; '
                    f'--spacing-inner:{padding}px; '
                    f'--font-body:{font_size / 16:.3f}rem;">\n'
                    f'{inner_html}\n</div>'
                )

            html = "\n".join(container_htmls)

        elif len(block_list) == 1:
            html = block_list[0]["html"]
        else:
            inner = "\n".join(b["html"] for b in block_list)
            html = (
                f'<div style="display:flex; flex-direction:column; '
                f'gap:var(--spacing-block); height:100%;">\n'
                f'{inner}\n</div>'
            )

        result.append({"area": area, "html": html})

    return result
```

**CSS 구조 (렌더링 결과):**
```html
<!-- body zone -->
<div class="area-body">
  <!-- 배경 컨테이너: 98px 고정 -->
  <div class="container-배경" style="height:98px; overflow:visible; font-size:13px;">
    <!-- topic 1: comparison-2col -->
    <!-- topic 2: dark-bullet-list -->
  </div>

  <!-- 본심 컨테이너: 294px 고정 -->
  <div class="container-본심" style="height:294px; overflow:visible; font-size:15.2px;">
    <!-- topic 3: compare-2col-split -->
  </div>
</div>

<!-- footer: 60px -->
<div class="area-footer" style="height:60px;">
  <!-- topic 5: banner-gradient -->
</div>

<!-- sidebar: 490px -->
<div class="area-sidebar">
  <!-- topic 4: card-numbered (여유로운 공간) -->
</div>
```

---

### O-6. 파이프라인 흐름 변경 (`pipeline.py`)

**현재 흐름:**
```
1A(Kei 꼭지) → 1B(컨셉) → A-2(블록선택) → B(zone배치) → 공간할당 → 3(편집) → 4(CSS+렌더) → 측정 → 5(검수)
```

**변경 후:**
```
1A(Kei 꼭지 + 비중)
    ↓
1B(Kei 컨셉)
    ↓
★ 컨테이너 스펙 계산 (O-1, 코드/결정론적)
    ↓
A-2(Kei 블록선택 — 컨테이너 제약 전달) (O-2)
    ↓
B(Sonnet zone + char_guide)
    ↓
★ 블록 스펙 확정 (O-3, 코드/결정론적)
    ↓
3(Kei 편집 — 블록 스펙 전달) (O-4)
    ↓
4(렌더링 — 컨테이너 grid) (O-5)
    ↓
측정(Selenium)
    ↓
5(Kei 검수)
```

**pipeline.py 변경 위치:**

```python
# 현재 코드 위치: pipeline.py 105행 부근 (2단계 시작 전)

# ★ O-1: 컨테이너 스펙 계산 (1B 완료 후, Step A-2 전)
yield {"event": "progress", "data": "1.8/5 컨테이너 스펙 계산 중..."}

from src.space_allocator import calculate_container_specs
container_specs = calculate_container_specs(
    page_structure=analysis.get("page_structure", {}),
    topics=analysis.get("topics", []),
    preset=preset,
)
_save_step(run_dir, "step1c_containers.json", {
    role: {
        "height_px": spec.height_px,
        "width_px": spec.width_px,
        "max_height_cost": spec.max_height_cost,
        "topic_ids": spec.topic_ids,
        "block_constraints": spec.block_constraints,
    }
    for role, spec in container_specs.items()
})

# 2단계: Step A-2에 container_specs 전달
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)

# ★ O-3: 블록 스펙 확정 (Step B 후, Step 3 전)
from src.space_allocator import finalize_block_specs
for page in layout_concept.get("pages", []):
    finalize_block_specs(page.get("blocks", []), container_specs, catalog)
_save_step(run_dir, "step2c_block_specs.json", {
    "blocks": [
        {
            "type": b.get("type"), "topic_id": b.get("topic_id"),
            "_container_height_px": b.get("_container_height_px"),
            "_max_items": b.get("_max_items"),
            "_max_chars_per_item": b.get("_max_chars_per_item"),
            "_max_chars_total": b.get("_max_chars_total"),
            "_font_size_px": b.get("_font_size_px"),
        }
        for p in layout_concept.get("pages", [])
        for b in p.get("blocks", [])
    ]
})

# 3단계: 편집자에게 블록 스펙이 전달됨 (O-4는 content_editor.py에서 자동 적용)
```

---

### O-7. 중간 산출물 추가 (리포트 반영)

**새로 추가되는 중간 산출물:**

| 파일 | 단계 | 내용 |
|------|------|------|
| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost, block_constraints) |
| `step2c_block_specs.json` | O-3 | 각 블록의 확정 스펙 (_container_height_px, _max_items, _font_size_px 등) |

`generate_run_report.py`에 이 2개 단계를 추가한다.

---

## 실행 순서

```
O-1: space_allocator.py 확장 (ContainerSpec + calculate_container_specs + calculate_block_constraints + determine_typography)
  ↓
O-2: design_director.py 변경 (컨테이너 제약을 Kei에게 전달 + 코드 레벨 height_cost 검증)
  ↓
O-3: space_allocator.py 추가 (finalize_block_specs)
  ↓
O-4: content_editor.py 변경 (블록 스펙을 편집자 프롬프트에 전달)
  ↓
O-5: renderer.py 변경 (비중 기반 grid row 컨테이너 생성)
  ↓
O-6: pipeline.py 변경 (새 단계 삽입 + 중간 산출물 저장)
  ↓
O-7: generate_run_report.py 확장 (새 중간 산출물 표시)
```

**의존 관계:**
- O-1이 먼저 (나머지 모두 O-1의 ContainerSpec에 의존)
- O-2, O-3은 O-1 완료 후
- O-4는 O-3 완료 후
- O-5는 O-1 완료 후 (O-3과 병렬 가능)
- O-6은 O-1~O-5 전부 완료 후
- O-7은 O-6 완료 후

---

## 검증 기준

이 Phase가 완료되면 아래가 반드시 성립해야 한다:

1. **비중 = 시각 비율**: Kei가 본심 60%로 판단하면, 실제 렌더링에서 body zone의 60%를 본심 블록이 차지한다
2. **컨테이너 밖으로 안 넘침**: 각 블록이 자기 컨테이너 높이 안에 들어간다 (overflow:visible이므로 넘치면 Selenium이 감지)
3. **블록 크기 적합**: 98px 컨테이너에 height_cost=large 블록이 선택되지 않는다
4. **텍스트 분량 적합**: 490px sidebar에서 용어 정의가 출처까지 포함하고, 98px 배경에서 문제제기가 간결하다
5. **중간 산출물 확인 가능**: report.html에서 컨테이너 스펙과 블록 스펙을 단계별로 확인할 수 있다

---

## 기술 조사 결과 반영

### 적용하는 것
- **fonttools** — `calculate_block_constraints()`에서 Pretendard 한글 실측 폭 사용. 하드코딩 `14.0px` 대체. 한글은 uniform-width이므로 정확.
- **CSS Grid 고정 행** — `grid-template-rows: 98px 294px` 형태로 컨테이너 높이 확정. W3C 표준, 모든 브라우저 지원.
- **`overflow: visible` + `scrollHeight`** — 컨테이너 높이 고정 + overflow visible → Selenium이 정확히 감지. CSSOM View 스펙 준수.

### 적용하지 않는 것
- **CSS Container Queries** — 38개 블록 템플릿 전부에 `@container` 규칙 추가 필요. Phase O의 핵심 목표(컨테이너 비중 반영)와 무관한 별도 작업. 필요 시 별도 Phase로.
- **Playwright** — Selenium으로 이미 작동 중. 성능 문제 체감 시 전환.
- **PPTAgent 방식 (절대 좌표)** — 우리는 콘텐츠마다 비중이 동적으로 변하므로 절대 좌표 방식 부적합.

### 조사에서 확인된 사실
- 기존 도구(Slidev, Marp, reveal.js, PPTAgent) 중 비중 기반 컨테이너 시스템을 쓰는 것은 없음. 우리가 직접 구현.
- PPTAgent의 `suggested_characters` 개념은 우리 `_max_chars`와 유사하지만, 원본 PPTX 고정값 vs 우리는 동적 계산.

---

## 기존 코드 충돌 해결 (6건)

Phase O 적용 시 기존 코드와 충돌하는 지점과 해결 방법.

### 충돌 1: `_max_height_px` vs `_container_height_px`
- **현재:** pipeline.py:188에서 `block["_max_height_px"]` 설정
- **해결:** pipeline.py 155~198행(Phase M 공간 할당) 전체를 O-1 `calculate_container_specs()`로 교체

### 충돌 2: `allocate_height_budget()` vs `calculate_container_specs()`
- **현재:** pipeline.py:179에서 `allocate_height_budget()` 호출
- **해결:** 호출부 교체. `allocate_height_budget()` 함수는 제거하지 않고 `calculate_container_specs()` 내부에서 재사용 가능.

### 충돌 3: `_max_chars` 단일값 vs `_max_items` + `_max_chars_per_item`
- **현재:** content_editor.py:121에서 `block.get("_max_chars")` 체크
- **해결:** N-3에서 추가한 `_max_chars` 프롬프트 코드를 O-4 블록 스펙으로 교체

### 충돌 4: Selenium 측정 스크립트가 container div 못 찾음
- **현재:** slide_measurer.py:36에서 `[class*="area-"]`만 검색
- **해결:** `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가. container div의 overflow도 감지.

### 충돌 5: Phase L 피드백 루프 필드명
- **현재:** pipeline.py:276에서 `block.get("_max_chars", 400)` 축소
- **해결:** `_max_chars_total` 또는 `_max_items` 축소로 변경

### 충돌 6: fonttools 의존성
- **현재:** pyproject.toml에 fonttools 없음, Pretendard .ttf 로컬 없음
- **해결:** `pip install fonttools` + Pretendard .ttf 다운로드 (CDN에서)

**원칙:** 모든 충돌은 "기존 코드를 Phase O 코드로 교체"하는 형태. 병존이 아닌 대체. 회귀 없음.

---

## 변경하지 않는 것

- catalog.yaml: Phase N에서 이미 개선 완료. 추가 수정 불필요.
- kei_client.py: 프롬프트 변경 없음. Kei는 이미 비중을 잘 판단하고 있다.
- slide_measurer.py: 측정 로직 기본 구조 변경 없음. container 셀렉터만 추가.
- Kei persona_agent: 수정 없음.
