2734 lines
120 KiB
Python
2734 lines
120 KiB
Python
"""블록 조립 공통 모듈.
|
|
|
|
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'<style>(.*?)</style>', ref_html, re.DOTALL)
|
|
block_body = re.sub(r'<style>.*?</style>', '', 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'<div style="position:relative;">{popup_html}{inner}</div>'
|
|
|
|
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** → <strong>."""
|
|
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'<strong>\1</strong>', stripped)
|
|
lines.append((indent, stripped))
|
|
return lines, popup_titles
|
|
|
|
|
|
def _apply_bold(text: str, keywords: list[str]) -> str:
|
|
"""V-10 bold 키워드를 <strong>으로 감쌈."""
|
|
for kw in keywords:
|
|
if kw in text:
|
|
text = text.replace(kw, f"<strong>{kw}</strong>")
|
|
return text
|
|
|
|
|
|
def _popup_links_html(popup_titles: list[str], font_size: float) -> str:
|
|
"""팝업 제목 리스트 → 우측상단 배치용 HTML."""
|
|
if not popup_titles:
|
|
return ""
|
|
links = " ".join(
|
|
f'<span style="color:#2563eb;font-size:{font_size - 2}px;cursor:pointer;">[{t}→]</span>'
|
|
for t in popup_titles
|
|
)
|
|
return (
|
|
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
|
|
f'{links}</div>'
|
|
)
|
|
|
|
|
|
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'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
|
|
elif indent == 1:
|
|
html += f'<div class="bl" style="padding-left:1em;font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
|
else:
|
|
html += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\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'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
|
|
f'border-left:2px solid #fca5a5;">{_apply_bold(st_text, bk)}</div>'
|
|
)
|
|
# V-9 강조 블록
|
|
emph_html = ""
|
|
if emphasis:
|
|
emph_html = (
|
|
f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
|
|
f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
|
|
f'→ {_apply_bold(emphasis, bk)}</div>'
|
|
)
|
|
inner = re.sub(r'<div class="cw-title">.*?</div>',
|
|
f'<div class="cw-title">{_apply_bold(topic.title, bk)}</div>', block_body, flags=re.DOTALL)
|
|
inner = re.sub(r'<div class="cw-desc">.*?</div>',
|
|
f'<div class="cw-desc" style="font-size:{font_size}px;">{desc_html}{sub_html}{emph_html}</div>', 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'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:4px;">{topic.title}</div>'
|
|
|
|
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'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
|
|
else:
|
|
desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{d}</span></div>\n'
|
|
num_size = int(font_size * 2)
|
|
items_html += (
|
|
f'<div class="cn-item">'
|
|
f'<div class="cn-number" style="background:#2563eb;width:{num_size}px;height:{num_size}px;font-size:{font_size-1}px;">{i+1}</div>'
|
|
f'<div class="cn-body">'
|
|
f'<div class="cn-title" style="font-size:{font_size}px;">{_apply_bold(title, bk)}</div>'
|
|
f'<div class="cn-desc" style="font-size:{font_size-1}px;white-space:normal;">{desc_html}</div>'
|
|
f'</div></div>\n'
|
|
)
|
|
|
|
return f'{label}<div class="block-card-num" style="gap:{card_gap}px;">{items_html}</div>'
|
|
|
|
|
|
def _assemble_banner(block_body, message):
|
|
"""banner-gradient 블록에 메시지 채움."""
|
|
inner = re.sub(r'<div class="bg-text">.*?</div>',
|
|
f'<div class="bg-text">{message}</div>', block_body, flags=re.DOTALL)
|
|
inner = re.sub(r'<div class="bg-sub">.*?</div>', '', 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(하단) 레이아웃. 실제 이미지 파일 사용."""
|
|
# 실제 이미지가 있으면 <img> 사용, 없으면 빈 placeholder
|
|
img_html = ""
|
|
if slide_images:
|
|
for img in slide_images:
|
|
b64 = img.get("b64", "")
|
|
if b64:
|
|
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
|
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'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{caption_lines[0]}</div>'
|
|
|
|
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'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
|
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
|
|
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
|
|
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
|
|
)
|
|
|
|
return (
|
|
f'<div style="display:flex;flex-direction:column;height:100%;padding:8px;box-sizing:border-box;">'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
|
|
f'{_apply_bold(topic.title, bk)}</div>'
|
|
f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;flex:1;min-height:0;align-items:flex-start;">'
|
|
f'<div style="width:{svg_w}px;flex-shrink:0;"><div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>{img_caption}</div>'
|
|
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
|
f'</div>{keymsg_html}</div>'
|
|
)
|
|
|
|
|
|
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'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
|
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
|
|
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
|
|
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
|
|
)
|
|
return (
|
|
f'<div style="height:100%;padding:6px;font-size:{font_size}px;line-height:1.4;">'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{_apply_bold(topic.title, bk)}</div>'
|
|
f'{bullets}{keymsg_html}</div>'
|
|
)
|
|
|
|
|
|
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'<style>(.*?)</style>', rendered, re.DOTALL)
|
|
css = "\n".join(css_parts)
|
|
html = re.sub(r'<style>.*?</style>', '', 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 추출: <style> 태그를 html에서 제거하고 css로 분리
|
|
css = ""
|
|
style_matches = re.findall(r'<style>([\s\S]*?)</style>', html)
|
|
if style_matches:
|
|
css = "\n".join(style_matches) # <style> 태그 없이 CSS 내용만
|
|
html = re.sub(r'<style>[\s\S]*?</style>', '', 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 = "<table" in pcontent
|
|
has_list = "<ul" in pcontent or "<li" in pcontent
|
|
|
|
if has_table:
|
|
# 표: 원본 header + data 행을 HTML째로 추출 (공간에 맞게)
|
|
all_rows = re.findall(r'<tr[^>]*>(.*?)</tr>', pcontent, re.DOTALL)
|
|
total_rows = len(all_rows)
|
|
header_rows = [r for r in all_rows if '<th' in r]
|
|
data_rows = [r for r in all_rows if '<td' in r]
|
|
|
|
# 공간에 맞게 행 수 결정: header 약 25px, data 약 40px
|
|
# right 영역 ≈ zone height의 40~50% ≈ 100~150px
|
|
# 제목(20px) + 링크(25px) 제외하면 preview 약 80~120px
|
|
available_h = 110 # 추정 가용 높이
|
|
header_h = len(header_rows) * 25
|
|
row_h = 40 # data 행당 추정
|
|
max_data_rows = max(1, int((available_h - header_h) / row_h))
|
|
shown_data = data_rows[:max_data_rows]
|
|
|
|
if header_rows or shown_data:
|
|
preview = '<div class="rdr-table-preview">'
|
|
preview += '<table class="rdr-preview-table">'
|
|
for r in header_rows:
|
|
preview += f'<tr>{r}</tr>'
|
|
for r in shown_data:
|
|
preview += f'<tr>{r}</tr>'
|
|
preview += '</table>'
|
|
remaining = len(data_rows) - len(shown_data)
|
|
if remaining > 0:
|
|
preview += f'<div class="rdr-preview-note">외 {remaining}행</div>'
|
|
preview += '</div>'
|
|
return preview
|
|
|
|
if has_list:
|
|
# 리스트: first 3 bullets
|
|
bullets = re.findall(r'<li[^>]*>(.*?)</li>', pcontent, re.DOTALL)
|
|
bullets = [re.sub(r'<[^>]+>', '', b).strip() for b in bullets if b.strip()][:3]
|
|
if bullets:
|
|
preview = '<div class="rdr-list-preview">'
|
|
for b in bullets:
|
|
preview += f'<div class="rdr-bullet">• {b[:40]}</div>'
|
|
preview += '</div>'
|
|
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'<div class="rdr-summary-text">{summary}</div>'
|
|
|
|
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'<div class="bul" style="color:#e2e8f0;font-size:11px;line-height:1.45;">• {d}</div>' for d in desc_parts
|
|
)
|
|
cards_html += (
|
|
f'<div class="rdr-card-dark" style="background:{bg};">'
|
|
f'<div class="rdr-card-dark-title" style="color:{title_color};">'
|
|
f'{item["title"].split("(")[0].strip()}</div>'
|
|
f'{bullets_html}'
|
|
f'</div>\n'
|
|
)
|
|
html = f'<div class="rdr-cards-stack">{cards_html}</div>'
|
|
|
|
elif render_kind == "text_list":
|
|
# heading + bullet — Type B' 검증된 .bul 구조 재사용
|
|
list_html = ""
|
|
for item in items:
|
|
list_html += f'<div style="font-size:12px;font-weight:700;color:#1a365d;margin-bottom:4px;">{item["title"]}</div>\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'<div style="font-size:11px;font-weight:600;color:#1e293b;margin-top:4px;">• {heading}</div>\n'
|
|
for b in bullets:
|
|
list_html += f'<div class="bul">• {b}</div>\n'
|
|
elif desc:
|
|
if heading:
|
|
list_html += f'<div style="font-size:11px;font-weight:600;color:#1e293b;margin-top:4px;">• {heading}</div>\n'
|
|
list_html += f'<div class="bul">• {desc}</div>\n'
|
|
elif heading:
|
|
# D1만 있고 D2 없음 → ":"로 제목/설명 분리, .bul 구조
|
|
if ": " in heading:
|
|
h_title, h_desc = heading.split(": ", 1)
|
|
list_html += f'<div class="bul">• <strong>{h_title}</strong>: {h_desc}</div>\n'
|
|
else:
|
|
list_html += f'<div class="bul">• {heading}</div>\n'
|
|
html = f'<div style="padding:4px 0;">{list_html}</div>'
|
|
|
|
elif render_kind == "summary_and_popup":
|
|
html = '<div class="rdr-summary">자세한 내용은 첨부 자료를 참조하세요.</div>'
|
|
|
|
else:
|
|
html = f'<div style="color:#94a3b8;font-size:12px;">[direct render: {render_kind}]</div>'
|
|
|
|
# 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'<img src="data:{mime};base64,{b64}" alt="{alt}" '
|
|
f'style="max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;'
|
|
f'width:auto;height:auto;">'
|
|
)
|
|
# 파일 없으면 경로 텍스트로 표시
|
|
return (
|
|
f'<div style="display:flex;align-items:center;justify-content:center;'
|
|
f'height:100%;color:#94a3b8;font-size:12px;border:1px dashed #e2e8f0;border-radius:6px;">'
|
|
f'[이미지: {alt or img_path}]</div>'
|
|
)
|
|
|
|
# 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를 <style>에 합침
|
|
|
|
measure_mode=True: zone에 overflow:auto (Selenium 측정용)
|
|
measure_mode=False: zone에 overflow:hidden (최종 출력용)
|
|
font_scale: 1.0 = 기본, 0.9 = 90% 축소 등 (fit 루프용)
|
|
"""
|
|
from pathlib import Path
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
templates_dir = Path(__file__).parent.parent / "templates"
|
|
env = Environment(loader=FileSystemLoader(str(templates_dir)))
|
|
|
|
title = title_text or ctx.analysis.title or ""
|
|
conclusion = ctx.analysis.conclusion_text or ctx.analysis.core_message or ""
|
|
|
|
ps = ctx.page_structure.roles
|
|
gap_small = 8 # zone 간 간격
|
|
|
|
# Y-14: popup 파일명 매핑 구축 (role_name → popup 파일명)
|
|
# target_role 기반 (명시적 연결, 문자열 유사도 매칭 제거)
|
|
popup_file_map = {} # role_name → popup_filename
|
|
popups = ctx.normalized.popups or []
|
|
for pi, popup in enumerate(popups, 1):
|
|
popup_title = popup.title if hasattr(popup, 'title') else ""
|
|
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
|
|
popup_filename = f"첨부{pi}_{safe_title}.html"
|
|
# target_role이 명시적으로 설정되어 있으면 바로 매핑
|
|
target = popup.target_role if hasattr(popup, 'target_role') else None
|
|
if target and target in ps:
|
|
popup_file_map[target] = popup_filename
|
|
logger.info(f"[popup_map] '{popup_title}' → role '{target}' (target_role)")
|
|
elif target:
|
|
# target_role이 있지만 ps에 없음 → 부분 매칭 시도
|
|
for rname in ps:
|
|
if target[:6] in rname or rname[:6] in target:
|
|
popup_file_map[rname] = popup_filename
|
|
logger.info(f"[popup_map] '{popup_title}' → role '{rname}' (target_role 부분 매칭)")
|
|
break
|
|
|
|
# recipe 조회 + 블록 렌더링
|
|
from src.section_parser import get_recipe_for_schema
|
|
zone_order = [] # (zone_type, role_name, html, css)
|
|
for role_name, info in ps.items():
|
|
if not isinstance(info, dict):
|
|
continue
|
|
zone = info.get("zone", "")
|
|
schema = info.get("group_schema", "")
|
|
recipe = get_recipe_for_schema(schema)
|
|
recipe_type = recipe.get("recipe", "single_block") if recipe else "single_block"
|
|
|
|
if recipe_type == "two_col_text_visual":
|
|
# *_plus_visual → direct render preferred, block fallback
|
|
html, css = _recipe_direct_render("parallel_cards", role_name, info, ctx)
|
|
if not html:
|
|
# fallback: 블록 선택
|
|
block_id = _select_block_for_recipe(recipe, "blocks_left", role_name, info, ctx)
|
|
if block_id:
|
|
html, css = _render_block_by_id(block_id, role_name, ctx)
|
|
logger.info(f"[Stage 2] {role_name}: direct render 실패 → block fallback '{block_id}'")
|
|
else:
|
|
block_id = None
|
|
logger.info(f"[Stage 2] {role_name}: direct render preferred")
|
|
visual_html = _render_visual_anchor(role_name, info, ctx)
|
|
ratio = recipe.get("ratio", "7:3")
|
|
left_r, right_r = [int(x) for x in ratio.split(":")]
|
|
left_pct = int(left_r / (left_r + right_r) * 100)
|
|
right_pct = 100 - left_pct
|
|
if html:
|
|
composed = (
|
|
f'<div style="display:flex;gap:12px;height:100%;align-items:stretch;">'
|
|
f'<div style="flex:0 0 {left_pct - 1}%;min-width:0;display:flex;flex-direction:column;justify-content:center;">{html}</div>'
|
|
f'<div style="flex:0 0 {right_pct - 1}%;display:flex;align-items:center;justify-content:center;overflow:hidden;">'
|
|
f'{visual_html}</div></div>'
|
|
)
|
|
zone_order.append((zone, role_name, composed, css))
|
|
logger.info(f"[Stage 2] {role_name} → two_col_text_visual, {'block='+block_id if block_id else 'direct_render'}")
|
|
else:
|
|
logger.warning(f"[Stage 2] {role_name} → two_col_text_visual 렌더링 실패")
|
|
elif recipe_type == "two_col_text_detail":
|
|
# recipe executor: 좌측=첫 sub_title 본문, 우측=나머지 sub_title 요약 + popup
|
|
sub_titles = info.get("sub_titles", [])
|
|
sub_types = info.get("sub_types", [])
|
|
|
|
# left = first subsection only (content slicing)
|
|
left_sub_type = sub_types[0].get("sub_type", "") if sub_types else ""
|
|
if left_sub_type == "text_list_candidate":
|
|
html, css = _recipe_direct_render("text_list", role_name, info, ctx, subsection_index=0)
|
|
block_id = None
|
|
logger.info(f"[Stage 2] {role_name}: left=first subsection → direct render")
|
|
else:
|
|
block_id = _select_block_for_recipe(recipe, "blocks_left", role_name, info, ctx)
|
|
if block_id:
|
|
html, css = _render_block_by_id(block_id, role_name, ctx)
|
|
text_only = re.sub(r'<[^>]+>', '', html).strip()
|
|
if len(text_only) < 30:
|
|
html, css = _recipe_direct_render("text_list", role_name, info, ctx, subsection_index=0)
|
|
block_id = None
|
|
else:
|
|
html, css = _recipe_direct_render("text_list", role_name, info, ctx, subsection_index=0)
|
|
|
|
# right sub_type 확인 — visual_detail이면 summary + popup
|
|
right_sub_type = sub_types[1].get("sub_type", "") if len(sub_types) > 1 else ""
|
|
|
|
# 우측: 제목(+자세히보기 우측) + 표 preview
|
|
right_parts = []
|
|
right_title = sub_titles[1] if len(sub_titles) > 1 else ""
|
|
pf = popup_file_map.get(role_name, "")
|
|
|
|
# 헤더 라인: 제목 좌측 + 자세히보기 우측 (한 줄)
|
|
if right_title or pf:
|
|
link_html = f'<a href="{pf}" class="rdr-detail-link">자세히보기 →</a>' if pf else ""
|
|
right_parts.append(
|
|
f'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">'
|
|
f'<div style="font-weight:700;font-size:12px;color:#1a365d;">{right_title}</div>'
|
|
f'{link_html}</div>'
|
|
)
|
|
|
|
# 표 preview (전체 너비)
|
|
if pf:
|
|
preview_html = _build_detail_preview(role_name, popups)
|
|
if preview_html:
|
|
right_parts.append(preview_html)
|
|
|
|
right_html = "\n".join(right_parts) if right_parts else ""
|
|
|
|
ratio = recipe.get("ratio", "6:4")
|
|
left_r, right_r = [int(x) for x in ratio.split(":")]
|
|
left_pct = int(left_r / (left_r + right_r) * 100)
|
|
right_pct = 100 - left_pct
|
|
|
|
if html:
|
|
composed = (
|
|
f'<div style="display:flex;gap:16px;height:100%;align-items:flex-start;">'
|
|
f'<div style="flex:0 0 {left_pct - 1}%;min-width:0;">{html}</div>'
|
|
f'<div style="flex:0 0 {right_pct - 1}%;padding:0 8px;">'
|
|
f'{right_html}</div></div>'
|
|
)
|
|
zone_order.append((zone, role_name, composed, css))
|
|
logger.info(f"[Stage 2] {role_name} → recipe two_col_text_detail, {'block='+block_id if block_id else 'direct_render'}")
|
|
else:
|
|
logger.warning(f"[Stage 2] {role_name} → recipe two_col_text_detail 렌더링 실패")
|
|
else:
|
|
# single_block: 기존 경로
|
|
html, css = render_block_for_role(role_name, ctx)
|
|
if html:
|
|
zone_order.append((zone, role_name, html, css))
|
|
logger.info(f"[Stage 2] {role_name} → 블록 렌더링 성공")
|
|
else:
|
|
logger.warning(f"[Stage 2] {role_name} → 블록 렌더링 실패")
|
|
|
|
# zone 순서: top → bottom/bottom_left/bottom_right → (footer 없음)
|
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
|
zone_order.sort(key=lambda x: zone_priority.get(x[0], 9))
|
|
|
|
# .slide-body 안에 들어갈 HTML 구성
|
|
body_parts = []
|
|
all_css = []
|
|
|
|
# Y-11f: zone wrapper — % 기반 + block height:100% (참고 결과물 방식)
|
|
# weight를 % 비율로 변환
|
|
total_weight = sum(
|
|
ps.get(role_name, {}).get("weight", 0)
|
|
for _, role_name, _, _ in zone_order
|
|
if isinstance(ps.get(role_name, {}), dict)
|
|
)
|
|
if total_weight <= 0:
|
|
total_weight = len(zone_order) or 1
|
|
|
|
for i, (zone, role_name, html, css) in enumerate(zone_order):
|
|
info = ps.get(role_name, {})
|
|
weight = info.get("weight", 0.5) if isinstance(info, dict) else 0.5
|
|
height_pct = int(weight / total_weight * 98) # 98% (2% 여백)
|
|
|
|
overflow_style = "overflow:auto" if measure_mode else "overflow:hidden"
|
|
margin = "margin-bottom:1%;" if i < len(zone_order) - 1 else ""
|
|
|
|
# zone 제목 (MDX 원본 ## 제목) — 대제목과 같은 시작선 (slide-body left:40 + padding:12 = 52px = 대제목 left)
|
|
zone_title_html = (
|
|
f'<div style="font-weight:700;font-size:13px;color:#1a365d;margin-bottom:8px;padding-left:12px;">'
|
|
f'{role_name}</div>'
|
|
)
|
|
|
|
# Y-14: popup 링크 (recipe가 자체 처리하지 않는 경우에만)
|
|
popup_link_html = ""
|
|
recipe_handles_popup = recipe_type in ("two_col_text_detail",)
|
|
if role_name in popup_file_map and not recipe_handles_popup:
|
|
pf = popup_file_map[role_name]
|
|
popup_link_html = (
|
|
f'<div class="popup-link">'
|
|
f'<a href="{pf}">자세히보기 →</a></div>'
|
|
)
|
|
|
|
# 블록 wrapper: height:100%로 zone 채움
|
|
# Y-11g: 글씨 크기는 블록 CSS 고정값 (font_scale 아님)
|
|
zone_inner_height = "calc(100% - 28px)" if not popup_link_html else "calc(100% - 48px)"
|
|
body_parts.append(
|
|
f'<div style="height:{height_pct}%;{margin}padding-top:4px;">'
|
|
f'{zone_title_html}'
|
|
f'<div class="zone-{zone}" data-role="{role_name}" '
|
|
f'style="height:{zone_inner_height};{overflow_style};">'
|
|
f'{html}</div>'
|
|
f'{popup_link_html}'
|
|
f'</div>'
|
|
)
|
|
if css:
|
|
all_css.append(css)
|
|
|
|
# Y-11g: 블록별 slide_font CSS 오버라이드 (글씨 크기 고정, font_scale 아님)
|
|
slide_font_css = """
|
|
/* ══ 공통 레이아웃 계약 (모든 블록에 적용) ══ */
|
|
/* 블록별 글씨/크기는 각 블록 템플릿 CSS에 있음. 여기는 공통 규칙만. */
|
|
|
|
/* zone wrapper: 중제목보다 안쪽, 타이트 */
|
|
.zone-top, .zone-bottom, .zone-sidebar {
|
|
padding: 0 8px 0 12px;
|
|
}
|
|
|
|
/* 불릿 hanging indent: 두번째 줄 = 첫줄 문장 시작선 */
|
|
.bul, .pp2-body-text, .cdg-bullet {
|
|
padding-left: 14px;
|
|
text-indent: -14px;
|
|
font-size: 11px;
|
|
color: #475569;
|
|
line-height: 1.55;
|
|
margin-bottom: 1px;
|
|
word-break: keep-all;
|
|
}
|
|
.bul strong { color: #1e293b; font-weight: 700; }
|
|
|
|
/* 블록이 zone height에 100% 채움 */
|
|
.block-p3c, .block-pp2, .block-cdg, .block-table-figma { height: 100%; }
|
|
.p3c-col, .pp2-col { min-height: 0; }
|
|
|
|
/* Y-14: popup 자세히보기 링크 */
|
|
.popup-link {
|
|
text-align: right;
|
|
padding: 4px 24px 0 0;
|
|
}
|
|
.popup-link a {
|
|
font-size: 11px;
|
|
color: #2563eb;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
.popup-link a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
"""
|
|
all_css.append(slide_font_css)
|
|
|
|
body_html = "\n".join(body_parts)
|
|
extra_css = "\n".join(all_css)
|
|
|
|
# slide-base.html 로드 → HTML 주석 제거 → {% block body %} 치환 → Jinja2 렌더링
|
|
try:
|
|
slide_base_path = templates_dir / "blocks" / "slide-base.html"
|
|
slide_base_raw = slide_base_path.read_text(encoding="utf-8")
|
|
# HTML 주석 제거 (주석 안에 Jinja2 구문이 섞여있어서 렌더링 오류 방지)
|
|
slide_base_raw = re.sub(r'<!--[\s\S]*?-->', '', slide_base_raw)
|
|
# {% block body %}{% endblock %} → body_html로 치환
|
|
slide_base_raw = slide_base_raw.replace(
|
|
"{% block body %}{% endblock %}",
|
|
body_html,
|
|
)
|
|
# Jinja2로 title, footer_text 등 변수만 렌더링
|
|
from jinja2 import Template
|
|
template = Template(slide_base_raw)
|
|
slide_html = template.render(
|
|
title=title,
|
|
footer_text=conclusion,
|
|
footer_pill_bg="",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[Stage 2] slide-base 렌더링 실패: {e}")
|
|
slide_html = _assemble_slide_base_fallback(title, conclusion, body_html, extra_css)
|
|
|
|
# 블록 CSS를 head의 첫 </style> 앞에 삽입 (body 안의 </style>은 건드리지 않음)
|
|
if extra_css and '</style>' in slide_html:
|
|
slide_html = slide_html.replace('</style>', f'\n{extra_css}\n</style>', 1) # 첫 번째만
|
|
|
|
# A-3 safety net: body 안에 <style> 잔존하면 head로 끌어올림
|
|
body_start = slide_html.find('<body')
|
|
if body_start > 0:
|
|
head_part = slide_html[:body_start]
|
|
body_part = slide_html[body_start:]
|
|
body_styles = re.findall(r'<style>([\s\S]*?)</style>', body_part)
|
|
if body_styles:
|
|
body_part = re.sub(r'<style>[\s\S]*?</style>', '', body_part)
|
|
extra_body_css = "\n".join(body_styles)
|
|
head_part = head_part.replace('</style>', f'\n{extra_body_css}\n</style>', 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"""<!DOCTYPE html>
|
|
<html lang="ko"><head><meta charset="UTF-8">
|
|
<meta name="viewport" content="width=1280">
|
|
<style>
|
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
|
body{{font-family:'Noto Sans KR',sans-serif;background:#e8ecf0;
|
|
display:flex;justify-content:center;align-items:center;min-height:100vh;word-break:keep-all;}}
|
|
.slide{{width:1280px;height:720px;position:relative;overflow:hidden;background:#fff;
|
|
box-shadow:0 4px 20px rgba(0,0,0,0.15);}}
|
|
.slide-title{{position:absolute;left:52px;top:22px;width:calc(100%-104px);
|
|
font-weight:700;font-size:22px;color:#1e293b;z-index:2;}}
|
|
.slide-body{{position:absolute;left:40px;top:65px;width:calc(100%-80px);height:590px;z-index:1;overflow:hidden;}}
|
|
.slide-footer{{position:absolute;left:50px;bottom:8px;width:calc(100%-100px);height:41px;
|
|
border-radius:999px;background:linear-gradient(90deg,#3b3523 5%,#263a2a 50%,#113f31 95%);
|
|
display:flex;align-items:center;justify-content:center;z-index:2;}}
|
|
.slide-footer-text{{font-size:20px;font-weight:700;color:#fff;text-align:center;}}
|
|
{extra_css}
|
|
</style></head><body>
|
|
<div class="slide">
|
|
<div class="slide-title">{title}</div>
|
|
<div class="slide-body">{body_html}</div>
|
|
<div class="slide-footer"><div class="slide-footer-text">{conclusion}</div></div>
|
|
</div></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"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
<style>
|
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
|
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
|
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
|
|
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
|
|
{css_block}
|
|
</style></head><body>
|
|
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
|
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
|
|
|
|
<div class="area-body" style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border:2px solid #dc2626;border-radius:6px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#dc2626;opacity:0.5;">배경 ({body_w}x{bg_h}px)</span>
|
|
{role_htmls.get("배경", "")}</div>
|
|
|
|
<div class="area-body" style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border:2px solid #2563eb;border-radius:6px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#2563eb;opacity:0.5;">본심 ({body_w}x{core_h}px)</span>
|
|
{role_htmls.get("본심", "")}</div>
|
|
|
|
<div class="area-sidebar" style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border:2px solid #16a34a;border-radius:6px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#16a34a;opacity:0.5;">첨부 ({sidebar_w}x{sb_h}px)</span>
|
|
{role_htmls.get("첨부", "")}</div>
|
|
|
|
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border:2px solid #7c3aed;border-radius:8px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#7c3aed;opacity:0.5;">결론 ({inner_w}x{concl_h}px)</span>
|
|
{role_htmls.get("결론", "")}</div>
|
|
|
|
</div></body></html>"""
|
|
|
|
|
|
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'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
|
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"<strong>{kw}</strong>")
|
|
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'<strong>\1</strong>', 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'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
|
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
|
|
for item in sec_items
|
|
)
|
|
if sec_title:
|
|
bullets += (
|
|
f'<div style="background:{bg};border-radius:{int(font_size*0.4)}px;'
|
|
f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
|
|
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
|
|
f'margin-bottom:{int(font_size*0.3)}px;">{_bold(sec_title, rn)}</div>'
|
|
f'{items_html}</div>\n'
|
|
)
|
|
else:
|
|
bullets += items_html
|
|
else:
|
|
for _, sec_items in sections:
|
|
for item in sec_items:
|
|
bullets += (
|
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
|
f'<span style="font-size:{font_size}px;">• {item}</span></div>\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'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
|
|
|
|
# 이미지 블록
|
|
img_block = ""
|
|
if has_image and img_html:
|
|
img_block = (
|
|
f'<div style="width:{img_w}px;flex-shrink:0;">'
|
|
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
|
|
f'{caption_html}</div>'
|
|
)
|
|
|
|
topic_title = _bold(topic_title_from_section or rn, rn)
|
|
|
|
top_html = (
|
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
|
f'display:flex;flex-direction:column;justify-content:space-between;">'
|
|
f'{popup_html}'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
|
|
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
|
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
|
f'{img_block}</div></div>'
|
|
)
|
|
|
|
# ── 하단: 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'<strong>\1</strong>', 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'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
|
|
|
bl_html = (
|
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title, rn)}</div>'
|
|
f'<div style="line-height:1.55;color:#333;">{bul}</div></div>'
|
|
)
|
|
|
|
# 하단 우측 + 표 요약
|
|
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'<strong>\1</strong>', sub_content_br)
|
|
|
|
# 팝업 링크
|
|
popup_link_title = f"{sub_title_br} 바로가기"
|
|
popup_html_br = (
|
|
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
|
|
f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
|
|
)
|
|
|
|
# Kei가 이 역할을 popup 대상으로 결정했으면 → 콘텐츠 대신 팝업 링크만
|
|
if rn in popup_roles:
|
|
bul = (
|
|
f'<div style="padding:{gap_small}px;text-align:center;color:#64748b;'
|
|
f'font-size:{font_size}px;margin-top:{gap_small*2}px;">'
|
|
f'상세 내용은 팝업에서 확인</div>'
|
|
)
|
|
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'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\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'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.5)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
|
|
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'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{_bold(str(cell), rn)}</div>'
|
|
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
|
|
table_html_br = (
|
|
f'<div style="margin-top:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
|
|
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
|
|
f'{rows_html}</div>'
|
|
)
|
|
elif fmt == "bullets":
|
|
items = ts_data.get("items", [])
|
|
table_html_br = "".join(
|
|
f'<div style="padding-left:{int(font_size*1.2)}px;font-size:{font_size-1}px;margin-bottom:1px;">• {_bold(str(item), rn)}</div>'
|
|
for item in items
|
|
)
|
|
elif fmt == "text":
|
|
table_html_br = f'<div style="font-size:{font_size-1}px;color:#475569;margin-top:{int(font_size*0.5)}px;">{_bold(str(ts_data.get("summary", "")), rn)}</div>'
|
|
|
|
br_html = (
|
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
|
f'display:flex;flex-direction:column;">'
|
|
f'{popup_html_br}'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title_br, rn)}</div>'
|
|
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bul}</div>'
|
|
f'{table_html_br}</div>'
|
|
)
|
|
|
|
# ── 결론 ──
|
|
footer_html = ""
|
|
if footer_role:
|
|
rn = footer_role[0]
|
|
footer_html = (
|
|
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
|
|
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
|
|
f'display:flex;align-items:center;justify-content:center;">'
|
|
f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
|
|
)
|
|
|
|
# ── HTML 조립 ──
|
|
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
|
|
|
|
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
<style>
|
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
|
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
|
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
|
|
</style></head><body>
|
|
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
|
|
|
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
|
|
|
|
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[0]};opacity:0.5;">상단 ({inner_w}x{top_h}px)</span>
|
|
{top_html}</div>
|
|
|
|
<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
|
|
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{_bold(bottom_title, "")}</div>
|
|
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
|
|
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
|
|
{bl_html}</div>
|
|
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
|
|
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
|
|
{br_html}</div>
|
|
</div></div>
|
|
|
|
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[3]};opacity:0.5;">결론 ({inner_w}x{footer_h_px}px)</span>
|
|
{footer_html}</div>
|
|
|
|
</div></body></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'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
|
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"<strong>{kw}</strong>")
|
|
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'<strong>\1</strong>', 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'<strong>{_bold(title_text, rn)}</strong>')
|
|
else:
|
|
# 02번 방식: D1이 카드 제목
|
|
if current_section[0] or current_section[1]:
|
|
sections.append(current_section)
|
|
current_section = (_bold(title_text, rn), [])
|
|
elif re.match(r'^D[2-9]:\s*', line):
|
|
# D2+ = 하위 불릿 = 본문
|
|
clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
|
|
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('<strong>'):
|
|
# D1: 1번 들여쓰기, bold, 불릿 없음
|
|
items_html += (
|
|
f'<div style="padding-left:{int(indent_body*0.5)}px;margin-bottom:1px;">'
|
|
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;font-weight:600;">{item}</span></div>'
|
|
)
|
|
else:
|
|
# D2: 2번 들여쓰기, 일반, 불릿
|
|
items_html += (
|
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
|
f'<span style="color:{text_color};font-size:{font_size-2}px;line-height:1.5;">• {item}</span></div>'
|
|
)
|
|
if sec_title:
|
|
bullets += (
|
|
f'<div style="flex:1;background:{bg};border-radius:{int(font_size*0.4)}px;'
|
|
f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
|
|
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
|
|
f'margin-bottom:{int(font_size*0.3)}px;">{_bold(sec_title, rn)}</div>'
|
|
f'{items_html}</div>\n'
|
|
)
|
|
else:
|
|
bullets += items_html
|
|
else:
|
|
for _, sec_items in sections:
|
|
for item in sec_items:
|
|
bullets += (
|
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
|
f'<span style="font-size:{font_size}px;">• {item}</span></div>\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'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
|
|
|
|
# 이미지 블록
|
|
img_block = ""
|
|
if has_image and img_html:
|
|
img_block = (
|
|
f'<div style="width:{img_w}px;flex-shrink:0;">'
|
|
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
|
|
f'{caption_html}</div>'
|
|
)
|
|
|
|
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'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
|
f'display:flex;flex-direction:column;">'
|
|
f'{popup_html}'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
|
|
f'<div style="display:flex;flex-direction:row;gap:{card_gap}px;flex:1;">{bullets}</div></div>'
|
|
)
|
|
else:
|
|
top_html = (
|
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
|
f'display:flex;flex-direction:column;justify-content:space-between;">'
|
|
f'{popup_html}'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
|
|
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
|
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
|
f'{img_block}</div></div>'
|
|
)
|
|
|
|
# ── 하단: normalized.sections에서 직접 매핑 ──
|
|
bottom_title = ""
|
|
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'<strong>\1</strong>', 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'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
|
|
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'<strong>\1</strong>', str(cell))
|
|
c_color = "#1e40af" if ci_idx == 0 else "#475569"
|
|
c_weight = "600" if ci_idx == 0 else "400"
|
|
cells += f'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.3)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{cell_clean}</div>'
|
|
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
|
|
|
|
table_html_bl = (
|
|
f'<div style="margin-bottom:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
|
|
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
|
|
f'{rows_html}</div>'
|
|
)
|
|
|
|
# 불릿: 표 셀과 중복되는 텍스트 제외
|
|
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'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
|
|
|
bl_html = (
|
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;overflow:hidden;">'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title, rn)}</div>'
|
|
f'{table_html_bl}'
|
|
f'<div style="line-height:1.55;color:#333;">{bul}</div></div>'
|
|
)
|
|
|
|
# 하단 우측 — 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'<strong>\1</strong>', 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'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
|
|
|
br_html = (
|
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
|
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title_br, rn)}</div>'
|
|
f'<div style="line-height:1.55;color:#333;flex:1;">{bul}</div></div>'
|
|
)
|
|
|
|
|
|
# ── 결론 ──
|
|
footer_html = ""
|
|
if footer_role:
|
|
rn = footer_role[0]
|
|
footer_html = (
|
|
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
|
|
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
|
|
f'display:flex;align-items:center;justify-content:center;">'
|
|
f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
|
|
)
|
|
|
|
# ── HTML 조립 ──
|
|
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
|
|
|
|
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
<style>
|
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
|
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
|
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
|
|
</style></head><body>
|
|
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
|
|
|
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
|
|
|
|
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[0]};opacity:0.5;">상단 ({inner_w}x{top_h}px)</span>
|
|
{top_html}</div>
|
|
|
|
<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
|
|
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{_bold(bottom_title, "")}</div>
|
|
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
|
|
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
|
|
{bl_html}</div>
|
|
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
|
|
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
|
|
{br_html}</div>
|
|
</div></div>
|
|
|
|
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
|
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[3]};opacity:0.5;">결론 ({inner_w}x{footer_h_px}px)</span>
|
|
{footer_html}</div>
|
|
|
|
</div></body></html>"""
|