from __future__ import annotations import argparse import json import re import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Sequence from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE from pptx.enum.text import PP_ALIGN from pptx.util import Inches, Pt @dataclass(frozen=True) class Theme: navy: RGBColor navy_2: RGBColor bg: RGBColor white: RGBColor text: RGBColor muted: RGBColor line: RGBColor accent_yellow: RGBColor accent_blue: RGBColor accent_red: RGBColor accent_green: RGBColor accent_purple: RGBColor accent_orange: RGBColor accent_cyan: RGBColor font: str font_bold: str font_mono: str T = Theme( navy=RGBColor(0x10, 0x1D, 0x2E), navy_2=RGBColor(0x14, 0x2A, 0x45), bg=RGBColor(0xF6, 0xF7, 0xFB), white=RGBColor(0xFF, 0xFF, 0xFF), text=RGBColor(0x1F, 0x29, 0x37), muted=RGBColor(0x6B, 0x72, 0x80), line=RGBColor(0xE5, 0xE7, 0xEB), accent_yellow=RGBColor(0xF0, 0xB4, 0x3B), accent_blue=RGBColor(0x3B, 0x82, 0xF6), accent_red=RGBColor(0xEF, 0x44, 0x44), accent_green=RGBColor(0x22, 0xC5, 0x5E), accent_purple=RGBColor(0xA8, 0x55, 0xF7), accent_orange=RGBColor(0xF9, 0x73, 0x16), accent_cyan=RGBColor(0x06, 0xB6, 0xD4), font="Apple SD Gothic Neo", font_bold="Apple SD Gothic Neo", font_mono="Menlo", ) SLIDE_W = Inches(13.333) SLIDE_H = Inches(7.5) def _rgb(hex6: str) -> RGBColor: h = hex6.lstrip("#") return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) def set_slide_bg(slide, color: RGBColor) -> None: fill = slide.background.fill fill.solid() fill.fore_color.rgb = color def add_rect(slide, x, y, w, h, *, fill: RGBColor | None, line: RGBColor | None, radius: bool = False): shape_type = MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE if radius else MSO_AUTO_SHAPE_TYPE.RECTANGLE shp = slide.shapes.add_shape(shape_type, x, y, w, h) if fill is None: shp.fill.background() else: shp.fill.solid() shp.fill.fore_color.rgb = fill if line is None: shp.line.fill.background() else: shp.line.color.rgb = line shp.line.width = Pt(1) return shp def add_shadow_card(slide, x, y, w, h, *, fill: RGBColor, line: RGBColor | None, radius: bool = True): # Faux shadow (editable) by layering two shapes. shadow = add_rect(slide, x + Inches(0.06), y + Inches(0.06), w, h, fill=_rgb("#E5E7EB"), line=None, radius=radius) shadow.fill.transparency = 0.35 card = add_rect(slide, x, y, w, h, fill=fill, line=line, radius=radius) return card def add_icon_circle(slide, x, y, d, icon: str, *, fill: RGBColor, fg: RGBColor): circ = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.OVAL, x, y, d, d) circ.fill.solid() circ.fill.fore_color.rgb = fill circ.line.fill.background() tf = circ.text_frame tf.clear() p = tf.paragraphs[0] p.alignment = PP_ALIGN.CENTER run = p.add_run() run.text = icon run.font.name = T.font_bold run.font.size = Pt(20) run.font.bold = True run.font.color.rgb = fg return circ @dataclass(frozen=True) class DeckStyle: accent: RGBColor accent_2: RGBColor badge_bg: RGBColor code_bg: RGBColor code_fg: RGBColor def style_from_meta(meta: dict[str, Any]) -> DeckStyle: badge = str(meta.get("badge") or "") # Track colors if "윤리" in badge or "보안" in badge: accent = T.accent_red accent2 = _rgb("#FDECEC") elif "업무 효율" in badge: accent = T.accent_yellow accent2 = _rgb("#FFF7ED") elif "문제 해결" in badge: accent = T.accent_blue accent2 = _rgb("#EFF6FF") elif "협업" in badge or "공유" in badge: accent = T.accent_green accent2 = _rgb("#ECFDF5") elif "데이터" in badge: accent = T.accent_purple accent2 = _rgb("#F5F3FF") elif "트렌드" in badge: accent = T.accent_orange accent2 = _rgb("#FFF7ED") elif "리더십" in badge: accent = T.accent_cyan accent2 = _rgb("#ECFEFF") else: accent = T.accent_yellow accent2 = _rgb("#FFF7ED") return DeckStyle( accent=accent, accent_2=accent2, badge_bg=_rgb("#24364D"), code_bg=_rgb("#0B1220"), code_fg=_rgb("#E5E7EB"), ) def _strip_prefix(s: str) -> str: t = s.strip() t = re.sub(r"^\s*\d+\)\s*", "", t) t = re.sub(r"^\s*\d+\.\s*", "", t) return t.strip() def _parse_minutes(text: str) -> float | None: """ Parse rough time strings into minutes. Supports: "20분", "60분+", "15~25분", "1시간", "1.5시간", "1시간 30분". Returns None if not parseable. """ s = str(text).strip().replace(" ", "") if not s: return None # Hours + minutes hm = re.fullmatch(r"(?:(\d+(?:\.\d+)?)시간)?(?:(\d+(?:\.\d+)?)분)?\+?", s) if hm and (hm.group(1) or hm.group(2)): h = float(hm.group(1)) if hm.group(1) else 0.0 m = float(hm.group(2)) if hm.group(2) else 0.0 return h * 60.0 + m # Range in minutes: 15~25분 / 15-25분 mrange = re.fullmatch(r"(\d+(?:\.\d+)?)[~-](\d+(?:\.\d+)?)분\+?", s) if mrange: a = float(mrange.group(1)) b = float(mrange.group(2)) return (a + b) / 2.0 # Simple minutes with plus m1 = re.fullmatch(r"(\d+(?:\.\d+)?)분\+?", s) if m1: return float(m1.group(1)) return None def _parse_percent(text: str) -> float | None: """ Parse rough percent strings into 0~100 float. Supports: "30%", "30~70%", "30-70%", "0.3" (treated as 30% if <= 1.0). """ s = str(text).strip().replace(" ", "") if not s: return None s = s.rstrip("+") if s.endswith("%"): s0 = s[:-1] mrange = re.fullmatch(r"(\d+(?:\.\d+)?)[~-](\d+(?:\.\d+)?)", s0) if mrange: a = float(mrange.group(1)) b = float(mrange.group(2)) return max(0.0, min(100.0, (a + b) / 2.0)) m1 = re.fullmatch(r"(\d+(?:\.\d+)?)", s0) if m1: return max(0.0, min(100.0, float(m1.group(1)))) return None # bare number m = re.fullmatch(r"\d+(?:\.\d+)?", s) if not m: return None v = float(s) if 0.0 <= v <= 1.0: v *= 100.0 if 0.0 <= v <= 100.0: return v return None @dataclass(frozen=True) class SlideMood: label: str icon: str color: RGBColor bg: RGBColor def mood_from_title(title: str, *, style: DeckStyle) -> SlideMood: t = str(title or "").strip() tl = t.lower() # Korean + English heuristics if any(k in t for k in ["리스크", "위험", "주의", "환각", "보안"]) or any(k in tl for k in ["risk", "warning", "security"]): return SlideMood(label="RISK", icon="!", color=T.accent_red, bg=_rgb("#FDECEC")) if any(k in t for k in ["체크리스트", "점검", "do", "don't", "dont"]) or any(k in tl for k in ["checklist", "do &", "don't", "dont"]): return SlideMood(label="CHECK", icon="✓", color=T.accent_green, bg=_rgb("#ECFDF5")) if any(k in t for k in ["요약", "정리", "핵심"]) or any(k in tl for k in ["summary", "key"]): return SlideMood(label="SUMMARY", icon="≡", color=style.accent, bg=style.accent_2) if any(k in t for k in ["기대 효과", "효과", "성과", "절감", "개선"]) or any(k in tl for k in ["impact", "benefit", "kpi", "saving"]): return SlideMood(label="IMPACT", icon="+", color=style.accent, bg=style.accent_2) if any(k in t for k in ["사례", "적용", "예시", "데모"]) or any(k in tl for k in ["case", "example", "demo"]): return SlideMood(label="CASE", icon="◆", color=style.accent, bg=style.accent_2) if any(k in t for k in ["프레임워크", "루틴", "프로세스", "단계"]) or any(k in tl for k in ["framework", "process", "steps"]): return SlideMood(label="FRAME", icon="→", color=style.accent, bg=style.accent_2) return SlideMood(label="INFO", icon="i", color=style.accent, bg=style.accent_2) def extract_kpis(lines: Sequence[str]) -> list[tuple[str, str]]: """ Extract up to 3 KPI-like tokens from bullet lines. Returns list of (value, label). """ out: list[tuple[str, str]] = [] seen: set[str] = set() def add(value: str, label: str) -> None: v = value.strip() if not v or v in seen: return seen.add(v) out.append((v, label.strip()[:18] or "KPI")) for s in lines: text = str(s) # percent m = re.search(r"(\d+(?:\.\d+)?\s*(?:~|-)\s*\d+(?:\.\d+)?\s*%|\d+(?:\.\d+)?\s*%)", text) if m: label = "비율" if "단축" in text or "절감" in text or "감소" in text: label = "단축/절감" add(m.group(1).replace(" ", ""), label) # minutes / hours m2 = re.search(r"(\d+(?:\.\d+)?\s*(?:~|-)\s*\d+(?:\.\d+)?\s*분\+?|\d+(?:\.\d+)?\s*분\+?|\d+(?:\.\d+)?\s*시간(?:\s*\d+(?:\.\d+)?\s*분)?)", text) if m2: label = "시간" if "절감" in text or "단축" in text: label = "절감" add(m2.group(1).replace(" ", ""), label) # weeks m3 = re.search(r"(\d+)\s*주", text) if m3: add(f"{m3.group(1)}주", "기간") # counts m4 = re.search(r"(\d+)\s*개", text) if m4: add(f"{m4.group(1)}개", "항목") if len(out) >= 3: break return out[:3] def add_kpi_cards(slide, *, kpis: Sequence[tuple[str, str]], style: DeckStyle) -> None: if not kpis: return n = min(3, len(kpis)) w = Inches(3.85) gap = Inches(0.25) x0 = Inches(0.7) y0 = Inches(6.0) for i in range(n): value, label = kpis[i] xx = x0 + (w + gap) * i add_shadow_card(slide, xx, y0, w, Inches(0.95), fill=T.white, line=_rgb("#E5E7EB")) add_rect(slide, xx, y0, Inches(0.08), Inches(0.95), fill=style.accent, line=None, radius=False) add_textbox(slide, xx + Inches(0.22), y0 + Inches(0.18), w - Inches(0.35), Inches(0.45), value, size=22, bold=True, color=T.text) add_textbox(slide, xx + Inches(0.22), y0 + Inches(0.62), w - Inches(0.35), Inches(0.25), label, size=11, bold=True, color=T.muted) def add_textbox( slide, x, y, w, h, text: str, *, size: int, bold: bool = False, color: RGBColor | None = None, align: int = PP_ALIGN.LEFT, line_spacing: float | None = None, ): tb = slide.shapes.add_textbox(x, y, w, h) tf = tb.text_frame tf.clear() tf.word_wrap = True p = tf.paragraphs[0] p.alignment = align run = p.add_run() run.text = text f = run.font f.name = T.font_bold if bold else T.font f.size = Pt(size) f.bold = bold f.color.rgb = color or T.text if line_spacing is not None: p.line_spacing = line_spacing return tb def add_textbox_mono(slide, x, y, w, h, text: str, *, size: int, color: RGBColor): tb = slide.shapes.add_textbox(x, y, w, h) tf = tb.text_frame tf.clear() tf.word_wrap = True # Keep line breaks as paragraphs for better rendering. lines = text.splitlines() if text else [""] for i, line in enumerate(lines): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.text = line p.font.name = T.font_mono p.font.size = Pt(size) p.font.color.rgb = color p.line_spacing = 1.12 p.space_after = Pt(0) return tb def add_bullets( slide, x, y, w, h, bullets: Sequence[str], *, size: int = 18, color: RGBColor | None = None, line_spacing: float = 1.15, ): tb = slide.shapes.add_textbox(x, y, w, h) tf = tb.text_frame tf.clear() tf.word_wrap = True for i, b in enumerate(bullets): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.text = b p.level = 0 p.font.name = T.font p.font.size = Pt(size) p.font.color.rgb = color or T.text p.line_spacing = line_spacing p.space_after = Pt(6) p.bullet = True return tb def add_pill(slide, x, y, w, h, text: str, *, fill: RGBColor, fg: RGBColor): pill = add_rect(slide, x, y, w, h, fill=fill, line=None, radius=True) tf = pill.text_frame tf.clear() p = tf.paragraphs[0] p.alignment = PP_ALIGN.CENTER run = p.add_run() run.text = text run.font.name = T.font run.font.size = Pt(12) run.font.color.rgb = fg return pill def add_footer(slide, slide_no: int, *, dark: bool, footer_left: str | None = None) -> None: y = SLIDE_H - Inches(0.35) add_rect(slide, Inches(0), y, SLIDE_W, Inches(0.35), fill=(T.navy if dark else T.bg), line=None, radius=False) fg = _rgb("#D1D5DB") if dark else T.muted if footer_left: add_textbox( slide, Inches(0.5), y + Inches(0.06), Inches(9.5), Inches(0.25), footer_left, size=11, bold=False, color=fg, align=PP_ALIGN.LEFT, ) add_textbox( slide, SLIDE_W - Inches(2.0), y + Inches(0.06), Inches(1.5), Inches(0.25), f"{slide_no:02d}", size=11, bold=False, color=fg, align=PP_ALIGN.RIGHT, ) def set_notes(slide, notes: str | None) -> None: if notes is None: return text = str(notes).strip() if not text: return ns = slide.notes_slide tf = ns.notes_text_frame tf.clear() tf.text = text def _header(slide, title: str, *, chapter: str | None = None, style: DeckStyle) -> None: # Top bar + accent strip (more "PPT-like") add_rect(slide, Inches(0), Inches(0), SLIDE_W, Inches(0.95), fill=T.white, line=None, radius=False) add_rect(slide, Inches(0), Inches(0), SLIDE_W, Inches(0.06), fill=style.accent, line=None, radius=False) add_textbox(slide, Inches(0.7), Inches(0.22), Inches(10.0), Inches(0.55), title, size=28, bold=True, color=T.text) if chapter: add_pill(slide, Inches(11.2), Inches(0.26), Inches(1.8), Inches(0.38), chapter, fill=_rgb("#EEF2F7"), fg=T.muted) add_rect(slide, Inches(0.7), Inches(0.93), Inches(11.93), Inches(0.04), fill=T.line, line=None, radius=False) def slide_cover(prs: Presentation, *, title: str, subtitle: str | None, badge: str | None, footer_left: str | None, style: DeckStyle): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.navy) # Subtle pattern bubbles for (x, y, d, a) in [(11.0, 0.6, 1.4, 0.12), (10.1, 1.7, 2.2, 0.08), (11.7, 2.8, 1.7, 0.07)]: c = s.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.OVAL, Inches(x), Inches(y), Inches(d), Inches(d)) c.fill.solid() c.fill.fore_color.rgb = style.accent c.fill.transparency = 1 - a c.line.fill.background() # Corner star (spark) star = s.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.STAR_5_POINT, Inches(0.55), Inches(0.65), Inches(0.65), Inches(0.65)) star.fill.solid() star.fill.fore_color.rgb = style.accent star.line.fill.background() star.fill.transparency = 0.08 if badge: add_pill(s, Inches(4.2), Inches(0.45), Inches(4.9), Inches(0.45), badge, fill=style.badge_bg, fg=_rgb("#E5E7EB")) # Icon badge add_icon_circle(s, Inches(6.2), Inches(1.35), Inches(0.9), "★", fill=style.accent, fg=T.navy) add_textbox( s, Inches(1.0), Inches(2.15), Inches(11.33), Inches(1.5), title, size=48 if len(title) <= 18 else 38, bold=True, color=T.white, align=PP_ALIGN.CENTER, line_spacing=1.05, ) if subtitle: add_textbox(s, Inches(1.3), Inches(4.0), Inches(10.8), Inches(0.5), subtitle, size=18, bold=True, color=_rgb("#E5E7EB"), align=PP_ALIGN.CENTER) add_rect(s, Inches(4.9), Inches(4.55), Inches(3.5), Inches(0.08), fill=style.accent, line=None) add_footer(s, 1, dark=True, footer_left=footer_left) return s def slide_section(prs: Presentation, slide_no: int, *, title: str, subtitle: str | None, footer_left: str | None, style: DeckStyle): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.navy_2) # Big part number if exists m = re.match(r"^\s*(\d+)\.\s*(.*)$", title) num = m.group(1) if m else None rest = m.group(2) if m else title if num: add_textbox(s, Inches(0.9), Inches(1.6), Inches(1.6), Inches(1.2), num, size=84, bold=True, color=style.accent) add_textbox(s, Inches(2.25), Inches(2.15), Inches(10.2), Inches(1.0), rest, size=44 if len(rest) <= 18 else 34, bold=True, color=T.white, line_spacing=1.05) else: add_textbox(s, Inches(0.9), Inches(2.15), Inches(11.6), Inches(1.0), rest, size=44 if len(rest) <= 18 else 34, bold=True, color=T.white, line_spacing=1.05) if subtitle: add_textbox(s, Inches(2.25) if num else Inches(0.9), Inches(3.25), Inches(11.0), Inches(0.6), subtitle, size=18, bold=True, color=_rgb("#D1D5DB")) add_rect(s, Inches(0.9), Inches(4.25), Inches(4.2), Inches(0.08), fill=style.accent, line=None) # Accent corner add_rect(s, Inches(11.4), Inches(6.0), Inches(2.0), Inches(1.5), fill=style.accent, line=None, radius=True).fill.transparency = 0.15 add_footer(s, slide_no, dark=True, footer_left=footer_left) return s def slide_agenda(prs: Presentation, slide_no: int, *, title: str, items: Sequence[str], footer_left: str | None, style: DeckStyle): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.bg) _header(s, title, style=style) # Stepper layout (horizontal) card = add_shadow_card(s, Inches(0.7), Inches(1.55), Inches(11.93), Inches(4.9), fill=T.white, line=T.line) _ = card steps = [_strip_prefix(str(x)) for x in items][:6] n = max(1, len(steps)) left = Inches(1.1) top = Inches(2.25) usable_w = Inches(11.2) gap = usable_w / n for i, txt in enumerate(steps): cx = left + gap * i + gap / 2 - Inches(0.35) add_icon_circle(s, cx, top, Inches(0.7), f"{i+1}", fill=style.accent, fg=T.navy) add_textbox(s, cx - Inches(0.6), top + Inches(0.85), Inches(2.0), Inches(0.55), txt, size=14, bold=True, color=T.text, align=PP_ALIGN.CENTER, line_spacing=1.05) if i < n - 1: # connector add_rect(s, cx + Inches(0.7), top + Inches(0.33), gap - Inches(0.7), Inches(0.05), fill=_rgb("#CBD5E1"), line=None, radius=True) add_rect(s, Inches(1.1), Inches(5.8), Inches(11.2), Inches(0.7), fill=style.accent_2, line=None, radius=True) add_textbox(s, Inches(1.35), Inches(5.93), Inches(10.7), Inches(0.45), "TIP: 각 파트는 5~7분 내로, 사례는 2개만 깊게 보면 충분합니다.", size=15, bold=True, color=T.text) add_footer(s, slide_no, dark=False, footer_left=footer_left) return s def slide_bullets(prs: Presentation, slide_no: int, *, title: str, bullets: Sequence[str], chapter: str | None, footer_left: str | None, style: DeckStyle): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.bg) _header(s, title, chapter=chapter, style=style) # Left icon rail + bullet cards (more visual) add_rect(s, Inches(0), Inches(0.95), Inches(0.22), SLIDE_H - Inches(0.95), fill=style.accent, line=None, radius=False) add_icon_circle(s, Inches(0.35), Inches(1.55), Inches(0.8), "✓", fill=style.accent, fg=T.navy) # Decorative blob blob = s.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.OVAL, Inches(11.6), Inches(6.0), Inches(1.6), Inches(1.6)) blob.fill.solid() blob.fill.fore_color.rgb = style.accent blob.fill.transparency = 0.88 blob.line.fill.background() # Mood badge (top-right) mood = mood_from_title(title, style=style) add_pill(s, Inches(10.65), Inches(1.12), Inches(2.55), Inches(0.38), f"{mood.icon} {mood.label}", fill=mood.bg, fg=T.text) items = list(bullets) n = len(items) if n <= 5: card = add_shadow_card(s, Inches(1.2), Inches(1.55), Inches(12.1), Inches(4.25), fill=T.white, line=T.line) _ = card y0 = Inches(1.95) for i, txt in enumerate(items): yy = y0 + Inches(0.78) * i add_icon_circle(s, Inches(1.45), yy, Inches(0.38), f"{i+1}", fill=style.accent_2, fg=T.text) add_textbox(s, Inches(1.9), yy - Inches(0.02), Inches(11.1), Inches(0.5), txt, size=18, bold=True, color=T.text, line_spacing=1.1) add_rect(s, Inches(1.45), yy + Inches(0.55), Inches(11.6), Inches(0.02), fill=_rgb("#E5E7EB"), line=None, radius=False) else: # 2-column grid of mini cards add_shadow_card(s, Inches(1.2), Inches(1.55), Inches(12.1), Inches(4.25), fill=T.white, line=T.line) col_w = Inches(5.8) row_h = Inches(1.1) left_x = Inches(1.45) right_x = Inches(7.35) top_y = Inches(1.9) for i, txt in enumerate(items[:8]): xx = left_x if i % 2 == 0 else right_x yy = top_y + row_h * (i // 2) add_shadow_card(s, xx, yy, col_w, Inches(0.92), fill=_rgb("#F9FAFB"), line=_rgb("#E5E7EB")) add_rect(s, xx, yy, Inches(0.08), Inches(0.92), fill=style.accent, line=None, radius=False) add_textbox(s, xx + Inches(0.25), yy + Inches(0.18), col_w - Inches(0.35), Inches(0.6), txt, size=15, bold=True, color=T.text, line_spacing=1.08) # KPI cards (auto extracted from bullets) add_kpi_cards(s, kpis=extract_kpis(items), style=style) add_footer(s, slide_no, dark=False, footer_left=footer_left) return s def slide_two_column( prs: Presentation, slide_no: int, *, title: str, left_title: str, left_bullets: Sequence[str], right_title: str, right_bullets: Sequence[str], chapter: str | None, footer_left: str | None, style: DeckStyle, ): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.bg) _header(s, title, chapter=chapter, style=style) lx = Inches(0.7) rx = Inches(6.75) y = Inches(1.55) w = Inches(5.88) h = Inches(5.0) add_shadow_card(s, lx, y, w, h, fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB")) add_shadow_card(s, rx, y, w, h, fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB")) # Colored headers + icons (auto semantics) add_rect(s, lx, y, w, Inches(0.9), fill=_rgb("#EFF6FF"), line=None, radius=True) add_rect(s, rx, y, w, Inches(0.9), fill=_rgb("#ECFDF5"), line=None, radius=True) add_rect(s, lx, y, w, Inches(0.08), fill=T.accent_blue, line=None, radius=False) add_rect(s, rx, y, w, Inches(0.08), fill=T.accent_green, line=None, radius=False) left_icon = "!" right_icon = "✓" lt = left_title.lower() rt = right_title.lower() if "오해" in left_title or "bad" in lt or "don" in lt or "리스크" in left_title: left_icon = "!" if "현실" in right_title or "good" in rt or "do" in rt or "완화" in right_title: right_icon = "✓" if "before" in lt or "기존" in left_title: left_icon = "◼" if "after" in rt or "ai" in rt or "협업" in right_title: right_icon = "▲" add_icon_circle(s, lx + Inches(0.35), y + Inches(0.22), Inches(0.45), left_icon, fill=_rgb("#DBEAFE"), fg=_rgb("#1D4ED8")) add_icon_circle(s, rx + Inches(0.35), y + Inches(0.22), Inches(0.45), right_icon, fill=_rgb("#D1FAE5"), fg=_rgb("#065F46")) add_textbox(s, lx + Inches(0.9), y + Inches(0.18), w - Inches(1.2), Inches(0.55), left_title, size=18, bold=True, color=T.text) add_textbox(s, rx + Inches(0.9), y + Inches(0.18), w - Inches(1.2), Inches(0.55), right_title, size=18, bold=True, color=T.text) add_bullets(s, lx + Inches(0.35), y + Inches(1.05), w - Inches(0.7), h - Inches(1.25), list(left_bullets), size=15, color=T.text, line_spacing=1.18) add_bullets(s, rx + Inches(0.35), y + Inches(1.05), w - Inches(0.7), h - Inches(1.25), list(right_bullets), size=15, color=T.text, line_spacing=1.18) # VS badge add_icon_circle(s, Inches(6.05), Inches(3.65), Inches(0.75), "VS", fill=_rgb("#111827"), fg=_rgb("#E5E7EB")) add_footer(s, slide_no, dark=False, footer_left=footer_left) return s def slide_process(prs: Presentation, slide_no: int, *, title: str, steps: Sequence[str], chapter: str | None, footer_left: str | None, style: DeckStyle): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.bg) _header(s, title, chapter=chapter, style=style) add_shadow_card(s, Inches(0.7), Inches(1.55), Inches(11.93), Inches(4.9), fill=T.white, line=T.line) st = list(steps) n = len(st) # Timeline (horizontal) when possible if n <= 6: left = Inches(1.1) y = Inches(3.05) usable_w = Inches(11.2) gap = usable_w / n add_rect(s, left, y + Inches(0.35), usable_w, Inches(0.06), fill=_rgb("#CBD5E1"), line=None, radius=True) for i, txt in enumerate(st): cx = left + gap * i + gap / 2 - Inches(0.32) add_icon_circle(s, cx, y, Inches(0.64), f"{i+1}", fill=style.accent, fg=T.navy) add_shadow_card(s, cx - Inches(0.55), y - Inches(1.25), Inches(2.1), Inches(0.95), fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB")) add_textbox(s, cx - Inches(0.45), y - Inches(1.1), Inches(1.9), Inches(0.75), txt, size=13, bold=True, color=T.text, align=PP_ALIGN.CENTER, line_spacing=1.05) else: # Grid fallback cols = 3 card_w = Inches(3.75) card_h = Inches(1.1) x0 = Inches(1.05) y0 = Inches(1.95) gx = Inches(0.35) gy = Inches(0.25) for i, txt in enumerate(st[:9]): r = i // cols c = i % cols xx = x0 + (card_w + gx) * c yy = y0 + (card_h + gy) * r add_shadow_card(s, xx, yy, card_w, card_h, fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB")) add_icon_circle(s, xx + Inches(0.22), yy + Inches(0.28), Inches(0.5), f"{i+1}", fill=style.accent_2, fg=T.text) add_textbox(s, xx + Inches(0.85), yy + Inches(0.25), card_w - Inches(1.05), Inches(0.7), txt, size=14, bold=True, color=T.text, line_spacing=1.05) add_footer(s, slide_no, dark=False, footer_left=footer_left) return s def slide_prompt( prs: Presentation, slide_no: int, *, title: str, prompt: str, tips: Sequence[str] | None, chapter: str | None, footer_left: str | None, style: DeckStyle, ): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.bg) _header(s, title, chapter=chapter, style=style) # Code block style prompt card add_shadow_card(s, Inches(0.7), Inches(1.55), Inches(11.93), Inches(3.2), fill=style.code_bg, line=None, radius=True) add_pill(s, Inches(0.95), Inches(1.72), Inches(2.2), Inches(0.38), "Copy & Paste", fill=style.accent, fg=T.navy) add_icon_circle(s, Inches(11.85), Inches(1.67), Inches(0.55), "AI", fill=style.accent, fg=T.navy) add_textbox_mono(s, Inches(0.95), Inches(2.15), Inches(11.33), Inches(2.35), prompt, size=14, color=style.code_fg) if tips: # Tip cards (3) add_textbox(s, Inches(0.7), Inches(4.95), Inches(6.0), Inches(0.35), "핵심 포인트", size=16, bold=True, color=T.text) tip_list = list(tips)[:3] tw = Inches(3.85) ty = Inches(5.3) for i, tip in enumerate(tip_list): tx = Inches(0.7) + (tw + Inches(0.25)) * i add_shadow_card(s, tx, ty, tw, Inches(1.15), fill=T.white, line=_rgb("#E5E7EB")) add_icon_circle(s, tx + Inches(0.25), ty + Inches(0.28), Inches(0.55), "i", fill=style.accent_2, fg=T.text) add_textbox(s, tx + Inches(0.95), ty + Inches(0.22), tw - Inches(1.15), Inches(0.9), tip, size=14, bold=True, color=T.text, line_spacing=1.08) add_footer(s, slide_no, dark=False, footer_left=footer_left) return s def slide_table( prs: Presentation, slide_no: int, *, title: str, columns: Sequence[str], rows: Sequence[Sequence[str]], chapter: str | None, footer_left: str | None, style: DeckStyle, ): s = prs.slides.add_slide(prs.slide_layouts[6]) set_slide_bg(s, T.bg) _header(s, title, chapter=chapter, style=style) x = Inches(0.7) y = Inches(1.55) w = Inches(11.93) h = Inches(4.9) add_shadow_card(s, x, y, w, h, fill=T.white, line=T.line) add_rect(s, x, y, w, Inches(0.08), fill=style.accent, line=None, radius=False) add_icon_circle(s, x + Inches(0.2), y + Inches(0.18), Inches(0.55), "▦", fill=style.accent_2, fg=T.text) n_rows = 1 + len(rows) n_cols = max(1, len(columns)) # Leave a strip at bottom for mini chart. table_h = h - Inches(1.45) tbl = s.shapes.add_table(n_rows, n_cols, x + Inches(0.35), y + Inches(0.35), w - Inches(0.7), table_h).table col_names = [str(c) for c in columns] col_names_l = [c.lower() for c in col_names] savings_idx = next((j for j, c in enumerate(col_names_l) if ("절감" in c or "감소" in c or "saving" in c)), None) before_idx = next((j for j, c in enumerate(col_names_l) if ("기존" in c or "before" in c or "as-is" in c or "현재" in c)), None) after_idx = next((j for j, c in enumerate(col_names_l) if ("ai" in c or "after" in c or "to-be" in c or "협업" in c or "개선" in c)), None) for j, col in enumerate(columns): cell = tbl.cell(0, j) cell.text = col for p in cell.text_frame.paragraphs: for run in p.runs: run.font.name = T.font_bold run.font.bold = True run.font.size = Pt(14) run.font.color.rgb = T.white p.alignment = PP_ALIGN.CENTER cell.fill.solid() if savings_idx is not None and j == savings_idx: cell.fill.fore_color.rgb = _rgb("#166534") # deep green header for savings else: cell.fill.fore_color.rgb = T.navy_2 for i, r in enumerate(rows, start=1): for j in range(n_cols): cell = tbl.cell(i, j) cell.text = (r[j] if j < len(r) else "") for p in cell.text_frame.paragraphs: for run in p.runs: run.font.name = T.font run.font.size = Pt(12) run.font.color.rgb = T.text p.alignment = PP_ALIGN.LEFT if j != 0 else PP_ALIGN.CENTER cell.fill.solid() base = _rgb("#F9FAFB") if i % 2 == 0 else T.white if savings_idx is not None and j == savings_idx: # subtle green tint for savings column base = _rgb("#ECFDF5") if i % 2 == 0 else _rgb("#F0FDF4") cell.fill.fore_color.rgb = base # --- Mini chart strip (paired bars / savings emphasis / percent) --- def minutes_ok(col_idx: int) -> int: ok = 0 for rr in rows: if col_idx < len(rr) and _parse_minutes(rr[col_idx]) is not None: ok += 1 return ok def percent_ok(col_idx: int) -> int: ok = 0 for rr in rows: if col_idx < len(rr) and _parse_percent(rr[col_idx]) is not None: ok += 1 return ok minute_cols = [(minutes_ok(j), j) for j in range(1, n_cols)] minute_cols.sort(reverse=True) percent_cols = [(percent_ok(j), j) for j in range(1, n_cols)] percent_cols.sort(reverse=True) # choose mode mode: str | None = None if before_idx is not None and after_idx is not None and minutes_ok(before_idx) >= 2 and minutes_ok(after_idx) >= 2: mode = "paired_time" a_idx = before_idx b_idx = after_idx elif percent_cols and percent_cols[0][0] >= 2: mode = "percent" p_idx = percent_cols[0][1] elif minute_cols and minute_cols[0][0] >= 2: mode = "single_time" a_idx = minute_cols[0][1] b_idx = minute_cols[1][1] if len(minute_cols) > 1 and minute_cols[1][0] >= 2 else None else: mode = None if mode: chart_y = y + Inches(0.35) + table_h + Inches(0.18) chart_h = Inches(0.86) add_rect(s, x, chart_y, w, chart_h, fill=style.accent_2, line=None, radius=True) add_textbox(s, x + Inches(0.35), chart_y + Inches(0.12), Inches(2.0), Inches(0.25), "Mini Chart", size=12, bold=True, color=T.text) label_x = x + Inches(0.35) label_w = Inches(5.6) bar_x = x + Inches(6.15) bar_w = Inches(6.1) if mode == "percent": # Legend add_rect(s, x + Inches(2.45), chart_y + Inches(0.18), Inches(0.22), Inches(0.14), fill=style.accent, line=None, radius=True) add_textbox(s, x + Inches(2.72), chart_y + Inches(0.11), Inches(3.4), Inches(0.3), str(columns[p_idx])[:18], size=11, bold=True, color=T.muted) max_rows = min(4, len(rows)) for i in range(max_rows): rr = rows[i] label = (rr[0] if rr else f"Row {i+1}")[:24] v = _parse_percent(rr[p_idx]) if p_idx < len(rr) else None if v is None: continue yy = chart_y + Inches(0.38) + Inches(0.18) * i add_textbox(s, label_x, yy - Inches(0.06), label_w, Inches(0.2), label, size=11, bold=True, color=T.text) bw = bar_w * (v / 100.0) add_rect(s, bar_x, yy, bw, Inches(0.12), fill=style.accent, line=None, radius=True) add_textbox(s, bar_x + bw + Inches(0.08), yy - Inches(0.06), Inches(1.0), Inches(0.2), f"{v:.0f}%", size=10, bold=True, color=T.muted) elif mode == "paired_time": before_name = str(columns[a_idx]) after_name = str(columns[b_idx]) # Legend: before/after + savings c_before = _rgb("#94A3B8") c_after = style.accent c_save = T.accent_green lx = x + Inches(2.45) for k, (cc, name) in enumerate([(c_before, before_name), (c_after, after_name), (c_save, "절감")]): add_rect(s, lx + Inches(1.45) * k, chart_y + Inches(0.18), Inches(0.22), Inches(0.14), fill=cc, line=None, radius=True) add_textbox(s, lx + Inches(1.45) * k + Inches(0.28), chart_y + Inches(0.11), Inches(1.2), Inches(0.3), name[:10], size=11, bold=True, color=T.muted) max_rows = min(3, len(rows)) max_v = 0.0 parsed: list[tuple[str, float | None, float | None, float | None]] = [] for i in range(max_rows): rr = rows[i] label = (rr[0] if rr else f"Row {i+1}")[:24] va = _parse_minutes(rr[a_idx]) if a_idx < len(rr) else None vb = _parse_minutes(rr[b_idx]) if b_idx < len(rr) else None vs = _parse_minutes(rr[savings_idx]) if (savings_idx is not None and savings_idx < len(rr)) else (va - vb if (va is not None and vb is not None) else None) parsed.append((label, va, vb, vs)) for vv in (va, vb): if vv is not None: max_v = max(max_v, vv) if max_v <= 0: max_v = 1.0 for i, (label, va, vb, vs) in enumerate(parsed): yy = chart_y + Inches(0.36) + Inches(0.26) * i add_textbox(s, label_x, yy - Inches(0.04), label_w, Inches(0.2), label, size=11, bold=True, color=T.text) # stacked bars if va is not None: add_rect(s, bar_x, yy, bar_w * (va / max_v), Inches(0.10), fill=c_before, line=None, radius=True) if vb is not None: add_rect(s, bar_x, yy + Inches(0.12), bar_w * (vb / max_v), Inches(0.10), fill=c_after, line=None, radius=True) if vs is not None and vs > 0: add_pill(s, bar_x + bar_w + Inches(0.08), yy - Inches(0.03), Inches(1.05), Inches(0.22), f"-{vs:.0f}분", fill=_rgb("#DCFCE7"), fg=_rgb("#166534")) else: # single_time # pick up to two cols and highlight savings if present chosen = [a_idx] + ([b_idx] if b_idx is not None else []) colors = [style.accent, _rgb("#94A3B8")] lx = x + Inches(2.45) for k, j in enumerate(chosen[:2]): add_rect(s, lx + Inches(1.45) * k, chart_y + Inches(0.18), Inches(0.22), Inches(0.14), fill=colors[k], line=None, radius=True) add_textbox(s, lx + Inches(1.45) * k + Inches(0.28), chart_y + Inches(0.11), Inches(1.2), Inches(0.3), str(columns[j])[:10], size=11, bold=True, color=T.muted) max_rows = min(4, len(rows)) max_v = 0.0 for i in range(max_rows): rr = rows[i] for j in chosen[:2]: v = _parse_minutes(rr[j]) if j < len(rr) else None if v is not None: max_v = max(max_v, v) if max_v <= 0: max_v = 1.0 for i in range(max_rows): rr = rows[i] label = (rr[0] if rr else f"Row {i+1}")[:24] yy = chart_y + Inches(0.38) + Inches(0.18) * i add_textbox(s, label_x, yy - Inches(0.06), label_w, Inches(0.2), label, size=11, bold=True, color=T.text) for k, j in enumerate(chosen[:2]): v = _parse_minutes(rr[j]) if j < len(rr) else None if v is None: continue bw = bar_w * (v / max_v) yk = yy + Inches(0.14) * k add_rect(s, bar_x, yk, bw, Inches(0.12), fill=colors[k], line=None, radius=True) add_textbox(s, bar_x + bw + Inches(0.08), yk - Inches(0.06), Inches(1.0), Inches(0.2), str(rr[j])[:8], size=10, bold=True, color=T.muted) add_footer(s, slide_no, dark=False, footer_left=footer_left) return s def _require(cond: bool, msg: str) -> None: if not cond: raise ValueError(msg) def _as_list(x: Any) -> list[Any]: if x is None: return [] if isinstance(x, list): return x raise TypeError(f"expected list, got {type(x).__name__}") def build_ppt(spec: dict[str, Any]) -> Presentation: meta = spec.get("meta") or {} title = str(meta.get("title") or "Untitled") subtitle = meta.get("subtitle") footer_left = meta.get("footer_left") badge = meta.get("badge") style = style_from_meta(meta) slides = _as_list(spec.get("slides")) _require(len(slides) >= 20, f"slides must be >= 20 (got {len(slides)})") prs = Presentation() prs.slide_width = SLIDE_W prs.slide_height = SLIDE_H first = slides[0] _require(first.get("type") == "cover", "first slide must be type=cover") s0 = slide_cover(prs, title=title, subtitle=str(subtitle) if subtitle else None, badge=str(badge) if badge else None, footer_left=str(footer_left) if footer_left else None, style=style) set_notes(s0, first.get("notes")) slide_no = 2 for item in slides[1:]: t = item.get("type") chapter = item.get("chapter") if t == "agenda": s = slide_agenda(prs, slide_no, title=str(item.get("title") or "Agenda"), items=[str(x) for x in _as_list(item.get("items"))], footer_left=str(footer_left) if footer_left else None, style=style) elif t == "section": s = slide_section(prs, slide_no, title=str(item.get("title") or ""), subtitle=str(item.get("subtitle")) if item.get("subtitle") else None, footer_left=str(footer_left) if footer_left else None, style=style) elif t == "bullets": s = slide_bullets(prs, slide_no, title=str(item.get("title") or ""), bullets=[str(x) for x in _as_list(item.get("bullets"))], chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style) elif t == "two_column": s = slide_two_column( prs, slide_no, title=str(item.get("title") or ""), left_title=str(item.get("left_title") or "Left"), left_bullets=[str(x) for x in _as_list(item.get("left_bullets"))], right_title=str(item.get("right_title") or "Right"), right_bullets=[str(x) for x in _as_list(item.get("right_bullets"))], chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style, ) elif t == "process": s = slide_process(prs, slide_no, title=str(item.get("title") or ""), steps=[str(x) for x in _as_list(item.get("steps"))], chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style) elif t == "prompt": s = slide_prompt( prs, slide_no, title=str(item.get("title") or ""), prompt=str(item.get("prompt") or ""), tips=[str(x) for x in _as_list(item.get("tips"))] if item.get("tips") is not None else None, chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style, ) elif t == "table": s = slide_table( prs, slide_no, title=str(item.get("title") or ""), columns=[str(x) for x in _as_list(item.get("columns"))], rows=[[str(c) for c in _as_list(r)] for r in _as_list(item.get("rows"))], chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style, ) else: raise ValueError(f"unknown slide type: {t!r}") set_notes(s, item.get("notes")) slide_no += 1 return prs def load_spec(path: Path) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise TypeError("spec must be a JSON object") return data def maybe_install_deps() -> None: try: import pptx # noqa: F401 except Exception: subprocess.check_call([sys.executable, "-m", "pip", "install", "python-pptx"]) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--spec", required=True, help="slides.json path") ap.add_argument("--out", required=False, help="output .pptx path (default: /.pptx)") ap.add_argument("--install-deps", action="store_true", help="pip install python-pptx if missing") args = ap.parse_args() if args.install_deps: maybe_install_deps() spec_path = Path(args.spec).resolve() spec = load_spec(spec_path) meta = spec.get("meta") or {} title = str(meta.get("title") or spec_path.parent.name) out = Path(args.out).resolve() if args.out else (spec_path.parent / f"{title}.pptx").resolve() out.parent.mkdir(parents=True, exist_ok=True) prs = build_ppt(spec) prs.save(str(out)) print(f"wrote {out} ({out.stat().st_size:,} bytes)") if __name__ == "__main__": main()