Phase S: Claude HTML 직접 생성 + 독립 검증 시스템 도입
블록 선택 방식(Phase P/Q/R) 폐기 → Claude Sonnet이 영역별 HTML 직접 생성. 생성-검증 분리: content_verifier.py로 텍스트 보존/금지 콘텐츠/구조를 코드 검증. 주요 변경: - src/html_generator.py: 4개 프롬프트 템플릿(BG/CORE/SIDEBAR/FOOTER) + 영역별 Claude 호출 - src/content_verifier.py: L1 텍스트 보존, L2 금지 콘텐츠, L3 구조 검증 + 재시도 루프 - src/html_validator.py: 보안 검증(script/iframe 제거) - src/renderer.py: render_slide_from_html() 추가, area div overflow:hidden - scripts/test_phase_s.py: generate_with_retry() 통합, step2b_verification 결과 저장 - 배경 라이트 디자인(#f8fafc), 개조식 어미 변환, 축약 금지 규칙 다음 과제: 폰트 위계(핵심14>본문12>배경10-12>첨부9-11) + 동적 컨테이너 계산 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
src/renderer.py
211
src/renderer.py
@@ -62,6 +62,41 @@ def _load_catalog_map() -> dict[str, str]:
|
||||
return _CATALOG_MAP
|
||||
|
||||
|
||||
# Phase R: variant별 template 경로 캐시
|
||||
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _load_catalog_map_with_variants() -> dict[str, str]:
|
||||
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다.
|
||||
|
||||
키: "block_id--variant_id" → 값: template 경로
|
||||
"""
|
||||
global _CATALOG_VARIANT_MAP
|
||||
|
||||
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용
|
||||
_load_catalog_map() # 캐시 갱신 보장
|
||||
|
||||
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0):
|
||||
return _CATALOG_VARIANT_MAP
|
||||
|
||||
_CATALOG_VARIANT_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", "")
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning(f"catalog variant 로드 실패: {e}")
|
||||
|
||||
return _CATALOG_VARIANT_MAP
|
||||
|
||||
|
||||
def create_jinja_env() -> Environment:
|
||||
"""Jinja2 환경 생성."""
|
||||
return Environment(
|
||||
@@ -70,19 +105,33 @@ def create_jinja_env() -> Environment:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_template_path(env: Environment, block_type: str) -> str | None:
|
||||
"""블록 타입으로 템플릿 경로를 찾는다.
|
||||
def _resolve_template_path(env: Environment, block_type: str, variant: str = "default") -> str | None:
|
||||
"""블록 타입 + variant로 템플릿 경로를 찾는다.
|
||||
|
||||
Phase R: variant가 지정되면 variant 전용 템플릿을 우선 탐색.
|
||||
variant 템플릿이 없으면 기존 블록 템플릿으로 fallback.
|
||||
|
||||
검색 순서:
|
||||
0. catalog.yaml 매핑 (id → template 경로, 최우선)
|
||||
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등 — 팀장이 카테고리 포함 지정)
|
||||
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. catalog.yaml에서 id → template 매핑 조회 (최우선)
|
||||
# 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]
|
||||
@@ -272,7 +321,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
block_data = _preprocess_svg_data(block_type, block_data)
|
||||
|
||||
# DA-21: 카테고리 경로 검색
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
|
||||
|
||||
if template_path:
|
||||
try:
|
||||
@@ -352,7 +401,7 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
block_type = block["type"]
|
||||
block_data = block.get("data", {})
|
||||
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
|
||||
|
||||
if template_path:
|
||||
try:
|
||||
@@ -403,10 +452,154 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
return html
|
||||
|
||||
|
||||
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
|
||||
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'")
|
||||
grid_columns = preset.get("grid_columns", "65fr 35fr")
|
||||
grid_rows = preset.get("grid_rows", "auto 1fr auto")
|
||||
|
||||
body_html = generated.get("body_html", "")
|
||||
sidebar_html = generated.get("sidebar_html", "")
|
||||
footer_html = generated.get("footer_html", "")
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user