"""DA-11 + DA-21: 슬라이드 조합 렌더러.
블록 배치 명세(JSON)를 받아 Jinja2로 HTML을 생성한다.
- 다중 페이지 지원
- 카테고리 경로 지원 (blocks/{category}/{name}.html)
- _legacy fallback (기존 경로 호환)
- 같은 area 블록 그룹핑 (겹침 방지)
"""
from __future__ import annotations
import logging
from collections import OrderedDict
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from src import catalog as _catalog_mod
logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
STATIC_DIR = Path(__file__).parent.parent / "static"
# 카테고리 검색 순서
BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
# id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache)
_CATALOG_MAP: dict[str, str] | None = None
_CATALOG_MAP_MTIME: float = 0.0
# Phase R: variant별 template 경로 캐시 (renderer-local projection)
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
_CATALOG_VARIANT_MAP_MTIME: float = 0.0
def _load_catalog_map() -> dict[str, str]:
"""블록 id → template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
catalog 파일 읽기와 mtime 캐싱은 ``src.catalog`` 가 단독 소유. 본 함수는
그 결과를 ``id → template`` 형태로 변환한 renderer-local projection 캐시만
유지하며, projection 무효화는 ``src.catalog.get_catalog_mtime()`` 키잉.
"""
global _CATALOG_MAP, _CATALOG_MAP_MTIME
blocks = _catalog_mod.load_blocks()
current_mtime = _catalog_mod.get_catalog_mtime()
if _CATALOG_MAP is not None and _CATALOG_MAP_MTIME == current_mtime:
return _CATALOG_MAP
_CATALOG_MAP_MTIME = current_mtime
_CATALOG_MAP = {}
for block in blocks:
block_id = block.get("id", "")
template = block.get("template", "")
if block_id and template:
_CATALOG_MAP[block_id] = template
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
return _CATALOG_MAP
def _load_catalog_map_with_variants() -> dict[str, str]:
"""variant별 template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
키: "block_id--variant_id" → 값: template 경로.
"""
global _CATALOG_VARIANT_MAP, _CATALOG_VARIANT_MAP_MTIME
blocks = _catalog_mod.load_blocks()
current_mtime = _catalog_mod.get_catalog_mtime()
if _CATALOG_VARIANT_MAP is not None and _CATALOG_VARIANT_MAP_MTIME == current_mtime:
return _CATALOG_VARIANT_MAP
_CATALOG_VARIANT_MAP_MTIME = current_mtime
_CATALOG_VARIANT_MAP = {}
for block in blocks:
block_id = block.get("id", "")
for variant in block.get("variants", []):
vid = variant.get("id", "default")
vtemplate = variant.get("template", "")
if vid != "default" and vtemplate:
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
return _CATALOG_VARIANT_MAP
def create_jinja_env() -> Environment:
"""Jinja2 환경 생성."""
return Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=False,
)
def _resolve_template_path(env: Environment, block_type: str, variant: str = "default") -> str | None:
"""블록 타입 + variant로 템플릿 경로를 찾는다.
Phase R: variant가 지정되면 variant 전용 템플릿을 우선 탐색.
variant 템플릿이 없으면 기존 블록 템플릿으로 fallback.
검색 순서:
0-v. catalog.yaml의 variant별 template 경로 (Phase R, 최우선)
0. catalog.yaml 매핑 (id → template 경로)
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등)
2. 카테고리 폴더 검색 (blocks/{category}/{block_type}.html)
3. _legacy fallback (blocks/_legacy/{block_type}.html)
4. 루트 fallback (blocks/{block_type}.html)
"""
candidates = []
# 0-v. Phase R: variant 전용 템플릿 우선 탐색
if variant and variant != "default":
catalog_map_full = _load_catalog_map_with_variants()
variant_key = f"{block_type}--{variant}"
if variant_key in catalog_map_full:
candidates.append(catalog_map_full[variant_key])
# 카테고리 폴더에서 variant 파일 탐색
for category in BLOCK_CATEGORIES:
candidates.append(f"blocks/{category}/{block_type}--{variant}.html")
# 0. catalog.yaml에서 id → template 매핑 조회
catalog_map = _load_catalog_map()
if block_type in catalog_map:
catalog_path = catalog_map[block_type]
candidates.append(catalog_path)
# .html 확장자 없는 경우 대비
if not catalog_path.endswith(".html"):
candidates.append(f"{catalog_path}.html")
# 1. 이미 카테고리 경로가 포함된 경우 (예: "cards/card-icon-desc")
if "/" in block_type:
candidates.append(f"blocks/{block_type}.html")
candidates.append(f"blocks/{block_type}") # .html 이미 포함된 경우
# 2. 카테고리 폴더 검색
for category in BLOCK_CATEGORIES:
candidates.append(f"blocks/{category}/{block_type}.html")
# 3. _legacy fallback
candidates.append(f"blocks/_legacy/{block_type}.html")
# 4. 루트 fallback
candidates.append(f"blocks/{block_type}.html")
for path in candidates:
try:
env.get_template(path)
return path
except Exception:
continue
return None
def _preprocess_svg_data(block_type: str, block_data: dict[str, Any]) -> dict[str, Any]:
"""P2-B: SVG 시각화 블록의 좌표를 사전 계산한다.
venn-diagram: items[]에 cx, cy, r 좌표 추가 + outer_r 등 레이아웃 데이터
다른 블록: 변경 없이 그대로 반환
"""
SVG_BLOCKS = {"venn-diagram", "relationship"}
if block_type not in SVG_BLOCKS:
return block_data
items = block_data.get("items", [])
if not items:
return block_data
# items에 이미 cx가 있으면 (수동 지정) 그대로 사용
if items[0].get("cx") is not None:
return block_data
try:
from src.svg_calculator import prepare_venn_data
prepared = prepare_venn_data(
items=items,
center_label=block_data.get("center_label", ""),
center_sub=block_data.get("center_sub", ""),
description=block_data.get("description", ""),
)
# 기존 block_data에 계산 결과 병합
block_data.update(prepared)
logger.info(
f"SVG 좌표 계산 완료: {block_type}, "
f"{len(items)}개 원소, outer_r={prepared.get('outer_r')}"
)
except Exception as e:
logger.warning(f"SVG 좌표 계산 실패 ({block_type}): {e}. Phase 1 fallback.")
# fallback: 좌표 없이 Jinja2에 전달 → Phase 1 고정 SVG
return block_data
def _group_blocks_by_area(
blocks: list[dict[str, Any]],
container_specs: dict | None = None,
) -> list[dict[str, Any]]:
"""Phase O: 같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.
container_specs가 있으면 body zone 안에 역할별 고정 높이 컨테이너를 생성.
"""
grouped = OrderedDict()
for block in blocks:
area = block["area"]
if area not in grouped:
grouped[area] = {"area": area, "blocks": []}
grouped[area]["blocks"].append(block)
result = []
for area, data in grouped.items():
block_list = data["blocks"]
# Phase O: body zone에 컨테이너 스펙 적용
if container_specs and area in ("body", "left", "right", "hero", "detail"):
container_htmls = []
assigned_ids = set()
role_order = ["배경", "본심"]
for role in role_order:
spec = container_specs.get(role)
if not spec or spec.zone != area:
continue
# 이 역할에 속하는 블록 찾기 (topic_id로 매칭)
role_blocks = [
b for b in block_list
if b.get("_topic_id") in spec.topic_ids
and id(b) not in assigned_ids
]
# topic_id 매칭 안 되면 순서로 매칭
if not role_blocks:
for b in block_list:
if id(b) not in assigned_ids:
role_blocks.append(b)
if len(role_blocks) >= len(spec.topic_ids):
break
for b in role_blocks:
assigned_ids.add(id(b))
if not role_blocks:
continue
inner_html = "\n".join(b["html"] for b in role_blocks)
font_size = spec.block_constraints.get("font_size_px", 15.2)
padding = spec.block_constraints.get("padding_px", 20)
container_htmls.append(
f'
\n'
f'{inner_html}\n
'
)
# 미배정 블록
for b in block_list:
if id(b) not in assigned_ids:
container_htmls.append(b["html"])
html = "\n".join(container_htmls)
elif len(block_list) == 1:
html = block_list[0]["html"]
else:
inner = "\n".join(b["html"] for b in block_list)
html = (
f'
\n'
f'{inner}\n
'
)
result.append({"area": area, "html": html})
return result
def render_multi_page(layout_concept: dict[str, Any]) -> str:
"""다중 페이지 레이아웃 컨셉으로 완성 HTML을 생성한다."""
env = create_jinja_env()
title = layout_concept.get("title", "슬라이드")
pages = layout_concept.get("pages", [])
if not pages:
logger.warning("페이지가 없습니다. 빈 HTML 반환.")
return "
페이지가 없습니다.
"
pages_rendered = []
for page_idx, page in enumerate(pages):
blocks_raw = []
for block in page.get("blocks", []):
block_type = block.get("type", "")
block_data = block.get("data", {})
# 높이 자동 조치: _strip_sub_text 플래그 처리
if block_data.get("_strip_sub_text"):
block_data.pop("sub_text", None)
block_data.pop("_strip_sub_text", None)
# P2-B: SVG 시각화 블록은 좌표 사전 계산
block_data = _preprocess_svg_data(block_type, block_data)
# DA-21: 카테고리 경로 검색
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
if template_path:
try:
block_template = env.get_template(template_path)
rendered_html = block_template.render(**block_data)
except Exception as e:
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
rendered_html = (
f'