1083 lines
42 KiB
Python
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()
|
|
|