으로 감쌈."""
+ for kw in keywords:
+ if kw in text:
+ text = text.replace(kw, f"{kw}")
+ return text
+
+
+def _popup_links_html(popup_titles: list[str], font_size: float) -> str:
+ """팝업 제목 리스트 → 우측상단 배치용 HTML."""
+ if not popup_titles:
+ return ""
+ links = " ".join(
+ f'[{t}→]'
+ for t in popup_titles
+ )
+ return (
+ f''
+ f'{links}
'
+ )
+
+
+def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str:
+ """(indent, text) 리스트를 HTML 불릿으로."""
+ bk = bold_keywords or []
+ html = ""
+ for indent, text in st_lines:
+ clean = _apply_bold(text.lstrip("• "), bk)
+ if text.startswith("출처:") or clean.startswith("출처:"):
+ # V'-3: "출처:" 라벨 삭제, 텍스트만 표시
+ caption = re.sub(r'^출처:\s*', '', clean)
+ html += f'{caption}
\n'
+ elif indent == 1:
+ html += f'•{clean}
\n'
+ else:
+ html += f'•{clean}
\n'
+ return html
+
+
+def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None):
+ """callout-warning/solution 블록에 텍스트 채움."""
+ bk = bold_keywords or []
+ desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
+ # V-7 종속꼭지 인라인
+ sub_html = ""
+ for st_text in (sub_topics_text or []):
+ sub_html += (
+ f'{_apply_bold(st_text, bk)}
'
+ )
+ # V-9 강조 블록
+ emph_html = ""
+ if emphasis:
+ emph_html = (
+ f''
+ f'→ {_apply_bold(emphasis, bk)}
'
+ )
+ inner = re.sub(r'.*?
',
+ f'{_apply_bold(topic.title, bk)}
', block_body, flags=re.DOTALL)
+ inner = re.sub(r'.*?
',
+ f'{desc_html}{sub_html}{emph_html}
', inner, flags=re.DOTALL)
+ return inner
+
+
+def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None):
+ """card-numbered 블록에 카드별 텍스트 채움."""
+ # indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명
+ cards = []
+ current_title = ""
+ current_descs = []
+ for indent, text in st_lines:
+ clean = text.lstrip("• ")
+ if indent == 0 and text.startswith("• "):
+ if current_title:
+ cards.append((current_title, current_descs))
+ current_title = clean
+ current_descs = []
+ else:
+ current_descs.append(clean)
+ if current_title:
+ cards.append((current_title, current_descs))
+
+ # sidebar 라벨
+ label = f'{topic.title}
'
+
+ bk = bold_keywords or []
+ card_gap = max(3, int(font_size * 0.4))
+ items_html = ""
+ for i, (title, descs) in enumerate(cards):
+ desc_html = ""
+ for d in descs:
+ d = _apply_bold(d, bk)
+ if d.startswith("출처:"):
+ caption = re.sub(r'^출처:\s*', '', d)
+ desc_html += f'{caption}
\n'
+ else:
+ desc_html += f'•{d}
\n'
+ num_size = int(font_size * 2)
+ items_html += (
+ f''
+ f'
{i+1}
'
+ f'
'
+ f'
{_apply_bold(title, bk)}
'
+ f'
{desc_html}
'
+ f'
\n'
+ )
+
+ return f'{label}{items_html}
'
+
+
+def _assemble_banner(block_body, message):
+ """banner-gradient 블록에 메시지 채움."""
+ inner = re.sub(r'.*?
',
+ f'{message}
', block_body, flags=re.DOTALL)
+ inner = re.sub(r'.*?
', '', inner, flags=re.DOTALL)
+ return inner
+
+
+def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None):
+ """이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용."""
+ # 실제 이미지가 있으면
사용, 없으면 빈 placeholder
+ img_html = ""
+ if slide_images:
+ for img in slide_images:
+ b64 = img.get("b64", "")
+ if b64:
+ img_html = f'
'
+ break
+
+ svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None)
+ text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None)
+ svg_w = int(svg_sc["width_px"]) if svg_sc else 200
+ svg_h = int(svg_sc["height_px"]) if svg_sc else 265
+
+ # 출처 라인을 이미지 아래 캡션으로 분리
+ caption_lines = []
+ content_lines = []
+ for indent, text in st_lines:
+ clean = text.lstrip("• ")
+ if text.startswith("출처:") or clean.startswith("출처:"):
+ caption_lines.append(re.sub(r'^출처:\s*', '', clean))
+ else:
+ content_lines.append((indent, text))
+
+ img_caption = ""
+ if caption_lines:
+ img_caption = f'{caption_lines[0]}
'
+
+ bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords)
+ bk = bold_keywords or []
+
+ keymsg_html = ""
+ if has_keymsg and core_message:
+ keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
+ km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
+ keymsg_html = (
+ f'{_apply_bold(core_message, bk)}
'
+ )
+
+ return (
+ f''
+ f'
'
+ f'{_apply_bold(topic.title, bk)}
'
+ f'
'
+ f'
'
+ f'
{bullets}
'
+ f'
{keymsg_html}
'
+ )
+
+
+def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None):
+ """기타 블록: 제목 + 불릿."""
+ bk = bold_keywords or []
+ bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
+ keymsg_html = ""
+ if has_keymsg and core_message:
+ keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
+ km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
+ keymsg_html = (
+ f'{_apply_bold(core_message, bk)}
'
+ )
+ return (
+ f''
+ f'
{_apply_bold(topic.title, bk)}
'
+ f'{bullets}{keymsg_html}
'
+ )
+
+
+def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
+ """전체 슬라이드를 조립하여 HTML 반환.
+
+ filled, assembled, stage_2 모두 이 함수를 호출.
+ layout_template에 따라 유형 A/B 분기.
+ """
+ if ctx.analysis.layout_template == "B":
+ return _assemble_slide_html_type_b(ctx, title_text)
+ if ctx.analysis.layout_template == "B'":
+ return _assemble_slide_html_type_b_prime(ctx, title_text)
+ return _assemble_slide_html_type_a(ctx, title_text)
+
+
+def _assemble_slide_html_type_a(ctx: "PipelineContext", title_text: str = "") -> str:
+ """유형 A 전체 슬라이드 조립 (기존 코드 그대로)."""
+ from src.fit_verifier import _load_design_tokens
+ tokens = _load_design_tokens()
+ pad = tokens["spacing_page"]
+ header_h = tokens.get("header_height", 66)
+ gap_block = tokens["spacing_block"]
+ gap_small = tokens["spacing_small"]
+
+ ratio = ctx.container_ratio
+ slide_w = tokens.get("slide_width", 1280)
+ slide_h = tokens.get("slide_height", 720)
+ inner_w = slide_w - pad * 2
+ body_w = int(inner_w * ratio[0] / 100)
+ sidebar_w = inner_w - body_w - gap_block
+
+ fit = ctx.fit_result or {}
+ redist = fit.get("redistribution", {})
+
+ all_css = set()
+ role_htmls = {}
+
+ for role in ["배경", "본심", "첨부", "결론"]:
+ html, css = assemble_role_html(role, ctx)
+ role_htmls[role] = html
+ all_css.update(css)
+
+ # 좌표 계산
+ bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px))
+ core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px))
+ sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px))
+ concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px))
+
+ bg_top = pad + header_h + gap_block
+ core_top = bg_top + bg_h + gap_small
+ sb_top = bg_top
+
+ # V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움
+ if redist:
+ ft_top = slide_h - pad - concl_h - gap_block
+ column_bottom = ft_top - gap_block
+ core_h = column_bottom - core_top
+ sb_h = column_bottom - sb_top
+ else:
+ ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block
+
+ title = title_text or ctx.analysis.title or ""
+ css_block = "\n".join(all_css)
+
+ return f"""
+
+
+
{title}
+
+
+배경 ({body_w}x{bg_h}px)
+{role_htmls.get("배경", "")}
+
+
+본심 ({body_w}x{core_h}px)
+{role_htmls.get("본심", "")}
+
+
+
+
+
+
"""
+
+
+def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") -> str:
+ """유형 B 전체 슬라이드 조립: 상단(top+이미지) + 하단 2분할 + 결론.
+
+ assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합.
+ filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성.
+ """
+ from src.fit_verifier import _load_design_tokens
+ tokens = _load_design_tokens()
+ pad = tokens["spacing_page"]
+ header_h = tokens.get("header_height", 66)
+ gap_block = tokens["spacing_block"]
+ gap_small = tokens["spacing_small"]
+ slide_w = tokens.get("slide_width", 1280)
+ slide_h = tokens.get("slide_height", 720)
+ inner_w = slide_w - pad * 2
+
+ ps = ctx.page_structure.roles
+ enh = ctx.enhancement_result or {}
+ bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
+ font_h = ctx.font_hierarchy
+ font_size = font_h.core
+ title = title_text or ctx.analysis.title or ""
+ core_message = ctx.analysis.core_message or ""
+ slide_images = ctx.slide_images or []
+ norm_sections = ctx.normalized.sections or []
+
+ # Kei 에스컬레이션 결정: popup 대상 역할 수집
+ kei_decisions = enh.get("kei_decisions", [])
+ popup_roles = set()
+ for d in kei_decisions:
+ if d.get("action") == "popup":
+ popup_roles.add(d.get("role", ""))
+
+ # ── zone별 역할 분류 ──
+ top_role = None
+ bottom_left_role = None
+ bottom_right_role = None
+ footer_role = None
+
+ for role_name, info in ps.items():
+ if not isinstance(info, dict):
+ continue
+ zone = info.get("zone", "")
+ if zone == "top":
+ top_role = (role_name, info)
+ elif zone == "bottom_left":
+ bottom_left_role = (role_name, info)
+ elif zone == "bottom_right":
+ bottom_right_role = (role_name, info)
+ elif zone == "footer":
+ footer_role = (role_name, info)
+
+ # ── 좌표 계산 (containers에서 동적으로) ──
+ footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
+ footer_h_px = footer_ci.height_px if footer_ci else 53
+ ft_top = slide_h - pad - footer_h_px
+
+ top_ci = ctx.containers.get(top_role[0]) if top_role else None
+ top_h = top_ci.height_px if top_ci else 200
+ top_top = pad + header_h + gap_block
+
+ # 이미지: block_constraints 또는 slide_images에서 판단
+ img_constraints = top_ci.block_constraints if top_ci else {}
+ img_w = img_constraints.get("img_width_px", 0)
+ has_image = img_constraints.get("has_image", False)
+ # block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용
+ if not has_image and slide_images:
+ has_image = any(img.get("b64") for img in slide_images)
+ if has_image and img_w <= 0:
+ # 이미지 폭: top_h * ratio, 최대 45%
+ first_img = next((img for img in slide_images if img.get("b64")), None)
+ if first_img:
+ img_ratio = first_img.get("ratio", 1)
+ img_w = min(int(top_h * img_ratio), int(inner_w * 0.45))
+
+ img_h = 0
+ img_html = ""
+ if has_image and slide_images:
+ for img in slide_images:
+ b64 = img.get("b64", "")
+ if b64:
+ img_ratio = img.get("ratio", 1)
+ img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
+ img_html = f'
'
+ break
+
+ # 하단
+ bottom_top = top_top + top_h + gap_small
+
+ # V'-4: 결론 바로 위까지 채움
+ fit = ctx.fit_result or {}
+ redist = fit.get("redistribution", {})
+ column_bottom = ft_top - gap_block
+ bottom_h = column_bottom - bottom_top
+ bottom_col_w = (inner_w - gap_block) // 2
+
+ # ── 유틸 ──
+ def _bold(text: str, role: str) -> str:
+ for kw in bold_kw.get(role, []):
+ if kw in text:
+ text = text.replace(kw, f"{kw}")
+ return text
+
+ # ── 상단 조립: normalized.sections에서 직접 가져오기 ──
+ top_html = ""
+ if top_role:
+ rn = top_role[0]
+ topic_title_from_section = ""
+ top_contents = []
+ for s in norm_sections:
+ if s.get("level") == 3:
+ break # level=3(소목차) 나오면 상단 끝
+ if not topic_title_from_section and s.get("title"):
+ topic_title_from_section = s["title"]
+ content = s.get("content", "")
+ if content:
+ if s.get("title") and s["title"] != topic_title_from_section:
+ top_contents.append(f"### {s['title']}")
+ top_contents.append(content)
+ all_text = "\n".join(top_contents)
+ all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text)
+
+ # 팝업 분리
+ popup_titles = []
+ content_lines = []
+ for line in all_text_clean.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
+ if popup_match:
+ popup_titles.append(popup_match.group(1))
+ continue
+ if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
+ continue
+ content_lines.append(stripped)
+
+ popup_html = _popup_links_html(popup_titles, font_size)
+
+ # 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
+ sections = []
+ current_section = ("", [])
+ for line in content_lines:
+ if line.startswith("### ") or line.startswith("###"):
+ if current_section[0] or current_section[1]:
+ sections.append(current_section)
+ current_section = (line.lstrip("# ").strip(), [])
+ elif re.match(r'^D1:\s*', line):
+ # D1 = 1단 불릿 = 소제목 (카드 제목)
+ title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
+ if current_section[0] or current_section[1]:
+ sections.append(current_section)
+ current_section = (_bold(title_text, rn), [])
+ elif re.match(r'^D[2-9]:\s*', line):
+ # D2+ = 하위 불릿 = 본문
+ clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
+ if clean.startswith("출처:"):
+ continue
+ current_section[1].append(_bold(clean, rn))
+ else:
+ clean = line.lstrip("• ")
+ if clean.startswith("출처:"):
+ continue
+ current_section[1].append(_bold(clean, rn))
+ if current_section[0] or current_section[1]:
+ sections.append(current_section)
+
+ # 카드형 HTML
+ _card_colors = [
+ ("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
+ ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
+ ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
+ ("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
+ ]
+ card_pad = int(font_size * 0.6)
+ card_gap = max(3, int(font_size * 0.4))
+ indent_body = int(font_size * 1.2)
+
+ bullets = ""
+ if len(sections) > 1 and sections[0][0]:
+ for ci, (sec_title, sec_items) in enumerate(sections):
+ bg, text_color = _card_colors[ci % len(_card_colors)]
+ items_html = "".join(
+ f''
+ f'• {item}
'
+ for item in sec_items
+ )
+ if sec_title:
+ bullets += (
+ f''
+ f'
{_bold(sec_title, rn)}
'
+ f'{items_html}
\n'
+ )
+ else:
+ bullets += items_html
+ else:
+ for _, sec_items in sections:
+ for item in sec_items:
+ bullets += (
+ f''
+ f'• {item}
\n'
+ )
+
+ # 이미지 캡션
+ img_caption = ""
+ norm_images = ctx.normalized.images or []
+ if norm_images:
+ img_caption = norm_images[0].get("alt", "")
+ if not img_caption:
+ for line in all_text.split("\n"):
+ stripped = line.strip().lstrip("• ")
+ if stripped.startswith("출처:"):
+ img_caption = re.sub(r'^출처:\s*', '', stripped)
+ break
+ caption_html = f'{img_caption}
' if img_caption else ""
+
+ # 이미지 블록
+ img_block = ""
+ if has_image and img_html:
+ img_block = (
+ f''
+ f'
{img_html}
'
+ f'{caption_html}
'
+ )
+
+ topic_title = _bold(topic_title_from_section or rn, rn)
+
+ top_html = (
+ f''
+ f'{popup_html}'
+ f'
{topic_title}
'
+ f'
'
+ f'
{bullets}
'
+ f'{img_block}
'
+ )
+
+ # ── 하단: normalized.sections에서 직접 매핑 ──
+ bottom_title = ""
+ sub_sections_from_norm = []
+ found_level3 = False
+ for s in norm_sections:
+ if s.get("level") == 3:
+ found_level3 = True
+ sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
+ # 하단 대목차: level=3 바로 앞의 level=2
+ for s in norm_sections:
+ if s.get("level") == 2:
+ idx = norm_sections.index(s)
+ if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3:
+ bottom_title = s.get("title", "")
+ break
+
+ bl_indent = int(font_size * 1.2)
+
+ # 하단 좌측
+ bl_html = ""
+ if sub_sections_from_norm and bottom_left_role:
+ rn = bottom_left_role[0]
+ sub_title, sub_content = sub_sections_from_norm[0]
+ sub_content = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content)
+
+ bul = ""
+ for line in sub_content.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ # D마커 제거 + depth별 스타일
+ depth = 1
+ dm = re.match(r'^D(\d+):\s*', stripped)
+ if dm:
+ depth = int(dm.group(1))
+ stripped = re.sub(r'^D\d+:\s*', '', stripped)
+ clean = stripped.lstrip("- ").lstrip("• ")
+ clean = _bold(clean, rn)
+ pad = bl_indent * depth
+ fs = font_size if depth == 1 else font_size - 1
+ weight = "font-weight:600;" if depth == 1 else ""
+ bul += f'• {clean}
\n'
+
+ bl_html = (
+ f''
+ f'
{_bold(sub_title, rn)}
'
+ f'
{bul}
'
+ )
+
+ # 하단 우측 + 표 요약
+ br_html = ""
+ if bottom_right_role and len(sub_sections_from_norm) > 1:
+ rn = bottom_right_role[0]
+ sub_title_br, sub_content_br = sub_sections_from_norm[1]
+ sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content_br)
+
+ # 팝업 링크
+ popup_link_title = f"{sub_title_br} 바로가기"
+ popup_html_br = (
+ f''
+ f'[{popup_link_title} →]
'
+ )
+
+ # Kei가 이 역할을 popup 대상으로 결정했으면 → 콘텐츠 대신 팝업 링크만
+ if rn in popup_roles:
+ bul = (
+ f''
+ f'상세 내용은 팝업에서 확인
'
+ )
+ table_summaries = {} # 표도 팝업으로 이동
+ else:
+ # 불릿
+ table_summaries = enh.get("table_summaries", {})
+ bul = ""
+ if not table_summaries:
+ for line in sub_content_br.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ depth = 1
+ dm = re.match(r'^D(\d+):\s*', stripped)
+ if dm:
+ depth = int(dm.group(1))
+ stripped = re.sub(r'^D\d+:\s*', '', stripped)
+ clean = stripped.lstrip("- ").lstrip("• ")
+ if clean:
+ clean = _bold(clean, rn)
+ _pad = bl_indent * depth
+ fs = font_size if depth == 1 else font_size - 1
+ weight = "font-weight:600;" if depth == 1 else ""
+ bul += f'• {clean}
\n'
+
+ # 표 요약 HTML
+ table_html_br = ""
+ for ts_key, ts_data in table_summaries.items():
+ fmt = ts_data.get("format", "text")
+ if fmt == "table":
+ cols = ts_data.get("columns", [])
+ data = ts_data.get("data", [])
+ col_count = len(cols)
+ if col_count > 0 and data:
+ header_cells = "".join(
+ f'{c}
'
+ for c in cols
+ )
+ rows_html = ""
+ for ri, row in enumerate(data):
+ bg = "#f8fafc" if ri % 2 == 0 else "#fff"
+ cells = ""
+ for ci_idx, cell in enumerate(row):
+ c_color = "#1e40af" if ci_idx == 0 else "#475569"
+ c_weight = "600" if ci_idx == 0 else "400"
+ cells += f'{_bold(str(cell), rn)}
'
+ rows_html += f'{cells}
\n'
+ table_html_br = (
+ f''
+ f'
{header_cells}
'
+ f'{rows_html}
'
+ )
+ elif fmt == "bullets":
+ items = ts_data.get("items", [])
+ table_html_br = "".join(
+ f'• {_bold(str(item), rn)}
'
+ for item in items
+ )
+ elif fmt == "text":
+ table_html_br = f'{_bold(str(ts_data.get("summary", "")), rn)}
'
+
+ br_html = (
+ f''
+ f'{popup_html_br}'
+ f'
{_bold(sub_title_br, rn)}
'
+ f'
{bul}
'
+ f'{table_html_br}
'
+ )
+
+ # ── 결론 ──
+ footer_html = ""
+ if footer_role:
+ rn = footer_role[0]
+ footer_html = (
+ f''
+ f'
{_bold(core_message, rn)}
'
+ )
+
+ # ── HTML 조립 ──
+ _color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
+
+ return f"""
+
+
+
+
{title}
+
+
+상단 ({inner_w}x{top_h}px)
+{top_html}
+
+
+
{_bold(bottom_title, "")}
+
+
+{bl_html}
+
+
+{br_html}
+
+
+
+
+
"""
+
+
+def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = "") -> str:
+ """유형 B' 전체 슬라이드 조립: 상단(세로 카드) + 하단 2분할 + 결론. (03번용)
+
+ assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합.
+ filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성.
+ """
+ from src.fit_verifier import _load_design_tokens
+ tokens = _load_design_tokens()
+ pad = tokens["spacing_page"]
+ header_h = tokens.get("header_height", 66)
+ gap_block = tokens["spacing_block"]
+ gap_small = tokens["spacing_small"]
+ slide_w = tokens.get("slide_width", 1280)
+ slide_h = tokens.get("slide_height", 720)
+ inner_w = slide_w - pad * 2
+
+ ps = ctx.page_structure.roles
+ enh = ctx.enhancement_result or {}
+ bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
+ font_h = ctx.font_hierarchy
+ font_size = font_h.core
+ title = title_text or ctx.analysis.title or ""
+ core_message = ctx.analysis.core_message or ""
+ slide_images = ctx.slide_images or []
+ norm_sections = ctx.normalized.sections or []
+
+ # Kei 에스컬레이션 결정: popup 대상 역할 수집
+ kei_decisions = enh.get("kei_decisions", [])
+ popup_roles = set()
+ for d in kei_decisions:
+ if d.get("action") == "popup":
+ popup_roles.add(d.get("role", ""))
+
+ # ── zone별 역할 분류 ──
+ top_role = None
+ bottom_left_role = None
+ bottom_right_role = None
+ footer_role = None
+
+ for role_name, info in ps.items():
+ if not isinstance(info, dict):
+ continue
+ zone = info.get("zone", "")
+ if zone == "top":
+ top_role = (role_name, info)
+ elif zone == "bottom_left":
+ bottom_left_role = (role_name, info)
+ elif zone == "bottom_right":
+ bottom_right_role = (role_name, info)
+ elif zone == "footer":
+ footer_role = (role_name, info)
+
+ # ── 좌표 계산 (containers에서 동적으로) ──
+ footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
+ footer_h_px = footer_ci.height_px if footer_ci else 53
+ ft_top = slide_h - pad - footer_h_px
+
+ top_ci = ctx.containers.get(top_role[0]) if top_role else None
+ top_h = top_ci.height_px if top_ci else 200
+ top_top = pad + header_h + gap_block
+
+ # 이미지: block_constraints 또는 slide_images에서 판단
+ img_constraints = top_ci.block_constraints if top_ci else {}
+ img_w = img_constraints.get("img_width_px", 0)
+ has_image = img_constraints.get("has_image", False)
+ # block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용
+ if not has_image and slide_images:
+ has_image = any(img.get("b64") for img in slide_images)
+ if has_image and img_w <= 0:
+ # 이미지 폭: top_h * ratio, 최대 45%
+ first_img = next((img for img in slide_images if img.get("b64")), None)
+ if first_img:
+ img_ratio = first_img.get("ratio", 1)
+ img_w = min(int(top_h * img_ratio), int(inner_w * 0.45))
+
+ img_h = 0
+ img_html = ""
+ if has_image and slide_images:
+ for img in slide_images:
+ b64 = img.get("b64", "")
+ if b64:
+ img_ratio = img.get("ratio", 1)
+ img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
+ img_html = f'
'
+ break
+
+ # 하단
+ bottom_top = top_top + top_h + gap_small
+
+ # V'-4: 결론 바로 위까지 채움
+ fit = ctx.fit_result or {}
+ redist = fit.get("redistribution", {})
+ column_bottom = ft_top - gap_block
+ bottom_h = column_bottom - bottom_top
+ bottom_col_w = (inner_w - gap_block) // 2
+
+ # ── 유틸 ──
+ def _bold(text: str, role: str) -> str:
+ for kw in bold_kw.get(role, []):
+ if kw in text:
+ text = text.replace(kw, f"{kw}")
+ return text
+
+ # ── 상단 조립: normalized.sections에서 직접 가져오기 ──
+ top_html = ""
+ if top_role:
+ rn = top_role[0]
+ topic_title_from_section = ""
+ top_contents = []
+ for s in norm_sections:
+ if s.get("level") == 3:
+ break # level=3(소목차) 나오면 상단 끝
+ if not topic_title_from_section and s.get("title"):
+ topic_title_from_section = s["title"]
+ content = s.get("content", "")
+ if content:
+ if s.get("title") and s["title"] != topic_title_from_section:
+ top_contents.append(f"### {s['title']}")
+ top_contents.append(content)
+ all_text = "\n".join(top_contents)
+ all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text)
+
+ # 팝업 분리
+ popup_titles = []
+ content_lines = []
+ for line in all_text_clean.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
+ if popup_match:
+ popup_titles.append(popup_match.group(1))
+ continue
+ if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
+ continue
+ content_lines.append(stripped)
+
+ popup_html = _popup_links_html(popup_titles, font_size)
+
+ # 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
+ sections = []
+ current_section = ("", [])
+ for line in content_lines:
+ if line.startswith("### ") or line.startswith("###"):
+ if current_section[0] or current_section[1]:
+ sections.append(current_section)
+ current_section = (line.lstrip("# ").strip(), [])
+ elif re.match(r'^D1:\s*', line):
+ # D1 = 1단 불릿 = 소제목 (카드 제목)
+ title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
+ if current_section[0] or current_section[1]:
+ sections.append(current_section)
+ current_section = (_bold(title_text, rn), [])
+ elif re.match(r'^D[2-9]:\s*', line):
+ # D2+ = 하위 불릿 = 본문
+ clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
+ if clean.startswith("출처:"):
+ continue
+ current_section[1].append(_bold(clean, rn))
+ else:
+ clean = line.lstrip("• ")
+ if clean.startswith("출처:"):
+ continue
+ current_section[1].append(_bold(clean, rn))
+ if current_section[0] or current_section[1]:
+ sections.append(current_section)
+
+ # 카드형 HTML
+ _card_colors = [
+ ("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
+ ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
+ ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
+ ("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
+ ]
+ card_pad = int(font_size * 0.6)
+ card_gap = max(3, int(font_size * 0.4))
+ indent_body = int(font_size * 1.2)
+
+ # B': 상단이 popup 대상이면 소제목만 유지, 하위 불릿 제거
+ top_is_popup = rn in popup_roles
+
+ bullets = ""
+ if len(sections) > 1 and sections[0][0]:
+ for ci, (sec_title, sec_items) in enumerate(sections):
+ bg, text_color = _card_colors[ci % len(_card_colors)]
+ if top_is_popup:
+ items_html = ""
+ else:
+ items_html = "".join(
+ f''
+ f'• {item}
'
+ for item in sec_items
+ )
+ if sec_title:
+ bullets += (
+ f''
+ f'
{_bold(sec_title, rn)}
'
+ f'{items_html}
\n'
+ )
+ else:
+ bullets += items_html
+ else:
+ for _, sec_items in sections:
+ for item in sec_items:
+ bullets += (
+ f''
+ f'• {item}
\n'
+ )
+
+ # 이미지 캡션
+ img_caption = ""
+ norm_images = ctx.normalized.images or []
+ if norm_images:
+ img_caption = norm_images[0].get("alt", "")
+ if not img_caption:
+ for line in all_text.split("\n"):
+ stripped = line.strip().lstrip("• ")
+ if stripped.startswith("출처:"):
+ img_caption = re.sub(r'^출처:\s*', '', stripped)
+ break
+ caption_html = f'{img_caption}
' if img_caption else ""
+
+ # 이미지 블록
+ img_block = ""
+ if has_image and img_html:
+ img_block = (
+ f''
+ f'
{img_html}
'
+ f'{caption_html}
'
+ )
+
+ topic_title = _bold(topic_title_from_section or rn, rn)
+
+ top_html = (
+ f''
+ f'{popup_html}'
+ f'
{topic_title}
'
+ f'
'
+ f'
{bullets}
'
+ f'{img_block}
'
+ )
+
+ # ── 하단: normalized.sections에서 직접 매핑 ──
+ bottom_title = ""
+ sub_sections_from_norm = []
+ found_level3 = False
+ for s in norm_sections:
+ if s.get("level") == 3:
+ found_level3 = True
+ sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
+ # 하단 대목차: level=3 바로 앞의 level=2
+ for s in norm_sections:
+ if s.get("level") == 2:
+ idx = norm_sections.index(s)
+ if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3:
+ bottom_title = s.get("title", "")
+ break
+
+ bl_indent = int(font_size * 1.2)
+
+ # 하단 좌측 — B': normalized.tables가 있으면 표로 렌더링
+ norm_tables = ctx.normalized.tables or []
+ bl_html = ""
+ if sub_sections_from_norm and bottom_left_role:
+ rn = bottom_left_role[0]
+ sub_title, sub_content = sub_sections_from_norm[0]
+ sub_content = re.sub(r'\*\*(.+?)\*\*', r'', sub_content)
+
+ # 표 렌더링 (normalized.tables에서)
+ table_html_bl = ""
+ if norm_tables:
+ for table_data in norm_tables:
+ headers = table_data.get("headers", [])
+ rows = table_data.get("rows", [])
+ col_count = len(headers)
+ if col_count > 0 and rows:
+ header_cells = "".join(
+ f'{c}
'
+ for c in headers
+ )
+ rows_html = ""
+ for ri, row in enumerate(rows):
+ bg = "#f8fafc" if ri % 2 == 0 else "#fff"
+ cells = ""
+ for ci_idx, cell in enumerate(row):
+ cell_clean = re.sub(r'\*\*(.+?)\*\*', r'', str(cell))
+ c_color = "#1e40af" if ci_idx == 0 else "#475569"
+ c_weight = "600" if ci_idx == 0 else "400"
+ cells += f'{cell_clean}
'
+ rows_html += f'{cells}
\n'
+
+ table_html_bl = (
+ f''
+ f'
{header_cells}
'
+ f'{rows_html}
'
+ )
+
+ # 불릿: 표 셀과 중복되는 텍스트 제외
+ table_cell_texts = set()
+ for td in norm_tables:
+ for h in td.get("headers", []):
+ table_cell_texts.add(h.strip().lstrip("*").rstrip("*"))
+ for row in td.get("rows", []):
+ for cell in row:
+ table_cell_texts.add(str(cell).strip().lstrip("*").rstrip("*"))
+
+ bul = ""
+ for line in sub_content.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ depth = 1
+ dm = re.match(r'^D(\d+):\s*', stripped)
+ if dm:
+ depth = int(dm.group(1))
+ stripped = re.sub(r'^D\d+:\s*', '', stripped)
+ clean = stripped.lstrip("- ").lstrip("• ")
+ clean_plain = re.sub(r'<[^>]+>', '', clean).strip()
+ if clean_plain in table_cell_texts or clean_plain == "➠":
+ continue
+ if clean:
+ clean = _bold(clean, rn)
+ _pad = bl_indent * depth
+ fs = font_size if depth == 1 else font_size - 1
+ weight = "font-weight:600;" if depth == 1 else ""
+ bul += f'• {clean}
\n'
+
+ bl_html = (
+ f''
+ f'
{_bold(sub_title, rn)}
'
+ f'{table_html_bl}'
+ f'
{bul}
'
+ )
+
+ # 하단 우측 — B': 불릿만 (table_summaries 사용 안 함)
+ br_html = ""
+ if bottom_right_role and len(sub_sections_from_norm) > 1:
+ rn = bottom_right_role[0]
+ sub_title_br, sub_content_br = sub_sections_from_norm[1]
+ sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'', sub_content_br)
+
+ bul = ""
+ for line in sub_content_br.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ depth = 1
+ dm = re.match(r'^D(\d+):\s*', stripped)
+ if dm:
+ depth = int(dm.group(1))
+ stripped = re.sub(r'^D\d+:\s*', '', stripped)
+ clean = stripped.lstrip("- ").lstrip("• ")
+ if clean:
+ clean = _bold(clean, rn)
+ _pad = bl_indent * depth
+ fs = font_size if depth == 1 else font_size - 1
+ weight = "font-weight:600;" if depth == 1 else ""
+ bul += f'• {clean}
\n'
+
+ br_html = (
+ f''
+ f'
{_bold(sub_title_br, rn)}
'
+ f'
{bul}
'
+ )
+
+
+ # ── 결론 ──
+ footer_html = ""
+ if footer_role:
+ rn = footer_role[0]
+ footer_html = (
+ f''
+ f'
{_bold(core_message, rn)}
'
+ )
+
+ # ── HTML 조립 ──
+ _color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
+
+ return f"""
+
+
+
+
{title}
+
+
+상단 ({inner_w}x{top_h}px)
+{top_html}
+
+
+
{_bold(bottom_title, "")}
+
+
+{bl_html}
+
+
+{br_html}
+
+
+
+
+
"""
diff --git a/src/kei_client.py b/src/kei_client.py
index ff860b2..e08a090 100644
--- a/src/kei_client.py
+++ b/src/kei_client.py
@@ -1358,17 +1358,21 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
## 핵심 원칙
- **텍스트 원문은 절대 수정/삭제/요약하지 않는다.**
-- 공간이 부족하면 **팝업으로 분리**하여 원문 전체를 팝업에 넣는다.
-- 슬라이드에는 제목 + "바로가기 →" 링크만 남긴다.
-- 중요도가 높은 영역의 공간을 우선 확보한다.
+- 공간이 부족하면 **하위 불릿(상세 설명)만 팝업으로 분리.**
+- **소제목(카드 제목)은 반드시 슬라이드에 유지.** 절대 팝업으로 빼지 않는다.
+- 슬라이드에는 소제목 + "바로가기 →" 링크. 팝업에 하위 불릿 원문 전체.
+- overflow가 없는 영역은 건드리지 않는다.
## 판단 기준
-- 넘치는 영역 중 중요도가 낮은 콘텐츠를 팝업으로 분리
-- 표 데이터가 큰 경우 → 팝업 분리 1순위
-- 이미 팝업이 있는 콘텐츠 → 슬라이드에서 제거하고 팝업으로 통합
+- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
+- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
+- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
+- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
+- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
## 출력 (JSON만. 설명 없이.)
- role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라.
+- overflow가 발생한 역할만 포함. overflow 없는 역할은 포함하지 마라.
```json
{
@@ -1376,7 +1380,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
{
"role": "역할 목록에 있는 정확한 역할명",
"action": "popup",
- "detail": "팝업으로 분리할 구체적 내용 (어떤 부분을 팝업으로 빼는지)",
+ "detail": "팝업으로 분리할 구체적 내용 (하위 불릿만. 소제목은 유지)",
"reason": "판단 근거 1문장"
}
]
@@ -1384,7 +1388,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
```
action 종류:
-- popup: 상세 내용을 팝업으로 분리하고 슬라이드에는 링크만 남김
+- popup: 하위 불릿(상세 설명)을 팝업으로 분리. 소제목은 슬라이드에 유지.
"""