@kaydash9999/banker-plugins 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +17 -5
  5. package/codex/manifest.json +3 -0
  6. package/package.json +1 -1
  7. package/skills/video-gen/SKILL.md +141 -0
  8. package/skills/video-gen/assets/example.json +36 -0
  9. package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
  10. package/skills/video-gen/scripts/vg-deck.mjs +72 -0
  11. package/skills/video-gen/scripts/vg-draw.mjs +231 -0
  12. package/skills/video-gen/scripts/vg-engine.mjs +74 -0
  13. package/skills/video-gen/scripts/vg-fx.mjs +106 -0
  14. package/skills/video-gen/scripts/vg-model.mjs +236 -0
  15. package/skills/video-gen/scripts/vg-render.mjs +151 -0
  16. package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
  17. package/skills/video-gen/scripts/video-gen.mjs +212 -0
  18. package/skills/video-gen/vendor/README.md +25 -0
  19. package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
  20. package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
  21. package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
  22. package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
  23. package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
  24. package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
  25. package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
  26. package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
  27. package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
  28. package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
  29. package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
  30. package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
  31. package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
  32. package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
  33. package/skills/wbs-gen/SKILL.md +127 -0
  34. package/skills/wbs-gen/assets/example.json +58 -0
  35. package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
  36. package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
  37. package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
  38. package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
  39. package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
  40. package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
  41. package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
  42. package/skills/wide-pptx/SKILL.md +121 -0
  43. package/skills/wide-pptx/assets/example.json +63 -0
  44. package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
  45. package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
  46. package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
  47. package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
  48. package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
  49. package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
  50. package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
  51. package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
  52. package/skills/wide-pptx/vendor/README.md +12 -0
  53. package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
  54. package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
