03번 B' 정상 동작: 가로 3단 카드 + overflow 해소 + 하단 균형

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:32:14 +09:00
parent bc7c08e575
commit b13df8b176
4 changed files with 79 additions and 1319 deletions

View File

@@ -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,8 +1033,12 @@ 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 has_section_titles:
# ### 카드 안의 bold 불릿
current_section[1].append(f'<strong>{_bold(title_text, rn)}</strong>')
else:
# 02번 방식: D1이 카드 제목
if current_section[0] or current_section[1]:
sections.append(current_section)
current_section = (_bold(title_text, rn), [])
@@ -1055,16 +1067,12 @@ 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(
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
@@ -1112,6 +1120,19 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str =
topic_title = _bold(topic_title_from_section or rn, rn)
# 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'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
f'display:flex;flex-direction:column;">'
f'{popup_html}'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
f'<div style="display:flex;flex-direction:row;gap:{card_gap}px;flex:1;">{bullets}</div></div>'
)
else:
top_html = (
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
f'display:flex;flex-direction:column;justify-content:space-between;">'
@@ -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'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
bl_html = (
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;overflow-y:auto;">'
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;overflow:hidden;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title, rn)}</div>'
f'{table_html_bl}'
f'<div style="line-height:1.55;color:#333;">{bul}</div></div>'
@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1364,10 +1364,10 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
- overflow가 없는 영역은 건드리지 않는다.
## 판단 기준
- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
- **상단(top zone)은 핵심 내용이므로 팝업 대상에서 제외.** 상단이 넘치면 하단에서 공간을 확보.
- 하단(bottom zone)에서 중요도가 낮은 콘텐츠를 팝업으로 분리.
- 표 데이터가 큰 경우 → 팝업 분리 1순위.
- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
## 출력 (JSON만. 설명 없이.)

View File

@@ -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)