diff --git a/src/block_assembler.py b/src/block_assembler.py
index da69a33..8a7dcb1 100644
--- a/src/block_assembler.py
+++ b/src/block_assembler.py
@@ -590,6 +590,8 @@ def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") ->
continue
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
continue
+ if re.search(r'\[핵심요약:', stripped):
+ continue
content_lines.append(stripped)
popup_html = _popup_links_html(popup_titles, font_size)
@@ -1012,11 +1014,17 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str =
continue
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
continue
+ if re.search(r'\[핵심요약:', stripped):
+ continue
content_lines.append(stripped)
popup_html = _popup_links_html(popup_titles, font_size)
- # 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
+ # B': ### (section title) = 카드 제목. D1/D2는 카드 내부 불릿.
+ # ###이 있으면 카드는 section 단위. D1은 카드 안의 bold 불릿.
+ # ###이 없으면 D1이 카드 제목 (02번 방식).
+ has_section_titles = any(line.startswith("### ") for line in content_lines)
+
sections = []
current_section = ("", [])
for line in content_lines:
@@ -1025,11 +1033,15 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str =
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), [])
+ if has_section_titles:
+ # ### 카드 안의 bold 불릿
+ current_section[1].append(f'{_bold(title_text, rn)}')
+ else:
+ # 02번 방식: D1이 카드 제목
+ 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("• ")
@@ -1055,17 +1067,13 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str =
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
+ # B': 상단은 핵심이므로 항상 불릿 표시 (팝업 대상 아님)
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(
+ items_html = "".join(
f'
'
+ f'
'
f'
{_bold(sub_title, rn)}
'
f'{table_html_bl}'
f'
{bul}
'
@@ -1232,6 +1255,8 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str =
depth = int(dm.group(1))
stripped = re.sub(r'^D\d+:\s*', '', stripped)
clean = stripped.lstrip("- ").lstrip("• ")
+ if re.search(r'\[핵심요약:', clean):
+ continue
if clean:
clean = _bold(clean, rn)
_pad = bl_indent * depth
diff --git a/src/block_assembler_fixed.py b/src/block_assembler_fixed.py
deleted file mode 100644
index fa7eb6f..0000000
--- a/src/block_assembler_fixed.py
+++ /dev/null
@@ -1,1291 +0,0 @@
-"""블록 조립 공통 모듈.
-
-filled, assembled, Stage 2 모두 이 모듈의 함수를 사용.
-조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨.
-
-입력: PipelineContext (또는 동등한 dict)
-출력: 역할별 HTML dict + 슬라이드 전체 HTML
-
-하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로.
-"""
-from __future__ import annotations
-
-import re
-import logging
-from typing import Any, TYPE_CHECKING
-
-if TYPE_CHECKING:
- from src.pipeline_context import PipelineContext
-
-logger = logging.getLogger(__name__)
-
-COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
-FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
-
-
-def assemble_role_html(
- role: str,
- ctx: "PipelineContext",
-) -> tuple[str, set[str]]:
- """하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립.
-
- Returns:
- (조립된 HTML, 사용된 CSS set)
- """
- ps = ctx.page_structure.roles
- info = ps.get(role, {})
- if not isinstance(info, dict):
- return "", set()
- tids = info.get("topic_ids", [])
- if not tids:
- return "", set()
-
- topic_map = {t.id: t for t in ctx.topics}
- ref_list = ctx.references.get(role, [])
- if not ref_list:
- return "", set()
-
- r0 = ref_list[0]
- primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
- primary_topic = topic_map.get(primary_tid)
- if not primary_topic:
- return "", set()
-
- font_key = FONT_MAP.get(role, "core")
- font_size = getattr(ctx.font_hierarchy, font_key, 12)
- sub_layouts = ctx.sub_layouts or {}
- role_sub = sub_layouts.get(role, {})
- role_scs = role_sub.get("sub_containers", [])
-
- # #10: V-10 bold 키워드
- enh = ctx.enhancement_result or {}
- bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
- role_bold = bold_kw.get(role, [])
-
- # ── 블록 디자인 HTML에서 CSS 추출 ──
- ref_html = r0.design_reference_html or ""
- css_parts = re.findall(r'', ref_html, re.DOTALL)
- block_body = re.sub(r'', '', ref_html, flags=re.DOTALL)
- block_body = re.sub(r'', '', block_body, flags=re.DOTALL).strip()
-
- # CSS font-size override (font_hierarchy 기준)
- overridden_css = set()
- for css in css_parts:
- def _override_font(m):
- val = float(m.group(1))
- if val > font_size + 2:
- return f"font-size: {font_size + 1}px"
- elif val > font_size:
- return f"font-size: {font_size}px"
- return m.group(0)
- oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css)
- # gap, padding, number size도 font_size 비례
- oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc)
- oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
- f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', oc)
- oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc)
- oc = oc.replace('white-space: pre-line', 'white-space: normal')
- overridden_css.add(oc)
-
- # ── structured_text 파싱 (들여쓰기 보존) ──
- st = primary_topic.structured_text or primary_topic.source_data or ""
- st_lines, popup_titles = _parse_structured_text(st, font_size)
-
- # ── sub_layouts 기반 판단 ──
- has_svg = any(sc.get("name") == "svg" for sc in role_scs)
- has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs)
-
- # #11: V-9 강조 블록
- emphasis_blocks = enh.get("emphasis_blocks", [])
- role_emphasis = ""
- for eb in emphasis_blocks:
- if eb.get("role") == role:
- role_emphasis = eb.get("sentence", "")
- break
-
- # #12: V-7 종속꼭지 텍스트
- 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 []
- sub_topics_text = []
- if is_hier and sup_tids:
- for st_id in sup_tids:
- st_topic = topic_map.get(st_id)
- if st_topic:
- st_text = st_topic.structured_text or st_topic.source_data or ""
- sub_topics_text.append(st_text[:120])
-
- # ── 블록 구조별 조립 ──
- if "block-callout-warn" in block_body or "block-callout-sol" in block_body:
- inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text)
- elif "block-card-num" in block_body:
- inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold)
- elif "block-banner-grad" in block_body:
- inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title)
- elif has_svg:
- # 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용
- # slide_images에 실제 이미지가 있는지 확인
- has_real_image = any(
- img.get("b64") or img.get("path", "").strip()
- for img in (ctx.slide_images or [])
- )
- if has_real_image:
- inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold)
- else:
- inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
- else:
- inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
-
- # V'-1: 팝업 링크를 컨테이너 우측상단에 배치
- popup_html = _popup_links_html(popup_titles, font_size)
- if popup_html:
- inner = f'
{popup_html}{inner}
'
-
- return inner, overridden_css
-
-
-def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]:
- """structured_text → ([(indent, text)], [팝업 제목 리스트]).
- [팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** →
."""
- lines = []
- popup_titles = []
- for raw_line in st.split("\n"):
- stripped = raw_line.strip()
- if not stripped:
- continue
- indent = 1 if raw_line.startswith(" ") else 0
-
- # 마커 처리 (bold 변환 전)
- popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
- if popup_match:
- popup_titles.append(popup_match.group(1))
- continue
- if re.search(r'\[이미지:', stripped):
- continue
-
- # 마크다운 bold → HTML (마커 처리 후)
- stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped)
- lines.append((indent, stripped))
- return lines, popup_titles
-
-
-def _apply_bold(text: str, keywords: list[str]) -> str:
- """V-10 bold 키워드를 으로 감쌈."""
- 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 e08a090..28fa0b1 100644
--- a/src/kei_client.py
+++ b/src/kei_client.py
@@ -1364,10 +1364,10 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
- overflow가 없는 영역은 건드리지 않는다.
## 판단 기준
-- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
-- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
+- **상단(top zone)은 핵심 내용이므로 팝업 대상에서 제외.** 상단이 넘치면 하단에서 공간을 확보.
+- 하단(bottom zone)에서 중요도가 낮은 콘텐츠를 팝업으로 분리.
+- 표 데이터가 큰 경우 → 팝업 분리 1순위.
- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
-- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
## 출력 (JSON만. 설명 없이.)
diff --git a/src/pipeline.py b/src/pipeline.py
index f10087a..b6a0aa8 100644
--- a/src/pipeline.py
+++ b/src/pipeline.py
@@ -588,14 +588,40 @@ async def generate_slide(
)
fit_analysis = redistribute(fit_analysis, containers_dict)
- # Type B: zone 간 재배분
+ # Type B: Selenium 실측 기반 zone 간 재배분
if context.analysis.layout_template in ("B", "B'"):
- deficit_roles = [(r, rf.shortfall_px) for r, rf in fit_analysis.roles.items() if rf.shortfall_px > 0]
- surplus_roles = [(r, abs(rf.shortfall_px)) for r, rf in fit_analysis.roles.items() if rf.shortfall_px < -8]
+ # Selenium 측정에서 실제 overflow/여유를 가져옴
+ zone_to_roles = {}
+ for role, ci in updated_containers.items():
+ # Selenium CSS 클래스 매핑: bottom_left/bottom_right → bottom
+ z = ci.zone
+ if z in ("bottom_left", "bottom_right"):
+ z = "bottom"
+ if z not in zone_to_roles:
+ zone_to_roles[z] = []
+ zone_to_roles[z].append(role)
+
+ deficit_roles = []
+ surplus_roles = []
+ for zn, zd in filled_measurement.get("zones", {}).items():
+ excess = zd.get("excess_px", 0)
+ scroll_h = zd.get("scrollHeight", 0)
+ roles_in_zone = zone_to_roles.get(zn, [])
+ if excess > 0:
+ for r in roles_in_zone:
+ deficit_roles.append((r, float(excess)))
+ elif roles_in_zone:
+ # 실제 콘텐츠(scrollHeight)와 할당 높이 차이로 여유 계산
+ allocated = sum(updated_containers[r].height_px for r in roles_in_zone if r in updated_containers)
+ slack = allocated - scroll_h
+ if slack > 8:
+ for r in roles_in_zone:
+ surplus_roles.append((r, float(slack)))
if deficit_roles and surplus_roles:
total_deficit = sum(d for _, d in deficit_roles)
total_surplus = sum(s for _, s in surplus_roles)
- transferable = min(total_deficit, total_surplus)
+ # surplus의 최대 50%만 이전 — 하단 최소 공간 보장
+ transferable = min(total_deficit, total_surplus * 0.5)
if transferable > 0:
for role, deficit in deficit_roles:
share = transferable * (deficit / total_deficit)