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'{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 + + 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