@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +17 -5
- package/codex/manifest.json +3 -0
- package/package.json +1 -1
- package/skills/video-gen/SKILL.md +141 -0
- package/skills/video-gen/assets/example.json +36 -0
- package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
- package/skills/video-gen/scripts/vg-deck.mjs +72 -0
- package/skills/video-gen/scripts/vg-draw.mjs +231 -0
- package/skills/video-gen/scripts/vg-engine.mjs +74 -0
- package/skills/video-gen/scripts/vg-fx.mjs +106 -0
- package/skills/video-gen/scripts/vg-model.mjs +236 -0
- package/skills/video-gen/scripts/vg-render.mjs +151 -0
- package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
- package/skills/video-gen/scripts/video-gen.mjs +212 -0
- package/skills/video-gen/vendor/README.md +25 -0
- package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
- package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
- package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
- package/skills/wbs-gen/SKILL.md +127 -0
- package/skills/wbs-gen/assets/example.json +58 -0
- package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
- package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
- package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
- package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
- package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
- package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
- package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
- package/skills/wide-pptx/SKILL.md +121 -0
- package/skills/wide-pptx/assets/example.json +63 -0
- package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
- package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
- package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
- package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
- package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
- package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
- package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
- package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
- package/skills/wide-pptx/vendor/README.md +12 -0
- package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
- package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
|
@@ -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));
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// wp-pptx.mjs: draws the wide-pptx model as a 16:9 PPTX (13.333 x 7.5 in) with the bundled PptxGenJS. Text sets no
|
|
2
|
+
// font of its own; the theme carries the fonts: Latin Noto Sans, Hangul (and other East Asian text) Noto Sans KR.
|
|
3
|
+
// PptxGenJS writes the theme's Hangul font as 맑은 고딕 and chart text as Arial, so those parts are patched after
|
|
4
|
+
// writing.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import vm from "node:vm";
|
|
9
|
+
import { PALETTE } from "./wp-model.mjs";
|
|
10
|
+
|
|
11
|
+
const BUNDLE = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "pptxgen.bundle.js");
|
|
12
|
+
export const LATIN = "Noto Sans";
|
|
13
|
+
export const KR = "Noto Sans KR";
|
|
14
|
+
const W = 13.333;
|
|
15
|
+
const M = 0.6;
|
|
16
|
+
const BODY = { x: 0.8, y: 1.55, w: W - 1.6, h: 5.15 };
|
|
17
|
+
|
|
18
|
+
let loaded = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Loads the browser bundle (UMD) in a context of its own: required as CommonJS it would export JSZip alone and fail
|
|
22
|
+
* on the JSZip global it expects. The context keeps its own built-ins (the bundle's core-js polyfills patch those,
|
|
23
|
+
* not this process's Uint8Array or Promise); it gets timers, text codecs and Buffer (for nodebuffer output), and no
|
|
24
|
+
* require. This is a loading context, not a security boundary: the bundle is trusted code pinned by its sha256.
|
|
25
|
+
* The bundle keeps counters across builds (chart and embedding numbers keep rising in one process); each file is
|
|
26
|
+
* still self-consistent.
|
|
27
|
+
*/
|
|
28
|
+
export function pptxgen() {
|
|
29
|
+
if (loaded) return loaded;
|
|
30
|
+
const sandbox = {
|
|
31
|
+
console, setTimeout, clearTimeout, setImmediate, clearImmediate, queueMicrotask, Buffer,
|
|
32
|
+
TextEncoder, TextDecoder, process: { versions: {}, release: {} },
|
|
33
|
+
};
|
|
34
|
+
sandbox.window = sandbox;
|
|
35
|
+
sandbox.self = sandbox;
|
|
36
|
+
sandbox.global = sandbox;
|
|
37
|
+
sandbox.globalThis = sandbox;
|
|
38
|
+
vm.createContext(sandbox);
|
|
39
|
+
vm.runInContext(readFileSync(BUNDLE, "utf8"), sandbox, { filename: "pptxgen.bundle.js" });
|
|
40
|
+
if (typeof sandbox.PptxGenJS !== "function" || typeof sandbox.JSZip !== "function") throw new Error("pptxgen.bundle.js 를 불러오지 못함");
|
|
41
|
+
loaded = { PptxGenJS: sandbox.PptxGenJS, JSZip: sandbox.JSZip };
|
|
42
|
+
return loaded;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Every text run says Korean, so spelling and the language bar match the text (fonts still follow the theme).
|
|
46
|
+
const LANG = "ko-KR";
|
|
47
|
+
|
|
48
|
+
function chrome(s, slide, model, n) {
|
|
49
|
+
const t = model.theme;
|
|
50
|
+
s.addText(slide.title, { x: M, y: 0.35, w: W - 2 * M, h: 0.85, fontSize: 28, bold: true, color: t.dark, valign: "middle", margin: 0, lang: LANG });
|
|
51
|
+
s.addShape("rect", { x: M, y: 1.22, w: 1.1, h: 0.06, fill: { color: t.accent }, line: { color: t.accent, width: 0 } });
|
|
52
|
+
footer(s, model, n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function footer(s, model, n, color) {
|
|
56
|
+
const c = color ?? model.theme.muted;
|
|
57
|
+
if (model.footer) s.addText(model.footer, { x: M, y: 7.0, w: 9, h: 0.3, fontSize: 10, color: c, margin: 0, lang: LANG });
|
|
58
|
+
s.addText(String(n), { x: W - M - 1, y: 7.0, w: 1, h: 0.3, fontSize: 10, color: c, align: "right", margin: 0, lang: LANG });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bullet paragraphs; a line break inside one bullet stays inside it (a soft break), as in the HTML deck. */
|
|
62
|
+
function runs(bullets, color) {
|
|
63
|
+
return bullets.flatMap((b) => {
|
|
64
|
+
const size = { fontSize: b.level === 2 ? 17 : 20, color, lang: LANG };
|
|
65
|
+
const para = { ...size, bullet: b.level === 2 ? { code: "25AA", indent: 18 } : true, indentLevel: b.level - 1, paraSpaceAfter: 8 };
|
|
66
|
+
const lines = b.text.split("\n");
|
|
67
|
+
return lines.map((line, j) => ({
|
|
68
|
+
text: line,
|
|
69
|
+
options: { ...(j ? { ...size, softBreakBefore: true } : para), breakLine: j === lines.length - 1 },
|
|
70
|
+
}));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DRAW = {
|
|
75
|
+
title(s, slide, model) {
|
|
76
|
+
const t = model.theme;
|
|
77
|
+
s.background = { color: t.primary };
|
|
78
|
+
s.addShape("rect", { x: 0.8, y: 2.25, w: 1.2, h: 0.08, fill: { color: t.accent }, line: { color: t.accent, width: 0 } });
|
|
79
|
+
s.addText(slide.title, { x: 0.8, y: 2.45, w: W - 1.6, h: 1.4, fontSize: 40, bold: true, color: "FFFFFF", valign: "top", margin: 0, lang: LANG });
|
|
80
|
+
if (slide.subtitle) s.addText(slide.subtitle, { x: 0.8, y: 3.95, w: W - 1.6, h: 0.8, fontSize: 22, color: "D6EAF8", margin: 0, lang: LANG });
|
|
81
|
+
const by = [slide.presenter, slide.date].filter(Boolean).join(" | ");
|
|
82
|
+
if (by) s.addText(by, { x: 0.8, y: 5.7, w: W - 1.6, h: 0.5, fontSize: 16, color: "FFFFFF", margin: 0, lang: LANG });
|
|
83
|
+
},
|
|
84
|
+
section(s, slide, model, n) {
|
|
85
|
+
const t = model.theme;
|
|
86
|
+
s.background = { color: t.light };
|
|
87
|
+
s.addShape("rect", { x: 0, y: 0, w: 0.35, h: 7.5, fill: { color: t.accent }, line: { color: t.accent, width: 0 } });
|
|
88
|
+
s.addText(slide.title, { x: 1.0, y: 2.7, w: W - 2, h: 1.2, fontSize: 36, bold: true, color: t.dark, margin: 0, lang: LANG });
|
|
89
|
+
if (slide.subtitle) s.addText(slide.subtitle, { x: 1.0, y: 3.9, w: W - 2, h: 0.7, fontSize: 20, color: t.muted, margin: 0, lang: LANG });
|
|
90
|
+
footer(s, model, n);
|
|
91
|
+
},
|
|
92
|
+
bullets(s, slide, model, n) {
|
|
93
|
+
chrome(s, slide, model, n);
|
|
94
|
+
if (slide.bullets.length) s.addText(runs(slide.bullets, model.theme.dark), { ...BODY, valign: "top", margin: 4 });
|
|
95
|
+
},
|
|
96
|
+
two(s, slide, model, n) {
|
|
97
|
+
chrome(s, slide, model, n);
|
|
98
|
+
const half = (BODY.w - 0.4) / 2;
|
|
99
|
+
[slide.left, slide.right].forEach((col, i) => {
|
|
100
|
+
const x = BODY.x + i * (half + 0.4);
|
|
101
|
+
if (col.heading) s.addText(col.heading, { x, y: BODY.y, w: half, h: 0.5, fontSize: 20, bold: true, color: model.theme.accent, margin: 0, lang: LANG });
|
|
102
|
+
const top = col.heading ? 0.6 : 0;
|
|
103
|
+
if (col.bullets.length) s.addText(runs(col.bullets, model.theme.dark), { x, y: BODY.y + top, w: half, h: BODY.h - top, valign: "top", margin: 4 });
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
table(s, slide, model, n) {
|
|
107
|
+
chrome(s, slide, model, n);
|
|
108
|
+
const t = model.theme;
|
|
109
|
+
const head = slide.table.header.map((h) => ({ text: h, options: { bold: true, color: "FFFFFF", fill: { color: t.primary }, lang: LANG } }));
|
|
110
|
+
const rows = slide.table.rows.map((r, i) => r.map((c) => ({ text: c, options: { color: t.dark, fill: { color: i % 2 ? "FFFFFF" : t.light }, lang: LANG } })));
|
|
111
|
+
const all = head.length ? [head, ...rows] : rows;
|
|
112
|
+
s.addTable(all, { x: BODY.x, y: BODY.y, w: BODY.w, fontSize: 14, border: { type: "solid", pt: 0.75, color: "D5D8DC" }, valign: "middle", autoPage: false });
|
|
113
|
+
caption(s, slide, model);
|
|
114
|
+
},
|
|
115
|
+
chart(s, slide, model, n, p) {
|
|
116
|
+
chrome(s, slide, model, n);
|
|
117
|
+
const c = slide.chart;
|
|
118
|
+
const data = c.series.map((x) => ({ name: x.name, labels: c.labels, values: x.values }));
|
|
119
|
+
// A colour for every point or series: past the list PptxGenJS picks one at random.
|
|
120
|
+
const colors = Array.from({ length: Math.max(c.labels.length, c.series.length, 1) }, (_, i) => PALETTE[i % PALETTE.length]);
|
|
121
|
+
const opts = {
|
|
122
|
+
x: BODY.x, y: BODY.y, w: BODY.w, h: BODY.h - 0.5, chartColors: colors, showLegend: c.series.length > 1 || c.kind === "pie",
|
|
123
|
+
legendPos: "b", legendFontSize: 12, catAxisLabelFontSize: 12, valAxisLabelFontSize: 12, barDir: "col", barGapWidthPct: 60,
|
|
124
|
+
valAxisTitle: c.unit || undefined, showValAxisTitle: !!c.unit, lineSize: 2, lineDataSymbolSize: 7,
|
|
125
|
+
showPercent: c.kind === "pie", showValue: false, dataLabelColor: "FFFFFF", dataLabelFontSize: 12,
|
|
126
|
+
};
|
|
127
|
+
s.addChart(p.ChartType[c.kind], data, opts);
|
|
128
|
+
caption(s, slide, model);
|
|
129
|
+
},
|
|
130
|
+
image(s, slide, model, n) {
|
|
131
|
+
chrome(s, slide, model, n);
|
|
132
|
+
const img = slide.image;
|
|
133
|
+
const box = { ...BODY, h: BODY.h - 0.5 };
|
|
134
|
+
const r = img.size && img.size.w && img.size.h ? img.size.w / img.size.h : box.w / box.h;
|
|
135
|
+
const w = Math.min(box.w, box.h * r);
|
|
136
|
+
const h = w / r;
|
|
137
|
+
const altText = slide.caption || slide.title;
|
|
138
|
+
s.addImage({ data: `${img.mime};base64,${img.data.toString("base64")}`, x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h, altText });
|
|
139
|
+
caption(s, slide, model);
|
|
140
|
+
},
|
|
141
|
+
quote(s, slide, model, n) {
|
|
142
|
+
const t = model.theme;
|
|
143
|
+
s.background = { color: t.light };
|
|
144
|
+
s.addText(`“${slide.quote}”`, { x: 1.2, y: 2.0, w: W - 2.4, h: 2.8, fontSize: 30, italic: true, color: t.dark, align: "center", valign: "middle", margin: 0, lang: LANG });
|
|
145
|
+
if (slide.by) s.addText(`- ${slide.by}`, { x: 1.2, y: 4.9, w: W - 2.4, h: 0.6, fontSize: 18, color: t.muted, align: "right", margin: 0, lang: LANG });
|
|
146
|
+
if (slide.title) s.addText(slide.title, { x: M, y: 0.35, w: W - 2 * M, h: 0.7, fontSize: 18, bold: true, color: t.accent, margin: 0, lang: LANG });
|
|
147
|
+
footer(s, model, n);
|
|
148
|
+
},
|
|
149
|
+
closing(s, slide, model) {
|
|
150
|
+
const t = model.theme;
|
|
151
|
+
s.background = { color: t.primary };
|
|
152
|
+
s.addText(slide.title, { x: 0.8, y: 2.7, w: W - 1.6, h: 1.3, fontSize: 40, bold: true, color: "FFFFFF", align: "center", valign: "middle", margin: 0, lang: LANG });
|
|
153
|
+
if (slide.subtitle) s.addText(slide.subtitle, { x: 0.8, y: 4.1, w: W - 1.6, h: 0.7, fontSize: 20, color: "D6EAF8", align: "center", margin: 0, lang: LANG });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function caption(s, slide, model) {
|
|
158
|
+
if (slide.caption) s.addText(slide.caption, { x: BODY.x, y: BODY.y + BODY.h - 0.4, w: BODY.w, h: 0.4, fontSize: 12, color: model.theme.muted, margin: 0, lang: LANG });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The theme fonts: Latin Noto Sans, East Asian and Hangul Noto Sans KR (major and minor font sets). */
|
|
162
|
+
export function patchTheme(xml) {
|
|
163
|
+
return xml
|
|
164
|
+
.replace(/<a:latin typeface="[^"]*"/g, `<a:latin typeface="${LATIN}"`)
|
|
165
|
+
.replace(/<a:ea typeface="[^"]*"/g, `<a:ea typeface="${KR}"`)
|
|
166
|
+
.replace(/<a:font script="Hang" typeface="[^"]*"/g, `<a:font script="Hang" typeface="${KR}"`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Chart text: PptxGenJS writes <a:latin typeface="Arial"/> alone; make it Noto Sans and add the Hangul font. */
|
|
170
|
+
export function patchChart(xml) {
|
|
171
|
+
return xml.replace(/<a:latin typeface="[^"]*"\/>(?!<a:ea )/g, `<a:latin typeface="${LATIN}"/><a:ea typeface="${KR}"/>`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** PptxGenJS 4.0.1 writes the chart data table as ref="A1:C6'" (a stray quote); Excel wants a plain range. */
|
|
175
|
+
export const patchTable = (xml) => xml.replace(/ ref="([A-Z]+\d+:[A-Z]+\d+)'"/g, ' ref="$1"');
|
|
176
|
+
|
|
177
|
+
async function patchEmbedded(JSZip, bytes) {
|
|
178
|
+
const inner = await JSZip.loadAsync(bytes);
|
|
179
|
+
for (const f of Object.keys(inner.files).filter((x) => /^xl\/tables\/table\d+\.xml$/.test(x))) {
|
|
180
|
+
inner.file(f, patchTable(await inner.file(f).async("string")));
|
|
181
|
+
}
|
|
182
|
+
return inner.generateAsync({ type: "uint8array", compression: "DEFLATE" });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** PptxGenJS ends every paragraph with <a:endParaRPr lang="en-US">; text typed there later should be Korean too. */
|
|
186
|
+
export const patchSlide = (xml) => xml.replace(/<a:endParaRPr lang="en-US"/g, '<a:endParaRPr lang="ko-KR"');
|
|
187
|
+
|
|
188
|
+
const PATCHES = [
|
|
189
|
+
[/^ppt\/theme\/theme\d+\.xml$/, "string", patchTheme],
|
|
190
|
+
[/^ppt\/slides\/slide\d+\.xml$/, "string", patchSlide],
|
|
191
|
+
[/^ppt\/charts\/chart\d+\.xml$/, "string", patchChart],
|
|
192
|
+
[/^ppt\/embeddings\/[^/]+\.xlsx$/, "uint8array", null],
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
/** Returns the PPTX bytes for a model. */
|
|
196
|
+
export async function toPptx(model) {
|
|
197
|
+
const { PptxGenJS, JSZip } = pptxgen();
|
|
198
|
+
const p = new PptxGenJS();
|
|
199
|
+
p.layout = "LAYOUT_WIDE";
|
|
200
|
+
p.title = model.title;
|
|
201
|
+
p.author = model.author || "wide-pptx";
|
|
202
|
+
p.company = "";
|
|
203
|
+
p.subject = model.title;
|
|
204
|
+
p.theme = { headFontFace: LATIN, bodyFontFace: LATIN };
|
|
205
|
+
model.slides.forEach((slide, i) => {
|
|
206
|
+
const s = p.addSlide();
|
|
207
|
+
DRAW[slide.type](s, slide, model, i + 1, p);
|
|
208
|
+
if (slide.notes) s.addNotes(slide.notes);
|
|
209
|
+
});
|
|
210
|
+
const raw = await p.write({ outputType: "nodebuffer", compression: true });
|
|
211
|
+
const zip = await JSZip.loadAsync(raw);
|
|
212
|
+
for (const f of Object.keys(zip.files)) {
|
|
213
|
+
const fix = PATCHES.find(([re]) => re.test(f));
|
|
214
|
+
if (!fix) continue;
|
|
215
|
+
const body = await zip.file(f).async(fix[1]);
|
|
216
|
+
zip.file(f, fix[2] ? fix[2](body) : await patchEmbedded(JSZip, body));
|
|
217
|
+
}
|
|
218
|
+
return Buffer.from(await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
|
|
219
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// wp-svg.mjs: bar, line and pie charts as inline SVG for the HTML deck (no library). Values come from wp-model.mjs
|
|
2
|
+
// as finite numbers; text is escaped; colours are 6-digit hex.
|
|
3
|
+
import { PALETTE } from "./wp-model.mjs";
|
|
4
|
+
|
|
5
|
+
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
6
|
+
const f = (n) => Math.round(n * 10) / 10;
|
|
7
|
+
|
|
8
|
+
/** A "nice" axis: [min, max, step] covering the values with about 5 steps. */
|
|
9
|
+
export function niceAxis(values) {
|
|
10
|
+
let lo = values.reduce((a, b) => Math.min(a, b), 0);
|
|
11
|
+
let hi = values.reduce((a, b) => Math.max(a, b), 0);
|
|
12
|
+
if (lo === hi) hi = lo + 1;
|
|
13
|
+
const raw = (hi - lo) / 5;
|
|
14
|
+
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
15
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw);
|
|
16
|
+
lo = Math.floor(lo / step) * step;
|
|
17
|
+
hi = Math.ceil(hi / step) * step;
|
|
18
|
+
return [lo, hi, step];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Decimal places a step needs: 0.25 needs 2, 0.02 needs 2, 5 needs 0. */
|
|
22
|
+
export function decimalsOf(step) {
|
|
23
|
+
for (let d = 0; d < 10; d++) if (Math.abs(Math.round(step * 10 ** d) - step * 10 ** d) < 1e-9) return d;
|
|
24
|
+
return 10;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The tick labels of an axis: lo + k * step, each with the decimals the step needs. */
|
|
28
|
+
export function tickLabels([lo, hi, step]) {
|
|
29
|
+
const dec = decimalsOf(step);
|
|
30
|
+
const n = Math.round((hi - lo) / step);
|
|
31
|
+
return Array.from({ length: n + 1 }, (_, k) => {
|
|
32
|
+
const v = lo + k * step;
|
|
33
|
+
return { v, text: Math.abs(v) >= 1000 ? v.toLocaleString("en-US", { maximumFractionDigits: dec }) : v.toFixed(dec) };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function legend(names, w, y) {
|
|
38
|
+
if (names.length < 2) return "";
|
|
39
|
+
const item = 150;
|
|
40
|
+
const x0 = (w - item * names.length) / 2;
|
|
41
|
+
return names.map((n, i) => `<rect x="${f(x0 + i * item)}" y="${y - 10}" width="14" height="14" fill="#${PALETTE[i % PALETTE.length]}"/>`
|
|
42
|
+
+ `<text x="${f(x0 + i * item + 20)}" y="${y + 2}" font-size="16">${esc(n)}</text>`).join("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function axes(c, box) {
|
|
46
|
+
const axis = niceAxis(c.series.flatMap((s) => s.values));
|
|
47
|
+
const [lo, hi] = axis;
|
|
48
|
+
const y = (v) => box.y + box.h - ((v - lo) / (hi - lo)) * box.h;
|
|
49
|
+
const grid = tickLabels(axis).map((t) => `<line x1="${box.x}" x2="${box.x + box.w}" y1="${f(y(t.v))}" y2="${f(y(t.v))}" stroke="#D5D8DC"/>`
|
|
50
|
+
+ `<text x="${box.x - 10}" y="${f(y(t.v)) + 5}" font-size="14" text-anchor="end">${esc(t.text)}</text>`).join("");
|
|
51
|
+
const unit = c.unit ? `<text x="${box.x}" y="${box.y - 14}" font-size="14">${esc(c.unit)}</text>` : "";
|
|
52
|
+
return { y, svg: grid + unit };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function labelsAt(c, box, slot) {
|
|
56
|
+
return c.labels.map((l, i) => `<text x="${f(box.x + slot * (i + 0.5))}" y="${box.y + box.h + 24}" font-size="14" text-anchor="middle">${esc(l)}</text>`).join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function bar(c, w, h) {
|
|
60
|
+
const box = { x: 80, y: 30, w: w - 110, h: h - 100 };
|
|
61
|
+
const { y, svg } = axes(c, box);
|
|
62
|
+
const slot = box.w / c.labels.length;
|
|
63
|
+
const bw = (slot * 0.7) / c.series.length;
|
|
64
|
+
const bars = c.series.map((s, k) => s.values.map((v, i) => {
|
|
65
|
+
const x = box.x + slot * i + slot * 0.15 + bw * k;
|
|
66
|
+
const top = Math.min(y(v), y(0));
|
|
67
|
+
return `<rect x="${f(x)}" y="${f(top)}" width="${f(bw)}" height="${f(Math.abs(y(v) - y(0)))}" fill="#${PALETTE[k % PALETTE.length]}"/>`;
|
|
68
|
+
}).join("")).join("");
|
|
69
|
+
return svg + bars + labelsAt(c, box, slot) + legend(c.series.map((s) => s.name), w, h - 16);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function line(c, w, h) {
|
|
73
|
+
const box = { x: 80, y: 30, w: w - 110, h: h - 100 };
|
|
74
|
+
const { y, svg } = axes(c, box);
|
|
75
|
+
const slot = box.w / c.labels.length;
|
|
76
|
+
const lines = c.series.map((s, k) => {
|
|
77
|
+
const color = PALETTE[k % PALETTE.length];
|
|
78
|
+
const pts = s.values.map((v, i) => [f(box.x + slot * (i + 0.5)), f(y(v))]);
|
|
79
|
+
return `<polyline fill="none" stroke="#${color}" stroke-width="3" points="${pts.map((p) => p.join(",")).join(" ")}"/>`
|
|
80
|
+
+ pts.map((p) => `<circle cx="${p[0]}" cy="${p[1]}" r="5" fill="#${color}"/>`).join("");
|
|
81
|
+
}).join("");
|
|
82
|
+
return svg + lines + labelsAt(c, box, slot) + legend(c.series.map((s) => s.name), w, h - 16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pie(c, w, h) {
|
|
86
|
+
const values = c.series[0].values;
|
|
87
|
+
const total = values.reduce((a, b) => a + b, 0) || 1;
|
|
88
|
+
const r = Math.min(w * 0.3, (h - 70) / 2);
|
|
89
|
+
const cx = w * 0.38;
|
|
90
|
+
const cy = (h - 40) / 2;
|
|
91
|
+
let a = -Math.PI / 2;
|
|
92
|
+
const parts = values.map((v, i) => {
|
|
93
|
+
const b = a + (v / total) * 2 * Math.PI;
|
|
94
|
+
const large = b - a > Math.PI ? 1 : 0;
|
|
95
|
+
const p = (t) => `${f(cx + r * Math.cos(t))} ${f(cy + r * Math.sin(t))}`;
|
|
96
|
+
const d = v >= total ? `M ${cx - r} ${cy} a ${r} ${r} 0 1 0 ${2 * r} 0 a ${r} ${r} 0 1 0 ${-2 * r} 0` : `M ${cx} ${cy} L ${p(a)} A ${r} ${r} 0 ${large} 1 ${p(b)} Z`;
|
|
97
|
+
a = b;
|
|
98
|
+
return `<path d="${d}" fill="#${PALETTE[i % PALETTE.length]}" stroke="#FFFFFF" stroke-width="2"/>`;
|
|
99
|
+
}).join("");
|
|
100
|
+
const keys = c.labels.map((l, i) => {
|
|
101
|
+
const ky = 40 + i * 30;
|
|
102
|
+
const pct = f((values[i] / total) * 100);
|
|
103
|
+
return `<rect x="${f(w * 0.7)}" y="${ky - 12}" width="16" height="16" fill="#${PALETTE[i % PALETTE.length]}"/>`
|
|
104
|
+
+ `<text x="${f(w * 0.7 + 24)}" y="${ky + 2}" font-size="16">${esc(l)} ${pct}%</text>`;
|
|
105
|
+
}).join("");
|
|
106
|
+
return parts + keys;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const KINDS = { bar, line, pie };
|
|
110
|
+
|
|
111
|
+
/** An SVG element w x h px for a model chart. */
|
|
112
|
+
export function chartSvg(c, w, h) {
|
|
113
|
+
return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" fill="#1B2631" font-family="inherit">${KINDS[c.kind](c, w, h)}</svg>`;
|
|
114
|
+
}
|