Add Type B slide pipeline and recipe rendering updates
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user