'
@@ -642,6 +840,11 @@ async def generate_slide(
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
))
+ # ── Phase Y: font_scale 축소 (재배분만으로 부족할 때) ──
+ # 재배분 후에도 여전히 overflow면 font를 줄임
+ font_scale = max(0.7, font_scale - 0.1)
+ logger.info(f"[Stage 1.8] round {fit_round+1}: font_scale → {font_scale:.1f}")
+
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
if fit_analysis.needs_escalation or has_overflow:
@@ -676,15 +879,19 @@ async def generate_slide(
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
break
- # Step 4: 보강 제안 분석
- enhancements = analyze_enhancements(
- topics=[t.model_dump() for t in context.topics],
- page_structure=context.page_structure.roles,
- references=refs_dict,
- analysis=fit_analysis,
- normalized=normalized,
- core_message=core_message,
- )
+ # Step 4: 보강 제안 분석 (fit_analysis가 있을 때만)
+ if fit_analysis:
+ enhancements = analyze_enhancements(
+ topics=[t.model_dump() for t in context.topics],
+ page_structure=context.page_structure.roles,
+ references=refs_dict,
+ analysis=fit_analysis,
+ normalized=normalized,
+ core_message=core_message,
+ )
+ else:
+ enhancements = EnhancementAnalysis()
+ logger.info("[Stage 1.8] overflow 없음 — 보강 분석 스킵")
# Step 5: Kei에게 보강 제안 확인 요청
if enhancements.enhancements:
@@ -744,15 +951,19 @@ async def generate_slide(
# 재배분된 컨테이너 크기 업데이트
updated_containers = {}
for role, ci in context.containers.items():
- new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
+ if fit_analysis and fit_analysis.redistribution:
+ new_h = fit_analysis.redistribution.get(role, ci.height_px)
+ else:
+ new_h = ci.height_px
updated_containers[role] = ci.model_copy(update={
"height_px": int(new_h),
})
# Step 7: 세부 컨테이너 배치 계산
sub_layouts = {}
- for role, rf in fit_analysis.roles.items():
- new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis.redistribution else rf.allocated_px
+ fit_roles = fit_analysis.roles if fit_analysis else {}
+ for role, rf in fit_roles.items():
+ new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis and fit_analysis.redistribution else rf.allocated_px
ci = context.containers.get(role)
if not ci or not rf.topic_fits:
continue
@@ -801,7 +1012,7 @@ async def generate_slide(
popup_refs = _re.findall(r'\[팝업:\s*([^\]]+)\]', st_text)
for pr in popup_refs:
# 팝업 원본 찾기
- popup = next((p for p in popups if pr in p.get("title", "")), None)
+ popup = next((p for p in popups if pr in (p.title if hasattr(p, 'title') else p.get("title", ""))), None)
if not popup:
continue
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
@@ -842,7 +1053,7 @@ async def generate_slide(
continue # 공간 부족하면 건너뜀
summary = await call_kei_summarize_popup(
popup_title=pr,
- popup_content=popup.get("content", ""),
+ popup_content=popup.content if hasattr(popup, 'content') else popup.get("content", ""),
available_width_px=available_w,
available_height_px=available_h,
font_size=fs,
@@ -891,6 +1102,7 @@ async def generate_slide(
"containers": updated_containers,
"sub_layouts": sub_layouts,
"measurement": filled_measurement,
+ "font_scale": font_scale, # Phase Y: fit 루프에서 확정된 font 축소 비율
"fit_result": {
"roles": {
role: {
@@ -899,10 +1111,10 @@ async def generate_slide(
"allocated_px": rf.allocated_px,
"shortfall_px": rf.shortfall_px,
}
- for role, rf in fit_analysis.roles.items()
+ for role, rf in (fit_analysis.roles.items() if fit_analysis else {}.items())
},
- "redistribution": fit_analysis.redistribution,
- "needs_escalation": fit_analysis.needs_escalation,
+ "redistribution": fit_analysis.redistribution if fit_analysis else {},
+ "needs_escalation": fit_analysis.needs_escalation if fit_analysis else False,
},
"enhancement_result": {
"kei_decisions": kei_decisions,
@@ -972,9 +1184,10 @@ async def generate_slide(
async def stage_2(context: PipelineContext) -> dict:
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
if context.analysis.layout_template in ("B", "B'", "B''"):
- from src.block_assembler import assemble_slide_html
- generated = assemble_slide_html(context)
- logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
+ from src.block_assembler import assemble_slide_html_final
+ fs = context.font_scale if hasattr(context, 'font_scale') else 1.0
+ generated = assemble_slide_html_final(context, font_scale=fs)
+ logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})")
return {"generated_html": generated}
# Type A: 기존 Sonnet 재구성 코드 그대로
@@ -1094,7 +1307,7 @@ async def generate_slide(
capture_slide_screenshot, context.rendered_html
)
- quality_score = 100
+ quality_score = -1 # 비전 미평가 시 -1 (거짓 100점 방지)
if screenshot_b64:
analysis_dict = {
"topics": [t.model_dump() for t in context.topics],
@@ -1111,6 +1324,8 @@ async def generate_slide(
"localization": f"품질 {quality_score}/100 < 30",
"instruction": "출력 차단",
}]}
+ else:
+ logger.warning("[Stage 4] 비전 품질 평가 실패 — quality_score=-1 (미평가)")
return {
"measurement": measurement,
@@ -1139,7 +1354,10 @@ async def generate_slide(
]
logger.warning(f"[Stage 5] overflow 감지: {overflow_zones} — 결과물에 경고 포함")
- if quality < 30:
+ if quality < 0:
+ # 비전 미평가: 차단하지 않고 경고만. Selenium overflow 검사는 통과한 상태.
+ logger.warning(f"[Stage 5] 비전 미평가 (quality={quality}) — Selenium 측정만으로 통과")
+ elif quality < 30:
logger.error(f"[Stage 5] 품질 {quality}/100 < 30 — 출력 차단")
yield {"event": "error", "data": f"품질 검증 미달 ({quality}/100). 출력 차단."}
return
@@ -1149,22 +1367,44 @@ async def generate_slide(
html = embed_images(html, ctx.base_path)
ctx = ctx.model_copy(update={"rendered_html": html})
- ctx.save_snapshot("final")
- # final.html 저장
+ # Stage 5: popup_file 확정 (save_snapshot 전에 완료)
run_dir = ctx.get_run_dir()
run_dir.mkdir(parents=True, exist_ok=True)
+ popups = ctx.normalized.popups
+ if popups:
+ updated_popups = []
+ for i, popup in enumerate(popups, 1):
+ popup_title = popup.title
+ popup_content = popup.content
+ pid = popup.popup_id or f"popup_{i}"
+ safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
+ popup_filename = f"첨부{i}_{safe_title}.html"
+ # popup_file 확정 → 새 PopupItem으로 (Pydantic immutable 대응)
+ updated_popups.append(popup.model_copy(update={"popup_file": popup_filename}))
+ ctx = ctx.model_copy(update={
+ "normalized": ctx.normalized.model_copy(update={"popups": updated_popups}),
+ })
+ popups = ctx.normalized.popups # 업데이트된 참조
+
+ ctx.save_snapshot("final")
+
+ # stage_4 검증판을 final 시점 context로 재생성 (popup_file 등 반영)
+ from src.step_visualizer import generate_step_html
+ try:
+ generate_step_html(ctx, "stage_4")
+ except Exception as e:
+ logger.warning(f"[Stage 5] stage_4 재생성 실패: {e}")
+
+ # final.html 저장
(run_dir / "final.html").write_text(html, encoding="utf-8")
# Phase T: 팝업(상세 내용)을 별도 HTML로 분리 저장
- popups = ctx.normalized.popups
if popups:
for i, popup in enumerate(popups, 1):
- popup_title = popup.get("title", f"첨부{i}")
- popup_content = popup.get("content", "")
- # 파일명에서 특수문자 제거
- safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
- popup_filename = f"첨부{i}_{safe_title}.html"
+ popup_title = popup.title
+ popup_content = popup.content
+ popup_filename = popup.popup_file or f"첨부{i}.html"
# TP-6: 첨부 HTML에 디자인 토큰 적용
import re as _re
# JSX style={{}} 잔여 정리
@@ -1179,27 +1419,27 @@ async def generate_slide(
# 콘텐츠 유형별 CSS
if has_table:
- # 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
- content_css = """
-table {{ border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }}
-th {{ background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }}
-th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
-th:nth-child(2) {{ width: 16%; }}
-td {{ padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }}
-tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
+ content_css = (
+ "table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }\n"
+ "th { background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }\n"
+ "th:nth-child(1), th:nth-child(3) { width: 42%; }\n"
+ "th:nth-child(2) { width: 16%; }\n"
+ "td { padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }\n"
+ "tr:nth-child(even) { background: var(--color-bg-subtle); }"
+ )
elif has_list:
- # 카드형 리스트: 항목별 박스, 하위 항목은 인라인
- content_css = """
-ul {{ padding-left: 0; margin: 12px 0; list-style: none; }}
-li {{ margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }}
-li ul {{ margin-top: 8px; margin-bottom: 0; padding-left: 0; }}
-li li {{ background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }}
-li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
+ content_css = (
+ "ul { padding-left: 0; margin: 12px 0; list-style: none; }\n"
+ "li { margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }\n"
+ "li ul { margin-top: 8px; margin-bottom: 0; padding-left: 0; }\n"
+ "li li { background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }\n"
+ 'li li::before { content: "\\2022"; color: var(--color-accent); margin-right: 8px; }'
+ )
else:
- # 기본 (텍스트)
- content_css = """
-ul {{ padding-left: 20px; margin: 8px 0; }}
-li {{ margin-bottom: 4px; font-size: 13px; }}"""
+ content_css = (
+ "ul { padding-left: 20px; margin: 8px 0; }\n"
+ "li { margin-bottom: 4px; font-size: 13px; }"
+ )
popup_html = f"""
diff --git a/src/pipeline_context.py b/src/pipeline_context.py
index 0e5e6c6..c452ae3 100644
--- a/src/pipeline_context.py
+++ b/src/pipeline_context.py
@@ -23,12 +23,30 @@ from pydantic import BaseModel, Field, model_validator
# 하위 모델
# ──────────────────────────────────────
+class PopupItem(BaseModel):
+ """팝업/첨부 항목.
+
+ 생애주기:
+ Stage 0 → title, content 확정
+ Y-14 감지 → popup_id 확정, is_component, source
+ Stage 2 → popup_id로 참조 (popup_file은 아직 없음)
+ Stage 5 저장 → popup_file 확정 (run_dir + 파일명 정책)
+ """
+ popup_id: str = "" # 감지 시점에 확정 (예: "popup_1", "comp_DxEffect")
+ title: str = ""
+ content: str = ""
+ source: str | None = None
+ is_component: bool = False
+ target_role: str | None = None # Y-14에서 확정: 이 popup이 속하는 role 이름
+ popup_file: str | None = None # Stage 5에서 확정
+
+
class NormalizedContent(BaseModel):
"""Stage 0 출력: MDX 정규화 결과."""
clean_text: str = ""
title: str = ""
images: list[dict[str, str]] = Field(default_factory=list)
- popups: list[dict[str, str]] = Field(default_factory=list)
+ popups: list[PopupItem] = Field(default_factory=list)
tables: list[dict[str, Any]] = Field(default_factory=list)
sections: list[dict[str, Any]] = Field(default_factory=list)
@@ -61,6 +79,7 @@ class PageStructure(BaseModel):
class Analysis(BaseModel):
"""Stage 1A 출력: Kei 분석 결과 전체."""
core_message: str = ""
+ conclusion_text: str = "" # Phase Y: slide-base footer에 들어갈 핵심요약 원본 텍스트
title: str = ""
total_pages: int = 1
layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
@@ -166,6 +185,9 @@ class PipelineContext(BaseModel):
topics: list[Topic] = Field(default_factory=list)
page_structure: PageStructure = Field(default_factory=PageStructure)
+ # ── Phase Y: MDX 원본 섹션 (## 파싱 결과) ──
+ mdx_sections: list[dict[str, Any]] = Field(default_factory=list) # [{title, content, level, is_intro}]
+
# ── Stage 1.5a ──
font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy)
container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct)
@@ -178,6 +200,7 @@ class PipelineContext(BaseModel):
# ── Stage 1.8 ──
fit_result: dict[str, Any] = Field(default_factory=dict)
+ font_scale: float = 1.0 # Phase Y: fit 루프에서 확정된 font 축소 비율
enhancement_result: dict[str, Any] = Field(default_factory=dict)
sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화
diff --git a/src/renderer.py b/src/renderer.py
index ac2c28b..70cd3fc 100644
--- a/src/renderer.py
+++ b/src/renderer.py
@@ -367,7 +367,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
"page_number": page_idx + 1,
})
- base_template = env.get_template("slide-base.html")
+ base_template = env.get_template("blocks/slide-base.html")
html = base_template.render(
slide_title=title,
pages=pages_rendered,
@@ -425,7 +425,7 @@ def render_slide(layout: dict[str, Any]) -> str:
blocks_grouped = _group_blocks_by_area(blocks_raw)
- base_template = env.get_template("slide-base.html")
+ base_template = env.get_template("blocks/slide-base.html")
html = base_template.render(
slide_title=layout.get("title", ""),
pages=[{
diff --git a/src/section_parser.py b/src/section_parser.py
new file mode 100644
index 0000000..9b7ff46
--- /dev/null
+++ b/src/section_parser.py
@@ -0,0 +1,573 @@
+"""Phase Y: 영역 확정 모듈.
+
+normalized.sections(Stage 0 산출물)를 기반으로 ## 대목차 구조를 파악하고,
+Kei 꼭지를 대목차에 매핑하여 영역을 확정한다.
+
+source of truth = normalized.sections (Stage 0)
+raw MDX는 사용하지 않음 (보존용/증거용으로만 존재).
+
+용도:
+ - Kei 꼭지를 대목차에 매핑
+ - 대목차별 묶음으로 블록 tag 매칭
+ - 영역 확정 (코드가, Kei가 아님)
+"""
+from __future__ import annotations
+
+import re
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def extract_major_sections(normalized_sections: list[dict]) -> list[dict]:
+ """normalized.sections에서 ## 대목차(level=2)를 추출하고,
+ 각 대목차 아래의 소목차(level=3) content를 합쳐서 반환.
+
+ normalized.sections 구조:
+ [{"level": 2, "title": "DX 시행을 위한 필수 요건", "content": ""},
+ {"level": 2, "title": "기술(디지털)", "content": "D1: ..."},
+ {"level": 3, "title": "과정(Process)의 혁신", "content": "D1: ..."}]
+
+ 반환:
+ [{"title": "DX 시행을 위한 필수 요건", "content": "기술+사람+자연 합침", "sub_titles": ["기술","사람","자연"]},
+ {"title": "Process의 혁신과 Product의 변화", "content": "과정+결과 합침", "sub_titles": ["과정","결과"]}]
+ """
+ if not normalized_sections:
+ return []
+
+ # level=2 중 content가 비어있는 것 = 대목차 헤더 (아래 level=2/3이 소속)
+ # level=2 중 content가 있는 것 = 대목차 헤더가 없는 독립 섹션 (소목차)
+ # level=3 = 소목차
+
+ major_sections = []
+ current_major = None
+
+ for sec in normalized_sections:
+ level = sec.get("level", 2)
+ title = sec.get("title", "")
+ content = sec.get("content", "")
+
+ if level == 2 and not content.strip():
+ # 대목차 헤더 (빈 content = 아래 섹션들의 그룹 헤더)
+ if current_major:
+ major_sections.append(current_major)
+ current_major = {
+ "title": title,
+ "content": "",
+ "sub_titles": [],
+ }
+ elif level == 2 and content.strip():
+ # content가 있는 level=2 = 소목차 또는 독립 섹션
+ if current_major:
+ # 현재 대목차 아래의 소목차
+ current_major["content"] += f"\n{content}" if current_major["content"] else content
+ current_major["sub_titles"].append(title)
+ else:
+ # 대목차 없이 시작된 독립 섹션 (도입부)
+ current_major = {
+ "title": title,
+ "content": content,
+ "sub_titles": [title],
+ }
+ elif level == 3:
+ # 소목차 → 현재 대목차에 합침
+ if current_major:
+ current_major["content"] += f"\n{content}" if current_major["content"] else content
+ current_major["sub_titles"].append(title)
+ else:
+ # 대목차 없는 level=3 (비정상이지만 처리)
+ current_major = {
+ "title": title,
+ "content": content,
+ "sub_titles": [title],
+ }
+
+ if current_major:
+ major_sections.append(current_major)
+
+ # 빈 섹션 제거
+ major_sections = [s for s in major_sections if s["content"].strip()]
+
+ logger.info(
+ f"[section_parser] {len(major_sections)}개 대목차: "
+ + ", ".join(f'"{s["title"]}" (sub: {s["sub_titles"]})' for s in major_sections)
+ )
+
+ return major_sections
+
+
+def detect_component_popups(raw_content: str, base_path: str = "") -> list[dict]:
+ """Y-14: MDX에서 import된 Astro 컴포넌트를 감지하고 popup 대상으로 등록.
+
+ Returns:
+ [{"name": "DxEffect", "source": "components/dx.astro",
+ "resolved_path": "실제 파일 경로", "content_html": "astro HTML 내용"}]
+ """
+ from pathlib import Path
+
+ popups = []
+ # import 문 파싱
+ imports = re.findall(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', raw_content)
+ # self-closing 태그 사용 여부
+ used_tags = set(re.findall(r'<(\w+)\s*/>', raw_content))
+
+ for name, source in imports:
+ if name not in used_tags:
+ continue # import만 하고 사용 안 한 것은 무시
+
+ # astro 파일 경로 해석
+ resolved = ""
+ content_html = ""
+ if base_path:
+ # MDX 기준 상대경로 → 절대경로
+ mdx_dir = Path(base_path)
+ candidate = mdx_dir / source
+ if not candidate.exists():
+ # samples/src/components/ 에서 찾기
+ candidate = Path(base_path).parent.parent / "src" / "components" / Path(source).name
+ if not candidate.exists():
+ # 프로젝트 루트에서 찾기
+ candidate = Path("samples/src/components") / Path(source).name
+ if candidate.exists():
+ resolved = str(candidate)
+ raw = candidate.read_text(encoding="utf-8")
+ # astro frontmatter 제거
+ if raw.startswith("---"):
+ end = raw.find("---", 3)
+ if end > 0:
+ content_html = raw[end + 3:].strip()
+ else:
+ content_html = raw
+ else:
+ content_html = raw
+
+ popups.append({
+ "name": name,
+ "source": source,
+ "resolved_path": resolved,
+ "content_html": content_html,
+ "tag": f"<{name} />",
+ })
+ logger.info(f"[Y-14] 컴포넌트 popup 감지: {name} → {resolved or source}")
+
+ return popups
+
+
+def _classify_sub_types(
+ sub_titles: list[str], full_content: str,
+ normalized_sections: list[dict] | None = None,
+ popup_sub_titles: list[str] | None = None,
+) -> list[dict]:
+ """B-1: 각 sub_title의 콘텐츠 유형을 점수 기반 힌트로 판단.
+
+ 점수 항목:
+ - 병렬 소목차 구조 (sub_titles 수, 대등성)
+ - 각 항목 길이 (D2 본문 길이)
+ - D1/D2 패턴 밀도
+ - popup/component 존재 여부 (popup_sub_titles)
+
+ Returns: [{title: str, sub_type: str}]
+ """
+ results = []
+ lines = full_content.split("\n")
+ norm_secs = normalized_sections or []
+
+ for st in sub_titles:
+ st_key = re.sub(r'\*+', '', st.split("(")[0].strip()).lower()
+ sub_content = ""
+
+ # 1차: normalized_sections에서 섹션 title로 매칭
+ for sec in norm_secs:
+ sec_title = sec.get("title", "").lower()
+ if st_key and len(st_key) >= 2 and st_key in sec_title:
+ sub_content = sec.get("content", "")
+ break
+
+ # 2차: D1: 항목 내 매칭 (sub_title이 D1 항목명인 경우)
+ if not sub_content:
+ capturing = False
+ for line in lines:
+ d1_match = re.match(r'^D1:\s*(.*)', line.strip())
+ if d1_match:
+ d1_text = re.sub(r'\*+', '', d1_match.group(1)).strip().lower()
+ if capturing:
+ break
+ if st_key and len(st_key) >= 2 and st_key in d1_text:
+ capturing = True
+ sub_content += line.strip() + "\n"
+ elif capturing:
+ stripped = line.strip()
+ if stripped:
+ sub_content += stripped + "\n"
+
+ # 점수 계산
+ scores = {
+ "parallel_card_candidate": 0,
+ "text_list_candidate": 0,
+ "visual_detail_candidate": 0,
+ "table_heavy_candidate": 0,
+ }
+
+ d2_lines = re.findall(r'^D2:', sub_content, re.MULTILINE)
+ d2_total_len = sum(len(l) for l in re.findall(r'^D2:\s*(.*)', sub_content, re.MULTILINE))
+ has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', sub_content))
+ is_empty = len(sub_content.strip()) < 10
+
+ # parallel_card: 짧은 D2, 항목이 대등
+ if len(d2_lines) >= 1 and d2_total_len < 200:
+ scores["parallel_card_candidate"] += 3
+ if len(sub_titles) >= 3:
+ scores["parallel_card_candidate"] += 2
+
+ # text_list: 긴 D2 본문
+ if d2_total_len >= 100:
+ scores["text_list_candidate"] += 3
+ if len(d2_lines) >= 3:
+ scores["text_list_candidate"] += 2
+
+ # visual_detail: content 비거나 popup/component
+ if is_empty:
+ scores["visual_detail_candidate"] += 5
+ if "컴포넌트" in sub_content or "[팝업:" in sub_content:
+ scores["visual_detail_candidate"] += 3
+ # popup_sub_titles에 포함되면 강하게 visual_detail
+ popup_subs = popup_sub_titles or []
+ if any(st_key in ps.lower() for ps in popup_subs):
+ scores["visual_detail_candidate"] += 6
+ # content가 핵심요약/결론 + D1 1줄 이하면 실질적으로 빈 것 — visual_detail
+ # D1이 2개 이상이면 실제 본문 콘텐츠로 봄
+ d1_lines = re.findall(r'^D1:', sub_content, re.MULTILINE)
+ content_without_markers = re.sub(r'\[핵심요약:[^\]]*\]', '', sub_content).strip()
+ if len(d1_lines) <= 1 and len(content_without_markers) < 50 and sub_content.strip():
+ scores["visual_detail_candidate"] += 4
+ # D1이 여러 개면 본문형 content → text_list 가점
+ if len(d1_lines) >= 2:
+ scores["text_list_candidate"] += 3
+
+ # table_heavy
+ if has_table:
+ scores["table_heavy_candidate"] += 5
+
+ # 최고 점수 candidate 선택
+ best_type = max(scores, key=scores.get)
+ best_score = scores[best_type]
+
+ # 점수가 0이면 content 길이로 fallback
+ if best_score == 0:
+ if sub_content.strip():
+ best_type = "text_list_candidate"
+ else:
+ best_type = "visual_detail_candidate"
+
+ results.append({"title": st, "sub_type": best_type})
+ logger.debug(f"[sub_type] '{st}': {best_type} (scores={scores})")
+
+ return results
+
+
+def classify_group_relations(
+ major_sections: list[dict],
+ topics: list[dict] | None = None,
+ normalized_sections: list[dict] | None = None,
+ popup_sub_titles: list[str] | None = None,
+) -> list[dict]:
+ """Y-13b: 각 대목차의 sub_titles 간 관계를 판단하여 group_schema를 부여.
+
+ 규칙 기반 판단 (Kei 없이):
+ - sub_titles 3개 + 병렬 → parallel_cluster
+ - sub_titles 2개 + 비대칭 → compare_asymmetric_paired
+ - sub_titles 2개 + 순서/변화 → sequence_list
+ - sub_titles 1개 → single_block
+ - sub_titles 4개+ → card_cluster_N
+
+ Returns: major_sections에 group_schema 필드 추가하여 반환
+ """
+ for sec in major_sections:
+ sub_titles = sec.get("sub_titles", [])
+ content = sec.get("content", "")
+ content_lower = content.lower()
+ n = len(sub_titles)
+
+ # sub_titles가 1개 이하지만 content에 D1: 항목이 여러 개면 → 실제 병렬 항목 수
+ if n <= 1:
+ d1_items = re.findall(r'^D1:\s*\*?\*?(.+?)\*?\*?\s*$', content, re.MULTILINE)
+ # 이미지/표 관련 D1 제외
+ d1_items = [d for d in d1_items if not d.strip().startswith('!') and not d.strip().startswith('As-is')]
+ if len(d1_items) >= 2:
+ n = len(d1_items)
+ sec["sub_titles"] = [re.sub(r'\*+', '', d).strip() for d in d1_items]
+ sub_titles = sec["sub_titles"]
+
+ if n == 0 or n == 1:
+ sec["group_schema"] = "single_block"
+ elif n == 3:
+ sec["group_schema"] = "parallel_cluster"
+ elif n == 2:
+ has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', content))
+ compare_hints = ["vs", "비교", "차이", "반면"]
+ asymmetric_hints = ["혁신", "변화", "변환", "전환"]
+ process_hints = ["과정", "단계", "수행", "주체"]
+ sub_text = " ".join(sub_titles).lower()
+ effect_hints = ["기대효과", "효과", "성과", "결과물"]
+
+ all_text = content_lower + " " + sub_text
+ has_compare = any(h in all_text for h in compare_hints)
+ has_asymmetric = any(h in all_text for h in asymmetric_hints)
+ has_process = any(h in all_text for h in process_hints)
+ has_effect = any(h in all_text for h in effect_hints)
+
+ if has_table and has_asymmetric:
+ sec["group_schema"] = "compare_asymmetric_paired"
+ elif has_process and has_effect:
+ sec["group_schema"] = "sequence_plus_visual"
+ elif has_process:
+ sec["group_schema"] = "sequence_list"
+ elif has_compare:
+ sec["group_schema"] = "compare_paired"
+ else:
+ sec["group_schema"] = "compare_paired"
+ elif n == 4:
+ sec["group_schema"] = "card_cluster_4"
+ else:
+ sec["group_schema"] = f"card_cluster_{n}"
+
+ # 시각 앵커 포함 여부 (이미지, 차트, 컴포넌트 등)
+ has_visual = "이미지" in content or "![" in content or ".png" in content
+ if has_visual:
+ sec["group_schema"] += "_plus_visual"
+
+ # B-1: subsection typing — 각 sub_title의 콘텐츠 유형을 점수 기반으로 판단
+ sec["sub_types"] = _classify_sub_types(sub_titles, content, normalized_sections, popup_sub_titles)
+
+ logger.info(f"[Y-13b] '{sec['title']}': sub={n}개, schema={sec['group_schema']}, sub_types={[s['sub_type'] for s in sec['sub_types']]}")
+
+ return major_sections
+
+
+# ══════════════════════════════════════
+# schema alias: 회귀 안전을 위해 old → new 매핑 유지
+# ══════════════════════════════════════
+SCHEMA_ALIASES = {
+ "parallel_3": "parallel_cluster",
+ "parallel_3_with_image": "parallel_cluster_plus_visual",
+ "compare_2": "compare_paired",
+ "compare_asymmetric_2col": "compare_asymmetric_paired",
+ "process_plus_visual": "sequence_plus_visual",
+ "process_list": "sequence_list",
+ "single_section": "single_block",
+ "card_list_4": "card_cluster_4",
+}
+
+
+def resolve_schema(schema: str) -> str:
+ """old schema 이름 → new 이름으로 해소. 이미 new면 그대로 반환."""
+ return SCHEMA_ALIASES.get(schema, schema)
+
+
+# ══════════════════════════════════════
+# schema → recipe 매핑 (표현 계약)
+# recipe = 블록 이름이 아닌, 레이아웃 계약
+# ══════════════════════════════════════
+SCHEMA_RECIPE_MAP = {
+ "parallel_cluster": {
+ "recipe": "single_block",
+ "block_kind": "parallel_cards",
+ "blocks": ["prerequisites-3col", "card-compare-3col", "card-icon-desc"],
+ },
+ "parallel_cluster_plus_visual": {
+ "recipe": "two_col_text_visual",
+ "left_kind": "parallel_cards",
+ "right_kind": "visual_anchor",
+ "ratio": "7:3",
+ "vertical_align": "center",
+ # direct single-block mapping 금지: p3c는 2층 구조(label+heading)라서
+ # 1층 구조(목표 제목만)인 plus_visual에서는 부적합.
+ # composition으로 쓸 가능성은 열어둠 (향후 blocks_composition에 추가 가능).
+ "blocks_left": ["card-icon-desc", "card-compare-3col", "card-text-grid"],
+ },
+ "compare_paired": {
+ "recipe": "single_block",
+ "block_kind": "compare_cards",
+ "blocks": ["compare-detail-gradient", "comparison-2col"],
+ },
+ "compare_asymmetric_paired": {
+ "recipe": "single_block",
+ "block_kind": "compare_asymmetric",
+ "blocks": ["process-product-2col", "compare-detail-gradient"],
+ },
+ "sequence_list": {
+ "recipe": "single_block",
+ "block_kind": "sequence_cards",
+ "blocks": ["card-step-vertical", "checklist-dark", "card-numbered"],
+ },
+ "sequence_plus_visual": {
+ "recipe": "two_col_text_detail",
+ "left_kind": "text_list",
+ "right_kind": "summary_and_popup",
+ "ratio": "6:4",
+ "vertical_align": "top",
+ "blocks_left": ["card-icon-desc", "card-step-vertical", "card-numbered"],
+ },
+ "single_block": {
+ "recipe": "single_block",
+ "block_kind": "text_list",
+ "blocks": ["dark-bullet-list", "checklist-dark", "card-numbered"],
+ },
+ "card_cluster_4": {
+ "recipe": "single_block",
+ "block_kind": "card_grid",
+ "blocks": ["card-icon-desc", "card-text-grid", "card-numbered"],
+ },
+}
+
+
+def get_recipe_for_schema(schema: str) -> dict:
+ """schema → recipe 표현 계약 반환. alias 자동 해소."""
+ resolved = resolve_schema(schema)
+ # _plus_visual suffix 분리: base schema에서 recipe 찾고, visual 플래그 추가
+ base = resolved.replace("_plus_visual", "")
+ has_visual = "_plus_visual" in resolved
+
+ recipe = SCHEMA_RECIPE_MAP.get(resolved)
+ if recipe:
+ return recipe
+
+ # base schema로 fallback하되 visual 플래그 추가
+ recipe = SCHEMA_RECIPE_MAP.get(base)
+ if recipe and has_visual:
+ # base recipe를 복사해서 visual 힌트 추가
+ r = dict(recipe)
+ r["has_visual"] = True
+ return r
+
+ # card_cluster_N → card_cluster_4 fallback
+ if base.startswith("card_cluster_"):
+ return SCHEMA_RECIPE_MAP.get("card_cluster_4", {})
+
+ return {}
+
+
+# C-1: recipe kind ↔ sub_type 호환 규칙
+KIND_SUBTYPE_COMPAT = {
+ "parallel_cards": ["parallel_card_candidate"],
+ "text_list": ["text_list_candidate"],
+ "visual_anchor": ["visual_detail_candidate"],
+ "summary_and_popup": ["visual_detail_candidate"],
+ "compare_cards": ["parallel_card_candidate", "text_list_candidate"],
+ "compare_asymmetric": ["text_list_candidate", "table_heavy_candidate"],
+ "sequence_cards": ["text_list_candidate"],
+ "card_grid": ["parallel_card_candidate"],
+}
+
+
+def check_kind_compatibility(recipe_kind: str, sub_types: list[dict]) -> bool:
+ """recipe의 left_kind/right_kind가 실제 sub_type과 호환되는지 확인."""
+ compatible = KIND_SUBTYPE_COMPAT.get(recipe_kind, [])
+ if not compatible:
+ return True # 규칙 없으면 호환 가정
+ actual_types = [s.get("sub_type", "") for s in sub_types]
+ return any(t in compatible for t in actual_types)
+
+
+def get_candidate_blocks_for_schema(group_schema: str) -> list[str]:
+ """Y-13d: group schema에 맞는 블록 후보 ID 목록 반환. recipe 경유.
+
+ 주의: *_plus_visual schema는 direct single-block 매칭 금지.
+ 이 함수는 recipe 내부의 블록 후보를 반환할 뿐,
+ 실제 선택은 recipe executor가 담당.
+ """
+ recipe = get_recipe_for_schema(group_schema)
+ if not recipe:
+ return []
+ # recipe 유형에 따라 블록 후보 반환
+ recipe_type = recipe.get("recipe", "")
+ if recipe_type in ("two_col_text_visual", "two_col_text_detail"):
+ return recipe.get("blocks_left", [])
+ else:
+ return recipe.get("blocks", [])
+
+
+def extract_conclusion_text(raw_content: str) -> str:
+ """raw MDX에서 :::note[핵심 요약] 텍스트만 추출.
+ 이것만 raw MDX에서 가져옴 (normalized에 없을 수 있으므로).
+ """
+ note_match = re.search(r':::note\[([^\]]*)\]\s*([\s\S]*?):::', raw_content)
+ if note_match:
+ text = note_match.group(2).strip()
+ # 마크다운 볼드/불릿 잔여 제거
+ text = re.sub(r'^\*\s*\*\*', '', text)
+ text = re.sub(r'\*\*$', '', text)
+ text = text.strip("* ")
+ # 선행 불릿 마커(*, •, -) 제거
+ text = re.sub(r'^[\*•\-]\s*', '', text).strip()
+ return text
+ return ""
+
+
+def map_topics_to_sections(
+ topics: list[dict],
+ sections: list[dict],
+) -> dict[str, list[int]]:
+ """Kei 꼭지들을 대목차 섹션에 매핑.
+
+ 각 꼭지의 title을 보고 어느 섹션의 content에 포함되는지 판단.
+
+ Returns:
+ {"1. DX 시행을 위한 필수 요건": [1, 2, 3], "2. Process의 혁신과 Product의 변화": [4, 5]}
+ """
+ section_topics: dict[str, list[int]] = {}
+ for sec in sections:
+ section_topics[sec["title"]] = []
+
+ for topic in topics:
+ tid = topic.get("id", 0)
+ t_title = topic.get("title", "").lower()
+ t_hint = topic.get("source_hint", "").lower()
+
+ best_section = None
+ best_score = 0
+
+ for sec in sections:
+ sec_content = sec["content"].lower()
+ sec_title = sec["title"].lower()
+ # sub_titles에서도 매칭
+ sub_titles_lower = " ".join(s.lower() for s in sec.get("sub_titles", []))
+ score = 0
+
+ # 꼭지 제목이 섹션 content에 포함되는지
+ key = t_title.split("(")[0].strip()
+ if key and len(key) >= 2:
+ if key in sec_content:
+ score += 10
+ if key in sec_title:
+ score += 5
+ if key in sub_titles_lower:
+ score += 8 # sub_title에 직접 매칭
+
+ # source_hint에 섹션 제목 키워드가 포함되는지
+ sec_key = sec_title.split(".")[-1].strip().lower()[:10]
+ if sec_key and len(sec_key) >= 2 and sec_key in t_hint:
+ score += 3
+
+ if score > best_score:
+ best_score = score
+ best_section = sec["title"]
+
+ if best_section and best_score > 0:
+ section_topics[best_section].append(tid)
+ else:
+ # 매칭 안 되면 첫 번째 섹션에 넣음
+ if sections:
+ section_topics[sections[0]["title"]].append(tid)
+ logger.warning(f"[section_parser] 꼭지 {tid} '{t_title}' 섹션 매핑 실패 → 첫 섹션")
+
+ # 빈 섹션 제거
+ section_topics = {k: v for k, v in section_topics.items() if v}
+
+ logger.info(
+ f"[section_parser] 꼭지-섹션 매핑: "
+ + ", ".join(f'"{k}": {v}' for k, v in section_topics.items())
+ )
+
+ return section_topics
diff --git a/src/slide_measurer.py b/src/slide_measurer.py
index 6aa9e43..91405a3 100644
--- a/src/slide_measurer.py
+++ b/src/slide_measurer.py
@@ -34,11 +34,11 @@ _MEASURE_SCRIPT = """
containers: {}
};
- // Zone 측정 (area-* 클래스)
- var areaDivs = slide.querySelectorAll('[class*="area-"]');
+ // Zone 측정 (area-* 또는 zone-* 클래스)
+ var areaDivs = slide.querySelectorAll('[class*="area-"], [class*="zone-"]');
for (var i = 0; i < areaDivs.length; i++) {
var zone = areaDivs[i];
- var areaMatch = zone.className.match(/area-(\\w+)/);
+ var areaMatch = zone.className.match(/(?:area|zone)-(\\w+)/);
if (!areaMatch) continue;
var areaName = areaMatch[1];
diff --git a/src/space_allocator.py b/src/space_allocator.py
index 98f2368..a73dc6f 100644
--- a/src/space_allocator.py
+++ b/src/space_allocator.py
@@ -468,9 +468,9 @@ def build_containers_type_b(
inner_w = slide_width - pad * 2
# 역할을 zone별로 분류
- top_roles = [] # zone=top
- bottom_roles = [] # zone=bottom_left, bottom_right
- footer_role = None # zone=footer
+ top_roles = [] # zone=top
+ bottom_roles = [] # zone=bottom (전체폭) 또는 bottom_left/bottom_right (2분할)
+ footer_role = None # zone=footer (Phase Y: 결론은 slide-base가 처리, 여기서 무시)
for role_name, info in page_structure.items():
if not isinstance(info, dict):
@@ -478,32 +478,46 @@ def build_containers_type_b(
zone = info.get("zone", "")
if zone == "top":
top_roles.append((role_name, info))
- elif zone in ("bottom_left", "bottom_right"):
+ elif zone in ("bottom", "bottom_left", "bottom_right"):
bottom_roles.append((role_name, info))
elif zone == "footer":
footer_role = (role_name, info)
- # 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
- total_available = slide_height - pad * 2 - header_h - gap_block
+ # Phase Y: slide-base.html 기준으로 가용 높이 계산
+ # slide-base: .slide-body = top:65px, height:590px
+ # 하단 footer pill = 41px (slide-base가 관리, 여기서 빼지 않음)
+ slide_body_top = 65 # slide-base .slide-body top
+ slide_body_h = 590 # slide-base .slide-body height
+ total_available = slide_body_h
- # footer 높이: weight 비율 (최소 보장)
- footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
- footer_h_raw = int(total_available * footer_weight)
- _footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
- footer_h = max(_footer_min, footer_h_raw)
+ # footer zone이 있으면 기존 방식으로 공간 배분 (하위 호환)
+ # footer zone이 없으면 (Phase Y) slide-base footer가 처리 → 전체를 zone에 사용
+ if footer_role:
+ footer_weight = footer_role[1].get("weight", 0.1)
+ footer_h_raw = int(total_available * footer_weight)
+ _footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
+ footer_h = max(_footer_min, footer_h_raw)
+ middle_h = total_available - footer_h - gap_block
+ else:
+ footer_h = 0
+ middle_h = total_available
- # 중간 영역: footer + gap 제외
- middle_h = total_available - footer_h - gap_block
+ # Phase Y: zone 제목 + gap 공간 확보
+ zone_count = len(top_roles) + len(bottom_roles)
+ zone_title_h = 28 # zone 제목 높이 (assembler와 동일)
+ zone_gap = 16 # zone 간 여백 (assembler와 동일)
+ zone_overhead = zone_count * zone_title_h + max(0, zone_count - 1) * zone_gap
+ usable_h = middle_h - zone_overhead
- # 상단/하단 높이: weight 비율로
+ # 상단/하단 높이: weight 비율로 (usable 영역에서)
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
total_mid_weight = top_weight + bottom_weight
if total_mid_weight <= 0:
total_mid_weight = 1
- top_h = int(middle_h * top_weight / total_mid_weight)
- bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
+ top_h = int(usable_h * top_weight / total_mid_weight)
+ bottom_h = usable_h - top_h
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
img_ratio = 0
@@ -541,16 +555,20 @@ def build_containers_type_b(
},
)
- # 하단 역할: 2분할
- bottom_col_w = (inner_w - gap_block) // 2
+ # 하단 역할: zone에 따라 전체폭 또는 2분할
+ has_bottom_full = any(info.get("zone") == "bottom" for _, info in bottom_roles)
+ bottom_col_w = inner_w if has_bottom_full else (inner_w - gap_block) // 2
+
for role_name, info in bottom_roles:
+ zone = info.get("zone", "bottom_left")
+ w = inner_w if zone == "bottom" else bottom_col_w
specs[role_name] = ContainerSpec(
role=role_name,
- zone=info.get("zone", "bottom_left"),
+ zone=zone,
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=bottom_h,
- width_px=bottom_col_w,
+ width_px=w,
max_height_cost=_max_allowed_height_cost(bottom_h),
block_constraints={},
)
diff --git a/src/step_visualizer.py b/src/step_visualizer.py
index 7120206..30a4fcd 100644
--- a/src/step_visualizer.py
+++ b/src/step_visualizer.py
@@ -29,8 +29,50 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
-COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
-FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
+COLORS_A = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
+FONT_MAP_A = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
+
+# Type B용 동적 색상 팔레트
+_COLOR_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#dc2626", "#0891b2"]
+
+# 하위 호환: 기존 코드에서 COLORS/FONT_MAP 참조하는 곳 대응
+COLORS = COLORS_A
+FONT_MAP = FONT_MAP_A
+
+
+def _is_type_b(ctx) -> bool:
+ """page_structure에 zone 키가 있으면 Type B."""
+ ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
+ for info in ps.values():
+ if isinstance(info, dict) and info.get("zone") in ("top", "bottom_left", "bottom_right"):
+ return True
+ return False
+
+
+def _get_roles(ctx) -> list[str]:
+ """page_structure의 실제 역할명 목록 (순서: zone 기준)."""
+ ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
+ if _is_type_b(ctx):
+ zone_order = {"top": 0, "bottom_left": 1, "bottom_right": 2, "footer": 3}
+ roles = []
+ for role_name, info in ps.items():
+ if isinstance(info, dict):
+ z = info.get("zone", "")
+ roles.append((zone_order.get(z, 9), role_name))
+ return [r for _, r in sorted(roles)]
+ else:
+ return ["배경", "본심", "첨부", "결론"]
+
+
+def _get_color(role: str, ctx=None) -> str:
+ """역할명 → 색상. Type A는 고정, Type B는 동적."""
+ if role in COLORS_A:
+ return COLORS_A[role]
+ if ctx:
+ roles = _get_roles(ctx)
+ idx = roles.index(role) if role in roles else 0
+ return _COLOR_PALETTE[idx % len(_COLOR_PALETTE)]
+ return "#666666"
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
@@ -74,21 +116,65 @@ def _tokens():
return _load_design_tokens()
-def _calc_coords(containers: dict, ratio: tuple) -> dict:
+def _calc_coords(containers: dict, ratio: tuple, ctx=None) -> dict:
+ """역할별 좌표 계산. Type A/B 자동 분기."""
t = _tokens()
pad = t.get("spacing_page", 40)
gap = t.get("spacing_block", 20)
small = t.get("spacing_small", 8)
header_h = 66
-
inner_w = 1280 - pad * 2
- body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
- sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
def gh(c):
if hasattr(c, "height_px"): return c.height_px
return c.get("height_px", 0) if isinstance(c, dict) else 0
+ def gw(c):
+ if hasattr(c, "width_px"): return c.width_px
+ return c.get("width_px", 0) if isinstance(c, dict) else 0
+
+ # Type B 감지
+ if ctx and _is_type_b(ctx):
+ ps = ctx.page_structure.roles
+ coords = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}}
+
+ # zone별 컨테이너 찾기
+ zone_map = {}
+ for role_name, info in ps.items():
+ if isinstance(info, dict):
+ zone_map[info.get("zone", "")] = role_name
+
+ top_role = zone_map.get("top", "")
+ bl_role = zone_map.get("bottom_left", "")
+ br_role = zone_map.get("bottom_right", "")
+ ft_role = zone_map.get("footer", "")
+
+ top_h = gh(containers.get(top_role, {}))
+ bl_h = gh(containers.get(bl_role, {}))
+ br_h = gh(containers.get(br_role, {}))
+ ft_h = gh(containers.get(ft_role, {}))
+
+ top_top = pad + header_h + gap
+ bottom_top = top_top + top_h + small
+ bottom_h = max(bl_h, br_h)
+ ft_top = bottom_top + bottom_h + gap
+ bottom_col_w = (inner_w - gap) // 2
+
+ if top_role:
+ coords[top_role] = {"l": pad, "t": top_top, "w": inner_w, "h": top_h}
+ if bl_role:
+ coords[bl_role] = {"l": pad, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
+ if br_role:
+ coords[br_role] = {"l": pad + bottom_col_w + gap, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
+ if ft_role:
+ coords[ft_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h}
+
+ return coords
+
+ # Type A (기존)
+ body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
+ sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
+
bg_h = gh(containers.get("배경", {}))
core_h = gh(containers.get("본심", {}))
sb_h = gh(containers.get("첨부", {}))
@@ -107,12 +193,38 @@ def _calc_coords(containers: dict, ratio: tuple) -> dict:
}
-def _wrap(title, subtitle, slide_body):
- return f"""
+def _wrap(title, subtitle, slide_body, ctx=None):
+ """slide-base.html 기반 래핑. step 시각화도 실제 슬라이드와 같은 기반 사용."""
+ from pathlib import Path
+
+ slide_base_path = Path(__file__).parent.parent / "templates" / "blocks" / "slide-base.html"
+ slide_title = ""
+ footer_text = ""
+ if ctx:
+ slide_title = ctx.analysis.title if ctx.analysis else ""
+ footer_text = ctx.analysis.conclusion_text if ctx.analysis else ""
+
+ try:
+ raw = slide_base_path.read_text(encoding="utf-8")
+ # {% block body %} → slide_body로 치환
+ raw = raw.replace("{% block body %}{% endblock %}", slide_body)
+ from jinja2 import Template
+ template = Template(raw)
+ slide_html = template.render(title=slide_title, footer_text=footer_text, footer_pill_bg="")
+ # step 라벨 추가
+ label = (f'
'
+ f'{title}
'
+ f'
'
+ f'{subtitle}
')
+ return slide_html.replace('', f'{label}')
+ except Exception:
+ # fallback: 기존 방식
+ return f"""
{title}
{subtitle}
@@ -263,23 +375,39 @@ def _gen_stage_1b(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_5a(ctx, steps_dir):
- coords = _calc_coords(ctx.containers, ctx.container_ratio)
- fh = ctx.font_hierarchy
- title = ctx.analysis.title or "슬라이드"
- body = _hdr(coords["header"], title)
+ """slide-base 위에 빈 zone 컨테이너만 표시."""
+ ps = ctx.page_structure.roles
+ gap = 8
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
- fk = FONT_MAP[role]
- font = getattr(fh, fk, "?")
- inner = (f'
'
- f'{role} '
- f'{c["w"]}x{c["h"]}px / font:{font}px
')
- body += _box(c, role, inner)
+ # zone 순서
+ zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
+ roles_sorted = sorted(
+ [(r, info) for r, info in ps.items() if isinstance(info, dict)],
+ key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
+ )
- r = ctx.container_ratio
- html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
+ body_html = ""
+ for i, (role, info) in enumerate(roles_sorted):
+ ci = ctx.containers.get(role)
+ if not ci:
+ continue
+ cl = _get_color(role, ctx)
+ zone = info.get("zone", "")
+ w = ci.width_px
+ h = ci.height_px
+ tids = info.get("topic_ids", [])
+
+ body_html += (
+ f'
'
+ f'
'
+ f'{role} '
+ f'zone: {zone} / {w}×{h}px '
+ f'topics: {tids} '
+ f'
\n'
+ )
+
+ html = _wrap("Stage 1.5a: 빈 컨테이너", "slide-base 위에 zone 배치", body_html, ctx=ctx)
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
@@ -288,19 +416,28 @@ def _gen_stage_1_5a(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_5a_content(ctx, steps_dir):
- coords = _calc_coords(ctx.containers, ctx.container_ratio)
- title = ctx.analysis.title or "슬라이드"
- body = _hdr(coords["header"], title)
+ """slide-base 위 zone에 topic 콘텐츠 배치."""
ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics}
+ gap = 8
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
- info = ps.get(role, {})
- tids = info.get("topic_ids", []) if isinstance(info, dict) else []
+ zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
+ roles_sorted = sorted(
+ [(r, info) for r, info in ps.items() if isinstance(info, dict)],
+ key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
+ )
- lines = [f'
{role}
']
+ body_html = ""
+ for role, info in roles_sorted:
+ ci = ctx.containers.get(role)
+ if not ci:
+ continue
+ cl = _get_color(role, ctx)
+ tids = info.get("topic_ids", [])
+ w = ci.width_px
+ h = ci.height_px
+
+ lines = [f'
{role} ({w}×{h}px)
']
for tid in tids:
t = topic_map.get(tid)
if not t:
@@ -308,16 +445,18 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
lines.append(f'
[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}
')
sd = t.source_data
if sd:
- # 불릿으로 표시
- for sent in sd.split(", "):
+ for sent in sd.split(", ")[:5]:
sent = sent.strip()
if sent:
- lines.append(f'
• {sent}
')
+ lines.append(f'
• {sent}
')
- inner = f'
{"".join(lines)}
'
- body += _box(c, role, inner)
+ body_html += (
+ f'
'
+ f'{"".join(lines)}
\n'
+ )
- html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
+ html = _wrap("Stage 1.5a: 콘텐츠 배치", "zone별 topic source_data", body_html, ctx=ctx)
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
@@ -326,36 +465,41 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_5b(ctx, steps_dir):
- """영역별 디자인 예산 (available height/width, fits 여부)."""
- coords = _calc_coords(ctx.containers, ctx.container_ratio)
- title = ctx.analysis.title or "슬라이드"
- body = _hdr(coords["header"], title)
+ """slide-base 위 zone별 디자인 예산."""
+ ps = ctx.page_structure.roles
+ gap = 8
+ zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
+ roles_sorted = sorted(
+ [(r, info) for r, info in ps.items() if isinstance(info, dict)],
+ key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
+ )
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
+ body_html = ""
+ for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
+ cl = _get_color(role, ctx)
+ w, h = ci.width_px, ci.height_px
db = ci.design_budget
if db and hasattr(db, 'model_dump'):
db = db.model_dump()
elif not isinstance(db, dict):
db = {}
-
avail_h = db.get("available_height_px", 0)
avail_w = db.get("available_width_px", 0)
fits = db.get("fits", False)
icon = "✅" if fits else "⚠️"
- inner = (f'
'
- f'
{icon} {role} ({c["w"]}×{c["h"]}px)
'
- f'
available: {avail_h}×{avail_w}px
'
- f'
fits: {fits}
'
- f'
')
- body += _box(c, role, inner)
+ body_html += (
+ f'
'
+ f'
{icon} {role} ({w}×{h}px)
'
+ f'
available: {avail_h}×{avail_w}px / fits: {fits}
'
+ f'
\n'
+ )
- html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body)
+ html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body_html, ctx=ctx)
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
@@ -364,30 +508,36 @@ def _gen_stage_1_5b(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_7(ctx, steps_dir):
- coords = _calc_coords(ctx.containers, ctx.container_ratio)
- title = ctx.analysis.title or "슬라이드"
- body = _hdr(coords["header"], title)
+ """slide-base 위 zone별 선택된 블록 표시."""
+ ps = ctx.page_structure.roles
+ gap = 8
+ zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
+ roles_sorted = sorted(
+ [(r, info) for r, info in ps.items() if isinstance(info, dict)],
+ key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
+ )
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
+ body_html = ""
+ for role, info in roles_sorted:
+ ci = ctx.containers.get(role)
+ if not ci:
+ continue
+ cl = _get_color(role, ctx)
+ w, h = ci.width_px, ci.height_px
ref_list = ctx.references.get(role, [])
- lines = [f'
{role} ({c["w"]}x{c["h"]}px)
']
+ lines = [f'
{role} ({w}×{h}px)
']
for r in ref_list:
- bid = r.block_id
- var = r.variant
- vtype = r.visual_type
- line = f'
{bid} ({var})
{vtype} '
- # 주종 정보 — model_dump에서 확인
- rd = r.model_dump() if hasattr(r, "model_dump") else {}
- # BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인
- lines.append(f'
{line}
')
+ vtype_label = "tag_match ✅" if r.visual_type == "tag_match" else r.visual_type
+ lines.append(f'
{r.block_id} ({r.variant}) — {vtype_label}
')
- inner = f'
{"".join(lines)}
'
- body += _box(c, role, inner)
+ body_html += (
+ f'
'
+ f'{"".join(lines)}
\n'
+ )
- html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
+ html = _wrap("Stage 1.7: 블록 선택", "tag 매칭 기반 블록 선택", body_html, ctx=ctx)
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
@@ -411,30 +561,35 @@ def _gen_stage_1_8_filled(ctx, steps_dir):
def _gen_stage_1_8_fit_before(ctx, steps_dir):
- """before: weight 비중대로 배정된 빈 컨테이너. fit 판단 없이 크기만 표시."""
- coords = _calc_coords(ctx.containers, ctx.container_ratio)
- title = ctx.analysis.title or "슬라이드"
- body = _hdr(coords["header"], title)
-
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
+ """slide-base 위 zone별 초기 배정 (weight 기반)."""
+ ps = ctx.page_structure.roles
+ gap = 8
+ zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
+ roles_sorted = sorted(
+ [(r, info) for r, info in ps.items() if isinstance(info, dict)],
+ key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
+ )
+ body_html = ""
+ for role, info in roles_sorted:
+ ci = ctx.containers.get(role)
+ if not ci:
+ continue
+ cl = _get_color(role, ctx)
+ w, h = ci.width_px, ci.height_px
+ weight = info.get("weight", 0)
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
- ps = ctx.page_structure.roles
- info = ps.get(role, {})
- weight = info.get("weight", 0) if isinstance(info, dict) else 0
+ body_html += (
+ f'
'
+ f'
{role} ({w}×{h}px)
'
+ f'
weight: {weight} / 블록: {blocks}
'
+ f'
\n'
+ )
- inner = (f'
'
- f'
{role} ({c["w"]}x{c["h"]}px)
'
- f'
weight: {weight}
'
- f'
블록: {blocks}
'
- f'
')
- body += _box(c, role, inner)
-
- html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body)
+ html = _wrap("Stage 1.8: before", "weight 비중 초기 배정. 블록 채움 전.", body_html, ctx=ctx)
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
@@ -443,65 +598,48 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_8_fit_after(ctx, steps_dir):
- fit = ctx.fit_result
- enh = ctx.enhancement_result
+ """slide-base 위 zone별 재배분 결과."""
+ ps = ctx.page_structure.roles
+ fit = ctx.fit_result or {}
+ enh = ctx.enhancement_result or {}
redist = fit.get("redistribution", {})
roles_fit = fit.get("roles", {})
+ gap = 8
- # 재배분된 컨테이너
- new_c = {}
- for role, ci in ctx.containers.items():
- new_h = int(redist.get(role, ci.height_px))
- new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
+ zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
+ roles_sorted = sorted(
+ [(r, info) for r, info in ps.items() if isinstance(info, dict)],
+ key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
+ )
- coords = _calc_coords(new_c, ctx.container_ratio)
- title = ctx.analysis.title or "슬라이드"
- body = _hdr(coords["header"], title)
-
- emps = enh.get("emphasis_blocks", [])
- bolds = enh.get("bold_keywords", {})
- sups = enh.get("supplement_blocks", [])
-
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
- rf = roles_fit.get(role, {})
- status = rf.get("fit_status", "?")
- icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
- old_h = rf.get("allocated_px", 0)
+ body_html = ""
+ for role, info in roles_sorted:
+ ci = ctx.containers.get(role)
+ if not ci:
+ continue
+ cl = _get_color(role, ctx)
+ w = ci.width_px
+ old_h = ci.height_px
new_h = int(redist.get(role, old_h))
- needed = rf.get("total_required_px", 0)
+ rf = roles_fit.get(role, {})
+ status = rf.get("fit_status", "OK")
+ icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "✅")
delta = new_h - old_h
+ delta_str = f" ({delta:+d}px)" if delta != 0 else ""
ref_list = ctx.references.get(role, [])
- blocks = ", ".join(r.block_id for r in ref_list)
+ blocks = ", ".join(r.block_id for r in ref_list) if ref_list else ""
- delta_str = f"
({delta:+d}px) " if delta != 0 else ""
+ body_html += (
+ f'
'
+ f'
{icon} {role} ({w}×{new_h}px){delta_str}
'
+ f'
블록: {blocks}
'
+ f'
\n'
+ )
- inner = (f'
'
- f'
{icon} {role} ({c["w"]}x{new_h}px){delta_str}
'
- f'
필요: {needed:.0f}px / 재배분 후: {new_h}px
'
- f'
블록: {blocks}
')
-
- # 보강 정보
- role_emps = [e for e in emps if e.get("role") == role]
- role_bolds = bolds.get(role, [])
- role_sups = [s for s in sups if s.get("role") == role]
-
- if role_emps:
- for e in role_emps:
- inner += f'
강조: {e.get("sentence","")[:40]}...
'
- if role_sups:
- for s in role_sups:
- inner += f'
보충: {s.get("block_id")} ({s.get("content_source")})
'
- if role_bolds:
- inner += f'
bold: {role_bolds[:4]}
'
-
- inner += '
'
- body += _box(c, role, inner)
-
- redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items())
- html = _wrap("Step 3b: 재배분 후 + 보강 (Stage 1.8)", f"재배분: {redist_str}", body)
+ redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items()) if redist else "재배분 없음"
+ html = _wrap("Stage 1.8: 재배분 후", f"재배분: {redist_str}", body_html, ctx=ctx)
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
@@ -510,161 +648,44 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_8_blocks(ctx, steps_dir):
- """재배분된 컨테이너에 블록 SLOT 구조 + 블록 디자인 + 주종관계 표시.
- debug_steps/step2_phase_v.html 수준의 시각화."""
- import re as _re
-
- fit = ctx.fit_result or {}
- redist = fit.get("redistribution", {})
- topic_map = {t.id: t for t in ctx.topics}
- ps = ctx.page_structure.roles
-
- new_c = {}
- for role, ci in ctx.containers.items():
- new_h = int(redist.get(role, ci.height_px))
- new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
-
- coords = _calc_coords(new_c, ctx.container_ratio)
- title = ctx.analysis.title or "슬라이드"
-
- all_block_css = set()
- slide_body = _hdr(coords["header"], title)
- legend_lines = []
-
- for role in ["배경", "본심", "첨부", "결론"]:
- c = coords[role]
- cl = COLORS[role]
- ref_list = ctx.references.get(role, [])
- info = ps.get(role, {})
- tids = info.get("topic_ids", []) if isinstance(info, dict) else []
-
- if not ref_list:
- slide_body += _box(c, role, f'
블록 없음
')
- continue
-
- r0 = ref_list[0]
- bid = r0.block_id
- var = r0.variant
- is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
- sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
- primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
-
- # 블록 디자인 HTML — SLOT 주석은 유지, 내용은 SLOT 마커로
- raw = r0.design_reference_html or ""
- # CSS 추출
- styles = _re.findall(r'', raw, _re.DOTALL)
- for s in styles:
- all_block_css.add(s)
- clean = _re.sub(r'', '', raw, flags=_re.DOTALL)
-
- # SLOT 주석을 보이는 텍스트로 변환
- def _slot_comment_to_visible(match):
- text = match.group(1).strip()
- if 'SLOT:' in text:
- return f'
{text} '
- return ''
- clean = _re.sub(r'', _slot_comment_to_visible, clean)
- # 나머지 주석 제거
- clean = _re.sub(r'', '', clean, flags=_re.DOTALL)
-
- # 태그 라벨 (동적)
- tag_parts = [f"{role} ({c['w']}×{c['h']})", bid]
- if is_hier:
- sup_str = "+".join(f"꼭지{st}" for st in sup_tids)
- tag_parts.append(f"꼭지{primary_tid}(주)+{sup_str}(종) → 블록 1개")
- tag_label = " · ".join(tag_parts)
-
- # 종속 꼭지 SLOT 표시
- sub_slot = ""
- if is_hier and sup_tids:
- for st in sup_tids:
- st_topic = topic_map.get(st)
- st_purpose = st_topic.purpose if st_topic and hasattr(st_topic, 'purpose') else ""
- sub_slot += (
- f'
'
- f'SLOT: 하위 (꼭지{st} — {st_purpose})
'
- )
-
- # key-msg SLOT (본심만)
- keymsg_slot = ""
- if role == "본심" and ctx.analysis.core_message:
- keymsg_slot = (
- f'
'
- f'SLOT: key-msg
'
- )
-
- inner = (
- f'
'
- f'{tag_label} '
- f'{clean}{sub_slot}{keymsg_slot}
'
- )
-
- slide_body += (
- f'
'
- f'{inner}
\n'
- )
-
- # 범례
- if is_hier:
- primary_topic = topic_map.get(primary_tid)
- p_layer = primary_topic.layer if primary_topic and hasattr(primary_topic, 'layer') else ""
- legend_lines.append(
- f'• {role}: 꼭지{primary_tid}({p_layer}) + '
- f'{"+".join(f"꼭지{st}" for st in sup_tids)} → '
- f'
주종 관계 → {bid} 1개 '
- )
- else:
- for r in ref_list:
- t = topic_map.get(r.topic_id if hasattr(r, 'topic_id') else None)
- t_layer = t.layer if t and hasattr(t, 'layer') else ""
- legend_lines.append(f'• {role}: 꼭지{r.topic_id}({t_layer}) →
{r.block_id} ')
-
- css_block = "\n".join(all_block_css)
- legend_html = "
".join(legend_lines)
-
- html = f"""
-
-
Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)
-
블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.
-
-{slide_body}
-
-
-블록 선택 근거 (layer 기반): {legend_html}
-
"""
+ """slide-base 위에 실제 블록 렌더링. assemble_slide_html_final과 동일한 결과."""
+ from src.block_assembler import assemble_slide_html_final
+ html = assemble_slide_html_final(ctx)
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
def _gen_stage_2(ctx, steps_dir):
- """Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
- 각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
+ """Stage 2 결과: 영역별 HTML 생성 결과.
+ Type A: Sonnet 영역별 출력. Type B: slide-base 완전 HTML."""
gen = ctx.generated_html or {}
sub_layouts = ctx.sub_layouts or {}
ps = ctx.page_structure.roles
- # body_html에서 배경/본심 분리 (spacer로 구분)
+ # Type B: generated_html이 str (완전한 HTML)
+ if isinstance(gen, str):
+ html = f"""
+
+
+
Stage 2: slide-base + 블록 템플릿 조립 결과 (Type B)
+
slide-base.html 배경 위에 블록 템플릿으로 조립된 최종 HTML
+
+"""
+ (steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
+ return
+
+ # Type A: dict (body_html, sidebar_html, footer_html)
+ import re as _re
+
body_html = gen.get("body_html", "")
sidebar_html = gen.get("sidebar_html", "")
footer_html = gen.get("footer_html", "")
- # body_html = 배경 + spacer + 본심. spacer로 분리
- import re as _re
spacer_pattern = r'
'
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
- # 역할별 HTML 매핑
role_htmls = {}
if bg_html and "배경" in ps:
role_htmls["배경"] = bg_html
@@ -680,11 +701,11 @@ def _gen_stage_2(ctx, steps_dir):
redist = fit.get("redistribution", {})
sections = []
- for role in ["배경", "본심", "첨부", "결론"]:
+ for role in _get_roles(ctx):
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
- cl = COLORS.get(role, "#333")
+ cl = _get_color(role, ctx)
ci = ctx.containers.get(role)
if not ci:
continue
@@ -745,6 +766,51 @@ Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_
# Stage 4: 품질 게이트
# ══════════════════════════════════════
+def _gen_structure_validation(ctx) -> str:
+ """sample-based 구조 검증. "어긋나면 안 된다" 기준."""
+ import re as _re
+ checks = []
+ html = ctx.rendered_html if hasattr(ctx, 'rendered_html') else ""
+ ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
+
+ # 1. 본문 텍스트 visible (body에 실제 텍스트가 있는지)
+ body_start = html.find(']+>', '', html[body_start:]) if body_start > 0 else ""
+ body_text = _re.sub(r'\s+', ' ', body_text).strip()
+ text_len = len(body_text)
+ ok = text_len > 100
+ checks.append(("본문 텍스트 visible", f"{'✅' if ok else '❌'} {text_len}자"))
+
+ # 2. detail link 개수 (role당 1개)
+ link_count = len(_re.findall(r'자세히보기', html)) if html else 0
+ popup_count = len(ctx.normalized.popups) if hasattr(ctx.normalized, 'popups') else 0
+ ok = link_count <= max(popup_count, 1)
+ checks.append(("detail link 개수", f"{'✅' if ok else '⚠️'} {link_count}개 (popup {popup_count}개)"))
+
+ # 3. body 안
+
Stage 4: 품질 게이트
품질 점수: {quality_score}
슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}
-
-영역 clientH scrollH excess {zone_rows}
+
+
Overflow 측정
+
+영역 clientH scrollH excess {zone_rows}
+
+
블록/Recipe 선택
+
+zone role schema block/recipe {recipe_rows}
+
+
Popup 연결
+
+popup_id target_role popup_file {popup_rows if popup_rows else '없음 '}
+
+
구조 검증
+
+검증 항목 결과
+{_gen_structure_validation(ctx)}
+
"""
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")
diff --git a/src/validators.py b/src/validators.py
index 8312493..3161d56 100644
--- a/src/validators.py
+++ b/src/validators.py
@@ -158,51 +158,8 @@ def validate_stage_1a(
})
return errors
- # weight 합 검증 (0.9~1.1)
- total_weight = sum(
- info.get("weight", 0) for info in page_struct.values()
- if isinstance(info, dict)
- )
- if total_weight < 0.9 or total_weight > 1.1:
- errors.append({
- "severity": "RETRYABLE",
- "field": "page_structure.weight",
- "localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
- "instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
- })
-
- # 유형에 따른 구조 검증
- layout_template = analysis.get("layout_template", "A")
- if layout_template == "A":
- # 유형 A: 본심 필수
- core_info = page_struct.get("본심", {})
- if not core_info or not isinstance(core_info, dict):
- errors.append({
- "severity": "RETRYABLE",
- "field": "page_structure.본심",
- "localization": "본심 역할이 page_structure에 없음",
- "instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
- })
- elif core_info.get("weight", 0) < 0.3:
- errors.append({
- "severity": "RETRYABLE",
- "field": "page_structure.본심.weight",
- "localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
- "instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
- })
- elif layout_template == "B":
- # 유형 B: 결론(footer) 필수, 나머지 자유
- has_footer = any(
- isinstance(info, dict) and info.get("zone") == "footer"
- for info in page_struct.values()
- )
- if not has_footer and "결론" not in page_struct:
- errors.append({
- "severity": "RETRYABLE",
- "field": "page_structure.footer",
- "localization": "결론(footer) 역할이 없음",
- "instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
- })
+ # Phase Y: page_structure 검증은 validate_page_structure()에서 별도 수행.
+ # Stage 1A에서는 Kei 응답의 page_structure를 검증하지 않음.
# 필수 필드 검증
for t in topics:
@@ -243,7 +200,8 @@ def validate_stage_1a(
# 원본 ## 섹션 수 vs topic 수 비교
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
# 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
- max_diff = 4 if layout_template == "B" else 2
+ _layout = analysis.get("layout_template", "A")
+ max_diff = 4 if _layout == "B" else 2
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
errors.append({
"severity": "RETRYABLE",
@@ -336,18 +294,29 @@ def validate_stage_1b(
})
# ── 모순 탐지 (결정 테이블) ──
+ # Phase Y: Type B에서는 purpose/relation_type이 블록 선택의 핵심 입력이 아님
+ # (tag 매칭이 item_count + content_example로 동작)
+ # → Type B: 경고만 (파이프라인 계속). Type A: hard fail 유지.
if purpose in CONTRADICTIONS:
if relation_type in CONTRADICTIONS[purpose]:
- errors.append({
- "severity": "RETRYABLE",
- "field": f"topics[{tid}].relation_type",
- "localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
- "current_value": f"purpose={purpose}, relation_type={relation_type}",
- "evidence": f"'{purpose}'는 '{relation_type}'와 논리적으로 양립 불가",
- "instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
- f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
- })
+ if layout_template == "B":
+ # Type B: 경고만
+ logger.warning(
+ f"[T-2 모순경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' "
+ f"— Type B에서는 보조 힌트이므로 경고만"
+ )
+ else:
+ # Type A: hard fail 유지
+ errors.append({
+ "severity": "RETRYABLE",
+ "field": f"topics[{tid}].relation_type",
+ "localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
+ "current_value": f"purpose={purpose}, relation_type={relation_type}",
+ "evidence": f"'{purpose}'는 '{relation_type}'와 논리적으로 양립 불가",
+ "instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
+ f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
+ })
if purpose in SOFT_WARNINGS:
if relation_type in SOFT_WARNINGS[purpose]:
@@ -400,3 +369,35 @@ def validate_stage_1b(
})
return errors
+
+
+def validate_page_structure(page_struct: dict) -> list[dict]:
+ """Phase Y: section_parser가 생성한 page_structure 검증.
+
+ Stage 1A 후, section_parser + 블록 매칭으로 page_structure가 채워진 후 호출.
+ """
+ errors = []
+
+ if not page_struct:
+ errors.append({
+ "severity": "FATAL",
+ "field": "page_structure",
+ "localization": "page_structure가 비어있음",
+ "instruction": "section_parser가 영역을 생성하지 못함",
+ })
+ return errors
+
+ # weight 합 검증 (0.9~1.1)
+ total_weight = sum(
+ info.get("weight", 0) for info in page_struct.values()
+ if isinstance(info, dict)
+ )
+ if total_weight < 0.9 or total_weight > 1.1:
+ errors.append({
+ "severity": "RETRYABLE",
+ "field": "page_structure.weight",
+ "localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
+ "instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
+ })
+
+ return errors
diff --git a/templates/blocks/cards/compare-detail-gradient.html b/templates/blocks/cards/compare-detail-gradient.html
index ba1f441..2597bc4 100644
--- a/templates/blocks/cards/compare-detail-gradient.html
+++ b/templates/blocks/cards/compare-detail-gradient.html
@@ -84,7 +84,7 @@
/* ── Headers (비대칭 라운드 — 자체 배경 유지) ── */
.cdg-header { padding: 12px 28px; display: flex; min-height: 52px; align-items: center; }
-.cdg-header-text { font-size: 26px; font-weight: var(--weight-black, 900); color: #000; line-height: 1.3; }
+.cdg-header-text { font-size: var(--cdg-heading-font, 26px); font-weight: var(--weight-black, 900); color: #000; line-height: 1.3; }
.cdg-header-warm {
background: linear-gradient(90deg, rgba(165,161,150,0.15), rgba(57,50,30,0.85));
border-radius: 0 28px 28px 0; justify-content: flex-end; text-align: right; margin-right: 4px;
@@ -105,7 +105,7 @@
.cdg-cell-teal { background: none; }
/* ── Section Title & Body ── */
-.cdg-sec-title { font-size: 18px; font-weight: var(--weight-black, 900); line-height: 1.4; word-break: keep-all; margin-bottom: 4px; }
+.cdg-sec-title { font-size: var(--cdg-body-font, 18px); font-weight: var(--weight-black, 900); line-height: 1.4; word-break: keep-all; margin-bottom: 4px; }
.cdg-title-warm { color: var(--color-warm-brown, #5C3714); }
.cdg-title-teal { color: var(--color-dark-teal, #084C56); }
.cdg-sec-body { padding-left: 8px; }
@@ -113,7 +113,7 @@
/* ── Bullets ── */
.cdg-bullet {
position: relative; padding-left: 14px;
- font-size: 14px; font-weight: var(--weight-bold, 700); color: #1a1a1a;
+ font-size: var(--cdg-body-font, 14px); font-weight: var(--weight-bold, 700); color: #1a1a1a;
line-height: 1.7; word-break: keep-all;
}
.cdg-bullet::before { content: '•'; position: absolute; left: 0; color: #666; }
diff --git a/templates/blocks/new/prerequisites-3col.html b/templates/blocks/new/prerequisites-3col.html
new file mode 100644
index 0000000..803923d
--- /dev/null
+++ b/templates/blocks/new/prerequisites-3col.html
@@ -0,0 +1,193 @@
+
+
+
+ {% for col in columns %}
+
+
+
+
+
+
+
{{ col.name }}
+
{{ col.sub }}
+
+
+
+ {% if col.kanji_top %}
+
{{ col.kanji_top }}
+ {% endif %}
+ {% if col.kanji_bottom %}
+
{{ col.kanji_bottom }}
+ {% endif %}
+
+
+
+
+ {{ col.entries[0].heading|safe }}
+
+
+ {% if col.entries[0].bullets is defined and col.entries[0].bullets %}
+ {% for b in col.entries[0].bullets %}
• {{ b }}
{% endfor %}
+ {% else %}
+ {{ col.entries[0].desc|safe }}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {{ col.entries[1].heading|safe }}
+
+
+ {% if col.entries[1].bullets is defined and col.entries[1].bullets %}
+ {% for b in col.entries[1].bullets %}
• {{ b }}
{% endfor %}
+ {% else %}
+ {{ col.entries[1].desc|safe }}
+ {% endif %}
+
+
+
+
+
+
+
+ {% endfor %}
+
+
+
diff --git a/templates/blocks/redesign/process-product-2col.html b/templates/blocks/redesign/process-product-2col.html
new file mode 100644
index 0000000..2b1de9b
--- /dev/null
+++ b/templates/blocks/redesign/process-product-2col.html
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if left_compare %}
+
+
{{ left_compare.title }}
+
+
+ {% for item in left_compare.left_items %}
+
• {{ item }}
+ {% endfor %}
+
+
+ {% if arrow_image %}
+ {% else %}
➠ {% endif %}
+
+
+ {% for item in left_compare.right_items %}
+
• {{ item }}
+ {% endfor %}
+
+
+
+
+ {% if paired_rows and paired_rows|length > 0 %}
+
{{ paired_rows[0].right.title }}
+ {% for bullet in paired_rows[0].right.bullets %}
+
• {{ bullet }}
+ {% endfor %}
+ {% endif %}
+
+ {% endif %}
+
+
+ {% for row in paired_rows %}
+ {% if loop.index0 > 0 or not left_compare %}
+
+ {% if row.left %}
+
{{ row.left.title }}
+ {% for bullet in row.left.bullets %}
+
• {{ bullet }}
+ {% endfor %}
+ {% endif %}
+
+
+ {% if row.right %}
+
{{ row.right.title }}
+ {% for bullet in row.right.bullets %}
+
• {{ bullet }}
+ {% endfor %}
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
diff --git a/templates/catalog.yaml b/templates/catalog.yaml
index cb17a27..0feab70 100644
--- a/templates/catalog.yaml
+++ b/templates/catalog.yaml
@@ -8,17 +8,25 @@ blocks:
min_height_px: 300
relation_types: []
visual: 전체 너비 배경 이미지 위에 흰색 영문 소제목 + 한글 대제목. 고정 높이 ~500px. 페이지 첫 화면 히어로.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 페이지/섹션의 맨 첫 화면 (히어로 영역)
+
- 배경 이미지 위에 제목만 (본문 없음)
+
- 한글 대제목(1~2줄) + 선택적 영문 소제목
+
- 높이 300px 이상 차지 가능
+
+ '
when: 페이지 첫 화면에 배경 이미지 위 제목을 크게 선언할 때. 본문 없이 제목만.
- not_for: |
- - 슬라이드 내부 소제목 → topic-left-right 또는 topic-center
+ not_for: '- 슬라이드 내부 소제목 → topic-left-right 또는 topic-center
+
- 배경 이미지 없이 텍스트만 → topic-center
+
- 높이 200px 이하 → section-header-bar
+
+ '
purpose_fit: []
slots:
required:
@@ -48,6 +56,11 @@ blocks:
note: 13px, 상단 경로
padding_overhead_px: 0
padding_h_px: 60
+ tags:
+ content_pattern: page-hero-title-with-background
+ content_example: 페이지 첫 화면 히어로. 배경이미지 위 영문소제목+한글대제목
+ item_count: 1
+ layout: full-width-hero-500px
- id: section-header-bar
name: 섹션 헤더 바
category: headers
@@ -56,17 +69,25 @@ blocks:
min_height_px: 40
relation_types: []
visual: 전체 너비 파란 바 + 중앙 흰색 제목. 섹션 구분. 컴팩트(~50px).
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 같은 페이지 안에서 주제 전환 구분선
+
- 제목(1줄) + 선택적 서브타이틀
+
- 높이 40~50px, 최소 공간 사용
+
- 설명/본문 없이 제목만
+
+ '
when: 페이지 내 섹션 전환 구분. 제목만. 컴팩트.
- not_for: |
- - 히어로 타이틀 → section-title-with-bg
+ not_for: '- 히어로 타이틀 → section-title-with-bg
+
- 좌:제목 우:설명 → topic-left-right
+
- 텍스트 구분선(―) → divider-text
+
+ '
purpose_fit: []
slots:
required:
@@ -90,6 +111,11 @@ blocks:
note: 13px, 1줄
padding_overhead_px: 28
padding_h_px: 32
+ tags:
+ content_pattern: section-divider-bar
+ content_example: 같은 페이지 안 섹션 전환. 파란바+중앙제목
+ item_count: 1
+ layout: full-width-compact-bar
- id: topic-left-right
name: 좌우 꼭지 헤더
category: headers
@@ -98,18 +124,27 @@ blocks:
min_height_px: 50
relation_types: []
visual: 좌측 고정폭 파란 제목 + 우측 본문 설명. 가로 2단 배치.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 꼭지 제목이 짧은 키워드(10자 이내)이고 우측에 설명이 붙는 구조
+
- 좌=핵심 주장/키워드, 우=근거/설명 대비
+
- 번호 없음 (번호 있으면 topic-numbered)
+
- 중앙 정렬 아님 (중앙이면 topic-center)
+
예: 좌"용어의 혼용" → 우"DX와 BIM이 혼용되고 있다..."
+
+ '
when: 짧은 키워드 제목 + 우측 설명의 좌우 대비 구조. 문제 제기 도입부.
- not_for: |
- - 중앙 정렬 대제목 → topic-center
+ not_for: '- 중앙 정렬 대제목 → topic-center
+
- 번호 순서 → topic-numbered
+
- 페이지 히어로 → section-title-with-bg
+
+ '
purpose_fit:
- 문제제기
slots:
@@ -132,6 +167,11 @@ blocks:
note: 16px, 510px 너비
padding_overhead_px: 24
padding_h_px: 40
+ tags:
+ content_pattern: left-keyword-right-description
+ content_example: 좌=용어의혼용 우=DX와BIM이 개념적으로 명확히 정립되지 않은 채 혼용
+ item_count: 1
+ layout: flex-row-240px-left
- id: topic-center
name: 중앙 정렬 꼭지 헤더
category: headers
@@ -140,18 +180,27 @@ blocks:
min_height_px: 60
relation_types: []
visual: 중앙 정렬 대제목 + 서브타이틀 + 설명. 단독 주제 선언.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 하나의 주제를 중앙에 크게 선언 (좌우 분리 없음)
+
- 제목(1줄) + 선택적 서브타이틀 + 선택적 설명(1~3줄)
+
- 번호/순서 없음, 좌우 대비 없음
+
- sidebar 섹션 라벨로도 사용 가능
+
예: "건설산업의 디지털 전환" + 서브: "DX Overview" + 설명 2줄
+
+ '
when: 주제를 중앙 정렬로 단독 선언할 때. 좌우 분리/번호 불필요.
- not_for: |
- - 좌:제목 우:설명 대비 → topic-left-right
+ not_for: '- 좌:제목 우:설명 대비 → topic-left-right
+
- 번호 순서 → topic-numbered
+
- 페이지 히어로(배경 이미지) → section-title-with-bg
+
+ '
purpose_fit: []
slots:
required:
@@ -183,6 +232,11 @@ blocks:
note: 16px
padding_overhead_px: 40
padding_h_px: 0
+ tags:
+ content_pattern: centered-topic-declaration
+ content_example: 하나의 주제를 중앙에 크게 선언. 제목+서브타이틀+설명
+ item_count: 1
+ layout: centered-column
- id: topic-numbered
name: 번호 꼭지 헤더
category: headers
@@ -191,18 +245,27 @@ blocks:
min_height_px: 45
relation_types: []
visual: 원형 번호 뱃지 + 제목 + 구분선 + 설명. 세로 배치. 섹션 시작 헤더.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 꼭지/섹션 시작에 번호(1, 2, 3)가 필요
+
- 번호 + 제목(1줄) + 선택적 설명(1~2줄)
+
- 섹션 헤더 역할 (본문 블록이 이 아래에 옴)
+
- 번호가 순서/단계를 의미
+
예: ① 건설산업 DX → 설명 1줄 (이 아래에 본문 블록들이 배치됨)
+
+ '
when: 순서가 있는 꼭지의 섹션 시작 헤더. 번호+제목+설명.
- not_for: |
- - 순서 없는 꼭지 → topic-left-right 또는 topic-center
+ not_for: '- 순서 없는 꼭지 → topic-left-right 또는 topic-center
+
- 카드 안의 번호 나열 → card-numbered
+
- 프로세스 흐름도 → process-horizontal
+
+ '
purpose_fit: []
slots:
required:
@@ -234,6 +297,12 @@ blocks:
note: 15px, line-height 1.7
padding_overhead_px: 28
padding_h_px: 40
+ tags:
+ content_pattern: numbered-section-header
+ content_example: 1.건설산업DX → 설명1줄. 섹션시작 번호헤더
+ item_count: 1
+ has_number: true
+ layout: flex-row-number-title
- id: card-image-3col
name: 이미지 카드 3열
category: cards
@@ -244,19 +313,29 @@ blocks:
min_items: 2
max_items: 3
visual: N열 카드. 각 카드 = 상단 이미지(160px) + 색상 밑줄 제목 + 불릿 목록.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~3개 항목, 각 항목에 대표 이미지(사진/도표)가 있음
+
- 이미지 아래에 제목(1줄) + 불릿 리스트(3~4개)
+
- 이미지가 콘텐츠의 핵심 (텍스트만으로는 부족)
+
- 각 항목이 독립적이고 동등한 비중
+
예: 설계단계(3D모델 사진) / 시공단계(현장 사진) / 유지관리(자산 사진)
+
+ '
when: 각 항목에 대표 이미지가 있고, 이미지 아래 제목+불릿으로 설명할 때. 2~3개 동등 항목.
- not_for: |
- - 이미지 없이 텍스트만 → card-icon-desc
+ not_for: '- 이미지 없이 텍스트만 → card-icon-desc
+
- 이미지 위에 어두운 오버레이+흰 텍스트 → card-dark-overlay
+
- 좌상단 태그 분류 → card-tag-image
+
- 원형 이미지 → card-image-round
+
+ '
purpose_fit:
- 핵심전달
- 근거사례
@@ -297,6 +376,13 @@ blocks:
note: 카드 수
padding_overhead_px: 16
padding_h_px: 0
+ tags:
+ content_pattern: N-items-image-title-bullets
+ content_example: 설계단계(3D모델사진+불릿) | 시공단계(현장사진+불릿) | 유지관리(자산사진+불릿)
+ item_count: 2-3
+ has_image: true
+ has_bullets: true
+ layout: N-col-grid
- id: card-dark-overlay
name: 다크 오버레이 카드
category: cards
@@ -307,19 +393,29 @@ blocks:
min_items: 3
max_items: 5
visual: N열 카드. 각 카드 = 다크 배경 이미지 + gradient 오버레이 + 흰색 제목 + 짧은 설명. 시각적 임팩트 중심.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 3~5개 키워드/개념을 시각적으로 강렬하게 표현
+
- 각 항목에 배경 이미지(사진) + 짧은 제목(1줄) + 설명(1~2줄 이내)
+
- 텍스트가 짧고 이미지 분위기가 중요 (정보 전달 < 시각 임팩트)
+
- 각 항목에 불릿 리스트 불필요
+
예: [협업지원(회의사진), 오류감소(모델사진), 생산성향상(현장사진)]
+
+ '
when: 키워드를 배경 이미지 위에 시각적으로 강조할 때. 설명 2줄 이내. 분위기/인상 전달 목적.
- not_for: |
- - 설명이 3줄+ → card-icon-desc
+ not_for: '- 설명이 3줄+ → card-icon-desc
+
- 이미지를 크게 보여줘야 함 (이미지가 콘텐츠) → card-image-3col
+
- 불릿 리스트 필요 → card-image-3col
+
- 이미지 없이 텍스트만 → card-icon-desc
+
+ '
purpose_fit:
- 핵심전달
- 구조시각화
@@ -346,6 +442,12 @@ blocks:
note: 카드 수
padding_overhead_px: 32
padding_h_px: 40
+ tags:
+ content_pattern: N-items-image-overlay-keyword
+ content_example: 협업지원(회의사진) | 오류감소(모델사진) | 생산성향상(현장사진). 키워드+1~2줄
+ item_count: 3-5
+ has_image: true
+ layout: N-col-grid-dark
- id: card-tag-image
name: 태그 이미지 카드
category: cards
@@ -356,18 +458,27 @@ blocks:
min_items: 2
max_items: 3
visual: N열 카드. 각 카드 = 좌상단 색상 태그 뱃지 + 이미지 + 제목 + 설명.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~3개 항목, 각 항목에 카테고리 태그(짧은 라벨, 8자 이내)가 있음
+
- 태그 색상으로 카테고리를 시각적으로 구분하는 것이 핵심
+
- 각 항목에 이미지 + 제목(1줄) + 설명(1~2줄)
+
- 태그 없이는 항목 분류가 불명확한 경우
+
예: [제조업(파란태그)/사진/설명] | [건축(초록태그)/사진/설명] | [인프라(빨간태그)/사진/설명]
+
+ '
when: 각 항목에 카테고리 태그 라벨이 있고, 태그 색상으로 분류를 구분할 때. 이미지+제목+설명 구조.
- not_for: |
- - 태그 불필요, 이미지+불릿 → card-image-3col
+ not_for: '- 태그 불필요, 이미지+불릿 → card-image-3col
+
- 이미지 없이 텍스트만 → card-icon-desc
+
- 어두운 분위기 강조 → card-dark-overlay
+
+ '
purpose_fit:
- 핵심전달
slots:
@@ -398,6 +509,13 @@ blocks:
note: 카드 수
padding_overhead_px: 14
padding_h_px: 0
+ tags:
+ content_pattern: N-items-tag-image-description
+ content_example: 제조업(파란태그/사진/설명) | 건축(초록태그/사진/설명) | 인프라(빨간태그/사진/설명)
+ item_count: 2-3
+ has_tag: true
+ has_image: true
+ layout: N-col-grid
- id: card-icon-desc
name: 아이콘 설명 카드
category: cards
@@ -416,20 +534,31 @@ blocks:
template: blocks/cards/card-icon-desc--compact.html
when: 컨테이너 높이가 150px 미만일 때
visual: 2~4열 그리드. 중앙 이모지 아이콘 + 제목 + 설명. 밝은 배경.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~4개 독립 개념/특성/키워드를 나열
+
- 각 항목에 이모지 아이콘(1글자) + 제목(1줄) + 설명(1~3줄)
+
- 실제 사진/이미지 없이 아이콘으로 직관적 구분
+
- 항목 간 순서 없음 (순서 있으면 card-numbered)
+
- 설명이 필요 (키워드만이면 card-dark-overlay)
+
예: [기술기반/설명3줄] | [S/W역량/설명3줄] | [여건조성/설명3줄]
+
+ '
when: 이미지 없이 이모지 아이콘+제목+설명으로 독립 개념을 나열할 때. 순서 없는 2~4개 항목.
- not_for: |
- - 실제 사진 필요 → card-image-3col
+ not_for: '- 실제 사진 필요 → card-image-3col
+
- 순서 번호 필요 → card-numbered
+
- 수치 데이터 → card-stat-number
+
- 키워드만(설명 불필요) → card-dark-overlay
+
+ '
purpose_fit:
- 핵심전달
- 근거사례
@@ -457,6 +586,12 @@ blocks:
note: 카드 수 (3열 grid)
padding_overhead_px: 40
padding_h_px: 32
+ tags:
+ content_pattern: N-items-icon-title-description
+ content_example: 기술기반(이모지+설명3줄) | SW역량(이모지+설명3줄) | 여건조성(이모지+설명3줄)
+ item_count: 2-4
+ has_icon: true
+ layout: N-col-grid
- id: card-compare-3col
name: 3단 비교 카드
category: cards
@@ -468,14 +603,19 @@ blocks:
min_items: 3
max_items: 3
visual: 3열 카드. 각 카드 = 색상 헤더바(제목+서브) + 선택적 이미지 + 불릿 목록.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 정확히 3개 대상을 각각 독립 카드로 비교
+
- 각 대상에 색상 구분 헤더(제목+서브타이틀) + 선택적 이미지 + 불릿 리스트
+
- 항목 간 행별 대조가 아니라, 각 카드가 독립적으로 내용 서술
+
- 카드별 색상으로 카테고리 구분
+
예: [BIM(파란)/불릿3개] | [DfMA(초록)/불릿3개] | [DX(주황)/불릿3개]
+ '
when: '3개 카테고리를 비교할 때. 각 카테고리에 다른 색상 헤더. 예: 상용SW(회색) vs 3rd Party(파랑) vs 전문SW(빨강).'
not_for: 2개 비교 → compare-pill-pair + compare-2col-split. 다항목 표 → compare-3col-badge.
purpose_fit:
@@ -503,6 +643,13 @@ blocks:
note: 카드당 불릿 수
padding_overhead_px: 26
padding_h_px: 0
+ tags:
+ content_pattern: 3-independent-cards-colored-header-bullets
+ content_example: BIM(파란헤더+불릿) | DfMA(초록헤더+불릿) | DX(주황헤더+불릿)
+ item_count: 3
+ has_color_bar: true
+ has_bullets: true
+ layout: 3-col-grid
- id: card-step-vertical
name: 세로 단계 카드
category: cards
@@ -514,18 +661,27 @@ blocks:
min_items: 2
max_items: 4
visual: 세로 타임라인. 좌측 색상 마커(단계명) + 우측 콘텐츠 박스(제목+이미지+설명). 단계 간 연결선.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~4개 단계가 세로로 순서대로 진행
+
- 각 단계에 단계명(좌) + 제목 + 선택적 이미지 + 설명(2~3줄)
+
- 단계 간 연결선이 시각적으로 필요
+
- 각 단계의 설명이 상세함 (간단하면 process-horizontal)
+
예: 설계단계(이미지+설명) → 시공단계(이미지+설명) → 운영단계(이미지+설명)
+
+ '
when: 생애주기/프로세스를 세로 타임라인으로 상세 설명할 때. 각 단계에 이미지+설명.
- not_for: |
- - 간단한 가로 흐름 → process-horizontal 또는 flow-arrow-horizontal
+ not_for: '- 간단한 가로 흐름 → process-horizontal 또는 flow-arrow-horizontal
+
- 높이 예산 부족 → card-numbered
+
- 순서 없는 독립 항목 → card-icon-desc
+
+ '
purpose_fit:
- 핵심전달
- 구조시각화
@@ -554,6 +710,12 @@ blocks:
note: 단계 수
padding_overhead_px: 24
padding_h_px: 0
+ tags:
+ content_pattern: N-steps-vertical-timeline
+ content_example: 설계단계(이미지+설명) → 시공단계(이미지+설명) → 운영단계(이미지+설명)
+ item_count: 2-4
+ has_connector: true
+ layout: vertical-timeline
- id: card-image-round
name: 원형 이미지 카드
category: cards
@@ -564,17 +726,25 @@ blocks:
min_items: 2
max_items: 3
visual: 2~3열. 원형 이미지(140px 원, 테두리+그림자) + 제목 + 설명. 중앙 정렬.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~3개 항목, 각 항목의 대표 이미지가 원형(인물/아바타/로고)
+
- 이미지 아래 짧은 제목(1줄) + 설명(1~2줄)
+
- 중앙 정렬, 프로필/비전/가치 표현
+
예: [CEO 사진(원형)/이름/역할] | [CTO 사진(원형)/이름/역할]
+
+ '
when: 원형 이미지(인물, 아바타, 로고)가 핵심이고 짧은 제목+설명을 아래 배치할 때.
- not_for: |
- - 사각형 이미지 → card-image-3col
+ not_for: '- 사각형 이미지 → card-image-3col
+
- 이미지 없이 아이콘만 → card-icon-desc
+
- 배경 이미지+오버레이 → card-dark-overlay
+
+ '
purpose_fit: []
slots:
required:
@@ -598,6 +768,12 @@ blocks:
note: 카드 수
padding_overhead_px: 12
padding_h_px: 0
+ tags:
+ content_pattern: N-items-circular-image-title
+ content_example: CEO(원형사진/이름/역할) | CTO(원형사진/이름/역할)
+ item_count: 2-3
+ has_image: true
+ layout: N-col-centered
- id: card-stat-number
name: 통계 숫자 카드
category: cards
@@ -608,18 +784,27 @@ blocks:
min_items: 2
max_items: 4
visual: 2~4열. 매우 큰 숫자 + 단위 + 라벨 + 설명. 숫자 중심 시각화.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 콘텐츠의 핵심이 숫자(KPI, 성과, 통계, 비율)
+
- 2~4개 수치 항목, 각 항목 = 숫자 + 단위(선택) + 라벨(1줄) + 설명(선택)
+
- 숫자 자체가 메시지 (30%, 220명, 5배 등)
+
- 텍스트 설명이 주가 아니라 숫자가 주
+
예: [30%/절감/비용] | [220명/운영/IT+CIVIL] | [5배/생산성/향상]
+
+ '
when: 숫자가 핵심 메시지인 데이터. KPI, 달성률, 비용 절감, 인원 규모 등.
- not_for: |
- - 숫자가 아닌 텍스트 항목 → card-icon-desc
+ not_for: '- 숫자가 아닌 텍스트 항목 → card-icon-desc
+
- 순서 번호(1,2,3) → card-numbered (순서 번호≠통계 숫자)
+
- 비교 구조 → compare-vs-rows
+
+ '
purpose_fit:
- 핵심전달
- 근거사례
@@ -657,6 +842,12 @@ blocks:
note: 통계 항목 수
padding_overhead_px: 40
padding_h_px: 24
+ tags:
+ content_pattern: N-items-large-number-label
+ content_example: 30%(비용절감) | 220명(IT+CIVIL운영) | 5배(생산성향상)
+ item_count: 2-4
+ has_number: true
+ layout: N-col-grid
- id: card-numbered
name: 번호 항목 카드
category: cards
@@ -675,16 +866,22 @@ blocks:
template: blocks/cards/card-numbered--horizontal.html
when: 같은 구조의 항목 2-3개를 나란히 비교할 때
visual: 세로 나열. 색상 원형 번호(1,2,3) + 제목 + 설명.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 1~5개 항목이 번호 순서로 나열됨
+
- 각 항목에 제목(1줄) + 설명(1~3줄)
+
- 번호가 의미 있음 (순서, 우선순위, 단계)
+
- 세로로 쌓이는 리스트 (가로 그리드 아님)
+
- 이미지/아이콘 없이 번호가 구분자
+
예: 1.건설산업DX → 설명 | 2.BIM기술 → 설명 | 3.수행체계 → 설명
- when: 번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업
- 2.BIM 3.DX). 조건/요구사항 나열.
+
+ '
+ when: 번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업 2.BIM 3.DX). 조건/요구사항 나열.
not_for: 순서 없는 독립 항목 → card-icon-desc. 이미지 포함 단계 → card-step-vertical. 가로 흐름 → process-horizontal.
purpose_fit:
- 용어정의
@@ -714,6 +911,12 @@ blocks:
note: 항목 수
padding_overhead_px: 22
padding_h_px: 32
+ tags:
+ content_pattern: N-items-numbered-title-description
+ content_example: 1.건설산업DX(설명) | 2.BIM기술(설명) | 3.수행체계(설명)
+ item_count: 1-5
+ has_number: true
+ layout: vertical-list
- id: cycle-orbit
name: 순환 궤도 다이어그램
category: visuals
@@ -725,21 +928,30 @@ blocks:
- definition
min_items: 3
max_items: 5
- visual: >
- 타원 궤도(SVG ellipse) 위에 N개 노드(아이콘 원+라벨)가 순환 배치.
- 궤도 위 화살표로 순환 방향 표시. 각 노드에 설명 제목+불릿.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ visual: '타원 궤도(SVG ellipse) 위에 N개 노드(아이콘 원+라벨)가 순환 배치. 궤도 위 화살표로 순환 방향 표시. 각 노드에 설명 제목+불릿.
+
+ '
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 3~5개 요소가 순환 관계 (A→B→C→A)
+
- 각 요소에 아이콘 + 라벨 + 선택적 설명/불릿
+
- 방향성이 있는 순환 (화살표)
+
- 단방향이 아닌 순환/피드백 루프
+
예: 설계→시공→운영→피드백→설계 (순환)
+
+ '
when: 요소들이 순환 관계(피드백 루프, 생태계)일 때. 3~5개 노드가 순환.
- not_for: |
- - 단방향 흐름 → process-horizontal
+ not_for: '- 단방향 흐름 → process-horizontal
+
- 겹침/교집합 → venn-diagram
+
- 3원 교차 → cycle-3way-intersect
+
+ '
purpose_fit:
- 구조시각화
zone: full-width-only
@@ -757,27 +969,33 @@ blocks:
font_size: 18
ref_chars:
body: 20
- note: "18px black/900, 중앙, 하단 밑줄"
+ note: 18px black/900, 중앙, 하단 밑줄
node_label:
max_lines: 2
font_size: 14
ref_chars:
body: 6
- note: "14px black/900, 아이콘 아래"
+ note: 14px black/900, 아이콘 아래
desc_title:
max_lines: 1
font_size: 13
ref_chars:
body: 20
- note: "13px bold"
+ note: 13px bold
bullet:
max_lines: 2
font_size: 11
ref_chars:
body: 15
- note: "11px medium gray"
+ note: 11px medium gray
padding_overhead_px: 30
padding_h_px: 0
+ tags:
+ content_pattern: N-nodes-circular-orbit
+ content_example: 설계→시공→운영→피드백→설계 순환관계
+ item_count: 3-5
+ has_connector: true
+ layout: svg-ellipse-orbit
- id: checklist-dark
name: 체크리스트 다크
category: emphasis
@@ -789,19 +1007,29 @@ blocks:
min_items: 2
max_items: 8
visual: 다크 배경 + 체크 아이콘 + 제목:설명 한 줄 구조. N행 반복.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~8개 항목, 각 항목이 "제목: 설명" 구조 (콜론으로 분리)
+
- 체크리스트/원칙/요건 형태
+
- 어두운 배경에 체크 아이콘
+
- 각 항목의 제목이 짧고(15자), 설명이 1~2줄
+
- dark-bullet-list와 차이: 이 블록은 제목:설명 분리, dark-bullet-list는 불릿만
+
예: ☑ 기반지식: 건설산업 깊은 이해 | ☑ SW기술: 디지털 역량 확보 | ...
+
+ '
when: 핵심 원칙/요건을 "제목:설명" 체크리스트로 나열할 때.
- not_for: |
- - 제목:설명 분리 없이 불릿만 → dark-bullet-list
+ not_for: '- 제목:설명 분리 없이 불릿만 → dark-bullet-list
+
- 단일 항목 강조 → callout-solution
+
- 밝은 배경 → card-numbered
+
+ '
purpose_fit:
- 핵심전달
slots:
@@ -814,15 +1042,21 @@ blocks:
font_size: 16
ref_chars:
body: 15
- note: "16px bold white"
+ note: 16px bold white
item_description:
max_lines: 2
font_size: 16
ref_chars:
body: 50
- note: "16px medium, rgba(255,255,255,0.8)"
+ note: 16px medium, rgba(255,255,255,0.8)
padding_overhead_px: 40
padding_h_px: 48
+ tags:
+ content_pattern: N-items-title-colon-description-dark
+ content_example: '기반지식: 건설산업 깊은이해 | SW기술: 디지털역량확보 | 투자의지: 대규모투자'
+ item_count: 2-8
+ has_check_icon: true
+ layout: dark-vertical-list
- id: system-2col-center
name: 중앙 라벨 2열 시스템 구성
category: cards
@@ -835,18 +1069,27 @@ blocks:
min_items: 2
max_items: 14
visual: 좌 항목 리스트 + 중앙 원형 라벨 + 우 항목 리스트. 3열 Grid.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 하나의 시스템/플랫폼을 중심으로 좌/우에 구성요소 나열
+
- 중앙에 시스템 이름(원형 라벨), 좌=한쪽 카테고리, 우=다른 카테고리
+
- 좌/우 각각 2~7개 항목, 각 항목에 아이콘+제목+설명
+
- 좌/우가 대비 관계(H/W vs S/W, 입력 vs 출력)
+
예: 좌=H/W[서버,워크스테이션,모니터] | 중앙=EG-BIM | 우=S/W[Modeler,GIS,Simulation]
+
+ '
when: 시스템 구성을 중앙 라벨 기준 좌/우로 나열할 때. 좌/우 카테고리가 다르고 중앙 시스템이 연결.
- not_for: |
- - 단순 좌/우 텍스트 비교 → comparison-2col
+ not_for: '- 단순 좌/우 텍스트 비교 → comparison-2col
+
- 행별 대조 → compare-2col-split
+
- 카테고리 리스트 + 번호 이슈 → split-panel-numbered
+
+ '
purpose_fit:
- 구조시각화
- 핵심전달
@@ -869,27 +1112,33 @@ blocks:
font_size: 18
ref_chars:
body: 8
- note: "18px black/900, 원형 안"
+ note: 18px black/900, 원형 안
tab_label:
max_lines: 1
font_size: 20
ref_chars:
body: 8
- note: "20px bold white, 색상 탭"
+ note: 20px bold white, 색상 탭
item_title:
max_lines: 1
font_size: 16
ref_chars:
body: 20
- note: "16px bold"
+ note: 16px bold
item_body:
max_lines: 3
font_size: 14
ref_chars:
body: 80
- note: "14px medium gray"
+ note: 14px medium gray
padding_overhead_px: 44
padding_h_px: 24
+ tags:
+ content_pattern: left-items-center-label-right-items
+ content_example: 좌=HW[서버,워크스테이션] | 중앙=EG-BIM | 우=SW[Modeler,GIS,Simulation]
+ item_count: 2-14
+ has_center_circle: true
+ layout: grid-3col-center
- id: category-strip-table
name: 카테고리 컬러 스트립 테이블
category: cards
@@ -902,18 +1151,27 @@ blocks:
min_items: 2
max_items: 5
visual: N열 다크 배경 테이블. 좌측 세로 색상 바(카테고리 라벨) + 우측 제목+본문 M행. 구분선.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~5개 카테고리, 각 카테고리에 2~4개 하위 항목(제목+본문)
+
- 각 카테고리를 세로 색상 바(2~4글자 세로 라벨)로 구분
+
- 어두운 배경, 표 형식 (줄 단위 구분)
+
- 카테고리 라벨이 세로쓰기로 표시됨
+
예: 기술[DB구축/SW개발/자동화] | 사람[교육/역량/조직] | 자연[환경/지형]
+
+ '
when: 카테고리별 하위 항목을 세로 색상 바로 구분하며 나열할 때. 다크 배경 표 구조.
- not_for: |
- - 단순 데이터 테이블 → table-simple-striped
+ not_for: '- 단순 데이터 테이블 → table-simple-striped
+
- 밝은 배경 아이콘 카드 → card-icon-desc
+
- 수평 색상 분류 바 → highlight-strip
+
+ '
purpose_fit:
- 핵심전달
- 구조시각화
@@ -931,21 +1189,27 @@ blocks:
font_size: 20
ref_chars:
body: 4
- note: "20px bold white, 세로쓰기, 색상 바 위"
+ note: 20px bold white, 세로쓰기, 색상 바 위
row_title:
max_lines: 2
font_size: 18
ref_chars:
body: 20
- note: "18px bold white"
+ note: 18px bold white
row_body:
max_lines: 3
font_size: 14
ref_chars:
body: 60
- note: "14px medium, rgba(255,255,255,0.7)"
+ note: 14px medium, rgba(255,255,255,0.7)
padding_overhead_px: 24
padding_h_px: 8
+ tags:
+ content_pattern: N-categories-vertical-bar-rows
+ content_example: 기술[DB구축/SW개발/자동화] | 사람[교육/역량/조직] | 자연[환경/지형]
+ item_count: 2-5
+ has_color_bar: true
+ layout: dark-N-col-strip
- id: hero-icon-cards
name: 히어로 문구 + 아이콘 카드
category: cards
@@ -958,20 +1222,31 @@ blocks:
min_items: 2
max_items: 6
visual: 상단 Hero 선언문 + 리본 배지 + 빨간 테두리 흰 박스 안 N열 아이콘 카드.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 상단에 핵심 선언문(1~2줄, 강조 키워드 포함)
+
- 하단에 2~6개 키워드를 나란히 나열 (아이콘+제목+부제)
+
- 리본 배지로 카드 영역의 주제를 명시 (예: "Solution 제작 목표")
+
- 각 카드는 짧은 키워드(제목 15자) + 부제(10자)
+
- 프레젠테이션형 시각 임팩트 필요
+
예: 선언문="~목표를 달성한다" → 배지="Solution" → [품질/안전/생산성/소통/신뢰]
+
+ '
when: 핵심 선언문 아래 N개 키워드를 배지+테두리 박스로 강렬하게 나열할 때.
- not_for: |
- - 선언문 없이 카드만 → card-icon-desc
+ not_for: '- 선언문 없이 카드만 → card-icon-desc
+
- 상세 설명(3줄+) → card-icon-desc
+
- 비교 구조 → compare-2col-badge
+
- 순서/단계 → process-horizontal
+
+ '
purpose_fit:
- 핵심전달
zone: full-width-only
@@ -990,27 +1265,34 @@ blocks:
font_size: 28
ref_chars:
body: 60
- note: "28px bold white, 중앙, em=빨간 강조"
+ note: 28px bold white, 중앙, em=빨간 강조
badge_title:
max_lines: 1
font_size: 20
ref_chars:
body: 15
- note: "20px bold white, 3D 리본 위"
+ note: 20px bold white, 3D 리본 위
card_title:
max_lines: 2
font_size: 20
ref_chars:
body: 15
- note: "20px black/900, 중앙정렬"
+ note: 20px black/900, 중앙정렬
card_subtitle:
max_lines: 1
font_size: 15
ref_chars:
body: 10
- note: "15px medium, 한국어 부제"
+ note: 15px medium, 한국어 부제
padding_overhead_px: 80
padding_h_px: 32
+ tags:
+ content_pattern: statement-plus-N-keyword-cards-with-badge
+ content_example: 선언문=목표를달성한다 → 배지=Solution → [품질,안전,생산성,소통,신뢰]
+ item_count: 2-6
+ has_badge: true
+ has_icon: true
+ layout: hero-ribbon-box-cards
- id: compare-detail-gradient
name: 그라디언트 상세 2열 비교
category: cards
@@ -1024,19 +1306,29 @@ blocks:
min_items: 2
max_items: 10
visual: 좌/우 gradient 배경 2열. 비대칭 라운드 헤더. N개 섹션(소제목+불릿) 행 정렬.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 좌/우 두 카테고리에 각각 3~5개 하위 섹션
+
- 각 섹션 = 소제목 + 불릿 리스트
+
- 좌/우 섹션이 행 단위로 대응 (같은 높이에 정렬)
+
- 선택적으로 As-Is → To-Be 수평 비교 포함
+
- "과정의 혁신 vs 결과의 혁신" 같은 깊은 대비
+
예: 좌"과정의 혁신"[프로세스/수행방식/도구] vs 우"결과의 혁신"[성과물/활용/관리]
+
+ '
when: 두 카테고리를 섹션 단위로 상세 비교할 때. 각 카테고리에 3+개 하위 섹션. As-Is/To-Be 가능.
- not_for: |
- - 간단 비교(본문 짧음) → compare-2col-badge 또는 comparison-2col
+ not_for: '- 간단 비교(본문 짧음) → compare-2col-badge 또는 comparison-2col
+
- 행별 카테고리 표 → compare-vs-rows
+
- 표 형식 → compare-2col-split
+
+ '
purpose_fit:
- 비교대조
- 구조시각화
@@ -1057,27 +1349,33 @@ blocks:
font_size: 26
ref_chars:
body: 20
- note: "26px black/900, 비대칭 라운드 바"
+ note: 26px black/900, 비대칭 라운드 바
right_header:
max_lines: 1
font_size: 26
ref_chars:
body: 20
- note: "26px black/900, 비대칭 라운드 바"
+ note: 26px black/900, 비대칭 라운드 바
section_title:
max_lines: 2
font_size: 18
ref_chars:
body: 30
- note: "18px/900, 좌=브라운 우=틸"
+ note: 18px/900, 좌=브라운 우=틸
section_body:
max_lines: 4
font_size: 14
ref_chars:
body: 120
- note: "14px/700, 불릿 구조"
+ note: 14px/700, 불릿 구조
padding_overhead_px: 52
padding_h_px: 0
+ tags:
+ content_pattern: 2-section-comparison-table-and-bullets
+ content_example: 좌=과정혁신[프로세스변화/도구전환/솔루션제공] vs 우=결과변화[품질향상/정보물추가/업무효율화]
+ item_count: 2-10
+ layout: gradient-2col-sections
+ source_mdx: '03'
- id: compare-2col-badge
name: 배지 헤더 2열 비교
category: cards
@@ -1088,18 +1386,27 @@ blocks:
- comparison
- contrast
visual: 상단 3D 리본 배지(gradient bar, 상위 주제 선언) + 아래 빨간 테두리 박스 안에 2열 비교. 좌/우 각각 대제목(22px)+본문. 중앙 구분선. 하단에 선택적 결론 문구.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- "상위 주제(배지)" + "A 정의/설명" vs "B 정의/설명" 구조
+
- 상위 주제를 배지 바로 먼저 선언하고, 아래에서 좌/우 비교
+
- 좌/우 각각 제목(1줄) + 본문(3~6줄)
+
- 하단에 결론 한 줄 추가 가능
+
예: 배지="정책 달성" → 좌"Engn. Solution: ~설명" vs 우"DfMA: ~설명"
+
+ '
when: 상위 주제를 배지로 선언한 뒤 두 개념/전략의 정의를 본문으로 비교할 때. 각 측이 제목+본문 구조.
- not_for: |
- - 상위 주제 배지 불필요, 단순 좌/우 대비 → comparison-2col
+ not_for: '- 상위 주제 배지 불필요, 단순 좌/우 대비 → comparison-2col
+
- 카테고리별 N행 비교 → compare-vs-rows
+
- 행별 기준 라벨 표 → compare-2col-split
+
+ '
purpose_fit:
- 비교대조
- 개념정의
@@ -1154,6 +1461,12 @@ blocks:
note: 18px bold, 중앙정렬
padding_overhead_px: 56
padding_h_px: 32
+ tags:
+ content_pattern: badge-header-2col-definition-comparison
+ content_example: 배지=정책달성 → 좌=Engn.Solution(정의+설명) vs 우=DfMA(정의+설명)
+ item_count: 2
+ has_badge: true
+ layout: badge-2col-divider
- id: compare-3col-badge
name: VS 배지 비교표
category: tables
@@ -1163,19 +1476,29 @@ blocks:
relation_types:
- comparison
visual: 3열 표. 좌 | 중앙 VS 배지 | 우. 행별 좌/우 값 대조.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- A와 B를 5~7행으로 대조하는 표 구조
+
- 중앙에 VS 배지 (비교 기준 라벨 없이 좌/우 값만 대조)
+
- 비교 기준 라벨이 필요하면 compare-2col-split
+
- 카테고리 pill이 필요하면 compare-vs-rows
+
예: A=BIM | VS | B=DX → [S/W항목, 프로세스항목, ...] 행별 대조
+
+ '
when: 두 개념을 행별로 값 대조할 때. 비교 기준 라벨 없이 좌/우만. 5+행.
- not_for: |
- - 비교 기준 라벨 필요 → compare-2col-split
+ not_for: '- 비교 기준 라벨 필요 → compare-2col-split
+
- 카테고리 pill 비교 → compare-vs-rows
+
- 간단 2~3항목 → comparison-2col
+
- 범용 데이터 → table-simple-striped
+
+ '
purpose_fit:
- 핵심전달
slots:
@@ -1197,6 +1520,12 @@ blocks:
note: 헤더 제외 행 수
padding_overhead_px: 28
padding_h_px: 0
+ tags:
+ content_pattern: AB-comparison-table-with-VS-badge
+ content_example: A=BIM | VS | B=DX → [SW항목,프로세스항목,...] 행별대조
+ item_count: 5-7
+ has_badge: true
+ layout: 3col-table
- id: compare-2col-split
name: 2단 분할 비교표
category: tables
@@ -1206,20 +1535,31 @@ blocks:
relation_types:
- comparison
visual: 표 형식. 파란 헤더(좌/구분/우) + 행별 좌 | 중앙 기준 라벨 | 우. 정형 비교표.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- A와 B를 비교하는 정형 표 데이터가 이미 있음
+
- 각 행에 비교 기준(정의/범위/역할 등)이 명시됨 (중앙 열)
+
- 5~10행의 항목별 상세 대조
+
- 비교 기준 라벨이 중앙에 있는 3열 구조
+
- compare-vs-rows와 차이: 이 블록은 기준 라벨이 중앙 "열"로 고정, compare-vs-rows는 gradient pill
+
예: [정의: A=~ | B=~] [범위: A=~ | B=~] [역할: A=~ | B=~] ...
+
+ '
when: 비교 기준이 명확한 정형 표 데이터로 두 개념을 항목별 대조할 때.
- not_for: |
- - 카테고리 pill 비교 → compare-vs-rows
+ not_for: '- 카테고리 pill 비교 → compare-vs-rows
+
- 간단 2~3항목 비교 → comparison-2col
+
- 범용 데이터 표 → table-simple-striped
+
- 3개 비교 → compare-3col-badge
+
+ '
purpose_fit:
- 핵심전달
zone: full-width-only
@@ -1241,6 +1581,12 @@ blocks:
note: 행 수
padding_overhead_px: 24
padding_h_px: 0
+ tags:
+ content_pattern: criteria-based-2col-comparison-table
+ content_example: '[정의:A=~/B=~] [범위:A=~/B=~] [역할:A=~/B=~] 기준라벨중앙열'
+ item_count: 5-10
+ has_criteria_label: true
+ layout: 3col-table-criteria-center
- id: table-simple-striped
name: 범용 줄무늬 테이블
category: tables
@@ -1249,17 +1595,25 @@ blocks:
min_height_px: 100
relation_types: []
visual: 남색 헤더 + 줄무늬 행 교차. 범용 데이터 표.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 비교가 아닌 일반 데이터 표 (스펙, 일정, 목록)
+
- N열 × M행 정형 데이터
+
- 헤더 행 + 데이터 행으로 구성
+
- A vs B 비교가 아님 (비교면 compare 계열)
+
예: [구분/현재/목표/비고] → N행 데이터
+
+ '
when: 비교 목적이 아닌 일반 데이터 표. 스펙표, 일정표, 항목 목록.
- not_for: |
- - A vs B 비교 → compare-3col-badge 또는 compare-2col-split
+ not_for: '- A vs B 비교 → compare-3col-badge 또는 compare-2col-split
+
- 카테고리 비교 → compare-vs-rows
+
+ '
purpose_fit:
- 핵심전달
- 근거사례
@@ -1289,6 +1643,11 @@ blocks:
note: 행 수
padding_overhead_px: 19
padding_h_px: 0
+ tags:
+ content_pattern: generic-data-table
+ content_example: '[구분/현재/목표/비고] → N행데이터. 비교가아닌 일반표'
+ item_count: 3-8
+ layout: striped-table
- id: venn-diagram
name: SVG 벤 다이어그램
category: visuals
@@ -1301,19 +1660,29 @@ blocks:
min_items: 2
max_items: 5
visual: SVG 벤 다이어그램. 큰 원(중심) + N개 작은 원. gradient+glow.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 상위 개념 안에 2~5개 하위 개념이 포함되는 관계
+
- 중심에 상위 개념(큰 원) + 주변에 하위 개념(작은 원)
+
- 포함/융합 관계 시각화 (A ⊃ {B, C, D})
+
- cycle-3way-intersect와 차이: venn은 포함 관계, cycle은 교차/융합 관계
+
예: DX(중심) ⊃ [GIS, BIM, 디지털트윈, IoT]
+
+ '
when: 상위-하위 포함 관계를 벤 다이어그램으로 시각화. 2~5개 하위 개념. 단독 배치 필수.
- not_for: |
- - 3개 교차/융합 관계 → cycle-3way-intersect
+ not_for: '- 3개 교차/융합 관계 → cycle-3way-intersect
+
- 순차 흐름 → process-horizontal
+
- 대등 비교 → compare-pill-pair
+
- 텍스트로 충분히 설명 가능하면 사용 금지
+
+ '
purpose_fit:
- 핵심전달
- 구조시각화
@@ -1355,6 +1724,11 @@ blocks:
padding_overhead_px: 22
padding_h_px: 0
min_display_width_px: 200
+ tags:
+ content_pattern: inclusion-hierarchy-N-circles
+ content_example: DX(중심) ⊃ [GIS, BIM, 디지털트윈, IoT]. 포함/융합관계
+ item_count: 2-5
+ layout: svg-venn
- id: circle-gradient
name: 원형 라벨
category: visuals
@@ -1363,17 +1737,25 @@ blocks:
min_height_px: 50
relation_types: []
visual: gradient 원(190px) + 이중 테두리 + 중앙 흰색 텍스트. 단일 키워드.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 단일 키워드(6자 이내)를 원형 아이콘처럼 강조
+
- 섹션 전환점의 주제 선언 (아래에 본문 블록이 따라옴)
+
- 선택적 보조 라벨
+
- keyword-circle-row와 차이: 이 블록은 단일 원, row는 N개 나열
+
+ '
when: 단일 키워드를 원형으로 강조. 섹션 주제 선언 아이콘.
- not_for: |
- - N개 키워드 나열 → keyword-circle-row
+ not_for: '- N개 키워드 나열 → keyword-circle-row
+
- 텍스트 제목 → topic-center
+
- 결론 한 줄 → banner-gradient
+
+ '
purpose_fit: []
slots:
required:
@@ -1398,6 +1780,11 @@ blocks:
padding_overhead_px: 16
padding_h_px: 0
min_display_width_px: 150
+ tags:
+ content_pattern: single-keyword-circle
+ content_example: 단일키워드(6자이내) 원형아이콘. 섹션주제선언
+ item_count: 1
+ layout: centered-circle
- id: compare-pill-pair
name: 둥근 박스 VS
category: visuals
@@ -1407,18 +1794,27 @@ blocks:
relation_types:
- comparison
visual: 이중 테두리 둥근 박스 2개 나란히 + VS. 짧은 라벨만. 비교 헤더 역할.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2개 개념의 시각적 대비를 짧은 라벨(10자 이내)로만 선언
+
- 세부 비교 항목 없이 "A vs B" 선언만
+
- 비교 테이블 블록 위에 헤더로 배치 (단독 사용도 가능)
+
- 상세 설명 불필요
+
예: "DX 협업 프로세스" VS "BIM 정보 관리" (이 아래에 비교표 블록 배치)
+
+ '
when: 2개 개념을 짧은 라벨로 시각적 대비 선언할 때. 비교표 위 헤더. 세부 항목 없음.
- not_for: |
- - 세부 비교 항목 필요 → compare-3col-badge 또는 compare-vs-rows
+ not_for: '- 세부 비교 항목 필요 → compare-3col-badge 또는 compare-vs-rows
+
- 설명 포함 비교 → comparison-2col
+
- 3개 비교 → card-compare-3col
+
+ '
purpose_fit:
- 핵심전달
zone: full-width-only
@@ -1445,6 +1841,11 @@ blocks:
padding_overhead_px: 40
padding_h_px: 40
min_display_width_px: 200
+ tags:
+ content_pattern: 2-labels-VS-header
+ content_example: DX협업프로세스 VS BIM정보관리. 비교표위 헤더용
+ item_count: 2
+ layout: flex-row-pills-VS
- id: process-horizontal
name: 가로 단계 흐름
category: visuals
@@ -1456,19 +1857,29 @@ blocks:
min_items: 2
max_items: 5
visual: 가로 흐름도. 원형 번호 + 제목 + 설명 카드 + 화살표 연결.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~5개 단계가 가로 순서로 진행
+
- 각 단계에 제목(10자) + 설명(1~2줄)이 필요
+
- A→B→C 순서 흐름 (순서 없으면 card-icon-desc)
+
- flow-arrow-horizontal과 차이: 이 블록은 설명 포함, flow-arrow는 키워드(8자)만
+
예: 1.현황분석(설명) → 2.전략수립(설명) → 3.실행(설명) → 4.검증(설명)
+
+ '
when: 각 단계에 제목+설명이 필요한 가로 프로세스 흐름. 2~5단계.
- not_for: |
- - 키워드만(설명 불필요) → flow-arrow-horizontal
+ not_for: '- 키워드만(설명 불필요) → flow-arrow-horizontal
+
- 세로 타임라인 → card-step-vertical
+
- 순서 없는 나열 → card-icon-desc
+
- 번호 목록 → card-numbered
+
+ '
purpose_fit:
- 핵심전달
- 구조시각화
@@ -1504,6 +1915,13 @@ blocks:
padding_overhead_px: 0
padding_h_px: 0
min_display_width_px: 250
+ tags:
+ content_pattern: N-steps-horizontal-flow-with-description
+ content_example: 1.현황분석(설명) → 2.전략수립(설명) → 3.실행(설명) → 4.검증(설명)
+ item_count: 2-5
+ has_number: true
+ has_connector: true
+ layout: horizontal-flow-cards
- id: flow-arrow-horizontal
name: 가로 흐름 화살표
category: visuals
@@ -1515,19 +1933,29 @@ blocks:
min_items: 2
max_items: 6
visual: SVG 캡슐이 가로 나열 + 사이 화살표. 라벨만. 컴팩트 흐름도.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- A→B→C 순서/흐름이 핵심 (2~6단계)
+
- 각 단계가 짧은 키워드(8자 이내)만
+
- 단계별 설명 불필요 (설명 필요하면 process-horizontal)
+
- 컴팩트하게 흐름만 표현 (높이 ~50px)
+
예: GIS → SPCC → 토공 → BIM (기술 발전 순서)
+
+ '
when: 짧은 키워드(8자 이내)로 순서/흐름만 간결하게 보여줄 때. 높이 예산 적을 때.
- not_for: |
- - 단계별 설명 필요 → process-horizontal
+ not_for: '- 단계별 설명 필요 → process-horizontal
+
- 라벨 8자 초과 → process-horizontal
+
- 순서 없는 나열 → dark-bullet-list 또는 card-icon-desc
+
- 번호 순서 나열 → card-numbered
+
+ '
purpose_fit:
- 구조시각화
zone: full-width-only
@@ -1548,6 +1976,11 @@ blocks:
padding_overhead_px: 20
padding_h_px: 0
min_display_width_px: 200
+ tags:
+ content_pattern: N-steps-keyword-only-flow
+ content_example: GIS → SPCC → 토공 → BIM. 키워드(8자이내)만
+ item_count: 2-6
+ layout: svg-capsule-arrow
- id: keyword-circle-row
name: 키워드 원형 행
category: visuals
@@ -1559,18 +1992,27 @@ blocks:
min_items: 2
max_items: 5
visual: SVG gradient 원 N개 가로 나열. 각 원에 약어 1~2글자 + 아래 라벨 + 설명.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 약어를 풀이하는 구조 (G=Geographic, S=Structure 등)
+
- 2~5개 약어/핵심 글자를 원형으로 가로 나열
+
- 각 원에 1~2글자 약어 + 아래 라벨(풀이) + 선택적 설명
+
- circle-gradient와 차이: 이 블록은 N개 나열, circle-gradient는 단일 원
+
예: G(Geographic) | S(Structure) | I(Information) | M(Model)
+
+ '
when: 약어 풀이를 원형 아이콘으로 가로 나열할 때. 2~5개 약어.
- not_for: |
- - 아이콘+설명 카드 → card-icon-desc
+ not_for: '- 아이콘+설명 카드 → card-icon-desc
+
- 단일 원 강조 → circle-gradient
+
- 약어가 아닌 일반 텍스트 → 사용 금지
+
+ '
purpose_fit:
- 구조시각화
slots:
@@ -1606,6 +2048,12 @@ blocks:
padding_overhead_px: 20
padding_h_px: 0
min_display_width_px: 200
+ tags:
+ content_pattern: N-abbreviation-circles-with-labels
+ content_example: G(Geographic) | S(Structure) | I(Information) | M(Model)
+ item_count: 2-5
+ has_icon: true
+ layout: flex-row-svg-circles
- id: quote-big-mark
name: 큰따옴표 인용
category: emphasis
@@ -1614,19 +2062,29 @@ blocks:
min_height_px: 80
relation_types: []
visual: 큰따옴표(❝❞) 장식 + 연한 배경 박스 + 인용문 + 출처. 인용 형식.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 타인의 발언/보고서/문서를 인용하는 구조
+
- 인용문(1~3줄) + 출처(선택, "~보고서", "~발언")
+
- 출처가 있으면 이 블록 (출처 없는 질문은 quote-question)
+
- 자기 주장이 아니라 타인/문서의 말을 빌리는 형식
+
예: "건설산업의 디지털 전환은 필수불가결하다" — 국토교통부 보고서
+
+ '
when: 출처가 있는 인용문을 강조 형태로 보여줄 때. 인용문+출처 구조.
- not_for: |
- - 자기 질문(물음표) → quote-question
+ not_for: '- 자기 질문(물음표) → quote-question
+
- 경고/문제점 서술 → callout-warning
+
- 해결책 강조 → callout-solution
+
- 1줄 결론 선언 → statement-pill-highlight
+
+ '
purpose_fit:
- 문제제기
- 근거사례
@@ -1652,6 +2110,12 @@ blocks:
note: caption, 1줄
padding_overhead_px: 48
padding_h_px: 56
+ tags:
+ content_pattern: quotation-with-source
+ content_example: 건설산업의디지털전환은필수불가결하다 — 국토교통부보고서
+ item_count: 1
+ has_source: true
+ layout: quote-box-marks
- id: quote-question
name: 질문형 강조
category: emphasis
@@ -1660,19 +2124,29 @@ blocks:
min_height_px: 80
relation_types: []
visual: 밝은 파란 배경 + 파란 테두리 + 큰 질문 텍스트 + 부연 설명.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 핵심이 물음표(?)로 끝나는 질문 1개
+
- 질문으로 독자의 문제 인식을 유도하는 전환점
+
- 질문(1줄) + 선택적 부연 설명(1~3줄)
+
- 타인 인용이 아님 (인용이면 quote-big-mark)
+
예: "지금의 방식으로도 가능할까?" → 부연 설명 2줄
+
+ '
when: 물음표로 끝나는 핵심 질문으로 문제 인식을 유도할 때. 질문+부연 설명 구조.
- not_for: |
- - 타인 인용+출처 → quote-big-mark
+ not_for: '- 타인 인용+출처 → quote-big-mark
+
- 경고/문제 서술 → callout-warning
+
- 결론 선언 → statement-pill-highlight 또는 banner-gradient
+
- 해결책 제시 → callout-solution
+
+ '
purpose_fit:
- 문제제기
slots:
@@ -1697,6 +2171,11 @@ blocks:
note: 14px, 3줄 이내
padding_overhead_px: 56
padding_h_px: 48
+ tags:
+ content_pattern: question-mark-provocation
+ content_example: 지금의방식으로도가능할까? + 부연설명2줄
+ item_count: 1
+ layout: blue-box-question
- id: comparison-2col
name: 2단 병렬 비교
category: emphasis
@@ -1713,20 +2192,31 @@ blocks:
template: blocks/emphasis/comparison-2col--cards-in-container.html
when: hierarchy/inclusion — A 안에 B,C,D가 포함됨을 보여줄 때. 포함 관계 시각화
visual: 좌우 2단 자유 텍스트. 좌=파란 밑줄 헤더, 우=빨간 밑줄 헤더. 중앙 1px 구분선. 각 측에 서브타이틀(선택)+본문 문단. 표가 아닌 문단형.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- A와 B 두 개념을 각각 2~3문장으로 설명하는 자유 문단형 비교
+
- 행별 카테고리 구분이 없음 (카테고리별 비교는 compare-vs-rows)
+
- 좌/우 텍스트 길이가 비슷하고 각각 독립된 설명
+
- 항목 수 2~3개, 문단형 텍스트
+
예: "BIM(하위기술): ~설명 3줄" vs "DX(상위개념): ~설명 3줄"
+
+ '
when: A와 B를 각각 자유 문단으로 설명하며 대비할 때. 행별 카테고리 구분 없이 좌/우 각각 독립 서술. 장단점, Before/After, 개념 대비.
- not_for: |
- - 카테고리별 N행 비교 → compare-vs-rows (10+행, 중앙 카테고리 pill)
+ not_for: '- 카테고리별 N행 비교 → compare-vs-rows (10+행, 중앙 카테고리 pill)
+
- 행별 기준 라벨이 있는 표 → compare-2col-split
+
- 배지 헤더로 상위 주제 선언 필요 → compare-2col-badge
+
- 3개 이상 비교 → card-compare-3col
+
- 좌/우 이슈 쌍 + 라벨 pill → issues-paired-rows
+
+ '
purpose_fit:
- 핵심전달
slots:
@@ -1777,6 +2267,11 @@ blocks:
note: var(--font-body)
padding_overhead_px: 0
padding_h_px: 0
+ tags:
+ content_pattern: 2-freetext-paragraphs-side-by-side
+ content_example: 좌=BIM(하위기술설명3줄) vs 우=DX(상위개념설명3줄). 자유문단형
+ item_count: 2
+ layout: grid-left-divider-right
- id: banner-gradient
name: 그라데이션 배너
category: emphasis
@@ -1785,19 +2280,29 @@ blocks:
min_height_px: 40
relation_types: []
visual: 전체 너비 파란 gradient 배경 + 중앙 흰색 텍스트 + 선택적 서브텍스트. 컴팩트 배너.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 결론/핵심 메시지 1줄 (38자 이내) + 선택적 부연 1줄
+
- 파란 배경 배너 (statement-pill-highlight는 어두운 gradient 캡슐)
+
- 섹션 마무리 또는 footer 배치에 적합 (compact, 50~60px)
+
- 키워드 하이라이트 불필요 (하이라이트 필요하면 statement-pill-highlight)
+
예: "BIM은 DX의 기초가 되는 일부분이다. DX ≠ BIM"
+
+ '
when: 결론 1줄을 파란 배너로 선언할 때. 키워드 하이라이트 불필요. 컴팩트 footer 용도.
- not_for: |
- - 키워드 하이라이트 필요 → statement-pill-highlight (
노란색)
+ not_for: '- 키워드 하이라이트 필요 → statement-pill-highlight ( 노란색)
+
- 인용+출처 → quote-big-mark
+
- 설명 3줄+ → callout-solution
+
- A vs B 비교 → comparison-2col
+
+ '
purpose_fit:
- 결론강조
slots:
@@ -1822,6 +2327,11 @@ blocks:
note: 12px, 1줄
padding_overhead_px: 32
padding_h_px: 60
+ tags:
+ content_pattern: conclusion-one-liner-blue-banner
+ content_example: BIM은DX의기초가되는일부분이다.DX!=BIM. 결론1줄파란배너
+ item_count: 1
+ layout: full-width-gradient-bar
- id: dark-bullet-list
name: 다크 배경 불릿
category: emphasis
@@ -1840,19 +2350,29 @@ blocks:
template: blocks/emphasis/dark-bullet-list--before-after.html
when: 기존 방식 → 새 방식으로의 전환/변화를 보여줄 때. 각 항목이 before/after 쌍일 때
visual: 짙은 남색 배경 + 파란 제목 + 흰 텍스트 불릿. 어두운 톤 강조.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 3~5개 독립 포인트/사례/증거를 나열
+
- 각 항목이 1줄 문장 (순서 없음)
+
- 어두운 배경으로 시각적 무게감/강조 필요
+
- 제목(선택) + 불릿 리스트만 (설명/이미지 불필요)
+
예: "정책 시행 근거" → [근거1, 근거2, 근거3, 근거4]
+
+ '
when: 순서 없는 독립 포인트를 어두운 배경에 강조하며 나열할 때. 근거, 사례, 문제점 목록.
- not_for: |
- - 밝은 배경 카드형 → card-icon-desc
+ not_for: '- 밝은 배경 카드형 → card-icon-desc
+
- 순서 있는 번호 나열 → card-numbered
+
- 개념별 설명 필요 → card-icon-desc (설명 3줄)
+
- 좌측 총괄 라벨+적층 → stacked-arrow-list
+
+ '
purpose_fit:
- 근거사례
- 문제제기
@@ -1883,6 +2403,11 @@ blocks:
note: 불릿 수
padding_overhead_px: 32
padding_h_px: 48
+ tags:
+ content_pattern: N-independent-points-dark-background
+ content_example: 정책시행근거 → [근거1,근거2,근거3,근거4]. 순서없는포인트
+ item_count: 2-5
+ layout: dark-box-bullets
- id: highlight-strip
name: 강조 분류 스트립
category: emphasis
@@ -1891,18 +2416,27 @@ blocks:
min_height_px: 35
relation_types: []
visual: 가로 1줄에 N개 색상 구간. 각 구간에 흰색 라벨. 카테고리 색상 분류 바.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~4개 카테고리를 색상으로 구분하여 한 줄에 표시
+
- 각 카테고리는 짧은 라벨(15자 이내)만
+
- 설명 없이 분류 자체를 보여주는 것이 목적
+
- 카테고리 바 아래에 다른 블록이 올 수 있음 (분류 헤더 역할)
+
예: [상용S/W(회색)] | [3rd Party(파랑)] | [전문S/W(빨강)]
+
+ '
when: 카테고리 색상 분류를 한 줄 바로 표시할 때. 라벨만, 설명 없음.
- not_for: |
- - 탭 전환 UI → tab-label-row
+ not_for: '- 탭 전환 UI → tab-label-row
+
- 메시지 강조 → banner-gradient
+
- 불릿 리스트 → dark-bullet-list
+
+ '
purpose_fit:
- 구조시각화
slots:
@@ -1923,6 +2457,11 @@ blocks:
note: 세그먼트 수
padding_overhead_px: 20
padding_h_px: 32
+ tags:
+ content_pattern: N-category-color-segments-one-line
+ content_example: 상용SW(회색) | 3rdParty(파랑) | 전문SW(빨강). 색상분류바
+ item_count: 2-4
+ layout: flex-row-color-segments
- id: callout-solution
name: 솔루션 콜아웃
category: emphasis
@@ -1932,19 +2471,17 @@ blocks:
relation_types:
- cause_effect
visual: 밝은 파란 배경 + 파란 테두리 + 아이콘 + 파란 제목 + 설명 + 출처. 긍정적 톤.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 해결책/방향성/솔루션을 강조하는 콜아웃 박스
+
- 제목(1줄) + 설명(2~4줄) + 선택적 출처
+
- 긍정적/해결책 톤 (파란색 계열)
+
- 경고/문제점이 아님 (경고면 callout-warning)
+
예: "핵심 해결 방향" → 설명 3줄 → 출처
- when: 해결책/솔루션/긍정적 방향을 콜아웃 박스로 강조할 때. 제목+설명+출처 구조.
- not_for: |
- - 경고/문제 톤(빨간) → callout-warning
- - 인용문+출처 → quote-big-mark
- - 질문형 → quote-question
- - 1줄 결론 → statement-pill-highlight
'
when: '핵심 해결책, 솔루션, 방향성을 강조. 예: "💡 Solution 제시 포인트".'
@@ -1975,6 +2512,11 @@ blocks:
note: 14px, 3~4줄
padding_overhead_px: 40
padding_h_px: 48
+ tags:
+ content_pattern: solution-callout-title-description
+ content_example: 핵심해결방향 → 설명3줄 → 출처. 긍정적/해결책톤
+ item_count: 1
+ layout: blue-callout-box
- id: callout-warning
name: 경고 콜아웃
category: emphasis
@@ -1984,19 +2526,29 @@ blocks:
relation_types:
- cause_effect
visual: 연한 빨간 배경 + 빨간 테두리 + 아이콘 + 빨간 제목 + 설명. 경고/문제점 톤.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 문제점/경고/주의사항을 강조하는 콜아웃 박스
+
- 제목(1줄) + 설명(2~4줄)
+
- 부정적/경고 톤 (잘못된 인식, 위험 요소, 현재 한계)
+
- 해결책이 아님 (해결책이면 callout-solution)
+
예: "현재 접근 방식의 한계" → 문제점 설명 3줄
+
+ '
when: 문제점/경고/위험을 콜아웃 박스로 강조할 때. 부정적 톤 제목+설명.
- not_for: |
- - 해결책/긍정 톤 → callout-solution
+ not_for: '- 해결책/긍정 톤 → callout-solution
+
- 인용문 → quote-big-mark
+
- 질문형 → quote-question
+
- 1줄 결론 → statement-pill-highlight
+
+ '
purpose_fit:
- 문제제기
slots:
@@ -2022,6 +2574,11 @@ blocks:
note: 14px 진한 빨간
padding_overhead_px: 40
padding_h_px: 48
+ tags:
+ content_pattern: warning-callout-title-description
+ content_example: 현재접근방식의한계 → 문제점설명3줄. 경고/부정적톤
+ item_count: 1
+ layout: red-callout-box
- id: tab-label-row
name: 탭 라벨 행
category: emphasis
@@ -2030,17 +2587,25 @@ blocks:
min_height_px: 35
relation_types: []
visual: 가로 탭 버튼 행. 선택됨=색상+흰 텍스트, 나머지=회색.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 2~5개 카테고리 중 하나가 현재 선택됨(active)
+
- 탭 UI 형태 (클릭 전환은 미지원, 시각적 표시만)
+
- 각 탭 라벨 10자 이내
+
- highlight-strip과 차이: 이 블록은 active/inactive 구분, strip은 모두 동등
+
예: 제조 | 건축 | [인프라/토목](선택됨)
+
+ '
when: 카테고리 중 현재 선택된 항목을 탭 형태로 표시할 때.
- not_for: |
- - 모두 동등한 색상 바 → highlight-strip
+ not_for: '- 모두 동등한 색상 바 → highlight-strip
+
- 섹션 구분 → section-header-bar
+
+ '
purpose_fit: []
slots:
required:
@@ -2060,6 +2625,11 @@ blocks:
note: 탭 수
padding_overhead_px: 8
padding_h_px: 0
+ tags:
+ content_pattern: N-tabs-one-active
+ content_example: 제조 | 건축 | [인프라/토목](선택됨). 탭형카테고리표시
+ item_count: 2-5
+ layout: flex-row-tabs
- id: divider-text
name: 텍스트 구분선
category: emphasis
@@ -2068,16 +2638,23 @@ blocks:
min_height_px: 25
relation_types: []
visual: 좌우 가는 선 + 중앙 작은 텍스트. ── 라벨 ── 형태.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 가벼운 섹션 전환 구분선 (선+텍스트+선)
+
- 텍스트 20자 이내, 1줄
+
- sidebar 섹션 라벨이나 가벼운 주제 전환
+
예: ── 용어 정의 ── 또는 ── 핵심 요약 ──
+
+ '
when: 가벼운 구분선에 라벨 텍스트. sidebar 또는 본문 전환점.
- not_for: |
- - 강한 파란 바 구분 → section-header-bar
+ not_for: '- 강한 파란 바 구분 → section-header-bar
+
- 결론 강조 → banner-gradient
+
+ '
purpose_fit: []
slots:
required:
@@ -2093,6 +2670,11 @@ blocks:
note: 13px bold, nowrap, 중앙정렬
padding_overhead_px: 16
padding_h_px: 0
+ tags:
+ content_pattern: line-text-line-divider
+ content_example: ── 용어정의 ── 또는 ── 핵심요약 ──
+ item_count: 1
+ layout: flex-row-line-text-line
- id: image-row-2col
name: 이미지 2열
category: media
@@ -2101,17 +2683,25 @@ blocks:
min_height_px: 200
relation_types: []
visual: 이미지 2장 나란히. 각 캡션 선택.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 이미지 정확히 2장을 나란히 배치
+
- 캡션 선택적, 텍스트 설명 불필요
+
- 이미지 자체가 콘텐츠 (사진, 도면, 스크린샷)
+
+ '
when: 이미지 2장 나란히. 현장 비교, 전후 사진 등.
- not_for: |
- - 4장 → image-grid-2x2
+ not_for: '- 4장 → image-grid-2x2
+
- 이미지+텍스트 설명 → image-side-text
+
- 1장 → image-full-caption
+
- 전/후 라벨 필요 → image-before-after
+
+ '
purpose_fit:
- 근거사례
slots:
@@ -2131,6 +2721,12 @@ blocks:
note: 이미지 수
padding_overhead_px: 0
padding_h_px: 0
+ tags:
+ content_pattern: 2-images-side-by-side
+ content_example: 이미지2장나란히. 현장비교, 전후사진
+ item_count: 2
+ has_image: true
+ layout: grid-2col
- id: image-grid-2x2
name: 이미지 2x2 그리드
category: media
@@ -2139,15 +2735,21 @@ blocks:
min_height_px: 350
relation_types: []
visual: 이미지 4장 2×2 격자. 각 캡션 선택.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 이미지 정확히 4장을 2×2 격자로 배치
+
- 캡션 선택적
+
+ '
when: 이미지 4장. 현장 사진, 4개 관점, 4단계 시각화 등.
- not_for: |
- - 2장 → image-row-2col
+ not_for: '- 2장 → image-row-2col
+
- 이미지+텍스트 → image-side-text
+
- 1장 → image-full-caption
+
+ '
purpose_fit:
- 근거사례
slots:
@@ -2166,6 +2768,12 @@ blocks:
note: 이미지 수 (2x2)
padding_overhead_px: 8
padding_h_px: 0
+ tags:
+ content_pattern: 4-images-grid
+ content_example: 이미지4장2x2격자. 현장사진, 4관점
+ item_count: 4
+ has_image: true
+ layout: grid-2x2
- id: image-side-text
name: 이미지+텍스트 가로
category: media
@@ -2174,16 +2782,23 @@ blocks:
min_height_px: 150
relation_types: []
visual: 좌측 이미지(320px) + 우측 제목+설명+불릿. 가로 배치.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 1장의 이미지 + 옆에 텍스트 설명이 필요
+
- 이미지를 보면서 동시에 설명을 읽는 구조
+
- 제목 + 설명 또는 불릿 리스트
+
예: [시스템 스크린샷] + 우측에 주요 기능 3가지 불릿
+
+ '
when: 이미지 1장 + 옆에 텍스트 설명. 제품 소개, 다이어그램 해설.
- not_for: |
- - 이미지만(텍스트 없음) → image-full-caption 또는 image-row-2col
+ not_for: '- 이미지만(텍스트 없음) → image-full-caption 또는 image-row-2col
+
- 여러 장 → image-grid-2x2
+
+ '
purpose_fit:
- 핵심전달
- 근거사례
@@ -2219,6 +2834,13 @@ blocks:
note: 불릿 수
padding_overhead_px: 4
padding_h_px: 0
+ tags:
+ content_pattern: image-plus-text-description
+ content_example: 좌=시스템스크린샷 우=주요기능3가지불릿
+ item_count: 1
+ has_image: true
+ has_bullets: true
+ layout: flex-row-image-text
- id: image-full-caption
name: 전체 너비 이미지
category: media
@@ -2227,15 +2849,21 @@ blocks:
min_height_px: 200
relation_types: []
visual: 전체 너비 이미지 1장 + 하단 캡션.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 이미지 1장을 전체 너비로 크게 보여줌
+
- 캡션만 선택적 (옆에 텍스트 설명 불필요)
+
- 핵심 도표, 대형 다이어그램, 전경 사진
+
+ '
when: 이미지 1장을 크게. 캡션만. 텍스트 설명 불필요.
- not_for: |
- - 2장+ → image-row-2col/image-grid-2x2
+ not_for: '- 2장+ → image-row-2col/image-grid-2x2
+
- 이미지+텍스트 → image-side-text
+
+ '
purpose_fit:
- 핵심전달
slots:
@@ -2253,6 +2881,12 @@ blocks:
note: 12px, 이미지 아래
padding_overhead_px: 0
padding_h_px: 0
+ tags:
+ content_pattern: single-fullwidth-image
+ content_example: 핵심도표 또는 대형다이어그램1장크게+캡션
+ item_count: 1
+ has_image: true
+ layout: full-width-image
- id: image-before-after
name: Before/After 이미지
category: media
@@ -2262,17 +2896,25 @@ blocks:
relation_types:
- comparison
visual: 좌 Before(회색 라벨) + → 화살표 + 우 After(파란 라벨). 이미지 전후 비교.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 이미지 2장이 전/후(Before/After) 관계
+
- 각 이미지에 "Before"/"After" 또는 "현재"/"개선" 라벨
+
- 사이에 → 화살표로 변화 방향 표시
+
- image-row-2col과 차이: 이 블록은 라벨+화살표 있음, row는 단순 나열
+
예: Before(기존 도면) → After(3D 모델)
+
+ '
when: 변화 전후를 이미지로 비교할 때. Before/After 라벨+화살표.
- not_for: |
- - 단순 이미지 나열(전후 아님) → image-row-2col
+ not_for: '- 단순 이미지 나열(전후 아님) → image-row-2col
+
- 텍스트 비교 → comparison-2col
+
+ '
purpose_fit:
- 핵심전달
- 근거사례
@@ -2305,10 +2947,23 @@ blocks:
note: 12px, 하단 캡션
padding_overhead_px: 0
padding_h_px: 0
-## ── Figma 추출 블록 (new/) ──────────────────────────────────
+ tags:
+ content_pattern: before-after-image-comparison
+ content_example: Before(기존도면) → After(3D모델). 라벨+화살표
+ item_count: 2
+ has_image: true
+ has_label: true
+ layout: flex-row-before-arrow-after
- id: statement-pill-highlight
name: 선언문 pill 강조
category: new
+ tags:
+ content_pattern: single-declaration-sentence
+ content_example: 수행과정 연속화와 관리체계 일원화된 형태의 전용ㆍ전문 S/W 개발 없이는 미래가 없다 | BIM은 건설산업의 DX을 수행하는 과정에서 가장 기초가 되는 일부분 | DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다
+ item_count: 1
+ has_highlight: true
+ layout: full-width-capsule
+ source_mdx: '01'
template: blocks/new/statement-pill-highlight.html
height_cost: compact
min_height_px: 59
@@ -2316,18 +2971,27 @@ blocks:
- conclusion
- emphasis
visual: 캡슐형(radius 999px) 어두운 gradient 배경 위에 흰색 Bold 한 줄 메시지. 으로 노란색 강조. 전체 너비 사용.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 핵심 결론/선언이 딱 1문장 (40자 이내)
+
- 문장 중 일부 키워드만 강조 필요 (예: "전용 S/W 개발 없이는 미래가 없다")
+
- 설명/불릿 없이 메시지 자체가 전부
+
- 페이지 끝 마무리 또는 섹션 전환점
+
+ '
when: 핵심 결론이 1문장이고, 그 문장 안에 강조할 키워드가 있을 때. "~없이는 ~없다", "~가 핵심이다" 같은 선언형 문장.
- not_for: |
- - 2줄 이상 메시지 → banner-gradient
+ not_for: '- 2줄 이상 메시지 → banner-gradient
+
- 인용문+출처 → quote-big-mark
+
- 질문형 강조 → quote-question
+
- 배경 이미지 위 타이틀 → section-title-with-bg
+
+ '
purpose_fit:
- 결론
- 선언
@@ -2345,10 +3009,18 @@ blocks:
note: 29px bold white, 으로 노란색 하이라이트
padding_overhead_px: 28
padding_h_px: 96
-
- id: stacked-arrow-list
name: 적층 화살표 리스트
category: new
+ tags:
+ content_pattern: grouped-items-under-single-label
+ content_example: 시공상세정보물 → [3차원 형상의 정보 모델과 D/B, 단계별 시공 시뮬레이션, 안전교육 영상물, 모델에서 추출한 도면, 안전유의사항 상세 표현 도면]
+ item_count: 3-7
+ has_icon: true
+ has_color_bar: true
+ has_left_label: true
+ layout: diamond-stacked-rows
+ source_mdx: '02'
template: blocks/new/stacked-arrow-list.html
height_cost: large
min_height_px: 300
@@ -2356,19 +3028,29 @@ blocks:
- hierarchy
- list
visual: 좌측 원호 장식+세로 라벨, 우측에 N개 캡슐 행이 다이아몬드형(가운데 좁고 양끝 넓은)으로 적층. 각 행에 화살표+텍스트+색상 하단 보더.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 하나의 총괄 개념(좌측 라벨) 아래 3~7개 구체 항목 나열
+
- 각 항목은 짧은 1줄 문장 (35자 이내)
+
- 항목 간 색상 구분이 있음 (단계별/레벨별 다른 색)
+
- 좌측에 "시공상세정보물" 같은 세로 총괄 라벨
+
예: 좌="시공상세정보물" → [3D 모델, 시뮬레이션, 영상물, 도면, 상세도면]
+
+ '
when: 하나의 상위 개념에 속하는 N개 구체 항목을 나열할 때. 각 항목이 짧은 1줄이고 항목별 색상 구분 필요.
- not_for: |
- - 항목에 설명/불릿이 붙는 경우 → card-numbered
+ not_for: '- 항목에 설명/불릿이 붙는 경우 → card-numbered
+
- 단순 불릿 목록 → dark-bullet-list
+
- 시간 순서/프로세스 → process-horizontal
+
- 항목이 카드형(제목+설명) → card-icon-desc
+
+ '
purpose_fit:
- 목록
- 계층
@@ -2387,7 +3069,7 @@ blocks:
font_size: 26
ref_chars:
body: 30
- note: 26px bold #144838, 으로 gradient(#cc5200) 강조
+ note: 26px bold
items:
max_items: 7
font_size: 22
@@ -2396,10 +3078,19 @@ blocks:
note: 22px medium, 각 행 1줄
padding_overhead_px: 44
padding_h_px: 0
-
- id: split-panel-numbered
name: 분할 패널 (좌 카테고리 + 우 번호)
category: new
+ tags:
+ content_pattern: left-categories-right-numbered-issues
+ content_example: '좌=[GIS: ArcGIS,QGIS,천지인 | Modeler: Rhino,Blender,EG-BIM,Revit,Civil3D | Simulation: Twin Highway,Infraworks] 우=[1.Model구축 기능위주, 2.고가고사양 전문가용, 3.S/W간 호환불가, 4.성과물 제작 별도, 5.시공현장 반영 한계]'
+ left_item_count: 2-4
+ right_item_count: 3-6
+ has_icon: true
+ has_color_bar: true
+ has_numbered_badge: true
+ layout: split-panel-52-48
+ source_mdx: '02'
template: blocks/new/split-panel-numbered.html
height_cost: large
min_height_px: 400
@@ -2407,18 +3098,27 @@ blocks:
- comparison
- hierarchy
visual: 좌측 52% 셰브론 배경에 카테고리별 색상 바+항목 리스트. 우측 48%에 번호 뱃지+설명 행. 2단 분할 패널.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 좌측: 2~4개 카테고리, 각 카테고리에 소속 항목 리스트 (예: GIS→[ArcGIS, QGIS])
+
- 우측: 번호가 매겨진 3~6개 설명/이슈 (예: 1.기능위주, 2.고가복잡, 3.호환불가)
+
- 좌=입력/도구/분류, 우=결과/문제점/특성 대응 구조
+
- 좌/우가 1:1 대응이 아니라 좌=카테고리 그룹, 우=전체에 대한 이슈
+
예: 좌=[GIS, Modeler, Simulation] → 우=[기능위주, 고가, 호환불가, 성과별도, 현장한계]
+
+ '
when: 좌측에 카테고리별 도구/항목 분류 + 우측에 그에 대한 번호 매긴 이슈/특성을 나열할 때.
- not_for: |
- - 좌/우 1:1 대응 비교 → comparison-2col
+ not_for: '- 좌/우 1:1 대응 비교 → comparison-2col
+
- 카테고리별 행 비교 → compare-vs-rows
+
- 독립 카드 3열 → card-image-3col
+
+ '
purpose_fit:
- 비교
- 분류
@@ -2441,13 +3141,21 @@ blocks:
font_size: 18
ref_chars:
body: 30
- note: 18px medium #11231d
+ note: 18px medium
padding_overhead_px: 36
padding_h_px: 0
-
- id: issues-paired-rows
name: 이슈 쌍 행 (두루마리 pill)
category: new
+ tags:
+ content_pattern: paired-left-right-issues-with-labels
+ content_example: 개념부재(BIM을 CAD확장판으로 인식) vs 잘못된접근방식(도구로만 교육) | 방향성상실(외국S/W 방향 따라감) vs 전제조건오류(건축방식을 토목에 적용) | 수행주체혼란(학자발주처 주도, 기업은 눈치) vs 수행방식무지(전환설계로 비용시간 추가) | 외산SW기술예속(범용SW만 사용) vs HW미비(탁상용PC 수준)
+ item_count: 2-5
+ pair_structure: true
+ has_label_pill: true
+ has_divider: true
+ layout: stacked-paired-rows
+ source_mdx: '01'
template: blocks/new/issues-paired-rows.html
height_cost: large
min_height_px: 400
@@ -2455,19 +3163,29 @@ blocks:
- comparison
- problem
visual: N개 행, 각 행은 녹색 border 박스 안에 좌/우 텍스트+점선 구분. 행 위/아래로 두루마리 pill이 걸침. pill 위치 교차(위→아래→위→아래).
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- N개 이슈 쌍: 각 쌍이 "좌 라벨+설명" vs "우 라벨+설명"
+
- 각 쌍에 짧은 라벨(2~5글자)이 있음 (예: "개념 부재", "방향성 상실")
+
- 좌/우가 대비 관계 (원인↔결과, 문제↔오류)
+
- 2~5개 쌍, 각 설명은 2~3줄
+
- 라벨이 pill 형태로 시각적 강조 필요
+
예: [개념부재 vs 잘못된접근, 방향성상실 vs 전제조건오류, 수행주체혼란 vs 수행방식무지]
+
+ '
when: 좌/우 이슈가 쌍으로 묶이고, 각 쌍에 짧은 라벨이 필요할 때. 문제점 진단, 원인-결과 대비.
- not_for: |
- - 카테고리별 N행 비교 (중앙에 카테고리 라벨) → compare-vs-rows
+ not_for: '- 카테고리별 N행 비교 (중앙에 카테고리 라벨) → compare-vs-rows
+
- 라벨 없는 단순 좌/우 비교 → comparison-2col
+
- 4개 카테고리 매트릭스 → quadrant-2x2-issues
+
+ '
purpose_fit:
- 문제점
- 이슈
@@ -2486,30 +3204,49 @@ blocks:
note: icon + title (gradient text)
padding_overhead_px: 32
padding_h_px: 32
-
- id: compare-vs-rows
name: VS 비교 행 테이블
category: new
+ tags:
+ content_pattern: category-by-category-AB-comparison-table
+ content_example: BIM vs DX → [BIM/DX, S/W(상용vs전용40~80개), 프로세스(2D유지vs근본개선), 성과물(3D모델vs정보콘텐츠연계), 활용(일반이해vs혁신), 확장성(분야단절vs전생애주기), 수행개념(단순화vs구체화), CIVIL+IT(소극vs적극), 주체(SW의존vs자체능력), 발주처(평준화vs차별화), 설계사(소규모BIM팀vs220명운영),
+ 시공사(소극적vs분야확장)]
+ item_count: 5-15
+ has_category_pill: true
+ has_conclusion: true
+ layout: 3col-grid-left-center-right
+ source_mdx: '01'
template: blocks/new/compare-vs-rows.html
height_cost: large
min_height_px: 400
relation_types:
- comparison
visual: 상단 gradient bar에 "A vs B" 라벨. 아래로 N행, 각 행=좌(갈색 우정렬)|중앙(카테고리 pill)|우(녹색 좌정렬). 하단 결론 박스.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- A와 B를 카테고리별로 비교하는 표 구조
+
- 카테고리가 5~15개 (S/W, 프로세스, 성과물, 활용, 확장성, 주체 등)
+
- 각 카테고리에서 A의 특성과 B의 특성을 1~2줄로 대비
+
- 중앙에 카테고리 라벨 pill이 행마다 있음
+
- 하단에 결론 메시지 1~2줄
+
예: BIM vs DX → [S/W, 프로세스, 성과물, 활용, 확장성, 수행개념, ...] 각각 좌/우 비교
+
+ '
when: 두 개념을 카테고리별(5+항목)로 행 단위 상세 비교할 때. 각 행에 카테고리 라벨이 있고, 좌/우 각각 짧은 설명.
- not_for: |
- - 카테고리 없는 단순 좌/우 대비 → comparison-2col
+ not_for: '- 카테고리 없는 단순 좌/우 대비 → comparison-2col
+
- 행별 기준 라벨이 있는 표 → compare-2col-split
+
- 좌/우 이슈 쌍+라벨 pill → issues-paired-rows (쌍 단위, 카테고리 아님)
+
- 3열 비교 → compare-3col-badge
+
+ '
purpose_fit:
- 비교
- 정의
@@ -2531,10 +3268,18 @@ blocks:
note: arrow_img + text (HTML with )
padding_overhead_px: 40
padding_h_px: 0
-
- id: quadrant-2x2-issues
name: 2×2 사분면 이슈
category: new
+ tags:
+ content_pattern: 4-category-quadrant-matrix
+ content_example: 정책집행[인정주의정책집행+적용효과도사례도없는방침남발] | 수행개념[공학적개념정립부재+본업기술력확보우선개념부재] | 근본취지이해부족[기술투자없는성과창출기대+엔지니어링SW개념부재] | 지속적투자의지부재[근본적역할회피+과거타성에머무르는업계]
+ item_count: 4
+ subitem_count: 2-4
+ has_center_quote: true
+ has_ribbon_header: true
+ layout: 2x2-grid-center-overlay
+ source_mdx: '01'
template: blocks/new/quadrant-2x2-issues.html
height_cost: large
min_height_px: 500
@@ -2542,20 +3287,31 @@ blocks:
- classification
- problem
visual: 2×2 사분면 grid. 각 사분면에 gradient ribbon 헤더 + 빨간 헤드라인 + 불릿. 중앙에 원형 인용구 오버레이.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 이슈/문제점이 정확히 4개 카테고리로 분류됨
+
- 각 카테고리에 2~4개 세부 이슈 (빨간 헤드라인+불릿)
+
- 4개가 2×2 매트릭스로 배치 가능 (예: 정책집행|수행개념|이해부족|투자부재)
+
- 중앙에 핵심 메시지/인용구 (선택)
+
- 좌측 2개와 우측 2개가 다른 색조 (warm vs teal)
- 예: [정책집행, 수행개념, 이해부족, 투자부재] 각각 2~3개 이슈 + 중앙 "Rome wasn't built in a day"
+
+ 예: [정책집행, 수행개념, 이해부족, 투자부재] 각각 2~3개 이슈 + 중앙 "Rome wasn''t built in a day"
+
+ '
when: 이슈가 정확히 4개 카테고리이고, 2×2 매트릭스로 배치할 때. 각 사분면에 헤드라인+불릿 구조.
- not_for: |
- - 이슈가 2개 쌍 → issues-paired-rows
+ not_for: '- 이슈가 2개 쌍 → issues-paired-rows
+
- 이슈가 N개 나열 → dark-bullet-list 또는 card-numbered
+
- 2열 비교 → comparison-2col
+
- 3개 교차 관계 → cycle-3way-intersect
+
+ '
purpose_fit:
- 문제점
- 분류
@@ -2573,10 +3329,20 @@ blocks:
note: bg_img(원형 일러스트 이미지) + text
padding_overhead_px: 0
padding_h_px: 0
-
- id: cards-3col-persona
name: 3열 페르소나 카드
category: new
+ tags:
+ content_pattern: 3-stakeholder-parallel-bullet-lists
+ content_example: 발주자목표[민원재작업예방, 직관화품질향상, 관리편의성, 소통오류최소화, 행정자동화, 정보통합관리, 디지털자산관리] | 시공자목표[시공오류예방, 시각화안전품질, 시공간관리, 의사소통강화, 시공상세도작성, 행정간소화, 생산성향상] | 설계자목표[직관적소통, 오류최소화Claim예방, 상호신뢰, 부가가치창출, 고부가가치산업전환,
+ 정보일관성관리]
+ item_count: 3
+ subitem_count: 5-8
+ has_badge: true
+ has_photo: true
+ has_bullet_icon: true
+ layout: 3col-equal-flex
+ source_mdx: '02'
template: blocks/new/cards-3col-persona.html
height_cost: large
min_height_px: 500
@@ -2584,20 +3350,31 @@ blocks:
- comparison
- stakeholder
visual: 3열 세로 카드. 각 컬럼에 배경+오버레이+원형 뱃지(2줄 라벨)+체크 불릿 리스트+하단 사진.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 정확히 3개 역할/이해관계자/관점을 나란히 비교
+
- 각 역할에: 역할명(2줄, 예: "발주자/목표") + 5~8개 목표/항목 불릿 리스트
+
- 각 역할이 고유 색상/아이덴티티를 가짐
+
- 선택적으로 각 역할 대표 사진
+
- 역할 간 항목 수가 다를 수 있음 (space-between 균등 분포)
+
예: 발주자[7항목] | 시공자[7항목] | 설계자[6항목] 각각 체크리스트+하단 사진
+
+ '
when: 3개 역할/관점별로 각각 5+개 항목을 불릿 리스트로 나열·비교할 때. 역할별 뱃지 아이덴티티 필요.
- not_for: |
- - 텍스트만 카드(불릿 없이 설명) → card-icon-desc
+ not_for: '- 텍스트만 카드(불릿 없이 설명) → card-icon-desc
+
- 이미지 중심 3열 → card-image-3col
+
- 2개만 비교 → comparison-2col
+
- 역할이 아닌 카테고리 비교 → card-compare-3col
+
+ '
purpose_fit:
- 역할비교
- 이해관계자
@@ -2618,10 +3395,18 @@ blocks:
note: 15px medium, 체크 아이콘 marker + text flex pair (R13)
padding_overhead_px: 0
padding_h_px: 8
-
- id: cycle-3way-intersect
name: 3원 교차 다이어그램
category: new
+ tags:
+ content_pattern: 3-values-intersecting-venn
+ content_example: 안전과품질(安:안전성제고+質:품질향상) × 생산성향상(速:신속정확성증진+利:비용저감부가가치창출) × 소통과신뢰(通:소통이해원활+信:신뢰투명성강화)
+ item_count: 3
+ subitem_count: 2
+ has_accent_char: true
+ has_side_labels: true
+ layout: positioned-circles-aspect-2to1
+ source_mdx: '02'
template: blocks/new/cycle-3way-intersect.html
height_cost: large
min_height_px: 400
@@ -2629,20 +3414,31 @@ blocks:
- relationship
- intersection
visual: CSS 3원 교차 다이어그램. 역삼각형 배치 3개 원(gradient+blend+ring) + 6개 한자 액센트 원 + 6개 사이드 라벨.
- content_structure: |
- 콘텐츠가 이 구조일 때 선택:
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+
- 정확히 3개 핵심 가치/목표가 서로 교차·융합하는 관계
+
- 각 가치에 2줄 라벨 (예: "안전과/품질", "생산성/향상", "소통과/신뢰")
+
- 각 가치에 2개 세부 키워드 (한자 1글자, 예: 安/質, 速/利, 通/信)
+
- 각 세부 키워드에 제목+설명 사이드 텍스트
+
- 3개가 겹치는 교차 영역이 의미 있음
+
예: [안전과품질, 생산성향상, 소통과신뢰] → 6개 세부 → 6개 사이드 설명
+
+ '
when: 3가지 가치가 서로 교차하는 관계를 다이어그램으로 시각화할 때. 각 가치에 세부 키워드+설명 필요.
- not_for: |
- - 3개가 교차하지 않고 독립 나열 → keyword-circle-row
+ not_for: '- 3개가 교차하지 않고 독립 나열 → keyword-circle-row
+
- 2개 비교 → comparison-2col 또는 compare-pill-pair
+
- 프로세스/순서 → process-horizontal
+
- N개 원 벤 다이어그램 → venn-diagram
+
+ '
purpose_fit:
- 관계
- 융합
@@ -2667,7 +3463,129 @@ blocks:
note: 각 label에 title, title_color, desc(HTML)
padding_overhead_px: 0
padding_h_px: 0
+- id: prerequisites-3col
+ name: 필수요건 3열 비교
+ category: new
+ tags:
+ content_pattern: 3-parallel-categories-with-subitems
+ content_example: 기술디지털(技術)[깊은기반건설산업토목지식+높은SW기술DigitalTechnology] | 사람역량(人材)[분야별전문지식역량기술자+디지털화역량개발경험많은개발자] | 자연여건(天地)[사회기업제도여건+지속적장기적투자능력의지]
+ item_count: 3
+ subitem_count: 2
+ has_icon: true
+ has_color_bar: true
+ has_kanji: true
+ layout: 3col-bar-heading-desc
+ source_mdx: '03'
+ template: blocks/new/prerequisites-3col.html
+ height_cost: large
+ min_height_px: 280
+ relation_types:
+ - definition
+ - comparison
+ visual: 3열 비교. 각 열에 세로 gradient 색상 바 + 한자 + 세로 라벨 + 2개 하위 항목(제목+설명). 상하 실선+중간 점선 구분.
+ content_structure: '콘텐츠가 이 구조일 때 선택:
+ - 정확히 3개 카테고리/필수요건을 나란히 비교
+
+ - 각 카테고리에 고유 색상 + 한자 심볼 + 세로 라벨
+
+ - 각 카테고리 안에 정확히 2개 하위 항목 (제목+설명)
+
+ - 카테고리가 대등한 비중 (기술/사람/자연 같은 균형 구조)
+
+ - category-strip-table과 차이: 이 블록은 한자+세로라벨+2항목 고정, strip-table은 N항목 자유
+
+ 예: 기술(技術)[기반지식/SW기술] | 사람(人材)[전문역량/개발경험] | 자연(天地)[여건/투자]
+
+ '
+ when: 3개 필수요건/카테고리를 한자 심볼 + 색상 바로 시각화하며 각각 2개 하위 항목으로 비교할 때.
+ not_for: '- 카테고리가 3개가 아님 → category-strip-table (N열)
+
+ - 하위 항목이 2개가 아닌 N개 → category-strip-table
+
+ - 한자/심볼 불필요 → card-compare-3col
+
+ - 독립 카드형 비교 → card-icon-desc
+
+ '
+ purpose_fit:
+ - 필수요건
+ - 분류
+ - 비교
+ slots:
+ required:
+ - columns
+ optional: []
+ schema:
+ columns:
+ fixed_count: 3
+ note: 각 column에 name, sub, kanji_top, kanji_bottom, bar_gradient, heading_gradient_top/bottom, items[2]{heading, desc}
+ padding_overhead_px: 0
+ padding_h_px: 0
+- id: process-product-2col
+ name: 프로세스/프로덕트 2단 비교 (비대칭)
+ category: redesign
+ template: blocks/redesign/process-product-2col.html
+ height_cost: large
+ min_height_px: 200
+ relation_types:
+ - comparison
+ - cause_effect
+ visual: 좌우 2단 비대칭 비교. 좌측에 As-is→To-be 비교표 + 추가 섹션. 우측에 불릿 섹션. 좌측 warm gradient, 우측 teal gradient.
+ content_structure: |
+ 콘텐츠가 이 구조일 때 선택:
+ - 2개 대주제를 좌우로 비교하되, 좌우가 비대칭 (한쪽에 표, 한쪽에 불릿)
+ - 좌측에 As-is→To-be 전환 비교가 있음
+ - 우측은 결과/변화를 불릿으로 나열
+ - 대칭 비교(좌우 같은 구조)는 compare-detail-gradient 사용
+ when: 2개 대주제를 좌우 비대칭으로 비교할 때. 한쪽에 전환 비교(As-is→To-be)가 있고 다른 쪽은 불릿 나열.
+ not_for: |
+ - 좌우 대칭 비교 → compare-detail-gradient
+ - 3개 이상 병렬 → prerequisites-3col 또는 card 계열
+ - 단일 목록 → dark-bullet-list
+ purpose_fit:
+ - 핵심전달
+ - 구조시각화
+ tags:
+ content_pattern: "2-section-asymmetric-compare-table-and-bullets"
+ content_example: "좌=과정혁신[Analogue→Digital 비교표+GIS연계+Solution] vs 우=결과변화[품질향상+정보물추가+효율화]"
+ item_count: 2
+ has_compare_table: true
+ layout: "flex-2col-asymmetric"
+ source_mdx: "03"
+ slide_font:
+ header: 13px
+ mid_title: 12px
+ body: 11px
+ slots:
+ required:
+ - left_title
+ - right_title
+ - left_sections[]
+ - right_sections[]
+ optional:
+ - left_compare
+ schema:
+ left_title:
+ type: string
+ right_title:
+ type: string
+ left_compare:
+ type: object
+ properties:
+ title: string
+ left_items: array
+ right_items: array
+ left_sections:
+ type: array
+ items:
+ title: string
+ bullets: array
+ right_sections:
+ type: array
+ items:
+ title: string
+ bullets: array
layouts:
- id: 65-35
name: 6.5:3.5 좌우 분할
diff --git a/templates/slide-base.html b/templates/slide-base.html
deleted file mode 100644
index c89019b..0000000
--- a/templates/slide-base.html
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-{{ slide_title | default('슬라이드') }}
-
-
-
-
-{% for page in pages %}
-
- {% if loop.first and slide_title %}
-
{{ slide_title }}
- {% endif %}
-
- {% for block in page.blocks %}
-
- {{ block.html }}
-
- {% endfor %}
-
-{% endfor %}
-
-
-