#!/usr/bin/env python3 """경영진 보고 덱 생성기 — 이미지를 base64 로 박아 넣은 단일 HTML. WHY A GENERATOR AND NOT HAND-WRITTEN HTML 이 덱은 내용이 계속 바뀐다(수치·현재 상태·클립 추가). 손으로 쓴 HTML 은 두 번째 수정부터 낡기 시작하고, 낡은 덱은 경영진 앞에서 틀린 숫자를 띄운다. 내용은 deck.json, 도판은 docs/images/, 클립은 clips/ 에 두고 이 스크립트로 다시 뽑는다 — server/deploy/manual/ build_manual.py 와 같은 구조다. WHY CLIPS ARE REFERENCED, NOT EMBEDDED 이미지는 3MB 라 통째로 넣어도 되지만 3초 클립 6개는 10MB 를 넘어 base64 로는 감당이 안 된다. clips/ 를 상대경로로 참조하므로 **HTML 과 clips/ 폴더는 같이 옮겨야 한다.** 클립이 없는 자리는 포스터 스틸로 자동 대체되므로, 클립을 하나도 안 넣어도 덱은 완성된 상태로 나온다. WHY EVERY CLIP CARRIES A "재현" BADGE 이 클립들은 제품 동작 녹화가 아니라 연출된 재현이다. 실제 동작처럼 보여주면 허위 시연이 된다. 발표자가 깜빡할 수 있는 종류의 것이라 배지를 템플릿에 박아 지울 수 없게 했다. 실화면 캡처(server/deploy/manual/shots/)에는 이 배지가 붙지 않는다 — 그건 진짜다. 사용법: python docs/deck/build_deck.py # -> docs/deck/경영진-보고.html python docs/deck/build_deck.py --out X.html """ from __future__ import annotations import argparse import base64 import datetime import html import json import pathlib import sys HERE = pathlib.Path(__file__).resolve().parent REPO = HERE.parents[1] IMAGES = REPO / "docs" / "images" CLIPS = HERE / "clips" MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"} def data_uri(name: str) -> str: p = IMAGES / name if not p.is_file(): # 조용히 넘기면 도판 빠진 덱이 회의실에서 발견된다. raise SystemExit(f"ERROR: 이미지가 없습니다: {p}") mime = MIME.get(p.suffix.lower()) if mime is None: raise SystemExit(f"ERROR: 지원하지 않는 이미지 형식: {p}") return f"data:{mime};base64," + base64.b64encode(p.read_bytes()).decode("ascii") CSS = """ :root{ --ink:#131A26; --ink-2:#3A4354; --ink-3:#5C6474; --line:#DCE2EA; --line-2:#EDF1F5; --paper:#FFFFFF; --wash:#F4F6F9; --navy:#003478; --navy-2:#0A4C9E; --navy-wash:#EAF0F9; --alarm:#C1121F; --alarm-wash:#FBEDEE; --caution:#8A6200; --caution-wash:#FBF4E4; --ok:#14614A; --ok-wash:#E9F3EF; --rail:64px; --font:"Pretendard","Noto Sans KR","Malgun Gothic","Apple SD Gothic Neo",system-ui,sans-serif; --mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace; } *{box-sizing:border-box} @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} body{margin:0;background:var(--wash);color:var(--ink);font-family:var(--font); line-height:1.6;-webkit-font-smoothing:antialiased; /* CJK 기본 줄바꿈은 글자 단위라 "설명할 수 있습/니다" 처럼 어절이 잘린다. */ word-break:keep-all;overflow-wrap:break-word} /* 좌측 레일 — 모든 슬라이드에 같은 자리에 있어 덱의 정체성이 된다. 가운데정렬 카드 나열을 피하려는 의도적 선택: 시선이 항상 왼쪽 같은 지점에서 출발한다. */ .rail{position:fixed;left:0;top:0;bottom:0;width:var(--rail);background:var(--navy);z-index:20; display:flex;flex-direction:column;align-items:center;justify-content:space-between; padding:1.1rem 0;color:#fff} .rail .mark{font-size:.62rem;font-weight:700;letter-spacing:.18em;writing-mode:vertical-rl; text-orientation:mixed;opacity:.92} .rail .dots{display:flex;flex-direction:column} .rail .dots a{width:24px;height:24px;display:flex;align-items:center;justify-content:center} .rail .dots a::before{content:"";width:7px;height:7px;border-radius:50%; background:rgba(255,255,255,.34);transition:background .15s} .rail .dots a:hover::before,.rail .dots a:focus-visible::before{background:#fff} .rail .dots a[aria-current="true"]::before{background:#fff} .rail a:focus-visible{outline:2px solid #fff;outline-offset:3px} .deck{margin-left:var(--rail);scroll-snap-type:y mandatory;overflow-y:auto;height:100vh} section.slide{min-height:100vh;scroll-snap-align:start;display:flex;flex-direction:column; justify-content:center;padding:4.2vh 5vw 6vh;position:relative;background:var(--paper); border-bottom:1px solid var(--line)} /* 표지·마무리는 자기 배경을 가진다. :nth-child(even) 는 명시도가 더 높아 그대로 두면 네이비 배경을 덮어쓰고, 그 위의 흰 글자가 흰 배경에 얹혀 슬라이드가 통째로 백지가 된다. */ section.slide:not(.cover):not(.closing):nth-child(even){background:var(--wash)} .num{position:absolute;right:2.2vw;top:3.4vh;font-family:var(--mono);font-size:.72rem; color:var(--ink-3);letter-spacing:.06em} .eyebrow{margin:0 0 .55rem;font-size:.72rem;font-weight:700;letter-spacing:.16em; color:var(--navy);text-transform:none} .eyebrow::before{content:"";display:inline-block;width:22px;height:2px;background:var(--navy); vertical-align:middle;margin-right:.6rem} h2.title{margin:0 0 1.1rem;font-size:clamp(1.7rem,3.3vw,2.9rem);line-height:1.24; letter-spacing:-.025em;font-weight:800;max-width:22ch} h2.title.wide{max-width:34ch} .lead{margin:0 0 1.4rem;font-size:clamp(1rem,1.35vw,1.18rem);color:var(--ink-2);max-width:56ch} .note{margin:1.15rem 0 0;padding-left:.95rem;border-left:3px solid var(--navy); font-size:.94rem;color:var(--ink-2);max-width:74ch} /* 표지 */ section.cover{padding:0;background:var(--navy);color:#fff;justify-content:flex-end;overflow:hidden} section.cover .shot{position:absolute;inset:0} section.cover .shot img{width:100%;height:100%;object-fit:cover;opacity:.52} section.cover .veil{position:absolute;inset:0; background:linear-gradient(180deg,rgba(0,26,60,.22) 0%,rgba(0,26,60,.62) 55%,rgba(0,20,46,.94) 100%)} section.cover .inner{position:relative;padding:0 5vw 8vh} section.cover .eyebrow{color:#9DC0EE} section.cover .eyebrow::before{background:#9DC0EE} section.cover h1{margin:.2rem 0 .8rem;font-size:clamp(2.2rem,5.4vw,4.4rem);line-height:1.1; letter-spacing:-.035em;font-weight:800} section.cover .lead{color:#DCE7F6;font-size:clamp(1.02rem,1.7vw,1.4rem);max-width:44ch;margin-bottom:1.6rem} section.cover .foot{font-family:var(--mono);font-size:.76rem;color:#8FB2E2;letter-spacing:.04em; border-top:1px solid rgba(255,255,255,.22);padding-top:.9rem;display:inline-block} /* 3열 진술 */ .cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;margin-top:.5rem; border-top:2px solid var(--ink)} .cols .col{padding:1.15rem 1.5rem 0 0;min-width:0} .cols .col + .col{padding-left:1.5rem;border-left:1px solid var(--line)} .cols h3{margin:0 0 .5rem;font-size:1.02rem;font-weight:700;letter-spacing:-.01em} .cols p{margin:0;font-size:.92rem;color:var(--ink-2)} /* 도판 */ .figure{margin:.3rem 0 0;display:grid;grid-template-columns:minmax(0,1fr);gap:.7rem} .figure img{width:100%;height:auto;display:block;border:1px solid var(--line); background:#fff;max-height:50vh;object-fit:contain} .media{position:relative;border:1px solid var(--line);background:#0B1220;overflow:hidden; display:flex;align-items:center;justify-content:center} .media video,.media img{width:auto;max-width:100%;height:auto;display:block;max-height:46vh} /* 스틸 대체 컷은 도판 자체가 흰 바탕이라, 영상용 검은 레터박스를 두르면 무겁고 지저분하다. */ .media.still{background:#fff} .media.still.tall img{max-height:54vh} /* 재현 배지 — 지울 수 없게 템플릿에 박아 둔다. 파일 D-0 참조. */ .badge-reenact{position:absolute;left:.7rem;top:.7rem;z-index:2;background:var(--alarm);color:#fff; font-size:.68rem;font-weight:700;letter-spacing:.1em;padding:.28rem .6rem; border-radius:2px} .missing{position:absolute;right:.7rem;top:.7rem;z-index:2;background:rgba(255,255,255,.86); color:var(--ink-3);font-size:.64rem;padding:.28rem .6rem;border-radius:2px; font-family:var(--mono);border:1px solid var(--line)} figcaption{font-size:.86rem;color:var(--ink-3)} .split{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:2rem;align-items:start} @media (max-width:900px){.split{grid-template-columns:minmax(0,1fr)}} ul.points{margin:.2rem 0 0;padding:0;list-style:none} ul.points li{position:relative;padding:.6rem 0 .6rem 1.15rem;border-bottom:1px solid var(--line-2); font-size:.95rem;color:var(--ink-2)} ul.points li::before{content:"";position:absolute;left:0;top:1.05rem;width:5px;height:5px; background:var(--navy);border-radius:50%} .kicker{margin:1rem 0 0;padding:.75rem .9rem;background:var(--navy-wash); border-left:3px solid var(--navy);font-size:.9rem;color:var(--ink-2)} /* 좌우 비교 */ .compare{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.4rem} @media (max-width:900px){.compare{grid-template-columns:minmax(0,1fr)}} .pane{min-width:0} .pane .tag{display:inline-block;font-size:.74rem;font-weight:700;letter-spacing:.06em; padding:.3rem .7rem;border-radius:2px;margin-bottom:.55rem} .pane.ok .tag{background:var(--ok-wash);color:var(--ok);border:1px solid #BFD9CF} .pane.alarm .tag{background:var(--alarm-wash);color:var(--alarm);border:1px solid #EFC9CC} .pane p{margin:.55rem 0 0;font-size:.92rem;color:var(--ink-2)} /* 지표 */ .metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;border-top:2px solid var(--ink)} @media (max-width:800px){.metrics{grid-template-columns:minmax(0,1fr)}} .metrics .m{padding:1.1rem 1.6rem 1.1rem 0;border-bottom:1px solid var(--line);min-width:0} .metrics .m:nth-child(even){padding-left:1.6rem;border-left:1px solid var(--line)} .metrics h3{margin:0 0 .35rem;font-size:1.02rem;color:var(--navy);font-weight:700} .metrics p{margin:0;font-size:.92rem;color:var(--ink-2)} /* 현재 상태 */ .status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.6rem} @media (max-width:900px){.status{grid-template-columns:minmax(0,1fr)}} .status .box{padding:1.05rem 1.2rem;border:1px solid var(--line);background:#fff;min-width:0} .status .box.done{border-top:3px solid var(--ok)} .status .box.pending{border-top:3px solid var(--caution)} .status h3{margin:0 0 .6rem;font-size:.98rem;font-weight:700} .status .box.done h3{color:var(--ok)} .status .box.pending h3{color:var(--caution)} .status ul{margin:0;padding-left:1.05rem} .status li{font-size:.91rem;color:var(--ink-2);margin:.32rem 0} /* 마무리 */ section.closing{background:var(--navy);color:#fff} section.closing .eyebrow{color:#9DC0EE} section.closing .eyebrow::before{background:#9DC0EE} section.closing h2.title{color:#fff} section.closing .lines{margin:.6rem 0 0;font-size:clamp(1.05rem,2vw,1.6rem);line-height:1.7; max-width:38ch;color:#DCE7F6} section.closing .lines b{color:#fff;font-weight:800} section.closing .num{color:#8FB2E2} section.closing .foot{margin-top:2rem;font-family:var(--mono);font-size:.78rem;color:#8FB2E2; letter-spacing:.22em;border-top:1px solid rgba(255,255,255,.22); padding-top:.9rem;display:inline-block} .nav{position:fixed;right:1.1rem;bottom:3.2rem;z-index:21;display:flex;gap:.5rem} .nav button{width:46px;height:46px;border-radius:3px;border:1px solid var(--line); background:rgba(255,255,255,.94);color:var(--navy);font-size:1.1rem;line-height:1; cursor:pointer;display:flex;align-items:center;justify-content:center} .nav button:hover{background:var(--navy);color:#fff;border-color:var(--navy)} .nav button:focus-visible{outline:2px solid var(--navy);outline-offset:2px} .deck:focus{outline:none} .help{position:fixed;right:1rem;bottom:1rem;z-index:20;font-family:var(--mono);font-size:.68rem; color:var(--ink-3);background:rgba(255,255,255,.9);border:1px solid var(--line); padding:.35rem .6rem;border-radius:3px} @media print{ .rail,.help{display:none} .deck{margin-left:0;height:auto;overflow:visible} section.slide{min-height:auto;break-after:page;border-bottom:none;padding:1.2cm} section.cover,section.closing{-webkit-print-color-adjust:exact;print-color-adjust:exact} .media video,.media img,.figure img{max-height:12cm} } """ JS = """ (function(){ var slides = Array.prototype.slice.call(document.querySelectorAll('section.slide')); var dots = Array.prototype.slice.call(document.querySelectorAll('.rail .dots a')); var idx = 0; function show(i){ dots.forEach(function(d, k){ d.setAttribute('aria-current', k === i ? 'true' : 'false'); }); slides.forEach(function(s, k){ s.querySelectorAll('video').forEach(function(v){ // 슬라이드에 들어올 때만 처음부터 재생. 자동 반복은 하지 않는다 — 넘어지는 장면이 // 무한 루프로 도는 것은 회의실에서 보기 불편하다. if (k === i) { try { v.currentTime = 0; v.play(); } catch (err) {} } else { v.pause(); } }); }); } if (window.IntersectionObserver) { var obs = new IntersectionObserver(function(entries){ entries.forEach(function(e){ if (!e.isIntersecting) return; idx = slides.indexOf(e.target); // 스크롤·클릭으로 옮겨간 경우도 따라잡는다 show(idx); }); }, { threshold: 0.55 }); slides.forEach(function(s){ obs.observe(s); }); } // idx 를 즉시 올리고 이동시킨다. 연타해도 누른 횟수만큼 넘어간다. function go(delta){ idx = Math.max(0, Math.min(slides.length - 1, idx + delta)); slides[idx].scrollIntoView({ block: 'start' }); // 'smooth' 는 스냅 컨테이너에서 취소된다 show(idx); } var NEXT = { ArrowDown:1, ArrowRight:1, PageDown:1, ' ':1, Enter:1 }; var PREV = { ArrowUp:1, ArrowLeft:1, PageUp:1 }; document.addEventListener('keydown', function(e){ if (e.ctrlKey || e.altKey || e.metaKey) return; if (NEXT[e.key]) { e.preventDefault(); go(1); } else if (PREV[e.key]) { e.preventDefault(); go(-1); } else if (e.key === 'Home') { e.preventDefault(); idx = 1; go(-1); } else if (e.key === 'End') { e.preventDefault(); idx = slides.length - 2; go(1); } }); dots.forEach(function(d, k){ d.addEventListener('click', function(){ idx = k; }); }); var prevBtn = document.querySelector('.nav-prev'); var nextBtn = document.querySelector('.nav-next'); if (prevBtn) prevBtn.addEventListener('click', function(){ go(-1); }); if (nextBtn) nextBtn.addEventListener('click', function(){ go(1); }); // .deck 이 스크롤 컨테이너인데 포커스를 못 받으면 방향키가 문서 스크롤러로 가버려 // 아무 일도 일어나지 않는다. 포커스 가능하게 만들고 처음에 한 번 준다. var deck = document.querySelector('.deck'); if (deck) { deck.setAttribute('tabindex', '-1'); try { deck.focus({preventScroll:true}); } catch (e) {} } show(0); })(); """ def esc(s: str) -> str: return html.escape(str(s), quote=True) def media_block(clip: str | None, poster: str, alt: str) -> str: """클립이 있으면 video, 없으면 포스터 스틸. 어느 쪽이든 재현 배지를 단다. 배지 문구는 실제 매체를 따른다 — 정지 이미지에 "재현 영상"이라고 붙이는 것 자체가 작은 거짓말이고, 이 덱의 존재 이유가 그 반대다. """ live = bool(clip) and (CLIPS / clip).is_file() out = [f'
', f'재현 {"영상" if live else "이미지"}'] if live: out.append( f'') else: out.append('클립 대기') out.append(f'{esc(alt)} (재현 이미지)') out.append("
") return "".join(out) def has_clip(name: str | None) -> bool: return bool(name) and (CLIPS / name).is_file() def head(sl: dict) -> str: o = [] if sl.get("eyebrow"): o.append(f'

