"""유형 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"""
"""
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"""
"""
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