#!/usr/bin/env python3 """역할별 사용자 설명서 생성기 — 캡처를 base64 로 박아 넣은 단일 HTML. WHY A SINGLE FILE 납품처는 폐쇄망이고, 매뉴얼은 메일로 돌거나 USB 로 옮겨 다닌다. 이미지가 옆 폴더에 있으면 그 순간 도판 없는 문서가 된다. 전부 한 파일에 넣으면 어디로 옮겨도 온전하다. WHY REGENERATE INSTEAD OF EDITING HTML 화면이 바뀌면 도판도 바뀌어야 한다. 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다 (rules: delivery-audit '매뉴얼 캡처 실물 정합'). 내용은 manual.json, 도판은 shots/ 에 두고 이 스크립트로 다시 뽑는 구조라, 화면이 바뀌면 캡처만 갈아끼우고 재실행하면 된다. 사용법: python server/deploy/manual/build_manual.py # -> 사용설명서.html python server/deploy/manual/build_manual.py --out X # 출력 경로 지정 """ 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[2] CALLOUT_LABEL = {"info": "참고", "warn": "주의", "danger": "경고"} def data_uri(path: pathlib.Path) -> str: return "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") CSS = """ :root{ --bg:#eef1f5; --card:#fff; --line:#dde3ea; --line-2:#eef2f6; --ink:#131824; --ink-2:#464e5c; --ink-3:#646c7a; --accent:#0b6b7a; --accent-wash:#e2f0f3; --accent-line:#b9d9df; --warn-bg:#fbf6e6; --warn-line:#efe0b4; --warn-ink:#7a5606; --crit-bg:#fdf2f2; --crit-line:#f2cccc; --crit-ink:#a51d1d; --font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif; --mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace; } *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font); line-height:1.75;-webkit-font-smoothing:antialiased} .wrap{max-width:880px;margin:0 auto;padding:0 1.2rem 5rem} /* 표지 */ .cover{padding:3.5rem 0 2rem;border-bottom:3px solid var(--ink);margin-bottom:2rem} .cover .kind{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.14em; text-transform:uppercase;color:var(--ink-3)} .cover h1{margin:.5rem 0 .3rem;font-size:2.1rem;letter-spacing:-.02em} .cover .sub{margin:0;color:var(--ink-2);font-size:1rem} .cover .meta{margin:1.2rem 0 0;font-family:var(--mono);font-size:.76rem;color:var(--ink-3)} /* 목차 */ .toc{background:var(--card);border:1px solid var(--line);border-radius:10px; padding:1.1rem 1.3rem;margin-bottom:2.5rem} .toc h2{margin:0 0 .6rem;font-size:.72rem;font-weight:700;letter-spacing:.08em; text-transform:uppercase;color:var(--ink-3)} .toc ul{margin:.2rem 0 0;padding-left:1.1rem;list-style:none} .toc li{margin:.15rem 0} .toc li::before{content:"·";color:var(--ink-3);margin-right:.5rem} .toc a{color:var(--accent);text-decoration:none} .toc a:hover{text-decoration:underline;text-underline-offset:3px} .toc-role{margin:.9rem 0 0;font-weight:700;color:var(--ink);font-size:.9rem} .toc-role:first-of-type{margin-top:0} /* 역할 */ .role{margin:0 0 3rem} .role-head{padding:1rem 1.2rem;background:var(--accent-wash); border:1px solid var(--accent-line);border-radius:10px;margin-bottom:1.5rem} .role-head h2{margin:0;font-size:1.25rem;color:var(--accent)} .role-head p{margin:.35rem 0 0;font-size:.9rem;color:var(--ink-2)} section.step{background:var(--card);border:1px solid var(--line);border-radius:10px; padding:1.3rem 1.4rem 1.5rem;margin-bottom:1rem} section.step h3{margin:0 0 .8rem;font-size:1.05rem;letter-spacing:-.01em} section.step p{margin:0 0 .7rem;font-size:.94rem;color:var(--ink-2)} section.step p:last-child{margin-bottom:0} section.step strong{color:var(--ink)} figure{margin:0 0 1.1rem} figure img{width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:8px} figcaption{margin-top:.35rem;font-size:.74rem;color:var(--ink-3)} .callout{margin:1rem 0 0;padding:.75rem .9rem;border-radius:8px;font-size:.9rem; border:1px solid var(--line);background:var(--line-2);color:var(--ink-2)} .callout .tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em; margin-right:.45rem;text-transform:uppercase} .callout.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn-ink)} .callout.danger{background:var(--crit-bg);border-color:var(--crit-line);color:var(--crit-ink)} .callout.info{background:var(--accent-wash);border-color:var(--accent-line);color:var(--accent)} code{font-family:var(--mono);font-size:.86em;background:var(--line-2); padding:.1em .35em;border-radius:4px} kbd{font-family:var(--mono);font-size:.8em;background:var(--card);border:1px solid var(--line); border-bottom-width:2px;border-radius:4px;padding:.05em .4em} .chip{display:inline-block;font-size:.72em;font-weight:700;color:#fff;background:var(--crit-ink); padding:.1em .5em;border-radius:999px} /* FAQ */ .faq{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.3rem 1.4rem} .faq h2{margin:0 0 1rem;font-size:1.15rem} .faq dt{font-weight:700;margin-top:1rem;color:var(--ink)} .faq dt:first-of-type{margin-top:0} .faq dd{margin:.3rem 0 0;font-size:.93rem;color:var(--ink-2)} footer.doc{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--line); font-size:.76rem;color:var(--ink-3);line-height:1.7} @media print{ body{background:#fff} .wrap{max-width:none;padding:0} section.step,.toc,.faq,.role-head{break-inside:avoid;box-shadow:none} .toc{display:none} } """ def render(doc: dict, shots: pathlib.Path, version: str) -> str: out: list[str] = [] a = out.append today = datetime.date.today().isoformat() a("") a('') a('') a(f"{html.escape(doc['title'])}") a(f"") a("
") # 표지 a('
') a('

