Add Type B slide pipeline and recipe rendering updates

This commit is contained in:
2026-04-15 16:39:50 +09:00
parent 51548fdc41
commit 66c00924ed
22 changed files with 6260 additions and 1322 deletions

View File

@@ -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('<div class="slide-footer-bg slide-footer--css"></div>', '')
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"<strong>{kw}</strong>")
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"""<style>
/* font_hierarchy: key_msg={font_h.key_msg}px, core={font_h.core}px, bg={font_h.bg}px, sidebar={font_h.sidebar}px */
.p3c-heading {{ font-size: {font_h.core}px !important; line-height: 1.5 !important; }}
.p3c-desc {{ font-size: {font_h.sidebar}px !important; line-height: 1.6 !important; }}
.p3c-desc .bul {{ padding-left: 12px; text-indent: -12px; }}
.p3c-vlabel {{ font-size: {font_h.key_msg}px !important; }}
.p3c-vlabel-sub {{ font-size: {font_h.core}px !important; }}
.p3c-kanji {{ display: none !important; }}
.p3c-vlabel-area {{ width: 56px !important; }}
.p3c-section {{ left: 60px !important; right: 6px !important; }}
.p3c-mid-line {{ left: 56px !important; }}
.p3c-col {{ min-height: 0 !important; height: 100% !important; }}
.block-p3c {{ height: 100% !important; }}
.pp2-header-text {{ font-size: {font_h.core + 1}px !important; font-weight: 900 !important; }}
.pp2-header-text--right {{ color: #ffffff !important; }}
.pp2-mid-title {{ font-size: {font_h.core}px !important; line-height: 1.5 !important; margin-top: 4px !important; }}
.pp2-mid-title:first-child {{ margin-top: 0 !important; }}
.pp2-body-text {{ font-size: {font_h.sidebar}px !important; line-height: 1.6 !important; padding-left: 12px !important; text-indent: -12px !important; font-weight: 500 !important; }}
</style>"""
# ── 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}
<div style="height:38%;margin-bottom:1%;padding-top:8px;">
<div style="font-weight:700;font-size:{font_h.core + 1}px;color:#1a365d;margin-bottom:8px;">
{top_zone_title}
</div>
<div style="height:calc(100% - 28px);padding:0 12px 0 24px;">{top_html}</div>
</div>
<div style="height:60%;margin-top:12px;">
<div style="font-weight:700;font-size:{font_h.core + 1}px;color:#1a365d;margin-bottom:8px;">
{bottom_zone_title}
</div>
<div style="height:calc(100% - 28px);padding:0 12px 0 24px;">{bottom_html}</div>
</div>"""
footer_text_html = f'{core_message}'.replace(
'기대할 수 있다', '<em>기대할 수 있다</em>'
) 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 "<div>상단 zone 없음</div>"
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'<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>'
)
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'<strong>\1</strong>', 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 '<strong>' 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'<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
headings.append({"title": "", "bullets": [clean]})
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>'
)
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 "<div>콘텐츠 없음</div>"
# 블록 CSS 가져오기
p3c_raw = (BLOCKS_DIR / "new" / "prerequisites-3col.html").read_text(encoding="utf-8")
p3c_css = re.search(r'<style>(.*?)</style>', 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'<div class="bul">• {b}</div>' for b in item["bullets"])
items_html += f"""
<div class="p3c-section" style="position:absolute;left:60px;right:6px;top:{pct_top}%;height:{pct_h}%;">
<div class="p3c-heading" style="background-image:{hgrad}">{item['title']}</div>
<div class="p3c-desc">{bul}</div>
</div>"""
if i < n - 1 and len(items) > 1:
line_top = pct_top + pct_h
items_html += f'<div class="p3c-mid-line" style="position:absolute;left:56px;right:0;top:{line_top}%;border-top:1.2px dashed #000;"></div>'
cols_html += f"""
<div class="p3c-col" style="flex:1;position:relative;height:100%;border-top:1.2px solid #000;border-bottom:1.2px solid #000;">
<div class="p3c-bar" style="background:{bar};position:absolute;left:0;top:0;width:56px;height:100%;"></div>
<div class="p3c-vlabel-area" style="position:absolute;left:0;top:0;width:56px;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;z-index:3;">
<div class="p3c-vlabel">{name}</div>
{'<div class="p3c-vlabel-sub">' + sub + '</div>' if sub else ''}
</div>
{items_html}
</div>"""
return f'<div class="block-p3c" style="display:flex;gap:12px;width:100%;height:100%;">{cols_html}</div>\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 "<div>하단 콘텐츠 없음</div>"
# 좌/우 분리 (첫 번째 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'<style>(.*?)</style>', 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'<div style="padding:{pad};">'
if lt:
left_cell += f'<div class="pp2-mid-title pp2-mid-title--left">{lt}</div>'
# 테이블 (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'<div class="pp2-body-text">• {b}</div>'
left_cell += '</div>'
# 우측
right_cell = f'<div style="padding:{pad};">'
if rt:
right_cell += f'<div class="pp2-mid-title pp2-mid-title--right">{rt}</div>'
for b in rbullets:
right_cell += f'<div class="pp2-body-text">• {b}</div>'
right_cell += '</div>'
rows_html += left_cell + right_cell
# 헤더
header_html = f"""
<div class="pp2-header-bar pp2-header-bar--left" style="background:linear-gradient(270deg,#a4a096 0%,#39311e 100%);border-radius:0 24px 24px 0;display:flex;align-items:center;justify-content:center;height:30px;margin-top:4px;">
<span class="pp2-header-text pp2-header-text--left" style="color:#3e3523;">{left_title}</span>
</div>
<div class="pp2-header-bar pp2-header-bar--right" style="background:linear-gradient(90deg,#296b55 0%,#022017 100%);border-radius:24px 0 0 24px;display:flex;align-items:center;padding-left:20px;height:30px;margin-top:4px;">
<span class="pp2-header-text pp2-header-text--right">{right_title}</span>
</div>"""
return f"""
<div style="position:relative;width:100%;height:100%;">
<div style="position:absolute;left:0;top:0;width:50%;height:100%;background:linear-gradient(180deg,#ffffff 46%,#39311e 100%);z-index:0;"></div>
<div style="position:absolute;left:50%;top:0;width:50%;height:100%;background:linear-gradient(0deg,#296b55 0%,#ffffff 56%);z-index:0;"></div>
<div style="position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr;width:100%;height:100%;">
{header_html}
{rows_html}
</div>
</div>
{css_html}"""
def _parse_sub_content(content, tables, bold_fn):
"""하위 콘텐츠를 소제목+불릿 리스트로 파싱."""
content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', 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'<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
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'<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>'
)
if clean_plain in table_texts or clean_plain == "":
continue
if re.search(r'\[핵심요약:', clean):
break
if not clean:
continue
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;">
# 소제목 감지 (볼드)
if '<strong>' 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)
<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>
if current_title or current_bullets:
items.append((current_title, current_bullets))
<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>
return items
<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>
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 ""
</div></body></html>"""
def _clean_md(text):
"""**볼드** 마크다운 제거 — 테이블 셀은 일반 텍스트."""
return re.sub(r'\*\*(.+?)\*\*', r'\1', str(text))
html = '<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">'
html += '<div style="flex:1;">'
for row in rows:
html += f'<div class="pp2-body-text">• {_clean_md(row[0])}</div>'
html += '</div>'
html += f'<div style="flex-shrink:0;width:30px;text-align:center;"><img src="{arrow_uri}" style="width:30px;height:16px;object-fit:contain;" alt=""></div>'
html += '<div style="flex:1;">'
for row in rows:
val = row[2] if len(row) > 2 else ""
html += f'<div class="pp2-body-text">• {_clean_md(val)}</div>'
html += '</div></div>'
return html