"""블록 조립 공통 모듈. 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 render_block_for_role(role: str, ctx: "PipelineContext") -> tuple[str, str]: """block_id → 템플릿 로드 → 슬롯에 콘텐츠 채우기 → (html, css) 반환. Stage 1.7에서 선택된 block_id를 실제로 사용하여 렌더링. 매칭 실패 시 빈 문자열 반환 (fallback은 호출측에서). """ from pathlib import Path import yaml refs = ctx.references.get(role, []) if not refs: return "", "" block_id = refs[0].block_id if not block_id: return "", "" # catalog.yaml에서 블록 정보 로드 catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" try: catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) except Exception: logger.warning(f"[assembler] catalog.yaml 로드 실패") return "", "" blocks = catalog.get("blocks", catalog) if isinstance(catalog, dict) else catalog if not isinstance(blocks, list): return "", "" entry = next((b for b in blocks if b.get("id") == block_id), None) if not entry: logger.warning(f"[assembler] block_id={block_id} catalog에 없음") return "", "" template_path = entry.get("template", "") if not template_path: return "", "" # Jinja2 렌더링 from jinja2 import Environment, FileSystemLoader templates_dir = Path(__file__).parent.parent / "templates" env = Environment(loader=FileSystemLoader(str(templates_dir))) try: template = env.get_template(template_path) except Exception as e: logger.warning(f"[assembler] 템플릿 로드 실패: {template_path} — {e}") return "", "" # 역할에 배정된 topic들의 structured_text → 슬롯 데이터 구성 ps_info = ctx.page_structure.roles.get(role, {}) topic_ids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else [] topic_map = {t.id: t for t in ctx.topics} slot_data = _build_slot_data(block_id, entry, topic_ids, topic_map, ctx) # Y-11h: payload contract 검증 contract_errors = _validate_payload_contract(block_id, slot_data, ctx) if contract_errors: for err in contract_errors: logger.warning(f"[payload contract] {block_id}: {err}") try: rendered = template.render(**slot_data) except Exception as e: logger.warning(f"[assembler] 렌더링 실패: {block_id} — {e}") return "", "" # CSS 추출 css_parts = re.findall(r'', rendered, re.DOTALL) css = "\n".join(css_parts) html = re.sub(r'', '', rendered, flags=re.DOTALL).strip() logger.info(f"[assembler] {role} → {block_id} 블록 렌더링 성공 ({len(html)} chars)") return html, css def _build_slot_data( block_id: str, entry: dict, topic_ids: list, topic_map: dict, ctx: "PipelineContext", ) -> dict: """블록 스키마에 맞게 데이터를 슬롯으로 변환. Phase Y: slot 구성 = normalized.sections의 sub_titles 기반. Kei topic 수에 의존하지 않음. sub_title 1개 = column/card 1개 (slot 1개). """ # page_structure에서 이 role의 sub_titles 가져오기 ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {} # role_name 찾기: topic_ids가 매칭되는 role role_sub_titles = [] for role_name, info in ps.items(): if isinstance(info, dict) and info.get("topic_ids") == list(topic_ids): role_sub_titles = info.get("sub_titles", []) break # mdx_sections에서 각 sub_title의 content 가져오기 mdx_sections = ctx.mdx_sections if hasattr(ctx, 'mdx_sections') else [] norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] if norm_sections and hasattr(norm_sections[0], 'model_dump'): norm_sections = [s.model_dump() if hasattr(s, 'model_dump') else dict(s) for s in norm_sections] def _find_sub_content(sub_title: str) -> str: """normalized.sections에서 sub_title에 해당하는 content를 찾음. 결론/핵심요약 텍스트는 제외 (footer 전용). 매칭 순서: 1. 섹션 title 직접 매칭 (MDX 03 방식: 각 sub_title이 별도 섹션) 2. D1: 항목 내 매칭 (MDX 02 방식: 하나의 섹션 안에 D1: 항목들) """ sub_key = sub_title.split("(")[0].strip().lower() # 결론 텍스트 (footer에만 가야 함) conclusion = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else "" # 1차: 섹션 title 매칭 for sec in norm_sections: sec_title = sec.get("title", "").lower() if sub_key and len(sub_key) >= 2 and sub_key in sec_title: content = sec.get("content", "") if conclusion and conclusion in content: content = content.replace(conclusion, "").strip() content = re.sub(r'\[핵심요약:[^\]]*\]', '', content).strip() return content # 2차: D1: 항목 내 매칭 — sub_title이 D1: 항목명인 경우 # 하나의 섹션 content 안에 여러 D1: 항목이 있을 때 if sub_key and len(sub_key) >= 2: for sec in norm_sections: content = sec.get("content", "") if not content: continue # D1: 항목으로 분할 lines = content.split("\n") capturing = False captured = [] for line in lines: d1_match = re.match(r'^D1:\s*(.*)', line.strip()) if d1_match: d1_text = re.sub(r'\*+', '', d1_match.group(1)).strip().lower() if capturing: break # 다음 D1을 만나면 캡처 종료 if sub_key in d1_text: capturing = True captured.append(line.strip()) elif capturing: stripped = line.strip() if stripped and not stripped.startswith("!["): captured.append(stripped) if captured: result = "\n".join(captured) if conclusion and conclusion in result: result = result.replace(conclusion, "").strip() result = re.sub(r'\[핵심요약:[^\]]*\]', '', result).strip() logger.info(f"[_find_sub_content] D1 fallback 매칭: '{sub_title}' → sec '{sec.get('title','')}' ({len(captured)}줄)") return result logger.warning(f"[_find_sub_content] 매칭 실패: '{sub_title}' (섹션 title, D1 항목 모두 불일치)") return "" # slot 구성: sub_titles 기반 (Kei topic 수와 무관) slot_sources = role_sub_titles if role_sub_titles else [t.title for t in (topic_map.get(tid) for tid in topic_ids) if t] # Y-14: popup 대상 감지 (normalized.popups에 is_component가 있는 것) # popup_id 기반 판단 (추측 로직 제거, PopupItem이 source of truth) popup_ids_by_title = {} if hasattr(ctx, 'normalized') and ctx.normalized.popups: for p in ctx.normalized.popups: pid = p.popup_id if hasattr(p, 'popup_id') else "" ptitle = p.title if hasattr(p, 'title') else "" if pid: popup_ids_by_title[ptitle] = pid topic_slots = [] for slot_title in slot_sources: content = _find_sub_content(slot_title) if not content.strip(): # content 비어있음 → popup_id로 확인 slot_key = slot_title.split("(")[0].strip() matched_popup_id = "" for pt, pid in popup_ids_by_title.items(): if slot_key in pt: matched_popup_id = pid break if matched_popup_id: items = [{"heading": f"{slot_title}", "desc": "", "bullets": ["자세한 내용은 첨부 자료를 참조하세요."]}] else: items = [{"heading": f"{slot_title}", "desc": "", "bullets": []}] else: items = _parse_topic_to_items(content) name = slot_title.split("(")[0].strip() if slot_title else "" sub = slot_title.split("(")[1].rstrip(")").strip() if "(" in slot_title else "" topic_slots.append({ "name": name, "sub": sub, "title": slot_title, "items": items, "raw_text": content, }) # columns[] 구성 — 색상은 블록 템플릿 CSS가 :nth-child로 자체 관리 columns = [] for i, ts in enumerate(topic_slots): columns.append({ "name": ts["name"], "sub": ts["sub"], "entries": ts["items"], "items": ts["items"], }) # cards[] 구성 (card-text-grid, card-icon-desc 등) # card-icon-desc 템플릿: card.title, card.description, card.icon cards = [] for ts in topic_slots: bullets = [item["desc"] for item in ts["items"] if item["desc"]] # description: items의 desc/bullets를 합쳐서 구성 desc_parts = [] for item in ts["items"]: if item.get("bullets"): desc_parts.extend(item["bullets"]) elif item.get("desc"): desc_parts.append(item["desc"]) description = "\n".join(f"• {d}" for d in desc_parts) if desc_parts else "" cards.append({ "title": ts["name"] or ts["title"], "description": description, "content": "\n".join(f"• {item['heading']}" for item in ts["items"]), "bullets": bullets, "items": ts["items"], }) # 범용 데이터 data = { "title": topic_slots[0]["title"] if topic_slots else "", "columns": columns, "cards": cards, "items": [item for ts in topic_slots for item in ts["items"]], } # 2분할 블록 payload (pp2 / cdg 공용) if len(topic_slots) == 2: left_slot = topic_slots[0] right_slot = topic_slots[1] # pp2 payload: left_title, right_title, left_compare, left_sections, right_sections data["left_title"] = left_slot["title"] data["right_title"] = right_slot["title"] # left_compare: 첫 번째 항목이 표 구조(As-is→To-be)인 경우 # D1 only 평탄화된 표를 복원: 연속 heading 3개씩 = As-is 3개 → To-be 3개 left_items = left_slot["items"] left_compare = None left_sections = [] # 표 구조 감지: normalized.tables에서 가져오기 tables = ctx.normalized.tables or [] if tables and len(tables) > 0: table = tables[0] headers = table.get("headers", []) rows = table.get("rows", []) if len(headers) >= 2 and rows: # 표의 첫 열 = left_items (As-is), 마지막 열 = right_items (To-be) compare_title = left_items[0]["heading"] if left_items else "" left_compare = { "title": compare_title, "left_items": [re.sub(r'\*+', '', str(row[0])).strip() for row in rows if len(row) > 0], "right_items": [re.sub(r'\*+', '', str(row[-1])).strip() for row in rows if len(row) > 1], } # Y-12b: compare에 사용된 모든 텍스트를 수집 → left_sections에서 제외 compare_used = set() # compare 제목 compare_used.add(compare_title.lower().strip()) # 표 헤더 (As-is [Analogue], 구분, To-be [Digital]) for h in headers: compare_used.add(str(h).strip("* ").lower()) # 표 셀 내용 (개념·문서·행정 절차 중심, 시각화된 목적물 등) for row in rows: for cell in row: compare_used.add(str(cell).strip("* ").lower()) # 화살표 기호 compare_used.add("➠") compare_used.add("→") # 나머지 항목 → left_sections (표 관련 전부 제외) for item in left_items[1:]: heading_lower = item["heading"].lower().strip() # 표에 사용된 텍스트면 스킵 if heading_lower in compare_used: continue # 부분 매칭도 체크 (표 셀이 heading에 포함되거나 그 반대) is_table_content = any( heading_lower in used or used in heading_lower for used in compare_used if len(used) >= 3 ) if is_table_content: continue if item["heading"]: bullets = item.get("bullets", []) or ([d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else []) left_sections.append({"title": item["heading"], "bullets": bullets}) else: # 표 없으면 모든 항목을 left_sections로 for item in left_items: bullets = [d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else [] left_sections.append({"title": item["heading"], "bullets": bullets}) data["left_compare"] = left_compare data["left_sections"] = left_sections # right_sections right_sections = [] for item in right_slot["items"]: bullets = item.get("bullets", []) or ([d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else []) right_sections.append({"title": item["heading"], "bullets": bullets}) data["right_sections"] = right_sections # pp2 paired_rows: 좌/우 sections를 행 단위로 매칭 # compare 행에서 right[0]을 이미 썼으면 나머지 right는 [1]부터 right_start = 1 if left_compare and right_sections else 0 remaining_right = right_sections[right_start:] max_rows = max(len(left_sections), len(remaining_right)) paired_rows = [] # row 0: compare 행에서 right[0] 사용 if left_compare and right_sections: paired_rows.append({ "left": None, # compare가 좌측 담당 "right": right_sections[0], }) for i in range(max_rows): row = { "left": left_sections[i] if i < len(left_sections) else None, "right": remaining_right[i] if i < len(remaining_right) else None, } paired_rows.append(row) data["paired_rows"] = paired_rows # cdg 호환: left_header, right_header, sections data["left_header"] = left_slot["title"] data["right_header"] = right_slot["title"] max_items = max(len(left_sections), len(right_sections)) cdg_sections = [] for i in range(max_items): ls = left_sections[i] if i < len(left_sections) else {"title": "", "bullets": []} rs = right_sections[i] if i < len(right_sections) else {"title": "", "bullets": []} row = {"left": ls, "right": rs} # As-is→To-be 표가 있으면 첫 행에 asis/tobe 추가 if i == 0 and left_compare: row["left"]["asis"] = left_compare.get("left_items", []) row["left"]["tobe"] = left_compare.get("right_items", []) cdg_sections.append(row) data["sections"] = cdg_sections # 텍스트 (단일 메시지 블록용) if len(topic_slots) == 1: data["text"] = topic_slots[0].get("raw_text", "") data["message"] = data["text"] return data def _validate_payload_contract(block_id: str, data: dict, ctx) -> list[str]: """Y-11h: payload contract 검증. 블록에 넣기 전에 필수 데이터 확인.""" errors = [] conclusion = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else "" # 2분할 블록 공통 if "pp2" in block_id or "cdg" in block_id or "compare" in block_id: if not data.get("left_title"): errors.append("left_title 비어있음") if not data.get("right_title"): errors.append("right_title 비어있음") # pp2 전용 if "pp2" in block_id or "process-product" in block_id: ls = data.get("left_sections", []) rs = data.get("right_sections", []) if not ls and not data.get("left_compare"): errors.append("left_sections와 left_compare 둘 다 비어있음") if not rs: errors.append("right_sections 비어있음") # 결론이 body payload에 섞여있는지 if conclusion: for key in ["left_title", "right_title"]: if conclusion in str(data.get(key, "")): errors.append(f"conclusion이 {key}에 섞여있음") for sec_list in [data.get("left_sections", []), data.get("right_sections", [])]: for sec in sec_list: if conclusion in str(sec.get("title", "")) or conclusion in str(sec.get("bullets", [])): errors.append("conclusion이 sections에 섞여있음") return errors def _parse_topic_to_items(st: str) -> list[dict]: """structured_text → [{heading, desc}] 리스트. '• 제목' = heading, ' • 설명' = desc (하위 불릿들 합침). """ items = [] current_heading = "" current_descs = [] for line in st.split("\n"): stripped = line.strip() if not stripped: continue # 마크다운 헤더, 이미지 참조는 건너뜀 if stripped.startswith("### ") or stripped.startswith("## "): continue if stripped.startswith("![") or stripped.startswith("[이미지:"): continue # D1: 포맷 (normalized.sections, 1단계 = heading) d1_match = re.match(r'^D1:\s*(.*)', stripped) if d1_match: if current_heading: items.append({ "heading": current_heading, "desc": " / ".join(current_descs) if current_descs else "", }) current_heading = re.sub(r'\*\*(.+?)\*\*', r'\1', d1_match.group(1).strip()) current_descs = [] continue # D2: 포맷 (normalized.sections, 2단계 = desc) d2_match = re.match(r'^D2:\s*(.*)', stripped) if d2_match: desc = re.sub(r'\*\*(.+?)\*\*', r'\1', d2_match.group(1).strip()) current_descs.append(desc) continue # 기존 • 불릿 (fallback: structured_text 등) if stripped.startswith("• ") and not line.startswith(" "): if current_heading: items.append({ "heading": current_heading, "desc": " / ".join(current_descs) if current_descs else "", }) current_heading = stripped.lstrip("• ").strip() current_heading = re.sub(r'\*\*(.+?)\*\*', r'\1', current_heading) current_descs = [] elif (stripped.startswith("• ") and line.startswith(" ")) or stripped.startswith("- "): desc = stripped.lstrip("•- ").strip() desc = re.sub(r'\*\*(.+?)\*\*', r'\1', desc) current_descs.append(desc) elif not stripped.startswith("|") and not stripped.startswith("---"): if current_heading: current_descs.append(re.sub(r'\*\*(.+?)\*\*', r'\1', stripped)) else: current_heading = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) # 마지막 항목 if current_heading: items.append({ "heading": current_heading, "desc": " / ".join(current_descs) if current_descs else "", "bullets": list(current_descs), # Y-12a: 불릿 배열도 제공 }) # 이전 항목들에도 bullets 추가 (desc에서 복원) for item in items: if "bullets" not in item: item["bullets"] = [d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else [] # 마크다운 잔여 토큰 최종 정리 for item in items: item["heading"] = re.sub(r'\*+', '', item["heading"]).strip() item["desc"] = re.sub(r'\*+', '', item["desc"]).strip() item["bullets"] = [re.sub(r'\*+', '', b).strip() for b in item["bullets"]] # 최소 2개 보장 while len(items) < 2: items.append({"heading": "", "desc": "", "bullets": []}) return items def _parse_structured_text(st: str) -> list[dict]: """structured_text를 섹션(제목+불릿) 리스트로 파싱.""" sections = [] current = {"title": "", "bullets": []} for line in st.split("\n"): stripped = line.strip() if not stripped: continue if stripped.startswith("### "): if current["title"] or current["bullets"]: sections.append(current) current = {"title": stripped.lstrip("# ").strip(), "bullets": []} elif stripped.startswith("* **") or stripped.startswith("- **"): bullet = stripped.lstrip("*- ").strip() current["bullets"].append(bullet) elif stripped.startswith(" * ") or stripped.startswith(" - "): bullet = stripped.strip().lstrip("*- ").strip() if current["bullets"]: current["bullets"].append(f" {bullet}") else: current["bullets"].append(bullet) elif stripped.startswith("•"): current["bullets"].append(stripped.lstrip("• ").strip()) elif not stripped.startswith("|") and not stripped.startswith("---"): current["bullets"].append(stripped) if current["title"] or current["bullets"]: sections.append(current) return sections def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str: """전체 슬라이드를 조립하여 HTML 반환. filled, assembled, stage_2 모두 이 함수를 호출. layout_template에 따라 유형 A/B 분기. """ # Stage 1.8 측정용: 기존 f-string 방식 (zone class 유지 → Selenium 호환) 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) if ctx.analysis.layout_template == "B''": return _assemble_slide_html_type_b(ctx, title_text) # B'' 측정도 B 방식 return _assemble_slide_html_type_a(ctx, title_text) def _select_block_for_recipe( recipe: dict, blocks_key: str, role_name: str, role_info: dict, ctx: "PipelineContext", ) -> str | None: """D-1: recipe 내부 블록 선택 — 점수 기반. 점수 항목: - kind 호환성 (sub_type ↔ 블록 when 조건) - content density (D1/D2 개수 vs 블록 슬롯) - description 슬롯 유무 - visual family suitability D-2: 전체 점수 0이면 None (미선택 → recipe direct render). """ from pathlib import Path import yaml candidates = recipe.get(blocks_key, recipe.get("blocks", [])) if not candidates: logger.info(f"[recipe] {role_name}: 후보 없음 → direct render") return None catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" try: catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) except Exception: return None blocks = catalog.get("blocks", catalog) if isinstance(catalog, dict) else catalog if not isinstance(blocks, list): return None # sub_types 정보 sub_types = role_info.get("sub_types", []) if isinstance(role_info, dict) else [] actual_types = [s.get("sub_type", "") for s in sub_types] recipe_kind = recipe.get("left_kind", recipe.get("block_kind", "")) scored = [] for cand_id in candidates: entry = next((b for b in blocks if b.get("id") == cand_id), None) if not entry or not entry.get("template"): continue score = 0 # 1. kind 호환성 from src.section_parser import KIND_SUBTYPE_COMPAT compatible_types = KIND_SUBTYPE_COMPAT.get(recipe_kind, []) if compatible_types: if any(t in compatible_types for t in actual_types): score += 5 else: score -= 3 # 비호환 감점 # 2. content density: text_list에 카드형/체크리스트형 → 강한 감점 (제외 수준) has_text_list = "text_list_candidate" in actual_types is_card_type = "card" in cand_id is_checklist = "checklist" in cand_id or "dark" in cand_id if has_text_list and (is_card_type or is_checklist): score -= 10 # 사실상 제외 # 3. description 슬롯 유무 block_slots = entry.get("slots", {}) has_desc_slot = "description" in str(block_slots) or "desc" in str(block_slots) if has_text_list and not has_desc_slot: score -= 2 # 4. visual family suitability has_visual_detail = "visual_detail_candidate" in actual_types is_dark = "dark" in cand_id or "checklist" in cand_id if has_visual_detail and is_dark: score -= 5 # summary/popup에 다크 체크리스트 → 강한 감점 scored.append((cand_id, score)) logger.debug(f"[recipe score] {role_name}: {cand_id}={score}") if not scored: logger.info(f"[recipe] {role_name}: catalog에 후보 없음 → direct render") return None # 최고 점수 선택 scored.sort(key=lambda x: x[1], reverse=True) best_id, best_score = scored[0] if best_score <= 0: logger.info(f"[recipe] {role_name}: 최고 점수 {best_score} ≤ 0 → direct render") return None logger.info(f"[recipe] {role_name}: '{best_id}' 선택 (score={best_score})") return best_id def _render_block_by_id(block_id: str, role_name: str, ctx: "PipelineContext") -> tuple[str, str]: """block_id로 직접 블록 렌더링. render_block_for_role()의 recipe 버전. Stage 1.7 references를 거치지 않고, block_id를 직접 지정하여 렌더링. """ from pathlib import Path import yaml if not block_id or block_id == "__needs_recipe__": return "", "" catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" try: catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) except Exception: return "", "" blocks = catalog.get("blocks", catalog) if isinstance(catalog, dict) else catalog if not isinstance(blocks, list): return "", "" entry = next((b for b in blocks if b.get("id") == block_id), None) if not entry: logger.warning(f"[recipe render] block_id={block_id} catalog에 없음") return "", "" template_path = entry.get("template", "") if not template_path: return "", "" from jinja2 import Environment, FileSystemLoader templates_dir = Path(__file__).parent.parent / "templates" env = Environment(loader=FileSystemLoader(str(templates_dir))) try: template = env.get_template(template_path) except Exception as e: logger.warning(f"[recipe render] 템플릿 로드 실패: {template_path} — {e}") return "", "" # 슬롯 데이터 구성 ps_info = ctx.page_structure.roles.get(role_name, {}) topic_ids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else [] topic_map = {t.id: t for t in ctx.topics} slot_data = _build_slot_data(block_id, entry, topic_ids, topic_map, ctx) try: html = template.render(**slot_data) # CSS 추출: ', html) if style_matches: css = "\n".join(style_matches) # ', '', html) # html에서 제거 return html, css except Exception as e: logger.warning(f"[recipe render] 렌더링 실패: {block_id} — {e}") return "", "" def _build_detail_preview(role_name: str, popups: list) -> str: """structured detail preview — popup source 유형에 따라 preview 생성. 일반 규칙: - 표 → header + 첫 행 preview (축소 테이블) - 리스트 → first 3 bullets - 컴포넌트/기타 → summary text + metadata """ for p in popups: target = p.target_role if hasattr(p, 'target_role') else None if not target or target != role_name: continue pcontent = p.content if hasattr(p, 'content') else "" if not pcontent: continue has_table = "]*>(.*?)', pcontent, re.DOTALL) total_rows = len(all_rows) header_rows = [r for r in all_rows if '{r}' for r in shown_data: preview += f'{r}' preview += '' remaining = len(data_rows) - len(shown_data) if remaining > 0: preview += f'
외 {remaining}행
' preview += '' return preview if has_list: # 리스트: first 3 bullets bullets = re.findall(r']*>(.*?)', pcontent, re.DOTALL) bullets = [re.sub(r'<[^>]+>', '', b).strip() for b in bullets if b.strip()][:3] if bullets: preview = '
' for b in bullets: preview += f'
• {b[:40]}
' preview += '
' return preview # 기타: 첫 2문장 summary text_only = re.sub(r'<[^>]+>', '', pcontent).strip() sentences = [s.strip() for s in text_only.split('.') if len(s.strip()) > 5][:2] if sentences: summary = ". ".join(sentences) + "." return f'
{summary}
' return "" def _recipe_direct_render( render_kind: str, role_name: str, role_info: dict, ctx: "PipelineContext", subsection_index: int | None = None, ) -> tuple[str, str]: """E-1: recipe direct render — 블록 미선택 시 직접 HTML 생성. 구조는 새로 짜되, 기존 블록의 visual language를 CSS 변수로 상속. subsection_index: None이면 전체, 0이면 첫 번째만, 1이면 두 번째만. """ # 슬롯 데이터 구성 ps_info = ctx.page_structure.roles.get(role_name, {}) all_sub_titles = ps_info.get("sub_titles", []) if isinstance(ps_info, dict) else [] # subsection_index로 slicing if subsection_index is not None: sub_titles = [all_sub_titles[subsection_index]] if subsection_index < len(all_sub_titles) else [] else: sub_titles = all_sub_titles topic_ids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else [] norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] if norm_sections and hasattr(norm_sections[0], 'model_dump'): norm_sections = [s.model_dump() if hasattr(s, 'model_dump') else dict(s) for s in norm_sections] # sub_title별 content 수집 items = [] for st in sub_titles: st_key = st.split("(")[0].strip().lower() content = "" # 섹션 title 매칭 for sec in norm_sections: if st_key and len(st_key) >= 2 and st_key in sec.get("title", "").lower(): content = sec.get("content", "") # 이미지 markdown 라인 제거 content = re.sub(r'^!\[.*?\]\(.*?\)\s*$', '', content, flags=re.MULTILINE) content = re.sub(r'^\[이미지:.*?\]\s*$', '', content, flags=re.MULTILINE) content = content.strip() break # D1 항목 내 매칭 fallback if not content: for sec in norm_sections: sec_content = sec.get("content", "") lines = sec_content.split("\n") capturing = False captured = [] for line in lines: d1_match = re.match(r'^D1:\s*(.*)', line.strip()) if d1_match: d1_text = re.sub(r'\*+', '', d1_match.group(1)).strip().lower() if capturing: break if st_key and st_key in d1_text: capturing = True captured.append(line.strip()) elif capturing: if line.strip(): captured.append(line.strip()) if captured: content = "\n".join(captured) break parsed = _parse_topic_to_items(content) if content else [] items.append({"title": st, "content": content, "parsed": parsed}) # render_kind에 따라 HTML 생성 if render_kind == "parallel_cards": # 세로 쌓기 카드 — two_col의 left로 들어갈 때 자연스러운 배치 # reference 스타일: 다크 배경 + 강조 제목 + 불릿 설명 cards_html = "" card_colors = [ ("linear-gradient(135deg, #1a365d, #2d3748)", "#fbbf24"), ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#fbbf24"), ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#fbbf24"), ("linear-gradient(135deg, #2d3748, #1a365d)", "#fbbf24"), ] for i, item in enumerate(items): bg, title_color = card_colors[i % len(card_colors)] desc_parts = [] for p in item["parsed"]: if p.get("bullets"): desc_parts.extend(p["bullets"]) elif p.get("desc"): desc_parts.append(p["desc"]) bullets_html = "".join( f'
• {d}
' for d in desc_parts ) cards_html += ( f'
' f'
' f'{item["title"].split("(")[0].strip()}
' f'{bullets_html}' f'
\n' ) html = f'
{cards_html}
' elif render_kind == "text_list": # heading + bullet — Type B' 검증된 .bul 구조 재사용 list_html = "" for item in items: list_html += f'
{item["title"]}
\n' for p in item["parsed"]: heading = p.get("heading", "") bullets = p.get("bullets", []) desc = p.get("desc", "") if bullets: if heading: list_html += f'
• {heading}
\n' for b in bullets: list_html += f'
• {b}
\n' elif desc: if heading: list_html += f'
• {heading}
\n' list_html += f'
• {desc}
\n' elif heading: # D1만 있고 D2 없음 → ":"로 제목/설명 분리, .bul 구조 if ": " in heading: h_title, h_desc = heading.split(": ", 1) list_html += f'
{h_title}: {h_desc}
\n' else: list_html += f'
• {heading}
\n' html = f'
{list_html}
' elif render_kind == "summary_and_popup": html = '
자세한 내용은 첨부 자료를 참조하세요.
' else: html = f'
[direct render: {render_kind}]
' # E-2: 기존 블록 visual language 상속 CSS css = """ /* recipe direct render — 기존 블록 visual language 상속 */ .rdr-cards-stack { display: flex; flex-direction: column; gap: 4px; height: 100%; } .rdr-card-dark { border-radius: 4px; padding: 7px 10px; flex: 1; min-height: 0; overflow: hidden; } .rdr-card-dark-title { font-size: 12px; font-weight: 700; margin-bottom: 3px; } /* 다크카드 블릿은 .bul 재사용 + inline style로 색상 오버라이드 */ /* text_list는 기존 .bul (slide_font_css) 재사용 — 별도 CSS 불필요 */ .rdr-summary { font-size: 12px; color: #64748b; padding: 16px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; } .rdr-summary-text { font-size: 12px; color: #475569; line-height: 1.7; padding: 12px 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-top: 8px; } .rdr-detail-link-wrap { text-align: right; margin-top: 10px; } .rdr-detail-link { font-size: 11px; color: #64748b; text-decoration: none; font-weight: 500; } .rdr-detail-link:hover { color: #2563eb; text-decoration: underline; } .rdr-table-preview { margin-top: 4px; width: 100%; } .rdr-preview-table { border-collapse: collapse; width: 100%; font-size: 9px; table-layout: fixed; } .rdr-preview-table th { background: #64748b; color: #fff; padding: 4px 6px; font-weight: 600; text-align: center; border: 1px solid #475569; word-break: keep-all; } .rdr-preview-table td { padding: 4px 6px; border: 1px solid #e2e8f0; vertical-align: middle; color: #475569; line-height: 1.4; word-break: keep-all; } .rdr-preview-table ul { padding-left: 12px; margin: 0; } .rdr-preview-table li { font-size: 9px; margin-bottom: 1px; } .rdr-preview-table strong { color: #1e293b; } .rdr-preview-note { font-size: 10px; color: #94a3b8; margin-top: 4px; text-align: right; } .rdr-list-preview { margin-top: 8px; padding: 8px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; } """ logger.info(f"[recipe] {role_name}: direct render '{render_kind}' ({len(items)}개 항목)") return html, css def _render_visual_anchor(role_name: str, role_info: dict, ctx: "PipelineContext") -> str: """recipe executor: visual anchor 렌더링. visual anchor = 이미지 / 차트 / 컴포넌트 등 텍스트가 아닌 시각 요소. normalized.images, normalized.popups에서 해당 role에 연결된 visual을 찾아 렌더링. """ # 1. 이미지 찾기: normalized.images에서 이 role의 content에 참조된 이미지 norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] role_content = "" for sec in norm_sections: sec_dict = sec if isinstance(sec, dict) else (sec.model_dump() if hasattr(sec, 'model_dump') else {}) # role의 sub_titles 중 하나라도 이 섹션에 속하면 content 수집 for st in role_info.get("sub_titles", []): st_key = st.split("(")[0].strip().lower() if st_key and st_key in sec_dict.get("title", "").lower(): role_content += sec_dict.get("content", "") # role_name이 섹션 title과 매칭 if role_name.lower() in sec_dict.get("title", "").lower(): role_content += sec_dict.get("content", "") # 이미지 경로 추출 import re as _re img_refs = _re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', role_content) if not img_refs: # normalized.images에서 찾기 for img in (ctx.normalized.images or []): path = img.get("path", "") if path: img_refs.append((img.get("alt", ""), path)) if img_refs: alt, img_path = img_refs[0] from pathlib import Path import base64 base = Path(ctx.base_path) if ctx.base_path else Path(".") abs_path = base / img_path.lstrip("/") # 경로 못 찾으면 파일명으로 재검색 if not abs_path.exists(): filename = Path(img_path).name found = list(base.rglob(filename)) if found: abs_path = found[0] # samples/images/, samples/mdx_batch/ 에서도 검색 if not abs_path.exists(): filename = Path(img_path).name for search_dir in [Path("samples/images"), Path("samples/mdx_batch")]: if search_dir.exists(): found = list(search_dir.rglob(filename)) if found: abs_path = found[0] break if abs_path.exists(): data = abs_path.read_bytes() ext = abs_path.suffix.lstrip(".").lower() mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "svg": "image/svg+xml"}.get(ext, "image/png") b64 = base64.b64encode(data).decode() return ( f'{alt}' ) # 파일 없으면 경로 텍스트로 표시 return ( f'
' f'[이미지: {alt or img_path}]
' ) # visual anchor 없음 — 빈 placeholder return "" def assemble_slide_html_final(ctx: "PipelineContext", title_text: str = "", measure_mode: bool = False, font_scale: float = 1.0) -> str: """Phase Y: slide-base.html 기반 블록 조립. 1. slide-base.html 로드 2. title, footer_text(conclusion_text) 삽입 3. .slide-body에 zone별 블록 HTML 배치 4. 블록 CSS를 앞에 삽입 (body 안의 은 건드리지 않음) if extra_css and '' in slide_html: slide_html = slide_html.replace('', f'\n{extra_css}\n', 1) # 첫 번째만 # A-3 safety net: body 안에 ', body_part) if body_styles: body_part = re.sub(r'', '', body_part) extra_body_css = "\n".join(body_styles) head_part = head_part.replace('', f'\n{extra_body_css}\n', 1) slide_html = head_part + body_part logger.info(f"[A-3] body→head style 이동: {len(body_styles)}개") # Y-12d: asset packaging — 상대경로 → base64 내장 (self-contained) slide_html = _embed_slide_assets(slide_html, templates_dir) return slide_html def _embed_slide_assets(html: str, templates_dir) -> str: """Y-12d: slide-base 상대경로 asset을 base64로 내장.""" import base64 from pathlib import Path svg_dir = templates_dir / "blocks" / "svg" if not svg_dir.exists(): return html # 치환 대상: src="svg/파일명" 패턴 def _replace_asset(match): filename = match.group(1) filepath = svg_dir / filename if not filepath.exists(): return match.group(0) # 파일 없으면 그대로 data = filepath.read_bytes() ext = filepath.suffix.lower() if ext == ".svg": mime = "image/svg+xml" elif ext == ".png": mime = "image/png" elif ext in (".jpg", ".jpeg"): mime = "image/jpeg" else: return match.group(0) b64 = base64.b64encode(data).decode("ascii") return f'src="data:{mime};base64,{b64}"' html = re.sub(r'src="svg/([^"]+)"', _replace_asset, html) return html def _assemble_slide_base_fallback(title, conclusion, body_html, extra_css): """slide-base.html 로드 실패 시 최소한의 슬라이드 HTML.""" return f"""
{title}
{body_html}
""" 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 if re.search(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 if re.search(r'\[핵심요약:', stripped): continue content_lines.append(stripped) popup_html = _popup_links_html(popup_titles, font_size) # 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: 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): title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ") 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("• ") 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': 상단은 핵심이므로 항상 불릿 표시 (팝업 대상 아님) 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 = "" for item in sec_items: if item.startswith(''): # D1: 1번 들여쓰기, bold, 불릿 없음 items_html += ( f'
' f'{item}
' ) else: # D2: 2번 들여쓰기, 일반, 불릿 items_html += ( f'
' f'• {item}
' ) 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) # 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 = "" 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'\1', 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'\1', 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 re.search(r'\[핵심요약:', clean): break # 핵심요약 이후는 결론이므로 스킵 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'\1', 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 re.search(r'\[핵심요약:', clean): break # 핵심요약 이후는 결론이므로 스킵 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}
"""