"""블록 조립 공통 모듈. 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) 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}
"""