Type B'' 추가: 참고 이미지 스타일 (색상바+여백, border 없음)

- block_assembler_b2.py: B'' 전용 조립 함수 (별도 파일)
- 상단: 색상 바 제목 + 소제목(accent 색상) + 불릿(들여쓰기)
- 하단: 색상 바 제목 + 표(있으면) + 불릿
- border/gradient 박스 없음, 여백과 폰트로 구분
- 이제부터 스타일 세부 조정은 하나씩 반영 예정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 11:51:10 +09:00
parent 3d1194a562
commit 076aeb0403
5 changed files with 285 additions and 5 deletions

View File

@@ -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)

277
src/block_assembler_b2.py Normal file
View File

@@ -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"<strong>{kw}</strong>")
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'<strong>\1</strong>', 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'<div style="flex:1;">'
card += (
f'<div style="background:{bc};color:#fff;font-weight:700;'
f'font-size:{font_size}px;padding:4px 10px;margin-bottom:4px;">'
f'{_bold(st, rn)}</div>'
)
for depth, text in items:
text = _bold(text, rn)
if depth <= 1 and '<strong>' in text:
card += (
f'<div style="padding-left:{int(font_size * 0.8)}px;margin-bottom:2px;'
f'font-size:{font_size - 1}px;color:{accent};font-weight:600;">'
f'{text}</div>'
)
else:
card += (
f'<div style="padding-left:{int(font_size * 1.5)}px;margin-bottom:1px;'
f'font-size:{font_size - 2}px;color:#333;">'
f'\u2022 {text}</div>'
)
card += '</div>'
cards += card
top_html = (
f'<div style="height:100%;padding:{gap_small}px;">'
f'<div style="font-weight:700;font-size:{font_size + 2}px;color:#1a365d;margin-bottom:6px;">'
f'{_bold(topic_title or rn, rn)}</div>'
f'<div style="display:flex;gap:{gap_small}px;flex:1;">{cards}</div></div>'
)
# ── 하단 ──
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'<strong>\1</strong>', sub_content)
html = (
f'<div style="height:100%;padding:{gap_small}px;">'
f'<div style="background:{bar_color};color:#fff;font-weight:700;font-size:{font_size}px;'
f'padding:4px 10px;margin-bottom:6px;">{_bold(sub_title, rn)}</div>'
)
# 표
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'<th style="padding:3px 6px;font-size:{font_size - 2}px;background:{bar_color};'
f'color:#fff;border:1px solid #ccc;text-align:center;">{h}</th>'
for h in headers
)
r_html = ""
for ri, row in enumerate(rows):
bg = "#f5f5f0" if ri % 2 == 0 else "#fff"
cells = "".join(
f'<td style="padding:3px 6px;font-size:{font_size - 2}px;border:1px solid #ddd;background:{bg};">'
f'{re.sub(r"\\*\\*(.+?)\\*\\*", r"<strong>\\1</strong>", str(c))}</td>'
for c in row
)
r_html += f'<tr>{cells}</tr>'
html += f'<table style="border-collapse:collapse;width:100%;margin-bottom:6px;"><tr>{h_cells}</tr>{r_html}</table>'
# 불릿
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 '<strong>' in clean:
html += (
f'<div style="margin-bottom:2px;font-size:{font_size - 1}px;'
f'color:{accent};font-weight:600;">{clean}</div>'
)
else:
html += (
f'<div style="padding-left:{int(font_size * 1.2)}px;margin-bottom:1px;'
f'font-size:{font_size - 2}px;color:#333;">\u2022 {clean}</div>'
)
html += '</div>'
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'<div style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
f'border-radius:8px;padding:{int(font_size * 1.2)}px;text-align:center;color:#fff;height:100%;'
f'display:flex;align-items:center;justify-content:center;">'
f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
)
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
</style></head><body>
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;overflow:hidden;">
{top_html}</div>
<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;overflow:hidden;">
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding-bottom:4px;border-bottom:2px solid #1a365d;margin-bottom:4px;">{_bold(bottom_title, "")}</div>
<div style="display:flex;height:calc(100% - {int(font_size * 1.5 + 10)}px);">
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
{bl_html}</div>
<div style="width:1px;background:#cbd5e1;flex-shrink:0;margin:0 {gap_small}px;"></div>
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
{br_html}</div>
</div></div>
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
{footer_html}</div>
</div></body></html>"""

View File

@@ -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}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB