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:
277
src/block_assembler_b2.py
Normal file
277
src/block_assembler_b2.py
Normal 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>"""
|
||||
Reference in New Issue
Block a user