받은 이미지 11장을 docs/images/ 로 최적화 배치(37.6MB -> 3.3MB: 사진은
JPEG q88, 평면 다이어그램은 PNG-8, 최대폭 1600). 원본은 image/ 에 그대로 둔다.
소개서: 매니페스트(docs/소개서-이미지.json)로 11장을 절에 배치해 HTML 재생성
(4.6MB 단일 파일). 표지 컷은 1장 앞에 before-heading 으로.
경영진 보고 덱(docs/deck/): 14장. 매뉴얼 생성기와 같은 구조 —
deck.json(내용) + build_deck.py(생성기) + clips/(3초 클립). 손으로 쓴 HTML 은
두 번째 수정부터 낡고, 낡은 덱은 회의실에서 틀린 숫자를 띄운다.
클립이 없으면 포스터 스틸로 자동 대체되므로 지금 상태로도 완성본이다.
비교 슬라이드는 클립이 하나도 없으면 이미 좌우 비교인 09 스틸 1장으로
바뀐다 — 좌우 패널에 쪼개 넣으면 같은 비교가 두 겹이 된다.
모든 클립·스틸에 '재현' 배지를 템플릿에 박았다. 이 영상들은 제품 동작
녹화가 아니라 연출된 재현이고, 실제 동작처럼 보여주면 허위 시연이 된다.
배지 문구는 매체를 따른다(영상/이미지) — 스틸에 "재현 영상"은 그 자체가
작은 거짓말이다. 실화면 캡처 8장에는 배지가 붙지 않는다.
실렌더로 확인하며 잡은 결함:
- 마지막 슬라이드가 백지였다. :nth-child(even) 이 section.closing 보다
명시도가 높아 네이비 배경을 덮고, 흰 글자가 흰 배경에 얹혔다(대비 1:1).
- 한국어 제목이 어절 중간에서 끊겼다("설명할 수 있습/니다") — CJK 기본
줄바꿈이 글자 단위다. word-break:keep-all.
- 슬라이드 이동이 전혀 동작하지 않았다. scroll-snap-type:y mandatory 인
컨테이너에서 behavior:'smooth' 가 취소되고 원위치로 스냅된다
(측정: smooth->0, auto->905). 즉시 전환으로 변경 — 덱에서는 컷 전환이
오히려 슬라이드답다. 키보드가 항상 잡히는 것은 아니라 이전/다음 버튼도 추가.
- 도판이 커서 하단 주석이 화면 밖으로 밀렸다(max-height 하향).
- 표지 베일이 무거워 경광등 빨강이 죽었다 — 이 제품의 상징색이라 살렸다.
- 4번과 5번이 같은 도판을 썼다(포스터 교체).
접근성 게이트(측정): 대비 실패 0건. figcaption 4.35:1 미달로 --ink-3 을
#5C6474 로, 네이비 위 슬라이드 번호 2.0:1 을 #8FB2E2 로 교정. 레일 점은
7x7 이라 발표 중 못 맞춘다 — 점은 그대로 두고 히트영역 24x24(WCAG 2.5.8),
이전/다음 버튼 46x46.
영상 브리프(docs/영상-브리프.md): 3초 클립 6종. 스틸이 못 하는 것만 발주
했고, 클립마다 0.0~3.0초 타임라인·프레임 안에 반드시 있어야 할 것·피할 것을
적었다. 대시보드 녹화는 제외 — 실화면 캡처가 이미 있고 그건 진짜다.
생성물(경영진-보고.html)과 clips/ 는 gitignore — 사용설명서.html 과 같은 이유.
470 lines
23 KiB
Python
470 lines
23 KiB
Python
#!/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'<div class="media{"" if live else " still"}">',
|
|
f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>']
|
|
if live:
|
|
out.append(
|
|
f'<video src="clips/{esc(clip)}" poster="{data_uri(poster)}" muted playsinline '
|
|
f'preload="metadata" controls aria-label="{esc(alt)} (재현 영상)"></video>')
|
|
else:
|
|
out.append('<span class="missing">클립 대기</span>')
|
|
out.append(f'<img src="{data_uri(poster)}" alt="{esc(alt)} (재현 이미지)">')
|
|
out.append("</div>")
|
|
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'<p class="eyebrow">{esc(sl["eyebrow"])}</p>')
|
|
if sl.get("title"):
|
|
wide = " wide" if len(sl["title"]) > 34 else ""
|
|
o.append(f'<h2 class="title{wide}">{sl["title"]}</h2>') # 의도된 인라인 마크업 허용
|
|
if sl.get("lead"):
|
|
o.append(f'<p class="lead">{esc(sl["lead"])}</p>')
|
|
return "".join(o)
|
|
|
|
|
|
def note(sl: dict) -> str:
|
|
return f'<p class="note">{esc(sl["note"])}</p>' if sl.get("note") else ""
|
|
|
|
|
|
def render_slide(sl: dict, idx: int) -> str:
|
|
kind = sl["kind"]
|
|
body = []
|
|
|
|
if kind == "cover":
|
|
return (f'<section class="slide cover" id="s{idx}">'
|
|
f'<div class="shot"><img src="{data_uri(sl["image"])}" alt=""></div>'
|
|
f'<div class="veil"></div><div class="inner">'
|
|
f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>'
|
|
f'<h1>{esc(sl["title"])}</h1>'
|
|
f'<p class="lead">{esc(sl["lead"])}</p>'
|
|
f'<span class="foot">{esc(sl["foot"])}</span>'
|
|
f'</div></section>')
|
|
|
|
if kind == "closing":
|
|
lines = "".join(f"<div>{ln}</div>" for ln in sl["lines"]) # 의도된 <b> 허용
|
|
return (f'<section class="slide closing" id="s{idx}">'
|
|
f'<span class="num">{idx:02d}</span>'
|
|
f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>'
|
|
f'<h2 class="title">{esc(sl["title"])}</h2>'
|
|
f'<div class="lines">{lines}</div>'
|
|
f'<span class="foot">{esc(sl["foot"])}</span></section>')
|
|
|
|
body.append(head(sl))
|
|
|
|
if kind == "statement":
|
|
cols = "".join(f'<div class="col"><h3>{esc(c["head"])}</h3><p>{esc(c["body"])}</p></div>'
|
|
for c in sl["columns"])
|
|
body.append(f'<div class="cols">{cols}</div>')
|
|
|
|
elif kind == "figure":
|
|
body.append(f'<figure class="figure"><img src="{data_uri(sl["image"])}" '
|
|
f'alt="{esc(sl.get("title", ""))}"></figure>')
|
|
|
|
elif kind == "clip":
|
|
pts = "".join(f"<li>{esc(p)}</li>" for p in sl.get("points", []))
|
|
right = f'<ul class="points">{pts}</ul>'
|
|
if sl.get("kicker"):
|
|
right += f'<p class="kicker">{esc(sl["kicker"])}</p>'
|
|
body.append(
|
|
'<div class="split"><figure class="figure">'
|
|
+ media_block(sl.get("clip"), sl["poster"], sl.get("title", ""))
|
|
+ (f'<figcaption>{esc(sl["caption"])}</figcaption>' if sl.get("caption") else "")
|
|
+ f'</figure><div>{right}</div></div>')
|
|
|
|
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('<figure class="figure"><div class="media still tall">'
|
|
'<span class="badge-reenact">재현 이미지</span>'
|
|
f'<img src="{data_uri(sl["fallback"])}" '
|
|
f'alt="{esc(sl.get("title", ""))} (재현 이미지)"></div></figure>')
|
|
else:
|
|
panes = []
|
|
for side in ("left", "right"):
|
|
pane = sl[side]
|
|
panes.append(
|
|
f'<div class="pane {esc(pane["tone"])}"><span class="tag">{esc(pane["label"])}</span>'
|
|
+ media_block(pane.get("clip"), pane["poster"], pane["label"])
|
|
+ f'<p>{esc(pane["body"])}</p></div>')
|
|
body.append(f'<div class="compare">{"".join(panes)}</div>')
|
|
|
|
elif kind == "metrics":
|
|
items = "".join(f'<div class="m"><h3>{esc(m["head"])}</h3><p>{esc(m["body"])}</p></div>'
|
|
for m in sl["items"])
|
|
body.append(f'<div class="metrics">{items}</div>')
|
|
|
|
elif kind == "status":
|
|
def box(cls: str, blk: dict) -> str:
|
|
li = "".join(f"<li>{esc(i)}</li>" for i in blk["items"])
|
|
return f'<div class="box {cls}"><h3>{esc(blk["head"])}</h3><ul>{li}</ul></div>'
|
|
body.append('<div class="status">' + box("done", sl["done"])
|
|
+ box("pending", sl["pending"]) + "</div>")
|
|
|
|
else:
|
|
raise SystemExit(f"ERROR: 알 수 없는 슬라이드 종류: {kind}")
|
|
|
|
body.append(note(sl))
|
|
return (f'<section class="slide" id="s{idx}"><span class="num">{idx:02d}</span>'
|
|
+ "".join(body) + "</section>")
|
|
|
|
|
|
def render(doc: dict, version: str) -> str:
|
|
slides = doc["slides"]
|
|
dots = "".join(f'<a href="#s{i}" aria-label="슬라이드 {i}"></a>' for i in range(len(slides)))
|
|
body = "".join(render_slide(sl, i) for i, sl in enumerate(slides))
|
|
today = datetime.date.today().isoformat()
|
|
return (
|
|
'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
f"<title>{esc(doc['title'])} — {esc(doc['audience'])}</title>"
|
|
f"<style>{CSS}</style></head><body>"
|
|
f'<nav class="rail"><span class="mark">{esc(doc["title"])}</span>'
|
|
f'<span class="dots">{dots}</span>'
|
|
f'<span class="mark">v{esc(version)} · {today}</span></nav>'
|
|
f'<main class="deck">{body}</main>'
|
|
'<div class="nav">'
|
|
'<button class="nav-prev" type="button" aria-label="이전 슬라이드">↑</button>'
|
|
'<button class="nav-next" type="button" aria-label="다음 슬라이드">↓</button>'
|
|
'</div>'
|
|
'<p class="help">← → 또는 버튼으로 이동 · F11 전체화면</p>'
|
|
f"<script>{JS}</script></body></html>")
|
|
|
|
|
|
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()
|