From b13df8b1769cf869894ce53f72712d0ddf12ffed Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Tue, 7 Apr 2026 10:32:14 +0900 Subject: [PATCH] =?UTF-8?q?03=EB=B2=88=20B'=20=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91:=20=EA=B0=80=EB=A1=9C=203=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20+=20overflow=20=ED=95=B4=EC=86=8C=20+=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EA=B7=A0=ED=98=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - block_assembler B': 카드 3개 이상 + 이미지 없음 → 가로(row) 배치 - block_assembler B': section title이 카드 제목, D1은 카드 내 bold 불릿 - block_assembler: overflow:auto → overflow:hidden, [핵심요약:] 마커 필터 - block_assembler: \x01 바이트 수정 - pipeline: Selenium 실측 기반 zone 간 재배분 (allocated-scrollHeight로 slack 계산) - pipeline: surplus 최대 50%만 이전 (하단 최소 공간 보장) - pipeline: bottom_left/bottom_right → Selenium bottom zone 매핑 - kei_client: 상단은 팝업 대상 제외, 하단에서만 팝업 분리 결과: 02번/03번 모두 overflow 없이 정상 출력 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/block_assembler.py | 67 +- src/block_assembler_fixed.py | 1291 ---------------------------------- src/kei_client.py | 6 +- src/pipeline.py | 34 +- 4 files changed, 79 insertions(+), 1319 deletions(-) delete mode 100644 src/block_assembler_fixed.py 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'• {item}
' for item in sec_items @@ -1112,15 +1120,28 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = 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}
' - ) + # B': 카드 3개 이상 + 이미지 없음 → 가로 배치 + card_count = len(sections) if len(sections) > 1 and sections[0][0] else 0 + use_row = card_count >= 3 and not (has_image and img_html) + + if use_row: + top_html = ( + f'
' + f'{popup_html}' + f'
{topic_title}
' + f'
{bullets}
' + ) + else: + top_html = ( + f'
' + f'{popup_html}' + f'
{topic_title}
' + f'
' + f'
{bullets}
' + f'{img_block}
' + ) # ── 하단: normalized.sections에서 직접 매핑 ── bottom_title = "" @@ -1200,6 +1221,8 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = clean_plain = re.sub(r'<[^>]+>', '', clean).strip() if clean_plain in table_cell_texts or clean_plain == "➠": continue + if re.search(r'\[핵심요약:', clean): + continue if clean: clean = _bold(clean, rn) _pad = bl_indent * depth @@ -1208,7 +1231,7 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = bul += f'
• {clean}
\n' bl_html = ( - 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'
{img_html}
{img_caption}
' - 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("본심", "")}
- -
-첨부 ({sidebar_w}x{sb_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)