"""유형 B'' 조립 함수 — slide-base.html + 블록 템플릿 사용. 변경 이력: - 기존: f-string 하드코딩 HTML - 현재: slide-base.html 래핑 + templates/blocks/ 블록 Jinja2 렌더링 + font_hierarchy 적용 원칙: - 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정) - 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님) - 블록은 콘텐츠에 맞게 재구성 (items 수 동적) """ from __future__ import annotations import base64 import re from pathlib import Path from typing import TYPE_CHECKING from jinja2 import Environment, FileSystemLoader if TYPE_CHECKING: from src.pipeline_context import PipelineContext BLOCKS_DIR = Path("templates/blocks") SVG_DIR = BLOCKS_DIR / "svg" _env = Environment(loader=FileSystemLoader(str(BLOCKS_DIR)), autoescape=False) def _img_b64(filename: str) -> str: """SVG/PNG → data URI.""" p = SVG_DIR / filename if not p.exists(): return "" ext = "svg+xml" if filename.endswith(".svg") else "png" return f"data:image/{ext};base64," + base64.b64encode(p.read_bytes()).decode() def _strip_comments(html: str) -> str: return re.sub(r'', '', html, flags=re.DOTALL).strip() def _render_slide_base(title: str, body_html: str, footer_text: str) -> str: """slide-base.html로 래핑. 공통 함수.""" sb = _strip_comments((BLOCKS_DIR / "slide-base.html").read_text(encoding="utf-8")) r = sb.replace('{{ title|default("슬라이드") }}', title) r = r.replace('{{ title|default("슬라이드 제목") }}', title) r = r.replace('{% block body %}{% endblock %}', body_html) pill = _img_b64("pill_scroll.png") r = r.replace('{% if footer_text %}', '').replace('{% if footer_pill_bg %}', '') r = r.replace('{{ footer_pill_bg }}', pill).replace('{% else %}', '') r = r.replace('', '') li = r.rfind('{% endif %}') if li > 0: r = r[:li] + r[li + len('{% endif %}'):] r = r.replace('{% endif %}', '').replace('{{ footer_text|safe }}', footer_text) r = r.replace('src="svg/bg_slide_texture.png"', f'src="{_img_b64("bg_slide_texture.png")}"') r = r.replace('src="svg/line_divider.svg"', f'src="{_img_b64("line_divider.svg")}"') return r def _assemble_slide_html_type_b_double_prime(ctx: "PipelineContext", title_text: str = "") -> str: """유형 B'' — slide-base.html + 블록 템플릿 + font_hierarchy. 블록 선택: PipelineContext.references에서 가져옴. 콘텐츠: PipelineContext.normalized.sections + structured_text에서 가져옴. 글씨 크기: font_hierarchy(core/bg/sidebar/key_msg)에서 가져옴. """ font_h = ctx.font_hierarchy title = title_text or ctx.analysis.title or "" core_message = ctx.analysis.core_message or "" ps = ctx.page_structure.roles norm_sections = ctx.normalized.sections or [] norm_tables = ctx.normalized.tables or [] enh = ctx.enhancement_result or {} bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} # zone 분류 zones = {} for role_name, info in ps.items(): if isinstance(info, dict): zones[info.get("zone", "")] = (role_name, info) top_role = zones.get("top") bl_role = zones.get("bottom_left") br_role = zones.get("bottom_right") footer_role = zones.get("footer") def _bold(text, role=""): for kw in bold_kw.get(role, []): if kw in text: text = text.replace(kw, f"{kw}") return text # ── 상단: 블록 레퍼런스에서 block_id 확인 → 블록 템플릿 렌더링 ── top_html = _render_top_zone(ctx, norm_sections, font_h, _bold) # ── 하단: process-product-2col 또는 블록 레퍼런스 기반 ── bottom_html = _render_bottom_zone(ctx, norm_sections, norm_tables, font_h, _bold) # ── font_hierarchy CSS override ── font_css = f"""""" # ── zone 제목 추출 ── # 상단: 첫 번째 level=2 (콘텐츠 없는 대제목) # 하단: level=3 직전의 level=2 (하단 대제목) top_zone_title = "" bottom_zone_title = "" for i, s in enumerate(norm_sections): if s.get("level") == 2: if not s.get("content", "").strip(): # 콘텐츠 없는 level=2 = zone 제목 # 다음 section이 level=3이면 하단 제목 if i + 1 < len(norm_sections) and norm_sections[i + 1].get("level") == 3: bottom_zone_title = s.get("title", "") elif not top_zone_title: top_zone_title = s.get("title", "") # ── 조립 ── body = f"""{font_css}
{top_zone_title}
{top_html}
{bottom_zone_title}
{bottom_html}
""" footer_text_html = f'{core_message}'.replace( '기대할 수 있다', '기대할 수 있다' ) if core_message else "" return _render_slide_base(title, body, footer_text_html) def _get_zone_title(sections, level=2, index=0): """normalized.sections에서 level=N인 제목을 index번째 가져옴.""" count = 0 for s in sections: if s.get("level") == level: if count == index: return s.get("title", "") count += 1 return "" def _render_top_zone(ctx, sections, font_h, bold_fn): """상단 zone 렌더링 — normalized sections의 level=2 카테고리를 직접 사용.""" # 상단 topic_ids에 해당하는 sections 가져오기 ps = ctx.page_structure.roles top_zone = None for role_name, info in ps.items(): if isinstance(info, dict) and info.get("zone") == "top": top_zone = (role_name, info) break if not top_zone: return "
상단 zone 없음
" top_topic_ids = top_zone[1].get("topic_ids", []) topic_map = {t.id: t for t in ctx.topics} # 각 topic의 structured_text 또는 normalized section에서 콘텐츠 가져오기 categories = [] for tid in top_topic_ids: topic = topic_map.get(tid) if not topic: continue cat_name = topic.title or "" # structured_text 우선, 없으면 normalized sections에서 찾기 content = topic.structured_text or "" if not content: for s in sections: if s.get("title") == cat_name and s.get("content"): content = s["content"] break if not content: continue # D1/D2 마커 기반 파싱 headings = [] current_heading = None for line in content.split("\n"): stripped = line.strip() if not stripped: continue stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) dm = re.match(r'^D(\d+):\s*', stripped) depth = int(dm.group(1)) if dm else 0 if dm: stripped = re.sub(r'^D\d+:\s*', '', stripped) clean = stripped.lstrip("•- ").strip() if not clean: continue if depth <= 1 and '' in clean: current_heading = {"title": clean, "bullets": []} headings.append(current_heading) else: if current_heading: current_heading["bullets"].append(clean) elif headings: headings[-1]["bullets"].append(clean) else: headings.append({"title": "", "bullets": [clean]}) categories.append({"name": cat_name, "headings": headings}) import logging logging.getLogger(__name__).info(f"[B'' top] cat={cat_name}, headings={len(headings)}") if not categories: return "
콘텐츠 없음
" # 블록 CSS 가져오기 p3c_raw = (BLOCKS_DIR / "new" / "prerequisites-3col.html").read_text(encoding="utf-8") p3c_css = re.search(r'', p3c_raw, re.DOTALL) css_html = p3c_css.group(0) if p3c_css else "" # 동적 열 생성 bar_gradients = [ "linear-gradient(180deg, #0D78D0 0%, #023056 100%)", "linear-gradient(180deg, #FF9A23 0%, #CC5200 100%)", "linear-gradient(180deg, #39BE49 0%, #23742C 100%)", "linear-gradient(180deg, #7c3aed 0%, #4c1d95 100%)", ] heading_gradients = [ "linear-gradient(180deg, #0D78D0 0%, #134D7F 100%)", "linear-gradient(180deg, #CC5200 0%, #883700 100%)", "linear-gradient(180deg, #39BE49 0%, #1E6328 100%)", "linear-gradient(180deg, #7c3aed 0%, #5b21b6 100%)", ] cols_html = "" for ci, cat in enumerate(categories): # 카테고리명에서 "기술(디지털)" → name="기술", sub="디지털" name_match = re.match(r'^(.+?)[((](.+?)[))]$', cat["name"]) if name_match: name, sub = name_match.group(1), name_match.group(2) else: name, sub = cat["name"], "" bar = bar_gradients[ci % len(bar_gradients)] hgrad = heading_gradients[ci % len(heading_gradients)] # 항목 HTML — 동적 items 수 items = cat["headings"] n = max(len(items), 1) items_html = "" for i, item in enumerate(items): if not item["title"] and not item["bullets"]: continue pct_h = int(95 / n) pct_top = int(3 + i * (95 / n)) bul = "".join(f'
• {b}
' for b in item["bullets"]) items_html += f"""
{item['title']}
{bul}
""" if i < n - 1 and len(items) > 1: line_top = pct_top + pct_h items_html += f'
' cols_html += f"""
{name}
{'
' + sub + '
' if sub else ''}
{items_html}
""" return f'
{cols_html}
\n{css_html}' def _render_bottom_zone(ctx, sections, tables, font_h, bold_fn): """하단 zone 렌더링 — 좌우 2분할, 소제목 행 정렬.""" # 하단 콘텐츠: level=3인 sections sub_secs = [] for s in sections: if s.get("level") == 3: sub_secs.append((s.get("title", ""), s.get("content", ""))) if not sub_secs: return "
하단 콘텐츠 없음
" # 좌/우 분리 (첫 번째 sub_sec가 좌, 두 번째가 우) left_title = sub_secs[0][0] if sub_secs else "" right_title = sub_secs[1][0] if len(sub_secs) > 1 else "" # 좌측 소제목+불릿 파싱 left_items = _parse_sub_content(sub_secs[0][1] if sub_secs else "", tables, bold_fn) right_items = _parse_sub_content(sub_secs[1][1] if len(sub_secs) > 1 else "", [], bold_fn) # 좌우 소제목 행 매칭 max_rows = max(len(left_items), len(right_items)) while len(left_items) < max_rows: left_items.append(("", [])) while len(right_items) < max_rows: right_items.append(("", [])) # 블록 CSS pp2_raw = (BLOCKS_DIR / "BEPs" / "process-product-2col.html").read_text(encoding="utf-8") pp2_css = re.search(r'', pp2_raw, re.DOTALL) css_html = pp2_css.group(0) if pp2_css else "" arrow_uri = _img_b64("arrow_asis_tobe.png") # Grid 생성 — 행 높이 동기화 + 전체 열 gradient rows_html = "" for i, ((lt, lbullets), (rt, rbullets)) in enumerate(zip(left_items, right_items)): pad = "3px 16px" if i == 0 else "2px 16px" # 좌측 left_cell = f'
' if lt: left_cell += f'
{lt}
' # 테이블 (As-is → To-be) 이 있으면 첫 번째 행에 삽입 if i == 0 and tables: left_cell += _render_compare_table(tables[0], arrow_uri, font_h) for b in lbullets: left_cell += f'
• {b}
' left_cell += '
' # 우측 right_cell = f'
' if rt: right_cell += f'
{rt}
' for b in rbullets: right_cell += f'
• {b}
' right_cell += '
' rows_html += left_cell + right_cell # 헤더 header_html = f"""
{left_title}
{right_title}
""" return f"""
{header_html} {rows_html}
{css_html}""" def _parse_sub_content(content, tables, bold_fn): """하위 콘텐츠를 소제목+불릿 리스트로 파싱.""" content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) items = [] current_title = "" current_bullets = [] # 테이블 텍스트 (중복 제거용) table_texts = set() for td in tables: for h in td.get("headers", []): table_texts.add(h.strip().lstrip("*").rstrip("*")) for row in td.get("rows", []): for c in row: table_texts.add(str(c).strip().lstrip("*").rstrip("*")) for line in content.split("\n"): stripped = line.strip() if not stripped: continue # D마커 dm = re.match(r'^D(\d+):\s*', stripped) if dm: stripped = re.sub(r'^D\d+:\s*', '', stripped) clean = stripped.lstrip("•- ").strip() clean_plain = re.sub(r'<[^>]+>', '', clean).strip() if clean_plain in table_texts or clean_plain == "➠": continue if re.search(r'\[핵심요약:', clean): break if not clean: continue # 소제목 감지 (볼드) if '' in clean and len(clean) < 80: if current_title or current_bullets: items.append((current_title, current_bullets)) current_title = clean current_bullets = [] else: current_bullets.append(clean) if current_title or current_bullets: items.append((current_title, current_bullets)) return items def _render_compare_table(table_data, arrow_uri, font_h): """As-is → To-be 비교 테이블 렌더링.""" headers = table_data.get("headers", []) rows = table_data.get("rows", []) if not headers or not rows: return "" def _clean_md(text): """**볼드** 마크다운 제거 — 테이블 셀은 일반 텍스트.""" return re.sub(r'\*\*(.+?)\*\*', r'\1', str(text)) html = '
' html += '
' for row in rows: html += f'
• {_clean_md(row[0])}
' html += '
' html += f'
→
' html += '
' for row in rows: val = row[2] if len(row) > 2 else "" html += f'
• {_clean_md(val)}
' html += '
' return html