{esc(sl["eyebrow"])}

') if sl.get("title"): wide = " wide" if len(sl["title"]) > 34 else "" o.append(f'

{sl["title"]}

') # 의도된 인라인 마크업 허용 if sl.get("lead"): o.append(f'

{esc(sl["lead"])}

') return "".join(o) def note(sl: dict) -> str: return f'

{esc(sl["note"])}

' if sl.get("note") else "" def render_slide(sl: dict, idx: int) -> str: kind = sl["kind"] body = [] if kind == "cover": return (f'
' f'
' f'
' f'

{esc(sl["eyebrow"])}

' f'

{esc(sl["title"])}

' f'

{esc(sl["lead"])}

' f'{esc(sl["foot"])}' f'
') if kind == "closing": lines = "".join(f"
{ln}
" for ln in sl["lines"]) # 의도된 허용 return (f'
' f'{idx:02d}' f'

{esc(sl["eyebrow"])}

' f'

{esc(sl["title"])}

' f'
{lines}
' f'{esc(sl["foot"])}
') body.append(head(sl)) if kind == "statement": cols = "".join(f'

{esc(c["head"])}

{esc(c["body"])}

' for c in sl["columns"]) body.append(f'
{cols}
') elif kind == "figure": body.append(f'
') elif kind == "clip": pts = "".join(f"
  • {esc(p)}
  • " for p in sl.get("points", [])) right = f'' if sl.get("kicker"): right += f'

    {esc(sl["kicker"])}

    ' body.append( '
    ' + media_block(sl.get("clip"), sl["poster"], sl.get("title", "")) + (f'
    {esc(sl["caption"])}
    ' if sl.get("caption") else "") + f'
    {right}
    ') elif kind == "compare": # 09 번 스틸은 그 자체가 이미 좌우 비교 그림이다. 클립이 없는 상태에서 좌우 패널로 # 쪼개 각 칸에 넣으면 같은 비교가 두 겹으로 들어가 오히려 읽히지 않는다. if sl.get("fallback") and not (has_clip(sl["left"].get("clip")) or has_clip(sl["right"].get("clip"))): body.append('
    ' '재현 이미지' f'
    ') else: panes = [] for side in ("left", "right"): pane = sl[side] panes.append( f'
    {esc(pane["label"])}' + media_block(pane.get("clip"), pane["poster"], pane["label"]) + f'

    {esc(pane["body"])}

    ') body.append(f'
    {"".join(panes)}
    ') elif kind == "metrics": items = "".join(f'

    {esc(m["head"])}

    {esc(m["body"])}

    ' for m in sl["items"]) body.append(f'
    {items}
    ') elif kind == "status": def box(cls: str, blk: dict) -> str: li = "".join(f"
  • {esc(i)}
  • " for i in blk["items"]) return f'

    {esc(blk["head"])}

    ' body.append('
    ' + box("done", sl["done"]) + box("pending", sl["pending"]) + "
    ") else: raise SystemExit(f"ERROR: 알 수 없는 슬라이드 종류: {kind}") body.append(note(sl)) return (f'
    {idx:02d}' + "".join(body) + "
    ") def render(doc: dict, version: str) -> str: slides = doc["slides"] dots = "".join(f'' for i in range(len(slides))) body = "".join(render_slide(sl, i) for i, sl in enumerate(slides)) today = datetime.date.today().isoformat() return ( '' '' f"{esc(doc['title'])} — {esc(doc['audience'])}" f"" f'' f'
    {body}
    ' '' '

    ← → 또는 버튼으로 이동 · F11 전체화면

    ' f"") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--out", default=str(HERE / "경영진-보고.html")) args = ap.parse_args() vf = REPO / "VERSION" if not vf.is_file(): raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 보고 자료는 만들지 않습니다.") version = vf.read_text(encoding="utf-8").strip() doc = json.loads((HERE / "deck.json").read_text(encoding="utf-8")) out = pathlib.Path(args.out) out.write_text(render(doc, version), encoding="utf-8") wanted, have = [], [] for sl in doc["slides"]: for c in ([sl.get("clip")] if sl.get("clip") else []) + \ [sl[s].get("clip") for s in ("left", "right") if isinstance(sl.get(s), dict)]: if c and c not in wanted: wanted.append(c) (have if (CLIPS / c).is_file() else []).append(c) missing = [c for c in wanted if c not in have] print(f"{out} ({out.stat().st_size/1024:.0f} KB, 슬라이드 {len(doc['slides'])}장)", file=sys.stderr) if missing: print(f"클립 대기 {len(missing)}/{len(wanted)} — 해당 자리는 스틸로 대체됨: " + ", ".join(missing), file=sys.stderr) else: print("클립 전량 배치됨. HTML 과 clips/ 폴더를 같이 옮기십시오.", file=sys.stderr) if __name__ == "__main__": main()