X' 핵심 수정: MDX sections에서 직접 텍스트 가져오기 + normalizer ### 지원

핵심 변경:
- mdx_normalizer: ### (h3) 소목차도 section으로 분리 (기존 ## 만)
- _assemble_type_b: Kei structured_text 대신 normalized.sections에서 직접 텍스트
- 대목차/소목차 계층 구조 그대로 반영

결과:
- 슬라이드 제목: 원본 MDX frontmatter 그대로
- 대목차: "DX 기반 Process 혁신에 따른 주체별 기대효과"
- 소목차 좌: "업무 수행 과정(Process)의 변화"
- 소목차 우: "DX 시행 주체별 기대효과" + 팝업 링크 + Kei 요약 표
- 캡션: normalized.images alt text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:22:09 +09:00
parent 6b17f448eb
commit 3719704d75
2 changed files with 80 additions and 75 deletions

View File

@@ -677,15 +677,19 @@ def _assemble_type_b(run: Path, ctx: dict):
bottom_h = column_bottom - bottom_top bottom_h = column_bottom - bottom_top
bottom_col_w = (inner_w - gap_block) // 2 bottom_col_w = (inner_w - gap_block) // 2
# ── 역할별 HTML 조립 ── # ── normalized.sections에서 직접 텍스트 가져오기 ──
norm_sections = ctx.get("normalized", {}).get("sections", [])
font_size = fh.get("core", 12) font_size = fh.get("core", 12)
# 상단 (텍스트 + 이미지 나란히) # 상단 (텍스트 + 이미지 나란히) — sections[0] 사용
top_html = "" top_html = ""
if top_role: if top_role:
rn, info = top_role rn, info = top_role
tids = info.get("topic_ids", []) tids = info.get("topic_ids", [])
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) # MDX 원본 sections에서 직접 가져오기 (Kei structured_text 대신)
top_section = norm_sections[0] if norm_sections else {}
all_text = top_section.get("content", "")
topic_title_from_section = top_section.get("title", "")
# 마크다운 bold → HTML # 마크다운 bold → HTML
all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text) all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
@@ -792,9 +796,8 @@ def _assemble_type_b(run: Path, ctx: dict):
f'{caption_html}</div>' f'{caption_html}</div>'
) )
# 제목 # 제목 — MDX 원본 section 제목 사용
primary_topic = topic_map.get(tids[0], {}) if tids else {} topic_title = bold(topic_title_from_section or rn, rn)
topic_title = bold(primary_topic.get("title", ""), rn)
# X'-4: 상단 컨테이너 — 내용을 전체 높이에 균등 배분 # X'-4: 상단 컨테이너 — 내용을 전체 높이에 균등 배분
top_html = ( top_html = (
@@ -807,75 +810,69 @@ def _assemble_type_b(run: Path, ctx: dict):
f'{img_block}</div></div>' f'{img_block}</div></div>'
) )
# 하단 좌측 # 하단: normalized.sections에서 직접 매핑
bl_html = "" # sections 구조: [level=2 상단, level=2 하단대목차, level=3 하단좌, level=3 하단우, ...]
if bottom_left_role: # 하단 대목차 = level=2 두 번째
rn, info = bottom_left_role # 하단 소목차들 = level=3
tids = info.get("topic_ids", []) bottom_title = ""
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) sub_sections_from_norm = [] # [(제목, content)]
all_text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text) for s in norm_sections[1:]: # 상단 제외
if s["level"] == 2:
bottom_title = s.get("title", "")
elif s["level"] == 3:
sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
primary_topic = topic_map.get(tids[0], {}) if tids else {}
topic_title = bold(primary_topic.get("title", ""), rn)
# X'-2: 들여쓰기 계층 (소제목+불릿)
bl_indent = int(font_size * 1.2) bl_indent = int(font_size * 1.2)
# 하단 좌측 = 첫 번째 소목차 (level=3)
bl_html = ""
if sub_sections_from_norm and bottom_left_role:
rn = bottom_left_role[0]
sub_title, sub_content = sub_sections_from_norm[0]
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
bullets = "" bullets = ""
for line in all_text.split("\n"): for line in sub_content.split("\n"):
stripped = line.strip()
if not stripped or re.search(r'\[팝업:|\[이미지:', stripped):
continue
if stripped.startswith("### "):
# 소제목
sub_title = stripped.lstrip("# ").strip()
bullets += f'<div style="font-weight:700;font-size:{font_size}px;color:#1e40af;margin-top:{int(font_size*0.4)}px;">{bold(sub_title, rn)}</div>\n'
else:
clean = stripped.lstrip("")
clean = bold(clean, rn)
bullets += f'<div style="padding-left:{bl_indent}px;font-size:{font_size}px;margin-bottom:1px;">• {clean}</div>\n'
bl_html = (
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
f'<div style="line-height:1.55;color:#333;">{bullets}</div></div>'
)
# 하단 우측
br_html = ""
if bottom_right_role:
rn, info = bottom_right_role
tids = info.get("topic_ids", [])
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
all_text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
primary_topic = topic_map.get(tids[0], {}) if tids else {}
topic_title = bold(primary_topic.get("title", ""), rn)
# 팝업 분리
popup_titles_br = []
content_lines_br = []
for line in all_text.split("\n"):
stripped = line.strip() stripped = line.strip()
if not stripped: if not stripped:
continue continue
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) clean = stripped.lstrip("- ").lstrip("")
if popup_match:
popup_titles_br.append(popup_match.group(1))
continue
if re.search(r'\[이미지:', stripped):
continue
content_lines_br.append(stripped)
popup_html_br = ""
if popup_titles_br:
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in popup_titles_br)
popup_html_br = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
bullets = ""
for line in content_lines_br:
clean = line.lstrip("")
clean = bold(clean, rn) clean = bold(clean, rn)
bullets += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n' bullets += f'<div style="padding-left:{bl_indent}px;font-size:{font_size}px;margin-bottom:2px;">• {clean}</div>\n'
bl_html = (
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(sub_title, rn)}</div>'
f'<div style="line-height:1.55;color:#333;">{bullets}</div></div>'
)
# 하단 우측 = 두 번째 소목차 (level=3) + 표 요약
br_html = ""
if bottom_right_role and len(sub_sections_from_norm) > 1:
rn = bottom_right_role[0]
sub_title_br, sub_content_br = sub_sections_from_norm[1]
sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content_br)
content_lines_br = [l.strip() for l in sub_content_br.split("\n") if l.strip()]
# 팝업 링크 — 소목차 제목으로 팝업 링크 생성
popup_html_br = ""
popup_link_title = f"{sub_title_br} 바로가기"
popup_html_br = (
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
)
# 불릿 — table_summaries가 있으면 표 데이터는 Kei 요약으로 대체되므로 불릿은 간략하게
table_summaries = enh.get("table_summaries", {})
bullets = ""
if not table_summaries:
# 표 요약 없으면 content 그대로
for line in content_lines_br:
clean = line.strip().lstrip("- ").lstrip("")
if clean:
clean = bold(clean, rn)
bullets += f'<div style="padding-left:{bl_indent}px;font-size:{font_size}px;margin-bottom:2px;">• {clean}</div>\n'
# X'-6: 본문 표 요약이 있으면 하단 우측에 추가 # X'-6: 본문 표 요약이 있으면 하단 우측에 추가
table_summaries = enh.get("table_summaries", {}) table_summaries = enh.get("table_summaries", {})
@@ -918,7 +915,7 @@ def _assemble_type_b(run: Path, ctx: dict):
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;' f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
f'display:flex;flex-direction:column;">' f'display:flex;flex-direction:column;">'
f'{popup_html_br}' f'{popup_html_br}'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>' f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(sub_title_br, rn)}</div>'
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>' f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>'
f'{table_html_br}</div>' f'{table_html_br}</div>'
) )
@@ -951,11 +948,15 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
<div style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;"> <div style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
{top_html}</div> {top_html}</div>
<div style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{bottom_col_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;"> <div style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;">
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(bottom_title, "")}</div>
<div style="display:flex;gap:{gap_block}px;height:calc(100% - {int(font_size*1.5 + 4)}px);">
<div style="flex:1;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
{bl_html}</div> {bl_html}</div>
<div style="position:absolute;left:{pad + bottom_col_w + gap_block}px;top:{bottom_top}px;width:{bottom_col_w}px;height:{bottom_h}px;border:2px solid {_color_palette[2]};border-radius:6px;overflow:hidden;"> <div style="flex:1;border:2px solid {_color_palette[2]};border-radius:6px;overflow:hidden;">
{br_html}</div> {br_html}</div>
</div></div>
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h}px;border-radius:8px;overflow:hidden;"> <div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h}px;border-radius:8px;overflow:hidden;">
{footer_html}</div> {footer_html}</div>

