@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,108 @@
|
|
|
1
|
+
// wg-html.mjs: draws the wbs-gen model as one self-contained HTML file (a table and CSS, no script, no library, no
|
|
2
|
+
// network). The grid matches the XLSX: 8 fixed columns, week and day headers, cell-fill bars, ◆, the red gate line.
|
|
3
|
+
// Colours reach the page only as 6-digit hex checked by wg-plan.mjs. Fonts: Noto Sans first and Noto Sans KR next;
|
|
4
|
+
// Noto Sans has no Hangul, so Hangul is drawn in Noto Sans KR and everything else in Noto Sans. Nothing is embedded:
|
|
5
|
+
// a viewer without the fonts uses its own sans-serif.
|
|
6
|
+
|
|
7
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8
|
+
const br = (s) => esc(s).replace(/\n/g, "<br>");
|
|
9
|
+
const WIDTHS = [48, 260, 84, 70, 210, 56, 56, 60];
|
|
10
|
+
const DAY = 36;
|
|
11
|
+
const ALIGN = ["c", "l", "c", "c", "l", "c", "c", "c"];
|
|
12
|
+
|
|
13
|
+
const CSS = `
|
|
14
|
+
*{box-sizing:border-box}
|
|
15
|
+
body{margin:16px;font-family:"Noto Sans","Noto Sans KR",sans-serif;color:#2C3E50;font-size:12px}
|
|
16
|
+
.wrap{overflow:auto;max-height:calc(100vh - 32px);border:1px solid #D5D8DC}
|
|
17
|
+
table{border-collapse:separate;border-spacing:0;table-layout:fixed}
|
|
18
|
+
th,td{border-right:1px solid #D5D8DC;border-bottom:1px solid #D5D8DC;padding:2px 4px;overflow:hidden;vertical-align:middle;height:22px}
|
|
19
|
+
.l{text-align:left;white-space:normal;word-break:keep-all}.c,th{text-align:center}
|
|
20
|
+
.title th{background:#1F3864;color:#fff;font-size:15px;text-align:left;height:34px;padding:0 10px}
|
|
21
|
+
.h th{background:#34495E;color:#fff;font-size:12px}
|
|
22
|
+
.h th.w{background:#5D6D7E}
|
|
23
|
+
.d th{background:#EBEDEF;color:#2C3E50;font-size:10px;line-height:1.2;height:36px;padding:0;letter-spacing:-.3px}
|
|
24
|
+
thead tr th{position:sticky;z-index:2}
|
|
25
|
+
.title th{top:0}.h th{top:34px}.d th{top:56px}
|
|
26
|
+
.fix{position:sticky;z-index:1;background:#fff}
|
|
27
|
+
thead .fix{z-index:3}
|
|
28
|
+
tr.group td{font-weight:700;color:#1B2631}
|
|
29
|
+
.dia{color:#C0392B;font-weight:700;text-align:center;padding:0}
|
|
30
|
+
.dot{outline:1px dotted #7F8C8D;outline-offset:-2px}
|
|
31
|
+
.gate{box-shadow:inset 2px 0 0 #C0392B}
|
|
32
|
+
.notes{margin-top:18px;font-size:12px}
|
|
33
|
+
.notes h3{font-size:13px;margin:14px 0 6px;color:#1B2631}
|
|
34
|
+
.notes ul{margin:0;padding:0;list-style:none}
|
|
35
|
+
.notes li{margin:3px 0;display:flex;align-items:center;gap:8px}
|
|
36
|
+
.sw{display:inline-block;width:28px;height:14px;border:1px solid #D5D8DC}
|
|
37
|
+
.sw.dotted{border:1px dotted #7F8C8D}
|
|
38
|
+
.sw.gateline{border:0;border-left:2px solid #C0392B;border-right:2px solid #C0392B;width:10px}
|
|
39
|
+
.sw.diamond{border:0;color:#C0392B;font-weight:700;text-align:center;line-height:14px}
|
|
40
|
+
@page{size:A3 landscape;margin:10mm}
|
|
41
|
+
@media print{body{margin:0}.wrap{overflow:visible;max-height:none;border:0}thead tr th,.fix{position:static}
|
|
42
|
+
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function colgroup(n) {
|
|
46
|
+
return `<colgroup>${WIDTHS.map((w) => `<col style="width:${w}px">`).join("")}${`<col style="width:${DAY}px">`.repeat(n)}</colgroup>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Left offsets of the 8 sticky columns.
|
|
50
|
+
const LEFT = WIDTHS.map((_, i) => WIDTHS.slice(0, i).reduce((a, b) => a + b, 0));
|
|
51
|
+
const fixStyle = (c, bg) => ` class="fix" style="left:${LEFT[c]}px${bg ? `;background:#${bg}` : ""}"`;
|
|
52
|
+
|
|
53
|
+
function head(m, gate) {
|
|
54
|
+
const total = 8 + m.days.length;
|
|
55
|
+
const title = `<tr class="title"><th colspan="${total}">${esc(m.title)}</th></tr>`;
|
|
56
|
+
const fixed = m.head.map((h, c) => `<th rowspan="2" class="fix" style="left:${LEFT[c]}px;background:#34495E">${br(h)}</th>`).join("");
|
|
57
|
+
const wk = m.weeks.map((w) => `<th class="w${gate.has(w.first) ? " gate" : ""}" colspan="${w.count}">${esc(w.label)}</th>`).join("");
|
|
58
|
+
const days = m.days.map((d, i) => `<th${gate.has(i) ? ' class="gate"' : ""}>${esc(d.label)}<br>${esc(d.dow)}</th>`).join("");
|
|
59
|
+
return `<thead>${title}<tr class="h">${fixed}${wk}</tr><tr class="d">${days}</tr></thead>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function dayTd(row, i, gate) {
|
|
63
|
+
const cls = [gate.has(i) ? "gate" : ""];
|
|
64
|
+
if (row.diamonds.includes(i)) {
|
|
65
|
+
cls.push("dia", row.dotted ? "dot" : "");
|
|
66
|
+
const bg = row.fill ? ` style="background:#${row.fill}"` : "";
|
|
67
|
+
return `<td class="${cls.filter(Boolean).join(" ")}"${bg}>◆</td>`;
|
|
68
|
+
}
|
|
69
|
+
const fill = i >= row.from && i <= row.to ? row.bar : row.fill;
|
|
70
|
+
const c = cls.filter(Boolean).join(" ");
|
|
71
|
+
return `<td${c ? ` class="${c}"` : ""}${fill ? ` style="background:#${fill}"` : ""}></td>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function body(m, gate) {
|
|
75
|
+
const rows = m.rows.map((row) => {
|
|
76
|
+
const fixed = row.cells.map((v, c) => `<td${fixStyle(c, row.fill)}><div class="${ALIGN[c]}">${br(v)}</div></td>`)
|
|
77
|
+
.join("");
|
|
78
|
+
const days = m.days.map((_, i) => dayTd(row, i, gate)).join("");
|
|
79
|
+
return `<tr class="${row.kind}">${fixed}${days}</tr>`;
|
|
80
|
+
});
|
|
81
|
+
return `<tbody>${rows.join("")}</tbody>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function swatch(item) {
|
|
85
|
+
if (item.type === "diamond") return `<span class="sw diamond">◆</span>`;
|
|
86
|
+
if (item.type === "gate") return `<span class="sw gateline"></span>`;
|
|
87
|
+
const cls = item.type === "dotted" ? "sw dotted" : "sw";
|
|
88
|
+
return `<span class="${cls}" style="background:#${item.color}"></span>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notes(m) {
|
|
92
|
+
const legend = m.legend.length
|
|
93
|
+
? `<h3>[범례]</h3><ul>${m.legend.map((l) => `<li>${swatch(l)}<span>${esc(l.label)}</span></li>`).join("")}</ul>`
|
|
94
|
+
: "";
|
|
95
|
+
const blocks = m.notes.map((n) => `${n.heading ? `<h3>${esc(n.heading)}</h3>` : ""}<ul>${n.lines.map((l) => `<li>${esc(l)}</li>`).join("")}</ul>`);
|
|
96
|
+
return `<section class="notes">${legend}${blocks.join("")}</section>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns the HTML text for a layout model. */
|
|
100
|
+
export function toHtml(m) {
|
|
101
|
+
const gate = new Set(m.gates.map((g) => g.col));
|
|
102
|
+
// A fixed width keeps every column at its colgroup width, so the sticky offsets match in a narrow window too.
|
|
103
|
+
const width = WIDTHS.reduce((x, y) => x + y, 0) + DAY * m.days.length;
|
|
104
|
+
const table = `<table style="width:${width}px">${colgroup(m.days.length)}${head(m, gate)}${body(m, gate)}</table>`;
|
|
105
|
+
return `<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`
|
|
106
|
+
+ `<title>${esc(m.title.split(" | ")[0])}</title><style>${CSS}</style></head>`
|
|
107
|
+
+ `<body><div class="wrap">${table}</div>${notes(m)}</body></html>\n`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// wg-plan.mjs: reads a wbs-gen plan (JSON), checks it and lays it out as one model that the XLSX and HTML writers
|
|
2
|
+
// both draw. Nothing here touches the network or the disk.
|
|
3
|
+
import { businessDays, daysBetween, dowName, keyMs, mmdd, parseHolidays, resolveDate, weeks } from "./wg-dates.mjs";
|
|
4
|
+
|
|
5
|
+
export const HEAD = ["WBS", "작업", "담당", "우선순위", "선행·미확정", "시작", "종료", "일수\n(영업일)"];
|
|
6
|
+
export const KINDS = ["group", "task", "constraint", "milestone"];
|
|
7
|
+
// Five years of calendar days at most: about 1300 day columns, well inside Excel's 16384 columns.
|
|
8
|
+
export const MAX_SPAN_DAYS = 5 * 366;
|
|
9
|
+
// Excel keeps at most 32767 characters in a cell.
|
|
10
|
+
export const MAX_TEXT = 32767;
|
|
11
|
+
const HEX = /^#?[0-9A-Fa-f]{6}$/;
|
|
12
|
+
const DEFAULT_BAR = "5D6D7E";
|
|
13
|
+
const DEFAULT_LIGHT = "EBEDEF";
|
|
14
|
+
|
|
15
|
+
export const hex = (c) => String(c).replace(/^#/, "").toUpperCase();
|
|
16
|
+
const str = (v) => (v === undefined || v === null ? "" : String(v));
|
|
17
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
18
|
+
// Lookups by a plan string go through own keys only, so "constructor" or "__proto__" is just a missing name.
|
|
19
|
+
const own = (o, k) => (Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
|
20
|
+
|
|
21
|
+
/** A list field: absent is empty; present but not an array is an error. */
|
|
22
|
+
function list(plan, name, errors) {
|
|
23
|
+
const v = plan[name];
|
|
24
|
+
if (v === undefined || v === null) return [];
|
|
25
|
+
if (Array.isArray(v)) return v;
|
|
26
|
+
errors.push(`${name} 는 배열이어야 함`);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRange(plan, errors) {
|
|
31
|
+
const range = { start: str(plan.start), end: str(plan.end) };
|
|
32
|
+
if (keyMs(range.start) === null || keyMs(range.end) === null) {
|
|
33
|
+
errors.push("start, end 는 YYYY-MM-DD 여야 함");
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (range.end < range.start) {
|
|
37
|
+
errors.push("end 가 start 보다 앞섬");
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (daysBetween(range.start, range.end) > MAX_SPAN_DAYS) {
|
|
41
|
+
errors.push("기간이 5년을 넘음. 단계나 해로 나누어 만드십시오");
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return range;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Checks the plan's frame (title, range, holidays, parts, bars). */
|
|
48
|
+
function readFrame(plan, errors, warnings) {
|
|
49
|
+
if (!str(plan.title).trim()) errors.push("title 이 없음");
|
|
50
|
+
const range = readRange(plan, errors);
|
|
51
|
+
if (!range) return null;
|
|
52
|
+
const h = parseHolidays(plan.holidays ?? [], range);
|
|
53
|
+
if (h.bad.length) errors.push(`공휴일을 날짜로 읽지 못함: ${h.bad.join(", ")}`);
|
|
54
|
+
if (h.ambiguous.length) errors.push(`기간 안 두 해에 걸리는 M/D: ${h.ambiguous.join(", ")}. YYYY-MM-DD 로 적으십시오`);
|
|
55
|
+
if (h.outside.length) warnings.push(`기간 밖 공휴일은 뺌: ${h.outside.join(", ")}`);
|
|
56
|
+
return { range, holidays: h.keys, parts: readParts(obj(plan.parts), errors), bars: readBars(obj(plan.bars), errors) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBars(given, errors) {
|
|
60
|
+
const bars = Object.create(null);
|
|
61
|
+
for (const [k, v] of Object.entries(given)) {
|
|
62
|
+
if (HEX.test(str(v))) bars[k] = hex(v);
|
|
63
|
+
else errors.push(`bars.${k} 색이 6자리 16진수가 아님: ${str(v)}`);
|
|
64
|
+
}
|
|
65
|
+
return bars;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readParts(parts, errors) {
|
|
69
|
+
const out = Object.create(null);
|
|
70
|
+
for (const [id, raw] of Object.entries(parts)) {
|
|
71
|
+
const p = obj(raw);
|
|
72
|
+
for (const k of ["light", "dark"]) {
|
|
73
|
+
if (p[k] !== undefined && !HEX.test(str(p[k]))) errors.push(`parts.${id}.${k} 색이 6자리 16진수가 아님: ${str(p[k])}`);
|
|
74
|
+
}
|
|
75
|
+
out[id] = { name: str(p.name || id), light: hex(p.light ?? DEFAULT_LIGHT), dark: hex(p.dark ?? DEFAULT_BAR), legend: p.legend !== false };
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A plan date as a key inside the range that is a working day, or null with an error. */
|
|
81
|
+
function workday(text, what, ctx) {
|
|
82
|
+
const r = resolveDate(text, ctx.range);
|
|
83
|
+
if (!r) ctx.errors.push(`${what}: 날짜로 읽지 못함 (${str(text)})`);
|
|
84
|
+
else if (r.ambiguous) ctx.errors.push(`${what}: ${str(text)} 가 기간 안 두 해에 걸림. YYYY-MM-DD 로 적으십시오`);
|
|
85
|
+
else if (!r.inRange) ctx.errors.push(`${what}: 기간 밖 (${r.key})`);
|
|
86
|
+
else if (ctx.index.get(r.key) === undefined) ctx.errors.push(`${what}: 주말이나 공휴일 (${r.key} ${dowName(r.key)})`);
|
|
87
|
+
else return r.key;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gatesOf(plan, ctx) {
|
|
92
|
+
return list(plan, "gates", ctx.errors).map((g, i) => {
|
|
93
|
+
const key = workday(obj(g).date, `gates[${i}]`, ctx);
|
|
94
|
+
return key && { key, col: ctx.index.get(key), label: str(obj(g).label) };
|
|
95
|
+
}).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function barColor(row, part, ctx) {
|
|
99
|
+
if (row.bar !== undefined) {
|
|
100
|
+
if (HEX.test(str(row.bar))) return hex(row.bar);
|
|
101
|
+
ctx.errors.push(`${str(row.wbs || row.task)}: bar 색이 6자리 16진수가 아님`);
|
|
102
|
+
}
|
|
103
|
+
return own(ctx.bars, str(row.priority)) ?? part?.dark ?? DEFAULT_BAR;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function partOf(row, what, ctx) {
|
|
107
|
+
if (row.part === undefined) return null;
|
|
108
|
+
const p = own(ctx.parts, str(row.part));
|
|
109
|
+
if (!p) ctx.errors.push(`${what}: parts 에 없는 part (${str(row.part)})`);
|
|
110
|
+
return p ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Start and end of a task or constraint; a constraint may give date alone. */
|
|
114
|
+
function spanOf(row, what, ctx) {
|
|
115
|
+
const s = workday(row.start ?? row.date, `${what} 시작`, ctx);
|
|
116
|
+
const e = workday(row.end ?? row.start ?? row.date, `${what} 종료`, ctx);
|
|
117
|
+
if (s && e && e < s) {
|
|
118
|
+
ctx.errors.push(`${what}: 종료가 시작보다 앞섬`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return s && e ? { s, e } : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cellsOf(row, extra) {
|
|
125
|
+
return [str(row.wbs), str(row.task), str(row.owner), str(row.priority), str(row.pending), ...extra];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function layRow(row, i, ctx) {
|
|
129
|
+
const kind = str(row.kind || "task");
|
|
130
|
+
const what = `rows[${i}] ${str(row.wbs || row.task)}`.trim();
|
|
131
|
+
if (!KINDS.includes(kind)) {
|
|
132
|
+
ctx.errors.push(`${what}: kind 는 ${KINDS.join(", ")} 중 하나여야 함 (${kind})`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const part = partOf(row, what, ctx);
|
|
136
|
+
const out = { kind, fill: null, bar: null, from: -1, to: -1, diamonds: [], dotted: false, cells: cellsOf(row, ["", "", ""]) };
|
|
137
|
+
if (kind === "group") return { ...out, fill: part?.light ?? DEFAULT_LIGHT };
|
|
138
|
+
if (kind === "milestone") {
|
|
139
|
+
const k = workday(row.date ?? row.start, what, ctx);
|
|
140
|
+
return { ...out, diamonds: k ? [ctx.index.get(k)] : [] };
|
|
141
|
+
}
|
|
142
|
+
return laySpan(row, kind, part, what, ctx, out);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function laySpan(row, kind, part, what, ctx, out) {
|
|
146
|
+
const span = spanOf(row, what, ctx);
|
|
147
|
+
if (!span) return out;
|
|
148
|
+
const from = ctx.index.get(span.s);
|
|
149
|
+
const to = ctx.index.get(span.e);
|
|
150
|
+
const cells = cellsOf(row, [ctx.label(span.s), ctx.label(span.e), to - from + 1]);
|
|
151
|
+
if (kind === "constraint") return { ...out, diamonds: [to], dotted: true, cells };
|
|
152
|
+
return { ...out, bar: barColor(row, part, ctx), from, to, cells };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function legendOf(plan, parts, gates, errors) {
|
|
156
|
+
const items = Object.values(parts).filter((p) => p.legend).map((p) => ({ type: "swatch", color: p.dark, label: p.name }));
|
|
157
|
+
list(plan, "legend", errors).map(obj).forEach((l, i) => {
|
|
158
|
+
if (!HEX.test(str(l.color))) errors.push(`legend[${i}] 색이 6자리 16진수가 아님`);
|
|
159
|
+
else items.push({ type: l.dotted ? "dotted" : "swatch", color: hex(l.color), label: str(l.label) });
|
|
160
|
+
});
|
|
161
|
+
if (plan.milestoneLegend !== false) items.push({ type: "diamond", color: "", label: str(plan.milestoneLabel || "마일스톤") });
|
|
162
|
+
for (const g of gates) items.push({ type: "gate", color: "C0392B", label: `${g.label || "게이트"}(빨간 수직선 = ${mmdd(g.key)})` });
|
|
163
|
+
return items;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** notes: [{ heading, lines }]; lines may also be one string. */
|
|
167
|
+
function notesOf(plan, errors) {
|
|
168
|
+
return list(plan, "notes", errors).map(obj).map((n, i) => {
|
|
169
|
+
const lines = typeof n.lines === "string" ? [n.lines] : n.lines ?? [];
|
|
170
|
+
if (!Array.isArray(lines)) errors.push(`notes[${i}].lines 는 배열이나 글이어야 함`);
|
|
171
|
+
return { heading: str(n.heading), lines: (Array.isArray(lines) ? lines : []).map(str) };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function rowsOf(plan, ctx) {
|
|
176
|
+
if (!Array.isArray(plan.rows) || !plan.rows.length) {
|
|
177
|
+
ctx.errors.push("rows 가 비어 있음");
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
return plan.rows.map((r, i) => layRow(obj(r), i, ctx)).filter(Boolean);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function titleOf(plan, range, n) {
|
|
184
|
+
const sameYear = range.start.slice(0, 4) === range.end.slice(0, 4);
|
|
185
|
+
const endText = sameYear ? mmdd(range.end) : range.end;
|
|
186
|
+
const span = `${range.start}(${dowName(range.start)}) ~ ${endText}(${dowName(range.end)})`;
|
|
187
|
+
return [str(plan.title).trim(), span, `${n} 영업일`, str(plan.subtitle).trim()].filter(Boolean).join(" | ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function tooLong(model, errors) {
|
|
191
|
+
const texts = [
|
|
192
|
+
model.title, model.sheet, ...model.legend.map((l) => l.label), ...model.rows.flatMap((r) => r.cells.map(str)),
|
|
193
|
+
...model.notes.flatMap((n) => [n.heading, ...n.lines]),
|
|
194
|
+
];
|
|
195
|
+
if (texts.some((t) => t.length > MAX_TEXT)) errors.push(`한 칸의 글이 ${MAX_TEXT}자를 넘음(Excel 한도)`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Checks a plan and lays it out. Returns { errors, warnings, model }; model is null when there are errors.
|
|
200
|
+
* model: { title, sheet, head, days: [{ key, label, dow }], weeks, gates, rows, legend, notes }
|
|
201
|
+
*/
|
|
202
|
+
export function layout(plan) {
|
|
203
|
+
const errors = [];
|
|
204
|
+
const warnings = [];
|
|
205
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan)) return { errors: ["plan 은 JSON 객체여야 함"], warnings, model: null };
|
|
206
|
+
const frame = readFrame(plan, errors, warnings);
|
|
207
|
+
if (!frame) return { errors, warnings, model: null };
|
|
208
|
+
const days = businessDays(frame.range.start, frame.range.end, frame.holidays);
|
|
209
|
+
if (!days.length) errors.push("기간 안에 영업일이 없음");
|
|
210
|
+
const multiYear = frame.range.start.slice(0, 4) !== frame.range.end.slice(0, 4);
|
|
211
|
+
const label = (k) => (multiYear ? `${k.slice(2, 4)}/${mmdd(k)}` : mmdd(k));
|
|
212
|
+
const ctx = { ...frame, errors, label, index: new Map(days.map((k, i) => [k, i])) };
|
|
213
|
+
const gates = gatesOf(plan, ctx);
|
|
214
|
+
const rows = rowsOf(plan, ctx);
|
|
215
|
+
const legend = legendOf(plan, frame.parts, gates, errors);
|
|
216
|
+
const notes = notesOf(plan, errors);
|
|
217
|
+
const model = {
|
|
218
|
+
title: titleOf(plan, frame.range, days.length),
|
|
219
|
+
sheet: str(plan.sheet || "WBS 간트"),
|
|
220
|
+
head: HEAD,
|
|
221
|
+
days: days.map((k) => ({ key: k, label: mmdd(k), dow: dowName(k) })),
|
|
222
|
+
weeks: weeks(days),
|
|
223
|
+
gates,
|
|
224
|
+
rows,
|
|
225
|
+
legend,
|
|
226
|
+
notes,
|
|
227
|
+
};
|
|
228
|
+
tooLong(model, errors);
|
|
229
|
+
return { errors, warnings, model: errors.length ? null : model };
|
|
230
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// wg-xlsx.mjs: draws the wbs-gen model as one XLSX sheet. Bars are plain cell fills (no conditional formatting), so
|
|
2
|
+
// the file looks the same in any viewer; the parts follow the layout Excel itself writes (shared strings, frozen
|
|
3
|
+
// panes, print titles, A3 landscape fit to one page wide).
|
|
4
|
+
import { zip } from "./wg-zip.mjs";
|
|
5
|
+
|
|
6
|
+
const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
|
|
7
|
+
const NSR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
8
|
+
// Hangul is set in Noto Sans KR and everything else in Noto Sans. A cell that mixes the two holds rich-text runs,
|
|
9
|
+
// one font per run; spaces and punctuation stay in the run they follow. Noto Sans has no geometric shapes, so ◆
|
|
10
|
+
// goes with Noto Sans KR, which has them; so do Hanja, CJK punctuation and full-width forms (Noto Sans has none).
|
|
11
|
+
export const KR = "Noto Sans KR";
|
|
12
|
+
export const LATIN = "Noto Sans";
|
|
13
|
+
const HANGUL = /[\u1100-\u11FF\u2190-\u21FF\u2460-\u24FF\u25A0-\u27BF\u2E80-\u9FFF\uA960-\uA97F\uAC00-\uD7A3\uD7B0-\uD7FF\uF900-\uFAFF\uFF00-\uFFEF※]/;
|
|
14
|
+
const NEUTRAL = /[\s.,·:;()[\]{}\-/|'"!?~]/;
|
|
15
|
+
const GRID = "D5D8DC";
|
|
16
|
+
const GATE = "C0392B";
|
|
17
|
+
const FONTS = {
|
|
18
|
+
base: { sz: 11, name: LATIN },
|
|
19
|
+
title: { b: 1, sz: 13, color: "FFFFFF" },
|
|
20
|
+
head: { b: 1, sz: 10, color: "FFFFFF" },
|
|
21
|
+
day: { b: 1, sz: 8, color: "2C3E50" },
|
|
22
|
+
body: { sz: 8, color: "2C3E50" },
|
|
23
|
+
bodyB: { b: 1, sz: 8, color: "1B2631" },
|
|
24
|
+
mile: { b: 1, sz: 9, color: GATE },
|
|
25
|
+
note: { sz: 9, color: "2C3E50" },
|
|
26
|
+
noteB: { b: 1, sz: 10, color: "1B2631" },
|
|
27
|
+
};
|
|
28
|
+
const WIDTHS = [6, 36, 10.5, 9, 28, 7.5, 7.5, 8.2];
|
|
29
|
+
const DAY_WIDTH = 4.8;
|
|
30
|
+
const ALIGN = ["center", "left", "center", "center", "left", "center", "center", "center"];
|
|
31
|
+
|
|
32
|
+
// Control characters other than tab and new line are not allowed in XML 1.0.
|
|
33
|
+
const clean = (s) => String(s).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "");
|
|
34
|
+
export const esc = (s) => clean(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
35
|
+
|
|
36
|
+
export function colName(i) {
|
|
37
|
+
let s = "";
|
|
38
|
+
for (let n = i + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Splits text into runs of one font: [{ text, kr }]. Text with no letters is one Noto Sans run. */
|
|
43
|
+
export function scriptRuns(text) {
|
|
44
|
+
const runs = [];
|
|
45
|
+
let lead = "";
|
|
46
|
+
for (const ch of String(text)) {
|
|
47
|
+
const last = runs[runs.length - 1];
|
|
48
|
+
if (NEUTRAL.test(ch)) {
|
|
49
|
+
if (last) last.text += ch;
|
|
50
|
+
else lead += ch;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const kr = HANGUL.test(ch);
|
|
54
|
+
if (last && last.kr === kr) last.text += ch;
|
|
55
|
+
else runs.push({ text: (last ? "" : lead) + ch, kr });
|
|
56
|
+
}
|
|
57
|
+
return runs.length ? runs : [{ text: lead, kr: false }];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sheet names may not hold []:*?/\, start or end with ', be History, or pass 31 characters. */
|
|
61
|
+
export function sheetName(s) {
|
|
62
|
+
const trim = (x) => x.trim().replace(/^'+|'+$/g, "").trim();
|
|
63
|
+
let t = trim(clean(s).replace(/[[\]:*?/\\]/g, " "));
|
|
64
|
+
// 31 UTF-16 units, without cutting a surrogate pair; trim again in case the cut leaves a ' at the end.
|
|
65
|
+
while (t.length > 31) t = Array.from(t).slice(0, -1).join("");
|
|
66
|
+
t = trim(t);
|
|
67
|
+
return !t || /^history$/i.test(t) ? "WBS" : t;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Collects the distinct fonts, fills, borders and cell formats a sheet uses. */
|
|
71
|
+
class Styles {
|
|
72
|
+
constructor() {
|
|
73
|
+
this.fonts = [FONTS.base];
|
|
74
|
+
this.fills = ["none", "gray125"];
|
|
75
|
+
this.borders = [{}];
|
|
76
|
+
this.xfs = [{ font: 0, fill: 0, border: 0, h: "", wrap: false }];
|
|
77
|
+
this.seen = new Map();
|
|
78
|
+
}
|
|
79
|
+
pick(list, item) {
|
|
80
|
+
const k = JSON.stringify(item);
|
|
81
|
+
let i = list.findIndex((x) => JSON.stringify(x) === k);
|
|
82
|
+
if (i < 0) i = list.push(item) - 1;
|
|
83
|
+
return i;
|
|
84
|
+
}
|
|
85
|
+
/** spec: { font, fill, border: { l, r, t, b }, h, wrap }; kr picks Noto Sans KR for the cell font. */
|
|
86
|
+
id(spec, kr) {
|
|
87
|
+
const key = JSON.stringify([spec, kr]);
|
|
88
|
+
if (this.seen.has(key)) return this.seen.get(key);
|
|
89
|
+
const xf = {
|
|
90
|
+
font: this.pick(this.fonts, fontOf(spec, kr)),
|
|
91
|
+
fill: spec.fill ? this.pick(this.fills, spec.fill) : 0,
|
|
92
|
+
border: this.pick(this.borders, spec.border ?? {}),
|
|
93
|
+
h: spec.h ?? "",
|
|
94
|
+
wrap: !!spec.wrap,
|
|
95
|
+
};
|
|
96
|
+
const i = this.pick(this.xfs, xf);
|
|
97
|
+
this.seen.set(key, i);
|
|
98
|
+
return i;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const fontOf = (spec, kr) => ({ ...(FONTS[spec.font] ?? FONTS.base), name: kr ? KR : LATIN });
|
|
103
|
+
|
|
104
|
+
const BORDER = { thin: ["thin", GRID], gate: ["medium", GATE], dotted: ["dotted", "7F8C8D"] };
|
|
105
|
+
|
|
106
|
+
// The inside of <font> (cell fonts, name) and of <rPr> (rich-text runs, rFont): same children, other name tag.
|
|
107
|
+
function fontBody(f, nameTag) {
|
|
108
|
+
const b = f.b ? "<b/>" : "";
|
|
109
|
+
const color = `<color rgb="FF${f.color ?? "000000"}"/>`;
|
|
110
|
+
const charset = f.name === KR ? `<charset val="129"/>` : "";
|
|
111
|
+
return `${b}<sz val="${f.sz}"/>${color}<${nameTag} val="${f.name}"/><family val="2"/>${charset}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const fontXml = (f) => `<font>${fontBody(f, "name")}</font>`;
|
|
115
|
+
|
|
116
|
+
function fillXml(f) {
|
|
117
|
+
if (f === "none" || f === "gray125") return `<fill><patternFill patternType="${f}"/></fill>`;
|
|
118
|
+
return `<fill><patternFill patternType="solid"><fgColor rgb="FF${f}"/><bgColor indexed="64"/></patternFill></fill>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function borderXml(b) {
|
|
122
|
+
const side = (n) => {
|
|
123
|
+
const v = BORDER[b[n[0]]];
|
|
124
|
+
return v ? `<${n} style="${v[0]}"><color rgb="FF${v[1]}"/></${n}>` : `<${n}/>`;
|
|
125
|
+
};
|
|
126
|
+
return `<border>${["left", "right", "top", "bottom"].map(side).join("")}<diagonal/></border>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function xfXml(x) {
|
|
130
|
+
const flags = `${x.fill ? ' applyFill="1"' : ""}${x.border ? ' applyBorder="1"' : ""}`;
|
|
131
|
+
const align = x.h || x.wrap
|
|
132
|
+
? `<alignment${x.h ? ` horizontal="${x.h}"` : ""} vertical="center"${x.wrap ? ' wrapText="1"' : ""}/>`
|
|
133
|
+
: "";
|
|
134
|
+
const open = `<xf numFmtId="0" fontId="${x.font}" fillId="${x.fill}" borderId="${x.border}" xfId="0" applyFont="1"${flags}`;
|
|
135
|
+
return align ? `${open} applyAlignment="1">${align}</xf>` : `${open}/>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stylesXml(st) {
|
|
139
|
+
const list = (tag, items, fn) => `<${tag} count="${items.length}">${items.map(fn).join("")}</${tag}>`;
|
|
140
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<styleSheet xmlns="${NS}">`
|
|
141
|
+
+ list("fonts", st.fonts, fontXml) + list("fills", st.fills, fillXml) + list("borders", st.borders, borderXml)
|
|
142
|
+
+ `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`
|
|
143
|
+
+ list("cellXfs", st.xfs, xfXml)
|
|
144
|
+
+ `<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>`
|
|
145
|
+
+ `<dxfs count="0"/><tableStyles count="0" defaultTableStyle="TableStyleMedium2" defaultPivotStyle="PivotStyleLight16"/></styleSheet>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The sheet being drawn: cells by row, merged ranges, row heights, shared strings. */
|
|
149
|
+
class Sheet {
|
|
150
|
+
constructor() {
|
|
151
|
+
this.rows = new Map();
|
|
152
|
+
this.merges = [];
|
|
153
|
+
this.heights = new Map();
|
|
154
|
+
this.strings = [];
|
|
155
|
+
this.refs = 0;
|
|
156
|
+
this.stringIndex = new Map();
|
|
157
|
+
this.styles = new Styles();
|
|
158
|
+
}
|
|
159
|
+
put(r, c, value, spec) {
|
|
160
|
+
if (!this.rows.has(r)) this.rows.set(r, new Map());
|
|
161
|
+
const runs = typeof value === "string" && value ? scriptRuns(clean(value)) : [{ text: "", kr: false }];
|
|
162
|
+
const kr = runs.some((x) => x.kr);
|
|
163
|
+
const mixed = runs.length > 1 ? runs.map((x) => ({ ...x, font: fontOf(spec, x.kr) })) : null;
|
|
164
|
+
this.rows.get(r).set(c, { value, s: this.styles.id(spec, kr), runs: mixed });
|
|
165
|
+
}
|
|
166
|
+
merge(r1, c1, r2, c2) {
|
|
167
|
+
this.merges.push(`${colName(c1)}${r1}:${colName(c2)}${r2}`);
|
|
168
|
+
}
|
|
169
|
+
/** A shared string; mixed runs carry their fonts, so the same text in another cell font is another entry. */
|
|
170
|
+
sst(text, runs) {
|
|
171
|
+
const t = clean(text);
|
|
172
|
+
const key = runs ? `r:${JSON.stringify(runs)}` : `p:${t}`;
|
|
173
|
+
this.refs++;
|
|
174
|
+
if (!this.stringIndex.has(key)) this.stringIndex.set(key, this.strings.push({ text: t, runs }) - 1);
|
|
175
|
+
return this.stringIndex.get(key);
|
|
176
|
+
}
|
|
177
|
+
cellXml(r, c, cell) {
|
|
178
|
+
const ref = `${colName(c)}${r}`;
|
|
179
|
+
if (typeof cell.value === "number") return `<c r="${ref}" s="${cell.s}"><v>${cell.value}</v></c>`;
|
|
180
|
+
if (cell.value === "" || cell.value === null || cell.value === undefined) return `<c r="${ref}" s="${cell.s}"/>`;
|
|
181
|
+
return `<c r="${ref}" s="${cell.s}" t="s"><v>${this.sst(cell.value, cell.runs)}</v></c>`;
|
|
182
|
+
}
|
|
183
|
+
dataXml() {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const r of [...this.rows.keys()].sort((a, b) => a - b)) {
|
|
186
|
+
const cells = this.rows.get(r);
|
|
187
|
+
const ht = this.heights.get(r);
|
|
188
|
+
const attr = ht ? ` ht="${ht}" customHeight="1"` : "";
|
|
189
|
+
const xml = [...cells.keys()].sort((a, b) => a - b).map((c) => this.cellXml(r, c, cells.get(c))).join("");
|
|
190
|
+
out.push(`<row r="${r}"${attr}>${xml}</row>`);
|
|
191
|
+
}
|
|
192
|
+
return out.join("");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const grid = (gate) => ({ l: gate ? "gate" : "thin", r: "thin", t: "thin", b: "thin" });
|
|
197
|
+
|
|
198
|
+
function drawHead(sh, m, last) {
|
|
199
|
+
sh.put(1, 0, m.title, { font: "title", fill: "1F3864", h: "left" });
|
|
200
|
+
for (let c = 1; c <= last; c++) sh.put(1, c, "", { font: "title", fill: "1F3864" });
|
|
201
|
+
sh.merge(1, 0, 1, last);
|
|
202
|
+
sh.heights.set(1, 28).set(2, 16).set(3, 30);
|
|
203
|
+
m.head.forEach((h, c) => {
|
|
204
|
+
for (const r of [2, 3]) sh.put(r, c, r === 2 ? h : "", { font: "head", fill: "34495E", border: grid(false), h: "center", wrap: true });
|
|
205
|
+
sh.merge(2, c, 3, c);
|
|
206
|
+
});
|
|
207
|
+
const gateCols = new Set(m.gates.map((g) => g.col));
|
|
208
|
+
for (const w of m.weeks) {
|
|
209
|
+
for (let i = w.first; i < w.first + w.count; i++) {
|
|
210
|
+
sh.put(2, 8 + i, i === w.first ? w.label : "", { font: "head", fill: "5D6D7E", border: grid(gateCols.has(i)), h: "center" });
|
|
211
|
+
}
|
|
212
|
+
if (w.count > 1) sh.merge(2, 8 + w.first, 2, 8 + w.first + w.count - 1);
|
|
213
|
+
}
|
|
214
|
+
m.days.forEach((d, i) => {
|
|
215
|
+
sh.put(3, 8 + i, `${d.label}\n${d.dow}`, { font: "day", fill: "EBEDEF", border: grid(gateCols.has(i)), h: "center", wrap: true });
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function dayCell(row, i, gate) {
|
|
220
|
+
const border = grid(gate);
|
|
221
|
+
if (row.diamonds.includes(i)) {
|
|
222
|
+
const b = row.dotted ? { ...border, l: gate ? "gate" : "dotted", r: "dotted", b: "dotted" } : border;
|
|
223
|
+
return ["◆", { font: "mile", fill: row.fill, border: b, h: "center" }];
|
|
224
|
+
}
|
|
225
|
+
const fill = i >= row.from && i <= row.to ? row.bar : row.fill;
|
|
226
|
+
return ["", { font: "body", fill, border }];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function drawRows(sh, m) {
|
|
230
|
+
const gateCols = new Set(m.gates.map((g) => g.col));
|
|
231
|
+
m.rows.forEach((row, n) => {
|
|
232
|
+
const r = 4 + n;
|
|
233
|
+
const font = row.kind === "group" ? "bodyB" : "body";
|
|
234
|
+
row.cells.forEach((v, c) => {
|
|
235
|
+
sh.put(r, c, v, { font, fill: row.fill, border: grid(false), h: ALIGN[c], wrap: c === 1 || c === 4 });
|
|
236
|
+
});
|
|
237
|
+
m.days.forEach((_, i) => {
|
|
238
|
+
const [v, spec] = dayCell(row, i, gateCols.has(i));
|
|
239
|
+
sh.put(r, 8 + i, v, spec);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
return 4 + m.rows.length;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function legendCell(item) {
|
|
246
|
+
if (item.type === "diamond") return ["◆", { font: "mile", h: "center" }];
|
|
247
|
+
if (item.type === "gate") return ["", { font: "note", border: { l: "gate", r: "gate" } }];
|
|
248
|
+
const b = item.type === "dotted" ? { l: "dotted", r: "dotted", t: "dotted", b: "dotted" } : grid(false);
|
|
249
|
+
return ["", { font: "note", fill: item.color, border: b }];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function drawNotes(sh, m, start) {
|
|
253
|
+
let r = start + 1;
|
|
254
|
+
if (m.legend.length) {
|
|
255
|
+
sh.put(r++, 0, "[범례]", { font: "noteB" });
|
|
256
|
+
for (const item of m.legend) {
|
|
257
|
+
const [v, spec] = legendCell(item);
|
|
258
|
+
sh.put(r, 0, v, spec);
|
|
259
|
+
sh.put(r++, 1, item.label, { font: "note" });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const n of m.notes) {
|
|
263
|
+
r++;
|
|
264
|
+
if (n.heading) sh.put(r++, 0, n.heading, { font: "noteB" });
|
|
265
|
+
for (const line of n.lines) sh.put(r++, 0, line, { font: "note" });
|
|
266
|
+
}
|
|
267
|
+
return r - 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function sheetXml(sh, last, lastRow) {
|
|
271
|
+
const cols = WIDTHS.map((w, i) => `<col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")
|
|
272
|
+
+ `<col min="9" max="${last + 1}" width="${DAY_WIDTH}" customWidth="1"/>`;
|
|
273
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="${NS}" xmlns:r="${NSR}">`
|
|
274
|
+
+ `<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr><dimension ref="A1:${colName(last)}${lastRow}"/>`
|
|
275
|
+
+ `<sheetViews><sheetView tabSelected="1" workbookViewId="0"><pane xSplit="8" ySplit="3" topLeftCell="I4" activePane="bottomRight" state="frozen"/>`
|
|
276
|
+
+ `<selection pane="topRight"/><selection pane="bottomLeft"/><selection pane="bottomRight" activeCell="I4" sqref="I4"/></sheetView></sheetViews>`
|
|
277
|
+
+ `<sheetFormatPr defaultRowHeight="16.5"/><cols>${cols}</cols><sheetData>${sh.dataXml()}</sheetData>`
|
|
278
|
+
+ `<mergeCells count="${sh.merges.length}">${sh.merges.map((x) => `<mergeCell ref="${x}"/>`).join("")}</mergeCells>`
|
|
279
|
+
+ `<printOptions horizontalCentered="1"/><pageMargins left="0.4" right="0.4" top="0.5" bottom="0.5" header="0.3" footer="0.3"/>`
|
|
280
|
+
+ `<pageSetup paperSize="8" orientation="landscape" fitToWidth="1" fitToHeight="0"/></worksheet>`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function sstXml(strings, refs) {
|
|
284
|
+
const run = (x) => `<r><rPr>${fontBody(x.font, "rFont")}</rPr><t xml:space="preserve">${esc(x.text)}</t></r>`;
|
|
285
|
+
const si = strings.map((s) => (s.runs ? `<si>${s.runs.map(run).join("")}</si>` : `<si><t xml:space="preserve">${esc(s.text)}</t></si>`)).join("");
|
|
286
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<sst xmlns="${NS}" count="${refs}" uniqueCount="${strings.length}">${si}</sst>`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function packageParts(name) {
|
|
290
|
+
const quoted = `'${name.replace(/'/g, "''")}'`;
|
|
291
|
+
return {
|
|
292
|
+
types: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`
|
|
293
|
+
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>`
|
|
294
|
+
+ `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>`
|
|
295
|
+
+ `<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
|
|
296
|
+
+ `<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>`
|
|
297
|
+
+ `<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>`,
|
|
298
|
+
rels: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`
|
|
299
|
+
+ `<Relationship Id="rId1" Type="${NSR}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
|
|
300
|
+
workbook: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="${NS}" xmlns:r="${NSR}">`
|
|
301
|
+
+ `<bookViews><workbookView/></bookViews><sheets><sheet name="${esc(name)}" sheetId="1" r:id="rId1"/></sheets>`
|
|
302
|
+
+ `<definedNames><definedName name="_xlnm.Print_Titles" localSheetId="0">${esc(quoted)}!$1:$3</definedName></definedNames></workbook>`,
|
|
303
|
+
wbRels: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`
|
|
304
|
+
+ `<Relationship Id="rId1" Type="${NSR}/worksheet" Target="worksheets/sheet1.xml"/>`
|
|
305
|
+
+ `<Relationship Id="rId2" Type="${NSR}/styles" Target="styles.xml"/>`
|
|
306
|
+
+ `<Relationship Id="rId3" Type="${NSR}/sharedStrings" Target="sharedStrings.xml"/></Relationships>`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Returns the XLSX bytes for a layout model. */
|
|
311
|
+
export function toXlsx(m) {
|
|
312
|
+
const sh = new Sheet();
|
|
313
|
+
const last = 7 + m.days.length;
|
|
314
|
+
drawHead(sh, m, last);
|
|
315
|
+
const lastRow = drawNotes(sh, m, drawRows(sh, m));
|
|
316
|
+
const sheet = sheetXml(sh, last, lastRow);
|
|
317
|
+
const name = sheetName(m.sheet);
|
|
318
|
+
const p = packageParts(name);
|
|
319
|
+
return zip([
|
|
320
|
+
{ name: "[Content_Types].xml", data: p.types },
|
|
321
|
+
{ name: "_rels/.rels", data: p.rels },
|
|
322
|
+
{ name: "xl/workbook.xml", data: p.workbook },
|
|
323
|
+
{ name: "xl/_rels/workbook.xml.rels", data: p.wbRels },
|
|
324
|
+
{ name: "xl/worksheets/sheet1.xml", data: sheet },
|
|
325
|
+
{ name: "xl/styles.xml", data: stylesXml(sh.styles) },
|
|
326
|
+
{ name: "xl/sharedStrings.xml", data: sstXml(sh.strings, sh.refs) },
|
|
327
|
+
]);
|
|
328
|
+
}
|