Consolidate three duplicated catalog readers and two _get_block_by_id implementations behind a single shared module (src/catalog.py) that owns file-read + mtime cache. All caller signatures and return contracts remain byte-identical. Units: - u1 NEW src/catalog.py (76 lines): load_root_catalog / load_blocks / get_block_by_id / get_catalog_mtime as the sole file-read + mtime-cache owner. - u2 src/block_reference.py: _load_catalog delegates to load_blocks (list[dict] preserved); _get_block_by_id (no-arg) delegates to catalog.get_block_by_id. Module-level _catalog_cache removed. - u3 src/block_selector.py: load_catalog delegates to load_root_catalog (root dict preserved); _get_block_by_id (catalog-injected sig preserved) delegates to catalog.get_block_by_id. Module-level _catalog_cache / _catalog_mtime / CATALOG_PATH removed. - u4 src/renderer.py: _load_catalog_map and _load_catalog_map_with_variants consume catalog.load_blocks; renderer projection caches kept local but keyed via catalog.get_catalog_mtime(). Per-projection invalidation keys (_CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME) introduced. import yaml, CATALOG_PATH, legacy _CATALOG_MTIME removed. - tests NEW tests/test_catalog_shared_loader.py (421 lines, 23 cases): shared loader + 3 wrappers covering single file-read, contract preservation, signature preservation, shared cache, private state absence, mtime invalidation propagation to renderer projections. Verification: - pytest tests/test_catalog_shared_loader.py -v: 23/23 PASS in 0.13s. - pytest tests/ -q --ignore=tests/matching: 365/365 PASS in 38.10s. - src/fit_verifier.py, src/space_allocator.py, src/pipeline.py and templates/catalog.yaml unchanged (git diff empty). Out of scope: - catalog.yaml schema/path unchanged. - Catalog direct-read call sites in fit_verifier / space_allocator / pipeline left for a separate follow-up axis. - Phase Z 22-step runtime, frame_selection, light_edit/restructure flows untouched. Refs: IMP-27 (gitea #27), INSIGHT-MAP §5 K5, PHASE-Q-AUDIT §2.10
678 lines
24 KiB
Python
678 lines
24 KiB
Python
"""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'<div class="container-{role}" style="'
|
|
f'height:{spec.height_px}px; '
|
|
f'overflow:visible; '
|
|
f'display:flex; flex-direction:column; gap:8px; '
|
|
f'font-size:{font_size}px; '
|
|
f'--spacing-inner:{padding}px; '
|
|
f'--font-body:{font_size / 16:.3f}rem;">\n'
|
|
f'{inner_html}\n</div>'
|
|
)
|
|
|
|
# 미배정 블록
|
|
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'<div style="display:flex; flex-direction:column; '
|
|
f'gap:var(--spacing-block); height:100%;">\n'
|
|
f'{inner}\n</div>'
|
|
)
|
|
|
|
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 "<html><body><p>페이지가 없습니다.</p></body></html>"
|
|
|
|
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'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
|
)
|
|
else:
|
|
logger.warning(f"블록 템플릿 미발견: {block_type}")
|
|
rendered_html = (
|
|
f'<div class="body-text">블록 템플릿 미발견: {block_type}</div>'
|
|
)
|
|
|
|
# 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"<style>\n{tokens_css}\n{base_css}\n</style>"
|
|
html = html.replace(
|
|
'<link rel="stylesheet" href="/static/base.css">',
|
|
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'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
|
)
|
|
else:
|
|
logger.warning(f"블록 템플릿 미발견: {block_type}")
|
|
rendered_html = (
|
|
f'<div class="body-text">블록 템플릿 미발견: {block_type}</div>'
|
|
)
|
|
|
|
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"<style>\n{tokens_css}\n{base_css}\n</style>"
|
|
html = html.replace(
|
|
'<link rel="stylesheet" href="/static/base.css">',
|
|
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"""<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
{tokens_css}
|
|
{base_css}
|
|
.candidate-container {{
|
|
width: {container_width_px}px;
|
|
height: {container_height_px}px;
|
|
overflow: visible;
|
|
font-size: {font_size_px}px;
|
|
--font-body: {font_size_px / 16:.3f}rem;
|
|
--spacing-inner: {padding_px}px;
|
|
padding: {padding_px}px;
|
|
font-family: 'Pretendard Variable', 'Pretendard', sans-serif;
|
|
line-height: 1.7;
|
|
word-break: keep-all;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="candidate-container">
|
|
{block_html}
|
|
</div>
|
|
</body>
|
|
</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*<div\s+style="[^"]*?)width:\s*\d+px',
|
|
r'\1width:100%',
|
|
sidebar_html,
|
|
count=1,
|
|
)
|
|
# 2) overflow-y:auto/scroll → overflow:hidden (스크롤 절대 금지)
|
|
body_html = _re.sub(r'overflow-y:\s*(auto|scroll)', 'overflow:hidden', body_html)
|
|
body_html = _re.sub(r'overflow:\s*(?:auto|scroll)(?!bar)', 'overflow:hidden', body_html)
|
|
sidebar_html = _re.sub(r'overflow-y:\s*(auto|scroll)', 'overflow:hidden', sidebar_html)
|
|
sidebar_html = _re.sub(r'overflow:\s*(?:auto|scroll)(?!bar)', 'overflow:hidden', sidebar_html)
|
|
# 3) markdown **bold** → <strong>
|
|
body_html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', body_html)
|
|
sidebar_html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', 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 끝(</div> + spacer) 이후가 본심
|
|
bg_end = body_html.find('<div style="height:12px;') # spacer between bg and core
|
|
if bg_end > 0:
|
|
bg_part = body_html[:bg_end]
|
|
core_part = body_html[bg_end:]
|
|
bg_part = _cap_font(bg_part, bg_max)
|
|
body_html = bg_part + core_part
|
|
# sidebar 전체 캡
|
|
sidebar_html = _cap_font(sidebar_html, sidebar_max)
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>{title}</title>
|
|
<style>
|
|
{tokens_css}
|
|
{base_css}
|
|
|
|
.slide-1 {{
|
|
grid-template-areas: {grid_areas};
|
|
grid-template-columns: {grid_columns};
|
|
grid-template-rows: {grid_rows};
|
|
}}
|
|
.slide-1 .area-body {{ grid-area: body; }}
|
|
.slide-1 .area-sidebar {{ grid-area: sidebar; }}
|
|
.slide-1 .area-footer {{ grid-area: footer; }}
|
|
|
|
.slide + .slide {{ margin-top: 40px; }}
|
|
@media print {{
|
|
.slide {{ page-break-after: always; }}
|
|
.slide + .slide {{ margin-top: 0; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="slide slide-1">
|
|
<div class="slide-title" style="grid-area: header;">{title}</div>
|
|
|
|
<div class="area-body" style="overflow:hidden;">
|
|
{body_html}
|
|
</div>
|
|
|
|
<div class="area-sidebar" style="overflow:hidden;">
|
|
{sidebar_html}
|
|
</div>
|
|
|
|
<div class="area-footer" style="overflow:hidden;">
|
|
{footer_html}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
window.onbeforeprint = function() {{
|
|
document.querySelectorAll('details').forEach(function(d) {{ d.open = true; }});
|
|
}};
|
|
window.onafterprint = function() {{
|
|
document.querySelectorAll('details').forEach(function(d) {{ d.open = false; }});
|
|
}};
|
|
</script>
|
|
</body>
|
|
</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"<div>블록 미발견: {block_type}</div>"
|
|
template = env.get_template(template_path)
|
|
return template.render(**data)
|