"""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'
블록 렌더링 실패: {block_type}
' ) else: logger.warning(f"블록 템플릿 미발견: {block_type}") rendered_html = ( f'
블록 템플릿 미발견: {block_type}
' ) # Phase N-3: max-height CSS 래퍼 제거. # 콘텐츠는 렌더링 전에 _max_chars로 맞춘다. CSS로 사후에 자르지 않는다. # overflow는 slide_measurer가 scrollHeight > clientHeight로 감지한다. blocks_raw.append({ "area": block.get("area", "main"), "html": rendered_html, "_topic_id": block.get("topic_id"), # Phase O: 컨테이너 매칭용 }) # Phase O: 비중 기반 컨테이너 그룹핑 page_container_specs = layout_concept.get("_container_specs") blocks_grouped = _group_blocks_by_area(blocks_raw, container_specs=page_container_specs) # A-1: area별 CSS 변수 override 주입 area_styles = page.get("area_styles", {}) for grouped_block in blocks_grouped: grouped_block["style_override"] = area_styles.get( grouped_block["area"], "" ) pages_rendered.append({ "grid_areas": page.get("grid_areas", "'main'"), "grid_columns": page.get("grid_columns", "1fr"), "grid_rows": page.get("grid_rows", "auto"), "blocks": blocks_grouped, "page_number": page_idx + 1, }) base_template = env.get_template("blocks/slide-base.html") html = base_template.render( slide_title=title, pages=pages_rendered, total_pages=len(pages_rendered), ) # CSS 인라인 삽입 tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8") base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8") base_css = base_css.replace("@import url('./tokens.css');", "") inline_css = f"" html = html.replace( '', inline_css, ) logger.info(f"슬라이드 렌더링 완료: {title}, {len(pages_rendered)}페이지") return html def render_slide(layout: dict[str, Any]) -> str: """하위 호환 렌더링. pages 구조가 있으면 render_multi_page로 위임.""" if "pages" in layout: return render_multi_page(layout) env = create_jinja_env() blocks_raw = [] for block in layout.get("blocks", []): block_type = block["type"] block_data = block.get("data", {}) 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'
블록 렌더링 실패: {block_type}
' ) else: logger.warning(f"블록 템플릿 미발견: {block_type}") rendered_html = ( f'
블록 템플릿 미발견: {block_type}
' ) blocks_raw.append({ "area": block["area"], "html": rendered_html, }) blocks_grouped = _group_blocks_by_area(blocks_raw) base_template = env.get_template("blocks/slide-base.html") html = base_template.render( slide_title=layout.get("title", ""), pages=[{ "grid_areas": layout.get("grid_areas", "'header' 'main' 'footer'"), "grid_columns": layout.get("grid_columns", "1fr"), "grid_rows": layout.get("grid_rows", "auto 1fr auto"), "blocks": blocks_grouped, "page_number": 1, }], total_pages=1, ) tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8") base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8") base_css = base_css.replace("@import url('./tokens.css');", "") inline_css = f"" html = html.replace( '', inline_css, ) logger.info(f"슬라이드 렌더링 완료: {layout.get('title', 'untitled')}") return html def render_block_in_container( block_type: str, data: dict[str, Any], container_height_px: int, container_width_px: int, font_size_px: float = 15.2, padding_px: int = 20, ) -> str: """Phase P: 단일 블록을 컨테이너 안에서 렌더링한다. 컨테이너 크기에 맞게 CSS가 적용된 완전한 HTML을 반환. Selenium으로 스크린샷 캡처 + 높이 측정에 사용. """ block_html = render_standalone_block(block_type, data) tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8") base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8") base_css = base_css.replace("@import url('./tokens.css');", "") return f"""
{block_html}
""" def render_slide_from_html( generated: dict[str, str], analysis: dict[str, Any], preset: dict[str, Any], ) -> str: """Phase R': AI가 생성한 HTML 조각을 슬라이드 프레임에 삽입하여 완성 HTML 반환. 블록 템플릿을 렌더링하지 않는다. AI가 생성한 body/sidebar/footer HTML을 직접 삽입. Args: generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."} analysis: 1단계 분석 결과 (title) preset: 프리셋 정보 (grid_areas, grid_columns, grid_rows) """ tokens_css = "" tokens_path = Path(__file__).parent.parent / "static" / "tokens.css" if tokens_path.exists(): tokens_css = tokens_path.read_text(encoding="utf-8") base_css = "" base_path = Path(__file__).parent.parent / "static" / "base.css" if base_path.exists(): # @import 제거 (inline으로 포함하므로) raw = base_path.read_text(encoding="utf-8") base_css = "\n".join( line for line in raw.split("\n") if not line.strip().startswith("@import") ) title = analysis.get("title", "슬라이드") grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'") # Phase T: 동적 비율 반영 — container_ratio가 있으면 프리셋 고정값 대신 사용 container_ratio = analysis.get("_container_ratio") if container_ratio and len(container_ratio) == 2: grid_columns = f"{container_ratio[0]}fr {container_ratio[1]}fr" else: grid_columns = preset.get("grid_columns", "65fr 35fr") # Phase W: AFTER 컨테이너 크기를 grid-template-rows에 반영 # header=auto, body/sidebar=AFTER 높이에 맞춤, footer=AFTER 높이 containers = analysis.get("_containers", {}) redist = analysis.get("_fit_redistribution", {}) from src.fit_verifier import _load_design_tokens as _ldt _tokens = _ldt() _header_h = _tokens.get("header_height", 66) _gap_small = _tokens["spacing_small"] # zone 기반으로 body/footer 높이를 동적 탐색 (유형 A: 배경+본심, 유형 B: zone별) def _find_h(role_name, zone_name=None): """redist → containers 순으로 높이 탐색. role_name 없으면 zone으로 fallback.""" h = redist.get(role_name, 0) if h: return int(h) ci = containers.get(role_name, {}) if ci: return int(ci.get("height_px", 0)) if zone_name: for _r, _c in containers.items(): if isinstance(_c, dict) and _c.get("zone") == zone_name: return int(redist.get(_r, _c.get("height_px", 0))) return 0 _bg_h = _find_h("배경") _core_h = _find_h("본심") _footer_h = _find_h("결론", "footer") _body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0 if _body_row_h > 0 and _footer_h > 0: grid_rows = f"auto {_body_row_h}px {_footer_h}px" else: grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto") body_html = generated.get("body_html", "") sidebar_html = generated.get("sidebar_html", "") footer_html = generated.get("footer_html", "") # ── 후처리 ── import re as _re # 1) sidebar 최외곽 wrapper div만 width:100% (grid cell에 맞추기) # 첫 번째 태그의 style에서만 변경. 내부 요소(카드 번호 등)는 건드리지 않음. sidebar_html = _re.sub( r'^(\s* body_html = _re.sub(r'\*\*(.+?)\*\*', r'\1', body_html) sidebar_html = _re.sub(r'\*\*(.+?)\*\*', r'\1', sidebar_html) # 4) 폰트 위계 강제: 배경 영역 font-size가 본심(core)보다 크면 안 됨 font_h = analysis.get("_font_hierarchy", {}) bg_max = font_h.get("bg", 11.0) core_max = font_h.get("core", 12.0) sidebar_max = font_h.get("sidebar", 10.0) def _cap_font(html_str: str, max_size: float) -> str: """font-size: NNpx 중 max_size 초과하는 것을 max_size로 캡.""" def _repl(m): val = float(m.group(1)) if val > max_size: return f"font-size:{max_size}px" return m.group(0) return _re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _repl, html_str) # body_html의 첫 번째 주요 div(배경)만 캡: height:117px or 배경 색상이 있는 div # 배경 div 끝( + spacer) 이후가 본심 bg_end = body_html.find('
{title}
{title}
{body_html}
{sidebar_html}
""" logger.info(f"[R'] 슬라이드 렌더링 완료: {title}") return html def render_standalone_block(block_type: str, data: dict[str, Any], variant: str = "default") -> str: """단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용).""" env = create_jinja_env() template_path = _resolve_template_path(env, block_type, variant) if not template_path: return f"
블록 미발견: {block_type}
" template = env.get_template(template_path) return template.render(**data)