@kaydash9999/banker-plugins 0.26.1 → 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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +25 -10
  5. package/bin/lib/codex-hooks.mjs +7 -1
  6. package/codex/command-skills/front-qa/SKILL.md +3 -1
  7. package/codex/hooks.json +15 -5
  8. package/codex/manifest.json +4 -0
  9. package/codex/skills/ralph/SKILL.md +272 -0
  10. package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
  11. package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
  12. package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
  13. package/codex/transform-matrix.md +5 -4
  14. package/commands/front-qa.md +3 -1
  15. package/hooks/ralph-continue-codex.mjs +59 -0
  16. package/package.json +1 -1
  17. package/skills/all-in-one/SKILL.md +22 -8
  18. package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
  19. package/skills/ralph-qa/references/run-folder.mjs +3 -3
  20. package/skills/ultra-chain/SKILL.md +11 -4
  21. package/skills/ultra-init/SKILL.md +9 -7
  22. package/skills/ultraqa/references/gate-evidence.mjs +3 -3
  23. package/skills/ultraqa/references/run-folder.mjs +3 -3
  24. package/skills/video-gen/SKILL.md +141 -0
  25. package/skills/video-gen/assets/example.json +36 -0
  26. package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
  27. package/skills/video-gen/scripts/vg-deck.mjs +72 -0
  28. package/skills/video-gen/scripts/vg-draw.mjs +231 -0
  29. package/skills/video-gen/scripts/vg-engine.mjs +74 -0
  30. package/skills/video-gen/scripts/vg-fx.mjs +106 -0
  31. package/skills/video-gen/scripts/vg-model.mjs +236 -0
  32. package/skills/video-gen/scripts/vg-render.mjs +151 -0
  33. package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
  34. package/skills/video-gen/scripts/video-gen.mjs +212 -0
  35. package/skills/video-gen/vendor/README.md +25 -0
  36. package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
  37. package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
  38. package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
  39. package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
  40. package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
  41. package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
  42. package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
  43. package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
  44. package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
  45. package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
  46. package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
  47. package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
  48. package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
  49. package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
  50. package/skills/visual-ralph/SKILL.md +9 -4
  51. package/skills/wbs-gen/SKILL.md +127 -0
  52. package/skills/wbs-gen/assets/example.json +58 -0
  53. package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
  54. package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
  55. package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
  56. package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
  57. package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
  58. package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
  59. package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
  60. package/skills/wide-pptx/SKILL.md +121 -0
  61. package/skills/wide-pptx/assets/example.json +63 -0
  62. package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
  63. package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
  64. package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
  65. package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
  66. package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
  67. package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
  68. package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
  69. package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
  70. package/skills/wide-pptx/vendor/README.md +12 -0
  71. package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
  72. package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
