908 lines
42 KiB
Python
908 lines
42 KiB
Python
"""파이프라인 각 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_A = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||
FONT_MAP_A = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||
|
||
# Type B용 동적 색상 팔레트
|
||
_COLOR_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#dc2626", "#0891b2"]
|
||
|
||
# 하위 호환: 기존 코드에서 COLORS/FONT_MAP 참조하는 곳 대응
|
||
COLORS = COLORS_A
|
||
FONT_MAP = FONT_MAP_A
|
||
|
||
|
||
def _is_type_b(ctx) -> bool:
|
||
"""page_structure에 zone 키가 있으면 Type B."""
|
||
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||
for info in ps.values():
|
||
if isinstance(info, dict) and info.get("zone") in ("top", "bottom_left", "bottom_right"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _get_roles(ctx) -> list[str]:
|
||
"""page_structure의 실제 역할명 목록 (순서: zone 기준)."""
|
||
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||
if _is_type_b(ctx):
|
||
zone_order = {"top": 0, "bottom_left": 1, "bottom_right": 2, "footer": 3}
|
||
roles = []
|
||
for role_name, info in ps.items():
|
||
if isinstance(info, dict):
|
||
z = info.get("zone", "")
|
||
roles.append((zone_order.get(z, 9), role_name))
|
||
return [r for _, r in sorted(roles)]
|
||
else:
|
||
return ["배경", "본심", "첨부", "결론"]
|
||
|
||
|
||
def _get_color(role: str, ctx=None) -> str:
|
||
"""역할명 → 색상. Type A는 고정, Type B는 동적."""
|
||
if role in COLORS_A:
|
||
return COLORS_A[role]
|
||
if ctx:
|
||
roles = _get_roles(ctx)
|
||
idx = roles.index(role) if role in roles else 0
|
||
return _COLOR_PALETTE[idx % len(_COLOR_PALETTE)]
|
||
return "#666666"
|
||
|
||
|
||
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, ctx=None) -> dict:
|
||
"""역할별 좌표 계산. Type A/B 자동 분기."""
|
||
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
|
||
|
||
def gh(c):
|
||
if hasattr(c, "height_px"): return c.height_px
|
||
return c.get("height_px", 0) if isinstance(c, dict) else 0
|
||
|
||
def gw(c):
|
||
if hasattr(c, "width_px"): return c.width_px
|
||
return c.get("width_px", 0) if isinstance(c, dict) else 0
|
||
|
||
# Type B 감지
|
||
if ctx and _is_type_b(ctx):
|
||
ps = ctx.page_structure.roles
|
||
coords = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}}
|
||
|
||
# zone별 컨테이너 찾기
|
||
zone_map = {}
|
||
for role_name, info in ps.items():
|
||
if isinstance(info, dict):
|
||
zone_map[info.get("zone", "")] = role_name
|
||
|
||
top_role = zone_map.get("top", "")
|
||
bl_role = zone_map.get("bottom_left", "")
|
||
br_role = zone_map.get("bottom_right", "")
|
||
ft_role = zone_map.get("footer", "")
|
||
|
||
top_h = gh(containers.get(top_role, {}))
|
||
bl_h = gh(containers.get(bl_role, {}))
|
||
br_h = gh(containers.get(br_role, {}))
|
||
ft_h = gh(containers.get(ft_role, {}))
|
||
|
||
top_top = pad + header_h + gap
|
||
bottom_top = top_top + top_h + small
|
||
bottom_h = max(bl_h, br_h)
|
||
ft_top = bottom_top + bottom_h + gap
|
||
bottom_col_w = (inner_w - gap) // 2
|
||
|
||
if top_role:
|
||
coords[top_role] = {"l": pad, "t": top_top, "w": inner_w, "h": top_h}
|
||
if bl_role:
|
||
coords[bl_role] = {"l": pad, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
|
||
if br_role:
|
||
coords[br_role] = {"l": pad + bottom_col_w + gap, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
|
||
if ft_role:
|
||
coords[ft_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h}
|
||
|
||
return coords
|
||
|
||
# Type A (기존)
|
||
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
|
||
|
||
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, ctx=None):
|
||
"""slide-base.html 기반 래핑. step 시각화도 실제 슬라이드와 같은 기반 사용."""
|
||
from pathlib import Path
|
||
|
||
slide_base_path = Path(__file__).parent.parent / "templates" / "blocks" / "slide-base.html"
|
||
slide_title = ""
|
||
footer_text = ""
|
||
if ctx:
|
||
slide_title = ctx.analysis.title if ctx.analysis else ""
|
||
footer_text = ctx.analysis.conclusion_text if ctx.analysis else ""
|
||
|
||
try:
|
||
raw = slide_base_path.read_text(encoding="utf-8")
|
||
# {% block body %} → slide_body로 치환
|
||
raw = raw.replace("{% block body %}{% endblock %}", slide_body)
|
||
from jinja2 import Template
|
||
template = Template(raw)
|
||
slide_html = template.render(title=slide_title, footer_text=footer_text, footer_pill_bg="")
|
||
# step 라벨 추가
|
||
label = (f'<div style="position:fixed;top:4px;left:4px;z-index:999;font-size:14px;'
|
||
f'font-weight:bold;background:rgba(255,255,255,0.9);padding:4px 8px;border-radius:4px;">'
|
||
f'{title}</div>'
|
||
f'<div style="position:fixed;top:26px;left:4px;z-index:999;font-size:10px;'
|
||
f'color:#666;background:rgba(255,255,255,0.9);padding:2px 8px;border-radius:4px;">'
|
||
f'{subtitle}</div>')
|
||
return slide_html.replace('<body>', f'<body>{label}')
|
||
except Exception:
|
||
# fallback: 기존 방식
|
||
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>
|
||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||
</style></head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
|
||
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
|
||
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
|
||
{slide_body}
|
||
</div></body></html>"""
|
||
|
||
|
||
def _hdr(c, title):
|
||
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
|
||
f'background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;'
|
||
f'padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>\n')
|
||
|
||
|
||
def _box(c, role, inner, extra=""):
|
||
cl = COLORS.get(role, "#333")
|
||
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
|
||
f'border:2px solid {cl};border-radius:6px;background:{cl}08;overflow:hidden;{extra}">{inner}</div>\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'<tr style="background:{bg};"><td style="padding:6px 8px;">{i+1}</td><td style="padding:6px 8px;font-weight:700;">{heading}</td><td style="padding:6px 8px;font-size:11px;">{preview}</td></tr>\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'<tr><td style="padding:6px 8px;font-weight:700;">{pt}</td><td style="padding:6px 8px;font-size:11px;">{len(pc)}자</td></tr>\n'
|
||
|
||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 0: MDX 정규화</div>
|
||
<div style="font-size:12px;color:#555;margin-bottom:12px;">제목: <b>{title}</b> | 섹션: {len(sections)}개 | 팝업: {len(popups)}개 | 이미지: {len(images)}개 | 테이블: {len(tables)}개</div>
|
||
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">섹션</div>
|
||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;margin-bottom:16px;">
|
||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">#</th><th style="padding:8px;">heading</th><th style="padding:8px;">content (미리보기)</th></tr>{sec_rows}</table>
|
||
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">팝업</div>
|
||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;">
|
||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">title</th><th style="padding:8px;">분량</th></tr>{popup_rows}</table>
|
||
</body></html>"""
|
||
(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'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
|
||
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
|
||
f'<td style="padding:6px 8px;">{t.purpose}</td><td style="padding:6px 8px;">{t.layer}</td>'
|
||
f'<td style="padding:6px 8px;">{t.relation_type}</td>'
|
||
f'<td style="padding:6px 8px;color:{c};font-weight:700;">{role}</td></tr>\n')
|
||
|
||
ps_info = "<br>".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"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1A/1B: Kei 꼭지 + 영역 배정</div>
|
||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;">
|
||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
|
||
<th style="padding:8px;">purpose</th><th style="padding:8px;">layer</th><th style="padding:8px;">relation_type</th>
|
||
<th style="padding:8px;">영역</th></tr>{rows}</table>
|
||
<div style="margin-top:12px;font-size:12px;color:#555;"><b>페이지 구조:</b><br>{ps_info}</div></body></html>"""
|
||
(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'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
|
||
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
|
||
f'<td style="padding:6px 8px;color:{c};">{role}</td>'
|
||
f'<td style="padding:6px 8px;">{t.layer}</td>'
|
||
f'<td style="padding:6px 8px;font-size:10px;">{sd_display}</td>'
|
||
f'<td style="padding:6px 8px;font-size:10px;color:#555;">{summary}</td></tr>\n')
|
||
|
||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1B: 컨셉 구체화</div>
|
||
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 1A의 꼭지에 source_data(원본 텍스트)와 summary가 추가됨</div>
|
||
<table style="border-collapse:collapse;font-size:12px;width:100%;">
|
||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
|
||
<th style="padding:8px;">영역</th><th style="padding:8px;">layer</th>
|
||
<th style="padding:8px;">source_data (미리보기)</th><th style="padding:8px;">summary</th></tr>{rows}</table></body></html>"""
|
||
(steps_dir / "stage_1b.html").write_text(html, encoding="utf-8")
|
||
|
||
|
||
# ══════════════════════════════════════
|
||
# Stage 1.5a: 빈 컨테이너
|
||
# ══════════════════════════════════════
|
||
|
||
def _gen_stage_1_5a(ctx, steps_dir):
|
||
"""slide-base 위에 빈 zone 컨테이너만 표시."""
|
||
ps = ctx.page_structure.roles
|
||
gap = 8
|
||
|
||
# zone 순서
|
||
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||
roles_sorted = sorted(
|
||
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||
)
|
||
|
||
body_html = ""
|
||
for i, (role, info) in enumerate(roles_sorted):
|
||
ci = ctx.containers.get(role)
|
||
if not ci:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
zone = info.get("zone", "")
|
||
w = ci.width_px
|
||
h = ci.height_px
|
||
tids = info.get("topic_ids", [])
|
||
|
||
body_html += (
|
||
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
|
||
f'margin-bottom:{gap}px;display:flex;align-items:center;justify-content:center;">'
|
||
f'<div style="text-align:center;">'
|
||
f'<b style="color:{cl};font-size:14px;">{role}</b><br>'
|
||
f'<span style="color:#888;font-size:11px;">zone: {zone} / {w}×{h}px</span><br>'
|
||
f'<span style="color:#aaa;font-size:10px;">topics: {tids}</span>'
|
||
f'</div></div>\n'
|
||
)
|
||
|
||
html = _wrap("Stage 1.5a: 빈 컨테이너", "slide-base 위에 zone 배치", body_html, ctx=ctx)
|
||
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
|
||
|
||
|
||
# ══════════════════════════════════════
|
||
# Stage 1.5a: 컨테이너에 콘텐츠 배치
|
||
# ══════════════════════════════════════
|
||
|
||
def _gen_stage_1_5a_content(ctx, steps_dir):
|
||
"""slide-base 위 zone에 topic 콘텐츠 배치."""
|
||
ps = ctx.page_structure.roles
|
||
topic_map = {t.id: t for t in ctx.topics}
|
||
gap = 8
|
||
|
||
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||
roles_sorted = sorted(
|
||
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||
)
|
||
|
||
body_html = ""
|
||
for role, info in roles_sorted:
|
||
ci = ctx.containers.get(role)
|
||
if not ci:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
tids = info.get("topic_ids", [])
|
||
w = ci.width_px
|
||
h = ci.height_px
|
||
|
||
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
|
||
for tid in tids:
|
||
t = topic_map.get(tid)
|
||
if not t:
|
||
continue
|
||
lines.append(f'<div style="font-size:11px;font-weight:700;margin-bottom:2px;">[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}</div>')
|
||
sd = t.source_data
|
||
if sd:
|
||
for sent in sd.split(", ")[:5]:
|
||
sent = sent.strip()
|
||
if sent:
|
||
lines.append(f'<div style="font-size:10px;color:#444;padding-left:12px;">• {sent}</div>')
|
||
|
||
body_html += (
|
||
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||
f'{"".join(lines)}</div>\n'
|
||
)
|
||
|
||
html = _wrap("Stage 1.5a: 콘텐츠 배치", "zone별 topic source_data", body_html, ctx=ctx)
|
||
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
|
||
|
||
|
||
# ══════════════════════════════════════
|
||
# Stage 1.5b: 디자인 예산
|
||
# ══════════════════════════════════════
|
||
|
||
def _gen_stage_1_5b(ctx, steps_dir):
|
||
"""slide-base 위 zone별 디자인 예산."""
|
||
ps = ctx.page_structure.roles
|
||
gap = 8
|
||
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||
roles_sorted = sorted(
|
||
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||
)
|
||
|
||
body_html = ""
|
||
for role, info in roles_sorted:
|
||
ci = ctx.containers.get(role)
|
||
if not ci:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
w, h = ci.width_px, ci.height_px
|
||
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 "⚠️"
|
||
|
||
body_html += (
|
||
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{h}px)</div>'
|
||
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px / fits: {fits}</div>'
|
||
f'</div>\n'
|
||
)
|
||
|
||
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body_html, ctx=ctx)
|
||
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
|
||
|
||
|
||
# ══════════════════════════════════════
|
||
# Stage 1.7: 블록 선택
|
||
# ══════════════════════════════════════
|
||
|
||
def _gen_stage_1_7(ctx, steps_dir):
|
||
"""slide-base 위 zone별 선택된 블록 표시."""
|
||
ps = ctx.page_structure.roles
|
||
gap = 8
|
||
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||
roles_sorted = sorted(
|
||
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||
)
|
||
|
||
body_html = ""
|
||
for role, info in roles_sorted:
|
||
ci = ctx.containers.get(role)
|
||
if not ci:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
w, h = ci.width_px, ci.height_px
|
||
ref_list = ctx.references.get(role, [])
|
||
|
||
lines = [f'<div style="font-size:11px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
|
||
for r in ref_list:
|
||
vtype_label = "tag_match ✅" if r.visual_type == "tag_match" else r.visual_type
|
||
lines.append(f'<div style="font-size:12px;margin-bottom:2px;"><b>{r.block_id}</b> ({r.variant}) — {vtype_label}</div>')
|
||
|
||
body_html += (
|
||
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||
f'{"".join(lines)}</div>\n'
|
||
)
|
||
|
||
html = _wrap("Stage 1.7: 블록 선택", "tag 매칭 기반 블록 선택", body_html, ctx=ctx)
|
||
(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 = (
|
||
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
|
||
'Stage 1.8: 블록 디자인에 텍스트 채운 상태 (fit 검증 전)</div>\n'
|
||
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
|
||
'블록 CSS + structured_text + font_hierarchy. 공통 조립 함수 사용.</div>\n'
|
||
)
|
||
html = slide_html.replace('</head><body>', '</head><body>\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):
|
||
"""slide-base 위 zone별 초기 배정 (weight 기반)."""
|
||
ps = ctx.page_structure.roles
|
||
gap = 8
|
||
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||
roles_sorted = sorted(
|
||
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||
)
|
||
|
||
body_html = ""
|
||
for role, info in roles_sorted:
|
||
ci = ctx.containers.get(role)
|
||
if not ci:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
w, h = ci.width_px, ci.height_px
|
||
weight = info.get("weight", 0)
|
||
ref_list = ctx.references.get(role, [])
|
||
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
|
||
|
||
body_html += (
|
||
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
|
||
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||
f'<div style="font-size:11px;color:{cl};font-weight:700;">{role} ({w}×{h}px)</div>'
|
||
f'<div style="font-size:10px;color:#555;">weight: {weight} / 블록: {blocks}</div>'
|
||
f'</div>\n'
|
||
)
|
||
|
||
html = _wrap("Stage 1.8: before", "weight 비중 초기 배정. 블록 채움 전.", body_html, ctx=ctx)
|
||
(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):
|
||
"""slide-base 위 zone별 재배분 결과."""
|
||
ps = ctx.page_structure.roles
|
||
fit = ctx.fit_result or {}
|
||
enh = ctx.enhancement_result or {}
|
||
redist = fit.get("redistribution", {})
|
||
roles_fit = fit.get("roles", {})
|
||
gap = 8
|
||
|
||
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||
roles_sorted = sorted(
|
||
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||
)
|
||
|
||
body_html = ""
|
||
for role, info in roles_sorted:
|
||
ci = ctx.containers.get(role)
|
||
if not ci:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
w = ci.width_px
|
||
old_h = ci.height_px
|
||
new_h = int(redist.get(role, old_h))
|
||
rf = roles_fit.get(role, {})
|
||
status = rf.get("fit_status", "OK")
|
||
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "✅")
|
||
delta = new_h - old_h
|
||
delta_str = f" ({delta:+d}px)" if delta != 0 else ""
|
||
|
||
ref_list = ctx.references.get(role, [])
|
||
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else ""
|
||
|
||
body_html += (
|
||
f'<div style="width:{w}px;height:{new_h}px;border:2px solid {cl};border-radius:6px;'
|
||
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{new_h}px){delta_str}</div>'
|
||
f'<div style="font-size:10px;color:#555;">블록: {blocks}</div>'
|
||
f'</div>\n'
|
||
)
|
||
|
||
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items()) if redist else "재배분 없음"
|
||
html = _wrap("Stage 1.8: 재배분 후", f"재배분: {redist_str}", body_html, ctx=ctx)
|
||
(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):
|
||
"""slide-base 위에 실제 블록 렌더링. assemble_slide_html_final과 동일한 결과."""
|
||
from src.block_assembler import assemble_slide_html_final
|
||
html = assemble_slide_html_final(ctx)
|
||
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
|
||
|
||
|
||
def _gen_stage_2(ctx, steps_dir):
|
||
"""Stage 2 결과: 영역별 HTML 생성 결과.
|
||
Type A: Sonnet 영역별 출력. Type B: slide-base 완전 HTML."""
|
||
gen = ctx.generated_html or {}
|
||
sub_layouts = ctx.sub_layouts or {}
|
||
ps = ctx.page_structure.roles
|
||
|
||
# Type B: generated_html이 str (완전한 HTML)
|
||
if isinstance(gen, str):
|
||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
|
||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: slide-base + 블록 템플릿 조립 결과 (Type B)</div>
|
||
<div style="font-size:11px;color:#666;margin-bottom:12px;">slide-base.html 배경 위에 블록 템플릿으로 조립된 최종 HTML</div>
|
||
<iframe srcdoc="{gen.replace('"', '"')}" style="width:1280px;height:720px;border:1px solid #ccc;"></iframe>
|
||
</body></html>"""
|
||
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
|
||
return
|
||
|
||
# Type A: dict (body_html, sidebar_html, footer_html)
|
||
import re as _re
|
||
|
||
body_html = gen.get("body_html", "")
|
||
sidebar_html = gen.get("sidebar_html", "")
|
||
footer_html = gen.get("footer_html", "")
|
||
|
||
spacer_pattern = r'<div style="height:\d+px;"></div>'
|
||
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()
|
||
|
||
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 _get_roles(ctx):
|
||
rhtml = role_htmls.get(role, "")
|
||
if not rhtml:
|
||
continue
|
||
cl = _get_color(role, ctx)
|
||
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'<div style="margin-bottom:20px;">'
|
||
f'<div style="font-size:13px;font-weight:700;color:{cl};margin-bottom:4px;">'
|
||
f'{role} ({w}×{h}px)'
|
||
f'{" — " + sc_desc if sc_desc else ""}</div>'
|
||
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||
f'overflow:hidden;background:white;font-family:Pretendard Variable,sans-serif;'
|
||
f'word-break:keep-all;">{rhtml}</div></div>'
|
||
)
|
||
|
||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
|
||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: 영역별 HTML 생성 결과 (Sonnet)</div>
|
||
<div style="font-size:11px;color:#666;margin-bottom:12px;">각 역할의 Sonnet 출력을 컨테이너 크기에 맞게 실제 렌더링</div>
|
||
{"".join(sections)}
|
||
</body></html>"""
|
||
(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"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
|
||
body{{background:#e5e5e5;padding:10px;font-family:sans-serif;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 3: 렌더링 조립 결과</div>
|
||
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 2의 영역별 HTML을 슬라이드 프레임(CSS Grid)에 배치 + 후처리 적용</div>
|
||
<p style="margin-bottom:8px;"><a href="stage_3_rendered.html" style="font-size:16px;font-weight:700;">렌더링 결과 보기 (1280×720) →</a></p>
|
||
<p><a href="../final.html" style="font-size:14px;">final.html 보기 →</a></p>
|
||
<div style="margin-top:16px;font-size:12px;color:#555;">
|
||
Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_hierarchy.bg}px, 첨부≤{ctx.font_hierarchy.sidebar}px), overflow 제거, bold 변환
|
||
</div>
|
||
</body></html>"""
|
||
(steps_dir / "stage_3.html").write_text(html, encoding="utf-8")
|
||
|
||
|
||
# ══════════════════════════════════════
|
||
# Stage 4: 품질 게이트
|
||
# ══════════════════════════════════════
|
||
|
||
def _gen_structure_validation(ctx) -> str:
|
||
"""sample-based 구조 검증. "어긋나면 안 된다" 기준."""
|
||
import re as _re
|
||
checks = []
|
||
html = ctx.rendered_html if hasattr(ctx, 'rendered_html') else ""
|
||
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||
|
||
# 1. 본문 텍스트 visible (body에 실제 텍스트가 있는지)
|
||
body_start = html.find('<body') if html else -1
|
||
body_text = _re.sub(r'<[^>]+>', '', html[body_start:]) if body_start > 0 else ""
|
||
body_text = _re.sub(r'\s+', ' ', body_text).strip()
|
||
text_len = len(body_text)
|
||
ok = text_len > 100
|
||
checks.append(("본문 텍스트 visible", f"{'✅' if ok else '❌'} {text_len}자"))
|
||
|
||
# 2. detail link 개수 (role당 1개)
|
||
link_count = len(_re.findall(r'자세히보기', html)) if html else 0
|
||
popup_count = len(ctx.normalized.popups) if hasattr(ctx.normalized, 'popups') else 0
|
||
ok = link_count <= max(popup_count, 1)
|
||
checks.append(("detail link 개수", f"{'✅' if ok else '⚠️'} {link_count}개 (popup {popup_count}개)"))
|
||
|
||
# 3. body 안 <style> 0개
|
||
body_styles = len(_re.findall(r'<style', html[body_start:])) if body_start > 0 else -1
|
||
ok = body_styles == 0
|
||
checks.append(("body 안 <style>", f"{'✅' if ok else '❌'} {body_styles}개"))
|
||
|
||
# 4. conclusion * 없음
|
||
ct = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else ""
|
||
ok = not ct.startswith("*")
|
||
checks.append(("conclusion 선행 * 없음", f"{'✅' if ok else '❌'} \"{ct[:30]}\""))
|
||
|
||
# 5. sub_types 분류 확인
|
||
for rname, rinfo in ps.items():
|
||
if not isinstance(rinfo, dict):
|
||
continue
|
||
sub_types = rinfo.get("sub_types", [])
|
||
for st in sub_types:
|
||
checks.append((f"sub_type: {st.get('title','')[:20]}", st.get("sub_type", "미분류")))
|
||
|
||
rows = ""
|
||
for name, result in checks:
|
||
rows += f'<tr><td style="padding:6px 8px;">{name}</td><td style="padding:6px 8px;">{result}</td></tr>\n'
|
||
return rows
|
||
|
||
|
||
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'<tr style="background:{bg};"><td style="padding:6px 8px;">{icon} {zone_name}</td>'
|
||
f'<td style="padding:6px 8px;">{client_h}px</td>'
|
||
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
|
||
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
|
||
|
||
if isinstance(quality_score, (int, float)) and quality_score < 0:
|
||
score_color = "#d97706" # 미평가 = 주황
|
||
quality_score = "미평가 (비전 모델 미응답)"
|
||
elif isinstance(quality_score, (int, float)) and quality_score >= 80:
|
||
score_color = "#16a34a"
|
||
else:
|
||
score_color = "#dc2626"
|
||
|
||
# 블록/recipe 정보 (page_structure에서)
|
||
recipe_rows = ""
|
||
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||
refs = ctx.references if hasattr(ctx, 'references') else {}
|
||
for rname, rinfo in ps.items():
|
||
if not isinstance(rinfo, dict):
|
||
continue
|
||
schema = rinfo.get("group_schema", "")
|
||
zone = rinfo.get("zone", "")
|
||
# block_id
|
||
role_refs = refs.get(rname, [])
|
||
block_id = ""
|
||
if role_refs:
|
||
r0 = role_refs[0]
|
||
block_id = r0.block_id if hasattr(r0, 'block_id') else r0.get("block_id", "")
|
||
is_recipe = block_id == "__needs_recipe__"
|
||
block_display = f"recipe ({schema})" if is_recipe else block_id
|
||
recipe_rows += (
|
||
f'<tr><td style="padding:6px 8px;">{zone}</td>'
|
||
f'<td style="padding:6px 8px;">{rname}</td>'
|
||
f'<td style="padding:6px 8px;">{schema}</td>'
|
||
f'<td style="padding:6px 8px;">{block_display}</td></tr>\n'
|
||
)
|
||
|
||
# popup 정보
|
||
popup_rows = ""
|
||
popups = ctx.normalized.popups if hasattr(ctx.normalized, 'popups') else []
|
||
for p in popups:
|
||
pid = p.popup_id if hasattr(p, 'popup_id') else ""
|
||
target = p.target_role if hasattr(p, 'target_role') else ""
|
||
pfile = p.popup_file if hasattr(p, 'popup_file') else ""
|
||
popup_rows += (
|
||
f'<tr><td style="padding:6px 8px;">{pid}</td>'
|
||
f'<td style="padding:6px 8px;">{target or "미연결"}</td>'
|
||
f'<td style="padding:6px 8px;">{pfile or "미확정"}</td></tr>\n'
|
||
)
|
||
|
||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}
|
||
table{{border-collapse:collapse;font-size:12px;width:100%;max-width:700px;margin-top:8px;}}
|
||
th{{padding:8px;text-align:left;}}td{{padding:6px 8px;border-bottom:1px solid #ddd;}}</style>
|
||
</head><body>
|
||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 4: 품질 게이트</div>
|
||
<div style="font-size:24px;font-weight:900;color:{score_color};margin-bottom:12px;">품질 점수: {quality_score}</div>
|
||
<div style="font-size:12px;color:#555;margin-bottom:4px;">슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}</div>
|
||
|
||
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Overflow 측정</div>
|
||
<table>
|
||
<tr style="background:#1e293b;color:white;"><th>영역</th><th>clientH</th><th>scrollH</th><th>excess</th></tr>{zone_rows}</table>
|
||
|
||
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">블록/Recipe 선택</div>
|
||
<table>
|
||
<tr style="background:#1e293b;color:white;"><th>zone</th><th>role</th><th>schema</th><th>block/recipe</th></tr>{recipe_rows}</table>
|
||
|
||
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Popup 연결</div>
|
||
<table>
|
||
<tr style="background:#1e293b;color:white;"><th>popup_id</th><th>target_role</th><th>popup_file</th></tr>{popup_rows if popup_rows else '<tr><td colspan="3">없음</td></tr>'}</table>
|
||
|
||
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">구조 검증</div>
|
||
<table>
|
||
<tr style="background:#1e293b;color:white;"><th>검증 항목</th><th>결과</th></tr>
|
||
{_gen_structure_validation(ctx)}
|
||
</table>
|
||
</body></html>"""
|
||
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")
|