Files
C.E.L_Slide_test2/docs/history/IMPROVEMENT-PHASE-O.md
kyeongmin c42e01f060 문서 정리: Phase 히스토리 md를 docs/history/로 이동 + 오래된 테스트/에셋 정리
- 루트의 IMPROVEMENT-PHASE-*.md, PHASE-*.md 등 45개 → docs/history/로 이동
- docs/block-tests/ 오래된 블록 테스트 HTML 삭제 (figma_to_html_agent로 대체)
- docs/figma-analysis/, docs/figma-assets/, docs/figma-screenshots/ 정리
- docs/test-*.html 등 초기 테스트 파일 정리
- 참고 페이지/ 스크린샷 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:56:23 +09:00

709 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 수정 없음.