"""파이프라인 각 Stage 실행 후 자동으로 HTML 시각화를 생성.
save_snapshot()에서 호출됨. 각 stage의 실제 context 데이터로 HTML 생성.
JSON context 파일과 동일한 stage 이름을 사용.
생성되는 파일 (JSON context 파일과 1:1 매칭):
stage_0.html — MDX 정규화 결과 (섹션, 팝업, 이미지)
stage_1a.html — Kei 꼭지 + 영역 배정 (테이블)
stage_1b.html — 컨셉 구체화 (source_data, summary 추가)
stage_1_5a.html — 빈 컨테이너 (1280x720)
stage_1_5a_content.html — 컨테이너에 실제 콘텐츠 배치
stage_1_5b.html — 디자인 예산 (영역별 available_height/width)
stage_1_7.html — 블록 선택 표시
stage_1_8_fit_before.html — 적합성 검증 (재배분 전)
stage_1_8_fit_after.html — 재배분 후 + 보강 결과
stage_1_8_blocks.html — 재배분 후 컨테이너에 블록 배치
stage_2.html — HTML 생성 결과 (영역별 생성된 HTML)
stage_3.html — 렌더링 조립 → final.html 링크
stage_4.html — 품질 게이트 (측정값, 점수)
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from src.pipeline_context import PipelineContext
logger = logging.getLogger(__name__)
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
"""stage_name에 따라 적절한 시각화 HTML 생성. 파일명은 JSON context와 1:1 매칭."""
try:
if stage_name == "stage_0":
_gen_stage_0(ctx, steps_dir)
elif stage_name == "stage_1a":
_gen_stage_1a(ctx, steps_dir)
elif stage_name == "stage_1b":
_gen_stage_1b(ctx, steps_dir)
elif stage_name == "stage_1_5a":
_gen_stage_1_5a(ctx, steps_dir)
_gen_stage_1_5a_content(ctx, steps_dir)
elif stage_name == "stage_1_5b":
_gen_stage_1_5b(ctx, steps_dir)
elif stage_name == "stage_1_7":
_gen_stage_1_7(ctx, steps_dir)
elif stage_name == "stage_1_8":
# before와 filled는 pipeline.py Stage 1.8 내부에서 before context로 이미 생성됨
# step_visualizer는 after context로 호출되므로 덮어쓰면 안 됨
# blocks와 fit_after만 생성 (after 상태 반영)
_gen_stage_1_8_blocks(ctx, steps_dir)
_gen_stage_1_8_fit_after(ctx, steps_dir)
elif stage_name == "stage_2":
_gen_stage_2(ctx, steps_dir)
elif stage_name == "stage_3":
_gen_stage_3(ctx, steps_dir)
elif stage_name == "stage_4":
_gen_stage_4(ctx, steps_dir)
except Exception as e:
logger.warning(f"[step_viz] {stage_name} 시각화 실패: {e}")
# ══════════════════════════════════════
# 공통
# ══════════════════════════════════════
def _tokens():
from src.fit_verifier import _load_design_tokens
return _load_design_tokens()
def _calc_coords(containers: dict, ratio: tuple) -> dict:
t = _tokens()
pad = t.get("spacing_page", 40)
gap = t.get("spacing_block", 20)
small = t.get("spacing_small", 8)
header_h = 66
inner_w = 1280 - pad * 2
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
def gh(c):
if hasattr(c, "height_px"): return c.height_px
return c.get("height_px", 0) if isinstance(c, dict) else 0
bg_h = gh(containers.get("배경", {}))
core_h = gh(containers.get("본심", {}))
sb_h = gh(containers.get("첨부", {}))
ft_h = gh(containers.get("결론", {}))
bg_top = pad + header_h + gap
core_top = bg_top + bg_h + small
ft_top = max(core_top + core_h, bg_top + sb_h) + gap
return {
"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
"배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
"첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
"결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
}
def _wrap(title, subtitle, slide_body):
return f"""
{title}
{subtitle}
{slide_body}
"""
def _hdr(c, title):
return (f'{title}
\n')
def _box(c, role, inner, extra=""):
cl = COLORS.get(role, "#333")
return (f'{inner}
\n')
# ══════════════════════════════════════
# Stage 0: MDX 정규화
# ══════════════════════════════════════
def _gen_stage_0(ctx, steps_dir):
"""MDX 정규화 결과: 섹션, 팝업, 이미지, 테이블 목록."""
norm = ctx.normalized if hasattr(ctx, 'normalized') else {}
if hasattr(norm, 'model_dump'):
norm = norm.model_dump()
elif not isinstance(norm, dict):
norm = {}
sections = norm.get("sections", [])
popups = norm.get("popups", [])
images = norm.get("images", [])
tables = norm.get("tables", [])
title = norm.get("title", ctx.analysis.title if ctx.analysis else "")
sec_rows = ""
for i, s in enumerate(sections):
heading = s.get("heading", "") if isinstance(s, dict) else ""
content = s.get("content", "") if isinstance(s, dict) else str(s)
preview = content[:120].replace("<", "<") + ("..." if len(content) > 120 else "")
bg = "#f8fafc" if i % 2 == 0 else "#fff"
sec_rows += f'| {i+1} | {heading} | {preview} |
\n'
popup_rows = ""
for p in popups:
pt = p.get("title", "") if isinstance(p, dict) else str(p)
pc = p.get("content", "") if isinstance(p, dict) else ""
popup_rows += f'| {pt} | {len(pc)}자 |
\n'
html = f"""
Stage 0: MDX 정규화
제목: {title} | 섹션: {len(sections)}개 | 팝업: {len(popups)}개 | 이미지: {len(images)}개 | 테이블: {len(tables)}개
섹션
| # | heading | content (미리보기) |
{sec_rows}
팝업
"""
(steps_dir / "stage_0.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1A: Kei 꼭지
# ══════════════════════════════════════
def _gen_stage_1a(ctx, steps_dir):
ps = ctx.page_structure.roles
rm = {}
for role, info in ps.items():
if isinstance(info, dict):
for tid in info.get("topic_ids", []):
rm[tid] = role
rows = ""
for t in ctx.topics:
role = rm.get(t.id, "?")
c = COLORS.get(role, "#333")
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
rows += (f'| {t.id} | '
f'{t.title} | '
f'{t.purpose} | {t.layer} | '
f'{t.relation_type} | '
f'{role} |
\n')
ps_info = "
".join(f"{r}: topic_ids={info.get('topic_ids')}, weight={info.get('weight')}"
for r, info in ps.items() if isinstance(info, dict))
html = f"""
Stage 1A/1B: Kei 꼭지 + 영역 배정
| ID | 제목 |
purpose | layer | relation_type |
영역 |
{rows}
페이지 구조:
{ps_info}
"""
(steps_dir / "stage_1a.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1B: 컨셉 구체화
# ══════════════════════════════════════
def _gen_stage_1b(ctx, steps_dir):
"""Stage 1B 후 꼭지에 source_data, summary가 추가된 상태."""
ps = ctx.page_structure.roles
rm = {}
for role, info in ps.items():
if isinstance(info, dict):
for tid in info.get("topic_ids", []):
rm[tid] = role
rows = ""
for t in ctx.topics:
role = rm.get(t.id, "?")
c = COLORS.get(role, "#333")
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
sd = (t.source_data or "")[:150]
sd_display = sd.replace("<", "<") + ("..." if len(t.source_data or "") > 150 else "")
summary = (t.summary or "")[:100] if hasattr(t, 'summary') else ""
rows += (f'| {t.id} | '
f'{t.title} | '
f'{role} | '
f'{t.layer} | '
f'{sd_display} | '
f'{summary} |
\n')
html = f"""
Stage 1B: 컨셉 구체화
Stage 1A의 꼭지에 source_data(원본 텍스트)와 summary가 추가됨
| ID | 제목 |
영역 | layer |
source_data (미리보기) | summary |
{rows}
"""
(steps_dir / "stage_1b.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5a: 빈 컨테이너
# ══════════════════════════════════════
def _gen_stage_1_5a(ctx, steps_dir):
coords = _calc_coords(ctx.containers, ctx.container_ratio)
fh = ctx.font_hierarchy
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
fk = FONT_MAP[role]
font = getattr(fh, fk, "?")
inner = (f''
f'{role}
'
f'{c["w"]}x{c["h"]}px / font:{font}px
')
body += _box(c, role, inner)
r = ctx.container_ratio
html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5a: 컨테이너에 콘텐츠 배치
# ══════════════════════════════════════
def _gen_stage_1_5a_content(ctx, steps_dir):
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics}
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
lines = [f'{role}
']
for tid in tids:
t = topic_map.get(tid)
if not t:
continue
lines.append(f'[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}
')
sd = t.source_data
if sd:
# 불릿으로 표시
for sent in sd.split(", "):
sent = sent.strip()
if sent:
lines.append(f'•{sent}
')
inner = f'{"".join(lines)}
'
body += _box(c, role, inner)
html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5b: 디자인 예산
# ══════════════════════════════════════
def _gen_stage_1_5b(ctx, steps_dir):
"""영역별 디자인 예산 (available height/width, fits 여부)."""
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
ci = ctx.containers.get(role)
if not ci:
continue
db = ci.design_budget
if db and hasattr(db, 'model_dump'):
db = db.model_dump()
elif not isinstance(db, dict):
db = {}
avail_h = db.get("available_height_px", 0)
avail_w = db.get("available_width_px", 0)
fits = db.get("fits", False)
icon = "✅" if fits else "⚠️"
inner = (f''
f'
{icon} {role} ({c["w"]}×{c["h"]}px)
'
f'
available: {avail_h}×{avail_w}px
'
f'
fits: {fits}
'
f'
')
body += _box(c, role, inner)
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body)
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.7: 블록 선택
# ══════════════════════════════════════
def _gen_stage_1_7(ctx, steps_dir):
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
ref_list = ctx.references.get(role, [])
lines = [f'{role} ({c["w"]}x{c["h"]}px)
']
for r in ref_list:
bid = r.block_id
var = r.variant
vtype = r.visual_type
line = f'{bid} ({var}) {vtype}'
# 주종 정보 — model_dump에서 확인
rd = r.model_dump() if hasattr(r, "model_dump") else {}
# BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인
lines.append(f'{line}
')
inner = f'{"".join(lines)}
'
body += _box(c, role, inner)
html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 텍스트/그림 채운 상태 (filled)
# ══════════════════════════════════════
def _gen_stage_1_8_filled(ctx, steps_dir):
"""블록 디자인에 텍스트를 채운 상태. 공통 조립 함수(block_assembler) 사용."""
from src.block_assembler import assemble_slide_html
slide_html = assemble_slide_html(ctx)
# 시각화 제목 삽입
header = (
''
'Stage 1.8: 블록 디자인에 텍스트 채운 상태 (fit 검증 전)
\n'
''
'블록 CSS + structured_text + font_hierarchy. 공통 조립 함수 사용.
\n'
)
html = slide_html.replace('', '\n' + header, 1)
(steps_dir / "stage_1_8_filled.html").write_text(html, encoding="utf-8")
def _gen_stage_1_8_fit_before(ctx, steps_dir):
"""before: weight 비중대로 배정된 빈 컨테이너. fit 판단 없이 크기만 표시."""
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
ps = ctx.page_structure.roles
info = ps.get(role, {})
weight = info.get("weight", 0) if isinstance(info, dict) else 0
inner = (f''
f'
{role} ({c["w"]}x{c["h"]}px)
'
f'
weight: {weight}
'
f'
블록: {blocks}
'
f'
')
body += _box(c, role, inner)
html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body)
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 재배분 후 + 보강
# ══════════════════════════════════════
def _gen_stage_1_8_fit_after(ctx, steps_dir):
fit = ctx.fit_result
enh = ctx.enhancement_result
redist = fit.get("redistribution", {})
roles_fit = fit.get("roles", {})
# 재배분된 컨테이너
new_c = {}
for role, ci in ctx.containers.items():
new_h = int(redist.get(role, ci.height_px))
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
coords = _calc_coords(new_c, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
emps = enh.get("emphasis_blocks", [])
bolds = enh.get("bold_keywords", {})
sups = enh.get("supplement_blocks", [])
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
rf = roles_fit.get(role, {})
status = rf.get("fit_status", "?")
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
old_h = rf.get("allocated_px", 0)
new_h = int(redist.get(role, old_h))
needed = rf.get("total_required_px", 0)
delta = new_h - old_h
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list)
delta_str = f" ({delta:+d}px)" if delta != 0 else ""
inner = (f''
f'
{icon} {role} ({c["w"]}x{new_h}px){delta_str}
'
f'
필요: {needed:.0f}px / 재배분 후: {new_h}px
'
f'
블록: {blocks}
')
# 보강 정보
role_emps = [e for e in emps if e.get("role") == role]
role_bolds = bolds.get(role, [])
role_sups = [s for s in sups if s.get("role") == role]
if role_emps:
for e in role_emps:
inner += f'
강조: {e.get("sentence","")[:40]}...
'
if role_sups:
for s in role_sups:
inner += f'
보충: {s.get("block_id")} ({s.get("content_source")})
'
if role_bolds:
inner += f'
bold: {role_bolds[:4]}
'
inner += '
'
body += _box(c, role, inner)
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items())
html = _wrap("Step 3b: 재배분 후 + 보강 (Stage 1.8)", f"재배분: {redist_str}", body)
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 블록 디자인을 컨테이너에 배치
# ══════════════════════════════════════
def _gen_stage_1_8_blocks(ctx, steps_dir):
"""재배분된 컨테이너에 블록 SLOT 구조 + 블록 디자인 + 주종관계 표시.
debug_steps/step2_phase_v.html 수준의 시각화."""
import re as _re
fit = ctx.fit_result or {}
redist = fit.get("redistribution", {})
topic_map = {t.id: t for t in ctx.topics}
ps = ctx.page_structure.roles
new_c = {}
for role, ci in ctx.containers.items():
new_h = int(redist.get(role, ci.height_px))
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
coords = _calc_coords(new_c, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
all_block_css = set()
slide_body = _hdr(coords["header"], title)
legend_lines = []
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
ref_list = ctx.references.get(role, [])
info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
if not ref_list:
slide_body += _box(c, role, f'블록 없음
')
continue
r0 = ref_list[0]
bid = r0.block_id
var = r0.variant
is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
# 블록 디자인 HTML — SLOT 주석은 유지, 내용은 SLOT 마커로
raw = r0.design_reference_html or ""
# CSS 추출
styles = _re.findall(r'', raw, _re.DOTALL)
for s in styles:
all_block_css.add(s)
clean = _re.sub(r'', '', raw, flags=_re.DOTALL)
# SLOT 주석을 보이는 텍스트로 변환
def _slot_comment_to_visible(match):
text = match.group(1).strip()
if 'SLOT:' in text:
return f'{text}'
return ''
clean = _re.sub(r'', _slot_comment_to_visible, clean)
# 나머지 주석 제거
clean = _re.sub(r'', '', clean, flags=_re.DOTALL)
# 태그 라벨 (동적)
tag_parts = [f"{role} ({c['w']}×{c['h']})", bid]
if is_hier:
sup_str = "+".join(f"꼭지{st}" for st in sup_tids)
tag_parts.append(f"꼭지{primary_tid}(주)+{sup_str}(종) → 블록 1개")
tag_label = " · ".join(tag_parts)
# 종속 꼭지 SLOT 표시
sub_slot = ""
if is_hier and sup_tids:
for st in sup_tids:
st_topic = topic_map.get(st)
st_purpose = st_topic.purpose if st_topic and hasattr(st_topic, 'purpose') else ""
sub_slot += (
f''
f'SLOT: 하위 (꼭지{st} — {st_purpose})
'
)
# key-msg SLOT (본심만)
keymsg_slot = ""
if role == "본심" and ctx.analysis.core_message:
keymsg_slot = (
f''
f'SLOT: key-msg
'
)
inner = (
f''
f'{tag_label}'
f'{clean}{sub_slot}{keymsg_slot}
'
)
slide_body += (
f''
f'{inner}
\n'
)
# 범례
if is_hier:
primary_topic = topic_map.get(primary_tid)
p_layer = primary_topic.layer if primary_topic and hasattr(primary_topic, 'layer') else ""
legend_lines.append(
f'• {role}: 꼭지{primary_tid}({p_layer}) + '
f'{"+".join(f"꼭지{st}" for st in sup_tids)} → '
f'주종 관계 → {bid} 1개'
)
else:
for r in ref_list:
t = topic_map.get(r.topic_id if hasattr(r, 'topic_id') else None)
t_layer = t.layer if t and hasattr(t, 'layer') else ""
legend_lines.append(f'• {role}: 꼭지{r.topic_id}({t_layer}) → {r.block_id}')
css_block = "\n".join(all_block_css)
legend_html = "
".join(legend_lines)
html = f"""
Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)
블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.
{slide_body}
블록 선택 근거 (layer 기반):
{legend_html}
"""
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
def _gen_stage_2(ctx, steps_dir):
"""Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
gen = ctx.generated_html or {}
sub_layouts = ctx.sub_layouts or {}
ps = ctx.page_structure.roles
# body_html에서 배경/본심 분리 (spacer로 구분)
body_html = gen.get("body_html", "")
sidebar_html = gen.get("sidebar_html", "")
footer_html = gen.get("footer_html", "")
# body_html = 배경 + spacer + 본심. spacer로 분리
import re as _re
spacer_pattern = r''
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
# 역할별 HTML 매핑
role_htmls = {}
if bg_html and "배경" in ps:
role_htmls["배경"] = bg_html
if core_html and "본심" in ps:
role_htmls["본심"] = core_html
if sidebar_html and "첨부" in ps:
role_htmls["첨부"] = sidebar_html
if footer_html and "결론" in ps:
role_htmls["결론"] = footer_html
# 각 역할을 컨테이너 크기에 맞게 실제 렌더링
fit = ctx.fit_result or {}
redist = fit.get("redistribution", {})
sections = []
for role in ["배경", "본심", "첨부", "결론"]:
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
cl = COLORS.get(role, "#333")
ci = ctx.containers.get(role)
if not ci:
continue
h = int(redist.get(role, ci.height_px))
w = ci.width_px
# sub_layout 정보
layout = sub_layouts.get(role, {})
scs = layout.get("sub_containers", [])
sc_desc = " + ".join(f'{sc["name"]}({int(sc["width_px"])}×{int(sc["height_px"])}px)' for sc in scs) if scs else ""
sections.append(
f''
f'
'
f'{role} ({w}×{h}px)'
f'{" — " + sc_desc if sc_desc else ""}
'
f'
{rhtml}
'
)
html = f"""
Stage 2: 영역별 HTML 생성 결과 (Sonnet)
각 역할의 Sonnet 출력을 컨테이너 크기에 맞게 실제 렌더링
{"".join(sections)}
"""
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
def _gen_stage_3(ctx, steps_dir):
"""Stage 3 결과: rendered_html을 별도 파일로 저장 + 링크.
rendered_html 자체가 완성 HTML이므로 직접 렌더링 가능."""
rendered = ctx.rendered_html or ""
if rendered:
# rendered_html을 별도 파일로 저장하여 브라우저에서 직접 확인 가능
(steps_dir / "stage_3_rendered.html").write_text(rendered, encoding="utf-8")
html = f"""
Stage 3: 렌더링 조립 결과
Stage 2의 영역별 HTML을 슬라이드 프레임(CSS Grid)에 배치 + 후처리 적용
렌더링 결과 보기 (1280×720) →
final.html 보기 →
Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_hierarchy.bg}px, 첨부≤{ctx.font_hierarchy.sidebar}px), overflow 제거, bold 변환
"""
(steps_dir / "stage_3.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 4: 품질 게이트
# ══════════════════════════════════════
def _gen_stage_4(ctx, steps_dir):
"""Stage 4 결과: 측정값 + 품질 점수."""
measurement = ctx.measurement or {}
quality_score = ctx.quality_score if hasattr(ctx, 'quality_score') else "N/A"
slide_m = measurement.get("slide", {})
zones = measurement.get("zones", {})
zone_rows = ""
for zone_name, zone_data in zones.items():
overflowed = zone_data.get("overflowed", False)
excess = zone_data.get("excess_px", 0)
client_h = zone_data.get("clientHeight", 0)
scroll_h = zone_data.get("scrollHeight", 0)
icon = "❌" if overflowed else "✅"
bg = "#fee2e2" if overflowed else "#f0fdf4"
zone_rows += (f'| {icon} {zone_name} | '
f'{client_h}px | '
f'{scroll_h}px | '
f'{excess:+d}px |
\n')
score_color = "#16a34a" if (isinstance(quality_score, (int, float)) and quality_score >= 80) else "#dc2626"
html = f"""
Stage 4: 품질 게이트
품질 점수: {quality_score}
슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}
| 영역 | clientH | scrollH | excess |
{zone_rows}
"""
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")