werkmap 0.1.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/LICENSE +201 -0
- package/README.md +216 -0
- package/lib/index.d.ts +131 -0
- package/lib/index.js +1089 -0
- package/package.json +65 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
// werkmap — a write-only OOXML (.xlsx) writer.
|
|
2
|
+
//
|
|
3
|
+
// One entry point, `workbook`, returning a small mutable builder whose only
|
|
4
|
+
// terminal is `bytes()`. Nothing here reads a .xlsx file, and nothing turns a
|
|
5
|
+
// string into code: the package runs under a Content Security Policy that
|
|
6
|
+
// permits no string-to-code execution, and its suite runs Node with
|
|
7
|
+
// `--disallow-code-generation-from-strings`. The source itself is scanned for
|
|
8
|
+
// the two constructs, so the words naming them appear nowhere below.
|
|
9
|
+
//
|
|
10
|
+
// Two promises shape almost every decision below. The same calls produce the
|
|
11
|
+
// same bytes on the same runtime, so nothing consults a clock, a locale or a
|
|
12
|
+
// random source, and every table is written in a fixed order. And every
|
|
13
|
+
// element is emitted in the Open XML SDK's own child sequence, which costs
|
|
14
|
+
// nothing here — the parts are assembled as strings in one pass — and removes
|
|
15
|
+
// the ordering risk in readers whose tolerance nobody has measured.
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- ZIP ----
|
|
18
|
+
|
|
19
|
+
// PKWARE APPNOTE 6.3.4, the subset a .xlsx needs: local headers, a central
|
|
20
|
+
// directory, an end-of-central-directory record. No ZIP64, no data
|
|
21
|
+
// descriptors, no per-entry timestamps.
|
|
22
|
+
const CRC = new Int32Array(256);
|
|
23
|
+
for (let n = 0; n < 256; n++) {
|
|
24
|
+
let c = n;
|
|
25
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
26
|
+
CRC[n] = c;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {Uint8Array} bytes */
|
|
30
|
+
const crc32 = (bytes) => {
|
|
31
|
+
let c = -1;
|
|
32
|
+
for (const b of bytes) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
33
|
+
return (c ^ -1) >>> 0;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** @param {string} text */
|
|
37
|
+
const utf8 = (text) => new TextEncoder().encode(text);
|
|
38
|
+
|
|
39
|
+
/** @param {Uint8Array} bytes */
|
|
40
|
+
const deflate = async (bytes) => {
|
|
41
|
+
const blob = new Blob([/** @type {BlobPart} */ (bytes)]);
|
|
42
|
+
const stream = blob.stream().pipeThrough(new CompressionStream("deflate-raw"));
|
|
43
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// The ZIP epoch, 1980-01-01 00:00. A real timestamp is the one field in the
|
|
47
|
+
// container that would vary per run, so it is pinned rather than read.
|
|
48
|
+
const DOS_TIME = 0;
|
|
49
|
+
const DOS_DATE = (1 << 5) | 1;
|
|
50
|
+
|
|
51
|
+
// Media is stored rather than deflated: a PNG or JPEG is already compressed,
|
|
52
|
+
// so deflating it spends time to grow the file, and method 0 keeps the bytes
|
|
53
|
+
// a caller handed over byte-identical inside the package.
|
|
54
|
+
const STORED = 0;
|
|
55
|
+
const DEFLATED = 8;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {ReadonlyArray<{ name: string, body: Uint8Array, store?: boolean }>} entries
|
|
59
|
+
* @returns {Promise<Uint8Array>}
|
|
60
|
+
*/
|
|
61
|
+
const zip = async (entries) => {
|
|
62
|
+
const local = [];
|
|
63
|
+
const central = [];
|
|
64
|
+
let offset = 0;
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const raw = entry.body;
|
|
68
|
+
const packed = entry.store ? raw : await deflate(raw);
|
|
69
|
+
const method = entry.store ? STORED : DEFLATED;
|
|
70
|
+
const name = utf8(entry.name);
|
|
71
|
+
const sum = crc32(raw);
|
|
72
|
+
|
|
73
|
+
const head = new DataView(new ArrayBuffer(30));
|
|
74
|
+
head.setUint32(0, 0x04034b50, true);
|
|
75
|
+
head.setUint16(4, 20, true);
|
|
76
|
+
head.setUint16(6, 0x0800, true); // the name is UTF-8
|
|
77
|
+
head.setUint16(8, method, true);
|
|
78
|
+
head.setUint16(10, DOS_TIME, true);
|
|
79
|
+
head.setUint16(12, DOS_DATE, true);
|
|
80
|
+
head.setUint32(14, sum, true);
|
|
81
|
+
head.setUint32(18, packed.length, true);
|
|
82
|
+
head.setUint32(22, raw.length, true);
|
|
83
|
+
head.setUint16(26, name.length, true);
|
|
84
|
+
local.push(new Uint8Array(head.buffer), name, packed);
|
|
85
|
+
|
|
86
|
+
const record = new DataView(new ArrayBuffer(46));
|
|
87
|
+
record.setUint32(0, 0x02014b50, true);
|
|
88
|
+
record.setUint16(4, 20, true);
|
|
89
|
+
record.setUint16(6, 20, true);
|
|
90
|
+
record.setUint16(8, 0x0800, true);
|
|
91
|
+
record.setUint16(10, method, true);
|
|
92
|
+
record.setUint16(12, DOS_TIME, true);
|
|
93
|
+
record.setUint16(14, DOS_DATE, true);
|
|
94
|
+
record.setUint32(16, sum, true);
|
|
95
|
+
record.setUint32(20, packed.length, true);
|
|
96
|
+
record.setUint32(24, raw.length, true);
|
|
97
|
+
record.setUint16(28, name.length, true);
|
|
98
|
+
record.setUint32(42, offset, true);
|
|
99
|
+
central.push(new Uint8Array(record.buffer), name);
|
|
100
|
+
|
|
101
|
+
offset += 30 + name.length + packed.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const size = central.reduce((total, chunk) => total + chunk.length, 0);
|
|
105
|
+
const end = new DataView(new ArrayBuffer(22));
|
|
106
|
+
end.setUint32(0, 0x06054b50, true);
|
|
107
|
+
end.setUint16(8, entries.length, true);
|
|
108
|
+
end.setUint16(10, entries.length, true);
|
|
109
|
+
end.setUint32(12, size, true);
|
|
110
|
+
end.setUint32(16, offset, true);
|
|
111
|
+
|
|
112
|
+
const all = [...local, ...central, new Uint8Array(end.buffer)];
|
|
113
|
+
const bytes = new Uint8Array(all.reduce((total, chunk) => total + chunk.length, 0));
|
|
114
|
+
let at = 0;
|
|
115
|
+
for (const chunk of all) {
|
|
116
|
+
bytes.set(chunk, at);
|
|
117
|
+
at += chunk.length;
|
|
118
|
+
}
|
|
119
|
+
return bytes;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------- XML ----
|
|
123
|
+
|
|
124
|
+
const DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|
|
125
|
+
const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
|
|
126
|
+
const NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
127
|
+
const NS_PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
128
|
+
const REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
129
|
+
const CT = "application/vnd.openxmlformats-officedocument";
|
|
130
|
+
|
|
131
|
+
// What XML 1.0 cannot carry at all: the C0 range except TAB, LF and CR, and
|
|
132
|
+
// unpaired surrogates. These are stripped rather than thrown over. The text
|
|
133
|
+
// reaching this writer has already passed its caller's own gates, and failing
|
|
134
|
+
// a whole document over one stray byte in a customer's name is the wrong
|
|
135
|
+
// trade — this is the one place leniency belongs.
|
|
136
|
+
const FORBIDDEN =
|
|
137
|
+
// This expression exists to find exactly the control characters the rule
|
|
138
|
+
// below warns about, which is the one case where it is wrong.
|
|
139
|
+
// oxlint-disable-next-line no-control-regex
|
|
140
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
141
|
+
|
|
142
|
+
// Excel decodes `_xHHHH_` in cell text back into the character it names, so a
|
|
143
|
+
// literal one in author text is escaped at its underscore to survive.
|
|
144
|
+
const ESCAPE_SEQUENCE = /_(x[0-9A-Fa-f]{4}_)/g;
|
|
145
|
+
|
|
146
|
+
/** @param {string} text */
|
|
147
|
+
const clean = (text) => text.replace(FORBIDDEN, "").replace(ESCAPE_SEQUENCE, "_x005F_$1");
|
|
148
|
+
|
|
149
|
+
/** @param {string} text */
|
|
150
|
+
const esc = (text) =>
|
|
151
|
+
clean(text)
|
|
152
|
+
.replace(/&/g, "&")
|
|
153
|
+
.replace(/</g, "<")
|
|
154
|
+
.replace(/>/g, ">")
|
|
155
|
+
.replace(/"/g, """);
|
|
156
|
+
|
|
157
|
+
// `xml:space="preserve"` goes on every `<t>` rather than only where the text
|
|
158
|
+
// needs it: without it a reader is free to trim, and one rule is easier to
|
|
159
|
+
// trust than a predicate over whitespace.
|
|
160
|
+
/** @param {string} text */
|
|
161
|
+
const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One run of rich text, written in full: an OOXML run inherits nothing from
|
|
165
|
+
* the cell font.
|
|
166
|
+
* @param {unknown} run
|
|
167
|
+
* @param {string} at
|
|
168
|
+
*/
|
|
169
|
+
const richRun = (run, at) => {
|
|
170
|
+
if (run === null || typeof run !== "object")
|
|
171
|
+
throw TypeError(`${at}: expected a { text, font } run, got ${JSON.stringify(run)}`);
|
|
172
|
+
const text = /** @type {Record<string, unknown>} */ (run).text;
|
|
173
|
+
if (typeof text !== "string")
|
|
174
|
+
throw TypeError(`${at}.text: expected a string, got ${JSON.stringify(text)}`);
|
|
175
|
+
const font = /** @type {Record<string, unknown>} */ (run).font;
|
|
176
|
+
const properties =
|
|
177
|
+
font === undefined || font === null
|
|
178
|
+
? ""
|
|
179
|
+
: `<rPr>${runFontXml(/** @type {Record<string, unknown>} */ (font), `${at}.font`)}</rPr>`;
|
|
180
|
+
return `<r>${properties}${textNode(text)}</r>`;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One `si` of rich text.
|
|
185
|
+
* @param {ReadonlyArray<unknown>} runs
|
|
186
|
+
* @param {string} where
|
|
187
|
+
*/
|
|
188
|
+
const richXml = (runs, where) =>
|
|
189
|
+
"<si>" + runs.map((run, index) => richRun(run, `${where}[${index}]`)).join("") + "</si>";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The interning key for a run list. Prefixed so a one-run list can never
|
|
193
|
+
* collide with the plain string it would render as.
|
|
194
|
+
* @param {ReadonlyArray<unknown>} runs
|
|
195
|
+
* @param {string} where
|
|
196
|
+
*/
|
|
197
|
+
const richKey = (runs, where) => {
|
|
198
|
+
if (runs.length === 0)
|
|
199
|
+
throw RangeError(`${where}: expected at least one run, got an empty array`);
|
|
200
|
+
return "r" + JSON.stringify(runs);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ------------------------------------------------------------- values ----
|
|
204
|
+
|
|
205
|
+
// Excel's 1900 date system counts from an epoch of 1899-12-30, which absorbs
|
|
206
|
+
// its phantom 1900-02-29. The conversion is in UTC: a reader's own timezone
|
|
207
|
+
// is never consulted, so one `Date` is one serial everywhere.
|
|
208
|
+
//
|
|
209
|
+
// The phantom day is real for serial 60 and nothing else. Excel shows 60 as
|
|
210
|
+
// 1900-02-29, a day that never happened, so every date before 1900-03-01 sits
|
|
211
|
+
// one lower than the 1899-12-30 epoch alone would put it. A writer that skips
|
|
212
|
+
// this arithmetic moves 1900-02-28 onto the phantom day.
|
|
213
|
+
const EPOCH_OFFSET = 25569;
|
|
214
|
+
const DAY = 86_400_000;
|
|
215
|
+
const PHANTOM = 61;
|
|
216
|
+
|
|
217
|
+
/** @param {Date} date */
|
|
218
|
+
const serial = (date) => {
|
|
219
|
+
const days = EPOCH_OFFSET + date.getTime() / DAY;
|
|
220
|
+
return days < PHANTOM ? days - 1 : days;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Excel's built-in "short date". A `Date` written with no format of its own
|
|
224
|
+
// reads back as a bare serial — 45351.5 rather than a day — so the writer
|
|
225
|
+
// supplies this one, and a reader renders it in its own locale.
|
|
226
|
+
const SHORT_DATE = "mm-dd-yy";
|
|
227
|
+
|
|
228
|
+
// JavaScript writes an exponent as `e+21` and Excel writes `E+21`; readers
|
|
229
|
+
// are not uniformly happy with the lowercase form.
|
|
230
|
+
/** @param {number} value */
|
|
231
|
+
const number = (value) => String(value).replace("e", "E");
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A 1-based column index as its letters: 1 is A, 27 is AA, 703 is AAA.
|
|
235
|
+
* @param {number} index
|
|
236
|
+
*/
|
|
237
|
+
const letters = (index) => {
|
|
238
|
+
let out = "";
|
|
239
|
+
let rest = index;
|
|
240
|
+
while (rest > 0) {
|
|
241
|
+
const remainder = (rest - 1) % 26;
|
|
242
|
+
out = String.fromCharCode(65 + remainder) + out;
|
|
243
|
+
rest = (rest - remainder - 1) / 26;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const COLOUR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `#rrggbb` (or `#rgb`) as the `AARRGGBB` OOXML spells. There is no alpha in
|
|
252
|
+
* this surface: a colour either paints or is absent.
|
|
253
|
+
* @param {unknown} value
|
|
254
|
+
* @param {string} where
|
|
255
|
+
*/
|
|
256
|
+
const rgb = (value, where) => {
|
|
257
|
+
if (typeof value !== "string" || !COLOUR.test(value))
|
|
258
|
+
throw TypeError(`${where}: expected a #rrggbb colour, got ${JSON.stringify(value)}`);
|
|
259
|
+
const body = value.slice(1);
|
|
260
|
+
const full = body.length === 3 ? body[0] + body[0] + body[1] + body[1] + body[2] + body[2] : body;
|
|
261
|
+
return "FF" + full.toUpperCase();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// The number formats every reader knows by id. A caller naming one of these
|
|
265
|
+
// codes gets the id; anything else is interned from 164 up.
|
|
266
|
+
const BUILT_IN = new Map([
|
|
267
|
+
["General", 0],
|
|
268
|
+
["0", 1],
|
|
269
|
+
["0.00", 2],
|
|
270
|
+
["#,##0", 3],
|
|
271
|
+
["#,##0.00", 4],
|
|
272
|
+
["0%", 9],
|
|
273
|
+
["0.00%", 10],
|
|
274
|
+
["0.00E+00", 11],
|
|
275
|
+
["# ?/?", 12],
|
|
276
|
+
["# ??/??", 13],
|
|
277
|
+
["mm-dd-yy", 14],
|
|
278
|
+
["d-mmm-yy", 15],
|
|
279
|
+
["d-mmm", 16],
|
|
280
|
+
["mmm-yy", 17],
|
|
281
|
+
["h:mm AM/PM", 18],
|
|
282
|
+
["h:mm:ss AM/PM", 19],
|
|
283
|
+
["h:mm", 20],
|
|
284
|
+
["h:mm:ss", 21],
|
|
285
|
+
["m/d/yy h:mm", 22],
|
|
286
|
+
["mm:ss", 45],
|
|
287
|
+
["[h]:mm:ss", 46],
|
|
288
|
+
["mmss.0", 47],
|
|
289
|
+
["##0.0E+0", 48],
|
|
290
|
+
["@", 49],
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
const FIRST_CUSTOM = 164;
|
|
294
|
+
|
|
295
|
+
// ------------------------------------------------------------- tables ----
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Interning by a canonical key, preserving first-seen order. Every style
|
|
299
|
+
* table in the file is one of these, which is what makes identical fonts,
|
|
300
|
+
* fills, borders and formats collapse to one entry each.
|
|
301
|
+
*/
|
|
302
|
+
/**
|
|
303
|
+
* @template T
|
|
304
|
+
* @param {Array<[string, T]>} initial
|
|
305
|
+
*/
|
|
306
|
+
const table = (initial) => {
|
|
307
|
+
/** @type {Map<string, number>} */
|
|
308
|
+
const keys = new Map();
|
|
309
|
+
/** @type {T[]} */
|
|
310
|
+
const items = [];
|
|
311
|
+
for (const [key, item] of initial) {
|
|
312
|
+
keys.set(key, items.length);
|
|
313
|
+
items.push(item);
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
items,
|
|
317
|
+
/**
|
|
318
|
+
* Arrow rather than a method, so `strings` below can pass it on by name:
|
|
319
|
+
* it closes over the table and never touches `this`.
|
|
320
|
+
* @param {string} key
|
|
321
|
+
* @param {() => T} make
|
|
322
|
+
*/
|
|
323
|
+
intern: (key, make) => {
|
|
324
|
+
const seen = keys.get(key);
|
|
325
|
+
if (seen !== undefined) return seen;
|
|
326
|
+
const at = items.length;
|
|
327
|
+
keys.set(key, at);
|
|
328
|
+
items.push(make());
|
|
329
|
+
return at;
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// ------------------------------------------------------------- styles ----
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The font half of a style, written in the SDK's `CT_Font` sequence:
|
|
338
|
+
* b i strike u sz color name.
|
|
339
|
+
* @param {Record<string, unknown>} font
|
|
340
|
+
* @param {string} where
|
|
341
|
+
*/
|
|
342
|
+
const fontXml = (font, where) => {
|
|
343
|
+
let out = "";
|
|
344
|
+
if (font.bold) out += "<b/>";
|
|
345
|
+
if (font.italic) out += "<i/>";
|
|
346
|
+
if (font.strikethrough) out += "<strike/>";
|
|
347
|
+
if (font.underline) out += "<u/>";
|
|
348
|
+
out += `<sz val="${font.size === undefined ? 11 : requireNumber(font.size, `${where}.size`)}"/>`;
|
|
349
|
+
if (font.color !== undefined) out += `<color rgb="${rgb(font.color, `${where}.color`)}"/>`;
|
|
350
|
+
return out + `<name val="${esc(requireText(font.name, `${where}.name`, "Calibri"))}"/>`;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A run's font, identical to a cell font but for the tag names OOXML uses
|
|
355
|
+
* inside `rPr`, where `name` is spelled `rFont`.
|
|
356
|
+
* @param {Record<string, unknown>} font
|
|
357
|
+
* @param {string} where
|
|
358
|
+
*/
|
|
359
|
+
const runFontXml = (font, where) => fontXml(font, where).replace(/<name val=/, "<rFont val=");
|
|
360
|
+
|
|
361
|
+
const BORDER_STYLES = new Set(["thin", "dashed", "dotted"]);
|
|
362
|
+
const SIDES = ["left", "right", "top", "bottom"];
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @param {Record<string, unknown>} border
|
|
366
|
+
* @param {string} where
|
|
367
|
+
*/
|
|
368
|
+
const borderXml = (border, where) => {
|
|
369
|
+
let out = "";
|
|
370
|
+
for (const side of SIDES) {
|
|
371
|
+
const edge = border[side];
|
|
372
|
+
if (edge === undefined || edge === null) {
|
|
373
|
+
out += `<${side}/>`;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const at = `${where}.${side}`;
|
|
377
|
+
const style = /** @type {Record<string, unknown>} */ (edge).style;
|
|
378
|
+
if (typeof style !== "string" || !BORDER_STYLES.has(style))
|
|
379
|
+
throw RangeError(
|
|
380
|
+
`${at}.style: expected thin, dashed or dotted, got ${JSON.stringify(style)}`,
|
|
381
|
+
);
|
|
382
|
+
const colour = /** @type {Record<string, unknown>} */ (edge).color;
|
|
383
|
+
out +=
|
|
384
|
+
colour === undefined
|
|
385
|
+
? `<${side} style="${style}"/>`
|
|
386
|
+
: `<${side} style="${style}"><color rgb="${rgb(colour, `${at}.color`)}"/></${side}>`;
|
|
387
|
+
}
|
|
388
|
+
return out + "<diagonal/>";
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const HORIZONTAL = new Set(["left", "center", "right", "justify", "fill"]);
|
|
392
|
+
const VERTICAL = new Set(["top", "center", "bottom", "justify"]);
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {Record<string, unknown>} alignment
|
|
396
|
+
* @param {string} where
|
|
397
|
+
*/
|
|
398
|
+
const alignmentXml = (alignment, where) => {
|
|
399
|
+
let out = "";
|
|
400
|
+
if (alignment.horizontal !== undefined) {
|
|
401
|
+
const horizontal = /** @type {string} */ (alignment.horizontal);
|
|
402
|
+
if (!HORIZONTAL.has(horizontal))
|
|
403
|
+
throw RangeError(`${where}.horizontal: unknown alignment ${JSON.stringify(horizontal)}`);
|
|
404
|
+
out += ` horizontal="${horizontal}"`;
|
|
405
|
+
}
|
|
406
|
+
if (alignment.vertical !== undefined) {
|
|
407
|
+
const vertical = /** @type {string} */ (alignment.vertical);
|
|
408
|
+
if (!VERTICAL.has(vertical))
|
|
409
|
+
throw RangeError(`${where}.vertical: unknown alignment ${JSON.stringify(vertical)}`);
|
|
410
|
+
out += ` vertical="${vertical}"`;
|
|
411
|
+
}
|
|
412
|
+
if (alignment.wrapText) out += ' wrapText="1"';
|
|
413
|
+
return out;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* @param {unknown} value
|
|
418
|
+
* @param {string} where
|
|
419
|
+
* @param {string} fallback
|
|
420
|
+
*/
|
|
421
|
+
const requireText = (value, where, fallback) => {
|
|
422
|
+
if (value === undefined) return fallback;
|
|
423
|
+
if (typeof value !== "string")
|
|
424
|
+
throw TypeError(`${where}: expected a string, got ${JSON.stringify(value)}`);
|
|
425
|
+
return value;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* @param {unknown} value
|
|
430
|
+
* @param {string} where
|
|
431
|
+
*/
|
|
432
|
+
const requireNumber = (value, where) => {
|
|
433
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
434
|
+
throw RangeError(`${where}: expected a finite number, got ${JSON.stringify(value)}`);
|
|
435
|
+
return value;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* @typedef {{ numFmtId: number, fontId: number, fillId: number, borderId: number, alignment: string }} Xf
|
|
440
|
+
*/
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* One `xf`. Each `apply*` flag says which table this format actually reaches
|
|
444
|
+
* into; a reader ignores a table entry the flag does not claim.
|
|
445
|
+
* @param {Xf} xf
|
|
446
|
+
*/
|
|
447
|
+
const xfXml = (xf) => {
|
|
448
|
+
let out = `<xf numFmtId="${xf.numFmtId}" fontId="${xf.fontId}" fillId="${xf.fillId}" borderId="${xf.borderId}" xfId="0"`;
|
|
449
|
+
if (xf.numFmtId !== 0) out += ' applyNumberFormat="1"';
|
|
450
|
+
if (xf.fontId !== 0) out += ' applyFont="1"';
|
|
451
|
+
if (xf.fillId !== 0) out += ' applyFill="1"';
|
|
452
|
+
if (xf.borderId !== 0) out += ' applyBorder="1"';
|
|
453
|
+
if (xf.alignment !== "") out += ' applyAlignment="1"';
|
|
454
|
+
return xf.alignment === "" ? out + "/>" : `${out}><alignment${xf.alignment}/></xf>`;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The whole of `styles.xml`, and the interning behind it. Entry 0 of each
|
|
459
|
+
* table is the default every unstyled cell points at; the two fills OOXML
|
|
460
|
+
* reserves (`none`, `gray125`) are written whether or not anything uses them,
|
|
461
|
+
* because readers index fills by position.
|
|
462
|
+
*/
|
|
463
|
+
const stylesheet = () => {
|
|
464
|
+
const numFmts = new Map();
|
|
465
|
+
const fonts = table([["", `<font><sz val="11"/><name val="Calibri"/></font>`]]);
|
|
466
|
+
const fills = table([
|
|
467
|
+
["none", `<fill><patternFill patternType="none"/></fill>`],
|
|
468
|
+
["gray125", `<fill><patternFill patternType="gray125"/></fill>`],
|
|
469
|
+
]);
|
|
470
|
+
const borders = table([["", `<border><left/><right/><top/><bottom/><diagonal/></border>`]]);
|
|
471
|
+
const formats = table([["", { numFmtId: 0, fontId: 0, fillId: 0, borderId: 0, alignment: "" }]]);
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* A style part that is absent means entry 0 of its table — the default every
|
|
475
|
+
* unstyled cell already points at.
|
|
476
|
+
*
|
|
477
|
+
* @template T
|
|
478
|
+
* @param {unknown} part
|
|
479
|
+
* @param {{ intern(key: string, make: () => T): number }} into
|
|
480
|
+
* @param {(value: Record<string, unknown>) => [string, () => T]} describe
|
|
481
|
+
*/
|
|
482
|
+
const optional = (part, into, describe) => {
|
|
483
|
+
if (part === undefined || part === null) return 0;
|
|
484
|
+
const [key, make] = describe(/** @type {Record<string, unknown>} */ (part));
|
|
485
|
+
return into.intern(key, make);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* A format code a reader knows by id keeps that id; anything else is
|
|
490
|
+
* interned from 164 up, in first-seen order.
|
|
491
|
+
* @param {unknown} code
|
|
492
|
+
* @param {string} where
|
|
493
|
+
*/
|
|
494
|
+
const formatId = (code, where) => {
|
|
495
|
+
if (code === undefined || code === null) return 0;
|
|
496
|
+
if (typeof code !== "string")
|
|
497
|
+
throw TypeError(`${where}: expected a format code, got ${JSON.stringify(code)}`);
|
|
498
|
+
const builtIn = BUILT_IN.get(code);
|
|
499
|
+
if (builtIn !== undefined) return builtIn;
|
|
500
|
+
const seen = numFmts.get(code);
|
|
501
|
+
if (seen !== undefined) return seen;
|
|
502
|
+
const id = FIRST_CUSTOM + numFmts.size;
|
|
503
|
+
numFmts.set(code, id);
|
|
504
|
+
return id;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
/**
|
|
509
|
+
* @param {Record<string, unknown> | null | undefined} style
|
|
510
|
+
* @param {string} where
|
|
511
|
+
* @returns {number}
|
|
512
|
+
*/
|
|
513
|
+
intern(style, where) {
|
|
514
|
+
if (style === undefined || style === null) return 0;
|
|
515
|
+
if (typeof style !== "object")
|
|
516
|
+
throw TypeError(`${where}: expected a style object, got ${JSON.stringify(style)}`);
|
|
517
|
+
|
|
518
|
+
const record = {
|
|
519
|
+
numFmtId: formatId(style.numberFormat, `${where}.numberFormat`),
|
|
520
|
+
fontId: optional(style.font, fonts, (font) => [
|
|
521
|
+
JSON.stringify(font),
|
|
522
|
+
() => `<font>${fontXml(font, `${where}.font`)}</font>`,
|
|
523
|
+
]),
|
|
524
|
+
fillId: optional(style.fill, fills, () => {
|
|
525
|
+
const colour = rgb(style.fill, `${where}.fill`);
|
|
526
|
+
return [
|
|
527
|
+
colour,
|
|
528
|
+
() =>
|
|
529
|
+
`<fill><patternFill patternType="solid"><fgColor rgb="${colour}"/><bgColor indexed="64"/></patternFill></fill>`,
|
|
530
|
+
];
|
|
531
|
+
}),
|
|
532
|
+
borderId: optional(style.border, borders, (border) => [
|
|
533
|
+
JSON.stringify(border),
|
|
534
|
+
() => `<border>${borderXml(border, `${where}.border`)}</border>`,
|
|
535
|
+
]),
|
|
536
|
+
alignment:
|
|
537
|
+
style.alignment === undefined || style.alignment === null
|
|
538
|
+
? ""
|
|
539
|
+
: alignmentXml(
|
|
540
|
+
/** @type {Record<string, unknown>} */ (style.alignment),
|
|
541
|
+
`${where}.alignment`,
|
|
542
|
+
),
|
|
543
|
+
};
|
|
544
|
+
return formats.intern(JSON.stringify(record), () => record);
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
xml() {
|
|
548
|
+
const custom = [...numFmts]
|
|
549
|
+
.map(([code, id]) => `<numFmt numFmtId="${id}" formatCode="${esc(code)}"/>`)
|
|
550
|
+
.join("");
|
|
551
|
+
const cellXfs = formats.items.map(xfXml).join("");
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
DECLARATION +
|
|
555
|
+
`<styleSheet xmlns="${NS}">` +
|
|
556
|
+
(custom === "" ? "" : `<numFmts count="${numFmts.size}">${custom}</numFmts>`) +
|
|
557
|
+
`<fonts count="${fonts.items.length}">${fonts.items.join("")}</fonts>` +
|
|
558
|
+
`<fills count="${fills.items.length}">${fills.items.join("")}</fills>` +
|
|
559
|
+
`<borders count="${borders.items.length}">${borders.items.join("")}</borders>` +
|
|
560
|
+
`<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>` +
|
|
561
|
+
`<cellXfs count="${formats.items.length}">${cellXfs}</cellXfs>` +
|
|
562
|
+
`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` +
|
|
563
|
+
`</styleSheet>`
|
|
564
|
+
);
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// ------------------------------------------------------ shared strings ----
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Text is always interned here rather than written inline. It is what Excel
|
|
573
|
+
* itself writes, and a report's repeated labels are what it shrinks.
|
|
574
|
+
*/
|
|
575
|
+
const strings = () => {
|
|
576
|
+
// The same interning the style tables use: one `si` per distinct piece of
|
|
577
|
+
// text, in first-seen order.
|
|
578
|
+
const entries = table(/** @type {Array<[string, string]>} */ ([]));
|
|
579
|
+
return {
|
|
580
|
+
intern: entries.intern,
|
|
581
|
+
xml() {
|
|
582
|
+
const count = entries.items.length;
|
|
583
|
+
return (
|
|
584
|
+
DECLARATION +
|
|
585
|
+
`<sst xmlns="${NS}" count="${count}" uniqueCount="${count}">` +
|
|
586
|
+
entries.items.join("") +
|
|
587
|
+
`</sst>`
|
|
588
|
+
);
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// -------------------------------------------------------------- sheet ----
|
|
594
|
+
|
|
595
|
+
const FORBIDDEN_IN_NAME = /[:\\/?*[\]]/;
|
|
596
|
+
const NAME_LIMIT = 31;
|
|
597
|
+
|
|
598
|
+
// A sheet is at most 1,048,576 rows by 16,384 columns. The writer refuses
|
|
599
|
+
// past that rather than emitting a file a reader silently truncates.
|
|
600
|
+
const MAX_ROW = 1_048_576;
|
|
601
|
+
const MAX_COLUMN = 16_384;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @param {unknown} value
|
|
605
|
+
* @param {number} limit
|
|
606
|
+
* @param {string} where
|
|
607
|
+
*/
|
|
608
|
+
const requireIndex = (value, limit, where) => {
|
|
609
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > limit)
|
|
610
|
+
throw RangeError(
|
|
611
|
+
`${where}: expected an integer between 1 and ${limit}, got ${JSON.stringify(value)}`,
|
|
612
|
+
);
|
|
613
|
+
return value;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// EMU per CSS pixel at 96 dpi.
|
|
617
|
+
const EMU = 9525;
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* One worksheet. Rows accumulate in call order; everything else is bookkeeping
|
|
621
|
+
* the sheet part needs at the end.
|
|
622
|
+
*
|
|
623
|
+
* @param {string} name
|
|
624
|
+
* @param {ReturnType<typeof stylesheet>} styles
|
|
625
|
+
* @param {ReturnType<typeof strings>} sst
|
|
626
|
+
* @param {(id: unknown) => boolean} knows
|
|
627
|
+
*/
|
|
628
|
+
const worksheet = (name, styles, sst, knows) => {
|
|
629
|
+
/** @type {string[]} */
|
|
630
|
+
const rows = [];
|
|
631
|
+
/** @type {Array<{ top: number, left: number, bottom: number, right: number }>} */
|
|
632
|
+
const merges = [];
|
|
633
|
+
/** @type {Array<{ id: number, row: number, col: number, width: number, height: number }>} */
|
|
634
|
+
const pictures = [];
|
|
635
|
+
let frozen = 0;
|
|
636
|
+
let widest = 1;
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* @param {unknown} value
|
|
640
|
+
* @param {number} styleId
|
|
641
|
+
* @param {string} ref
|
|
642
|
+
* @param {string} where
|
|
643
|
+
*/
|
|
644
|
+
const cellXml = (value, styleId, ref, where) => {
|
|
645
|
+
const s = styleId === 0 ? "" : ` s="${styleId}"`;
|
|
646
|
+
|
|
647
|
+
if (value === undefined || value === null) return s === "" ? "" : `<c r="${ref}"${s}/>`;
|
|
648
|
+
|
|
649
|
+
if (typeof value === "number") {
|
|
650
|
+
requireNumber(value, `${where}.value`);
|
|
651
|
+
return `<c r="${ref}"${s}><v>${number(value)}</v></c>`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (typeof value === "boolean") return `<c r="${ref}"${s} t="b"><v>${value ? 1 : 0}</v></c>`;
|
|
655
|
+
|
|
656
|
+
if (value instanceof Date) {
|
|
657
|
+
if (Number.isNaN(value.getTime()))
|
|
658
|
+
throw RangeError(`${where}.value: expected a valid Date, got an invalid one`);
|
|
659
|
+
return `<c r="${ref}"${s}><v>${number(serial(value))}</v></c>`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (typeof value === "string") {
|
|
663
|
+
const at = sst.intern("s" + value, () => `<si>${textNode(value)}</si>`);
|
|
664
|
+
return `<c r="${ref}"${s} t="s"><v>${at}</v></c>`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (Array.isArray(value)) {
|
|
668
|
+
const at = sst.intern(richKey(value, `${where}.value`), () =>
|
|
669
|
+
richXml(value, `${where}.value`),
|
|
670
|
+
);
|
|
671
|
+
return `<c r="${ref}"${s} t="s"><v>${at}</v></c>`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
throw TypeError(`${where}.value: unsupported cell value ${JSON.stringify(value)}`);
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
name,
|
|
679
|
+
|
|
680
|
+
/** @param {ReadonlyArray<unknown>} cells */
|
|
681
|
+
row(cells) {
|
|
682
|
+
if (!Array.isArray(cells))
|
|
683
|
+
throw TypeError(`row: expected an array of cells, got ${JSON.stringify(cells)}`);
|
|
684
|
+
const at = rows.length + 1;
|
|
685
|
+
if (cells.length > MAX_COLUMN)
|
|
686
|
+
throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
|
|
687
|
+
|
|
688
|
+
let body = "";
|
|
689
|
+
for (let index = 0; index < cells.length; index++) {
|
|
690
|
+
const cell = cells[index];
|
|
691
|
+
if (cell === undefined || cell === null) continue;
|
|
692
|
+
if (typeof cell !== "object")
|
|
693
|
+
throw TypeError(`row: cell ${index + 1} is not a { value, style } object`);
|
|
694
|
+
const where = `row ${at}, cell ${index + 1}`;
|
|
695
|
+
const declared = /** @type {Record<string, unknown> | null | undefined} */ (
|
|
696
|
+
/** @type {Record<string, unknown>} */ (cell).style
|
|
697
|
+
);
|
|
698
|
+
// A date with no format of its own gets the short-date built-in, so a
|
|
699
|
+
// `Date` reads back as a day rather than as the number underneath it.
|
|
700
|
+
// A style that is not an object passes through untouched, so the
|
|
701
|
+
// interning below is still the one place that refuses it.
|
|
702
|
+
const usable = declared === null || declared === undefined || typeof declared === "object";
|
|
703
|
+
const style =
|
|
704
|
+
/** @type {Record<string, unknown>} */ (cell).value instanceof Date &&
|
|
705
|
+
usable &&
|
|
706
|
+
(declared?.numberFormat ?? null) === null
|
|
707
|
+
? { ...declared, numberFormat: SHORT_DATE }
|
|
708
|
+
: /** @type {Record<string, unknown> | null | undefined} */ (declared);
|
|
709
|
+
const styleId = styles.intern(style, `${where}.style`);
|
|
710
|
+
body += cellXml(
|
|
711
|
+
/** @type {Record<string, unknown>} */ (cell).value,
|
|
712
|
+
styleId,
|
|
713
|
+
letters(index + 1) + at,
|
|
714
|
+
where,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (cells.length > widest) widest = cells.length;
|
|
718
|
+
rows.push(`<row r="${at}">${body}</row>`);
|
|
719
|
+
return at;
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* @param {number} row
|
|
724
|
+
* @param {number} at
|
|
725
|
+
* @param {number} width
|
|
726
|
+
*/
|
|
727
|
+
merge(row, at, width) {
|
|
728
|
+
requireIndex(row, MAX_ROW, "merge: row");
|
|
729
|
+
requireIndex(at, MAX_COLUMN, "merge: at");
|
|
730
|
+
if (row > rows.length)
|
|
731
|
+
throw RangeError(`merge: row ${row} does not exist yet (the sheet has ${rows.length})`);
|
|
732
|
+
if (typeof width !== "number" || !Number.isInteger(width) || width < 2)
|
|
733
|
+
throw RangeError(`merge: expected a width of 2 or more, got ${JSON.stringify(width)}`);
|
|
734
|
+
const right = at + width - 1;
|
|
735
|
+
if (right > MAX_COLUMN) throw RangeError(`merge: the range ends past column ${MAX_COLUMN}`);
|
|
736
|
+
for (const other of merges)
|
|
737
|
+
if (row >= other.top && row <= other.bottom && at <= other.right && right >= other.left)
|
|
738
|
+
throw RangeError(
|
|
739
|
+
`merge: the range ${letters(at)}${row}:${letters(right)}${row} overlaps ` +
|
|
740
|
+
`${letters(other.left)}${other.top}:${letters(other.right)}${other.bottom}`,
|
|
741
|
+
);
|
|
742
|
+
merges.push({ top: row, left: at, bottom: row, right });
|
|
743
|
+
if (right > widest) widest = right;
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
/** @param {number} count */
|
|
747
|
+
freeze(count) {
|
|
748
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count >= MAX_ROW)
|
|
749
|
+
throw RangeError(`freeze: expected a row count of 0 or more, got ${JSON.stringify(count)}`);
|
|
750
|
+
frozen = count;
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* @param {unknown} id
|
|
755
|
+
* @param {{ row: number, col?: number, width: number, height: number }} at
|
|
756
|
+
*/
|
|
757
|
+
place(id, at) {
|
|
758
|
+
if (!knows(id)) throw TypeError(`place: no image with id ${JSON.stringify(id)}`);
|
|
759
|
+
if (at === null || typeof at !== "object")
|
|
760
|
+
throw TypeError(`place: expected { row, col, width, height }, got ${JSON.stringify(at)}`);
|
|
761
|
+
const row = requireIndex(at.row, MAX_ROW, "place: row");
|
|
762
|
+
const col = requireIndex(at.col === undefined ? 1 : at.col, MAX_COLUMN, "place: col");
|
|
763
|
+
const width = requireNumber(at.width, "place: width");
|
|
764
|
+
const height = requireNumber(at.height, "place: height");
|
|
765
|
+
if (width <= 0 || height <= 0)
|
|
766
|
+
throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
|
|
767
|
+
pictures.push({ id: /** @type {number} */ (id), row, col, width, height });
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
get pictures() {
|
|
771
|
+
return pictures;
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
/** @param {number | null} drawing the relationship id of this sheet's drawing part */
|
|
775
|
+
xml(drawing) {
|
|
776
|
+
const dimension = `A1:${letters(widest)}${Math.max(rows.length, 1)}`;
|
|
777
|
+
const pane =
|
|
778
|
+
frozen === 0
|
|
779
|
+
? '<sheetView workbookViewId="0"/>'
|
|
780
|
+
: `<sheetView workbookViewId="0"><pane ySplit="${frozen}" topLeftCell="A${frozen + 1}" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft" activeCell="A${frozen + 1}" sqref="A${frozen + 1}"/></sheetView>`;
|
|
781
|
+
const merged =
|
|
782
|
+
merges.length === 0
|
|
783
|
+
? ""
|
|
784
|
+
: `<mergeCells count="${merges.length}">` +
|
|
785
|
+
merges
|
|
786
|
+
.map(
|
|
787
|
+
(range) =>
|
|
788
|
+
`<mergeCell ref="${letters(range.left)}${range.top}:${letters(range.right)}${range.bottom}"/>`,
|
|
789
|
+
)
|
|
790
|
+
.join("") +
|
|
791
|
+
`</mergeCells>`;
|
|
792
|
+
|
|
793
|
+
return (
|
|
794
|
+
DECLARATION +
|
|
795
|
+
`<worksheet xmlns="${NS}" xmlns:r="${NS_R}">` +
|
|
796
|
+
`<dimension ref="${dimension}"/>` +
|
|
797
|
+
`<sheetViews>${pane}</sheetViews>` +
|
|
798
|
+
`<sheetData>${rows.join("")}</sheetData>` +
|
|
799
|
+
merged +
|
|
800
|
+
(drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
|
|
801
|
+
`</worksheet>`
|
|
802
|
+
);
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The drawing part for one sheet: a `oneCellAnchor` per placement, anchored to
|
|
809
|
+
* the top-left of its cell at the size the caller asked for. The XML counts
|
|
810
|
+
* rows and columns from zero and the subtraction happens here, so the surface
|
|
811
|
+
* stays 1-based throughout.
|
|
812
|
+
*
|
|
813
|
+
* @param {ReturnType<typeof worksheet>["pictures"]} pictures
|
|
814
|
+
* @param {ReadonlyArray<number>} media the media index each picture points at
|
|
815
|
+
*/
|
|
816
|
+
const drawingXml = (pictures, media) =>
|
|
817
|
+
DECLARATION +
|
|
818
|
+
`<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">` +
|
|
819
|
+
pictures
|
|
820
|
+
.map((picture, index) => {
|
|
821
|
+
const cx = Math.round(picture.width * EMU);
|
|
822
|
+
const cy = Math.round(picture.height * EMU);
|
|
823
|
+
return (
|
|
824
|
+
`<xdr:oneCellAnchor editAs="oneCell">` +
|
|
825
|
+
`<xdr:from><xdr:col>${picture.col - 1}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>${picture.row - 1}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>` +
|
|
826
|
+
`<xdr:ext cx="${cx}" cy="${cy}"/>` +
|
|
827
|
+
`<xdr:pic>` +
|
|
828
|
+
`<xdr:nvPicPr><xdr:cNvPr id="${index + 2}" name="Picture ${index + 1}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr>` +
|
|
829
|
+
`<xdr:blipFill><a:blip xmlns:r="${NS_R}" r:embed="rId${media[index] + 1}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill>` +
|
|
830
|
+
`<xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr>` +
|
|
831
|
+
`</xdr:pic>` +
|
|
832
|
+
`<xdr:clientData/>` +
|
|
833
|
+
`</xdr:oneCellAnchor>`
|
|
834
|
+
);
|
|
835
|
+
})
|
|
836
|
+
.join("") +
|
|
837
|
+
`</xdr:wsDr>`;
|
|
838
|
+
|
|
839
|
+
// ----------------------------------------------------------- workbook ----
|
|
840
|
+
|
|
841
|
+
const FORMATS = new Map([
|
|
842
|
+
["png", "image/png"],
|
|
843
|
+
["jpeg", "image/jpeg"],
|
|
844
|
+
]);
|
|
845
|
+
|
|
846
|
+
// Pinned rather than stamped. Byte-identity for the same input is this
|
|
847
|
+
// writer's headline promise, and an overridable clock would quietly redefine
|
|
848
|
+
// "the same input" as "the same input at the same wall time".
|
|
849
|
+
const CREATED = "1970-01-01T00:00:00Z";
|
|
850
|
+
|
|
851
|
+
/** @param {Uint8Array} a @param {Uint8Array} b */
|
|
852
|
+
const same = (a, b) => {
|
|
853
|
+
if (a.length !== b.length) return false;
|
|
854
|
+
for (let at = 0; at < a.length; at++) if (a[at] !== b[at]) return false;
|
|
855
|
+
return true;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Open a workbook.
|
|
860
|
+
*
|
|
861
|
+
* @param {{ title?: string, creator?: string, subject?: string, description?: string }} [meta]
|
|
862
|
+
*/
|
|
863
|
+
export const workbook = (meta = {}) => {
|
|
864
|
+
if (meta === null || typeof meta !== "object")
|
|
865
|
+
throw TypeError(`workbook: expected a metadata object, got ${JSON.stringify(meta)}`);
|
|
866
|
+
|
|
867
|
+
const styles = stylesheet();
|
|
868
|
+
const sst = strings();
|
|
869
|
+
/** @type {Array<{ bytes: Uint8Array, format: string }>} */
|
|
870
|
+
const media = [];
|
|
871
|
+
/** @type {Array<ReturnType<typeof worksheet>>} */
|
|
872
|
+
const sheets = [];
|
|
873
|
+
|
|
874
|
+
/** @param {"title" | "creator" | "subject" | "description"} key */
|
|
875
|
+
const property = (key) => {
|
|
876
|
+
const value = meta[key];
|
|
877
|
+
if (value === undefined) return undefined;
|
|
878
|
+
if (typeof value !== "string")
|
|
879
|
+
throw TypeError(`workbook: ${key} must be a string, got ${JSON.stringify(value)}`);
|
|
880
|
+
return value;
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
const title = property("title");
|
|
884
|
+
const creator = property("creator");
|
|
885
|
+
const subject = property("subject");
|
|
886
|
+
const description = property("description");
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
/**
|
|
890
|
+
* @param {Uint8Array} bytes
|
|
891
|
+
* @param {"png" | "jpeg"} format
|
|
892
|
+
*/
|
|
893
|
+
image(bytes, format) {
|
|
894
|
+
if (!(bytes instanceof Uint8Array))
|
|
895
|
+
throw TypeError(`image: expected a Uint8Array, got ${JSON.stringify(bytes)}`);
|
|
896
|
+
if (!FORMATS.has(format))
|
|
897
|
+
throw TypeError(`image: expected png or jpeg, got ${JSON.stringify(format)}`);
|
|
898
|
+
// Identical bytes deduplicate: a caller embedding the same logo on every
|
|
899
|
+
// sheet pays for it once, and does so without keeping a tally of its own.
|
|
900
|
+
for (let at = 0; at < media.length; at++)
|
|
901
|
+
if (media[at].format === format && same(media[at].bytes, bytes)) return at;
|
|
902
|
+
media.push({ bytes, format });
|
|
903
|
+
return media.length - 1;
|
|
904
|
+
},
|
|
905
|
+
|
|
906
|
+
/** @param {string} name */
|
|
907
|
+
sheet(name) {
|
|
908
|
+
if (typeof name !== "string" || name === "")
|
|
909
|
+
throw RangeError(`sheet: expected a name, got ${JSON.stringify(name)}`);
|
|
910
|
+
if (name.length > NAME_LIMIT)
|
|
911
|
+
throw RangeError(`sheet: a name is at most ${NAME_LIMIT} characters, got ${name.length}`);
|
|
912
|
+
if (FORBIDDEN_IN_NAME.test(name))
|
|
913
|
+
throw RangeError(
|
|
914
|
+
`sheet: a name cannot contain : \\ / ? * [ ], got ${JSON.stringify(name)}`,
|
|
915
|
+
);
|
|
916
|
+
if (sheets.some((each) => each.name === name))
|
|
917
|
+
throw RangeError(`sheet: a sheet named ${JSON.stringify(name)} already exists`);
|
|
918
|
+
const made = worksheet(
|
|
919
|
+
name,
|
|
920
|
+
styles,
|
|
921
|
+
sst,
|
|
922
|
+
(id) => typeof id === "number" && Number.isInteger(id) && id >= 0 && id < media.length,
|
|
923
|
+
);
|
|
924
|
+
sheets.push(made);
|
|
925
|
+
return made;
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
async bytes() {
|
|
929
|
+
if (sheets.length === 0) throw RangeError("bytes: a workbook needs at least one sheet");
|
|
930
|
+
|
|
931
|
+
/** @type {Array<{ name: string, body: Uint8Array, store?: boolean }>} */
|
|
932
|
+
const entries = [];
|
|
933
|
+
/**
|
|
934
|
+
* @param {string} name
|
|
935
|
+
* @param {string | Uint8Array} body
|
|
936
|
+
* @param {boolean} [store]
|
|
937
|
+
*/
|
|
938
|
+
const part = (name, body, store) =>
|
|
939
|
+
entries.push({
|
|
940
|
+
name,
|
|
941
|
+
body: store ? /** @type {Uint8Array} */ (body) : utf8(/** @type {string} */ (body)),
|
|
942
|
+
store,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// Which media each sheet's drawing references, and in what order, so a
|
|
946
|
+
// drawing's relationship ids line up with its own part rather than with
|
|
947
|
+
// the workbook-wide media list.
|
|
948
|
+
const drawings = sheets
|
|
949
|
+
.map((sheet, index) => ({ sheet, index }))
|
|
950
|
+
.filter((each) => each.sheet.pictures.length > 0);
|
|
951
|
+
|
|
952
|
+
const overrides = sheets
|
|
953
|
+
.map(
|
|
954
|
+
(_sheet, index) =>
|
|
955
|
+
`<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="${CT}.spreadsheetml.worksheet+xml"/>`,
|
|
956
|
+
)
|
|
957
|
+
.join("");
|
|
958
|
+
const drawingOverrides = drawings
|
|
959
|
+
.map(
|
|
960
|
+
(_each, at) =>
|
|
961
|
+
`<Override PartName="/xl/drawings/drawing${at + 1}.xml" ContentType="${CT}.drawing+xml"/>`,
|
|
962
|
+
)
|
|
963
|
+
.join("");
|
|
964
|
+
const defaults = [...new Set(media.map((each) => each.format))]
|
|
965
|
+
.map((format) => `<Default Extension="${format}" ContentType="${FORMATS.get(format)}"/>`)
|
|
966
|
+
.join("");
|
|
967
|
+
|
|
968
|
+
part(
|
|
969
|
+
"[Content_Types].xml",
|
|
970
|
+
DECLARATION +
|
|
971
|
+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
|
972
|
+
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
|
|
973
|
+
`<Default Extension="xml" ContentType="application/xml"/>` +
|
|
974
|
+
defaults +
|
|
975
|
+
`<Override PartName="/xl/workbook.xml" ContentType="${CT}.spreadsheetml.sheet.main+xml"/>` +
|
|
976
|
+
overrides +
|
|
977
|
+
`<Override PartName="/xl/styles.xml" ContentType="${CT}.spreadsheetml.styles+xml"/>` +
|
|
978
|
+
`<Override PartName="/xl/sharedStrings.xml" ContentType="${CT}.spreadsheetml.sharedStrings+xml"/>` +
|
|
979
|
+
drawingOverrides +
|
|
980
|
+
`<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>` +
|
|
981
|
+
`<Override PartName="/docProps/app.xml" ContentType="${CT}.extended-properties+xml"/>` +
|
|
982
|
+
`</Types>`,
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
part(
|
|
986
|
+
"_rels/.rels",
|
|
987
|
+
DECLARATION +
|
|
988
|
+
`<Relationships xmlns="${NS_PKG_REL}">` +
|
|
989
|
+
`<Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/>` +
|
|
990
|
+
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>` +
|
|
991
|
+
`<Relationship Id="rId3" Type="${REL}/extended-properties" Target="docProps/app.xml"/>` +
|
|
992
|
+
`</Relationships>`,
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
/** @param {string} name @param {string | undefined} value */
|
|
996
|
+
const tag = (name, value) => (value === undefined ? "" : `<${name}>${esc(value)}</${name}>`);
|
|
997
|
+
part(
|
|
998
|
+
"docProps/core.xml",
|
|
999
|
+
DECLARATION +
|
|
1000
|
+
`<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
|
|
1001
|
+
tag("dc:title", title) +
|
|
1002
|
+
tag("dc:subject", subject) +
|
|
1003
|
+
tag("dc:creator", creator) +
|
|
1004
|
+
tag("dc:description", description) +
|
|
1005
|
+
`<dcterms:created xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:created>` +
|
|
1006
|
+
`<dcterms:modified xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:modified>` +
|
|
1007
|
+
`</cp:coreProperties>`,
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
part(
|
|
1011
|
+
"docProps/app.xml",
|
|
1012
|
+
DECLARATION +
|
|
1013
|
+
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>werkmap</Application></Properties>`,
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
part(
|
|
1017
|
+
"xl/workbook.xml",
|
|
1018
|
+
DECLARATION +
|
|
1019
|
+
`<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` +
|
|
1020
|
+
sheets
|
|
1021
|
+
.map(
|
|
1022
|
+
(sheet, index) =>
|
|
1023
|
+
`<sheet name="${esc(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`,
|
|
1024
|
+
)
|
|
1025
|
+
.join("") +
|
|
1026
|
+
`</sheets></workbook>`,
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
part(
|
|
1030
|
+
"xl/_rels/workbook.xml.rels",
|
|
1031
|
+
DECLARATION +
|
|
1032
|
+
`<Relationships xmlns="${NS_PKG_REL}">` +
|
|
1033
|
+
sheets
|
|
1034
|
+
.map(
|
|
1035
|
+
(_sheet, index) =>
|
|
1036
|
+
`<Relationship Id="rId${index + 1}" Type="${REL}/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`,
|
|
1037
|
+
)
|
|
1038
|
+
.join("") +
|
|
1039
|
+
`<Relationship Id="rId${sheets.length + 1}" Type="${REL}/styles" Target="styles.xml"/>` +
|
|
1040
|
+
`<Relationship Id="rId${sheets.length + 2}" Type="${REL}/sharedStrings" Target="sharedStrings.xml"/>` +
|
|
1041
|
+
`</Relationships>`,
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
for (const [index, sheet] of sheets.entries()) {
|
|
1045
|
+
const at = drawings.findIndex((each) => each.index === index);
|
|
1046
|
+
part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
|
|
1047
|
+
if (at !== -1)
|
|
1048
|
+
part(
|
|
1049
|
+
`xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
|
|
1050
|
+
DECLARATION +
|
|
1051
|
+
`<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
part("xl/styles.xml", styles.xml());
|
|
1056
|
+
part("xl/sharedStrings.xml", sst.xml());
|
|
1057
|
+
|
|
1058
|
+
for (const [at, each] of drawings.entries()) {
|
|
1059
|
+
// Each drawing numbers its own relationships from one, in the order
|
|
1060
|
+
// its pictures were placed.
|
|
1061
|
+
const used = [...new Set(each.sheet.pictures.map((picture) => picture.id))];
|
|
1062
|
+
part(
|
|
1063
|
+
`xl/drawings/drawing${at + 1}.xml`,
|
|
1064
|
+
drawingXml(
|
|
1065
|
+
each.sheet.pictures,
|
|
1066
|
+
each.sheet.pictures.map((picture) => used.indexOf(picture.id)),
|
|
1067
|
+
),
|
|
1068
|
+
);
|
|
1069
|
+
part(
|
|
1070
|
+
`xl/drawings/_rels/drawing${at + 1}.xml.rels`,
|
|
1071
|
+
DECLARATION +
|
|
1072
|
+
`<Relationships xmlns="${NS_PKG_REL}">` +
|
|
1073
|
+
used
|
|
1074
|
+
.map(
|
|
1075
|
+
(id, index) =>
|
|
1076
|
+
`<Relationship Id="rId${index + 1}" Type="${REL}/image" Target="../media/image${id + 1}.${media[id].format}"/>`,
|
|
1077
|
+
)
|
|
1078
|
+
.join("") +
|
|
1079
|
+
`</Relationships>`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
for (const [index, each] of media.entries())
|
|
1084
|
+
part(`xl/media/image${index + 1}.${each.format}`, each.bytes, true);
|
|
1085
|
+
|
|
1086
|
+
return zip(entries);
|
|
1087
|
+
},
|
|
1088
|
+
};
|
|
1089
|
+
};
|