View File

@@ -229,15 +229,18 @@ def _extract_structure(text: str) -> dict[str, Any]:
current_section_title = "" current_section_title = ""
current_section_lines = [] current_section_lines = []
current_section_level = 2
def _flush_section(): def _flush_section():
nonlocal current_section_title, current_section_lines nonlocal current_section_title, current_section_lines, current_section_level
if current_section_title: if current_section_title:
sections.append({ sections.append({
"level": 2, "level": current_section_level,
"title": current_section_title, "title": current_section_title,
"content": "\n".join(current_section_lines).strip(), "content": "\n".join(current_section_lines).strip(),
}) })
current_section_lines = [] current_section_lines = []
current_section_level = 2
for i, token in enumerate(tokens): for i, token in enumerate(tokens):
# 이미지 추출 (inline children) # 이미지 추출 (inline children)
@@ -283,12 +286,13 @@ def _extract_structure(text: str) -> dict[str, Any]:
if table["headers"] or table["rows"]: if table["headers"] or table["rows"]:
tables.append(table) tables.append(table)
# 섹션 추출 (## 기준) # 섹션 추출 (## 및 ### 기준 — 대목차/소목차 모두)
if token.type == "heading_open" and token.tag == "h2": if token.type == "heading_open" and token.tag in ("h2", "h3"):
_flush_section() _flush_section()
# 다음 토큰이 inline (제목 텍스트) # 다음 토큰이 inline (제목 텍스트)
if i + 1 < len(tokens) and tokens[i + 1].type == "inline": if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
current_section_title = tokens[i + 1].content current_section_title = tokens[i + 1].content
current_section_level = 2 if token.tag == "h2" else 3
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open", elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
"ordered_list_open", "fence"): "ordered_list_open", "fence"):
# 섹션 내용 수집 — inline 토큰의 content만 # 섹션 내용 수집 — inline 토큰의 content만