Initial commit: Kei Design Agent
콘텐츠를 시각적으로 구조화된 슬라이드 HTML로 변환하는 독립 에이전트. 아키텍처 (4단계 파이프라인): 1. Kei 실장 (Opus) — 콘텐츠 유형 분류 + 블록 배치 2. 디자인 팀장 (Sonnet) — 레이아웃 컨셉 (블록 배치 + 페이지 수) 3. 텍스트 편집자 (Sonnet) — 슬롯 텍스트 정리 (핵심 유지) 4. CSS Grid 렌더러 — HTML 조립 블록 템플릿 7종: comparison, card-grid, relationship, process, quote-block, conclusion-bar, comparison-table 기술 스택: FastAPI + Anthropic API + Jinja2 + CSS Grid Pretendard Variable 한국어 폰트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
171
src/renderer.py
Normal file
171
src/renderer.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""DA-11: 슬라이드 조합 렌더러.
|
||||
|
||||
블록 배치 명세(JSON)를 받아 Jinja2로 HTML을 생성한다.
|
||||
다중 페이지 지원: pages 배열의 각 페이지를 .slide div로 렌더링.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
def create_jinja_env() -> Environment:
|
||||
"""Jinja2 환경 생성. templates/ 폴더를 로더로 사용."""
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
|
||||
def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
"""다중 페이지 레이아웃 컨셉으로 완성 HTML을 생성한다.
|
||||
|
||||
Args:
|
||||
layout_concept: 디자인 팀장 + 텍스트 편집자가 완성한 구조:
|
||||
{
|
||||
"title": "슬라이드 제목",
|
||||
"pages": [
|
||||
{
|
||||
"grid_areas": "...",
|
||||
"grid_columns": "...",
|
||||
"grid_rows": "...",
|
||||
"blocks": [{"area": "...", "type": "...", "data": {...}}]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Returns:
|
||||
완성된 HTML 문자열 (다중 페이지 시 .slide div 여러 개).
|
||||
"""
|
||||
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_rendered = []
|
||||
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>'
|
||||
|
||||
blocks_rendered.append({
|
||||
"area": block.get("area", "main"),
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
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,
|
||||
"page_number": page_idx + 1,
|
||||
})
|
||||
|
||||
# base 템플릿 렌더링
|
||||
base_template = env.get_template("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
|
||||
|
||||
|
||||
# 하위 호환: 기존 render_slide도 유지
|
||||
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_rendered = []
|
||||
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>'
|
||||
|
||||
blocks_rendered.append({
|
||||
"area": block["area"],
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
base_template = env.get_template("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_rendered,
|
||||
"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_standalone_block(block_type: str, data: dict[str, Any]) -> str:
|
||||
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
|
||||
env = create_jinja_env()
|
||||
template = env.get_template(f"blocks/{block_type}.html")
|
||||
return template.render(**data)
|
||||
Reference in New Issue
Block a user