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/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, "&amp;")
153
+ .replace(/</g, "&lt;")
154
+ .replace(/>/g, "&gt;")
155
+ .replace(/"/g, "&quot;");
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
+ };