From 66c00924edcaa3a33a055ee14a2e9698f88b3f8d Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Wed, 15 Apr 2026 16:39:50 +0900 Subject: [PATCH] Add Type B slide pipeline and recipe rendering updates --- PIPELINE.md | 61 +- README.md | 431 +++-- docs/history/PHASE-Y-PLAN.md | 611 +++++++ samples/src/components/dx.astro | 307 ++++ src/block_assembler.py | 1406 +++++++++++++++- src/block_assembler_b2.py | 634 +++++--- src/block_reference.py | 284 +++- src/image_utils.py | 11 + src/kei_client.py | 113 +- src/pipeline.py | 348 +++- src/pipeline_context.py | 25 +- src/renderer.py | 4 +- src/section_parser.py | 573 +++++++ src/slide_measurer.py | 6 +- src/space_allocator.py | 58 +- src/step_visualizer.py | 695 ++++---- src/validators.py | 111 +- .../blocks/cards/compare-detail-gradient.html | 6 +- templates/blocks/new/prerequisites-3col.html | 193 +++ .../blocks/redesign/process-product-2col.html | 228 +++ templates/catalog.yaml | 1416 ++++++++++++++--- templates/slide-base.html | 61 - 22 files changed, 6260 insertions(+), 1322 deletions(-) create mode 100644 docs/history/PHASE-Y-PLAN.md create mode 100644 samples/src/components/dx.astro create mode 100644 src/section_parser.py create mode 100644 templates/blocks/new/prerequisites-3col.html create mode 100644 templates/blocks/redesign/process-product-2col.html delete mode 100644 templates/slide-base.html diff --git a/PIPELINE.md b/PIPELINE.md index e54f9d2..689b2ad 100644 --- a/PIPELINE.md +++ b/PIPELINE.md @@ -241,28 +241,49 @@ PipelineContext: --- -## 7. 현재 구현 상태 +## 7. 현재 구현 상태 (Phase Y-11~13, 2026-04-15) -| 항목 | 상태 | 비고 | -|------|------|------| -| Stage 0 (MDX 정규화) | ✅ | | -| Stage 1A (꼭지 추출) | ✅ | A/B 선택 포함 | -| Stage 1B (컨셉 구체화) | ✅ | | -| Stage 1B-ST (구조화 텍스트) | ✅ | | -| Stage 1.5a (컨테이너 계산) | ✅ | Type A/B 분기 | -| Stage 1.7 (블록 선택) | ✅ | catalog.yaml 기반 | -| Stage 1.8 (적합성 검증) | ✅ | Selenium 3회 루프 | -| **Stage 2 — Type A** | ⚠ 미완성 | Sonnet 의존, 검증 불완전 | -| **Stage 2 — Type B** | ✅ | block_assembler 코드 조립 | -| **Stage 2 — Type B'** | ✅ | 03번 전용 하드코딩 | -| **Stage 2 — Type B''** | ✅ | B' 스타일 변형 | -| Stage 3 (렌더링) | ⚠ | Type A만 사용 | -| Stage 4 (검증) | ✅ | Selenium + Opus Vision | +> Phase Y: slide-base 기반 파이프라인 재설계. 상세: `docs/history/PHASE-Y-PLAN.md` -### 미해결 과제 -1. **Type A HTML 생성** — Sonnet 의존 구조가 불안정. block_assembler처럼 코드 기반으로 전환 필요? -2. **B'/B'' 범용화** — 03번 전용 → 다양한 콘텐츠 구조 커버 -3. **블록 템플릿 업데이트 반영** — 최근 추가/수정된 블록이 파이프라인에 제대로 연결되는지 검증 필요 +### 파이프라인 흐름 (현재) + +``` +[Stage 0] MDX → normalized.sections (source of truth) +[Stage 1A] Kei 꼭지 추출 (영역/zone 판단 안 함) +[Phase Y] 코드: normalized → 대목차 추출 → group schema 분류 → 블록 매칭 → 영역 확정 +[Stage 1.5a] space_allocator: weight → zone px (% 기반) +[Stage 1.7] block_reference: tag_match → schema_match → fallback 순서 +[Stage 1.8] assembler(measure_mode) → Selenium 측정 → fit 루프 +[Stage 2] assembler(slide-base + 블록) → final HTML +[Stage 4] Selenium overflow + 비전 (-1 미평가) +``` + +### MDX별 상태 + +| MDX | 상태 | 비고 | +|-----|------|------| +| **03** | ✅ 동작 | prerequisites-3col + pp2. 텍스트 누락 없음. 회귀 기준. | +| **02** | ⚠ schema 1차 | top: parallel_3_with_image. bottom: 분류 정교화 필요. | +| **01** | ⬜ 미착수 | Type A. 별도 작업. | + +### 핵심 원칙 (확립됨) + +- source of truth = normalized.sections (Stage 0) +- 영역 = 코드가 결정 (Kei 아님). sub_titles 기반 + group schema. +- 블록 CSS에 최종 고정값. slide_font_css는 공통 레이아웃 계약만. +- zone = % 기반, block = height:100%. +- 글씨 크기 고정. fit은 padding → 내용량 → font 1단계(responsive tier). +- 기존 경로 삭제 금지. 새 schema 점진적 추가. MDX 03 회귀 기준. +- 하드코딩 금지. 프로세스가 결과를 만드는 구조. + +3. **블록 글씨 크기 하드코딩 (px 고정)** + - 블록 CSS에 font-size가 Figma 원본 px로 고정 + - 컨테이너 크기에 따라 조정 불가 → overflow 원인 + - CSS 변수(`var(--block-font-heading)`)로 전환 → assembler가 zone 크기에 따라 계산 + +### 미해결 프로세스 +1. **overflow 시 font 조정 루프** — 재배분만으로 부족할 때 font/padding 줄이기 (Y-5) +2. **Sonnet redesign 경로** — tag 매칭 실패 시 블록 단위 redesign → 저장 (Y-6) --- diff --git a/README.md b/README.md index 9a69f41..f16881d 100644 --- a/README.md +++ b/README.md @@ -1,303 +1,248 @@ # Kei Design Agent -콘텐츠를 시각적으로 구조화된 슬라이드 HTML(1280×720px, 16:9)로 변환하는 AI 파이프라인. +MDX 콘텐츠를 분석해 1280x720 슬라이드 HTML로 변환하는 파이프라인입니다. -## 개요 +이 문서는 "지금 실제 코드 기준으로 파이프라인이 어떻게 동작하는지"를 빠르게 파악하기 위한 개요 문서입니다. 과거 Phase 문서와 일부 legacy 경로는 남아 있지만, 아래 설명은 현재 메인 경로를 기준으로 정리했습니다. -텍스트/MDX 콘텐츠를 입력하면: -1. Kei 실장(Opus)이 정보 구조와 비중을 판단하고 -2. 코드가 컨테이너 크기를 계산하고 -3. 블록을 선택하고 -4. 콘텐츠-컨테이너 적합성을 검증하고 -5. AI(Sonnet)가 블록 디자인을 참고하여 HTML을 생성하고 -6. 코드가 슬라이드 프레임에 조립하고 -7. 측정+비전 모델로 검증합니다 +## 한눈에 보기 ---- +파이프라인의 큰 흐름은 아래와 같습니다. -## 파이프라인 (10단계) +1. MDX를 코드가 정규화한다. +2. Kei가 문서의 의미와 topic을 읽는다. +3. 코드는 `normalized.sections`를 기준으로 실제 슬라이드 구조를 다시 만든다. +4. 코드는 schema, recipe, tag를 바탕으로 블록을 고른다. +5. Type B는 코드가 템플릿을 조립해 `final.html`을 만든다. +6. Type A는 아직 AI/renderer 비중이 더 크다. +7. Selenium과 vision gate로 측정/검증한 뒤 run 산출물을 저장한다. -``` -MDX 원본 - ↓ -[Stage 0] MDX 정규화 (코드) - ↓ -[Stage 1A] 꼭지 추출 + 영역 배정 (Kei API / Opus) - ↓ -[Stage 1B] 컨셉 구체화 (Kei API / Opus) - ↓ -[Stage 1.5a] 컨테이너 초기 계산 (코드) - ↓ -[Stage 1.7] 블록 선택 (코드) - ↓ -[Stage 1.8] 적합성 검증 + 재배분 + 보강 (코드 + Kei 에스컬레이션) - ↓ -[Stage 1.5b] 디자인 예산 재계산 (코드) - ↓ -[Stage 2] HTML 생성 (영역별 개별 호출) (Claude Sonnet) - ↓ -[Stage 3] 렌더링 조립 + 후처리 (코드) - ↓ -[Stage 4] 측정 + 품질 검증 (Selenium + Opus Vision) - ↓ -검증 통과 시 → final.html 저장 + 팝업 분리 (파일 출력) -``` +핵심 원칙은 다음과 같습니다. -※ Stage 4 이후의 파일 저장은 별도 Stage가 아닌 후처리입니다. +- source of truth는 `normalized.sections` +- block 선택은 문서명 하드코딩이 아니라 shape, schema, tag 기반 +- 흐름의 우선순위는 `구조 -> payload -> layout -> fit` +- popup/detail은 overflow를 덮는 임시 장치가 아니라 `메인 요약 + 상세 보기`의 2단 표현 계약 ---- +## 타입 구조 -## 단계별 상세 +현재 메인 타입 선택은 사실상 `A`와 `B`입니다. -### Stage 0: MDX 정규화 +### Type A -| 항목 | 내용 | -|------|------| -| **목적** | 원본 MDX에서 JSX/frontmatter를 제거하고, 섹션/팝업/이미지/테이블로 분리 | -| **적용기술** | 코드 (`normalize_mdx_content()`) | -| **인풋** | 원본 MDX 문자열 | -| **아웃풋** | `normalized` — clean_text, title, sections[], popups[], images[], tables[] | -| **연계** | → Stage 1A가 clean_text를 Kei에게 전달 | +- 본문 외에 sidebar, reference, 부록성 영역이 함께 필요한 슬라이드 +- 현재는 Type B보다 덜 닫혀 있고, AI 생성 + renderer 경로 비중이 큽니다 -### Stage 1A: 꼭지 추출 + 영역 배정 +### Type B -| 항목 | 내용 | -|------|------| -| **목적** | 콘텐츠에서 핵심 파트(꼭지)를 식별하고, 슬라이드의 어떤 영역(배경/본심/첨부/결론)에 배치할지 결정 | -| **적용기술** | Kei API (`classify_content()`) | -| **인풋** | normalized.clean_text | -| **아웃풋** | `topics[]` (id, title, purpose, layer, relation_type, expression_hint), `page_structure` (role별 topic_ids, weight) | -| **연계** | → Stage 1B가 각 꼭지를 구체화 | +- top, bottom 같은 본문 zone 조합으로 해결되는 슬라이드 +- 현재 가장 안정적인 메인 경로입니다 +- 최근 구조화 작업은 대부분 이 Type B 경로를 중심으로 진행되었습니다 -### Stage 1B: 컨셉 구체화 +### Type B' / B'' -| 항목 | 내용 | -|------|------| -| **목적** | 각 꼭지에 실제 원본 텍스트(source_data)와 요약(summary)을 매핑 | -| **적용기술** | Kei API (`refine_concepts()`) | -| **인풋** | topics + clean_text | -| **아웃풋** | `topics` 업데이트 — source_data, summary 추가 | -| **연계** | → Stage 1.5a가 텍스트 양을 기반으로 컨테이너 비율 계산 | +- 역사적으로 실험/호환 경로에서 나온 변형입니다 +- 문서와 일부 legacy 코드에 흔적이 남아 있습니다 +- 현재 메인 개념의 1급 타입으로 보기보다, 과거 흐름과 호환 레이어로 이해하는 편이 맞습니다 -### Stage 1.5a: 컨테이너 초기 계산 +## 단계별 파이프라인 -| 항목 | 내용 | -|------|------| -| **목적** | 폰트 위계 확정 + 슬라이드 내 영역별 컨테이너 크기(px) 계산 + 프리셋 선택 | -| **적용기술** | 코드 (`calculate_font_hierarchy()`, `calculate_dynamic_ratio()`, `calculate_container_specs()`) | -| **인풋** | topics, page_structure (weight), preset | -| **아웃풋** | `font_hierarchy` (key_msg/core/bg/sidebar px), `container_ratio` (71:29 등), `containers` (role별 width_px, height_px), `preset` | -| **연계** | → Stage 1.7이 컨테이너 크기를 보고 블록 선택 | +아래는 현재 기준의 실질적인 단계입니다. -### Stage 1.7: 블록 선택 +| 단계 | 담당 | 주요 파일 | 하는 일 | 주요 산출물 | +|---|---|---|---|---| +| Stage 0 | 코드 | `src/mdx_normalizer.py` | MDX를 정규화하고 sections, tables, images, popups로 분리 | `NormalizedContent` | +| Stage 1A | AI (Kei/Opus) | `src/kei_client.py` | title, core message, topic, 초기 layout 힌트 추출 | `Analysis`, `Topic[]` | +| Phase Y | 코드 | `src/pipeline.py`, `src/section_parser.py` | `normalized.sections` 기반으로 group schema, recipe, zone/page_structure 결정 | `PageStructure`, `mdx_sections` | +| Stage 1B | AI (Kei/Opus) | `src/kei_client.py` | topic별 relation, source_data, 표현 힌트 보강 | 보강된 `Topic[]` | +| Stage 1B-ST | AI (Kei/Opus) | `src/kei_client.py` | structured_text 생성 | `Topic.structured_text` | +| Stage 1.5a | 코드 | `src/space_allocator.py` | zone/container 크기, preset, font hierarchy 계산 | `containers`, `font_hierarchy` | +| Stage 1.7 | 코드 | `src/block_reference.py`, `templates/catalog.yaml` | tag_match, schema_match, fallback 기준으로 block 선택 | `references` | +| Stage 1.8 | 코드 + Selenium + 일부 AI | `src/pipeline.py`, `src/slide_measurer.py` | fit 측정, overflow 확인, 재배분, 보정 | `fit_result`, `measurement` | +| Stage 2 | 코드 중심 | `src/block_assembler.py` | Type B 기준 slide-base + block template + payload 조립 | `generated_html` | +| Stage 3 | 코드 | `src/renderer.py` | Type A 쪽 Jinja/renderer 조립, Type B는 대체로 생략 | `rendered_html` | +| Stage 4 | 코드 + Vision AI | `src/slide_measurer.py`, `src/kei_client.py` | Selenium overflow 측정, screenshot, vision quality 평가 | `measurement`, `quality_score` | +| Stage 5 | 코드 | `src/pipeline.py` | `final.html`, `final_context.json`, popup/detail html 저장 | run 산출물 | -| 항목 | 내용 | -|------|------| -| **목적** | 각 꼭지의 relation_type + expression_hint + 컨테이너 크기로 적합한 블록 결정. 같은 영역 꼭지들의 layer가 다르면 주종관계 판단 (블록 1개로 합침) | -| **적용기술** | 코드 (`select_and_generate_references()`) — catalog.yaml 기반 결정론적 매칭 | -| **인풋** | topics, containers, page_structure | -| **아웃풋** | `references` — role별 block_id, variant, design_reference_html, topic_id, is_hierarchical, supporting_topic_ids | -| **연계** | → Stage 1.8이 선택된 블록+콘텐츠가 컨테이너에 맞는지 검증 | +## 현재 메인 실행 경로 -### Stage 1.8: 적합성 검증 + 재배분 + 보강 + 서브 컨테이너 +### Type B 메인 경로 -| 항목 | 내용 | -|------|------| -| **목적** | 콘텐츠가 컨테이너에 들어가는지 검증 → 안 맞으면 재배분 → 여전히 안 되면 Kei 에스컬레이션 → 여유 공간에 보충 콘텐츠 → 서브 컨테이너 배치 계산 | -| **적용기술** | 코드 (`calculate_fit()`, `redistribute()`, `analyze_enhancements()`, `apply_enhancements()`, `calculate_sub_layout()`) + Kei API (에스컬레이션 시 `call_kei_fit_escalation()`) | -| **인풋** | topics, containers, references, font_hierarchy, normalized, core_message | -| **아웃풋** | `containers` (재배분된 height_px), `fit_result` (role별 fit_status, redistribution), `enhancement_result` (V-7 subordinate_treatments, V-8 supplement_blocks, V-9 emphasis_blocks, V-10 bold_keywords, V-4 kei_decisions), `sub_layouts` (role별 서브 컨테이너 name/width/height, table_rows) | -| **내부 흐름** | Step 1: 필요 높이 계산 → Step 2: 재배분 → Step 3: Kei 에스컬레이션 → Step 4-5: 보강 분석+적용 → Step 6: fit 재검증 → Step 7: 서브 컨테이너 배치 → Step 8: 확정 | -| **연계** | → Stage 1.5b가 재배분된 크기로 디자인 예산 재계산, → Stage 2가 sub_layouts + enhancements를 프롬프트에 반영 | +지금 실전에서 가장 중요한 경로는 아래입니다. -### Stage 1.5b: 디자인 예산 재계산 +1. Stage 0에서 MDX를 정규화 +2. Stage 1A/1B에서 Kei가 의미와 topic 추출 +3. Phase Y에서 코드가 `normalized.sections`를 읽고 page_structure를 다시 생성 +4. Stage 1.7에서 block 선택 +5. Stage 1.8에서 fit/overflow 검증 +6. Stage 2에서 `assemble_slide_html_final()`로 최종 HTML 조립 +7. Stage 4/5에서 측정과 산출물 저장 -| 항목 | 내용 | -|------|------| -| **목적** | 재배분된 컨테이너 크기 + 선택된 블록 schema 기준으로 영역별 가용 공간 계산 | -| **적용기술** | 코드 (`calculate_design_budget()`) | -| **인풋** | containers (재배분 후), references (블록 schema) | -| **아웃풋** | `containers` 업데이트 — design_budget (available_height_px, available_width_px, fits) | -| **연계** | → Stage 2가 design_budgets를 프롬프트에 포함 | +Type B의 핵심 파일은 아래입니다. -### Stage 2: HTML 생성 (영역별 개별 호출) +- `src/pipeline.py` +- `src/section_parser.py` +- `src/block_reference.py` +- `src/block_assembler.py` +- `src/space_allocator.py` +- `templates/catalog.yaml` -| 항목 | 내용 | -|------|------| -| **목적** | page_structure에 존재하는 각 역할(배경/본심/첨부/결론)의 HTML을 **영역별 개별 Sonnet 호출**로 생성. 블록 디자인을 참고하되 콘텐츠가 구조를 결정 (Phase R' 방식) | -| **적용기술** | Claude Sonnet API — 영역당 1회 호출 (`build_area_prompt()` → `_call_claude()`) | -| **인풋** | raw_content, topics, containers, font_hierarchy, references (design_reference_html), sub_layouts (서브 컨테이너 치수), enhancements (V-4~V-10 지시), design_budgets | -| **호출 흐름** | Sonnet(배경) → bg_html, Sonnet(본심) → core_html, Sonnet(첨부) → sidebar_html, Sonnet(결론) → footer_html. 해당 역할에 꼭지가 없으면 스킵. body_html = bg_html + spacer + core_html | -| **아웃풋** | `generated_html` — body_html, sidebar_html, footer_html | -| **프롬프트에 포함되는 것** | 서브 컨테이너 레이아웃 제약, 디자인 레퍼런스 HTML (블록 CSS 참고), Kei 에스컬레이션 결정, 종속 꼭지 처리 지시, 보충 블록 지시, 강조 문장, bold 키워드, 폰트/컨테이너 크기 제약 | -| **연계** | → Stage 3이 영역별 HTML을 슬라이드 프레임에 배치 | +### Type A 경로 -### Stage 3: 렌더링 조립 + 후처리 +Type A는 현재도 살아 있지만, Type B만큼 단단하게 닫힌 상태는 아닙니다. -| 항목 | 내용 | -|------|------| -| **목적** | 생성된 HTML 조각을 CSS Grid 슬라이드 프레임에 삽입 + 후처리 (폰트 캡핑, overflow 제거, sidebar width 조정, bold 변환) | -| **적용기술** | 코드 (`render_slide_from_html()`) | -| **인풋** | generated_html, preset (grid_areas, grid_columns), font_hierarchy, container_ratio | -| **아웃풋** | `rendered_html` → `final.html` 파일 저장 | -| **연계** | → Stage 4가 렌더링 결과를 측정+검증 | +- AI 생성 비중이 더 큼 +- `src/renderer.py` 의존도가 더 큼 +- sidebar/reference 구조를 포함하는 쪽에서 의미가 큼 -### Stage 4: 품질 검증 +## schema -> recipe -> block -| 항목 | 내용 | -|------|------| -| **목적** | Selenium으로 실제 브라우저 렌더링 후 overflow 측정 + Opus Vision으로 시각적 품질 평가 | -| **적용기술** | Selenium (`measure_rendered_heights()`) + Claude Opus Vision (`vision_quality_gate()`) | -| **인풋** | rendered_html | -| **아웃풋** | `measurement` (zone별 clientHeight, scrollHeight, overflow, excess_px), `quality_score` | -| **연계** | 파이프라인 완료. overflow 시 경고 포함하여 진행 | +최근 구조화에서 가장 중요한 변화 중 하나는 `schema -> recipe -> block` 레이어입니다. ---- +### schema -## 중간 산출물 +콘텐츠의 의미 구조입니다. -파이프라인 실행마다 `data/runs/{timestamp}/`에 단계별 결과가 저장된다. +예: -### JSON Context (Stage별 누적 상태) -| 파일 | Stage | 내용 | -|------|-------|------| -| `stage_0_context.json` | 0 | normalized (섹션, 팝업, 이미지) | -| `stage_1a_context.json` | 1A | topics, page_structure | -| `stage_1b_context.json` | 1B | topics (source_data 추가) | -| `stage_1_5a_context.json` | 1.5a | font_hierarchy, containers, ratio | -| `stage_1_7_context.json` | 1.7 | references (블록 선택 결과) | -| `stage_1_8_context.json` | 1.8 | fit_result, enhancements, sub_layouts | -| `stage_1_5b_context.json` | 1.5b | containers (design_budget 추가) | -| `stage_2_context.json` | 2 | generated_html | -| `stage_3_context.json` | 3 | (rendered_html은 final.html로 별도 저장) | -| `stage_4_context.json` | 4 | measurement, quality_score | -| `final_context.json` | 최종 | 전체 context | +- `parallel_cluster` +- `parallel_cluster_plus_visual` +- `compare_asymmetric_paired` +- `sequence_plus_visual` +- `single_block` -### HTML 시각화 (`steps/` 폴더) -| 파일 | Stage | 내용 | -|------|-------|------| -| `stage_0.html` | 0 | 섹션/팝업/이미지 목록 | -| `stage_1a.html` | 1A | 꼭지 테이블 (purpose, layer, 영역) | -| `stage_1b.html` | 1B | 꼭지 + source_data + summary | -| `stage_1_5a.html` | 1.5a | 빈 컨테이너 (1280×720) | -| `stage_1_5a_content.html` | 1.5a | 컨테이너에 콘텐츠 배치 | -| `stage_1_5b.html` | 1.5b | 디자인 예산 (available height/width) | -| `stage_1_7.html` | 1.7 | 블록 선택 표시 | -| `stage_1_8_fit_before.html` | 1.8 | 적합성 (재배분 전) | -| `stage_1_8_fit_after.html` | 1.8 | 재배분 후 + 보강 | -| `stage_1_8_blocks.html` | 1.8 | SLOT 구조 + 블록 디자인 + 주종관계 (1280×720) | -| `stage_2.html` | 2 | 영역별 Sonnet 출력을 실제 렌더링 (역할별 개별 확인) | -| `stage_3.html` | 3 | 영역을 합쳐 슬라이드 프레임에 배치한 결과 (1280×720 실제 렌더링) | -| `stage_4.html` | 4 | 측정 결과 + 품질 점수 | +### recipe ---- +block 이름이 아니라 표현 규칙입니다. -## 핵심 원칙 +예: -1. **콘텐츠가 구조를 결정** — 블록 CSS는 참고만. AI가 콘텐츠 전달 의도를 보고 HTML 구조 결정 (Phase R') -2. **하드코딩 금지** — font-size 외 모든 수치는 동적 계산. 어떤 MDX가 들어와도 동일하게 동작 -3. **스크롤 절대 금지** — overflow:auto/scroll 어떤 영역에서도 불허 -4. **Kei API 필수** — fallback 없음. 성공할 때까지 무한 재시도 -5. **AI가 옵션 생성, Kei가 결정** — 공간 부족 시 하드코딩 대응이 아니라 Kei 판단 요청 -6. **계산 먼저, AI 판단 나중에, 렌더링은 검증만** -7. **overflow 상태에서 출력 금지** — Vision 모델 품질 게이트 통과 필수 +- `single_block` +- `two_col_text_visual` +- `stacked_summary_detail` ---- +recipe는 이런 계약을 가질 수 있습니다. -## 블록 라이브러리 (38개) +- left/right kind +- top/bottom kind +- ratio +- vertical align -6개 카테고리, 38개 블록. 각 블록은 `catalog.yaml`에 용도(when), 금지(not_for), purpose_fit, schema(슬롯 정의)가 있음. +### block -| 카테고리 | 개수 | 용도 | -|---------|------|------| -| **headers** | 5 | 타이틀, 꼭지 헤더 | -| **cards** | 9 | 항목 나열, 카드 그리드 | -| **tables** | 3 | 비교표, 데이터 테이블 | -| **visuals** | 6 | SVG 다이어그램, 관계도 | -| **emphasis** | 10 | 강조, 인용, 결론, 불릿 | -| **media** | 5 | 이미지/사진 | +실제 구현 템플릿 후보입니다. ---- +예: -## 기술 스택 +- `prerequisites-3col` +- `process-product-2col` +- `compare-detail-gradient` +- `card-icon-desc` -| 역할 | 도구 | -|------|------| -| 서버 | FastAPI + uvicorn (포트 8001) | -| AI (Kei 실장/편집자) | Kei API → Opus (localhost:8000) | -| AI (HTML 생성) | Anthropic API → Claude Sonnet | -| AI (품질 검증) | Anthropic API → Claude Opus Vision | -| 블록 검색 | FAISS + bge-m3 | -| 템플릿 | Jinja2 (블록 디자인 레퍼런스용) | -| 렌더링 | CSS Grid + 디자인 토큰 (1280×720) | -| 렌더링 측정 | Selenium headless Chrome | -| SVG 시각화 | svg_calculator.py (N개 동적 배치) | -| 이미지 | Pillow (크기 측정) + base64 인라인 | -| 폰트 | Pretendard Variable | -| 공간 계산 | space_allocator.py + fit_verifier.py (결정론적) | +즉, "무슨 문서냐"가 아니라 "무슨 구조냐"를 먼저 읽고, 그 구조에 맞는 표현 규칙을 정한 뒤, 마지막에 구현 블록을 고르는 방향으로 가고 있습니다. ---- +## popup / detail 계약 -## 설치 및 실행 +popup은 지금 다음 철학으로 정리되는 중입니다. -```bash -# 설치 -cd design_agent -pip install -e . +- 메인 슬라이드에는 존 크기에 맞는 요약만 남긴다 +- 큰 표, 시각 컴포넌트, 과다한 bullet은 상세 popup으로 분리한다 +- 메인에서는 `자세히보기` 링크를 제공한다 -# FAISS 인덱스 빌드 (블록 추가/수정 시) -python scripts/build_block_index.py +현재 popup 관련 핵심은 아래입니다. -# .env 설정 -ANTHROPIC_API_KEY=sk-ant-... -KEI_API_URL=http://localhost:8000 -LOG_LEVEL=DEBUG -``` +- `PopupItem` 모델이 도입되어 popup 데이터를 명시적으로 다룸 +- `popup_id`, `popup_file` 생애주기를 분리해 관리 중 +- 최종 목표는 popup 판단을 휴리스틱이 아니라 명시적 contract로 만드는 것 -```bash -# 터미널 1: Kei API (필수) -cd D:\ad-hoc\kei\persona_agent -python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 +다만 아직 일부 구간엔 추측 로직과 이중 관리가 남아 있어, 이 부분은 계속 정리 중입니다. -# 터미널 2: Design Agent -cd D:\ad-hoc\kei\design_agent -python -m uvicorn src.main:app --host 127.0.0.1 --port 8001 --reload -``` +## run 산출물 구조 -접속: http://localhost:8001 +각 실행은 `data/runs/{run_id}/` 아래에 저장됩니다. ---- +주요 파일은 다음과 같습니다. -## 개선 이력 +- `final.html` +- `final_context.json` +- `steps/*.html` +- popup/detail html -| Phase | 내용 | 상태 | -|-------|------|------| -| A~D | 슬라이드 품질 핵심 | 완료 | -| G~N | Kei API, 스토리라인, 정합성, 블록 선택, 비중, 측정 | 완료 | -| O | 컨테이너 기반 레이아웃 | 완료 | -| P | 다후보 렌더링 비교 | 완료 (20/100점 → 방향 전환) | -| Q | 제약 기반 블록 선택 | 완료 | -| R | 하이브리드 블록 (실패 — P=Q=R 동일 구조) | 실패 | -| R' | 블록 CSS 참고 + AI 구조 결정 | 설계 확정 | -| S | 검증 합격 프롬프트 + Claude HTML 생성 | 설계 확정 | -| T | 11-Stage 파이프라인 + 디자인 레퍼런스 | 완료 (31/31 통과) | -| V | 적합성 검증 + Kei 에스컬레이션 + 서브 컨테이너 | 완료 | -| W | Stage 2 출력 품질 수정 (6건) | 진행 중 | +### `final.html` ---- +- 최종 렌더 결과 +- 실제 눈으로 보는 산출물 -## Kei Persona와의 관계 +### `final_context.json` -``` -Kei Persona Agent (localhost:8000) - ├── Opus + RAG + 세션 컨텍스트 - ├── 도메인 지식 (건설/DX/BIM) - └── 대화/생성/피드백/실행 모드 +- 각 단계 결과를 최종 context 형태로 저장 +- block 선택, page_structure, measurement, quality_score 등을 확인할 때 가장 중요 + +### `steps/*.html` + +- 단계별 디버그/설명용 보드 +- 현재는 검토용으로 유용하지만, 일부 인코딩/설명 품질은 더 다듬을 필요가 있습니다 + +## 자주 봐야 하는 파일 + +### 파이프라인 핵심 + +- [src/pipeline.py](src/pipeline.py) +- [src/pipeline_context.py](src/pipeline_context.py) +- [src/section_parser.py](src/section_parser.py) +- [src/block_reference.py](src/block_reference.py) +- [src/block_assembler.py](src/block_assembler.py) +- [src/space_allocator.py](src/space_allocator.py) + +### 템플릿 / 카탈로그 + +- [templates/catalog.yaml](templates/catalog.yaml) +- [templates/blocks/new/prerequisites-3col.html](templates/blocks/new/prerequisites-3col.html) +- [templates/blocks/redesign/process-product-2col.html](templates/blocks/redesign/process-product-2col.html) +- [templates/blocks/cards/compare-detail-gradient.html](templates/blocks/cards/compare-detail-gradient.html) +- [templates/blocks/slide-base.html](templates/blocks/slide-base.html) + 주의: 현재 삭제/legacy 정리 여부를 별도로 확인해야 하는 경로입니다. + +### 검증 / 측정 + +- [src/slide_measurer.py](src/slide_measurer.py) +- [src/validators.py](src/validators.py) + +### 역사 / 계획 문서 + +- [PIPELINE.md](PIPELINE.md) +- [docs/history/PHASE-Y-PLAN.md](docs/history/PHASE-Y-PLAN.md) +- [ARCHITECTURE_OVERVIEW.md](ARCHITECTURE_OVERVIEW.md) + +## 현재 상태 요약 + +### 잘 닫혀가는 것 + +- Type B 메인 경로 +- `normalized.sections` 기반 구조 해석 +- schema / recipe 기반 block selection +- `prerequisites-3col`, `process-product-2col` 같은 redesign 블록 자산화 +- popup/detail 2단 표현 계약의 초안 연결 + +### 아직 정리 중인 것 + +- Type A 전체 안정화 +- popup을 완전한 source of truth로 정리 +- `tag_match` 와 `schema_match`의 완전한 동등 점수 비교 +- step 보드 인코딩/설명 품질 +- legacy 경로와 문서의 정리 + +## 읽는 방법 추천 + +프로세스를 빠르게 파악하려면 아래 순서가 좋습니다. + +1. 이 `README.md` +2. [src/pipeline.py](src/pipeline.py) +3. [src/section_parser.py](src/section_parser.py) +4. [src/block_assembler.py](src/block_assembler.py) +5. 최근 run의 `final_context.json` + +히스토리까지 보려면 아래 문서를 이어서 보면 좋습니다. + +- [PIPELINE.md](PIPELINE.md) +- [docs/history/PHASE-Y-PLAN.md](docs/history/PHASE-Y-PLAN.md) -Design Agent (localhost:8001, 이 프로젝트) - ├── 슬라이드 생성 전용 - ├── Kei API로 꼭지 추출(1A) + 컨셉 구체화(1B) + 에스컬레이션(1.8) 호출 - ├── Sonnet으로 HTML 생성(Stage 2) - ├── Opus Vision으로 품질 검증(Stage 4) - └── 두 프로젝트는 독립. 코드 공유 없음. API 연동만. -``` diff --git a/docs/history/PHASE-Y-PLAN.md b/docs/history/PHASE-Y-PLAN.md new file mode 100644 index 0000000..4b586dd --- /dev/null +++ b/docs/history/PHASE-Y-PLAN.md @@ -0,0 +1,611 @@ +# Phase Y: slide-base 기반 블록 조립 파이프라인 재설계 + +> **작성일:** 2026-04-14 +> **목적:** assembler를 slide-base.html 기반으로 재작성. 블록 선택 → 배치 → 측정 → 조정 루프 완성. +> **근거:** Phase X-BX까지 assembler가 slide-base를 무시하고 HTML을 처음부터 생성. 블록 선택(1.7)이 조립(Stage 2)에 반영 안 됨. Stage 4 품질 점수 거짓말. 전체 파이프라인 연결 끊김. + +--- + +## 핵심 전환 + +``` +[이전] assembler가 HTML 전체를 하드코딩 생성. 블록 무시. slide-base 미사용. +[이후] slide-base.html 위에 tag 매칭된 블록을 배치. 사전계산 + 실측 조정 루프. +``` + +--- + +## 전체 흐름 (2026-04-15 재설계) + +``` +[1] slide-base.html 로드 + ├── title → .slide-title + ├── 핵심요약(:::note) → .slide-footer (footer_text) + └── .slide-body (590px 가용) → 영역(zone)들이 여기에 배치됨 + +[2] Stage 1A: Kei 꼭지 추출 (영역 없이, 꼭지만) + ├── Kei에게 zone/영역 판단을 시키지 않음 + ├── 꼭지별 title, purpose, layer, relation_type만 추출 + └── 핵심요약은 conclusion_text로 분리 + +[3] 코드: MDX ## 파싱 → 꼭지-대목차 매핑 + ├── MDX에서 ## 대목차 목록 추출 (## 없는 도입부도 포함) + ├── 각 꼭지의 source_data/title이 어느 ## 아래에 속하는지 매핑 + └── 대목차별 꼭지 묶음 생성 + +[4] 코드: 대목차별 묶음으로 블록 tag 매칭 시도 (영역 확정 단계) + ├── 묶음별 item_count + 꼭지 title → catalog tag 검색 + ├── 매칭됨 → 이 묶음 = 하나의 영역 (코드가 확정, 블록도 확정) + └── 매칭 안 됨 → Kei에게 이 꼭지들의 영역 판단 요청 + ├── "이 꼭지들을 어떻게 묶을지?" + ├── "sidebar로 뺄 것이 있는지?" → Type A 결정 + └── Kei는 주어진 꼭지 목록 안에서만 판단 (새 영역 이름 만들지 않음) + +[5] 영역 확정 + 콘텐츠 소스 결정 + ├── 영역 제목 = MDX 원본 ## 제목 그대로 + ├── 영역 콘텐츠 = normalized.sections에서 MDX 원본 텍스트 + ├── Kei structured_text가 아님 (Kei는 구조 판단만) + └── sidebar 지정된 영역 → Type A / 나머지 → Type B + +[6] 사전 계산: 영역별 비중 → 대략적 px 배정 + └── 블록 후보 필터링 (너무 큰 블록 제외) + +[7] .slide-body에 블록 배치 + 실측 조정 루프 + ├── 블록 템플릿 Jinja2 렌더링 (슬롯에 MDX 원본 텍스트 삽입) + ├── slide-base + 블록 HTML 조합 + ├── Selenium 측정 (measure_mode: overflow:auto) → overflow 확인 + ├── overflow 시: + │ ├── ① font/padding 조정 (CSS 변수, 코드) + │ ├── ② 간격 축소 + │ ├── ③ 텍스트 압축 (최후 수단, AI 1회) + │ └── 재측정 (최대 3회 루프) + └── overflow 없음 → 확정 + +[8] 최종 HTML 출력 + └── slide-base + 확정된 블록들 = final.html (overflow:hidden) + +[9] 품질 검증 + ├── Selenium overflow 측정 + ├── 비전 모델 평가 (가능할 때만) + └── 미평가 시 -1 (거짓말 안 함) +``` + +### 핵심 변경 (이전 대비) + +| 항목 | 이전 (문제) | 이후 (재설계) | +|------|-----------|-------------| +| 영역/zone 결정 | Kei가 zone 구조를 만듦 → 매번 다름, 오인 | 코드가 ##파싱 + 블록매칭으로 먼저 확정. 안 되는 것만 Kei | +| 콘텐츠 소스 | Kei structured_text (재구성) | MDX 원본 (normalized.sections) | +| 영역 제목 | Kei가 축약 ("필수요건") | MDX 원본 ## 제목 그대로 | +| Kei 역할 | 꼭지+영역+zone+텍스트 전부 | 꼭지 추출 + 성격 판단(sidebar/팝업)만 | +| 블록 매칭 시점 | 영역 확정 후 | 영역 확정 전 (블록이 영역을 결정) | + +--- + +## 태스크 목록 + +### Y-1: Kei 프롬프트 — zone = 대목차 (완료) +- [x] zone = `##` 대목차 단위, `###` 소목차 = zone 안 블록 +- [x] 2단계 판단: 먼저 zone 잡고 → 용어정의 있으면 Type A +- [x] 결론/핵심요약 = slide-base footer (별도 zone 아님) +- [ ] Kei 프롬프트에서 결론 zone 제거 (page_structure에서 제외) + +### Y-2: space_allocator — zone=bottom 지원 +- [ ] `bottom` zone 전체폭 처리 추가 +- [ ] Type A/B에 따라 .slide-body 내 레이아웃 분기 +- [ ] 결론 zone 미생성 (slide-base footer로 처리) + +### Y-3: block_reference — tag 매칭 강화 +- [x] tag 기반 0순위 매칭 추가 +- [x] item_count 범위("2-3") 처리 +- [ ] content_pattern 가중치 추가 (item_count만으로 매칭 방지) +- [ ] 결론 role 매칭 제외 (slide-base가 처리) + +### Y-4: assembler 재작성 — slide-base 기반 +- [ ] slide-base.html 로드 → Jinja2 렌더링 +- [ ] title, footer_text(핵심요약) 삽입 +- [ ] .slide-body에 zone별 블록 HTML 삽입 +- [ ] render_block_for_role()로 블록 렌더링 +- [ ] 블록 CSS를 slide-base diff --git a/src/block_assembler.py b/src/block_assembler.py index f228913..a34a86a 100644 --- a/src/block_assembler.py +++ b/src/block_assembler.py @@ -363,22 +363,1424 @@ def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role ) +def render_block_for_role(role: str, ctx: "PipelineContext") -> tuple[str, str]: + """block_id → 템플릿 로드 → 슬롯에 콘텐츠 채우기 → (html, css) 반환. + + Stage 1.7에서 선택된 block_id를 실제로 사용하여 렌더링. + 매칭 실패 시 빈 문자열 반환 (fallback은 호출측에서). + """ + from pathlib import Path + import yaml + + refs = ctx.references.get(role, []) + if not refs: + return "", "" + + block_id = refs[0].block_id + if not block_id: + return "", "" + + # catalog.yaml에서 블록 정보 로드 + catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" + try: + catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) + except Exception: + logger.warning(f"[assembler] catalog.yaml 로드 실패") + return "", "" + + blocks = catalog.get("blocks", catalog) if isinstance(catalog, dict) else catalog + if not isinstance(blocks, list): + return "", "" + + entry = next((b for b in blocks if b.get("id") == block_id), None) + if not entry: + logger.warning(f"[assembler] block_id={block_id} catalog에 없음") + return "", "" + + template_path = entry.get("template", "") + if not template_path: + return "", "" + + # Jinja2 렌더링 + from jinja2 import Environment, FileSystemLoader + templates_dir = Path(__file__).parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(templates_dir))) + + try: + template = env.get_template(template_path) + except Exception as e: + logger.warning(f"[assembler] 템플릿 로드 실패: {template_path} — {e}") + return "", "" + + # 역할에 배정된 topic들의 structured_text → 슬롯 데이터 구성 + ps_info = ctx.page_structure.roles.get(role, {}) + topic_ids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else [] + topic_map = {t.id: t for t in ctx.topics} + + slot_data = _build_slot_data(block_id, entry, topic_ids, topic_map, ctx) + + # Y-11h: payload contract 검증 + contract_errors = _validate_payload_contract(block_id, slot_data, ctx) + if contract_errors: + for err in contract_errors: + logger.warning(f"[payload contract] {block_id}: {err}") + + try: + rendered = template.render(**slot_data) + except Exception as e: + logger.warning(f"[assembler] 렌더링 실패: {block_id} — {e}") + return "", "" + + # CSS 추출 + css_parts = re.findall(r'', rendered, re.DOTALL) + css = "\n".join(css_parts) + html = re.sub(r'', '', rendered, flags=re.DOTALL).strip() + + logger.info(f"[assembler] {role} → {block_id} 블록 렌더링 성공 ({len(html)} chars)") + return html, css + + +def _build_slot_data( + block_id: str, entry: dict, + topic_ids: list, topic_map: dict, + ctx: "PipelineContext", +) -> dict: + """블록 스키마에 맞게 데이터를 슬롯으로 변환. + + Phase Y: slot 구성 = normalized.sections의 sub_titles 기반. + Kei topic 수에 의존하지 않음. + sub_title 1개 = column/card 1개 (slot 1개). + """ + # page_structure에서 이 role의 sub_titles 가져오기 + ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {} + # role_name 찾기: topic_ids가 매칭되는 role + role_sub_titles = [] + for role_name, info in ps.items(): + if isinstance(info, dict) and info.get("topic_ids") == list(topic_ids): + role_sub_titles = info.get("sub_titles", []) + break + + # mdx_sections에서 각 sub_title의 content 가져오기 + mdx_sections = ctx.mdx_sections if hasattr(ctx, 'mdx_sections') else [] + norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] + if norm_sections and hasattr(norm_sections[0], 'model_dump'): + norm_sections = [s.model_dump() if hasattr(s, 'model_dump') else dict(s) for s in norm_sections] + + def _find_sub_content(sub_title: str) -> str: + """normalized.sections에서 sub_title에 해당하는 content를 찾음. + 결론/핵심요약 텍스트는 제외 (footer 전용). + + 매칭 순서: + 1. 섹션 title 직접 매칭 (MDX 03 방식: 각 sub_title이 별도 섹션) + 2. D1: 항목 내 매칭 (MDX 02 방식: 하나의 섹션 안에 D1: 항목들) + """ + sub_key = sub_title.split("(")[0].strip().lower() + # 결론 텍스트 (footer에만 가야 함) + conclusion = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else "" + + # 1차: 섹션 title 매칭 + for sec in norm_sections: + sec_title = sec.get("title", "").lower() + if sub_key and len(sub_key) >= 2 and sub_key in sec_title: + content = sec.get("content", "") + if conclusion and conclusion in content: + content = content.replace(conclusion, "").strip() + content = re.sub(r'\[핵심요약:[^\]]*\]', '', content).strip() + return content + + # 2차: D1: 항목 내 매칭 — sub_title이 D1: 항목명인 경우 + # 하나의 섹션 content 안에 여러 D1: 항목이 있을 때 + if sub_key and len(sub_key) >= 2: + for sec in norm_sections: + content = sec.get("content", "") + if not content: + continue + # D1: 항목으로 분할 + lines = content.split("\n") + capturing = False + captured = [] + 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 # 다음 D1을 만나면 캡처 종료 + if sub_key in d1_text: + capturing = True + captured.append(line.strip()) + elif capturing: + stripped = line.strip() + if stripped and not stripped.startswith("!["): + captured.append(stripped) + if captured: + result = "\n".join(captured) + if conclusion and conclusion in result: + result = result.replace(conclusion, "").strip() + result = re.sub(r'\[핵심요약:[^\]]*\]', '', result).strip() + logger.info(f"[_find_sub_content] D1 fallback 매칭: '{sub_title}' → sec '{sec.get('title','')}' ({len(captured)}줄)") + return result + + logger.warning(f"[_find_sub_content] 매칭 실패: '{sub_title}' (섹션 title, D1 항목 모두 불일치)") + return "" + + # slot 구성: sub_titles 기반 (Kei topic 수와 무관) + slot_sources = role_sub_titles if role_sub_titles else [t.title for t in (topic_map.get(tid) for tid in topic_ids) if t] + + # Y-14: popup 대상 감지 (normalized.popups에 is_component가 있는 것) + # popup_id 기반 판단 (추측 로직 제거, PopupItem이 source of truth) + popup_ids_by_title = {} + if hasattr(ctx, 'normalized') and ctx.normalized.popups: + for p in ctx.normalized.popups: + pid = p.popup_id if hasattr(p, 'popup_id') else "" + ptitle = p.title if hasattr(p, 'title') else "" + if pid: + popup_ids_by_title[ptitle] = pid + + topic_slots = [] + for slot_title in slot_sources: + content = _find_sub_content(slot_title) + + if not content.strip(): + # content 비어있음 → popup_id로 확인 + slot_key = slot_title.split("(")[0].strip() + matched_popup_id = "" + for pt, pid in popup_ids_by_title.items(): + if slot_key in pt: + matched_popup_id = pid + break + if matched_popup_id: + items = [{"heading": f"{slot_title}", "desc": "", "bullets": ["자세한 내용은 첨부 자료를 참조하세요."]}] + else: + items = [{"heading": f"{slot_title}", "desc": "", "bullets": []}] + else: + items = _parse_topic_to_items(content) + name = slot_title.split("(")[0].strip() if slot_title else "" + sub = slot_title.split("(")[1].rstrip(")").strip() if "(" in slot_title else "" + topic_slots.append({ + "name": name, + "sub": sub, + "title": slot_title, + "items": items, + "raw_text": content, + }) + + # columns[] 구성 — 색상은 블록 템플릿 CSS가 :nth-child로 자체 관리 + columns = [] + for i, ts in enumerate(topic_slots): + columns.append({ + "name": ts["name"], + "sub": ts["sub"], + "entries": ts["items"], + "items": ts["items"], + }) + + # cards[] 구성 (card-text-grid, card-icon-desc 등) + # card-icon-desc 템플릿: card.title, card.description, card.icon + cards = [] + for ts in topic_slots: + bullets = [item["desc"] for item in ts["items"] if item["desc"]] + # description: items의 desc/bullets를 합쳐서 구성 + desc_parts = [] + for item in ts["items"]: + if item.get("bullets"): + desc_parts.extend(item["bullets"]) + elif item.get("desc"): + desc_parts.append(item["desc"]) + description = "\n".join(f"• {d}" for d in desc_parts) if desc_parts else "" + cards.append({ + "title": ts["name"] or ts["title"], + "description": description, + "content": "\n".join(f"• {item['heading']}" for item in ts["items"]), + "bullets": bullets, + "items": ts["items"], + }) + + # 범용 데이터 + data = { + "title": topic_slots[0]["title"] if topic_slots else "", + "columns": columns, + "cards": cards, + "items": [item for ts in topic_slots for item in ts["items"]], + } + + # 2분할 블록 payload (pp2 / cdg 공용) + if len(topic_slots) == 2: + left_slot = topic_slots[0] + right_slot = topic_slots[1] + + # pp2 payload: left_title, right_title, left_compare, left_sections, right_sections + data["left_title"] = left_slot["title"] + data["right_title"] = right_slot["title"] + + # left_compare: 첫 번째 항목이 표 구조(As-is→To-be)인 경우 + # D1 only 평탄화된 표를 복원: 연속 heading 3개씩 = As-is 3개 → To-be 3개 + left_items = left_slot["items"] + left_compare = None + left_sections = [] + + # 표 구조 감지: normalized.tables에서 가져오기 + tables = ctx.normalized.tables or [] + if tables and len(tables) > 0: + table = tables[0] + headers = table.get("headers", []) + rows = table.get("rows", []) + if len(headers) >= 2 and rows: + # 표의 첫 열 = left_items (As-is), 마지막 열 = right_items (To-be) + compare_title = left_items[0]["heading"] if left_items else "" + left_compare = { + "title": compare_title, + "left_items": [re.sub(r'\*+', '', str(row[0])).strip() for row in rows if len(row) > 0], + "right_items": [re.sub(r'\*+', '', str(row[-1])).strip() for row in rows if len(row) > 1], + } + # Y-12b: compare에 사용된 모든 텍스트를 수집 → left_sections에서 제외 + compare_used = set() + # compare 제목 + compare_used.add(compare_title.lower().strip()) + # 표 헤더 (As-is [Analogue], 구분, To-be [Digital]) + for h in headers: + compare_used.add(str(h).strip("* ").lower()) + # 표 셀 내용 (개념·문서·행정 절차 중심, 시각화된 목적물 등) + for row in rows: + for cell in row: + compare_used.add(str(cell).strip("* ").lower()) + # 화살표 기호 + compare_used.add("➠") + compare_used.add("→") + + # 나머지 항목 → left_sections (표 관련 전부 제외) + for item in left_items[1:]: + heading_lower = item["heading"].lower().strip() + # 표에 사용된 텍스트면 스킵 + if heading_lower in compare_used: + continue + # 부분 매칭도 체크 (표 셀이 heading에 포함되거나 그 반대) + is_table_content = any( + heading_lower in used or used in heading_lower + for used in compare_used if len(used) >= 3 + ) + if is_table_content: + continue + if item["heading"]: + bullets = item.get("bullets", []) or ([d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else []) + left_sections.append({"title": item["heading"], "bullets": bullets}) + else: + # 표 없으면 모든 항목을 left_sections로 + for item in left_items: + bullets = [d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else [] + left_sections.append({"title": item["heading"], "bullets": bullets}) + + data["left_compare"] = left_compare + data["left_sections"] = left_sections + + # right_sections + right_sections = [] + for item in right_slot["items"]: + bullets = item.get("bullets", []) or ([d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else []) + right_sections.append({"title": item["heading"], "bullets": bullets}) + data["right_sections"] = right_sections + + # pp2 paired_rows: 좌/우 sections를 행 단위로 매칭 + # compare 행에서 right[0]을 이미 썼으면 나머지 right는 [1]부터 + right_start = 1 if left_compare and right_sections else 0 + remaining_right = right_sections[right_start:] + max_rows = max(len(left_sections), len(remaining_right)) + paired_rows = [] + # row 0: compare 행에서 right[0] 사용 + if left_compare and right_sections: + paired_rows.append({ + "left": None, # compare가 좌측 담당 + "right": right_sections[0], + }) + for i in range(max_rows): + row = { + "left": left_sections[i] if i < len(left_sections) else None, + "right": remaining_right[i] if i < len(remaining_right) else None, + } + paired_rows.append(row) + data["paired_rows"] = paired_rows + + # cdg 호환: left_header, right_header, sections + data["left_header"] = left_slot["title"] + data["right_header"] = right_slot["title"] + max_items = max(len(left_sections), len(right_sections)) + cdg_sections = [] + for i in range(max_items): + ls = left_sections[i] if i < len(left_sections) else {"title": "", "bullets": []} + rs = right_sections[i] if i < len(right_sections) else {"title": "", "bullets": []} + row = {"left": ls, "right": rs} + # As-is→To-be 표가 있으면 첫 행에 asis/tobe 추가 + if i == 0 and left_compare: + row["left"]["asis"] = left_compare.get("left_items", []) + row["left"]["tobe"] = left_compare.get("right_items", []) + cdg_sections.append(row) + data["sections"] = cdg_sections + + # 텍스트 (단일 메시지 블록용) + if len(topic_slots) == 1: + data["text"] = topic_slots[0].get("raw_text", "") + data["message"] = data["text"] + + return data + + +def _validate_payload_contract(block_id: str, data: dict, ctx) -> list[str]: + """Y-11h: payload contract 검증. 블록에 넣기 전에 필수 데이터 확인.""" + errors = [] + conclusion = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else "" + + # 2분할 블록 공통 + if "pp2" in block_id or "cdg" in block_id or "compare" in block_id: + if not data.get("left_title"): + errors.append("left_title 비어있음") + if not data.get("right_title"): + errors.append("right_title 비어있음") + + # pp2 전용 + if "pp2" in block_id or "process-product" in block_id: + ls = data.get("left_sections", []) + rs = data.get("right_sections", []) + if not ls and not data.get("left_compare"): + errors.append("left_sections와 left_compare 둘 다 비어있음") + if not rs: + errors.append("right_sections 비어있음") + + # 결론이 body payload에 섞여있는지 + if conclusion: + for key in ["left_title", "right_title"]: + if conclusion in str(data.get(key, "")): + errors.append(f"conclusion이 {key}에 섞여있음") + for sec_list in [data.get("left_sections", []), data.get("right_sections", [])]: + for sec in sec_list: + if conclusion in str(sec.get("title", "")) or conclusion in str(sec.get("bullets", [])): + errors.append("conclusion이 sections에 섞여있음") + + return errors + + +def _parse_topic_to_items(st: str) -> list[dict]: + """structured_text → [{heading, desc}] 리스트. + + '• 제목' = heading, ' • 설명' = desc (하위 불릿들 합침). + """ + items = [] + current_heading = "" + current_descs = [] + + for line in st.split("\n"): + stripped = line.strip() + if not stripped: + continue + + # 마크다운 헤더, 이미지 참조는 건너뜀 + if stripped.startswith("### ") or stripped.startswith("## "): + continue + if stripped.startswith("![") or stripped.startswith("[이미지:"): + continue + + # D1: 포맷 (normalized.sections, 1단계 = heading) + d1_match = re.match(r'^D1:\s*(.*)', stripped) + if d1_match: + if current_heading: + items.append({ + "heading": current_heading, + "desc": " / ".join(current_descs) if current_descs else "", + }) + current_heading = re.sub(r'\*\*(.+?)\*\*', r'\1', d1_match.group(1).strip()) + current_descs = [] + continue + + # D2: 포맷 (normalized.sections, 2단계 = desc) + d2_match = re.match(r'^D2:\s*(.*)', stripped) + if d2_match: + desc = re.sub(r'\*\*(.+?)\*\*', r'\1', d2_match.group(1).strip()) + current_descs.append(desc) + continue + + # 기존 • 불릿 (fallback: structured_text 등) + if stripped.startswith("• ") and not line.startswith(" "): + if current_heading: + items.append({ + "heading": current_heading, + "desc": " / ".join(current_descs) if current_descs else "", + }) + current_heading = stripped.lstrip("• ").strip() + current_heading = re.sub(r'\*\*(.+?)\*\*', r'\1', current_heading) + current_descs = [] + elif (stripped.startswith("• ") and line.startswith(" ")) or stripped.startswith("- "): + desc = stripped.lstrip("•- ").strip() + desc = re.sub(r'\*\*(.+?)\*\*', r'\1', desc) + current_descs.append(desc) + elif not stripped.startswith("|") and not stripped.startswith("---"): + if current_heading: + current_descs.append(re.sub(r'\*\*(.+?)\*\*', r'\1', stripped)) + else: + current_heading = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) + + # 마지막 항목 + if current_heading: + items.append({ + "heading": current_heading, + "desc": " / ".join(current_descs) if current_descs else "", + "bullets": list(current_descs), # Y-12a: 불릿 배열도 제공 + }) + + # 이전 항목들에도 bullets 추가 (desc에서 복원) + for item in items: + if "bullets" not in item: + item["bullets"] = [d.strip() for d in item["desc"].split("/") if d.strip()] if item["desc"] else [] + + # 마크다운 잔여 토큰 최종 정리 + for item in items: + item["heading"] = re.sub(r'\*+', '', item["heading"]).strip() + item["desc"] = re.sub(r'\*+', '', item["desc"]).strip() + item["bullets"] = [re.sub(r'\*+', '', b).strip() for b in item["bullets"]] + + # 최소 2개 보장 + while len(items) < 2: + items.append({"heading": "", "desc": "", "bullets": []}) + + return items + + +def _parse_structured_text(st: str) -> list[dict]: + """structured_text를 섹션(제목+불릿) 리스트로 파싱.""" + sections = [] + current = {"title": "", "bullets": []} + + for line in st.split("\n"): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("### "): + if current["title"] or current["bullets"]: + sections.append(current) + current = {"title": stripped.lstrip("# ").strip(), "bullets": []} + elif stripped.startswith("* **") or stripped.startswith("- **"): + bullet = stripped.lstrip("*- ").strip() + current["bullets"].append(bullet) + elif stripped.startswith(" * ") or stripped.startswith(" - "): + bullet = stripped.strip().lstrip("*- ").strip() + if current["bullets"]: + current["bullets"].append(f" {bullet}") + else: + current["bullets"].append(bullet) + elif stripped.startswith("•"): + current["bullets"].append(stripped.lstrip("• ").strip()) + elif not stripped.startswith("|") and not stripped.startswith("---"): + current["bullets"].append(stripped) + + if current["title"] or current["bullets"]: + sections.append(current) + + return sections + + def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str: """전체 슬라이드를 조립하여 HTML 반환. filled, assembled, stage_2 모두 이 함수를 호출. layout_template에 따라 유형 A/B 분기. """ + # Stage 1.8 측정용: 기존 f-string 방식 (zone class 유지 → Selenium 호환) if ctx.analysis.layout_template == "B": return _assemble_slide_html_type_b(ctx, title_text) if ctx.analysis.layout_template == "B'": return _assemble_slide_html_type_b_prime(ctx, title_text) if ctx.analysis.layout_template == "B''": - from src.block_assembler_b2 import _assemble_slide_html_type_b_double_prime - return _assemble_slide_html_type_b_double_prime(ctx, title_text) + return _assemble_slide_html_type_b(ctx, title_text) # B'' 측정도 B 방식 return _assemble_slide_html_type_a(ctx, title_text) +def _select_block_for_recipe( + recipe: dict, blocks_key: str, role_name: str, + role_info: dict, ctx: "PipelineContext", +) -> str | None: + """D-1: recipe 내부 블록 선택 — 점수 기반. + + 점수 항목: + - kind 호환성 (sub_type ↔ 블록 when 조건) + - content density (D1/D2 개수 vs 블록 슬롯) + - description 슬롯 유무 + - visual family suitability + + D-2: 전체 점수 0이면 None (미선택 → recipe direct render). + """ + from pathlib import Path + import yaml + + candidates = recipe.get(blocks_key, recipe.get("blocks", [])) + if not candidates: + logger.info(f"[recipe] {role_name}: 후보 없음 → direct render") + return None + + catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" + try: + catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) + except Exception: + return None + + blocks = catalog.get("blocks", catalog) if isinstance(catalog, dict) else catalog + if not isinstance(blocks, list): + return None + + # sub_types 정보 + sub_types = role_info.get("sub_types", []) if isinstance(role_info, dict) else [] + actual_types = [s.get("sub_type", "") for s in sub_types] + recipe_kind = recipe.get("left_kind", recipe.get("block_kind", "")) + + scored = [] + for cand_id in candidates: + entry = next((b for b in blocks if b.get("id") == cand_id), None) + if not entry or not entry.get("template"): + continue + + score = 0 + + # 1. kind 호환성 + from src.section_parser import KIND_SUBTYPE_COMPAT + compatible_types = KIND_SUBTYPE_COMPAT.get(recipe_kind, []) + if compatible_types: + if any(t in compatible_types for t in actual_types): + score += 5 + else: + score -= 3 # 비호환 감점 + + # 2. content density: text_list에 카드형/체크리스트형 → 강한 감점 (제외 수준) + has_text_list = "text_list_candidate" in actual_types + is_card_type = "card" in cand_id + is_checklist = "checklist" in cand_id or "dark" in cand_id + if has_text_list and (is_card_type or is_checklist): + score -= 10 # 사실상 제외 + + # 3. description 슬롯 유무 + block_slots = entry.get("slots", {}) + has_desc_slot = "description" in str(block_slots) or "desc" in str(block_slots) + if has_text_list and not has_desc_slot: + score -= 2 + + # 4. visual family suitability + has_visual_detail = "visual_detail_candidate" in actual_types + is_dark = "dark" in cand_id or "checklist" in cand_id + if has_visual_detail and is_dark: + score -= 5 # summary/popup에 다크 체크리스트 → 강한 감점 + + scored.append((cand_id, score)) + logger.debug(f"[recipe score] {role_name}: {cand_id}={score}") + + if not scored: + logger.info(f"[recipe] {role_name}: catalog에 후보 없음 → direct render") + return None + + # 최고 점수 선택 + scored.sort(key=lambda x: x[1], reverse=True) + best_id, best_score = scored[0] + + if best_score <= 0: + logger.info(f"[recipe] {role_name}: 최고 점수 {best_score} ≤ 0 → direct render") + return None + + logger.info(f"[recipe] {role_name}: '{best_id}' 선택 (score={best_score})") + return best_id + + +def _render_block_by_id(block_id: str, role_name: str, ctx: "PipelineContext") -> tuple[str, str]: + """block_id로 직접 블록 렌더링. render_block_for_role()의 recipe 버전. + + Stage 1.7 references를 거치지 않고, block_id를 직접 지정하여 렌더링. + """ + from pathlib import Path + import yaml + + if not block_id or block_id == "__needs_recipe__": + return "", "" + + catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" + try: + catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) + except Exception: + return "", "" + + blocks = catalog.get("blocks", catalog) if isinstance(catalog, dict) else catalog + if not isinstance(blocks, list): + return "", "" + + entry = next((b for b in blocks if b.get("id") == block_id), None) + if not entry: + logger.warning(f"[recipe render] block_id={block_id} catalog에 없음") + return "", "" + + template_path = entry.get("template", "") + if not template_path: + return "", "" + + from jinja2 import Environment, FileSystemLoader + templates_dir = Path(__file__).parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(templates_dir))) + + try: + template = env.get_template(template_path) + except Exception as e: + logger.warning(f"[recipe render] 템플릿 로드 실패: {template_path} — {e}") + return "", "" + + # 슬롯 데이터 구성 + ps_info = ctx.page_structure.roles.get(role_name, {}) + topic_ids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else [] + topic_map = {t.id: t for t in ctx.topics} + + slot_data = _build_slot_data(block_id, entry, topic_ids, topic_map, ctx) + + try: + html = template.render(**slot_data) + # CSS 추출: ', html) + if style_matches: + css = "\n".join(style_matches) # ', '', html) # html에서 제거 + return html, css + except Exception as e: + logger.warning(f"[recipe render] 렌더링 실패: {block_id} — {e}") + return "", "" + + +def _build_detail_preview(role_name: str, popups: list) -> str: + """structured detail preview — popup source 유형에 따라 preview 생성. + + 일반 규칙: + - 표 → header + 첫 행 preview (축소 테이블) + - 리스트 → first 3 bullets + - 컴포넌트/기타 → summary text + metadata + """ + for p in popups: + target = p.target_role if hasattr(p, 'target_role') else None + if not target or target != role_name: + continue + pcontent = p.content if hasattr(p, 'content') else "" + if not pcontent: + continue + + has_table = "]*>(.*?)', pcontent, re.DOTALL) + total_rows = len(all_rows) + header_rows = [r for r in all_rows if '{r}' + for r in shown_data: + preview += f'{r}' + preview += '' + remaining = len(data_rows) - len(shown_data) + if remaining > 0: + preview += f'
외 {remaining}행
' + preview += '' + return preview + + if has_list: + # 리스트: first 3 bullets + bullets = re.findall(r']*>(.*?)', pcontent, re.DOTALL) + bullets = [re.sub(r'<[^>]+>', '', b).strip() for b in bullets if b.strip()][:3] + if bullets: + preview = '
' + for b in bullets: + preview += f'
• {b[:40]}
' + preview += '
' + return preview + + # 기타: 첫 2문장 summary + text_only = re.sub(r'<[^>]+>', '', pcontent).strip() + sentences = [s.strip() for s in text_only.split('.') if len(s.strip()) > 5][:2] + if sentences: + summary = ". ".join(sentences) + "." + return f'
{summary}
' + + return "" + + +def _recipe_direct_render( + render_kind: str, role_name: str, role_info: dict, ctx: "PipelineContext", + subsection_index: int | None = None, +) -> tuple[str, str]: + """E-1: recipe direct render — 블록 미선택 시 직접 HTML 생성. + + 구조는 새로 짜되, 기존 블록의 visual language를 CSS 변수로 상속. + subsection_index: None이면 전체, 0이면 첫 번째만, 1이면 두 번째만. + """ + # 슬롯 데이터 구성 + ps_info = ctx.page_structure.roles.get(role_name, {}) + all_sub_titles = ps_info.get("sub_titles", []) if isinstance(ps_info, dict) else [] + # subsection_index로 slicing + if subsection_index is not None: + sub_titles = [all_sub_titles[subsection_index]] if subsection_index < len(all_sub_titles) else [] + else: + sub_titles = all_sub_titles + topic_ids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else [] + norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] + if norm_sections and hasattr(norm_sections[0], 'model_dump'): + norm_sections = [s.model_dump() if hasattr(s, 'model_dump') else dict(s) for s in norm_sections] + + # sub_title별 content 수집 + items = [] + for st in sub_titles: + st_key = st.split("(")[0].strip().lower() + content = "" + # 섹션 title 매칭 + for sec in norm_sections: + if st_key and len(st_key) >= 2 and st_key in sec.get("title", "").lower(): + content = sec.get("content", "") + # 이미지 markdown 라인 제거 + content = re.sub(r'^!\[.*?\]\(.*?\)\s*$', '', content, flags=re.MULTILINE) + content = re.sub(r'^\[이미지:.*?\]\s*$', '', content, flags=re.MULTILINE) + content = content.strip() + break + # D1 항목 내 매칭 fallback + if not content: + for sec in norm_sections: + sec_content = sec.get("content", "") + lines = sec_content.split("\n") + capturing = False + captured = [] + 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 st_key in d1_text: + capturing = True + captured.append(line.strip()) + elif capturing: + if line.strip(): + captured.append(line.strip()) + if captured: + content = "\n".join(captured) + break + + parsed = _parse_topic_to_items(content) if content else [] + items.append({"title": st, "content": content, "parsed": parsed}) + + # render_kind에 따라 HTML 생성 + if render_kind == "parallel_cards": + # 세로 쌓기 카드 — two_col의 left로 들어갈 때 자연스러운 배치 + # reference 스타일: 다크 배경 + 강조 제목 + 불릿 설명 + cards_html = "" + card_colors = [ + ("linear-gradient(135deg, #1a365d, #2d3748)", "#fbbf24"), + ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#fbbf24"), + ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#fbbf24"), + ("linear-gradient(135deg, #2d3748, #1a365d)", "#fbbf24"), + ] + for i, item in enumerate(items): + bg, title_color = card_colors[i % len(card_colors)] + desc_parts = [] + for p in item["parsed"]: + if p.get("bullets"): + desc_parts.extend(p["bullets"]) + elif p.get("desc"): + desc_parts.append(p["desc"]) + bullets_html = "".join( + f'
• {d}
' for d in desc_parts + ) + cards_html += ( + f'
' + f'
' + f'{item["title"].split("(")[0].strip()}
' + f'{bullets_html}' + f'
\n' + ) + html = f'
{cards_html}
' + + elif render_kind == "text_list": + # heading + bullet — Type B' 검증된 .bul 구조 재사용 + list_html = "" + for item in items: + list_html += f'
{item["title"]}
\n' + for p in item["parsed"]: + heading = p.get("heading", "") + bullets = p.get("bullets", []) + desc = p.get("desc", "") + + if bullets: + if heading: + list_html += f'
• {heading}
\n' + for b in bullets: + list_html += f'
• {b}
\n' + elif desc: + if heading: + list_html += f'
• {heading}
\n' + list_html += f'
• {desc}
\n' + elif heading: + # D1만 있고 D2 없음 → ":"로 제목/설명 분리, .bul 구조 + if ": " in heading: + h_title, h_desc = heading.split(": ", 1) + list_html += f'
{h_title}: {h_desc}
\n' + else: + list_html += f'
• {heading}
\n' + html = f'
{list_html}
' + + elif render_kind == "summary_and_popup": + html = '
자세한 내용은 첨부 자료를 참조하세요.
' + + else: + html = f'
[direct render: {render_kind}]
' + + # E-2: 기존 블록 visual language 상속 CSS + css = """ +/* recipe direct render — 기존 블록 visual language 상속 */ +.rdr-cards-stack { + display: flex; + flex-direction: column; + gap: 4px; + height: 100%; +} +.rdr-card-dark { + border-radius: 4px; + padding: 7px 10px; + flex: 1; + min-height: 0; + overflow: hidden; +} +.rdr-card-dark-title { + font-size: 12px; + font-weight: 700; + margin-bottom: 3px; +} +/* 다크카드 블릿은 .bul 재사용 + inline style로 색상 오버라이드 */ +/* text_list는 기존 .bul (slide_font_css) 재사용 — 별도 CSS 불필요 */ +.rdr-summary { + font-size: 12px; + color: #64748b; + padding: 16px; + background: #f8fafc; + border-radius: 6px; + border: 1px solid #e2e8f0; +} +.rdr-summary-text { + font-size: 12px; + color: #475569; + line-height: 1.7; + padding: 12px 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + margin-top: 8px; +} +.rdr-detail-link-wrap { + text-align: right; + margin-top: 10px; +} +.rdr-detail-link { + font-size: 11px; + color: #64748b; + text-decoration: none; + font-weight: 500; +} +.rdr-detail-link:hover { + color: #2563eb; + text-decoration: underline; +} +.rdr-table-preview { + margin-top: 8px; +} +.rdr-preview-table { + border-collapse: collapse; + width: 100%; + font-size: 9px; + table-layout: fixed; +} +.rdr-preview-table th { + background: #64748b; + color: #fff; + padding: 4px 6px; + font-weight: 600; + text-align: center; + border: 1px solid #475569; + word-break: keep-all; +} +.rdr-preview-table td { + padding: 4px 6px; + border: 1px solid #e2e8f0; + vertical-align: middle; + color: #475569; + line-height: 1.4; + word-break: keep-all; +} +.rdr-preview-table ul { padding-left: 12px; margin: 0; } +.rdr-preview-table li { font-size: 9px; margin-bottom: 1px; } +.rdr-preview-table strong { color: #1e293b; } +.rdr-preview-note { + font-size: 10px; + color: #94a3b8; + margin-top: 4px; + text-align: right; +} +.rdr-list-preview { + margin-top: 8px; + padding: 8px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; +} +""" + logger.info(f"[recipe] {role_name}: direct render '{render_kind}' ({len(items)}개 항목)") + return html, css + + +def _render_visual_anchor(role_name: str, role_info: dict, ctx: "PipelineContext") -> str: + """recipe executor: visual anchor 렌더링. + + visual anchor = 이미지 / 차트 / 컴포넌트 등 텍스트가 아닌 시각 요소. + normalized.images, normalized.popups에서 해당 role에 연결된 visual을 찾아 렌더링. + """ + # 1. 이미지 찾기: normalized.images에서 이 role의 content에 참조된 이미지 + norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] + role_content = "" + for sec in norm_sections: + sec_dict = sec if isinstance(sec, dict) else (sec.model_dump() if hasattr(sec, 'model_dump') else {}) + # role의 sub_titles 중 하나라도 이 섹션에 속하면 content 수집 + for st in role_info.get("sub_titles", []): + st_key = st.split("(")[0].strip().lower() + if st_key and st_key in sec_dict.get("title", "").lower(): + role_content += sec_dict.get("content", "") + # role_name이 섹션 title과 매칭 + if role_name.lower() in sec_dict.get("title", "").lower(): + role_content += sec_dict.get("content", "") + + # 이미지 경로 추출 + import re as _re + img_refs = _re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', role_content) + if not img_refs: + # normalized.images에서 찾기 + for img in (ctx.normalized.images or []): + path = img.get("path", "") + if path: + img_refs.append((img.get("alt", ""), path)) + + if img_refs: + alt, img_path = img_refs[0] + from pathlib import Path + import base64 + base = Path(ctx.base_path) if ctx.base_path else Path(".") + abs_path = base / img_path.lstrip("/") + + # 경로 못 찾으면 파일명으로 재검색 + if not abs_path.exists(): + filename = Path(img_path).name + found = list(base.rglob(filename)) + if found: + abs_path = found[0] + # samples/images/, samples/mdx_batch/ 에서도 검색 + if not abs_path.exists(): + filename = Path(img_path).name + for search_dir in [Path("samples/images"), Path("samples/mdx_batch")]: + if search_dir.exists(): + found = list(search_dir.rglob(filename)) + if found: + abs_path = found[0] + break + + if abs_path.exists(): + data = abs_path.read_bytes() + ext = abs_path.suffix.lstrip(".").lower() + mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "svg": "image/svg+xml"}.get(ext, "image/png") + b64 = base64.b64encode(data).decode() + return ( + f'{alt}' + ) + # 파일 없으면 경로 텍스트로 표시 + return ( + f'
' + f'[이미지: {alt or img_path}]
' + ) + + # visual anchor 없음 — 빈 placeholder + return "" + + +def assemble_slide_html_final(ctx: "PipelineContext", title_text: str = "", measure_mode: bool = False, font_scale: float = 1.0) -> str: + """Phase Y: slide-base.html 기반 블록 조립. + + 1. slide-base.html 로드 + 2. title, footer_text(conclusion_text) 삽입 + 3. .slide-body에 zone별 블록 HTML 배치 + 4. 블록 CSS를 앞에 삽입 (body 안의 은 건드리지 않음) + if extra_css and '' in slide_html: + slide_html = slide_html.replace('', f'\n{extra_css}\n', 1) # 첫 번째만 + + # A-3 safety net: body 안에 ', body_part) + if body_styles: + body_part = re.sub(r'', '', body_part) + extra_body_css = "\n".join(body_styles) + head_part = head_part.replace('', f'\n{extra_body_css}\n', 1) + slide_html = head_part + body_part + logger.info(f"[A-3] body→head style 이동: {len(body_styles)}개") + + # Y-12d: asset packaging — 상대경로 → base64 내장 (self-contained) + slide_html = _embed_slide_assets(slide_html, templates_dir) + + return slide_html + + +def _embed_slide_assets(html: str, templates_dir) -> str: + """Y-12d: slide-base 상대경로 asset을 base64로 내장.""" + import base64 + from pathlib import Path + + svg_dir = templates_dir / "blocks" / "svg" + if not svg_dir.exists(): + return html + + # 치환 대상: src="svg/파일명" 패턴 + def _replace_asset(match): + filename = match.group(1) + filepath = svg_dir / filename + if not filepath.exists(): + return match.group(0) # 파일 없으면 그대로 + + data = filepath.read_bytes() + ext = filepath.suffix.lower() + if ext == ".svg": + mime = "image/svg+xml" + elif ext == ".png": + mime = "image/png" + elif ext in (".jpg", ".jpeg"): + mime = "image/jpeg" + else: + return match.group(0) + + b64 = base64.b64encode(data).decode("ascii") + return f'src="data:{mime};base64,{b64}"' + + html = re.sub(r'src="svg/([^"]+)"', _replace_asset, html) + return html + + +def _assemble_slide_base_fallback(title, conclusion, body_html, extra_css): + """slide-base.html 로드 실패 시 최소한의 슬라이드 HTML.""" + return f""" + + + +
+
{title}
+
{body_html}
+ +
""" + + def _assemble_slide_html_type_a(ctx: "PipelineContext", title_text: str = "") -> str: """유형 A 전체 슬라이드 조립 (기존 코드 그대로).""" from src.fit_verifier import _load_design_tokens diff --git a/src/block_assembler_b2.py b/src/block_assembler_b2.py index 56eb943..9395a50 100644 --- a/src/block_assembler_b2.py +++ b/src/block_assembler_b2.py @@ -1,277 +1,457 @@ -"""유형 B'' 조립 함수 — 참고 이미지 스타일 (border 없음, 색상바+여백으로 구분).""" +"""유형 B'' 조립 함수 — slide-base.html + 블록 템플릿 사용. + +변경 이력: +- 기존: f-string 하드코딩 HTML +- 현재: slide-base.html 래핑 + templates/blocks/ 블록 Jinja2 렌더링 + font_hierarchy 적용 + +원칙: +- 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정) +- 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님) +- 블록은 콘텐츠에 맞게 재구성 (items 수 동적) +""" from __future__ import annotations + +import base64 import re +from pathlib import Path from typing import TYPE_CHECKING +from jinja2 import Environment, FileSystemLoader + if TYPE_CHECKING: from src.pipeline_context import PipelineContext +BLOCKS_DIR = Path("templates/blocks") +SVG_DIR = BLOCKS_DIR / "svg" + +_env = Environment(loader=FileSystemLoader(str(BLOCKS_DIR)), autoescape=False) + + +def _img_b64(filename: str) -> str: + """SVG/PNG → data URI.""" + p = SVG_DIR / filename + if not p.exists(): + return "" + ext = "svg+xml" if filename.endswith(".svg") else "png" + return f"data:image/{ext};base64," + base64.b64encode(p.read_bytes()).decode() + + +def _strip_comments(html: str) -> str: + return re.sub(r'', '', html, flags=re.DOTALL).strip() + + +def _render_slide_base(title: str, body_html: str, footer_text: str) -> str: + """slide-base.html로 래핑. 공통 함수.""" + sb = _strip_comments((BLOCKS_DIR / "slide-base.html").read_text(encoding="utf-8")) + r = sb.replace('{{ title|default("슬라이드") }}', title) + r = r.replace('{{ title|default("슬라이드 제목") }}', title) + r = r.replace('{% block body %}{% endblock %}', body_html) + + pill = _img_b64("pill_scroll.png") + r = r.replace('{% if footer_text %}', '').replace('{% if footer_pill_bg %}', '') + r = r.replace('{{ footer_pill_bg }}', pill).replace('{% else %}', '') + r = r.replace('', '') + li = r.rfind('{% endif %}') + if li > 0: + r = r[:li] + r[li + len('{% endif %}'):] + r = r.replace('{% endif %}', '').replace('{{ footer_text|safe }}', footer_text) + r = r.replace('src="svg/bg_slide_texture.png"', f'src="{_img_b64("bg_slide_texture.png")}"') + r = r.replace('src="svg/line_divider.svg"', f'src="{_img_b64("line_divider.svg")}"') + return r + def _assemble_slide_html_type_b_double_prime(ctx: "PipelineContext", title_text: str = "") -> str: - """유형 B'' - 참고 이미지 스타일. + """유형 B'' — slide-base.html + 블록 템플릿 + font_hierarchy. - border/gradient 박스 없음. 색상 바 + 폰트 크기 + 여백으로 구분. + 블록 선택: PipelineContext.references에서 가져옴. + 콘텐츠: PipelineContext.normalized.sections + structured_text에서 가져옴. + 글씨 크기: font_hierarchy(core/bg/sidebar/key_msg)에서 가져옴. """ - from src.fit_verifier import _load_design_tokens - tokens = _load_design_tokens() - pad = tokens["spacing_page"] - header_h = tokens.get("header_height", 66) - gap_block = tokens["spacing_block"] - gap_small = tokens["spacing_small"] - slide_w = tokens.get("slide_width", 1280) - slide_h = tokens.get("slide_height", 720) - inner_w = slide_w - pad * 2 - - ps = ctx.page_structure.roles - enh = ctx.enhancement_result or {} - bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} font_h = ctx.font_hierarchy - font_size = font_h.core title = title_text or ctx.analysis.title or "" core_message = ctx.analysis.core_message or "" + ps = ctx.page_structure.roles norm_sections = ctx.normalized.sections or [] + norm_tables = ctx.normalized.tables or [] + enh = ctx.enhancement_result or {} + bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} - kei_decisions = enh.get("kei_decisions", []) - popup_roles = set() - for d in kei_decisions: - if d.get("action") == "popup": - popup_roles.add(d.get("role", "")) - - top_role = bottom_left_role = bottom_right_role = footer_role = None + # zone 분류 + zones = {} for role_name, info in ps.items(): - if not isinstance(info, dict): - continue - zone = info.get("zone", "") - if zone == "top": - top_role = (role_name, info) - elif zone == "bottom_left": - bottom_left_role = (role_name, info) - elif zone == "bottom_right": - bottom_right_role = (role_name, info) - elif zone == "footer": - footer_role = (role_name, info) + if isinstance(info, dict): + zones[info.get("zone", "")] = (role_name, info) - footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None - footer_h_px = footer_ci.height_px if footer_ci else 53 - ft_top = slide_h - pad - footer_h_px - top_ci = ctx.containers.get(top_role[0]) if top_role else None - top_h = top_ci.height_px if top_ci else 200 - top_top = pad + header_h + gap_block - bottom_top = top_top + top_h + gap_small - bottom_h = ft_top - gap_block - bottom_top + top_role = zones.get("top") + bl_role = zones.get("bottom_left") + br_role = zones.get("bottom_right") + footer_role = zones.get("footer") - def _bold(text, role): + def _bold(text, role=""): for kw in bold_kw.get(role, []): if kw in text: text = text.replace(kw, f"{kw}") return text - # 색상 (참고 이미지 기반) - bar_colors = ["#2d5016", "#5c3d1a", "#1a365d"] - accent = "#c05621" + # ── 상단: 블록 레퍼런스에서 block_id 확인 → 블록 템플릿 렌더링 ── + top_html = _render_top_zone(ctx, norm_sections, font_h, _bold) - # ── 상단 ── - top_html = "" - if top_role: - rn = top_role[0] - topic_title = "" - top_secs = [] # [(title, [(depth, text)])] - cur_title = "" - cur_items = [] - for s in norm_sections: - if s.get("level") == 3: - break - if not topic_title and s.get("title"): - topic_title = s["title"] - content = s.get("content", "") - if not content: - continue - st = s.get("title", "") - if st and st != topic_title: - if cur_title: - top_secs.append((cur_title, cur_items)) - cur_title = st - cur_items = [] - for line in content.split("\n"): - stripped = line.strip() - if not stripped: - continue - if re.search(r'\[팝업:', stripped): - continue - if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped): - continue - if re.search(r'\[핵심요약:', stripped): + # ── 하단: process-product-2col 또는 블록 레퍼런스 기반 ── + bottom_html = _render_bottom_zone(ctx, norm_sections, norm_tables, font_h, _bold) + + # ── font_hierarchy CSS override ── + font_css = f"""""" + + # ── zone 제목 추출 ── + # 상단: 첫 번째 level=2 (콘텐츠 없는 대제목) + # 하단: level=3 직전의 level=2 (하단 대제목) + top_zone_title = "" + bottom_zone_title = "" + for i, s in enumerate(norm_sections): + if s.get("level") == 2: + if not s.get("content", "").strip(): + # 콘텐츠 없는 level=2 = zone 제목 + # 다음 section이 level=3이면 하단 제목 + if i + 1 < len(norm_sections) and norm_sections[i + 1].get("level") == 3: + bottom_zone_title = s.get("title", "") + elif not top_zone_title: + top_zone_title = s.get("title", "") + + # ── 조립 ── + body = f"""{font_css} +
+
+ {top_zone_title} +
+
{top_html}
+
+
+
+ {bottom_zone_title} +
+
{bottom_html}
+
""" + + footer_text_html = f'{core_message}'.replace( + '기대할 수 있다', '기대할 수 있다' + ) if core_message else "" + + return _render_slide_base(title, body, footer_text_html) + + +def _get_zone_title(sections, level=2, index=0): + """normalized.sections에서 level=N인 제목을 index번째 가져옴.""" + count = 0 + for s in sections: + if s.get("level") == level: + if count == index: + return s.get("title", "") + count += 1 + return "" + + +def _render_top_zone(ctx, sections, font_h, bold_fn): + """상단 zone 렌더링 — normalized sections의 level=2 카테고리를 직접 사용.""" + # 상단 topic_ids에 해당하는 sections 가져오기 + ps = ctx.page_structure.roles + top_zone = None + for role_name, info in ps.items(): + if isinstance(info, dict) and info.get("zone") == "top": + top_zone = (role_name, info) + break + + if not top_zone: + return "
상단 zone 없음
" + + top_topic_ids = top_zone[1].get("topic_ids", []) + topic_map = {t.id: t for t in ctx.topics} + + # 각 topic의 structured_text 또는 normalized section에서 콘텐츠 가져오기 + categories = [] + for tid in top_topic_ids: + topic = topic_map.get(tid) + if not topic: + continue + + cat_name = topic.title or "" + # structured_text 우선, 없으면 normalized sections에서 찾기 + content = topic.structured_text or "" + if not content: + for s in sections: + if s.get("title") == cat_name and s.get("content"): + content = s["content"] break - depth = 0 - dm = re.match(r'^D(\d+):\s*', stripped) - if dm: - depth = int(dm.group(1)) - stripped = re.sub(r'^D\d+:\s*', '', stripped) - stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) - cur_items.append((depth, stripped)) - if cur_title: - top_secs.append((cur_title, cur_items)) - cards = "" - for ci_idx, (st, items) in enumerate(top_secs): - bc = bar_colors[ci_idx % len(bar_colors)] - card = f'
' - card += ( - f'
' - f'{_bold(st, rn)}
' - ) - for depth, text in items: - text = _bold(text, rn) - if depth <= 1 and '' in text: - card += ( - f'
' - f'{text}
' - ) + if not content: + continue + + # D1/D2 마커 기반 파싱 + headings = [] + current_heading = None + for line in content.split("\n"): + stripped = line.strip() + if not stripped: + continue + stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) + + dm = re.match(r'^D(\d+):\s*', stripped) + depth = int(dm.group(1)) if dm else 0 + if dm: + stripped = re.sub(r'^D\d+:\s*', '', stripped) + clean = stripped.lstrip("•- ").strip() + if not clean: + continue + + if depth <= 1 and '' in clean: + current_heading = {"title": clean, "bullets": []} + headings.append(current_heading) + else: + if current_heading: + current_heading["bullets"].append(clean) + elif headings: + headings[-1]["bullets"].append(clean) else: - card += ( - f'
' - f'\u2022 {text}
' - ) - card += '
' - cards += card + headings.append({"title": "", "bullets": [clean]}) - top_html = ( - f'
' - f'
' - f'{_bold(topic_title or rn, rn)}
' - f'
{cards}
' - ) + categories.append({"name": cat_name, "headings": headings}) + import logging + logging.getLogger(__name__).info(f"[B'' top] cat={cat_name}, headings={len(headings)}") - # ── 하단 ── - bottom_title = "" + if not categories: + return "
콘텐츠 없음
" + + # 블록 CSS 가져오기 + p3c_raw = (BLOCKS_DIR / "new" / "prerequisites-3col.html").read_text(encoding="utf-8") + p3c_css = re.search(r'', p3c_raw, re.DOTALL) + css_html = p3c_css.group(0) if p3c_css else "" + + # 동적 열 생성 + bar_gradients = [ + "linear-gradient(180deg, #0D78D0 0%, #023056 100%)", + "linear-gradient(180deg, #FF9A23 0%, #CC5200 100%)", + "linear-gradient(180deg, #39BE49 0%, #23742C 100%)", + "linear-gradient(180deg, #7c3aed 0%, #4c1d95 100%)", + ] + heading_gradients = [ + "linear-gradient(180deg, #0D78D0 0%, #134D7F 100%)", + "linear-gradient(180deg, #CC5200 0%, #883700 100%)", + "linear-gradient(180deg, #39BE49 0%, #1E6328 100%)", + "linear-gradient(180deg, #7c3aed 0%, #5b21b6 100%)", + ] + + cols_html = "" + for ci, cat in enumerate(categories): + # 카테고리명에서 "기술(디지털)" → name="기술", sub="디지털" + name_match = re.match(r'^(.+?)[((](.+?)[))]$', cat["name"]) + if name_match: + name, sub = name_match.group(1), name_match.group(2) + else: + name, sub = cat["name"], "" + + bar = bar_gradients[ci % len(bar_gradients)] + hgrad = heading_gradients[ci % len(heading_gradients)] + + # 항목 HTML — 동적 items 수 + items = cat["headings"] + n = max(len(items), 1) + items_html = "" + for i, item in enumerate(items): + if not item["title"] and not item["bullets"]: + continue + pct_h = int(95 / n) + pct_top = int(3 + i * (95 / n)) + bul = "".join(f'
• {b}
' for b in item["bullets"]) + items_html += f""" +
+
{item['title']}
+
{bul}
+
""" + if i < n - 1 and len(items) > 1: + line_top = pct_top + pct_h + items_html += f'
' + + cols_html += f""" +
+
+
+
{name}
+ {'
' + sub + '
' if sub else ''} +
+ {items_html} +
""" + + return f'
{cols_html}
\n{css_html}' + + +def _render_bottom_zone(ctx, sections, tables, font_h, bold_fn): + """하단 zone 렌더링 — 좌우 2분할, 소제목 행 정렬.""" + # 하단 콘텐츠: level=3인 sections sub_secs = [] - for s in norm_sections: + for s in sections: if s.get("level") == 3: sub_secs.append((s.get("title", ""), s.get("content", ""))) - for s in norm_sections: - if s.get("level") == 2: - idx = norm_sections.index(s) - if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3: - bottom_title = s.get("title", "") - break - norm_tables = ctx.normalized.tables or [] + if not sub_secs: + return "
하단 콘텐츠 없음
" + + # 좌/우 분리 (첫 번째 sub_sec가 좌, 두 번째가 우) + left_title = sub_secs[0][0] if sub_secs else "" + right_title = sub_secs[1][0] if len(sub_secs) > 1 else "" + + # 좌측 소제목+불릿 파싱 + left_items = _parse_sub_content(sub_secs[0][1] if sub_secs else "", tables, bold_fn) + right_items = _parse_sub_content(sub_secs[1][1] if len(sub_secs) > 1 else "", [], bold_fn) + + # 좌우 소제목 행 매칭 + max_rows = max(len(left_items), len(right_items)) + while len(left_items) < max_rows: + left_items.append(("", [])) + while len(right_items) < max_rows: + right_items.append(("", [])) + + # 블록 CSS + pp2_raw = (BLOCKS_DIR / "BEPs" / "process-product-2col.html").read_text(encoding="utf-8") + pp2_css = re.search(r'', pp2_raw, re.DOTALL) + css_html = pp2_css.group(0) if pp2_css else "" + + arrow_uri = _img_b64("arrow_asis_tobe.png") + + # Grid 생성 — 행 높이 동기화 + 전체 열 gradient + rows_html = "" + for i, ((lt, lbullets), (rt, rbullets)) in enumerate(zip(left_items, right_items)): + pad = "3px 16px" if i == 0 else "2px 16px" + + # 좌측 + left_cell = f'
' + if lt: + left_cell += f'
{lt}
' + # 테이블 (As-is → To-be) 이 있으면 첫 번째 행에 삽입 + if i == 0 and tables: + left_cell += _render_compare_table(tables[0], arrow_uri, font_h) + for b in lbullets: + left_cell += f'
• {b}
' + left_cell += '
' + + # 우측 + right_cell = f'
' + if rt: + right_cell += f'
{rt}
' + for b in rbullets: + right_cell += f'
• {b}
' + right_cell += '
' + + rows_html += left_cell + right_cell + + # 헤더 + header_html = f""" +
+ {left_title} +
+
+ {right_title} +
""" + + return f""" +
+
+
+
+ {header_html} + {rows_html} +
+
+{css_html}""" + + +def _parse_sub_content(content, tables, bold_fn): + """하위 콘텐츠를 소제목+불릿 리스트로 파싱.""" + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + items = [] + current_title = "" + current_bullets = [] + + # 테이블 텍스트 (중복 제거용) table_texts = set() - for td in norm_tables: + for td in tables: for h in td.get("headers", []): table_texts.add(h.strip().lstrip("*").rstrip("*")) for row in td.get("rows", []): for c in row: table_texts.add(str(c).strip().lstrip("*").rstrip("*")) - def _render_section(sub_title, sub_content, rn, bar_color, include_table=False): - sub_content = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content) - html = ( - f'
' - f'
{_bold(sub_title, rn)}
' - ) - # 표 - if include_table and norm_tables: - for td in norm_tables: - headers = td.get("headers", []) - rows = td.get("rows", []) - if headers and rows: - col_count = len(headers) - h_cells = "".join( - f'{h}' - for h in headers - ) - r_html = "" - for ri, row in enumerate(rows): - bg = "#f5f5f0" if ri % 2 == 0 else "#fff" - cells = "".join( - f'' - f'{re.sub(r"\\*\\*(.+?)\\*\\*", r"\\1", str(c))}' - for c in row - ) - r_html += f'{cells}' - html += f'{h_cells}{r_html}
' - # 불릿 - for line in sub_content.split("\n"): - stripped = line.strip() - if not stripped: - continue - depth = 1 - dm = re.match(r'^D(\d+):\s*', stripped) - if dm: - depth = int(dm.group(1)) - stripped = re.sub(r'^D\d+:\s*', '', stripped) - clean = stripped.lstrip("- ").lstrip("\u2022 ") - clean_plain = re.sub(r'<[^>]+>', '', clean).strip() - if clean_plain in table_texts or clean_plain == "\u27a0": - continue - if re.search(r'\[핵심요약:', clean): - break - if not clean: - continue - clean = _bold(clean, rn) - if depth == 1 and '' in clean: - html += ( - f'
{clean}
' - ) - else: - html += ( - f'
\u2022 {clean}
' - ) - html += '
' - return html + for line in content.split("\n"): + stripped = line.strip() + if not stripped: + continue - bl_html = "" - if sub_secs and bottom_left_role: - rn = bottom_left_role[0] - bl_html = _render_section(sub_secs[0][0], sub_secs[0][1], rn, bar_colors[0], include_table=True) + # D마커 + dm = re.match(r'^D(\d+):\s*', stripped) + if dm: + stripped = re.sub(r'^D\d+:\s*', '', stripped) - br_html = "" - if bottom_right_role and len(sub_secs) > 1: - rn = bottom_right_role[0] - br_html = _render_section(sub_secs[1][0], sub_secs[1][1], rn, bar_colors[1], include_table=False) + clean = stripped.lstrip("•- ").strip() + clean_plain = re.sub(r'<[^>]+>', '', clean).strip() - # 결론 - footer_html = "" - if footer_role: - rn = footer_role[0] - footer_html = ( - f'
' - f'
{_bold(core_message, rn)}
' - ) + if clean_plain in table_texts or clean_plain == "➠": + continue + if re.search(r'\[핵심요약:', clean): + break + if not clean: + continue - return f""" - -
+ # 소제목 감지 (볼드) + if '' in clean and len(clean) < 80: + if current_title or current_bullets: + items.append((current_title, current_bullets)) + current_title = clean + current_bullets = [] + else: + current_bullets.append(clean) -
{title}
+ if current_title or current_bullets: + items.append((current_title, current_bullets)) -
-{top_html}
+ return items -
-
{_bold(bottom_title, "")}
-
-
-{bl_html}
-
-
-{br_html}
-
- +def _render_compare_table(table_data, arrow_uri, font_h): + """As-is → To-be 비교 테이블 렌더링.""" + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + if not headers or not rows: + return "" -
""" + def _clean_md(text): + """**볼드** 마크다운 제거 — 테이블 셀은 일반 텍스트.""" + return re.sub(r'\*\*(.+?)\*\*', r'\1', str(text)) + + html = '
' + html += '
' + for row in rows: + html += f'
• {_clean_md(row[0])}
' + html += '
' + html += f'
→
' + html += '
' + for row in rows: + val = row[2] if len(row) > 2 else "" + html += f'
• {_clean_md(val)}
' + html += '
' + return html diff --git a/src/block_reference.py b/src/block_reference.py index 24c1ae8..80427fe 100644 --- a/src/block_reference.py +++ b/src/block_reference.py @@ -149,25 +149,128 @@ def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]: DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"} +def _match_by_tags( + catalog: list[dict], + topic_count: int, + topic_titles: list[str], + container_height_px: int, + zone: str, +) -> dict | None: + """catalog의 tags 필드로 콘텐츠 패턴 매칭. + + 매칭 기준 (AND 조건): + 1. item_count가 topic_count와 일치 (필수) + 2. content_example에 topic 제목 키워드 포함 (가산) + 3. container 크기 적합 (감점) + + threshold: item_count 매칭(필수) + content_example 매칭 1개 이상 + """ + if topic_count <= 0: + return None + + scored = [] + + for block in catalog: + tags = block.get("tags", {}) + if not tags: + continue + + item_count_matched = False + content_matched = 0 + + # item_count 매칭 + tag_item_count = tags.get("item_count") + if tag_item_count: + try: + if int(tag_item_count) == topic_count: + item_count_matched = True + except (ValueError, TypeError): + parts = str(tag_item_count).split("-") + if len(parts) == 2: + try: + lo, hi = int(parts[0]), int(parts[1]) + if lo <= topic_count <= hi: + item_count_matched = True + except (ValueError, TypeError): + pass + + if not item_count_matched: + continue # item_count 안 맞으면 스킵 + + # content_example에 topic 제목 키워드 포함되는지 + score = 50 # item_count 매칭 기본점 + content_example = tags.get("content_example", "").lower() + if content_example: + for t in topic_titles: + key = t.split("(")[0].strip().lower() + if key and len(key) >= 2 and key in content_example: + content_matched += 1 + score += 20 + + # source_mdx 매칭 + if tags.get("source_mdx"): + score += 3 + + # Y-11c: shape 특성 가산점 + # redesign 블록 (특화) > 범용 블록 + if block.get("category") == "redesign": + score += 5 + # 비교 표가 있는 블록은 비대칭 구조에서 우선 + if tags.get("has_compare_table"): + score += 5 + + # threshold: item_count 매칭 + content_example 1개 이상 매칭 + if content_matched >= 1: + scored.append((score, block)) + logger.info( + f"[T-3 tag] {block['id']}: score={score} " + f"(content_matched={content_matched}/{len(topic_titles)})" + ) + + if not scored: + return None + + scored.sort(key=lambda x: -x[0]) + return scored[0][1] + + def select_reference_block( relation_type: str, expression_hint: str, container_height_px: int, zone: str = "body", role: str = "", + topic_count: int = 0, + topic_titles: list[str] | None = None, ) -> dict[str, Any]: - """참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback). + """참고 블록 선택 (tag 매칭 → relation_type → fallback). + + 1순위: catalog tags의 content_pattern/item_count로 정확 매칭 + 2순위: relation_type → 카테고리 필터 + 3순위: fallback Returns: { "block_id": str, "variant": str, "visual_type": str, - "catalog_entry": dict, # catalog.yaml의 해당 블록 전체 + "catalog_entry": dict, } """ catalog = _load_catalog() + # ══ 0순위: tag 기반 정확 매칭 ══ + tag_match = _match_by_tags(catalog, topic_count, topic_titles or [], container_height_px, zone) + if tag_match: + logger.info(f"[T-3] tag 매칭 성공: {tag_match['id']} (content_pattern={tag_match.get('tags',{}).get('content_pattern','')})") + variant = "default" + return { + "block_id": tag_match["id"], + "variant": variant, + "visual_type": "tag_match", + "catalog_entry": tag_match, + } + # ── 1차 필터: relation_type → 카테고리 ── allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"]) candidates_1 = [ @@ -462,6 +565,12 @@ def select_and_generate_references( _tokens = _load_design_tokens() gap_between = _tokens["spacing_small"] + # _plus_visual schema는 주종 관계 무시 → recipe executor가 처리 + role_info_for_schema = page_structure.get(role, {}) + role_schema = role_info_for_schema.get("group_schema", "") if isinstance(role_info_for_schema, dict) else "" + if "_plus_visual" in role_schema: + is_hierarchical = False # recipe로 보냄 + if is_hierarchical: # 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택 # 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함 @@ -477,12 +586,17 @@ def select_and_generate_references( relation_type = primary_topic.get("relation_type", "none") expression_hint = primary_topic.get("expression_hint", "") + # tag 매칭용: 이 role에 속한 모든 topic 제목 + all_topic_titles = [topic_map.get(tid, {}).get("title", "") for tid in topic_ids] + selection = select_reference_block( relation_type=relation_type, expression_hint=expression_hint, container_height_px=total_height_px, zone=zone, role=role, + topic_count=len(topic_ids), + topic_titles=all_topic_titles, ) ref_html = generate_design_reference( block_id=selection["block_id"], @@ -507,50 +621,134 @@ def select_and_generate_references( f"주={primary_tid}, 종={supporting_tids}" ) else: - # 동급: 꼭지별 블록 선택 - topic_count = len(topic_ids) - available_for_topics = total_height_px - gap_between * max(0, topic_count - 1) - min_block_height = min( - (b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0), - default=1, - ) - per_topic_height = max(min_block_height, available_for_topics // topic_count) - - role_refs = [] - for tid in topic_ids: - topic = topic_map.get(tid, {}) - relation_type = topic.get("relation_type", "none") - expression_hint = topic.get("expression_hint", "") - - selection = select_reference_block( - relation_type=relation_type, - expression_hint=expression_hint, - container_height_px=per_topic_height, - zone=zone, - role=role, - ) - ref_html = generate_design_reference( - block_id=selection["block_id"], - variant=selection["variant"], - catalog_entry=selection["catalog_entry"], - ) - - schema_info = selection["catalog_entry"].get("schema", {}) - - role_refs.append({ - "block_id": selection["block_id"], - "variant": selection["variant"], - "visual_type": selection["visual_type"], - "schema_info": schema_info, - "design_reference_html": ref_html, - "topic_id": tid, - }) + # Phase Y: sub_titles 기반 블록 매칭 (Kei topic 수에 의존 안 함) + role_refs = [] # 초기화 + role_info = page_structure.get(role, {}) + sub_titles = role_info.get("sub_titles", []) if isinstance(role_info, dict) else [] + slot_count = len(sub_titles) if sub_titles else len(topic_ids) + slot_titles = sub_titles if sub_titles else [topic_map.get(tid, {}).get("title", "") for tid in topic_ids] + # _plus_visual schema는 direct block 선택 금지 → recipe executor가 처리 + group_schema = role_info.get("group_schema", "") if isinstance(role_info, dict) else "" + if "_plus_visual" in group_schema: + from src.section_parser import get_recipe_for_schema + recipe = get_recipe_for_schema(group_schema) + recipe_type = recipe.get("recipe", "") if recipe else "" + role_refs = [{ + "block_id": "__needs_recipe__", + "variant": "default", + "visual_type": "recipe", + "schema_info": {"recipe": recipe_type, "group_schema": group_schema}, + "design_reference_html": "", + "topic_id": topic_ids[0], + "supporting_topic_ids": topic_ids[1:], + "is_hierarchical": True, + }] logger.info( - f"[V-1] {role}/꼭지{tid}: {selection['block_id']} " - f"(visual_type={selection['visual_type']}, variant={selection['variant']}, " - f"budget={per_topic_height}px)" + f"[V-1] {role}: _plus_visual → recipe '{recipe_type}' " + f"(direct block 선택 건너뜀)" ) + else: + # Y-14: tag_match와 schema_match 동등 비교 + zone_tag_match = _match_by_tags( + _load_catalog(), slot_count, slot_titles, + total_height_px, zone, + ) + + # schema_match + zone_schema_match = None + if group_schema: + from src.section_parser import get_candidate_blocks_for_schema + schema_candidates = get_candidate_blocks_for_schema(group_schema) + catalog_all = _load_catalog() + for cand_id in schema_candidates: + cand = next((b for b in catalog_all if b.get("id") == cand_id), None) + if cand: + zone_schema_match = cand + break + + best_match = zone_tag_match or zone_schema_match + if zone_tag_match and zone_schema_match: + best_match = zone_tag_match + match_type = "tag_match" + logger.info(f"[V-1] {role}: tag={zone_tag_match['id']}, schema={zone_schema_match['id']} → tag 우선") + elif zone_tag_match: + match_type = "tag_match" + elif zone_schema_match: + match_type = "schema_match" + best_match = zone_schema_match + else: + match_type = None + + if best_match: + ref_html = generate_design_reference( + block_id=best_match["id"], + variant="default", + catalog_entry=best_match, + ) + schema_info = best_match.get("schema", {}) + role_refs = [{ + "block_id": best_match["id"], + "variant": "default", + "visual_type": match_type or "fallback", + "schema_info": schema_info, + "design_reference_html": ref_html, + "topic_id": topic_ids[0], + "supporting_topic_ids": topic_ids[1:], + "is_hierarchical": True, + }] + logger.info( + f"[V-1] {role}: {match_type} → {best_match['id']} " + f"(topics {topic_ids} → 블록 1개)" + ) + else: + # tag도 schema도 없음 → 기존 fallback: 꼭지별 블록 선택 + if not role_refs: + n_topics = len(topic_ids) + available_for_topics = total_height_px - gap_between * max(0, n_topics - 1) + min_block_height = min( + (b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0), + default=1, + ) + per_topic_height = max(min_block_height, available_for_topics // max(1, n_topics)) + + role_refs = [] + for tid in topic_ids: + topic = topic_map.get(tid, {}) + relation_type = topic.get("relation_type", "none") + expression_hint = topic.get("expression_hint", "") + + selection = select_reference_block( + relation_type=relation_type, + expression_hint=expression_hint, + container_height_px=per_topic_height, + zone=zone, + role=role, + topic_count=1, + topic_titles=[topic.get("title", "")], + ) + ref_html = generate_design_reference( + block_id=selection["block_id"], + variant=selection["variant"], + catalog_entry=selection["catalog_entry"], + ) + + schema_info = selection["catalog_entry"].get("schema", {}) + + role_refs.append({ + "block_id": selection["block_id"], + "variant": selection["variant"], + "visual_type": selection["visual_type"], + "schema_info": schema_info, + "design_reference_html": ref_html, + "topic_id": tid, + }) + + logger.info( + f"[V-1] {role}/꼭지{tid}: {selection['block_id']} " + f"(visual_type={selection['visual_type']}, variant={selection['variant']}, " + f"budget={per_topic_height}px)" + ) references[role] = role_refs diff --git a/src/image_utils.py b/src/image_utils.py index e6d2513..597b5ac 100644 --- a/src/image_utils.py +++ b/src/image_utils.py @@ -59,6 +59,17 @@ def get_image_sizes(content: str, base_path: str) -> list[dict[str, Any]]: abs_path = found[0] logger.info(f"이미지 경로 재탐색 성공: {filename} → {abs_path}") + # samples/images/, samples/mdx_batch/ 에서도 탐색 + if not abs_path.exists(): + filename = Path(rel_path).name + for search_dir in [Path("samples/images"), Path("samples/mdx_batch")]: + if search_dir.exists(): + found = list(search_dir.rglob(filename)) + if found: + abs_path = found[0] + logger.info(f"이미지 경로 확장 탐색 성공: {filename} → {abs_path}") + break + if not abs_path.exists(): logger.warning(f"이미지 파일 미발견: {abs_path}") images.append({ diff --git a/src/kei_client.py b/src/kei_client.py index 28fa0b1..409daec 100644 --- a/src/kei_client.py +++ b/src/kei_client.py @@ -30,33 +30,21 @@ KEI_PROMPT = ( "## 3단계: 슬라이드 스토리라인 설계\n" "핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n" "각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n" - "## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n" - "먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n" - "### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n" - "- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n" - "- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n" - "- page_structure 키: 배경, 본심, 첨부, 결론\n\n" - "### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n" - "- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n" - "- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n" - "- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n" - "- 하단: 세부 내용 2분할 (좌/우)\n" - "- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n" - "- 결론 키는 반드시 '결론'\n\n" - "선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n" - "### 역할별 규칙 (유형 A)\n" - "- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n" - "- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n" - "- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n" - "- **결론**: 핵심 한 줄. footer.\n\n" - "### 역할별 규칙 (유형 B)\n" - "- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n" - "- 하단 좌측: zone: 'bottom_left'\n" - "- 하단 우측: zone: 'bottom_right'\n" - "- 결론: zone: 'footer'\n\n" - "각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n" - "**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n" - "page_structure 필드에 기록.\n\n" + "## 4단계: 꼭지별 성격 판단\n" + "각 꼭지에 대해 다음을 판단하라:\n\n" + "### sidebar 판단\n" + "- 이 꼭지의 내용이 **본문과 독립된 참조 정보**(용어 정의, 개념 비교, 참조 테이블)인가?\n" + "- 독립 참조 → role: 'reference' (sidebar 후보)\n" + "- 본문 흐름의 일부 → role: 'flow'\n\n" + "### 팝업 판단\n" + "-
안에 있는 콘텐츠 → 팝업 처리 대상\n" + "- 너무 세부적인 내용 → 팝업으로 분리 가능\n\n" + "### 핵심요약\n" + "- :::note[핵심 요약] 등의 결론 텍스트가 있으면 **conclusion_text** 필드에 원본 그대로 기록\n" + "- conclusion_text는 슬라이드 하단 footer에 자동 배치됨\n\n" + "**주의: page_structure, zone, 영역 배치는 판단하지 마라.**\n" + "**영역과 zone은 코드가 블록 매칭을 통해 결정한다.**\n" + "**너는 꼭지 추출 + 각 꼭지의 성격(reference/flow, 팝업 여부)만 판단하라.**\n\n" "## 원본 텍스트 보존 원칙 (절대 규칙)\n" "- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n" " 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n" @@ -70,61 +58,76 @@ KEI_PROMPT = ( "## 배치 규칙\n" "- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n" "- 본문 흐름은 role: 'flow' → 메인 영역 배치\n" - "- 결론은 layer: 'conclusion' → 하단 배치\n" + "- 결론/핵심요약은 conclusion_text 필드에 기록. page_structure에 넣지 마라.\n" "- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n" "- 이미지/표가 있으면 images[], tables[]에 기록\n" "- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n" "- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n" "## 출력 형식 (JSON만)\n" - "layout_template에 따라 page_structure가 달라진다.\n\n" - "유형 A 예시:\n" + "**page_structure는 출력하지 마라. 영역/zone 배치는 코드가 결정한다.**\n\n" "```json\n" - '{"title": "제목", ' + '{"title": "슬라이드 제목 (MDX title 또는 전체 주제)", ' '"core_message": "핵심 메시지", ' + '"conclusion_text": "핵심 요약 원본 텍스트 (:::note 등에서 추출. 없으면 빈 문자열)", ' '"total_pages": 1, ' - '"layout_template": "A", ' - '"page_structure": {' - '"본심": {"topic_ids": [2, 3], "weight": 0.60}, ' - '"배경": {"topic_ids": [1], "weight": 0.15}, ' - '"첨부": {"topic_ids": [4], "weight": 0.15}, ' - '"결론": {"topic_ids": [5], "weight": 0.10}}, ' '"topics": [' - '{"id": 1, "title": "꼭지 제목", "summary": "요약", ' + '{"id": 1, "title": "꼭지 제목 (원본 그대로)", "summary": "요약", ' '"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", ' - '"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", ' + '"source_hint": "원본에서 이 꼭지에 해당하는 텍스트 범위 설명", ' '"layer": "intro|core|supporting|conclusion", ' '"role": "flow|reference", ' - '"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", ' '"emphasis": true, "direction": "vertical|horizontal|flexible", ' '"content_type": "text|image|table|mixed", ' '"detail_target": false, "page": 1}], ' '"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], ' '"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n' "```\n\n" - "유형 B 예시:\n" - "```json\n" - '{"title": "제목", ' - '"core_message": "핵심 메시지", ' - '"total_pages": 1, ' - '"layout_template": "B", ' - '"page_structure": {' - '"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.35}, ' - '"프로세스변화": {"zone": "bottom_left", "topic_ids": [2], "weight": 0.25}, ' - '"기대효과": {"zone": "bottom_right", "topic_ids": [3], "weight": 0.25}, ' - '"결론": {"zone": "footer", "topic_ids": [4], "weight": 0.15}}, ' - '"topics": [...],' - '"images": [...]}\n' - "```\n\n" "## 콘텐츠:\n" ) +def _detect_structure_hints(content: str) -> str: + """MDX 구조에서 유형 판단 힌트를 자동 감지.""" + hints = [] + content_lower = content.lower() + + # 용어 정의 섹션 감지 + if re.search(r'##\s*\d*\.?\s*용어\s*정의', content): + hints.append("[구조 힌트] '용어 정의' 섹션 감지 → 유형 A 후보") + if re.search(r'##\s*\d*\.?\s*개념\s*비교', content): + hints.append("[구조 힌트] '개념 비교' 섹션 감지 → 유형 A 후보") + + # sidebar 마크다운 감지 + if 'sidebar:' in content[:200]: + pass # frontmatter의 sidebar는 Starlight 설정이므로 무시 + + #
감지 + if '
' in content: + hints.append("[구조 힌트]
참고 사례 감지 → 팝업 처리 대상 (유형 선택과 무관)") + + # 표 감지 + if '|' in content and '---' in content: + hints.append("[구조 힌트] 표(테이블) 감지 → 비교 구조") + + # 이미지 감지 + if re.search(r'!\[.*?\]\(.*?\)', content): + hints.append("[구조 힌트] 이미지 감지 → 이미지 배치 필요") + + # A 후보 힌트가 없으면 B 유력 + if not any("유형 A 후보" in h for h in hints): + hints.append("[구조 힌트] 독립 참조 섹션 없음 → 유형 B 유력") + + return "\n".join(hints) + "\n\n" + + async def classify_content(content: str) -> dict[str, Any] | None: """1단계: Kei API를 통해 꼭지를 추출하고 분석한다. Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러. """ - result = await _call_kei_api(content) + # MDX 구조 힌트를 content 앞에 추가 + hints = _detect_structure_hints(content) + result = await _call_kei_api(hints + content) if result: logger.info( f"[Kei API] 꼭지 추출 완료: {result.get('title', '')}, " diff --git a/src/pipeline.py b/src/pipeline.py index acfe90e..77f6aa5 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -141,12 +141,23 @@ async def generate_slide( if errors: return {"_errors": errors} + # popup_id 부여 (Stage 0 시점) + from src.pipeline_context import PopupItem + raw_popups = result.get("popups", []) + popup_items = [] + for pi, rp in enumerate(raw_popups, 1): + popup_items.append(PopupItem( + popup_id=f"popup_{pi}", + title=rp.get("title", ""), + content=rp.get("content", ""), + )) + return { "normalized": NormalizedContent( clean_text=result["clean_text"], title=result["title"], images=result["images"], - popups=result["popups"], + popups=popup_items, tables=result["tables"], sections=result["sections"], ), @@ -176,6 +187,7 @@ async def generate_slide( original_title = context.normalized.title or analysis_raw.get("title", "") analysis = Analysis( core_message=analysis_raw.get("core_message", ""), + conclusion_text=analysis_raw.get("conclusion_text", ""), title=original_title, total_pages=analysis_raw.get("total_pages", 1), layout_template=analysis_raw.get("layout_template", "A"), @@ -200,14 +212,197 @@ async def generate_slide( if validation_errors: return {"_errors": validation_errors} + # Phase Y: page_structure는 Kei가 만들지 않음. + # Kei 응답에 page_structure가 있어도 무시. + # 코드가 section_parser + 블록 매칭으로 생성 (Stage 1A 후 별도 단계) return { "analysis": analysis, "topics": topics, - "page_structure": page_structure, + "page_structure": PageStructure(roles={}), # 빈 상태, 아래에서 채움 } ctx = await run_stage(stage_1a, ctx, "stage_1a", max_retries=2) + # ── Phase Y: 영역 확정 (코드: normalized.sections 기반 + 블록 매칭) ── + from src.section_parser import extract_major_sections, extract_conclusion_text, map_topics_to_sections, classify_group_relations, get_candidate_blocks_for_schema, detect_component_popups + from src.block_reference import _match_by_tags, _load_catalog + + # source of truth = normalized.sections (Stage 0 산출물) + norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else [] + if hasattr(norm_sections, '__iter__') and norm_sections: + if hasattr(norm_sections[0], 'model_dump'): + norm_sections = [s.model_dump() for s in norm_sections] + elif not isinstance(norm_sections[0], dict): + norm_sections = [dict(s) for s in norm_sections] + + major_sections = extract_major_sections(norm_sections) + + # popup 대상 sub_title 목록 (컴포넌트 태그가 있던 섹션) + popup_sub_titles = [] + component_tags = re.findall(r'<([A-Z]\w+)\s*/>', ctx.raw_content) + if component_tags: + raw_lines = ctx.raw_content.split("\n") + for tag_name in component_tags: + current_section = "" + for line in raw_lines: + if line.strip().startswith("### "): + current_section = re.sub(r'^#{1,3}\s*\d*\.?\d*\s*', '', line.strip()).strip() + if f"<{tag_name}" in line: + if current_section: + popup_sub_titles.append(current_section) + break + + # Y-13b: group relation 분류 + major_sections = classify_group_relations( + major_sections, normalized_sections=norm_sections, + popup_sub_titles=popup_sub_titles, + ) + + # conclusion_text: raw MDX에서 추출 또는 기존 값 정제 + conclusion_text = ctx.analysis.conclusion_text or "" + if not conclusion_text: + conclusion_text = extract_conclusion_text(ctx.raw_content) + # 선행 불릿 마커 정제 (Kei가 * 포함해서 넣을 수 있음) + if conclusion_text: + conclusion_text = re.sub(r'^[\*•\-]\s*', '', conclusion_text).strip() + conclusion_text = re.sub(r'\*+', '', conclusion_text).strip() + if conclusion_text != (ctx.analysis.conclusion_text or ""): + ctx = ctx.model_copy(update={ + "analysis": ctx.analysis.model_copy(update={"conclusion_text": conclusion_text}), + }) + + # 꼭지-대목차 매핑 + topic_dicts = [t.model_dump() for t in ctx.topics] + section_topic_map = map_topics_to_sections(topic_dicts, major_sections) + + # 대목차별 묶음으로 블록 tag 매칭 → 영역 확정 + # 블록 매칭 기준: Kei topic 수가 아니라 normalized sub_titles 수 + catalog = _load_catalog() + page_struct_roles = {} + zone_names = ["top", "bottom"] + + # major_sections를 title로 빠르게 찾기 + major_sec_map = {s["title"]: s for s in major_sections} + + for i, (sec_title, tids) in enumerate(section_topic_map.items()): + # sub_titles = normalized.sections에서 파싱된 소항목 목록 + sec = major_sec_map.get(sec_title, {}) + sub_titles = sec.get("sub_titles", []) + + # sidebar 판단: 모든 꼭지가 reference면 sidebar + all_reference = all( + t.role == "reference" for t in ctx.topics if t.id in tids + ) + if all_reference: + zone = "sidebar" + elif i < len(zone_names): + zone = zone_names[i] + else: + zone = f"bottom_{i}" + + # 블록 매칭: sub_titles 수 기준 (Kei topic 수 아님) + slot_count = len(sub_titles) if sub_titles else len(tids) + tag_match = _match_by_tags(catalog, slot_count, sub_titles, 300, zone) + if tag_match: + logger.info(f"[Phase Y] '{sec_title}' → 블록 {tag_match['id']} (tag_match) 확정") + else: + # Y-13d: tag 매칭 실패 → group schema 후보로 블록 찾기 + group_schema = sec.get("group_schema", "") + schema_candidates = get_candidate_blocks_for_schema(group_schema) + if schema_candidates: + # catalog에서 첫 번째 존재하는 후보 선택 + for cand_id in schema_candidates: + cand = next((b for b in catalog if b.get("id") == cand_id), None) + if cand: + tag_match = cand # schema 기반 선택 + logger.info(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 {cand_id}") + break + if not tag_match: + logger.warning(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 매칭 실패") + + # weight: 콘텐츠 양(content 길이) 기반 + sec_content_len = len(sec.get("content", "")) + page_struct_roles[sec_title] = { + "zone": zone, + "topic_ids": tids, + "weight": sec_content_len, + "sub_titles": sub_titles, + "sub_types": sec.get("sub_types", []), + "group_schema": sec.get("group_schema", ""), + } + + # weight를 비율로 변환 (합계 1.0) + total_content = sum(info["weight"] for info in page_struct_roles.values()) + if total_content > 0: + for role in page_struct_roles: + page_struct_roles[role]["weight"] = round(page_struct_roles[role]["weight"] / total_content, 2) + else: + # content가 없으면 균등 분배 + n = len(page_struct_roles) + for role in page_struct_roles: + page_struct_roles[role]["weight"] = round(1.0 / n, 2) + + # Phase Y: layout_template도 코드가 결정 + # sidebar zone이 있으면 Type A, 없으면 Type B + has_sidebar = any( + info.get("zone") == "sidebar" for info in page_struct_roles.values() + ) + determined_layout = "A" if has_sidebar else "B" + logger.info(f"[Phase Y] 영역 확정: {list(page_struct_roles.keys())} → layout={determined_layout}") + + ctx = ctx.model_copy(update={ + "page_structure": PageStructure(roles=page_struct_roles), + "mdx_sections": major_sections, # normalized.sections 기반 대목차 (assembler용) + "analysis": ctx.analysis.model_copy(update={"layout_template": determined_layout}), + }) + + # Phase Y: page_structure 검증 (section_parser가 만든 결과) + from src.validators import validate_page_structure + ps_errors = validate_page_structure(page_struct_roles) + if ps_errors: + logger.warning(f"[Phase Y] page_structure 검증 경고: {ps_errors}") + + # Y-14: 컴포넌트 popup 감지 + target_role 확정 + component_popups = detect_component_popups(ctx.raw_content, ctx.base_path or "samples/mdx") + if component_popups: + from src.pipeline_context import PopupItem + existing_popups = list(ctx.normalized.popups or []) + + # target_role 결정: raw MDX에서 컴포넌트 태그가 어느 ## 섹션에 있었는지 + raw_lines = ctx.raw_content.split("\n") + for cp in component_popups: + tag = cp.get("tag", f"<{cp['name']} />") + target_role = None + current_section = None + for line in raw_lines: + if line.strip().startswith("## "): + # ## 번호 제거: "## 2. DX 기반..." → "DX 기반..." + # "## 2. DX 기반..." → "DX 기반..." + sec_title = re.sub(r'^#{1,3}\s*\d*\.?\s*', '', line.strip()).strip() + # page_structure roles에서 매칭 + for rname in page_struct_roles: + if sec_title and len(sec_title) >= 3 and sec_title[:6] in rname: + current_section = rname + break + if tag.replace(" ", "") in line.replace(" ", ""): + target_role = current_section + break + + existing_popups.append(PopupItem( + popup_id=f"comp_{cp['name']}", + title=f"상세: {cp['name']}", + content=cp["content_html"], + source=cp["source"], + is_component=True, + target_role=target_role, + )) + logger.info(f"[Y-14] 컴포넌트 popup: {cp['name']} → target_role='{target_role}'") + + ctx = ctx.model_copy(update={ + "normalized": ctx.normalized.model_copy(update={"popups": existing_popups}), + }) + logger.info(f"[Y-14] 컴포넌트 popup {len(component_popups)}개 추가") + # ── Stage 1B: 컨셉 구체화 ── yield {"event": "progress", "data": "1.5/7 컨셉 구체화 중..."} @@ -449,7 +644,7 @@ async def generate_slide( build_enhancement_report, calculate_sub_layout, EnhancementAnalysis, ) - from src.block_assembler import assemble_slide_html + from src.block_assembler import assemble_slide_html_final as assemble_slide_html from src.slide_measurer import measure_rendered_heights refs_dict = {} @@ -520,6 +715,9 @@ async def generate_slide( # ── filled→측정→Kei 재판단 루프 (최대 3회) ── kei_decisions = [] updated_containers = dict(context.containers) + fit_analysis = None + filled_measurement = {} + font_scale = 1.0 # fit 루프에서 축소 MAX_FIT_RETRIES = 3 for fit_round in range(MAX_FIT_RETRIES): @@ -533,8 +731,8 @@ async def generate_slide( "containers": updated_containers, }) - # ── filled: 컨테이너에 블록+텍스트 채움 ── - filled_html = assemble_slide_html(context) + # ── filled: 컨테이너에 블록+텍스트 채움 (측정용: overflow:auto) ── + filled_html = assemble_slide_html(context, measure_mode=True, font_scale=font_scale) (steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text( filled_html.replace('', '\n' f'
' @@ -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", "?")}
- -{zone_rows}
영역clientHscrollHexcess
+ +
Overflow 측정
+ +{zone_rows}
영역clientHscrollHexcess
+ +
블록/Recipe 선택
+ +{recipe_rows}
zoneroleschemablock/recipe
+ +
Popup 연결
+ +{popup_rows if popup_rows else ''}
popup_idtarget_rolepopup_file
없음
+ +
구조 검증
+ + +{_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 @@ + + +
+ +
+
+ + +
+ {{ left_title }} +
+
+ {{ right_title }} +
+ + + {% 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 %} - - -