573 lines
25 KiB
Python
573 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Consulting deliverable renderer — storyline(.report.yaml) → 문서 + PPT.
|
|
|
|
한 소스(EM synthesis 보고서의 storyline/narrative)에서 두 산출물을 drift 없이 생성:
|
|
① 문서: <name>-report.md (장문 — BLUF·SCQA·권고·근거·이견, exhibit embed)
|
|
② 덱 : <name>-deck.md (Marp) + <name>-deck.html (self-contained, 오프라인 발표)
|
|
└ --marp면 marp-cli로 .pptx/.pdf/.html export(chrome 필요). 실패해도 HTML 덱은 보장.
|
|
|
|
exhibit 2계열: ①정량·개념 = consult_exhibits.py 손제작 SVG 아키타입 7종.
|
|
②소프트웨어 구조·흐름·의존성 = {type: d2}(1급, d2 CLI 실물 렌더) 우선, {type: mermaid}는 폴백.
|
|
방법론(액션타이틀·one-message-per-slide·Pyramid)을 렌더러가 구조로 강제한다(픽셀이 아니라 논리).
|
|
|
|
Usage:
|
|
python3 render_consult.py <report.yaml> [--outdir DIR] [--name NAME] [--marp]
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import yaml
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import consult_exhibits as CE # noqa: E402
|
|
|
|
|
|
def slugify(s, fallback="deck"):
|
|
s = re.sub(r"[^\w가-힣\- ]", "", str(s or "")).strip().lower()
|
|
s = re.sub(r"[\s_]+", "-", s)
|
|
return s[:60] or fallback
|
|
|
|
|
|
def _mkdir(p):
|
|
os.makedirs(p, exist_ok=True)
|
|
return p
|
|
|
|
|
|
# ------------------------------------------------------------ mermaid (실제 다이어그램 산출)
|
|
def _mermaid_fallback_svg(code):
|
|
"""mmdc 미가용 시: 코드를 monospace로 보여주는 폴백 SVG(파이프라인 유지, 오프라인 안전).
|
|
**열화(degraded)로 표시** — 실물 렌더가 아님을 배너+기계마커로 명시한다(성공 위장 금지)."""
|
|
return CE.degraded_svg("mermaid", code=code, title="Mermaid 다이어그램")
|
|
|
|
|
|
def render_mermaid(code, workdir):
|
|
"""Mermaid 코드를 mmdc(+system chrome)로 SVG 렌더. 실패/미가용 시 코드 폴백 SVG.
|
|
diagram-as-code 실물 산출 — 플로우·시퀀스·C4풍·의존성 그래프 등 7 아키타입이 못 그리는 그림."""
|
|
if not str(code).strip():
|
|
return None
|
|
_mkdir(workdir)
|
|
if os.environ.get("RENDER_CONSULT_NO_MMDC"):
|
|
return _mermaid_fallback_svg(code)
|
|
h = hashlib.md5(code.encode("utf-8")).hexdigest()[:8] # 결정적 임시명(재현 안전)
|
|
mmd = os.path.join(workdir, f"_mmd-{h}.mmd")
|
|
outp = os.path.join(workdir, f"_mmd-{h}.svg")
|
|
cfg = os.path.join(workdir, "_pptr.json")
|
|
with open(mmd, "w") as f:
|
|
f.write(str(code))
|
|
if not os.path.exists(cfg):
|
|
chrome = "/usr/bin/google-chrome"
|
|
exe = chrome if os.path.exists(chrome) else ""
|
|
with open(cfg, "w") as f:
|
|
f.write('{"executablePath":"%s","args":["--no-sandbox","--disable-gpu"]}' % exe)
|
|
try:
|
|
r = subprocess.run(
|
|
["npx", "--yes", "-p", "@mermaid-js/mermaid-cli", "mmdc",
|
|
"-i", mmd, "-o", outp, "-p", cfg, "-b", "transparent"],
|
|
capture_output=True, text=True, timeout=200)
|
|
if r.returncode == 0 and os.path.exists(outp):
|
|
svg = open(outp).read()
|
|
for p in (mmd, outp):
|
|
try:
|
|
os.remove(p)
|
|
except OSError:
|
|
pass
|
|
return svg
|
|
sys.stderr.write(f"[mermaid] failed rc={r.returncode}: {r.stderr[-200:]}\n")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
sys.stderr.write(f"[mermaid] skipped: {e}\n")
|
|
return _mermaid_fallback_svg(code)
|
|
|
|
|
|
# ------------------------------------------------------------ D2 (1급 diagram-as-code — Mermaid보다 실무급)
|
|
def _d2_bin():
|
|
"""d2 실행파일 위치: PATH → ~/.local/bin/d2(무루트 설치) 순."""
|
|
b = shutil.which("d2")
|
|
if b:
|
|
return b
|
|
cand = os.path.expanduser("~/.local/bin/d2")
|
|
return cand if os.path.exists(cand) else None
|
|
|
|
|
|
def _d2_fallback_svg(code):
|
|
"""d2 미가용 시: 코드를 monospace로 보여주는 폴백 SVG(파이프라인 유지, 오프라인 안전).
|
|
**열화(degraded)로 표시** — 실물 렌더가 아님을 배너+기계마커로 명시한다(성공 위장 금지)."""
|
|
return CE.degraded_svg("d2", code=code, title="D2 다이어그램")
|
|
|
|
|
|
def render_d2(ex, workdir):
|
|
"""D2 코드를 d2 CLI로 SVG 렌더. 소프트웨어 아키텍처·의존성·중첩 컨테이너 —
|
|
레이아웃엔진(dagre/elk)·테마·컨테이너로 Mermaid보다 실무급. 실패/미가용 시 코드 폴백.
|
|
exhibit 스키마: {type: d2, code, layout?: dagre|elk, theme?: int, sketch?: bool, pad?: int}."""
|
|
code = ex.get("code", "") if isinstance(ex, dict) else str(ex)
|
|
if not str(code).strip():
|
|
return None
|
|
_mkdir(workdir)
|
|
if os.environ.get("RENDER_CONSULT_NO_D2"):
|
|
return _d2_fallback_svg(code)
|
|
d2 = _d2_bin()
|
|
if not d2:
|
|
return _d2_fallback_svg(code)
|
|
h = hashlib.md5(str(code).encode("utf-8")).hexdigest()[:8] # 결정적 임시명(재현 안전)
|
|
src = os.path.join(workdir, f"_d2-{h}.d2")
|
|
outp = os.path.join(workdir, f"_d2-{h}.svg")
|
|
with open(src, "w") as f:
|
|
f.write(str(code))
|
|
o = ex if isinstance(ex, dict) else {}
|
|
cmd = [d2, "--pad", str(o.get("pad", 16)), "--theme", str(o.get("theme", 0))]
|
|
if o.get("layout"):
|
|
cmd += ["--layout", str(o["layout"])]
|
|
if o.get("sketch"):
|
|
cmd += ["--sketch"]
|
|
cmd += [src, outp]
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
if r.returncode == 0 and os.path.exists(outp):
|
|
svg = open(outp).read()
|
|
for p in (src, outp):
|
|
try:
|
|
os.remove(p)
|
|
except OSError:
|
|
pass
|
|
return svg
|
|
sys.stderr.write(f"[d2] failed rc={r.returncode}: {r.stderr[-200:]}\n")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
sys.stderr.write(f"[d2] skipped: {e}\n")
|
|
return _d2_fallback_svg(code)
|
|
|
|
|
|
# ------------------------------------------------------------ exhibit handling
|
|
def collect_exhibits(slides, narrative, imgdir):
|
|
"""slide/narrative의 exhibit을 SVG로 렌더해 파일로 쓰고 (map, degraded[]) 반환.
|
|
엔진 우선순위: type=d2(실무급 diagram-as-code, 1급) → type=mermaid(폴백) → consult_exhibits 7 아키타입(정량·개념).
|
|
degraded[] = 실물 렌더 실패로 폴백 SVG(코드 텍스트)로 대체된 exhibit 목록 — main()이 이를
|
|
성공으로 위장하지 않고 stderr·stdout·render.json으로 보고한다."""
|
|
_mkdir(imgdir)
|
|
out = {}
|
|
degraded = []
|
|
idx = 0
|
|
for kind, items in (("s", slides), ("n", narrative)):
|
|
for i, it in enumerate(items):
|
|
ex = it.get("exhibit") if isinstance(it, dict) else None
|
|
if not ex:
|
|
continue
|
|
t = ex.get("type") if isinstance(ex, dict) else None
|
|
if t == "d2":
|
|
svg = render_d2(ex, imgdir)
|
|
elif t == "mermaid":
|
|
svg = render_mermaid(ex.get("code", ""), imgdir)
|
|
else:
|
|
svg = CE.render_exhibit(ex)
|
|
if not svg:
|
|
continue
|
|
idx += 1
|
|
slug = f"ex-{kind}{i+1:02d}"
|
|
fp = os.path.join(imgdir, slug + ".svg")
|
|
with open(fp, "w") as f:
|
|
f.write(svg)
|
|
is_degraded = CE.DEGRADED_MARKER in svg
|
|
out[id(it)] = {"file": os.path.join("img", slug + ".svg"), "inline": svg,
|
|
"slug": slug, "degraded": is_degraded}
|
|
if is_degraded:
|
|
degraded.append({"slug": slug, "type": t or "archetype",
|
|
"file": os.path.join("img", slug + ".svg")})
|
|
return out, degraded
|
|
|
|
|
|
# ------------------------------------------------------------ document (.md)
|
|
def render_document(doc, storyline, exmap):
|
|
hdr = doc.get("report-header", {}) or {}
|
|
st = storyline or {}
|
|
title = st.get("title") or doc.get("title") or "컨설팅 보고서"
|
|
meta = []
|
|
if st.get("client"):
|
|
meta.append(f"대상: **{st['client']}**")
|
|
if st.get("date") or doc.get("created-at"):
|
|
meta.append(f"일자: {st.get('date') or doc.get('created-at')}")
|
|
if doc.get("synthesized-by"):
|
|
meta.append(f"작성: {doc['synthesized-by']}")
|
|
L = [f"# {title}", "", " · ".join(meta) if meta else "", ""]
|
|
|
|
# BLUF callout
|
|
bl = (hdr.get("bottom-line") or "").strip()
|
|
if bl:
|
|
L += ["> **BLUF (핵심 결론)**", ">", "> " + bl.replace("\n", "\n> "), ""]
|
|
dn = hdr.get("decision-needed") or {}
|
|
conf = hdr.get("confidence") or {}
|
|
info = []
|
|
if dn.get("needed"):
|
|
info.append(f"**결정 필요** · 승인자 `{dn.get('approver','?')}`")
|
|
if conf.get("value"):
|
|
info.append(f"신뢰도 **{conf['value']}**")
|
|
if info:
|
|
L += [" · ".join(info), ""]
|
|
|
|
# SCQA
|
|
scqa = st.get("scqa") or {}
|
|
if scqa:
|
|
L += ["## 배경 (SCQA)", ""]
|
|
for k, ko in (("situation", "상황"), ("complication", "문제"), ("question", "질문"), ("answer", "답(지배 메시지)")):
|
|
if scqa.get(k):
|
|
L.append(f"- **{ko}**: {scqa[k]}")
|
|
L.append("")
|
|
|
|
# storyline as story (horizontal logic) — action titles
|
|
slides = st.get("slides") or []
|
|
if slides:
|
|
L += ["## 핵심 논리 (액션타이틀만 읽어도 이야기가 된다)", ""]
|
|
for i, s in enumerate(slides, 1):
|
|
L.append(f"{i}. {s.get('action-title','')}")
|
|
L.append("")
|
|
|
|
# detailed sections: narrative first, else slides
|
|
sections = doc.get("narrative") or []
|
|
if sections:
|
|
L += ["## 상세", ""]
|
|
for sec in sections:
|
|
L.append(f"### {sec.get('heading','')}")
|
|
L.append("")
|
|
for para in (sec.get("body") or []):
|
|
L += [para, ""]
|
|
ex = exmap.get(id(sec))
|
|
if ex:
|
|
L += [f"", ""]
|
|
# slide detail (exhibit + body) — always show exhibits/bodies from slides
|
|
if slides:
|
|
L += ["## 근거 도해 · 슬라이드별", ""]
|
|
for i, s in enumerate(slides, 1):
|
|
L.append(f"### {i}. {s.get('action-title','')}")
|
|
L.append("")
|
|
ex = exmap.get(id(s))
|
|
if ex:
|
|
L += [f"", ""]
|
|
for b in (s.get("body") or []):
|
|
L.append(f"- {b}")
|
|
if s.get("evidence"):
|
|
L.append(f"- _근거: {', '.join(str(e) for e in s['evidence'])}_")
|
|
L.append("")
|
|
|
|
# recommendation / go-no-go
|
|
if doc.get("recommendation"):
|
|
L += ["## 권고", "", str(doc["recommendation"]).strip(), ""]
|
|
if doc.get("go-no-go"):
|
|
L += [f"**Go/No-Go**: {doc['go-no-go']}", ""]
|
|
|
|
# conflicts / dissent (preserve)
|
|
conflicts = doc.get("conflicts") or doc.get("dissent") or []
|
|
if conflicts:
|
|
L += ["## 보존된 이견 (dissent)", ""]
|
|
for c in conflicts:
|
|
L.append(f"- {c}")
|
|
L.append("")
|
|
|
|
# risks
|
|
risks = hdr.get("risks") or []
|
|
if risks:
|
|
L += ["## 리스크", ""]
|
|
for r in risks:
|
|
L.append(f"- {r}")
|
|
L.append("")
|
|
|
|
# evidence table
|
|
ev = hdr.get("evidence") or []
|
|
if ev:
|
|
L += ["## 근거 (evidence)", "", "| # | source-uri | grade |", "|---|---|---|"]
|
|
for i, e in enumerate(ev, 1):
|
|
if isinstance(e, dict):
|
|
L.append(f"| {i} | `{e.get('source-uri','')}` | {e.get('grade','')} |")
|
|
L.append("")
|
|
linked = doc.get("linked-reports") or []
|
|
if linked:
|
|
L += ["## 분과 원본 보고서 (linked)", ""]
|
|
for lp in linked:
|
|
L.append(f"- `{lp}`")
|
|
L.append("")
|
|
|
|
return "\n".join(x for x in L if x is not None) + "\n"
|
|
|
|
|
|
# ------------------------------------------------------------ Marp deck (.md)
|
|
MARP_STYLE = """<style>
|
|
:root { --navy:#1f3a5f; --accent:#e07b39; --ink:#1b2430; --mute:#5b6472; }
|
|
section { font-family:'Segoe UI',Helvetica,Arial,sans-serif; color:var(--ink); padding:46px 58px; font-size:22px; }
|
|
section h1 { color:var(--navy); font-size:40px; line-height:1.2; }
|
|
section h2 { color:var(--navy); font-size:26px; line-height:1.3; border-bottom:2px solid var(--navy); padding-bottom:10px; margin:0 0 18px 0; font-weight:700; }
|
|
section.lead { justify-content:center; text-align:left; }
|
|
section.lead h1 { border:none; }
|
|
section .sub { color:var(--mute); font-size:20px; }
|
|
section img { display:block; margin:6px auto; max-height:74%; }
|
|
section ul { margin-top:8px; } section li { margin:5px 0; line-height:1.35; }
|
|
section .tag { color:var(--accent); font-weight:700; letter-spacing:.5px; font-size:15px; }
|
|
section footer { color:var(--mute); font-size:13px; }
|
|
strong { color:var(--navy); }
|
|
</style>"""
|
|
|
|
|
|
def render_deck_md(doc, storyline, exmap, theme_footer=""):
|
|
st = storyline or {}
|
|
hdr = doc.get("report-header", {}) or {}
|
|
title = st.get("title") or doc.get("title") or "컨설팅 보고서"
|
|
L = ["---", "marp: true", "paginate: true", "size: 16:9", f'footer: "{theme_footer}"', "---", "", MARP_STYLE, ""]
|
|
|
|
# title slide
|
|
L += ["<!-- _class: lead -->", "<!-- _paginate: false -->",
|
|
f'<span class="tag">CONSULTING DELIVERABLE</span>', "", f"# {title}", ""]
|
|
subs = []
|
|
if st.get("client"):
|
|
subs.append(f"대상: **{st['client']}**")
|
|
if st.get("date"):
|
|
subs.append(str(st["date"]))
|
|
if doc.get("synthesized-by"):
|
|
subs.append(str(doc["synthesized-by"]))
|
|
if subs:
|
|
L.append(f'<span class="sub">{" · ".join(subs)}</span>')
|
|
L += ["", "---", ""]
|
|
|
|
# BLUF slide
|
|
bl = (hdr.get("bottom-line") or "").strip()
|
|
if bl:
|
|
L += ["<!-- _class: lead -->", '<span class="tag">BOTTOM LINE UP FRONT</span>', "", f"## 결론", "", bl, ""]
|
|
scqa = st.get("scqa") or {}
|
|
if scqa.get("answer"):
|
|
L += ["", f"**지배 메시지 —** {scqa['answer']}"]
|
|
L += ["", "---", ""]
|
|
|
|
# content slides
|
|
for i, s in enumerate(st.get("slides") or [], 1):
|
|
L.append(f"## {s.get('action-title','')}")
|
|
L.append("")
|
|
ex = exmap.get(id(s))
|
|
if ex:
|
|
L += [f"", ""]
|
|
for b in (s.get("body") or []):
|
|
L.append(f"- {b}")
|
|
if s.get("body"):
|
|
L.append("")
|
|
L += ["---", ""]
|
|
|
|
# closing / recommendation
|
|
rec = doc.get("recommendation")
|
|
dn = hdr.get("decision-needed") or {}
|
|
L += ["<!-- _class: lead -->", '<span class="tag">RECOMMENDATION</span>', "", "## 권고 및 결정 요청", ""]
|
|
if rec:
|
|
L += [str(rec).strip(), ""]
|
|
if doc.get("go-no-go"):
|
|
L += [f"**Go/No-Go —** {doc['go-no-go']}", ""]
|
|
if dn.get("needed"):
|
|
L += [f'<span class="sub">결정 필요 · 승인자 <strong>{dn.get("approver","?")}</strong></span>', ""]
|
|
return "\n".join(L) + "\n"
|
|
|
|
|
|
# ------------------------------------------------------------ HTML deck (offline)
|
|
HTML_TMPL = """<!doctype html><html lang="ko"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{title}</title>
|
|
<style>
|
|
:root{{--navy:#1f3a5f;--accent:#e07b39;--ink:#1b2430;--mute:#5b6472}}
|
|
*{{box-sizing:border-box}}
|
|
html,body{{margin:0;height:100%;background:#0e1622;font-family:'Segoe UI',Helvetica,Arial,sans-serif;color:var(--ink)}}
|
|
#deck{{height:100vh;display:flex;align-items:center;justify-content:center}}
|
|
.slide{{display:none;width:min(1120px,94vw);aspect-ratio:16/9;background:#fff;border-radius:8px;
|
|
box-shadow:0 12px 40px rgba(0,0,0,.5);padding:44px 56px;overflow:hidden;position:relative;flex-direction:column}}
|
|
.slide.active{{display:flex}}
|
|
.slide.lead{{justify-content:center}}
|
|
h1{{color:var(--navy);font-size:38px;margin:.1em 0}}
|
|
h2{{color:var(--navy);font-size:26px;border-bottom:2px solid var(--navy);padding-bottom:9px;margin:0 0 14px}}
|
|
.tag{{color:var(--accent);font-weight:700;letter-spacing:.6px;font-size:14px}}
|
|
.sub{{color:var(--mute);font-size:19px}}
|
|
.body{{overflow:auto}}
|
|
.slide img,.slide svg{{display:block;margin:4px auto;max-width:100%;max-height:66vh;height:auto}}
|
|
ul{{margin:8px 0 0}} li{{margin:5px 0;line-height:1.35;font-size:20px}}
|
|
strong{{color:var(--navy)}}
|
|
#nav{{position:fixed;bottom:14px;right:20px;color:#9fb0c2;font-size:14px;user-select:none}}
|
|
#bar{{position:fixed;top:0;left:0;height:4px;background:var(--accent);transition:width .2s}}
|
|
.credit{{position:absolute;bottom:16px;left:56px;color:var(--mute);font-size:12px}}
|
|
</style></head><body>
|
|
<div id="bar"></div><div id="deck">{slides}</div>
|
|
<div id="nav"><span id="cur">1</span> / {n} ←/→</div>
|
|
<script>
|
|
var S=[].slice.call(document.querySelectorAll('.slide')),i=0;
|
|
function show(n){{i=Math.max(0,Math.min(S.length-1,n));S.forEach((s,k)=>s.classList.toggle('active',k===i));
|
|
document.getElementById('cur').textContent=i+1;document.getElementById('bar').style.width=((i+1)/S.length*100)+'%';}}
|
|
document.addEventListener('keydown',e=>{{if(e.key==='ArrowRight'||e.key===' ')show(i+1);
|
|
if(e.key==='ArrowLeft')show(i-1);if(e.key==='Home')show(0);if(e.key==='End')show(S.length-1);}});
|
|
document.getElementById('deck').addEventListener('click',e=>{{var r=innerWidth/2;show(e.clientX<r?i-1:i+1);}});
|
|
window.addEventListener('hashchange',()=>show((parseInt(location.hash.slice(1))||1)-1));
|
|
show((parseInt(location.hash.slice(1))||1)-1);
|
|
</script></body></html>"""
|
|
|
|
|
|
def render_deck_html(doc, storyline, exmap):
|
|
st = storyline or {}
|
|
hdr = doc.get("report-header", {}) or {}
|
|
title = st.get("title") or doc.get("title") or "컨설팅 보고서"
|
|
slides_html = []
|
|
|
|
def sec(inner, cls=""):
|
|
slides_html.append(f'<section class="slide {cls}">{inner}<div class="credit">Org OS · Consulting (LENS-ADVISORY)</div></section>')
|
|
|
|
subs = " · ".join([str(x) for x in [st.get("client") and f"대상: {st['client']}", st.get("date"),
|
|
doc.get("synthesized-by")] if x])
|
|
sec(f'<span class="tag">CONSULTING DELIVERABLE</span><h1>{CE._esc(title)}</h1><div class="sub">{CE._esc(subs)}</div>', "lead")
|
|
|
|
bl = (hdr.get("bottom-line") or "").strip()
|
|
if bl:
|
|
ans = (st.get("scqa") or {}).get("answer")
|
|
extra = f'<p><strong>지배 메시지 —</strong> {CE._esc(ans)}</p>' if ans else ""
|
|
sec(f'<span class="tag">BOTTOM LINE UP FRONT</span><h2>결론</h2><div class="body"><p style="font-size:22px">{CE._esc(bl)}</p>{extra}</div>', "lead")
|
|
|
|
for i, s in enumerate(st.get("slides") or [], 1):
|
|
inner = [f'<h2>{CE._esc(s.get("action-title",""))}</h2><div class="body">']
|
|
ex = exmap.get(id(s))
|
|
if ex:
|
|
inner.append(ex["inline"])
|
|
if s.get("body"):
|
|
inner.append("<ul>" + "".join(f"<li>{CE._esc(b)}</li>" for b in s["body"]) + "</ul>")
|
|
inner.append("</div>")
|
|
sec("".join(inner))
|
|
|
|
rec = doc.get("recommendation")
|
|
dn = hdr.get("decision-needed") or {}
|
|
inner = ['<span class="tag">RECOMMENDATION</span><h2>권고 및 결정 요청</h2><div class="body">']
|
|
if rec:
|
|
inner.append(f'<p style="font-size:21px">{CE._esc(str(rec).strip())}</p>')
|
|
if doc.get("go-no-go"):
|
|
inner.append(f'<p><strong>Go/No-Go —</strong> {CE._esc(doc["go-no-go"])}</p>')
|
|
if dn.get("needed"):
|
|
inner.append(f'<div class="sub">결정 필요 · 승인자 <strong>{CE._esc(dn.get("approver","?"))}</strong></div>')
|
|
inner.append("</div>")
|
|
sec("".join(inner), "lead")
|
|
|
|
return HTML_TMPL.format(title=CE._esc(title), slides="".join(slides_html), n=len(slides_html))
|
|
|
|
|
|
# ------------------------------------------------------------ marp export
|
|
def try_marp(deck_md_path, outdir, stem):
|
|
"""best-effort: marp-cli로 pptx/pdf. 성공한 산출물 경로 리스트 반환.
|
|
절대경로로 정규화한다 — cwd=덱 디렉터리라 상대 outdir가 이중 적용되면 marp가 파일을 못 찾는다."""
|
|
produced = []
|
|
env = dict(os.environ)
|
|
chrome = "/usr/bin/google-chrome"
|
|
if os.path.exists(chrome):
|
|
env.setdefault("CHROME_PATH", chrome)
|
|
deck_abs = os.path.abspath(deck_md_path)
|
|
workdir = os.path.dirname(deck_abs) # 이미지 상대경로(img/…)는 덱 위치 기준으로 해석
|
|
out_abs = os.path.abspath(outdir)
|
|
base = ["npx", "--yes", "@marp-team/marp-cli@latest", os.path.basename(deck_abs), "--allow-local-files"]
|
|
# --html은 제외: render_deck_html이 만든 self-contained <stem>-deck.html(오프라인 보장)을 덮어쓰지 않게.
|
|
targets = [("--pptx", stem + ".pptx"), ("--pdf", stem + ".pdf")]
|
|
for flag, outname in targets:
|
|
outpath = os.path.join(out_abs, outname)
|
|
try:
|
|
r = subprocess.run(base + [flag, "-o", outpath], env=env, cwd=workdir,
|
|
capture_output=True, text=True, timeout=240)
|
|
if r.returncode == 0 and os.path.exists(outpath):
|
|
produced.append(outpath)
|
|
else:
|
|
sys.stderr.write(f"[marp] {flag} failed rc={r.returncode}: {r.stderr[-300:]}\n")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
sys.stderr.write(f"[marp] {flag} skipped: {e}\n")
|
|
return produced
|
|
|
|
|
|
# ------------------------------------------------------------ main
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("report")
|
|
ap.add_argument("--outdir", default=None)
|
|
ap.add_argument("--name", default=None)
|
|
ap.add_argument("--marp", action="store_true", help="marp-cli로 pptx/pdf export 시도(네트워크·chrome 필요)")
|
|
ap.add_argument("--strict", action="store_true",
|
|
help="degraded(폴백 SVG 대체)면 비영점 종료 — 발표/게시 게이트용(#16). "
|
|
"기본은 exit 0 유지(마커·meta·stderr로 degraded 표시).")
|
|
args = ap.parse_args()
|
|
|
|
with open(args.report) as f:
|
|
doc = yaml.safe_load(f)
|
|
if not isinstance(doc, dict):
|
|
sys.exit("report YAML 파싱 실패 또는 매핑 아님")
|
|
|
|
storyline = doc.get("storyline") or {}
|
|
outdir = args.outdir or os.path.join(os.path.dirname(os.path.abspath(args.report)), "deliverables")
|
|
_mkdir(outdir)
|
|
stem = args.name or slugify(storyline.get("title") or doc.get("title") or
|
|
os.path.splitext(os.path.basename(args.report))[0])
|
|
|
|
slides = storyline.get("slides") or []
|
|
narrative = doc.get("narrative") or []
|
|
exmap, degraded = collect_exhibits(slides, narrative, os.path.join(outdir, "img"))
|
|
|
|
doc_md = render_document(doc, storyline, exmap)
|
|
deck_md = render_deck_md(doc, storyline, exmap, theme_footer="Org OS · Consulting (LENS-ADVISORY)")
|
|
deck_html = render_deck_html(doc, storyline, exmap)
|
|
|
|
paths = {
|
|
"report": os.path.join(outdir, stem + "-report.md"),
|
|
"deck-md": os.path.join(outdir, stem + "-deck.md"),
|
|
"deck-html": os.path.join(outdir, stem + "-deck.html"),
|
|
}
|
|
with open(paths["report"], "w") as f:
|
|
f.write(doc_md)
|
|
with open(paths["deck-md"], "w") as f:
|
|
f.write(deck_md)
|
|
with open(paths["deck-html"], "w") as f:
|
|
f.write(deck_html)
|
|
|
|
produced = []
|
|
if args.marp:
|
|
produced = try_marp(paths["deck-md"], outdir, stem + "-deck")
|
|
|
|
# 렌더 상태(degraded 여부)를 기계 감지 가능한 메타로 기록 — 열화를 성공으로 위장하지 않는다.
|
|
status = "degraded" if degraded else "ok"
|
|
render_meta = {
|
|
"status": status,
|
|
"degraded": bool(degraded),
|
|
"exhibits": len(exmap),
|
|
"degraded_exhibits": degraded,
|
|
"outputs": paths,
|
|
"marp-exports": produced,
|
|
}
|
|
meta_path = os.path.join(outdir, stem + "-render.json")
|
|
with open(meta_path, "w") as f:
|
|
json.dump(render_meta, f, ensure_ascii=False, indent=2)
|
|
|
|
if degraded:
|
|
# stderr 경고(사람+CI) — 실물 렌더 실패로 폴백 SVG 대체됨을 명시.
|
|
sys.stderr.write(
|
|
"[render_consult] DEGRADED: %d exhibit(s) fell back to code-text SVG "
|
|
"(d2/mmdc/exhibit 렌더 미가용): %s\n"
|
|
% (len(degraded), ", ".join("%s(%s)" % (d["slug"], d["type"]) for d in degraded))
|
|
)
|
|
|
|
print("OK render_consult:")
|
|
print(f" 문서(document): {paths['report']}")
|
|
print(f" 덱(Marp source): {paths['deck-md']}")
|
|
print(f" 덱(offline HTML): {paths['deck-html']} ← 브라우저에서 바로 발표")
|
|
print(f" exhibits: {len(exmap)} SVG in {os.path.join(outdir,'img')}")
|
|
print(f" render-meta: {meta_path}")
|
|
for p in produced:
|
|
print(f" 덱(marp export): {p}")
|
|
if args.marp and not produced:
|
|
print(" (marp export 실패/미가용 — HTML 덱으로 발표하세요)")
|
|
# stdout 기계 감지 마커(마지막 줄) — degraded면 발표 전 재렌더 필요.
|
|
if degraded:
|
|
print("RENDER_STATUS: DEGRADED (%d exhibit fallback — NOT publication-grade)" % len(degraded))
|
|
else:
|
|
print("RENDER_STATUS: OK")
|
|
|
|
# #16: --strict면 degraded를 하드 게이트(비영점). 기본은 exit 0 유지(파이프라인 계약 보존).
|
|
if degraded and args.strict:
|
|
sys.stderr.write(
|
|
"[render_consult] --strict: degraded 산출은 발표/게시 등급이 아니다 — "
|
|
"d2/mmdc 설치 후 재렌더하라(비영점 종료).\n")
|
|
sys.exit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|