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:
@@ -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'<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), [])
|
||||
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'<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>'
|
||||
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'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
||||
f'display:flex;flex-direction:column;justify-content:space-between;">'
|
||||
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;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
||||
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||
f'{img_block}</div></div>'
|
||||
)
|
||||
# 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;">'
|
||||
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;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
||||
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||
f'{img_block}</div></div>'
|
||||
)
|
||||
|
||||
# ── 하단: 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'<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
@@ -1364,10 +1364,10 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
||||
- overflow가 없는 영역은 건드리지 않는다.
|
||||
|
||||
## 판단 기준
|
||||
- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
|
||||
- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
|
||||
- **상단(top zone)은 핵심 내용이므로 팝업 대상에서 제외.** 상단이 넘치면 하단에서 공간을 확보.
|
||||
- 하단(bottom zone)에서 중요도가 낮은 콘텐츠를 팝업으로 분리.
|
||||
- 표 데이터가 큰 경우 → 팝업 분리 1순위.
|
||||
- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
|
||||
- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
|
||||
- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
|
||||
|
||||
## 출력 (JSON만. 설명 없이.)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user