Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정
Phase W: - weight 비율 초기 배정 (space_allocator header 높이 반영) - block_assembler 공통 조립 함수 (filled/assembled 통합) - filled → Selenium 측정 → context 저장 - sidebar overflow 확장 + body 재배분 - sub_layouts 사전 계산 (이미지 누락 해결) Phase V': - 팝업 링크 우측상단 배치 (인라인 → position:absolute) - 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약) - 출처 라벨 삭제 + 이미지 아래 캡션 배치 - after 공란 제거 (결론 바로 위까지 body/sidebar 채움) 추가: - V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단 - ** 마크다운 → <strong> 변환 - [이미지:] 마커 제거 (bold 변환 전 처리) - grid-template-rows AFTER 크기 반영 (Sonnet final) - assemble_stage2 CSS font-size override, white-space fix - 하드코딩 전수 검토 완료 - 본심 여러 topic 텍스트 합침 Phase X 계획 문서 작성 (동적 역할 구조) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
445
src/block_assembler.py
Normal file
445
src/block_assembler.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""블록 조립 공통 모듈.
|
||||
|
||||
filled, assembled, Stage 2 모두 이 모듈의 함수를 사용.
|
||||
조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨.
|
||||
|
||||
입력: PipelineContext (또는 동등한 dict)
|
||||
출력: 역할별 HTML dict + 슬라이드 전체 HTML
|
||||
|
||||
하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.pipeline_context import PipelineContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||
|
||||
|
||||
def assemble_role_html(
|
||||
role: str,
|
||||
ctx: "PipelineContext",
|
||||
) -> tuple[str, set[str]]:
|
||||
"""하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립.
|
||||
|
||||
Returns:
|
||||
(조립된 HTML, 사용된 CSS set)
|
||||
"""
|
||||
ps = ctx.page_structure.roles
|
||||
info = ps.get(role, {})
|
||||
if not isinstance(info, dict):
|
||||
return "", set()
|
||||
tids = info.get("topic_ids", [])
|
||||
if not tids:
|
||||
return "", set()
|
||||
|
||||
topic_map = {t.id: t for t in ctx.topics}
|
||||
ref_list = ctx.references.get(role, [])
|
||||
if not ref_list:
|
||||
return "", set()
|
||||
|
||||
r0 = ref_list[0]
|
||||
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
|
||||
primary_topic = topic_map.get(primary_tid)
|
||||
if not primary_topic:
|
||||
return "", set()
|
||||
|
||||
font_key = FONT_MAP.get(role, "core")
|
||||
font_size = getattr(ctx.font_hierarchy, font_key, 12)
|
||||
sub_layouts = ctx.sub_layouts or {}
|
||||
role_sub = sub_layouts.get(role, {})
|
||||
role_scs = role_sub.get("sub_containers", [])
|
||||
|
||||
# #10: V-10 bold 키워드
|
||||
enh = ctx.enhancement_result or {}
|
||||
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||
role_bold = bold_kw.get(role, [])
|
||||
|
||||
# ── 블록 디자인 HTML에서 CSS 추출 ──
|
||||
ref_html = r0.design_reference_html or ""
|
||||
css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
|
||||
block_body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
|
||||
block_body = re.sub(r'<!--.*?-->', '', block_body, flags=re.DOTALL).strip()
|
||||
|
||||
# CSS font-size override (font_hierarchy 기준)
|
||||
overridden_css = set()
|
||||
for css in css_parts:
|
||||
def _override_font(m):
|
||||
val = float(m.group(1))
|
||||
if val > font_size + 2:
|
||||
return f"font-size: {font_size + 1}px"
|
||||
elif val > font_size:
|
||||
return f"font-size: {font_size}px"
|
||||
return m.group(0)
|
||||
oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css)
|
||||
# gap, padding, number size도 font_size 비례
|
||||
oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc)
|
||||
oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
|
||||
f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', oc)
|
||||
oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc)
|
||||
oc = oc.replace('white-space: pre-line', 'white-space: normal')
|
||||
overridden_css.add(oc)
|
||||
|
||||
# ── structured_text 파싱 (들여쓰기 보존) ──
|
||||
st = primary_topic.structured_text or primary_topic.source_data or ""
|
||||
st_lines, popup_titles = _parse_structured_text(st, font_size)
|
||||
|
||||
# ── sub_layouts 기반 판단 ──
|
||||
has_svg = any(sc.get("name") == "svg" for sc in role_scs)
|
||||
has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs)
|
||||
|
||||
# #11: V-9 강조 블록
|
||||
emphasis_blocks = enh.get("emphasis_blocks", [])
|
||||
role_emphasis = ""
|
||||
for eb in emphasis_blocks:
|
||||
if eb.get("role") == role:
|
||||
role_emphasis = eb.get("sentence", "")
|
||||
break
|
||||
|
||||
# #12: V-7 종속꼭지 텍스트
|
||||
is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
|
||||
sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
|
||||
sub_topics_text = []
|
||||
if is_hier and sup_tids:
|
||||
for st_id in sup_tids:
|
||||
st_topic = topic_map.get(st_id)
|
||||
if st_topic:
|
||||
st_text = st_topic.structured_text or st_topic.source_data or ""
|
||||
sub_topics_text.append(st_text[:120])
|
||||
|
||||
# ── 블록 구조별 조립 ──
|
||||
if "block-callout-warn" in block_body or "block-callout-sol" in block_body:
|
||||
inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text)
|
||||
elif "block-card-num" in block_body:
|
||||
inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold)
|
||||
elif "block-banner-grad" in block_body:
|
||||
inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title)
|
||||
elif has_svg:
|
||||
# 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용
|
||||
# slide_images에 실제 이미지가 있는지 확인
|
||||
has_real_image = any(
|
||||
img.get("b64") or img.get("path", "").strip()
|
||||
for img in (ctx.slide_images or [])
|
||||
)
|
||||
if has_real_image:
|
||||
inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold)
|
||||
else:
|
||||
inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
|
||||
else:
|
||||
inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
|
||||
|
||||
# V'-1: 팝업 링크를 컨테이너 우측상단에 배치
|
||||
popup_html = _popup_links_html(popup_titles, font_size)
|
||||
if popup_html:
|
||||
inner = f'<div style="position:relative;">{popup_html}{inner}</div>'
|
||||
|
||||
return inner, overridden_css
|
||||
|
||||
|
||||
def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]:
|
||||
"""structured_text → ([(indent, text)], [팝업 제목 리스트]).
|
||||
[팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** → <strong>."""
|
||||
lines = []
|
||||
popup_titles = []
|
||||
for raw_line in st.split("\n"):
|
||||
stripped = raw_line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
indent = 1 if raw_line.startswith(" ") else 0
|
||||
|
||||
# 마커 처리 (bold 변환 전)
|
||||
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
|
||||
if popup_match:
|
||||
popup_titles.append(popup_match.group(1))
|
||||
continue
|
||||
if re.search(r'\[이미지:', stripped):
|
||||
continue
|
||||
|
||||
# 마크다운 bold → HTML (마커 처리 후)
|
||||
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
|
||||
lines.append((indent, stripped))
|
||||
return lines, popup_titles
|
||||
|
||||
|
||||
def _apply_bold(text: str, keywords: list[str]) -> str:
|
||||
"""V-10 bold 키워드를 <strong>으로 감쌈."""
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||
return text
|
||||
|
||||
|
||||
def _popup_links_html(popup_titles: list[str], font_size: float) -> str:
|
||||
"""팝업 제목 리스트 → 우측상단 배치용 HTML."""
|
||||
if not popup_titles:
|
||||
return ""
|
||||
links = " ".join(
|
||||
f'<span style="color:#2563eb;font-size:{font_size - 2}px;cursor:pointer;">[{t}→]</span>'
|
||||
for t in popup_titles
|
||||
)
|
||||
return (
|
||||
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
|
||||
f'{links}</div>'
|
||||
)
|
||||
|
||||
|
||||
def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str:
|
||||
"""(indent, text) 리스트를 HTML 불릿으로."""
|
||||
bk = bold_keywords or []
|
||||
html = ""
|
||||
for indent, text in st_lines:
|
||||
clean = _apply_bold(text.lstrip("• "), bk)
|
||||
if text.startswith("출처:") or clean.startswith("출처:"):
|
||||
# V'-3: "출처:" 라벨 삭제, 텍스트만 표시
|
||||
caption = re.sub(r'^출처:\s*', '', clean)
|
||||
html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
|
||||
elif indent == 1:
|
||||
html += f'<div class="bl" style="padding-left:1em;font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
||||
else:
|
||||
html += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
||||
return html
|
||||
|
||||
|
||||
def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None):
|
||||
"""callout-warning/solution 블록에 텍스트 채움."""
|
||||
bk = bold_keywords or []
|
||||
desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
|
||||
# V-7 종속꼭지 인라인
|
||||
sub_html = ""
|
||||
for st_text in (sub_topics_text or []):
|
||||
sub_html += (
|
||||
f'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
|
||||
f'border-left:2px solid #fca5a5;">{_apply_bold(st_text, bk)}</div>'
|
||||
)
|
||||
# V-9 강조 블록
|
||||
emph_html = ""
|
||||
if emphasis:
|
||||
emph_html = (
|
||||
f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
|
||||
f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
|
||||
f'→ {_apply_bold(emphasis, bk)}</div>'
|
||||
)
|
||||
inner = re.sub(r'<div class="cw-title">.*?</div>',
|
||||
f'<div class="cw-title">{_apply_bold(topic.title, bk)}</div>', block_body, flags=re.DOTALL)
|
||||
inner = re.sub(r'<div class="cw-desc">.*?</div>',
|
||||
f'<div class="cw-desc" style="font-size:{font_size}px;">{desc_html}{sub_html}{emph_html}</div>', inner, flags=re.DOTALL)
|
||||
return inner
|
||||
|
||||
|
||||
def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None):
|
||||
"""card-numbered 블록에 카드별 텍스트 채움."""
|
||||
# indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명
|
||||
cards = []
|
||||
current_title = ""
|
||||
current_descs = []
|
||||
for indent, text in st_lines:
|
||||
clean = text.lstrip("• ")
|
||||
if indent == 0 and text.startswith("• "):
|
||||
if current_title:
|
||||
cards.append((current_title, current_descs))
|
||||
current_title = clean
|
||||
current_descs = []
|
||||
else:
|
||||
current_descs.append(clean)
|
||||
if current_title:
|
||||
cards.append((current_title, current_descs))
|
||||
|
||||
# sidebar 라벨
|
||||
label = f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:4px;">{topic.title}</div>'
|
||||
|
||||
bk = bold_keywords or []
|
||||
card_gap = max(3, int(font_size * 0.4))
|
||||
items_html = ""
|
||||
for i, (title, descs) in enumerate(cards):
|
||||
desc_html = ""
|
||||
for d in descs:
|
||||
d = _apply_bold(d, bk)
|
||||
if d.startswith("출처:"):
|
||||
caption = re.sub(r'^출처:\s*', '', d)
|
||||
desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
|
||||
else:
|
||||
desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{d}</span></div>\n'
|
||||
num_size = int(font_size * 2)
|
||||
items_html += (
|
||||
f'<div class="cn-item">'
|
||||
f'<div class="cn-number" style="background:#2563eb;width:{num_size}px;height:{num_size}px;font-size:{font_size-1}px;">{i+1}</div>'
|
||||
f'<div class="cn-body">'
|
||||
f'<div class="cn-title" style="font-size:{font_size}px;">{_apply_bold(title, bk)}</div>'
|
||||
f'<div class="cn-desc" style="font-size:{font_size-1}px;white-space:normal;">{desc_html}</div>'
|
||||
f'</div></div>\n'
|
||||
)
|
||||
|
||||
return f'{label}<div class="block-card-num" style="gap:{card_gap}px;">{items_html}</div>'
|
||||
|
||||
|
||||
def _assemble_banner(block_body, message):
|
||||
"""banner-gradient 블록에 메시지 채움."""
|
||||
inner = re.sub(r'<div class="bg-text">.*?</div>',
|
||||
f'<div class="bg-text">{message}</div>', block_body, flags=re.DOTALL)
|
||||
inner = re.sub(r'<div class="bg-sub">.*?</div>', '', inner, flags=re.DOTALL)
|
||||
return inner
|
||||
|
||||
|
||||
def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None):
|
||||
"""이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용."""
|
||||
# 실제 이미지가 있으면 <img> 사용, 없으면 빈 placeholder
|
||||
img_html = ""
|
||||
if slide_images:
|
||||
for img in slide_images:
|
||||
b64 = img.get("b64", "")
|
||||
if b64:
|
||||
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
||||
break
|
||||
|
||||
svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None)
|
||||
text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None)
|
||||
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
|
||||
svg_h = int(svg_sc["height_px"]) if svg_sc else 265
|
||||
|
||||
# 출처 라인을 이미지 아래 캡션으로 분리
|
||||
caption_lines = []
|
||||
content_lines = []
|
||||
for indent, text in st_lines:
|
||||
clean = text.lstrip("• ")
|
||||
if text.startswith("출처:") or clean.startswith("출처:"):
|
||||
caption_lines.append(re.sub(r'^출처:\s*', '', clean))
|
||||
else:
|
||||
content_lines.append((indent, text))
|
||||
|
||||
img_caption = ""
|
||||
if caption_lines:
|
||||
img_caption = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{caption_lines[0]}</div>'
|
||||
|
||||
bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords)
|
||||
bk = bold_keywords or []
|
||||
|
||||
keymsg_html = ""
|
||||
if has_keymsg and core_message:
|
||||
keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
|
||||
km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
|
||||
keymsg_html = (
|
||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
||||
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
|
||||
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
|
||||
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
|
||||
)
|
||||
|
||||
return (
|
||||
f'<div style="display:flex;flex-direction:column;height:100%;padding:8px;box-sizing:border-box;">'
|
||||
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
|
||||
f'{_apply_bold(topic.title, bk)}</div>'
|
||||
f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;flex:1;min-height:0;align-items:flex-start;">'
|
||||
f'<div style="width:{svg_w}px;flex-shrink:0;"><div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>{img_caption}</div>'
|
||||
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||
f'</div>{keymsg_html}</div>'
|
||||
)
|
||||
|
||||
|
||||
def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None):
|
||||
"""기타 블록: 제목 + 불릿."""
|
||||
bk = bold_keywords or []
|
||||
bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
|
||||
keymsg_html = ""
|
||||
if has_keymsg and core_message:
|
||||
keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
|
||||
km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
|
||||
keymsg_html = (
|
||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
||||
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
|
||||
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
|
||||
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
|
||||
)
|
||||
return (
|
||||
f'<div style="height:100%;padding:6px;font-size:{font_size}px;line-height:1.4;">'
|
||||
f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{_apply_bold(topic.title, bk)}</div>'
|
||||
f'{bullets}{keymsg_html}</div>'
|
||||
)
|
||||
|
||||
|
||||
def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
|
||||
"""전체 슬라이드를 조립하여 HTML 반환.
|
||||
|
||||
filled, assembled, stage_2 모두 이 함수를 호출.
|
||||
"""
|
||||
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"]
|
||||
|
||||
ratio = ctx.container_ratio
|
||||
slide_w = tokens.get("slide_width", 1280)
|
||||
slide_h = tokens.get("slide_height", 720)
|
||||
inner_w = slide_w - pad * 2
|
||||
body_w = int(inner_w * ratio[0] / 100)
|
||||
sidebar_w = inner_w - body_w - gap_block
|
||||
|
||||
fit = ctx.fit_result or {}
|
||||
redist = fit.get("redistribution", {})
|
||||
|
||||
all_css = set()
|
||||
role_htmls = {}
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
html, css = assemble_role_html(role, ctx)
|
||||
role_htmls[role] = html
|
||||
all_css.update(css)
|
||||
|
||||
# 좌표 계산
|
||||
bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px))
|
||||
core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px))
|
||||
sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px))
|
||||
concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px))
|
||||
|
||||
bg_top = pad + header_h + gap_block
|
||||
core_top = bg_top + bg_h + gap_small
|
||||
sb_top = bg_top
|
||||
|
||||
# V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움
|
||||
if redist:
|
||||
ft_top = slide_h - pad - concl_h - gap_block
|
||||
column_bottom = ft_top - gap_block
|
||||
core_h = column_bottom - core_top
|
||||
sb_h = column_bottom - sb_top
|
||||
else:
|
||||
ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block
|
||||
|
||||
title = title_text or ctx.analysis.title or ""
|
||||
css_block = "\n".join(all_css)
|
||||
|
||||
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;}}
|
||||
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
|
||||
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
|
||||
{css_block}
|
||||
</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-body" style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border:2px solid #dc2626;border-radius:6px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#dc2626;opacity:0.5;">배경 ({body_w}x{bg_h}px)</span>
|
||||
{role_htmls.get("배경", "")}</div>
|
||||
|
||||
<div class="area-body" style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border:2px solid #2563eb;border-radius:6px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#2563eb;opacity:0.5;">본심 ({body_w}x{core_h}px)</span>
|
||||
{role_htmls.get("본심", "")}</div>
|
||||
|
||||
<div class="area-sidebar" style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border:2px solid #16a34a;border-radius:6px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#16a34a;opacity:0.5;">첨부 ({sidebar_w}x{sb_h}px)</span>
|
||||
{role_htmls.get("첨부", "")}</div>
|
||||
|
||||
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border:2px solid #7c3aed;border-radius:8px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#7c3aed;opacity:0.5;">결론 ({inner_w}x{concl_h}px)</span>
|
||||
{role_htmls.get("결론", "")}</div>
|
||||
|
||||
</div></body></html>"""
|
||||
Reference in New Issue
Block a user