Files
ax_document/AX 강의 - 1/_common/make_ppt.py

1083 lines
42 KiB
Python

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: <spec-dir>/<title>.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()