사용 설명서

') a(f"

{html.escape(doc['title'])}

") a(f"

{html.escape(doc['subtitle'])}

") a(f'

버전 {html.escape(version)} · {today} 기준 화면

') a("
") # 목차. 항목 번호는 제목이 이미 달고 있으므로(1., 2., …) 목록 자체는 번호를 매기지 # 않는다. 역할 이름을
  • 로 끼워 넣고 list-style 로만 숨기면 그 줄이 카운터를 먹어 # 본문 번호와 목차 번호가 어긋난다(7 다음이 9로 뛴다). a('") # 본문 for role in doc["roles"]: a('
    ') a('
    ') a(f"

    {html.escape(role['name'])}

    ") a(f"

    {html.escape(role['summary'])}

    ") a("
    ") for sec in role["sections"]: a(f'
    ') a(f"

    {html.escape(sec['heading'])}

    ") shot = sec.get("shot") if shot: p = shots / shot if not p.is_file(): # 도판 누락을 조용히 넘기면 구멍 난 매뉴얼이 납품된다. raise SystemExit(f"ERROR: 캡처가 없습니다: {p}") a("
    ") a(f'{html.escape(sec[') a(f"
    {html.escape(sec['heading'])} — 실제 화면
    ") a("
    ") for para in sec["body"]: a(f"

    {para}

    ") # 본문은 의도된 인라인 마크업을 허용한다 co = sec.get("callout") if co: label = CALLOUT_LABEL.get(co["kind"], "참고") a(f'
    ' f'{label}{co["text"]}
    ') a("
    ") a("
    ") # FAQ a('

    자주 묻는 질문

    ') for item in doc["faq"]: a(f"
    {html.escape(item['q'])}
    {item['a']}
    ") a("
    ") a('") a("
  • ") return "\n".join(out) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--out", default=str(HERE / "사용설명서.html")) ap.add_argument("--shots", default=str(HERE / "shots")) args = ap.parse_args() version_file = REPO / "VERSION" if not version_file.is_file(): raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 매뉴얼은 만들지 않습니다.") version = version_file.read_text(encoding="utf-8").strip() doc = json.loads((HERE / "manual.json").read_text(encoding="utf-8")) out = pathlib.Path(args.out) out.write_text(render(doc, pathlib.Path(args.shots), version), encoding="utf-8") size_kb = out.stat().st_size / 1024 print(f"{out} ({size_kb:.0f} KB, 단일 파일 · 이미지 포함)", file=sys.stderr) if __name__ == "__main__": main()