@@ -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
+ }
@@ -0,0 +1,236 @@
1
+ // wp-model.mjs: reads a wide-pptx deck (JSON), checks it and returns one model that the PPTX and HTML writers both
2
+ // draw. Images are read here (local regular files only) so both outputs embed the same bytes. No network.
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { dirname, isAbsolute, resolve, win32 } from "node:path";
5
+
6
+ export const TYPES = ["title", "section", "bullets", "two", "table", "chart", "image", "quote", "closing"];
7
+ export const CHARTS = ["bar", "line", "pie"];
8
+ const HEX = /^#?[0-9A-Fa-f]{6}$/;
9
+ export const THEME = { primary: "1F3864", accent: "2E86C1", dark: "1B2631", light: "F4F6F7", muted: "5D6D7E" };
10
+ export const PALETTE = ["2E86C1", "E67E22", "27AE60", "9B59B6", "E74C3C", "16A085", "F1C40F", "34495E"];
11
+ const MAX_IMAGE = 20 * 1024 * 1024;
12
+ // Soft limits: past these the fixed boxes overflow or crowd, so check warns (the deck is still built).
13
+ export const LIMITS = { tableRows: 12, tableCols: 7, chartPoints: 24, piePoints: 12 };
14
+
15
+ const arr = (v) => (Array.isArray(v) ? v : []);
16
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
17
+ export const hex = (c) => String(c).replace(/^#/, "").toUpperCase();
18
+
19
+ // XML 1.0 has no place for most control characters. PowerPoint's soft line break comes out of copied text as
20
+ // U+000B, so that one becomes a line break; the rest, U+FFFE/U+FFFF and lone surrogates are dropped.
21
+ const LINE_TAB = /\u{B}/gu;
22
+ const NOT_XML = /[\u{0}-\u{8}\u{C}\u{E}-\u{1F}\u{FFFE}\u{FFFF}]/gu;
23
+ // In a /u pattern a well-formed pair is one code point, so this class matches lone surrogates only.
24
+ const LONE = /[\u{D800}-\u{DFFF}]/gu;
25
+ export const cleanText = (s) => s.replace(LINE_TAB, "\n").replace(NOT_XML, "").replace(LONE, "");
26
+
27
+ /** A text field: a string (cleaned) or a finite number; absent is empty; anything else is an error. */
28
+ function text(v, what, ctx) {
29
+ if (v === undefined || v === null) return "";
30
+ if (typeof v === "string") return cleanText(v);
31
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
32
+ ctx.errors.push(`${what}: 글이어야 함`);
33
+ return "";
34
+ }
35
+
36
+ /** A list field: absent is empty; present but not an array is an error. */
37
+ function list(v, what, ctx) {
38
+ if (v === undefined || v === null) return [];
39
+ if (Array.isArray(v)) return v;
40
+ ctx.errors.push(`${what}: 배열이어야 함`);
41
+ return [];
42
+ }
43
+
44
+ // Width in em of a line: Hangul, CJK and full-width forms take a full em, other characters about half.
45
+ const WIDE = /[\u{1100}-\u{11FF}\u{2E80}-\u{9FFF}\u{AC00}-\u{D7A3}\u{F900}-\u{FAFF}\u{FF00}-\u{FFEF}]/u;
46
+ export const ems = (s) => Array.from(s).reduce((n, ch) => n + (WIDE.test(ch) ? 1 : 0.55), 0);
47
+ const linesOf = (s, pt, widthIn) => s.split("\n").reduce((n, l) => n + Math.max(1, Math.ceil((ems(l) * pt) / 72 / widthIn)), 0);
48
+
49
+ /** Estimated height in inches of bullets set at 20 pt (level 2 at 17 pt) in a box widthIn wide. */
50
+ export function bulletsHeight(bullets, widthIn) {
51
+ return bullets.reduce((h, b) => {
52
+ const pt = b.level === 2 ? 17 : 20;
53
+ return h + (linesOf(b.text, pt, widthIn - (b.level === 2 ? 0.6 : 0.35)) * pt * 1.2) / 72 + 8 / 72;
54
+ }, 0);
55
+ }
56
+
57
+ function themeOf(deck, ctx) {
58
+ const t = { ...THEME };
59
+ for (const [k, v] of Object.entries(obj(deck.theme))) {
60
+ if (!Object.prototype.hasOwnProperty.call(THEME, k)) ctx.errors.push(`theme.${k} 는 없는 칸(${Object.keys(THEME).join(", ")})`);
61
+ else if (!HEX.test(String(v))) ctx.errors.push(`theme.${k} 색이 6자리 16진수가 아님: ${String(v)}`);
62
+ else t[k] = hex(v);
63
+ }
64
+ return t;
65
+ }
66
+
67
+ /** Bullets: strings, or { text, level } with level 1 or 2. box: { w, h } in inches for the overflow estimate. */
68
+ function bulletsOf(raw, what, ctx, box) {
69
+ const out = list(raw, what, ctx).map((b, i) => {
70
+ if (b === null || b === undefined) ctx.errors.push(`${what}[${i}]: 글이어야 함`);
71
+ if (b && typeof b === "object" && !Array.isArray(b)) return { text: text(b.text, `${what}[${i}].text`, ctx), level: Number(b.level) === 2 ? 2 : 1 };
72
+ return { text: text(b, `${what}[${i}]`, ctx), level: 1 };
73
+ });
74
+ if (bulletsHeight(out, box.w) > box.h) ctx.warnings.push(`${what}: 글머리가 상자 높이를 넘을 수 있음(나누거나 줄이기를 권함)`);
75
+ return out;
76
+ }
77
+
78
+ function tableOf(s, what, ctx) {
79
+ const header = list(s.header, `${what}.header`, ctx).map((h, i) => text(h, `${what}.header[${i}]`, ctx));
80
+ const rows = list(s.rows, `${what}.rows`, ctx).map((r, i) => list(r, `${what}.rows[${i}]`, ctx).map((c, j) => text(c, `${what}.rows[${i}][${j}]`, ctx)));
81
+ const width = rows.reduce((m, r) => Math.max(m, r.length), header.length);
82
+ if (!width) ctx.errors.push(`${what}: 표에 칸이 없음`);
83
+ if (rows.length > LIMITS.tableRows) ctx.warnings.push(`${what}: 표 행이 ${LIMITS.tableRows}개를 넘음`);
84
+ if (width > LIMITS.tableCols) ctx.warnings.push(`${what}: 표 열이 ${LIMITS.tableCols}개를 넘음`);
85
+ const pad = (r) => [...r, ...Array(Math.max(0, width - r.length)).fill("")];
86
+ return { header: header.length ? pad(header) : [], rows: rows.map(pad) };
87
+ }
88
+
89
+ function seriesOf(raw, what, ctx) {
90
+ return list(raw, `${what}.series`, ctx).map(obj).map((x, k) => {
91
+ const values = list(x.values, `${what}.series[${k}].values`, ctx);
92
+ if (values.some((v) => typeof v !== "number" || !Number.isFinite(v))) ctx.errors.push(`${what}: 계열 값은 숫자만(null 이나 글자 안 됨)`);
93
+ return { name: text(x.name, `${what}.series[${k}].name`, ctx), values: values.map((v) => (typeof v === "number" ? v : 0)) };
94
+ });
95
+ }
96
+
97
+ function chartOf(s, what, ctx) {
98
+ const c = obj(s.chart);
99
+ const kind = text(c.kind || "bar", `${what}.chart.kind`, ctx);
100
+ if (!CHARTS.includes(kind)) ctx.errors.push(`${what}: chart.kind 는 ${CHARTS.join(", ")} 중 하나`);
101
+ const labels = list(c.labels, `${what}.chart.labels`, ctx).map((l, i) => text(l, `${what}.chart.labels[${i}]`, ctx));
102
+ const series = seriesOf(c.series, `${what}.chart`, ctx);
103
+ checkSeries(kind, labels, series, what, ctx);
104
+ return { kind, labels, series, unit: text(c.unit, `${what}.chart.unit`, ctx) };
105
+ }
106
+
107
+ function checkSeries(kind, labels, series, what, ctx) {
108
+ if (!labels.length || !series.length) ctx.errors.push(`${what}: chart.labels 와 chart.series 가 필요함`);
109
+ if (series.some((x) => x.values.length !== labels.length)) ctx.errors.push(`${what}: 계열마다 값 수가 labels 수와 같아야 함`);
110
+ const names = series.map((x) => x.name.trim());
111
+ if (series.length > 1 && (names.some((n) => !n) || new Set(names).size !== names.length)) ctx.errors.push(`${what}: 계열이 둘 이상이면 이름이 비지 않고 서로 달라야 함`);
112
+ if (kind === "pie") checkPie(series, labels, what, ctx);
113
+ else if (labels.length > LIMITS.chartPoints) ctx.warnings.push(`${what}: 차트 항목이 ${LIMITS.chartPoints}개를 넘음`);
114
+ }
115
+
116
+ function checkPie(series, labels, what, ctx) {
117
+ const v = series[0]?.values ?? [];
118
+ if (series.length !== 1 || v.some((x) => x < 0)) ctx.errors.push(`${what}: pie 는 음수 없는 계열 하나`);
119
+ else if (!v.some((x) => x > 0)) ctx.errors.push(`${what}: pie 값이 모두 0`);
120
+ if (labels.length > LIMITS.piePoints) ctx.warnings.push(`${what}: pie 항목이 ${LIMITS.piePoints}개를 넘음(범례가 잘림)`);
121
+ }
122
+
123
+ const IMAGE_TYPES = [
124
+ { mime: "image/png", magic: [0x89, 0x50, 0x4e, 0x47] },
125
+ { mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
126
+ { mime: "image/gif", magic: [0x47, 0x49, 0x46, 0x38] },
127
+ ];
128
+
129
+ const isPath = (p) => isAbsolute(p) || win32.isAbsolute(p) || !/^[a-z][a-z0-9+.-]+:/i.test(p);
130
+
131
+ /** Reads a local PNG, JPEG or GIF once; the type comes from its first bytes, not its name. */
132
+ function imageOf(s, what, ctx) {
133
+ const p = text(s.image, `${what}.image`, ctx);
134
+ if (!p || !isPath(p)) {
135
+ ctx.errors.push(`${what}: image 는 로컬 파일 경로여야 함(URL 은 받지 않음)`);
136
+ return null;
137
+ }
138
+ const file = isAbsolute(p) || win32.isAbsolute(p) ? p : resolve(ctx.base, p);
139
+ try {
140
+ const st = statSync(file);
141
+ if (!st.isFile()) throw new Error("보통 파일이 아님");
142
+ if (st.size > MAX_IMAGE) throw new Error("20MB 를 넘음");
143
+ const data = readFileSync(file);
144
+ const t = IMAGE_TYPES.find((x) => x.magic.every((b, i) => data[i] === b));
145
+ if (!t) throw new Error("PNG, JPEG, GIF 가 아님");
146
+ const size = imageSize(data, t.mime);
147
+ if (!size) ctx.warnings.push(`${what}: 그림 크기를 읽지 못해 상자 비율로 맞춤`);
148
+ return { file, mime: t.mime, size, data };
149
+ } catch (e) {
150
+ ctx.errors.push(`${what}: 이미지를 읽지 못함 (${file}: ${e.message})`);
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /** Pixel size from the header (PNG IHDR, GIF screen, JPEG SOFn), or null. */
156
+ export function imageSize(b, mime) {
157
+ if (mime === "image/png" && b.length > 24) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
158
+ if (mime === "image/gif" && b.length > 10) return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
159
+ return mime === "image/jpeg" ? jpegSize(b) : null;
160
+ }
161
+
162
+ /** Walks the JPEG segments to the first start-of-frame marker (SOF0..SOF15 but DHT, JPG, DAC); fill bytes skipped. */
163
+ function jpegSize(b) {
164
+ for (let i = 2; i + 9 < b.length;) {
165
+ if (b[i] !== 0xff) return null;
166
+ if (b[i + 1] === 0xff) {
167
+ i++;
168
+ continue;
169
+ }
170
+ const marker = b[i + 1];
171
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
172
+ const len = b.readUInt16BE(i + 2);
173
+ if (len < 2) return null;
174
+ i += 2 + len;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ const BOX = { w: 11.73, h: 5.15 };
180
+ const HALF = { w: 5.66, h: 4.55 };
181
+
182
+ const FIELDS = {
183
+ title: (s, w, ctx) => ({ subtitle: text(s.subtitle, `${w}.subtitle`, ctx), presenter: text(s.presenter, `${w}.presenter`, ctx), date: text(s.date, `${w}.date`, ctx) }),
184
+ section: (s, w, ctx) => ({ subtitle: text(s.subtitle, `${w}.subtitle`, ctx) }),
185
+ bullets: (s, w, ctx) => ({ bullets: bulletsOf(s.bullets, `${w}.bullets`, ctx, BOX) }),
186
+ two: (s, w, ctx) => ({ left: columnOf(s.left, `${w}.left`, ctx), right: columnOf(s.right, `${w}.right`, ctx) }),
187
+ table: (s, w, ctx) => ({ table: tableOf(s, w, ctx), caption: text(s.caption, `${w}.caption`, ctx) }),
188
+ chart: (s, w, ctx) => ({ chart: chartOf(s, w, ctx), caption: text(s.caption, `${w}.caption`, ctx) }),
189
+ image: (s, w, ctx) => ({ image: imageOf(s, w, ctx), caption: text(s.caption, `${w}.caption`, ctx) }),
190
+ quote: (s, w, ctx) => ({ quote: text(s.quote, `${w}.quote`, ctx), by: text(s.by, `${w}.by`, ctx) }),
191
+ closing: (s, w, ctx) => ({ subtitle: text(s.subtitle, `${w}.subtitle`, ctx) }),
192
+ };
193
+
194
+ function columnOf(raw, what, ctx) {
195
+ const c = obj(raw);
196
+ const heading = text(c.heading, `${what}.heading`, ctx);
197
+ return { heading, bullets: bulletsOf(c.bullets, `${what}.bullets`, ctx, heading ? HALF : { ...HALF, h: HALF.h + 0.6 }) };
198
+ }
199
+
200
+ function slideOf(raw, i, ctx) {
201
+ const s = obj(raw);
202
+ const what = `slides[${i}]`;
203
+ const type = typeof s.type === "string" ? s.type : "";
204
+ if (!TYPES.includes(type)) {
205
+ ctx.errors.push(`${what}: type 은 ${TYPES.join(", ")} 중 하나 (${String(s.type)})`);
206
+ return null;
207
+ }
208
+ const title = text(s.title, `${what}.title`, ctx);
209
+ if (type !== "quote" && !title.trim()) ctx.errors.push(`${what}: title 이 없음`);
210
+ if ((ems(title) * 28) / 72 > 12.1 && !["title", "closing"].includes(type)) ctx.warnings.push(`${what}: 제목이 한 줄을 넘을 수 있음`);
211
+ return { type, title, notes: text(s.notes, `${what}.notes`, ctx), ...FIELDS[type](s, what, ctx) };
212
+ }
213
+
214
+ /**
215
+ * Checks a deck and returns { errors, warnings, model }; model is null when there are errors. base is the folder
216
+ * relative image paths start from (the deck file's folder).
217
+ */
218
+ export function readDeck(deck, base) {
219
+ const errors = [];
220
+ const warnings = [];
221
+ if (!deck || typeof deck !== "object" || Array.isArray(deck)) return { errors: ["deck 은 JSON 객체여야 함"], warnings, model: null };
222
+ const ctx = { errors, warnings, base: base || process.cwd() };
223
+ const title = text(deck.title, "title", ctx);
224
+ if (!title.trim()) errors.push("title 이 없음");
225
+ if (!Array.isArray(deck.slides) || !deck.slides.length) errors.push("slides 가 비어 있음");
226
+ const model = {
227
+ title,
228
+ author: text(deck.author, "author", ctx),
229
+ footer: text(deck.footer, "footer", ctx),
230
+ theme: themeOf(deck, ctx),
231
+ slides: arr(deck.slides).map((s, i) => slideOf(s, i, ctx)).filter(Boolean),
232
+ };
233
+ return { errors, warnings, model: errors.length ? null : model };
234
+ }
235
+
236
+ export const deckBase = (file) => dirname(resolve(file));