Add Type B slide pipeline and recipe rendering updates

This commit is contained in:
2026-04-15 16:39:50 +09:00
parent 51548fdc41
commit 66c00924ed
22 changed files with 6260 additions and 1322 deletions

View File

@@ -29,8 +29,50 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
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:
@@ -74,21 +116,65 @@ def _tokens():
return _load_design_tokens()
def _calc_coords(containers: dict, ratio: tuple) -> dict:
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
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
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("첨부", {}))
@@ -107,12 +193,38 @@ def _calc_coords(containers: dict, ratio: tuple) -> dict:
}
def _wrap(title, subtitle, slide_body):
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
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;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
</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>
@@ -263,23 +375,39 @@ def _gen_stage_1b(ctx, steps_dir):
# ══════════════════════════════════════
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)
"""slide-base 위에 빈 zone 컨테이너만 표시."""
ps = ctx.page_structure.roles
gap = 8
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
fk = FONT_MAP[role]
font = getattr(fh, fk, "?")
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
f'<b style="color:{cl};font-size:13px;">{role}</b><br>'
f'<span style="color:#888;font-size:10px;">{c["w"]}x{c["h"]}px / font:{font}px</span></div>')
body += _box(c, role, inner)
# 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),
)
r = ctx.container_ratio
html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
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")
@@ -288,19 +416,28 @@ def _gen_stage_1_5a(ctx, steps_dir):
# ══════════════════════════════════════
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)
"""slide-base 위 zone에 topic 콘텐츠 배치."""
ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics}
gap = 8
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
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),
)
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role}</div>']
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:
@@ -308,16 +445,18 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
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(", "):
for sent in sd.split(", ")[:5]:
sent = sent.strip()
if sent:
lines.append(f'<div class="bl" style="font-size:10px;color:#444;"><span class="bl-m">•</span><span class="bl-t">{sent}</span></div>')
lines.append(f'<div style="font-size:10px;color:#444;padding-left:12px;">{sent}</div>')
inner = f'<div style="padding:6px 10px;overflow:hidden;">{"".join(lines)}</div>'
body += _box(c, role, inner)
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("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
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")
@@ -326,36 +465,41 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
# ══════════════════════════════════════
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)
"""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),
)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
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 "⚠️"
inner = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}×{c["h"]}px)</div>'
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px</div>'
f'<div style="font-size:10px;color:#555;">fits: {fits}</div>'
f'</div>')
body += _box(c, role, inner)
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 = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body_html, ctx=ctx)
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
@@ -364,30 +508,36 @@ def _gen_stage_1_5b(ctx, steps_dir):
# ══════════════════════════════════════
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)
"""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),
)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
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:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
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:
bid = r.block_id
var = r.variant
vtype = r.visual_type
line = f'<b>{bid}</b> ({var}) <span style="color:#888;font-size:9px;">{vtype}</span>'
# 주종 정보 — model_dump에서 확인
rd = r.model_dump() if hasattr(r, "model_dump") else {}
# BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인
lines.append(f'<div style="font-size:11px;margin-bottom:2px;">{line}</div>')
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>')
inner = f'<div style="padding:6px 10px;">{"".join(lines)}</div>'
body += _box(c, role, inner)
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("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
html = _wrap("Stage 1.7: 블록 선택", "tag 매칭 기반 블록 선택", body_html, ctx=ctx)
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
@@ -411,30 +561,35 @@ def _gen_stage_1_8_filled(ctx, steps_dir):
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]
"""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 "미선택"
ps = ctx.page_structure.roles
info = ps.get(role, {})
weight = info.get("weight", 0) if isinstance(info, dict) else 0
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'
)
inner = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{cl};font-weight:700;">{role} ({c["w"]}x{c["h"]}px)</div>'
f'<div style="font-size:10px;color:#555;">weight: {weight}</div>'
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>'
f'</div>')
body += _box(c, role, inner)
html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body)
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")
@@ -443,65 +598,48 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_8_fit_after(ctx, steps_dir):
fit = ctx.fit_result
enh = ctx.enhancement_result
"""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
# 재배분된 컨테이너
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}
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),
)
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)
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))
needed = rf.get("total_required_px", 0)
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)
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else ""
delta_str = f" <span style='color:#16a34a;'>({delta:+d}px)</span>" if delta != 0 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'
)
inner = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}x{new_h}px){delta_str}</div>'
f'<div style="font-size:10px;color:#555;">필요: {needed:.0f}px / 재배분 후: {new_h}px</div>'
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>')
# 보강 정보
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'<div style="margin-top:3px;background:#991b1b;color:#fff;border-radius:2px;padding:2px 6px;font-size:9px;font-weight:700;">강조: {e.get("sentence","")[:40]}...</div>'
if role_sups:
for s in role_sups:
inner += f'<div style="margin-top:3px;font-size:9px;color:#2563eb;">보충: {s.get("block_id")} ({s.get("content_source")})</div>'
if role_bolds:
inner += f'<div style="margin-top:3px;font-size:9px;color:#475569;">bold: {role_bolds[:4]}</div>'
inner += '</div>'
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)
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")
@@ -510,161 +648,44 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir):
# ══════════════════════════════════════
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'<div style="padding:6px;color:#888;">블록 없음</div>')
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'<style>(.*?)</style>', raw, _re.DOTALL)
for s in styles:
all_block_css.add(s)
clean = _re.sub(r'<style>.*?</style>', '', raw, flags=_re.DOTALL)
# SLOT 주석을 보이는 텍스트로 변환
def _slot_comment_to_visible(match):
text = match.group(1).strip()
if 'SLOT:' in text:
return f'<span style="color:#888;font-size:9px;background:#f0f0f0;padding:1px 4px;border-radius:2px;">{text}</span>'
return ''
clean = _re.sub(r'<!--\s*(SLOT:[^>]+?)-->', _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'<div style="padding-left:8px;border-left:2px solid {cl};margin-top:4px;'
f'font-size:10px;color:{cl};">'
f'SLOT: 하위 (꼭지{st}{st_purpose})</div>'
)
# key-msg SLOT (본심만)
keymsg_slot = ""
if role == "본심" and ctx.analysis.core_message:
keymsg_slot = (
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:3px;'
f'padding:2px 6px;font-size:9px;color:#1e40af;font-weight:700;margin-top:4px;">'
f'SLOT: key-msg</div>'
)
inner = (
f'<div style="position:relative;padding-top:18px;width:100%;height:100%;">'
f'<span style="position:absolute;top:-16px;left:4px;font-size:9px;font-weight:700;'
f'background:white;padding:1px 6px;border-radius:3px;border:1px solid {cl};'
f'color:{cl};white-space:nowrap;">{tag_label}</span>'
f'{clean}{sub_slot}{keymsg_slot}</div>'
)
slide_body += (
f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;'
f'height:{c["h"]}px;border:2px dashed {cl};border-radius:6px;overflow:hidden;">'
f'{inner}</div>\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'<b>주종 관계 → {bid} 1개</b>'
)
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}) → <b>{r.block_id}</b>')
css_block = "\n".join(all_block_css)
legend_html = "<br>".join(legend_lines)
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;}}
{css_block}
</style></head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.</div>
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
{slide_body}
</div>
<div style="margin-top:16px;font-size:12px;color:#555;line-height:1.8;">
<b>블록 선택 근거 (layer 기반):</b><br>{legend_html}
</div></body></html>"""
"""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 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
"""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
# body_html에서 배경/본심 분리 (spacer로 구분)
# 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('"', '&quot;')}" 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", "")
# body_html = 배경 + spacer + 본심. spacer로 분리
import re as _re
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()
# 역할별 HTML 매핑
role_htmls = {}
if bg_html and "배경" in ps:
role_htmls["배경"] = bg_html
@@ -680,11 +701,11 @@ def _gen_stage_2(ctx, steps_dir):
redist = fit.get("redistribution", {})
sections = []
for role in ["배경", "본심", "첨부", "결론"]:
for role in _get_roles(ctx):
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
cl = COLORS.get(role, "#333")
cl = _get_color(role, ctx)
ci = ctx.containers.get(role)
if not ci:
continue
@@ -745,6 +766,51 @@ Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_
# 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 안 &lt;style&gt;", 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 {}
@@ -766,15 +832,76 @@ def _gen_stage_4(ctx, steps_dir):
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
score_color = "#16a34a" if (isinstance(quality_score, (int, float)) and quality_score >= 80) else "#dc2626"
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;}}</style>
<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>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;margin-top:8px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">영역</th><th style="padding:8px;">clientH</th><th style="padding:8px;">scrollH</th><th style="padding:8px;">excess</th></tr>{zone_rows}</table>
<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")