@@ -0,0 +1,83 @@
1
+ // wg-zip.mjs: a minimal ZIP writer (deflate, no ZIP64) for the XLSX package. Pure Node: zlib is built in, and the
2
+ // CRC-32 is computed here because zlib.crc32 only exists from Node 20.15 / 22.2.
3
+ import { deflateRawSync } from "node:zlib";
4
+
5
+ const TABLE = (() => {
6
+ const t = new Uint32Array(256);
7
+ for (let n = 0; n < 256; n++) {
8
+ let c = n;
9
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
10
+ t[n] = c >>> 0;
11
+ }
12
+ return t;
13
+ })();
14
+
15
+ export function crc32(buf) {
16
+ let c = 0xffffffff;
17
+ for (let i = 0; i < buf.length; i++) c = TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
18
+ return (c ^ 0xffffffff) >>> 0;
19
+ }
20
+
21
+ // 1980-01-01 00:00 in DOS time: a fixed stamp so the same plan gives the same bytes.
22
+ const DOS_TIME = 0;
23
+ const DOS_DATE = (0 << 9) | (1 << 5) | 1;
24
+
25
+ function header(sig, size) {
26
+ const b = Buffer.alloc(size);
27
+ b.writeUInt32LE(sig, 0);
28
+ return b;
29
+ }
30
+
31
+ function localHeader(name, crc, comp, raw) {
32
+ const h = header(0x04034b50, 30);
33
+ h.writeUInt16LE(20, 4); // version needed
34
+ h.writeUInt16LE(0x0800, 6); // UTF-8 names
35
+ h.writeUInt16LE(8, 8); // deflate
36
+ h.writeUInt16LE(DOS_TIME, 10);
37
+ h.writeUInt16LE(DOS_DATE, 12);
38
+ h.writeUInt32LE(crc, 14);
39
+ h.writeUInt32LE(comp, 18);
40
+ h.writeUInt32LE(raw, 22);
41
+ h.writeUInt16LE(name.length, 26);
42
+ return h;
43
+ }
44
+
45
+ function centralHeader(name, crc, comp, raw, offset) {
46
+ const h = header(0x02014b50, 46);
47
+ h.writeUInt16LE(20, 4); // version made by
48
+ h.writeUInt16LE(20, 6);
49
+ h.writeUInt16LE(0x0800, 8);
50
+ h.writeUInt16LE(8, 10);
51
+ h.writeUInt16LE(DOS_TIME, 12);
52
+ h.writeUInt16LE(DOS_DATE, 14);
53
+ h.writeUInt32LE(crc, 16);
54
+ h.writeUInt32LE(comp, 20);
55
+ h.writeUInt32LE(raw, 24);
56
+ h.writeUInt16LE(name.length, 28);
57
+ h.writeUInt32LE(offset, 42);
58
+ return h;
59
+ }
60
+
61
+ /** entries: [{ name, data: Buffer|string }] in order. Returns the ZIP as one Buffer. */
62
+ export function zip(entries) {
63
+ const parts = [];
64
+ const central = [];
65
+ let offset = 0;
66
+ for (const e of entries) {
67
+ const name = Buffer.from(e.name, "utf8");
68
+ const raw = Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data, "utf8");
69
+ const comp = deflateRawSync(raw, { level: 9 });
70
+ const crc = crc32(raw);
71
+ const local = localHeader(name, crc, comp.length, raw.length);
72
+ parts.push(local, name, comp);
73
+ central.push(centralHeader(name, crc, comp.length, raw.length, offset), name);
74
+ offset += local.length + name.length + comp.length;
75
+ }
76
+ const dir = Buffer.concat(central);
77
+ const end = header(0x06054b50, 22);
78
+ end.writeUInt16LE(entries.length, 8);
79
+ end.writeUInt16LE(entries.length, 10);
80
+ end.writeUInt32LE(dir.length, 12);
81
+ end.writeUInt32LE(offset, 16);
82
+ return Buffer.concat([...parts, dir, end]);
83
+ }
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: wide-pptx
3
+ description: "발표용 16:9 슬라이드를 PPTX 와 단일 HTML 덱으로 만듦. 한 JSON 덱에서 두 형식을 같은 배치로 냄: 표지, 구간, 글머리, 2단, 표, 차트(막대, 꺾은선, 원), 이미지, 인용, 마무리와 발표자 노트. 글꼴은 한글 Noto Sans KR, 그 밖 Noto Sans. PptxGenJS 를 스킬 안에 넣어 설치와 내려받기 없이 오프라인에서 동작. A4 세로 인쇄 문서는 vertical-pptx. 'wide-pptx'/'발표 자료 만들어'/'프레젠테이션'/'슬라이드 만들어'/'html 발표 자료' 시 사용."
4
+ argument-hint: "[--pptx] [--html] [--dir <폴더>] [--deck <deck.json>] [--minutes <발표 분>]"
5
+ ---
6
+
7
+ # wide-pptx: 발표용 16:9 슬라이드(PPTX, HTML)
8
+
9
+ 발표 내용을 받아 16:9 슬라이드를 PPTX 와 HTML 덱으로 만든다. 답변은 한글.
10
+ 두 형식은 한 JSON 덱에서 같은 배치(13.333 x 7.5 in, HTML 은 1280 x 720 px)로 나온다.
11
+
12
+ ## 원칙
13
+
14
+ - **설치와 내려받기가 없다.** PPTX 는 스킬 안의 `vendor/pptxgen.bundle.js`(PptxGenJS 4.0.1, MIT)로, HTML 은 도우미가 직접 쓴다. 인터넷을 쓰지 않는다.
15
+ - **글꼴은 한글 Noto Sans KR, 그 밖(영문, 숫자, 기호) Noto Sans 다.** PPTX 는 테마의 라틴 글꼴과 한글 글꼴로 나누어 적고, 글 상자는 테마 글꼴을 따른다. HTML 은 `"Noto Sans","Noto Sans KR"` 순서다. 글꼴 파일은 넣지 않으므로 보는 PC 에 두 글꼴이 있어야 그대로 보인다.
16
+ - **슬라이드는 종류별 고정 배치다.** 글이 상자보다 길면 넘친다. 도우미가 넘칠 만한 곳을 경고하면 슬라이드를 나눈다. 글자를 줄여 맞추지 않는다.
17
+ - **이미지는 로컬 파일만 쓴다.** PNG, JPEG, GIF. 두 형식에 같은 바이트가 들어간다. URL 은 받지 않는다.
18
+ - 있는 파일을 덮어쓰지 않는다. 같은 이름이 있으면 묻고, 허락을 받은 뒤에만 `--force` 를 준다.
19
+ - 사용자가 주지 않은 수치, 인용, 출처를 지어내지 않는다. 비어 있는 곳은 `[확인 필요]` 로 두고 보고에 적는다.
20
+
21
+ ## 런타임
22
+
23
+ | 항목 | Claude Code | Codex |
24
+ |---|---|---|
25
+ | 스킬 디렉터리 `<S>` | 스킬 맨 위 "Base directory for this skill:" 경로 | 이 SKILL.md 의 폴더. npm 설치는 `~/.codex/skills/banker-wide-pptx/`, 플러그인 설치는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/wide-pptx/`. 절대경로로 씀 |
26
+ | 부르기 | `/banker:wide-pptx` | `$banker-wide-pptx`, 플러그인 `$banker:wide-pptx` |
27
+ | 확인 질문 | `AskUserQuestion` | `request_user_input`. 없으면 번호 붙인 평문 선택지 |
28
+ | 샌드박스 | 없음 | 도우미는 작업 폴더 안에서만 읽고 써서 승인이 필요 없음. 작업 폴더 밖 이미지는 `sandbox_permissions: "require_escalated"` 로 승인을 청하거나 작업 폴더로 복사해 씀 |
29
+ | 셸 | bash | Windows 는 PowerShell. 도우미는 Node 라 두 셸에서 같다 |
30
+
31
+ 물을 수 없는 실행(`codex exec`, 다른 스킬의 연쇄)에서는 인터뷰를 건너뛰고 받은 자료만으로 만든다. 모자란 곳은 `[확인 필요]` 로 둔다.
32
+
33
+ ## 인자
34
+
35
+ | 인자 | 뜻 |
36
+ |---|---|
37
+ | `--pptx`, `--html` | 낼 형식. 둘 다 없으면 둘 다 냄 |
38
+ | `--dir <폴더>` | 출력 폴더. 없으면 지금 작업 폴더. 도우미의 `--out <파일>` 과 다름 |
39
+ | `--deck <deck.json>` | 이미 있는 덱 파일을 씀. 없으면 대화와 자료로 새로 씀 |
40
+ | `--minutes <분>` | 발표 시간. 슬라이드 수를 정할 때 씀(1~2분에 한 장) |
41
+
42
+ ## 도우미
43
+
44
+ `P="<S>/scripts/wide-pptx.mjs"`. 머리 주석이 전체 사용법이다.
45
+
46
+ | 명령 | 하는 일 | 종료 코드 |
47
+ |---|---|---|
48
+ | `node "$P" example` | 예시 덱(JSON)을 냄. 덱 파일의 틀로 씀 | 0 |
49
+ | `node "$P" check --deck <deck.json>` | 덱을 검사하고 `OK 슬라이드 N, 표 T, 차트 C, 이미지 I, 노트 K` 한 줄을 냄. 넘칠 만한 곳은 `경고:` 줄 | 0, 덱 오류 1 |
50
+ | `node "$P" build --deck <deck.json> --out <파일.pptx> --out <파일.html>` | 파일을 만듦. 쓰기 전에 모든 `--out` 을 검사하고, 하나라도 이미 있으면 `--force` 없이는 아무것도 쓰지 않고 2 로 끝남 | 0, 덱 오류 1, 사용법과 파일 오류 2 |
51
+
52
+ ## 덱 파일(deck.json)
53
+
54
+ `node "$P" example` 이 낸 틀을 고쳐 쓴다. 이미지 경로는 덱 파일이 있는 폴더 기준이다.
55
+ 모든 글 칸은 글(또는 숫자)만 받는다. PowerPoint 에서 복사한 줄 바꿈(U+000B)은 줄 바꿈으로, XML 에 넣을 수 없는 제어 문자는 지운다. 제목은 한 줄을 넘을 것 같으면 경고한다.
56
+
57
+ | 칸 | 뜻 |
58
+ |---|---|
59
+ | `title`, `author` | 파일 속성의 제목과 작성자 |
60
+ | `footer` | 표지와 마무리를 뺀 슬라이드 아래 왼쪽 글. 오른쪽에는 쪽 번호 |
61
+ | `theme` | `primary`(표지, 표 머리), `accent`(강조 막대, 소제목), `dark`(본문 글), `light`(구간, 인용 바탕), `muted`(보조 글). 6자리 16진수 |
62
+ | `slides` | 슬라이드 목록. 아래 표. 모두 `notes`(발표자 노트)를 받음 |
63
+
64
+ | type | 칸 | 넘침 경고와 한도 |
65
+ |---|---|---|
66
+ | `title` | `title`, `subtitle`, `presenter`, `date` | |
67
+ | `section` | `title`, `subtitle` | |
68
+ | `bullets` | `title`, `bullets`(글, 또는 `{ "text", "level": 2 }`) | 글머리의 줄 수를 글자 폭(한글 1, 그 밖 약 0.55)으로 어림해 상자 높이를 넘으면 경고 |
69
+ | `two` | `title`, `left`, `right`(각 `{ "heading", "bullets" }`) | 칸마다 같은 어림 |
70
+ | `table` | `title`, `header`, `rows`, `caption` | 12행, 7열 |
71
+ | `chart` | `title`, `chart`(`kind`: bar, line, pie / `labels` / `series`: `[{ "name", "values" }]` / `unit`), `caption` | 항목 24개(pie 12개) 경고. 값은 숫자만(null 은 오류), 계열이 둘 이상이면 이름이 서로 달라야 함, pie 는 음수 없는 계열 하나이고 합이 0 이면 오류 |
72
+ | `image` | `title`, `image`(로컬 보통 파일 경로, Windows 드라이브 경로 포함), `caption` | 20MB 넘으면 오류. 캡션이 PPTX 그림의 대체 텍스트가 됨 |
73
+ | `quote` | `quote`, `by`, `title`(선택) | |
74
+ | `closing` | `title`, `subtitle` | |
75
+
76
+ ## 절차
77
+
78
+ ### 1. 자료 모으기
79
+ - 사용자가 준 원고, 문서, 표, 수치, 그림을 읽는다.
80
+ - 아래가 없으면 한 번에 묻는다(확인 질문 하나에 최대 4문항): 발표 목적과 청중, 발표 시간, 꼭 전할 메시지 한 줄, 넣을 자료(표, 수치, 그림 경로). 색과 배치는 묻지 않고 기본값을 쓴다.
81
+
82
+ ### 2. 개요 확인
83
+ - 슬라이드 제목 목록(번호, 종류, 제목)을 먼저 보여 주고 확인을 받는다. 장수는 발표 시간 1~2분에 한 장을 기준으로 한다.
84
+ - 한 슬라이드에 메시지 하나. 표지, 구간, 마무리로 흐름을 나눈다.
85
+
86
+ ### 3. 덱 파일 쓰기
87
+ - 출력 폴더에 `<이름>-deck.json` 으로 쓴다. 같은 이름이 있으면 묻는다.
88
+ - 발표에서 할 말은 화면 글이 아니라 `notes` 에 적는다. 화면 글머리는 짧게(한 줄 40자 안팎).
89
+ - `node "$P" check --deck <파일>` 이 0 이 될 때까지 고친다. `경고:` 가 나오면 그 슬라이드를 나누거나 줄인다. 그대로 두면 보고에 적는다.
90
+
91
+ ### 4. 만들기
92
+ - `node "$P" build --deck <파일> --out <이름>.pptx --out <이름>.html` 을 돈다. 형식 인자를 받았으면 그 형식만 준다.
93
+ - 종료 코드 2 에 `--force` 안내가 나오면 덮어쓸지 묻고, 허락을 받은 뒤에만 `--force` 를 더해 다시 돈다.
94
+
95
+ ### 5. 확인
96
+ - `build` 출력의 슬라이드, 표, 차트, 이미지, 노트 수를 덱과 대조한다.
97
+ - 브라우저를 쓸 수 있으면 HTML 을 열어 몇 장을 본다(`<파일>.html#3` 처럼 쪽을 고름).
98
+ - LibreOffice 가 있으면 PPTX 를 PDF 로 바꿔 본다. `soffice --convert-to pdf` 는 같은 이름 PDF 를 묻지 않고 덮어쓰므로 `--outdir` 을 새 임시 폴더로 준다.
99
+ - 쓸 수 없으면 보았다고 적지 않는다.
100
+
101
+ ## HTML 덱 쓰는 법
102
+
103
+ - 오른쪽 화살표, 스페이스, PageDown 은 다음, 왼쪽 화살표, PageUp 은 이전, Home 과 End 는 처음과 끝이다. 화면 왼쪽 1/3 을 누르면 이전, 나머지는 다음이다.
104
+ - N 은 발표자 노트를 화면 아래에 보이거나 숨긴다.
105
+ - 브라우저 인쇄(PDF 저장)는 한 장에 슬라이드 하나, 13.333 x 7.5 in 이다. 배경 그래픽 인쇄를 켠다.
106
+
107
+ ## 보고
108
+
109
+ - 만든 파일의 절대경로
110
+ - 슬라이드 수와 종류별 수, 노트가 있는 장 수
111
+ - 남은 경고와 `[확인 필요]` 자리
112
+ - 확인하지 못한 것(브라우저나 LibreOffice 로 보지 않음 등)
113
+ - 다음 행동이 있으면 마지막 줄에. 예: "고칠 곳은 `<이름>-deck.json` 을 고치고 다시 부르십시오."
114
+
115
+ ## 함정
116
+
117
+ - 보는 PC 에 Noto Sans KR, Noto Sans 가 없으면 다른 글꼴로 보이고 줄바꿈이 달라진다. 발표 PC 에 두 글꼴이 있는지 먼저 본다.
118
+ - LibreOffice 는 한글과 영문 사이에 간격을 넣어 보일 수 있다(PowerPoint 와 다름).
119
+ - 휴대폰 사진(JPEG)의 회전 정보(EXIF)는 읽지 않는다. 옆으로 누워 보이면 돌려 저장한 그림을 쓴다.
120
+ - PPTX 의 차트는 PowerPoint 차트 개체라 PowerPoint 에서 값을 고칠 수 있다. HTML 차트는 그림(SVG)이라 덱 파일에서 고친다.
121
+ - 덱 파일을 고치면 두 형식을 함께 다시 만든다. 한쪽만 고치면 둘이 달라진다.
@@ -0,0 +1,63 @@
1
+ {
2
+ "title": "샘플 대시보드 2차 개발 계획",
3
+ "author": "기획팀",
4
+ "footer": "샘플 대시보드 2차 개발 | 내부 검토용",
5
+ "theme": { "primary": "1F3864", "accent": "2E86C1" },
6
+ "slides": [
7
+ {
8
+ "type": "title",
9
+ "title": "샘플 대시보드 2차 개발 계획",
10
+ "subtitle": "요건 확정부터 운영 배포까지",
11
+ "presenter": "홍길동",
12
+ "date": "2026-07-22",
13
+ "notes": "인사와 발표 목적. 오늘 결정할 것은 요건 확정일과 선택 범위입니다."
14
+ },
15
+ { "type": "section", "title": "1. 배경", "subtitle": "왜 2차 개발이 필요한가" },
16
+ {
17
+ "type": "bullets",
18
+ "title": "현황과 문제",
19
+ "bullets": [
20
+ "1차 대시보드는 은행 지표만 보여 줌",
21
+ { "text": "계열사 지표는 따로 받아 엑셀로 합침", "level": 2 },
22
+ "화면 진입점이 셋으로 나뉘어 사용자가 헤맴",
23
+ "요건 담당자 변경으로 범위가 아직 확정되지 않음"
24
+ ],
25
+ "notes": "문제 세 가지를 짧게. 범위 미확정이 가장 큰 위험이라는 점을 강조."
26
+ },
27
+ {
28
+ "type": "two",
29
+ "title": "범위: 필수와 선택",
30
+ "left": { "heading": "필수", "bullets": ["요건과 UI 확정", "API 리팩터링", "대시보드 웹 개발"] },
31
+ "right": { "heading": "선택(마감 위험 시 먼저 제외)", "bullets": ["계열사 종합 수치", "계열사 웹 서비스"] }
32
+ },
33
+ {
34
+ "type": "table",
35
+ "title": "일정 요약",
36
+ "header": ["단계", "기간", "담당", "비고"],
37
+ "rows": [
38
+ ["요건·UI 확정", "07/27 ~ 08/03", "기획부", "게이트"],
39
+ ["데이터 매핑", "08/04 ~ 08/07", "데이터팀", ""],
40
+ ["개발", "08/04 ~ 08/14", "백엔드, 프론트", "선택 항목 포함"],
41
+ ["통합 점검", "08/18 ~ 08/21", "전 파트", ""],
42
+ ["운영 배포", "08/28", "전 파트", "오픈"]
43
+ ],
44
+ "caption": "영업일 기준, 08/17 대체공휴일 제외"
45
+ },
46
+ {
47
+ "type": "chart",
48
+ "title": "파트별 투입 영업일",
49
+ "chart": {
50
+ "kind": "bar",
51
+ "unit": "영업일",
52
+ "labels": ["기획", "데이터", "백엔드", "프론트", "AI"],
53
+ "series": [
54
+ { "name": "필수", "values": [6, 4, 9, 9, 7] },
55
+ { "name": "선택", "values": [0, 5, 0, 5, 0] }
56
+ ]
57
+ },
58
+ "caption": "선택 항목은 08/07 까지 범위가 확정되지 않으면 뺍니다"
59
+ },
60
+ { "type": "quote", "title": "핵심 원칙", "quote": "요건 확정 전에는 요건에 기대지 않는 일부터 한다", "by": "프로젝트 운영 원칙" },
61
+ { "type": "closing", "title": "감사합니다", "subtitle": "질문과 결정 사항을 받겠습니다" }
62
+ ]
63
+ }
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wide-pptx.mjs: the helper the wide-pptx skill runs. It turns a deck (JSON) into a 16:9 presentation as PPTX and/or
4
+ * one self-contained HTML file. Pure Node plus the bundled PptxGenJS (vendor/); no install, no network.
5
+ *
6
+ * node wide-pptx.mjs example print a sample deck (JSON)
7
+ * node wide-pptx.mjs check --deck <deck.json>
8
+ * node wide-pptx.mjs build --deck <deck.json> --out <file.pptx|file.html>... [--force]
9
+ *
10
+ * - Image paths in the deck are local files, relative to the deck file's folder; both outputs embed them.
11
+ * - check and build print warnings for text that may overflow its fixed box (the deck is still built).
12
+ * - build checks every --out first (kind, duplicates, folder, existing files) and makes every output before it
13
+ * writes any, each through a random temporary
14
+ * name: a hard link (or an exclusive copy) puts it in place, so a file that appears meanwhile is never replaced;
15
+ * --force renames over an existing file instead.
16
+ *
17
+ * Exit status: 0 done, 1 the deck has errors, 2 usage error or a file that could not be read or written.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ import { constants, copyFileSync, existsSync, linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, realpathSync } from "node:fs";
21
+ import { dirname, extname, join, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { toHtml } from "./wp-html.mjs";
24
+ import { deckBase, readDeck } from "./wp-model.mjs";
25
+ import { toPptx } from "./wp-pptx.mjs";
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+ const EXAMPLE = join(HERE, "..", "assets", "example.json");
29
+
30
+ class Usage extends Error {}
31
+
32
+ const isDir = (p) => {
33
+ try {
34
+ return statSync(p).isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+
40
+ /** Reads --name value pairs; flags listed in bools take no value; --out may repeat. */
41
+ export function parseArgs(argv, bools = ["force"]) {
42
+ const out = { _: [], out: [] };
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (!a.startsWith("--")) {
46
+ out._.push(a);
47
+ continue;
48
+ }
49
+ const name = a.slice(2);
50
+ if (bools.includes(name)) {
51
+ out[name] = true;
52
+ continue;
53
+ }
54
+ const v = argv[++i];
55
+ if (v === undefined || v.startsWith("--")) throw new Usage(`--${name} 에 값이 없음`);
56
+ if (name === "out") out.out.push(v);
57
+ else out[name] = v;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function readDeckFile(args) {
63
+ if (!args.deck) throw new Usage("--deck <deck.json> 이 필요함");
64
+ let deck;
65
+ try {
66
+ deck = JSON.parse(readFileSync(args.deck, "utf8").replace(/^\uFEFF/, ""));
67
+ } catch (e) {
68
+ throw new Usage(`deck 을 읽지 못함: ${e.message}`);
69
+ }
70
+ return readDeck(deck, deckBase(args.deck));
71
+ }
72
+
73
+ function report(res) {
74
+ for (const w of res.warnings) process.stderr.write(`경고: ${w}\n`);
75
+ for (const e of res.errors) process.stderr.write(`오류: ${e}\n`);
76
+ }
77
+
78
+ function summary(m) {
79
+ const count = (t) => m.slides.filter((s) => s.type === t).length;
80
+ const notes = m.slides.filter((s) => s.notes).length;
81
+ return `슬라이드 ${m.slides.length}, 표 ${count("table")}, 차트 ${count("chart")}, 이미지 ${count("image")}, 노트 ${notes}`;
82
+ }
83
+
84
+ function cmdCheck(args) {
85
+ const res = readDeckFile(args);
86
+ report(res);
87
+ if (!res.model) return 1;
88
+ process.stdout.write(`OK ${summary(res.model)}\n`);
89
+ return 0;
90
+ }
91
+
92
+ /** An exclusive copy; a copy that fails after making target (disk full) removes what it made. */
93
+ function copyExclusive(tmp, target) {
94
+ try {
95
+ copyFileSync(tmp, target, constants.COPYFILE_EXCL);
96
+ } catch (e) {
97
+ if (e.code !== "EEXIST") rmSync(target, { force: true });
98
+ throw e;
99
+ }
100
+ }
101
+
102
+ /** Puts tmp at target without replacing a file that appeared meanwhile (link, else an exclusive copy). */
103
+ function placeNew(tmp, target) {
104
+ try {
105
+ linkSync(tmp, target);
106
+ } catch (e) {
107
+ if (e.code === "EEXIST") throw e;
108
+ copyExclusive(tmp, target);
109
+ }
110
+ rmSync(tmp, { force: true });
111
+ }
112
+
113
+ function writeOut(target, data, force) {
114
+ const tmp = join(dirname(target), `.${randomBytes(6).toString("hex")}.wide-pptx.tmp`);
115
+ try {
116
+ writeFileSync(tmp, data, { flag: "wx" });
117
+ if (force) renameSync(tmp, target);
118
+ else placeNew(tmp, target);
119
+ } catch (e) {
120
+ rmSync(tmp, { force: true });
121
+ const why = e.code === "EEXIST" ? "이미 있음(덮어쓰려면 --force)" : e.message;
122
+ throw new Usage(`쓰지 못함 ${target}: ${why}`);
123
+ }
124
+ return target;
125
+ }
126
+
127
+ /** Checks every output before any is written: kind, duplicates, and (without --force) files already there. */
128
+ function targetsOf(args) {
129
+ if (!args.out.length) throw new Usage("--out <파일.pptx|파일.html> 이 필요함");
130
+ const targets = args.out.map((f) => ({ path: resolve(f), kind: extname(f).toLowerCase() }));
131
+ const bad = targets.filter((x) => ![".pptx", ".html", ".htm"].includes(x.kind));
132
+ if (bad.length) throw new Usage(`--out 은 .pptx 나 .html 이어야 함: ${bad.map((x) => x.path).join(", ")}`);
133
+ if (new Set(targets.map((x) => x.path)).size !== targets.length) throw new Usage("--out 에 같은 파일이 두 번 있음");
134
+ const noDir = targets.filter((x) => !isDir(dirname(x.path)));
135
+ if (noDir.length) throw new Usage(`출력 폴더가 없음: ${noDir.map((x) => dirname(x.path)).join(", ")}`);
136
+ const there = targets.filter((x) => existsSync(x.path));
137
+ if (there.length && !args.force) throw new Usage(`이미 있음(덮어쓰려면 --force): ${there.map((x) => x.path).join(", ")}`);
138
+ return targets;
139
+ }
140
+
141
+ async function cmdBuild(args) {
142
+ const targets = targetsOf(args);
143
+ const res = readDeckFile(args);
144
+ report(res);
145
+ if (!res.model) return 1;
146
+ // Every output is made before any is written, so a drawing error leaves no half set behind.
147
+ const made = [];
148
+ for (const x of targets) made.push({ ...x, data: x.kind === ".pptx" ? await toPptx(res.model) : toHtml(res.model) });
149
+ for (const x of made) process.stdout.write(`WROTE ${writeOut(x.path, x.data, args.force)}\n`);
150
+ process.stdout.write(`OK ${summary(res.model)}\n`);
151
+ return 0;
152
+ }
153
+
154
+ export async function main(argv) {
155
+ const [cmd, ...rest] = argv;
156
+ const args = parseArgs(rest);
157
+ if (cmd === "example") {
158
+ process.stdout.write(readFileSync(EXAMPLE, "utf8"));
159
+ return 0;
160
+ }
161
+ if (cmd === "check") return cmdCheck(args);
162
+ if (cmd === "build") return cmdBuild(args);
163
+ throw new Usage("명령: example | check | build");
164
+ }
165
+
166
+ // Compared as real paths: Node resolves links in import.meta.url but not in argv[1], so a linked path would
167
+ // otherwise do nothing and exit 0.
168
+ const real = (p) => {
169
+ try {
170
+ return realpathSync(p);
171
+ } catch {
172
+ return null;
173
+ }
174
+ };
175
+ if (process.argv[1] && real(process.argv[1]) !== null && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
176
+ main(process.argv.slice(2)).then(
177
+ (code) => {
178
+ process.exitCode = code;
179
+ },
180
+ (e) => {
181
+ process.stderr.write(`${e instanceof Usage ? "" : "내부 오류: "}${e.message}\n`);
182
+ process.exitCode = 2;
183
+ },
184
+ );
185
+ }
@@ -0,0 +1,98 @@
1
+ // wp-html.mjs: draws the wide-pptx model as one self-contained HTML deck: 1280 x 720 slides (13.333 x 7.5 in at
2
+ // 96 px/in, the PPTX size), scaled to the window, one slide per page when printed. A small inline script moves
3
+ // between slides (arrows, space, PageUp/PageDown, Home/End, click) and N shows the speaker notes. No library, no
4
+ // network: images are data URIs, charts inline SVG. Fonts: "Noto Sans" then "Noto Sans KR" (Hangul falls to KR).
5
+ import { chartSvg, esc } from "./wp-svg.mjs";
6
+
7
+ const br = (s) => esc(s).replace(/\n/g, "<br>");
8
+
9
+ const css = (t) => `
10
+ *{box-sizing:border-box;margin:0;padding:0}
11
+ html,body{height:100%;background:#2C3E50;font-family:"Noto Sans","Noto Sans KR",sans-serif;color:#${t.dark}}
12
+ .stage{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;overflow:hidden}
13
+ .slide{position:absolute;width:1280px;height:720px;background:#fff;display:none;transform-origin:center center;overflow:hidden}
14
+ .slide.on{display:block}
15
+ .slide h2{position:absolute;left:58px;top:34px;width:1164px;height:82px;font-size:37px;font-weight:700;display:flex;align-items:center;color:#${t.dark}}
16
+ .bar{position:absolute;left:58px;top:117px;width:106px;height:6px;background:#${t.accent}}
17
+ .foot{position:absolute;left:58px;right:58px;top:672px;height:29px;font-size:13px;color:#${t.muted};display:flex;justify-content:space-between}
18
+ .body{position:absolute;left:77px;top:149px;width:1126px;height:494px}
19
+ ul.b{list-style:none;font-size:27px;line-height:1.35}
20
+ ul.b li{position:relative;padding-left:30px;margin-bottom:11px}
21
+ ul.b li::before{content:"";position:absolute;left:4px;top:.55em;width:10px;height:10px;border-radius:50%;background:#${t.accent}}
22
+ ul.b li.l2{font-size:23px;margin-left:34px}
23
+ ul.b li.l2::before{width:8px;height:8px;border-radius:0;background:#${t.muted}}
24
+ .cols{display:flex;gap:38px;height:100%}.cols>div{flex:1;min-width:0}
25
+ .cols h3{font-size:27px;color:#${t.accent};margin-bottom:18px}
26
+ table.t{width:100%;border-collapse:collapse;font-size:19px}
27
+ table.t th{background:#${t.primary};color:#fff;font-weight:700}
28
+ table.t th,table.t td{border:1px solid #D5D8DC;padding:8px 10px;text-align:left}
29
+ table.t tbody tr:nth-child(odd) td{background:#${t.light}}
30
+ .cap{position:absolute;left:77px;top:605px;width:1126px;font-size:16px;color:#${t.muted}}
31
+ .media{display:flex;align-items:center;justify-content:center;height:446px}
32
+ .media img{max-width:100%;max-height:100%}
33
+ .s-title,.s-closing{background:#${t.primary};color:#fff}
34
+ .s-title .acc{position:absolute;left:77px;top:216px;width:115px;height:8px;background:#${t.accent}}
35
+ .s-title h1{position:absolute;left:77px;top:235px;width:1126px;font-size:53px;font-weight:700;line-height:1.2}
36
+ .s-title .sub{position:absolute;left:77px;top:379px;width:1126px;font-size:29px;color:#D6EAF8}
37
+ .s-title .by{position:absolute;left:77px;top:547px;font-size:21px}
38
+ .s-closing h1{position:absolute;left:77px;top:259px;width:1126px;text-align:center;font-size:53px;font-weight:700}
39
+ .s-closing .sub{position:absolute;left:77px;top:394px;width:1126px;text-align:center;font-size:27px;color:#D6EAF8}
40
+ .s-section,.s-quote{background:#${t.light}}
41
+ .s-section .edge{position:absolute;left:0;top:0;width:34px;height:720px;background:#${t.accent}}
42
+ .s-section h1{position:absolute;left:96px;top:259px;width:1088px;font-size:48px;font-weight:700}
43
+ .s-section .sub{position:absolute;left:96px;top:374px;width:1088px;font-size:27px;color:#${t.muted}}
44
+ .s-quote .q{position:absolute;left:115px;top:192px;width:1050px;height:269px;display:flex;align-items:center;justify-content:center;text-align:center;font-size:40px;font-style:italic}
45
+ .s-quote .who{position:absolute;left:115px;top:470px;width:1050px;text-align:right;font-size:24px;color:#${t.muted}}
46
+ .s-quote .qt{position:absolute;left:58px;top:34px;font-size:24px;font-weight:700;color:#${t.accent}}
47
+ .notes{position:fixed;left:0;right:0;bottom:0;max-height:35%;overflow:auto;background:#fff;border-top:4px solid #${t.accent};padding:14px 22px;font-size:18px;display:none;white-space:pre-wrap}
48
+ body.show-notes .notes{display:block}
49
+ .hint{position:fixed;right:12px;bottom:8px;color:#BDC3C7;font-size:12px}
50
+ @page{size:13.333in 7.5in;margin:0}
51
+ @media print{html,body{background:#fff;height:auto}.stage{position:static;display:block}.slide{position:relative;display:block;transform:none!important;page-break-after:always;break-after:page}
52
+ .notes,.hint{display:none!important}*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
53
+ `;
54
+
55
+ const bullets = (list) => (list.length ? `<ul class="b">${list.map((b) => `<li class="l${b.level}">${br(b.text)}</li>`).join("")}</ul>` : "");
56
+ const head = (s) => `<h2>${br(s.title)}</h2><div class="bar"></div>`;
57
+ const cap = (s) => (s.caption ? `<div class="cap">${br(s.caption)}</div>` : "");
58
+
59
+ function table(t) {
60
+ const th = t.header.length ? `<thead><tr>${t.header.map((h) => `<th>${br(h)}</th>`).join("")}</tr></thead>` : "";
61
+ return `<table class="t">${th}<tbody>${t.rows.map((r) => `<tr>${r.map((c) => `<td>${br(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
62
+ }
63
+
64
+ const BODY = {
65
+ title: (s) => `<div class="acc"></div><h1>${br(s.title)}</h1>${s.subtitle ? `<div class="sub">${br(s.subtitle)}</div>` : ""}`
66
+ + `<div class="by">${esc([s.presenter, s.date].filter(Boolean).join(" | "))}</div>`,
67
+ section: (s) => `<div class="edge"></div><h1>${br(s.title)}</h1>${s.subtitle ? `<div class="sub">${br(s.subtitle)}</div>` : ""}`,
68
+ bullets: (s) => `${head(s)}<div class="body">${bullets(s.bullets)}</div>`,
69
+ two: (s) => `${head(s)}<div class="body cols">${[s.left, s.right].map((c) => `<div>${c.heading ? `<h3>${br(c.heading)}</h3>` : ""}${bullets(c.bullets)}</div>`).join("")}</div>`,
70
+ table: (s) => `${head(s)}<div class="body">${table(s.table)}</div>${cap(s)}`,
71
+ chart: (s) => `${head(s)}<div class="body media">${chartSvg(s.chart, 1126, 446)}</div>${cap(s)}`,
72
+ image: (s) => `${head(s)}<div class="body media"><img alt="${esc(s.caption || s.title)}" src="data:${s.image.mime};base64,${s.image.data.toString("base64")}"></div>${cap(s)}`,
73
+ quote: (s) => `${s.title ? `<div class="qt">${br(s.title)}</div>` : ""}<div class="q">“${br(s.quote)}”</div>${s.by ? `<div class="who">- ${br(s.by)}</div>` : ""}`,
74
+ closing: (s) => `<h1>${br(s.title)}</h1>${s.subtitle ? `<div class="sub">${br(s.subtitle)}</div>` : ""}`,
75
+ };
76
+
77
+ function slideHtml(s, i, m) {
78
+ const plain = s.type === "title" || s.type === "closing";
79
+ const foot = plain ? "" : `<div class="foot"><span>${esc(m.footer)}</span><span>${i + 1}</span></div>`;
80
+ return `<section class="slide s-${s.type}" data-notes="${esc(s.notes)}">${BODY[s.type](s)}${foot}</section>`;
81
+ }
82
+
83
+ // The deck's own script: scale to the window, move between slides, show notes. Reads nothing from outside.
84
+ const SCRIPT = `(function(){var s=[].slice.call(document.querySelectorAll('.slide')),n=document.querySelector('.notes'),i=0;
85
+ function fit(){var k=Math.min(innerWidth/1280,innerHeight/720);s.forEach(function(e){e.style.transform='scale('+k+')';});}
86
+ function go(j){i=Math.max(0,Math.min(s.length-1,j));s.forEach(function(e,x){e.classList.toggle('on',x===i);});n.textContent=s[i].getAttribute('data-notes')||'(노트 없음)';history.replaceState(null,'','#'+(i+1));}
87
+ addEventListener('keydown',function(e){var k=e.key;if(k==='ArrowRight'||k==='PageDown'||k===' ')go(i+1);else if(k==='ArrowLeft'||k==='PageUp')go(i-1);else if(k==='Home')go(0);else if(k==='End')go(s.length-1);else if(k==='n'||k==='N')document.body.classList.toggle('show-notes');else return;e.preventDefault();});
88
+ document.querySelector('.stage').addEventListener('click',function(e){go(e.clientX<innerWidth/3?i-1:i+1);});
89
+ addEventListener('resize',fit);fit();go((parseInt(location.hash.slice(1),10)||1)-1);})();`;
90
+
91
+ /** Returns the HTML text for a model. */
92
+ export function toHtml(m) {
93
+ const slides = m.slides.map((s, i) => slideHtml(s, i, m)).join("\n");
94
+ return `<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`
95
+ + `<title>${esc(m.title)}</title><style>${css(m.theme)}</style></head><body>`
96
+ + `<div class="stage">\n${slides}\n</div><div class="notes"></div><div class="hint">← → 넘김, N 노트</div>`
97
+ + `<script>${SCRIPT}</script></body></html>\n`;
98
+ }