diff --git a/src/block_assembler.py b/src/block_assembler.py
index 0605f26..f228913 100644
--- a/src/block_assembler.py
+++ b/src/block_assembler.py
@@ -373,6 +373,9 @@ def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
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_a(ctx, title_text)
diff --git a/src/block_assembler_b2.py b/src/block_assembler_b2.py
new file mode 100644
index 0000000..56eb943
--- /dev/null
+++ b/src/block_assembler_b2.py
@@ -0,0 +1,277 @@
+"""유형 B'' 조립 함수 — 참고 이미지 스타일 (border 없음, 색상바+여백으로 구분)."""
+from __future__ import annotations
+import re
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from src.pipeline_context import PipelineContext
+
+
+def _assemble_slide_html_type_b_double_prime(ctx: "PipelineContext", title_text: str = "") -> str:
+ """유형 B'' - 참고 이미지 스타일.
+
+ border/gradient 박스 없음. 색상 바 + 폰트 크기 + 여백으로 구분.
+ """
+ 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 ""
+ norm_sections = ctx.normalized.sections or []
+
+ 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
+ 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)
+
+ 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
+
+ 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"
+
+ # ── 상단 ──
+ 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):
+ 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}
'
+ )
+ else:
+ card += (
+ f''
+ f'\u2022 {text}
'
+ )
+ card += ' '
+ cards += card
+
+ top_html = (
+ f''
+ f'
'
+ f'{_bold(topic_title or rn, rn)}
'
+ f'
{cards}
'
+ )
+
+ # ── 하단 ──
+ bottom_title = ""
+ sub_secs = []
+ for s in norm_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 []
+ table_texts = set()
+ for td in norm_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'
'
+ # 불릿
+ 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
+
+ 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)
+
+ 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)
+
+ # 결론
+ footer_html = ""
+ if footer_role:
+ rn = footer_role[0]
+ footer_html = (
+ f''
+ f'
{_bold(core_message, rn)}
'
+ )
+
+ return f"""
+
+
+
+
{title}
+
+
+{top_html}
+
+
+
{_bold(bottom_title, "")}
+
+
+{bl_html}
+
+
+{br_html}
+
+
+
+
+
"""
diff --git a/src/pipeline.py b/src/pipeline.py
index b6a0aa8..acfe90e 100644
--- a/src/pipeline.py
+++ b/src/pipeline.py
@@ -332,7 +332,7 @@ async def generate_slide(
)
# Phase X-B: 유형에 따라 컨테이너 생성 분기
- if context.analysis.layout_template in ("B", "B'"):
+ if context.analysis.layout_template in ("B", "B'", "B''"):
from src.space_allocator import build_containers_type_b
container_specs = build_containers_type_b(
page_structure=context.page_structure.roles,
@@ -589,7 +589,7 @@ async def generate_slide(
fit_analysis = redistribute(fit_analysis, containers_dict)
# Type B: Selenium 실측 기반 zone 간 재배분
- if context.analysis.layout_template in ("B", "B'"):
+ if context.analysis.layout_template in ("B", "B'", "B''"):
# Selenium 측정에서 실제 overflow/여유를 가져옴
zone_to_roles = {}
for role, ci in updated_containers.items():
@@ -854,7 +854,7 @@ async def generate_slide(
# X'-6: 본문 표 요약 (유형 B — normalized.tables가 있으면)
table_summaries = {}
norm_tables = context.normalized.tables or []
- if norm_tables and context.analysis.layout_template in ("B", "B'"):
+ if norm_tables and context.analysis.layout_template in ("B", "B'", "B''"):
from src.kei_client import call_kei_summarize_popup
for ti, table_data in enumerate(norm_tables):
headers = table_data.get("headers", [])
@@ -971,7 +971,7 @@ 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'"):
+ 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 스킵)")
@@ -1040,7 +1040,7 @@ async def generate_slide(
async def stage_3(context: PipelineContext) -> dict:
# Phase X-BX': Type B는 Stage 2에서 이미 완전한 HTML → renderer 스킵
- if context.analysis.layout_template in ("B", "B'"):
+ if context.analysis.layout_template in ("B", "B'", "B''"):
logger.info("[Stage 3] Type B: renderer 스킵 (generated_html 직접 사용)")
return {"rendered_html": context.generated_html}
diff --git a/templates/참고 페이지/005_건설산업의 혁신.png b/templates/참고 페이지/005_건설산업의 혁신.png
new file mode 100644
index 0000000..fa62ce2
Binary files /dev/null and b/templates/참고 페이지/005_건설산업의 혁신.png differ
diff --git a/templates/참고 페이지/스크린샷 2026-04-07 113217.png b/templates/참고 페이지/스크린샷 2026-04-07 113217.png
new file mode 100644
index 0000000..68979dc
Binary files /dev/null and b/templates/참고 페이지/스크린샷 2026-04-07 113217.png differ