IMPROVEMENT Phase A~D + Phase 2 전체 반영
## IMPROVEMENT (Phase A~D) - A-1: 4단계 Sonnet 디자인 조정 (_adjust_design) — CSS 변수 cascade - A-2: 5단계 HTML 전문 프롬프트 전달 - A-3: shrink/expand 하드코딩 제거 → Sonnet target_ratio 기반 - A-4: rewrite action 구현 - A-5: overflow: visible (area 레벨 텍스트 잘림 방지) - A-6: object-fit cover → contain (이미지 crop 방지) - A-7: table-layout: fixed - A-8: container query 폰트 스케일링 - B-1: details-block 템플릿 신규 (CSS 변수만 사용) - B-2: 인쇄 시 details 자동 펼침 JS - B-3: catalog에 details-block 등록 - B-4/B-5: images[]/tables[] 상세 판단 + fallback 3곳 동기화 - B-8: fallback card-grid → topic-header + char_guide 제거 - C-1: CLAUDE.md gradient 원칙 완화 - C-3: border-radius 9개 파일 var(--radius) 통일 - C-4: box-shadow 2레벨 → 1레벨 - D-0: 이미지 경로 입력 UI + API base_path - D-1: Pillow 의존성 + image_utils.py - D-2~D-4: 이미지 비율/축소방지 프롬프트 전달 - D-5: HTML에 이미지 base64 삽입 ## Phase 2 (다른 Claude 작업) - P2-A: FAISS 블록 검색 (bge-m3, 46개 블록) - P2-B: SVG N개 자동 배치 (svg_calculator.py) - P2-C: Opus 블록 추천 (Kei API 경유) - P2-D: 5단계 재검토 루프 강화 (MAX_REVIEW_ROUNDS=2) - P2-E: details-block fallback 연동 ## 버그 수정 (BF-8~10) - BF-8: 컨테이너 예산 기반 블록 배치 - BF-9: grid와 Sonnet 역할 분리 - BF-10: catalog mtime 캐시 자동 갱신 ## 블록 라이브러리 - 46개 블록 (6 카테고리), catalog/BLOCK_SLOTS/INDEX 동기화 - 구 블록 제거 (quote-block, card-grid, comparison) - 13개 _legacy 블록 보존 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
278
src/renderer.py
278
src/renderer.py
@@ -1,51 +1,194 @@
|
||||
"""DA-11: 슬라이드 조합 렌더러.
|
||||
"""DA-11 + DA-21: 슬라이드 조합 렌더러.
|
||||
|
||||
블록 배치 명세(JSON)를 받아 Jinja2로 HTML을 생성한다.
|
||||
다중 페이지 지원: pages 배열의 각 페이지를 .slide div로 렌더링.
|
||||
- 다중 페이지 지원
|
||||
- 카테고리 경로 지원 (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
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"
|
||||
|
||||
# 카테고리 검색 순서
|
||||
BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
|
||||
|
||||
# catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신)
|
||||
_CATALOG_MAP: dict[str, str] | None = None
|
||||
_CATALOG_MTIME: float = 0.0
|
||||
|
||||
def _load_catalog_map() -> dict[str, str]:
|
||||
"""catalog.yaml에서 블록 id → template 경로 매핑을 로드한다.
|
||||
|
||||
파일 수정시간(mtime)을 확인하여, 변경 시에만 재로드한다.
|
||||
"""
|
||||
global _CATALOG_MAP, _CATALOG_MTIME
|
||||
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0
|
||||
|
||||
if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime:
|
||||
return _CATALOG_MAP # 파일 변경 없음 → 캐시 재사용
|
||||
|
||||
# 변경 감지 또는 첫 로드 → 새로 읽기
|
||||
_CATALOG_MTIME = current_mtime
|
||||
_CATALOG_MAP = {}
|
||||
if CATALOG_PATH.exists():
|
||||
try:
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
for block in catalog.get("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)}개 블록 매핑")
|
||||
except Exception as e:
|
||||
logger.warning(f"catalog.yaml 로드 실패: {e}")
|
||||
else:
|
||||
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
|
||||
|
||||
return _CATALOG_MAP
|
||||
|
||||
|
||||
def create_jinja_env() -> Environment:
|
||||
"""Jinja2 환경 생성. templates/ 폴더를 로더로 사용."""
|
||||
"""Jinja2 환경 생성."""
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
|
||||
def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
"""다중 페이지 레이아웃 컨셉으로 완성 HTML을 생성한다.
|
||||
def _resolve_template_path(env: Environment, block_type: str) -> str | None:
|
||||
"""블록 타입으로 템플릿 경로를 찾는다.
|
||||
|
||||
Args:
|
||||
layout_concept: 디자인 팀장 + 텍스트 편집자가 완성한 구조:
|
||||
{
|
||||
"title": "슬라이드 제목",
|
||||
"pages": [
|
||||
{
|
||||
"grid_areas": "...",
|
||||
"grid_columns": "...",
|
||||
"grid_rows": "...",
|
||||
"blocks": [{"area": "...", "type": "...", "data": {...}}]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Returns:
|
||||
완성된 HTML 문자열 (다중 페이지 시 .slide div 여러 개).
|
||||
검색 순서:
|
||||
0. catalog.yaml 매핑 (id → template 경로, 최우선)
|
||||
1. 정확한 경로 (blocks/cards/card-text-grid.html 등 — 팀장이 카테고리 포함 지정)
|
||||
2. 카테고리 폴더 검색 (blocks/{category}/{block_type}.html)
|
||||
3. _legacy fallback (blocks/_legacy/{block_type}.html)
|
||||
4. 루트 fallback (blocks/{block_type}.html)
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 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-text-grid")
|
||||
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]]) -> list[dict[str, Any]]:
|
||||
"""같은 area의 블록들을 하나로 그룹핑한다.
|
||||
|
||||
CSS Grid에서 같은 area에 여러 div가 있으면 겹치므로,
|
||||
같은 area의 블록 HTML을 합쳐서 하나의 div로 만든다.
|
||||
"""
|
||||
grouped = OrderedDict()
|
||||
for block in blocks:
|
||||
area = block["area"]
|
||||
if area not in grouped:
|
||||
grouped[area] = {"area": area, "htmls": []}
|
||||
grouped[area]["htmls"].append(block["html"])
|
||||
|
||||
result = []
|
||||
for area, data in grouped.items():
|
||||
if len(data["htmls"]) == 1:
|
||||
html = data["htmls"][0]
|
||||
else:
|
||||
# 여러 블록을 flex-column으로 세로 쌓기
|
||||
inner = "\n".join(data["htmls"])
|
||||
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", [])
|
||||
@@ -54,36 +197,57 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
logger.warning("페이지가 없습니다. 빈 HTML 반환.")
|
||||
return "<html><body><p>페이지가 없습니다.</p></body></html>"
|
||||
|
||||
# 각 페이지의 블록을 개별 렌더링
|
||||
pages_rendered = []
|
||||
for page_idx, page in enumerate(pages):
|
||||
blocks_rendered = []
|
||||
blocks_raw = []
|
||||
for block in page.get("blocks", []):
|
||||
block_type = block.get("type", "")
|
||||
block_data = block.get("data", {})
|
||||
template_path = f"blocks/{block_type}.html"
|
||||
|
||||
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>'
|
||||
# P2-B: SVG 시각화 블록은 좌표 사전 계산
|
||||
block_data = _preprocess_svg_data(block_type, block_data)
|
||||
|
||||
blocks_rendered.append({
|
||||
# DA-21: 카테고리 경로 검색
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
|
||||
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.get("area", "main"),
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
# Fix 1: 같은 area 블록 그룹핑
|
||||
blocks_grouped = _group_blocks_by_area(blocks_raw)
|
||||
|
||||
# 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_rendered,
|
||||
"blocks": blocks_grouped,
|
||||
"page_number": page_idx + 1,
|
||||
})
|
||||
|
||||
# base 템플릿 렌더링
|
||||
base_template = env.get_template("slide-base.html")
|
||||
html = base_template.render(
|
||||
slide_title=title,
|
||||
@@ -91,7 +255,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
total_pages=len(pages_rendered),
|
||||
)
|
||||
|
||||
# CSS를 인라인으로 삽입
|
||||
# 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');", "")
|
||||
@@ -106,37 +270,42 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
return html
|
||||
|
||||
|
||||
# 하위 호환: 기존 render_slide도 유지
|
||||
def render_slide(layout: dict[str, Any]) -> str:
|
||||
"""기존 단일 페이지 렌더링 (하위 호환).
|
||||
|
||||
pages 구조가 있으면 render_multi_page로 위임.
|
||||
없으면 기존 방식으로 단일 페이지 렌더링.
|
||||
"""
|
||||
"""하위 호환 렌더링. pages 구조가 있으면 render_multi_page로 위임."""
|
||||
if "pages" in layout:
|
||||
return render_multi_page(layout)
|
||||
|
||||
# 기존 단일 페이지 로직
|
||||
env = create_jinja_env()
|
||||
|
||||
blocks_rendered = []
|
||||
blocks_raw = []
|
||||
for block in layout.get("blocks", []):
|
||||
block_type = block["type"]
|
||||
block_data = block.get("data", {})
|
||||
template_path = f"blocks/{block_type}.html"
|
||||
|
||||
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>'
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
|
||||
blocks_rendered.append({
|
||||
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("slide-base.html")
|
||||
html = base_template.render(
|
||||
slide_title=layout.get("title", ""),
|
||||
@@ -144,7 +313,7 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
"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_rendered,
|
||||
"blocks": blocks_grouped,
|
||||
"page_number": 1,
|
||||
}],
|
||||
total_pages=1,
|
||||
@@ -167,5 +336,8 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
|
||||
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
|
||||
env = create_jinja_env()
|
||||
template = env.get_template(f"blocks/{block_type}.html")
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
if not template_path:
|
||||
return f"<div>블록 미발견: {block_type}</div>"
|
||||
template = env.get_template(template_path)
|
||||
return template.render(**data)
|
||||
|
||||
Reference in New Issue
Block a user