#!/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"사용 설명서
') a(f"{html.escape(doc['subtitle'])}
") a(f'') a("{html.escape(role['summary'])}
") a("{para}
") # 본문은 의도된 인라인 마크업을 허용한다 co = sec.get("callout") if co: label = CALLOUT_LABEL.get(co["kind"], "참고") a(f'