werkmap 0.2.0 → 0.4.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 CHANGED
@@ -1,18 +1,7 @@
1
1
  // werkmap — a write-only OOXML (.xlsx) writer.
2
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.
3
+ // One entry point, `workbook`, returning a mutable builder whose only terminal
4
+ // is `bytes()`. Nothing here reads a .xlsx file.
16
5
 
17
6
  // ---------------------------------------------------------------- ZIP ----
18
7
 
@@ -57,6 +46,66 @@ const DOS_DATE = (1 << 5) | 1;
57
46
  const STORED = 0;
58
47
  const DEFLATED = 8;
59
48
 
49
+ /**
50
+ * @typedef {{ method: number, sum: number, packed: Uint8Array, raw: Uint8Array, name: Uint8Array }} Packed
51
+ */
52
+
53
+ /** @param {Packed} entry */
54
+ const localHeader = ({ method, sum, packed, raw, name }) => {
55
+ const head = new DataView(new ArrayBuffer(30));
56
+ head.setUint32(0, 0x04034b50, true);
57
+ head.setUint16(4, 20, true);
58
+ head.setUint16(6, 0x0800, true); // the name is UTF-8
59
+ head.setUint16(8, method, true);
60
+ head.setUint16(10, DOS_TIME, true);
61
+ head.setUint16(12, DOS_DATE, true);
62
+ head.setUint32(14, sum, true);
63
+ head.setUint32(18, packed.length, true);
64
+ head.setUint32(22, raw.length, true);
65
+ head.setUint16(26, name.length, true);
66
+ return new Uint8Array(head.buffer);
67
+ };
68
+
69
+ /** @param {Packed} entry @param {number} offset */
70
+ const centralRecord = ({ method, sum, packed, raw, name }, offset) => {
71
+ const record = new DataView(new ArrayBuffer(46));
72
+ record.setUint32(0, 0x02014b50, true);
73
+ record.setUint16(4, 20, true);
74
+ record.setUint16(6, 20, true);
75
+ record.setUint16(8, 0x0800, true);
76
+ record.setUint16(10, method, true);
77
+ record.setUint16(12, DOS_TIME, true);
78
+ record.setUint16(14, DOS_DATE, true);
79
+ record.setUint32(16, sum, true);
80
+ record.setUint32(20, packed.length, true);
81
+ record.setUint32(24, raw.length, true);
82
+ record.setUint16(28, name.length, true);
83
+ record.setUint32(42, offset, true);
84
+ return new Uint8Array(record.buffer);
85
+ };
86
+
87
+ /** @param {number} count @param {number} size @param {number} offset */
88
+ const endRecord = (count, size, offset) => {
89
+ const end = new DataView(new ArrayBuffer(22));
90
+ end.setUint32(0, 0x06054b50, true);
91
+ end.setUint16(8, count, true);
92
+ end.setUint16(10, count, true);
93
+ end.setUint32(12, size, true);
94
+ end.setUint32(16, offset, true);
95
+ return new Uint8Array(end.buffer);
96
+ };
97
+
98
+ /** @param {ReadonlyArray<Uint8Array>} chunks */
99
+ const concat = (chunks) => {
100
+ const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
101
+ let at = 0;
102
+ for (const chunk of chunks) {
103
+ bytes.set(chunk, at);
104
+ at += chunk.length;
105
+ }
106
+ return bytes;
107
+ };
108
+
60
109
  /**
61
110
  * @param {ReadonlyArray<{ name: string, body: Uint8Array, store?: boolean }>} entries
62
111
  * @returns {Promise<Uint8Array>}
@@ -68,58 +117,16 @@ const zip = async (entries) => {
68
117
 
69
118
  for (const entry of entries) {
70
119
  const raw = entry.body;
71
- const packed = entry.store ? raw : await deflate(raw);
72
- const method = entry.store ? STORED : DEFLATED;
120
+ const [packed, method] = entry.store ? [raw, STORED] : [await deflate(raw), DEFLATED];
73
121
  const name = utf8(entry.name);
74
- const sum = crc32(raw);
75
-
76
- const head = new DataView(new ArrayBuffer(30));
77
- head.setUint32(0, 0x04034b50, true);
78
- head.setUint16(4, 20, true);
79
- head.setUint16(6, 0x0800, true); // the name is UTF-8
80
- head.setUint16(8, method, true);
81
- head.setUint16(10, DOS_TIME, true);
82
- head.setUint16(12, DOS_DATE, true);
83
- head.setUint32(14, sum, true);
84
- head.setUint32(18, packed.length, true);
85
- head.setUint32(22, raw.length, true);
86
- head.setUint16(26, name.length, true);
87
- local.push(new Uint8Array(head.buffer), name, packed);
88
-
89
- const record = new DataView(new ArrayBuffer(46));
90
- record.setUint32(0, 0x02014b50, true);
91
- record.setUint16(4, 20, true);
92
- record.setUint16(6, 20, true);
93
- record.setUint16(8, 0x0800, true);
94
- record.setUint16(10, method, true);
95
- record.setUint16(12, DOS_TIME, true);
96
- record.setUint16(14, DOS_DATE, true);
97
- record.setUint32(16, sum, true);
98
- record.setUint32(20, packed.length, true);
99
- record.setUint32(24, raw.length, true);
100
- record.setUint16(28, name.length, true);
101
- record.setUint32(42, offset, true);
102
- central.push(new Uint8Array(record.buffer), name);
103
-
122
+ const item = { method, sum: crc32(raw), packed, raw, name };
123
+ local.push(localHeader(item), name, packed);
124
+ central.push(centralRecord(item, offset), name);
104
125
  offset += 30 + name.length + packed.length;
105
126
  }
106
127
 
107
128
  const size = central.reduce((total, chunk) => total + chunk.length, 0);
108
- const end = new DataView(new ArrayBuffer(22));
109
- end.setUint32(0, 0x06054b50, true);
110
- end.setUint16(8, entries.length, true);
111
- end.setUint16(10, entries.length, true);
112
- end.setUint32(12, size, true);
113
- end.setUint32(16, offset, true);
114
-
115
- const all = [...local, ...central, new Uint8Array(end.buffer)];
116
- const bytes = new Uint8Array(all.reduce((total, chunk) => total + chunk.length, 0));
117
- let at = 0;
118
- for (const chunk of all) {
119
- bytes.set(chunk, at);
120
- at += chunk.length;
121
- }
122
- return bytes;
129
+ return concat([...local, ...central, endRecord(entries.length, size, offset)]);
123
130
  };
124
131
 
125
132
  // ---------------------------------------------------------------- XML ----
@@ -132,10 +139,8 @@ const REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships
132
139
  const CT = "application/vnd.openxmlformats-officedocument";
133
140
 
134
141
  // What XML 1.0 cannot carry at all: the C0 range except TAB, LF and CR, and
135
- // unpaired surrogates. These are stripped rather than thrown over. The text
136
- // reaching this writer has already passed its caller's own gates, and failing
137
- // a whole document over one stray byte in a customer's name is the wrong
138
- // trade — this is the one place leniency belongs.
142
+ // unpaired surrogates. Stripped rather than thrown over the one place
143
+ // leniency belongs.
139
144
  const FORBIDDEN =
140
145
  // This expression exists to find exactly the control characters the rule
141
146
  // below warns about, which is the one case where it is wrong.
@@ -157,12 +162,75 @@ const esc = (text) =>
157
162
  .replace(/>/g, "&gt;")
158
163
  .replace(/"/g, "&quot;");
159
164
 
160
- // `xml:space="preserve"` goes on every `<t>` rather than only where the text
161
- // needs it: without it a reader is free to trim, and one rule is easier to
162
- // trust than a predicate over whitespace.
165
+ // `xml:space="preserve"` goes on every `<t>`, not only where the text needs
166
+ // it: one rule is easier to trust than a predicate over whitespace.
163
167
  /** @param {string} text */
164
168
  const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
165
169
 
170
+ // ---------------------------------------------------------- validation ----
171
+
172
+ // Absent means the caller said nothing: `undefined` and `null` read the same
173
+ // everywhere on this surface.
174
+ /** @param {unknown} value */
175
+ const absent = (value) => value === undefined || value === null;
176
+
177
+ /**
178
+ * @param {unknown} value
179
+ * @param {string} where
180
+ * @param {string} expected what the message names when the value is not an object
181
+ * @returns {Record<string, unknown>}
182
+ */
183
+ const requireRecord = (value, where, expected) => {
184
+ if (value === null || typeof value !== "object")
185
+ throw TypeError(`${where}: expected ${expected}, got ${JSON.stringify(value)}`);
186
+ return /** @type {Record<string, unknown>} */ (value);
187
+ };
188
+
189
+ /**
190
+ * @param {unknown} value
191
+ * @param {string} where
192
+ */
193
+ const requireString = (value, where) => {
194
+ if (typeof value !== "string")
195
+ throw TypeError(`${where}: expected a string, got ${JSON.stringify(value)}`);
196
+ return value;
197
+ };
198
+
199
+ /**
200
+ * @param {unknown} value
201
+ * @param {string} where
202
+ * @param {string} fallback
203
+ */
204
+ const requireText = (value, where, fallback) =>
205
+ value === undefined ? fallback : requireString(value, where);
206
+
207
+ /**
208
+ * @param {unknown} value
209
+ * @param {string} where
210
+ */
211
+ const requireNumber = (value, where) => {
212
+ if (typeof value !== "number" || !Number.isFinite(value))
213
+ throw RangeError(`${where}: expected a finite number, got ${JSON.stringify(value)}`);
214
+ return value;
215
+ };
216
+
217
+ /** @param {unknown} value @param {number} limit */
218
+ const isIndex = (value, limit) =>
219
+ typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= limit;
220
+
221
+ /**
222
+ * @param {unknown} value
223
+ * @param {number} limit
224
+ * @param {string} where
225
+ */
226
+ const requireIndex = (value, limit, where) => {
227
+ if (!isIndex(value, limit))
228
+ throw RangeError(
229
+ `${where}: expected an integer between 1 and ${limit}, got ${JSON.stringify(value)}`,
230
+ );
231
+ return /** @type {number} */ (value);
232
+ };
233
+
166
234
  /**
167
235
  * One run of rich text, written in full: an OOXML run inherits nothing from
168
236
  * the cell font.
@@ -170,17 +238,12 @@ const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
170
238
  * @param {string} at
171
239
  */
172
240
  const richRun = (run, at) => {
173
- if (run === null || typeof run !== "object")
174
- throw TypeError(`${at}: expected a { text, font } run, got ${JSON.stringify(run)}`);
175
- const text = /** @type {Record<string, unknown>} */ (run).text;
176
- if (typeof text !== "string")
177
- throw TypeError(`${at}.text: expected a string, got ${JSON.stringify(text)}`);
178
- const font = /** @type {Record<string, unknown>} */ (run).font;
179
- const properties =
180
- font === undefined || font === null
181
- ? ""
182
- : `<rPr>${runFontXml(/** @type {Record<string, unknown>} */ (font), `${at}.font`)}</rPr>`;
183
- return `<r>${properties}${textNode(text)}</r>`;
241
+ const { text, font } = requireRecord(run, at, "a { text, font } run");
242
+ const body = textNode(requireString(text, `${at}.text`));
243
+ const properties = absent(font)
244
+ ? ""
245
+ : `<rPr>${runFontXml(/** @type {Record<string, unknown>} */ (font), `${at}.font`)}</rPr>`;
246
+ return `<r>${properties}${body}</r>`;
184
247
  };
185
248
 
186
249
  /**
@@ -205,14 +268,10 @@ const richKey = (runs, where) => {
205
268
 
206
269
  // ------------------------------------------------------------- values ----
207
270
 
208
- // Excel's 1900 date system counts from an epoch of 1899-12-30, which absorbs
209
- // its phantom 1900-02-29. The conversion is in UTC: a reader's own timezone
210
- // is never consulted, so one `Date` is one serial everywhere.
211
- //
212
- // The phantom day is real for serial 60 and nothing else. Excel shows 60 as
213
- // 1900-02-29, a day that never happened, so every date before 1900-03-01 sits
214
- // one lower than the 1899-12-30 epoch alone would put it. A writer that skips
215
- // this arithmetic moves 1900-02-28 onto the phantom day.
271
+ // Excel's 1900 date system counts from an epoch of 1899-12-30, in UTC. The
272
+ // phantom 1900-02-29 is serial 60, so every date before 1900-03-01 sits one
273
+ // lower than that epoch alone would put it: skip this arithmetic and
274
+ // 1900-02-28 lands on the phantom day.
216
275
  const EPOCH_OFFSET = 25569;
217
276
  const DAY = 86_400_000;
218
277
  const PHANTOM = 61;
@@ -299,8 +358,7 @@ const FIRST_CUSTOM = 164;
299
358
 
300
359
  /**
301
360
  * Interning by a canonical key, preserving first-seen order. Every style
302
- * table in the file is one of these, which is what makes identical fonts,
303
- * fills, borders and formats collapse to one entry each.
361
+ * table is one of these.
304
362
  */
305
363
  /**
306
364
  * @template T
@@ -318,8 +376,7 @@ const table = (initial) => {
318
376
  return {
319
377
  items,
320
378
  /**
321
- * Arrow rather than a method, so `strings` below can pass it on by name:
322
- * it closes over the table and never touches `this`.
379
+ * Arrow rather than a method, so `strings` below can pass it on by name.
323
380
  * @param {string} key
324
381
  * @param {() => T} make
325
382
  */
@@ -336,6 +393,15 @@ const table = (initial) => {
336
393
 
337
394
  // ------------------------------------------------------------- styles ----
338
395
 
396
+ // The boolean half of a font, in the order `CT_Font` writes them.
397
+ /** @type {ReadonlyArray<[string, string]>} */
398
+ const FONT_FLAGS = [
399
+ ["bold", "<b/>"],
400
+ ["italic", "<i/>"],
401
+ ["strikethrough", "<strike/>"],
402
+ ["underline", "<u/>"],
403
+ ];
404
+
339
405
  /**
340
406
  * The font half of a style, written in the SDK's `CT_Font` sequence:
341
407
  * b i strike u sz color name.
@@ -343,14 +409,14 @@ const table = (initial) => {
343
409
  * @param {string} where
344
410
  */
345
411
  const fontXml = (font, where) => {
346
- let out = "";
347
- if (font.bold) out += "<b/>";
348
- if (font.italic) out += "<i/>";
349
- if (font.strikethrough) out += "<strike/>";
350
- if (font.underline) out += "<u/>";
351
- out += `<sz val="${font.size === undefined ? 11 : requireNumber(font.size, `${where}.size`)}"/>`;
352
- if (font.color !== undefined) out += `<color rgb="${rgb(font.color, `${where}.color`)}"/>`;
353
- return out + `<name val="${esc(requireText(font.name, `${where}.name`, "Calibri"))}"/>`;
412
+ const flags = FONT_FLAGS.filter(([key]) => font[key])
413
+ .map(([, tag]) => tag)
414
+ .join("");
415
+ const size = font.size === undefined ? 11 : requireNumber(font.size, `${where}.size`);
416
+ const colour =
417
+ font.color === undefined ? "" : `<color rgb="${rgb(font.color, `${where}.color`)}"/>`;
418
+ const name = esc(requireText(font.name, `${where}.name`, "Calibri"));
419
+ return `${flags}<sz val="${size}"/>${colour}<name val="${name}"/>`;
354
420
  };
355
421
 
356
422
  /**
@@ -368,100 +434,98 @@ const SIDES = ["left", "right", "top", "bottom"];
368
434
  * @param {Record<string, unknown>} border
369
435
  * @param {string} where
370
436
  */
371
- const borderXml = (border, where) => {
372
- let out = "";
373
- for (const side of SIDES) {
374
- const edge = border[side];
375
- if (edge === undefined || edge === null) {
376
- out += `<${side}/>`;
377
- continue;
378
- }
379
- const at = `${where}.${side}`;
380
- const style = /** @type {Record<string, unknown>} */ (edge).style;
381
- if (typeof style !== "string" || !BORDER_STYLES.has(style))
382
- throw RangeError(
383
- `${at}.style: expected thin, dashed or dotted, got ${JSON.stringify(style)}`,
384
- );
385
- const colour = /** @type {Record<string, unknown>} */ (edge).color;
386
- out +=
387
- colour === undefined
388
- ? `<${side} style="${style}"/>`
389
- : `<${side} style="${style}"><color rgb="${rgb(colour, `${at}.color`)}"/></${side}>`;
390
- }
391
- return out + "<diagonal/>";
437
+ /**
438
+ * @param {unknown} style
439
+ * @param {string} at
440
+ */
441
+ const edgeStyle = (style, at) => {
442
+ if (typeof style !== "string" || !BORDER_STYLES.has(style))
443
+ throw RangeError(`${at}.style: expected thin, dashed or dotted, got ${JSON.stringify(style)}`);
444
+ return style;
392
445
  };
393
446
 
394
- const HORIZONTAL = new Set(["left", "center", "right", "justify", "fill"]);
395
- const VERTICAL = new Set(["top", "center", "bottom", "justify"]);
447
+ /**
448
+ * One side of a border. An absent side is written empty rather than skipped,
449
+ * because `CT_Border` lists all four in a fixed order.
450
+ * @param {string} side
451
+ * @param {unknown} edge
452
+ * @param {string} at
453
+ */
454
+ const sideXml = (side, edge, at) => {
455
+ if (absent(edge)) return `<${side}/>`;
456
+ const style = edgeStyle(/** @type {Record<string, unknown>} */ (edge).style, at);
457
+ const colour = /** @type {Record<string, unknown>} */ (edge).color;
458
+ return colour === undefined
459
+ ? `<${side} style="${style}"/>`
460
+ : `<${side} style="${style}"><color rgb="${rgb(colour, `${at}.color`)}"/></${side}>`;
461
+ };
396
462
 
397
463
  /**
398
- * @param {Record<string, unknown>} alignment
464
+ * @param {Record<string, unknown>} border
399
465
  * @param {string} where
400
466
  */
401
- const alignmentXml = (alignment, where) => {
402
- let out = "";
403
- if (alignment.horizontal !== undefined) {
404
- const horizontal = /** @type {string} */ (alignment.horizontal);
405
- if (!HORIZONTAL.has(horizontal))
406
- throw RangeError(`${where}.horizontal: unknown alignment ${JSON.stringify(horizontal)}`);
407
- out += ` horizontal="${horizontal}"`;
408
- }
409
- if (alignment.vertical !== undefined) {
410
- const vertical = /** @type {string} */ (alignment.vertical);
411
- if (!VERTICAL.has(vertical))
412
- throw RangeError(`${where}.vertical: unknown alignment ${JSON.stringify(vertical)}`);
413
- out += ` vertical="${vertical}"`;
414
- }
415
- if (alignment.wrapText) out += ' wrapText="1"';
416
- return out;
417
- };
467
+ const borderXml = (border, where) =>
468
+ SIDES.map((side) => sideXml(side, border[side], `${where}.${side}`)).join("") + "<diagonal/>";
469
+
470
+ const HORIZONTAL = new Set(["left", "center", "right", "justify", "fill"]);
471
+ const VERTICAL = new Set(["top", "center", "bottom", "justify"]);
418
472
 
419
473
  /**
474
+ * One alignment axis as its attribute, or nothing when the caller left it out.
420
475
  * @param {unknown} value
476
+ * @param {ReadonlySet<string>} known
477
+ * @param {string} axis
421
478
  * @param {string} where
422
- * @param {string} fallback
423
479
  */
424
- const requireText = (value, where, fallback) => {
425
- if (value === undefined) return fallback;
426
- if (typeof value !== "string")
427
- throw TypeError(`${where}: expected a string, got ${JSON.stringify(value)}`);
428
- return value;
480
+ const axisXml = (value, known, axis, where) => {
481
+ if (value === undefined) return "";
482
+ const named = /** @type {string} */ (value);
483
+ if (!known.has(named))
484
+ throw RangeError(`${where}.${axis}: unknown alignment ${JSON.stringify(named)}`);
485
+ return ` ${axis}="${named}"`;
429
486
  };
430
487
 
431
488
  /**
432
- * @param {unknown} value
489
+ * @param {Record<string, unknown>} alignment
433
490
  * @param {string} where
434
491
  */
435
- const requireNumber = (value, where) => {
436
- if (typeof value !== "number" || !Number.isFinite(value))
437
- throw RangeError(`${where}: expected a finite number, got ${JSON.stringify(value)}`);
438
- return value;
439
- };
492
+ const alignmentXml = (alignment, where) =>
493
+ axisXml(alignment.horizontal, HORIZONTAL, "horizontal", where) +
494
+ axisXml(alignment.vertical, VERTICAL, "vertical", where) +
495
+ (alignment.wrapText ? ' wrapText="1"' : "");
440
496
 
441
497
  /**
442
498
  * @typedef {{ numFmtId: number, fontId: number, fillId: number, borderId: number, alignment: string }} Xf
443
499
  */
444
500
 
501
+ // Which `apply*` flag each table id switches on, in the order `CT_Xf` lists
502
+ // them. Alignment is not a table, so it is written apart.
503
+ /** @type {ReadonlyArray<["numFmtId" | "fontId" | "fillId" | "borderId", string]>} */
504
+ const APPLIES = [
505
+ ["numFmtId", "applyNumberFormat"],
506
+ ["fontId", "applyFont"],
507
+ ["fillId", "applyFill"],
508
+ ["borderId", "applyBorder"],
509
+ ];
510
+
445
511
  /**
446
512
  * One `xf`. Each `apply*` flag says which table this format actually reaches
447
513
  * into; a reader ignores a table entry the flag does not claim.
448
514
  * @param {Xf} xf
449
515
  */
450
516
  const xfXml = (xf) => {
451
- let out = `<xf numFmtId="${xf.numFmtId}" fontId="${xf.fontId}" fillId="${xf.fillId}" borderId="${xf.borderId}" xfId="0"`;
452
- if (xf.numFmtId !== 0) out += ' applyNumberFormat="1"';
453
- if (xf.fontId !== 0) out += ' applyFont="1"';
454
- if (xf.fillId !== 0) out += ' applyFill="1"';
455
- if (xf.borderId !== 0) out += ' applyBorder="1"';
456
- if (xf.alignment !== "") out += ' applyAlignment="1"';
457
- return xf.alignment === "" ? out + "/>" : `${out}><alignment${xf.alignment}/></xf>`;
517
+ const ids = `numFmtId="${xf.numFmtId}" fontId="${xf.fontId}" fillId="${xf.fillId}" borderId="${xf.borderId}"`;
518
+ const applies = APPLIES.filter(([key]) => xf[key] !== 0)
519
+ .map(([, flag]) => ` ${flag}="1"`)
520
+ .join("");
521
+ if (xf.alignment === "") return `<xf ${ids} xfId="0"${applies}/>`;
522
+ return `<xf ${ids} xfId="0"${applies} applyAlignment="1"><alignment${xf.alignment}/></xf>`;
458
523
  };
459
524
 
460
525
  /**
461
- * The whole of `styles.xml`, and the interning behind it. Entry 0 of each
462
- * table is the default every unstyled cell points at; the two fills OOXML
463
- * reserves (`none`, `gray125`) are written whether or not anything uses them,
464
- * because readers index fills by position.
526
+ * The whole of `styles.xml`. Entry 0 of each table is the default every
527
+ * unstyled cell points at, and the two fills OOXML reserves (`none`,
528
+ * `gray125`) are always written, because readers index fills by position.
465
529
  */
466
530
  const stylesheet = () => {
467
531
  const numFmts = new Map();
@@ -483,11 +547,14 @@ const stylesheet = () => {
483
547
  * @param {(value: Record<string, unknown>) => [string, () => T]} describe
484
548
  */
485
549
  const optional = (part, into, describe) => {
486
- if (part === undefined || part === null) return 0;
550
+ if (absent(part)) return 0;
487
551
  const [key, make] = describe(/** @type {Record<string, unknown>} */ (part));
488
552
  return into.intern(key, make);
489
553
  };
490
554
 
555
+ /** @param {string} code */
556
+ const knownFormat = (code) => BUILT_IN.get(code) ?? numFmts.get(code);
557
+
491
558
  /**
492
559
  * A format code a reader knows by id keeps that id; anything else is
493
560
  * interned from 164 up, in first-seen order.
@@ -495,12 +562,10 @@ const stylesheet = () => {
495
562
  * @param {string} where
496
563
  */
497
564
  const formatId = (code, where) => {
498
- if (code === undefined || code === null) return 0;
565
+ if (absent(code)) return 0;
499
566
  if (typeof code !== "string")
500
567
  throw TypeError(`${where}: expected a format code, got ${JSON.stringify(code)}`);
501
- const builtIn = BUILT_IN.get(code);
502
- if (builtIn !== undefined) return builtIn;
503
- const seen = numFmts.get(code);
568
+ const seen = knownFormat(code);
504
569
  if (seen !== undefined) return seen;
505
570
  const id = FIRST_CUSTOM + numFmts.size;
506
571
  numFmts.set(code, id);
@@ -514,7 +579,7 @@ const stylesheet = () => {
514
579
  * @returns {number}
515
580
  */
516
581
  intern(style, where) {
517
- if (style === undefined || style === null) return 0;
582
+ if (absent(style)) return 0;
518
583
  if (typeof style !== "object")
519
584
  throw TypeError(`${where}: expected a style object, got ${JSON.stringify(style)}`);
520
585
 
@@ -536,13 +601,12 @@ const stylesheet = () => {
536
601
  JSON.stringify(border),
537
602
  () => `<border>${borderXml(border, `${where}.border`)}</border>`,
538
603
  ]),
539
- alignment:
540
- style.alignment === undefined || style.alignment === null
541
- ? ""
542
- : alignmentXml(
543
- /** @type {Record<string, unknown>} */ (style.alignment),
544
- `${where}.alignment`,
545
- ),
604
+ alignment: absent(style.alignment)
605
+ ? ""
606
+ : alignmentXml(
607
+ /** @type {Record<string, unknown>} */ (style.alignment),
608
+ `${where}.alignment`,
609
+ ),
546
610
  };
547
611
  return formats.intern(JSON.stringify(record), () => record);
548
612
  },
@@ -571,10 +635,7 @@ const stylesheet = () => {
571
635
 
572
636
  // ------------------------------------------------------ shared strings ----
573
637
 
574
- /**
575
- * Text is always interned here rather than written inline. It is what Excel
576
- * itself writes, and a report's repeated labels are what it shrinks.
577
- */
638
+ /** Text is always interned here rather than written inline. */
578
639
  const strings = () => {
579
640
  // The same interning the style tables use: one `si` per distinct piece of
580
641
  // text, in first-seen order.
@@ -618,28 +679,36 @@ const FURNITURE = 0.3;
618
679
 
619
680
  /**
620
681
  * `pageMargins` and `pageSetup`, or nothing at all. `pageMargins` has no
621
- * optional attributes, so asking for any margin means writing all six the
622
- * two this surface does not take keep Excel's own gap for a header and a
623
- * footer.
682
+ * optional attributes, so any margin means writing all six; the two this
683
+ * surface does not take keep Excel's own header and footer gap.
624
684
  *
625
- * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean } | null} setup
685
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} setup
626
686
  */
627
687
  const printXml = (setup) => {
628
688
  if (setup === null) return "";
629
- let out = "";
630
- if (setup.margin !== undefined) {
631
- const inches = (setup.margin / POINTS_PER_INCH).toFixed(3);
632
- out +=
633
- `<pageMargins left="${inches}" right="${inches}" top="${inches}" bottom="${inches}"` +
634
- ` header="${FURNITURE}" footer="${FURNITURE}"/>`;
635
- }
689
+ const attributes = setupAttributes(setup);
690
+ return marginsXml(setup.margin) + (attributes === "" ? "" : `<pageSetup${attributes}/>`);
691
+ };
692
+
693
+ /** @param {number | undefined} margin in points */
694
+ const marginsXml = (margin) => {
695
+ if (margin === undefined) return "";
696
+ const inches = (margin / POINTS_PER_INCH).toFixed(3);
697
+ return (
698
+ `<pageMargins left="${inches}" right="${inches}" top="${inches}" bottom="${inches}"` +
699
+ ` header="${FURNITURE}" footer="${FURNITURE}"/>`
700
+ );
701
+ };
702
+
703
+ /** @param {{ size?: string, orientation?: string, fit?: boolean }} setup */
704
+ const setupAttributes = (setup) => {
636
705
  let attributes = "";
637
706
  if (setup.size !== undefined) attributes += ` paperSize="${PAPER.get(setup.size)}"`;
638
707
  if (setup.orientation !== undefined) attributes += ` orientation="${setup.orientation}"`;
639
708
  // `fitToHeight="0"` is what makes it *width* the document fits to: one page
640
709
  // across, as many down as it takes.
641
710
  if (setup.fit === true) attributes += ' fitToWidth="1" fitToHeight="0"';
642
- return attributes === "" ? out : out + `<pageSetup${attributes}/>`;
711
+ return attributes;
643
712
  };
644
713
 
645
714
  // -------------------------------------------------------------- sheet ----
@@ -652,21 +721,248 @@ const NAME_LIMIT = 31;
652
721
  const MAX_ROW = 1_048_576;
653
722
  const MAX_COLUMN = 16_384;
654
723
 
724
+ // Excel's ceiling for a column width, in the unit the format states it: a
725
+ // count of characters of the default font, the number Excel's own width box
726
+ // shows. Not pixels: that conversion runs through the default font's maximum
727
+ // digit width, and any cell here may name a font of its own.
728
+ const MAX_WIDTH = 255;
729
+
730
+ // EMU per CSS pixel at 96 dpi.
731
+ const EMU = 9525;
732
+
733
+ /**
734
+ * A `Date` as the serial Excel stores. An invalid one has no serial to give.
735
+ * @param {Date} date
736
+ * @param {string} at
737
+ */
738
+ const dateSerial = (date, at) => {
739
+ if (Number.isNaN(date.getTime()))
740
+ throw RangeError(`${at}: expected a valid Date, got an invalid one`);
741
+ return serial(date);
742
+ };
743
+
655
744
  /**
745
+ * The shared-string index of a cell's text, plain or rich.
746
+ * @param {ReturnType<typeof strings>} sst
747
+ * @param {unknown} value
748
+ * @param {string} at
749
+ */
750
+ const textIndex = (sst, value, at) => {
751
+ if (typeof value === "string")
752
+ return sst.intern("s" + value, () => `<si>${textNode(value)}</si>`);
753
+ if (Array.isArray(value)) return sst.intern(richKey(value, at), () => richXml(value, at));
754
+ throw TypeError(`${at}: unsupported cell value ${JSON.stringify(value)}`);
755
+ };
756
+
757
+ /**
758
+ * The type attribute and `<v>` of a present cell value.
759
+ * @param {ReturnType<typeof strings>} sst
656
760
  * @param {unknown} value
657
- * @param {number} limit
658
761
  * @param {string} where
762
+ * @returns {[string, string | number]}
659
763
  */
660
- const requireIndex = (value, limit, where) => {
661
- if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > limit)
764
+ const cellBody = (sst, value, where) => {
765
+ const at = `${where}.value`;
766
+ if (typeof value === "number") return ["", number(requireNumber(value, at))];
767
+ if (typeof value === "boolean") return [' t="b"', Number(value)];
768
+ if (value instanceof Date) return ["", number(dateSerial(value, at))];
769
+ return [' t="s"', textIndex(sst, value, at)];
770
+ };
771
+
772
+ /**
773
+ * @param {ReturnType<typeof strings>} sst
774
+ * @param {unknown} value
775
+ * @param {number} styleId
776
+ * @param {string} ref
777
+ * @param {string} where
778
+ */
779
+ const cellXml = (sst, value, styleId, ref, where) => {
780
+ const s = styleId === 0 ? "" : ` s="${styleId}"`;
781
+ if (absent(value)) return s === "" ? "" : `<c r="${ref}"${s}/>`;
782
+ const [type, v] = cellBody(sst, value, where);
783
+ return `<c r="${ref}"${s}${type}><v>${v}</v></c>`;
784
+ };
785
+
786
+ // A style that is not an object passes through untouched, so the interning
787
+ // below is still the one place that refuses it.
788
+ /** @param {unknown} declared */
789
+ const usable = (declared) => absent(declared) || typeof declared === "object";
790
+
791
+ // A declared style that names no number format of its own.
792
+ /** @param {Record<string, unknown> | null | undefined} declared */
793
+ const undated = (declared) => usable(declared) && absent(declared?.numberFormat);
794
+
795
+ /**
796
+ * A date with no format of its own gets the short-date built-in, so a `Date`
797
+ * reads back as a day rather than as the number underneath it.
798
+ * @param {unknown} value
799
+ * @param {Record<string, unknown> | null | undefined} declared
800
+ */
801
+ const cellStyle = (value, declared) =>
802
+ value instanceof Date && undated(declared) ? { ...declared, numberFormat: SHORT_DATE } : declared;
803
+
804
+ /** @param {number} frozen */
805
+ const paneXml = (frozen) =>
806
+ frozen === 0
807
+ ? '<sheetView workbookViewId="0"/>'
808
+ : `<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>`;
809
+
810
+ /**
811
+ * @typedef {{ top: number, left: number, bottom: number, right: number }} Range
812
+ */
813
+
814
+ /** @param {Range} range */
815
+ const rangeRef = (range) =>
816
+ `${letters(range.left)}${range.top}:${letters(range.right)}${range.bottom}`;
817
+
818
+ /** @param {Range} a @param {Range} b */
819
+ const overlaps = (a, b) =>
820
+ a.top <= b.bottom && a.bottom >= b.top && a.left <= b.right && a.right >= b.left;
821
+
822
+ /**
823
+ * A `<col>` per column that was given a width, in column order, and nothing
824
+ * at all when none was. One element apiece rather than a `min`/`max` run: a
825
+ * run may never span a column left unset, and the sparse case breaks that
826
+ * first, for bytes on one of the smallest parts here.
827
+ *
828
+ * `customWidth` is not optional. Without it a reader treats the width as one
829
+ * it derived, and is free to compute its own instead.
830
+ *
831
+ * @param {ReadonlyArray<number | null | undefined>} columns
832
+ */
833
+ const colsXml = (columns) => {
834
+ const body = columns
835
+ .map((width, index) =>
836
+ absent(width)
837
+ ? ""
838
+ : `<col min="${index + 1}" max="${index + 1}" width="${number(width)}" customWidth="1"/>`,
839
+ )
840
+ .join("");
841
+ return body === "" ? "" : `<cols>${body}</cols>`;
842
+ };
843
+
844
+ // One rule of a filter's range, stated the way a caller reads it.
845
+ /** @param {boolean} ok @param {string} why */
846
+ const requireFilterRule = (ok, why) => {
847
+ if (!ok) throw RangeError(`filter: ${why}`);
848
+ };
849
+
850
+ // A range may only cover cells that were written. `merge` and `filter` both
851
+ // ask it, of a row and of a column.
852
+ /** @param {number} at @param {number} written @param {string} what @param {string} where */
853
+ const requireWritten = (at, written, what, where) => {
854
+ if (at > written)
855
+ throw RangeError(`${where}: ${what} ${at} does not exist yet (the sheet has ${written})`);
856
+ };
857
+
858
+ /**
859
+ * The rectangle an autofilter covers, 1-based and inclusive on all four
860
+ * sides. A range whose bottom row was never written is a caller's bug, so
861
+ * this takes the row count to check it against.
862
+ *
863
+ * @param {unknown} range
864
+ * @param {number} rows how many rows the sheet holds
865
+ * @param {number} columns how many columns it has written
866
+ */
867
+ const requireFilter = (range, rows, columns) => {
868
+ const rect = requireRecord(range, "filter", "{ top, left, bottom, right }");
869
+ const top = requireIndex(rect.top, MAX_ROW, "filter: top");
870
+ const left = requireIndex(rect.left, MAX_COLUMN, "filter: left");
871
+ const bottom = requireIndex(rect.bottom, MAX_ROW, "filter: bottom");
872
+ const right = requireIndex(rect.right, MAX_COLUMN, "filter: right");
873
+ requireFilterRule(bottom >= top, `bottom ${bottom} is above top ${top}`);
874
+ requireFilterRule(right >= left, `right ${right} is left of left ${left}`);
875
+ requireWritten(bottom, rows, "row", "filter");
876
+ requireWritten(right, columns, "column", "filter");
877
+ return { top, left, bottom, right };
878
+ };
879
+
880
+ /** @param {ReadonlyArray<Range>} merges */
881
+ const mergesXml = (merges) =>
882
+ merges.length === 0
883
+ ? ""
884
+ : `<mergeCells count="${merges.length}">` +
885
+ merges.map((range) => `<mergeCell ref="${rangeRef(range)}"/>`).join("") +
886
+ `</mergeCells>`;
887
+
888
+ /**
889
+ * The last column a merge reaches, once its width is known to be a count of
890
+ * two or more that stays on the sheet.
891
+ * @param {unknown} width
892
+ * @param {number} at
893
+ */
894
+ const mergeEnd = (width, at) => {
895
+ if (!Number.isInteger(width) || /** @type {number} */ (width) < 2)
896
+ throw RangeError(`merge: expected a width of 2 or more, got ${JSON.stringify(width)}`);
897
+ const right = at + /** @type {number} */ (width) - 1;
898
+ if (right > MAX_COLUMN) throw RangeError(`merge: the range ends past column ${MAX_COLUMN}`);
899
+ return right;
900
+ };
901
+
902
+ /**
903
+ * One entry of a width list. Absent is how a caller leaves a column alone, so
904
+ * it is the one value that checks nothing.
905
+ *
906
+ * @param {unknown} width
907
+ * @param {number} index 0-based, so the message can name the column
908
+ */
909
+ const checkWidth = (width, index) => {
910
+ if (absent(width)) return;
911
+ const chars = requireNumber(width, `widths: column ${index + 1}`);
912
+ if (chars > 0 && chars <= MAX_WIDTH) return;
913
+ throw RangeError(
914
+ `widths: column ${index + 1} expected a width above 0 and at most ${MAX_WIDTH}, got ${chars}` +
915
+ ` -- null leaves a column unset`,
916
+ );
917
+ };
918
+
919
+ /** @param {unknown} margin */
920
+ const requireMargin = (margin) => {
921
+ const points = requireNumber(margin, "print: margin");
922
+ if (points < 0) throw RangeError(`print: margin cannot be negative, got ${points}`);
923
+ };
924
+
925
+ /** @param {unknown} size */
926
+ const requirePaper = (size) => {
927
+ if (!PAPER.has(/** @type {string} */ (size)))
662
928
  throw RangeError(
663
- `${where}: expected an integer between 1 and ${limit}, got ${JSON.stringify(value)}`,
929
+ `print: unknown paper size ${JSON.stringify(size)} -- known: ${[...PAPER.keys()].join(", ")}`,
664
930
  );
665
- return value;
666
931
  };
667
932
 
668
- // EMU per CSS pixel at 96 dpi.
669
- const EMU = 9525;
933
+ // Zero clears, as it does for a freeze, so a caller that set a count has a way
934
+ // back. Not `requireIndex`: its message names 1 as the floor, and here it is 0.
935
+ /** @param {unknown} rows */
936
+ const requireTitles = (rows) => {
937
+ if (rows === 0) return;
938
+ if (!isIndex(rows, MAX_ROW))
939
+ throw RangeError(
940
+ `print: titles expected 0, or a row count between 1 and ${MAX_ROW}, got ${JSON.stringify(rows)}`,
941
+ );
942
+ };
943
+
944
+ /** @param {unknown} orientation */
945
+ const requireOrientation = (orientation) => {
946
+ if (!ORIENTATION.has(/** @type {string} */ (orientation)))
947
+ throw RangeError(`print: expected portrait or landscape, got ${JSON.stringify(orientation)}`);
948
+ };
949
+
950
+ /** @param {number} width @param {number} height */
951
+ const requireSize = (width, height) => {
952
+ if (width <= 0 || height <= 0)
953
+ throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
954
+ };
955
+
956
+ // Every print key this surface checks, and the check it gets. Key order is
957
+ // the order a caller hears about a mistake. A table rather than a run of
958
+ // guards: a guard apiece puts `print` over the cyclomatic ceiling. `fit` is
959
+ // absent because only `true` writes anything.
960
+ const PRINT_CHECKS = {
961
+ margin: requireMargin,
962
+ size: requirePaper,
963
+ orientation: requireOrientation,
964
+ titles: requireTitles,
965
+ };
670
966
 
671
967
  /**
672
968
  * One worksheet. Rows accumulate in call order; everything else is bookkeeping
@@ -686,48 +982,34 @@ const worksheet = (name, styles, sst, knows) => {
686
982
  const pictures = [];
687
983
  let frozen = 0;
688
984
  let widest = 1;
985
+ // This sheet's `autoFilter` element, or the empty string for a sheet that
986
+ // asked for none. Rendered where it is set, the way a row is.
987
+ let filtered = "";
988
+ // Widths by position, the first entry being column A. Replaced wholesale by
989
+ // `widths`, never merged: a hole in a positional list cannot mean both
990
+ // "leave this one alone" and "clear it".
991
+ /** @type {ReadonlyArray<number | null | undefined>} */
992
+ let columns = [];
689
993
  // What `print` was told, or null. Nothing reaches the file until a caller
690
994
  // asks: a reader's own print defaults are better than this writer guessing.
691
- /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean } | null} */
995
+ /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} */
692
996
  let printing = null;
693
997
 
694
998
  /**
695
- * @param {unknown} value
696
- * @param {number} styleId
697
- * @param {string} ref
698
- * @param {string} where
999
+ * One cell of a row: the empty string for a gap, else its `<c>`.
1000
+ * @param {unknown} cell
1001
+ * @param {number} index 0-based column
1002
+ * @param {number} at 1-based row
699
1003
  */
700
- const cellXml = (value, styleId, ref, where) => {
701
- const s = styleId === 0 ? "" : ` s="${styleId}"`;
702
-
703
- if (value === undefined || value === null) return s === "" ? "" : `<c r="${ref}"${s}/>`;
704
-
705
- if (typeof value === "number") {
706
- requireNumber(value, `${where}.value`);
707
- return `<c r="${ref}"${s}><v>${number(value)}</v></c>`;
708
- }
709
-
710
- if (typeof value === "boolean") return `<c r="${ref}"${s} t="b"><v>${value ? 1 : 0}</v></c>`;
711
-
712
- if (value instanceof Date) {
713
- if (Number.isNaN(value.getTime()))
714
- throw RangeError(`${where}.value: expected a valid Date, got an invalid one`);
715
- return `<c r="${ref}"${s}><v>${number(serial(value))}</v></c>`;
716
- }
717
-
718
- if (typeof value === "string") {
719
- const at = sst.intern("s" + value, () => `<si>${textNode(value)}</si>`);
720
- return `<c r="${ref}"${s} t="s"><v>${at}</v></c>`;
721
- }
722
-
723
- if (Array.isArray(value)) {
724
- const at = sst.intern(richKey(value, `${where}.value`), () =>
725
- richXml(value, `${where}.value`),
726
- );
727
- return `<c r="${ref}"${s} t="s"><v>${at}</v></c>`;
728
- }
729
-
730
- throw TypeError(`${where}.value: unsupported cell value ${JSON.stringify(value)}`);
1004
+ const cellAt = (cell, index, at) => {
1005
+ if (absent(cell)) return "";
1006
+ if (typeof cell !== "object")
1007
+ throw TypeError(`row: cell ${index + 1} is not a { value, style } object`);
1008
+ const where = `row ${at}, cell ${index + 1}`;
1009
+ const { value, style } = /** @type {Record<string, unknown>} */ (cell);
1010
+ const declared = /** @type {Record<string, unknown> | null | undefined} */ (style);
1011
+ const styleId = styles.intern(cellStyle(value, declared), `${where}.style`);
1012
+ return cellXml(sst, value, styleId, letters(index + 1) + at, where);
731
1013
  };
732
1014
 
733
1015
  return {
@@ -742,35 +1024,8 @@ const worksheet = (name, styles, sst, knows) => {
742
1024
  throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
743
1025
 
744
1026
  let body = "";
745
- for (let index = 0; index < cells.length; index++) {
746
- const cell = cells[index];
747
- if (cell === undefined || cell === null) continue;
748
- if (typeof cell !== "object")
749
- throw TypeError(`row: cell ${index + 1} is not a { value, style } object`);
750
- const where = `row ${at}, cell ${index + 1}`;
751
- const declared = /** @type {Record<string, unknown> | null | undefined} */ (
752
- /** @type {Record<string, unknown>} */ (cell).style
753
- );
754
- // A date with no format of its own gets the short-date built-in, so a
755
- // `Date` reads back as a day rather than as the number underneath it.
756
- // A style that is not an object passes through untouched, so the
757
- // interning below is still the one place that refuses it.
758
- const usable = declared === null || declared === undefined || typeof declared === "object";
759
- const style =
760
- /** @type {Record<string, unknown>} */ (cell).value instanceof Date &&
761
- usable &&
762
- (declared?.numberFormat ?? null) === null
763
- ? { ...declared, numberFormat: SHORT_DATE }
764
- : /** @type {Record<string, unknown> | null | undefined} */ (declared);
765
- const styleId = styles.intern(style, `${where}.style`);
766
- body += cellXml(
767
- /** @type {Record<string, unknown>} */ (cell).value,
768
- styleId,
769
- letters(index + 1) + at,
770
- where,
771
- );
772
- }
773
- if (cells.length > widest) widest = cells.length;
1027
+ for (let index = 0; index < cells.length; index++) body += cellAt(cells[index], index, at);
1028
+ widest = Math.max(widest, cells.length);
774
1029
  rows.push(`<row r="${at}">${body}</row>`);
775
1030
  return at;
776
1031
  },
@@ -783,67 +1038,91 @@ const worksheet = (name, styles, sst, knows) => {
783
1038
  merge(row, at, width) {
784
1039
  requireIndex(row, MAX_ROW, "merge: row");
785
1040
  requireIndex(at, MAX_COLUMN, "merge: at");
786
- if (row > rows.length)
787
- throw RangeError(`merge: row ${row} does not exist yet (the sheet has ${rows.length})`);
788
- if (typeof width !== "number" || !Number.isInteger(width) || width < 2)
789
- throw RangeError(`merge: expected a width of 2 or more, got ${JSON.stringify(width)}`);
790
- const right = at + width - 1;
791
- if (right > MAX_COLUMN) throw RangeError(`merge: the range ends past column ${MAX_COLUMN}`);
792
- for (const other of merges)
793
- if (row >= other.top && row <= other.bottom && at <= other.right && right >= other.left)
794
- throw RangeError(
795
- `merge: the range ${letters(at)}${row}:${letters(right)}${row} overlaps ` +
796
- `${letters(other.left)}${other.top}:${letters(other.right)}${other.bottom}`,
797
- );
798
- merges.push({ top: row, left: at, bottom: row, right });
799
- if (right > widest) widest = right;
1041
+ requireWritten(row, rows.length, "row", "merge");
1042
+ const range = { top: row, left: at, bottom: row, right: mergeEnd(width, at) };
1043
+ const clash = merges.find((other) => overlaps(range, other));
1044
+ if (clash !== undefined)
1045
+ throw RangeError(`merge: the range ${rangeRef(range)} overlaps ${rangeRef(clash)}`);
1046
+ merges.push(range);
1047
+ widest = Math.max(widest, range.right);
800
1048
  },
801
1049
 
802
1050
  /**
803
1051
  * How this worksheet prints. Every key is optional, and a worksheet that
804
- * never calls this carries no print setup at all a reader's own defaults
805
- * are better than a guess, and writing one would put this writer's opinion
806
- * in every file.
1052
+ * never calls this carries no print setup at all: a reader's own defaults
1053
+ * beat a guess.
807
1054
  *
808
- * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean }} setup
1055
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number }} setup
809
1056
  */
810
1057
  print(setup) {
811
- if (setup === null || typeof setup !== "object")
812
- throw TypeError(`print: expected a setup object, got ${JSON.stringify(setup)}`);
813
- if (setup.margin !== undefined && requireNumber(setup.margin, "print: margin") < 0)
814
- throw RangeError(`print: margin cannot be negative, got ${setup.margin}`);
815
- if (setup.size !== undefined && !PAPER.has(setup.size))
816
- throw RangeError(
817
- `print: unknown paper size ${JSON.stringify(setup.size)} -- known: ${[...PAPER.keys()].join(", ")}`,
818
- );
819
- if (setup.orientation !== undefined && !ORIENTATION.has(setup.orientation))
820
- throw RangeError(
821
- `print: expected portrait or landscape, got ${JSON.stringify(setup.orientation)}`,
822
- );
1058
+ const declared = requireRecord(setup, "print", "a setup object");
1059
+ for (const [key, check] of Object.entries(PRINT_CHECKS))
1060
+ if (declared[key] !== undefined) check(declared[key]);
823
1061
  printing = { ...printing, ...setup };
824
1062
  },
825
1063
 
1064
+ // Read by the workbook part rather than by this sheet's own: a print title
1065
+ // is a defined name, and defined names live in `xl/workbook.xml`.
1066
+ get titles() {
1067
+ return printing?.titles ?? 0;
1068
+ },
1069
+
826
1070
  /** @param {number} count */
827
1071
  freeze(count) {
828
- if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count >= MAX_ROW)
1072
+ if (!Number.isInteger(count) || count < 0 || count >= MAX_ROW)
829
1073
  throw RangeError(`freeze: expected a row count of 0 or more, got ${JSON.stringify(count)}`);
830
1074
  frozen = count;
831
1075
  },
832
1076
 
1077
+ /**
1078
+ * Column widths by position, the first entry being column A. `null` leaves
1079
+ * a column unset, so a reader keeps its own default for it.
1080
+ *
1081
+ * Replaces rather than merges, and an empty list clears. A width never
1082
+ * widens the sheet: `dimension` describes the cells that were written, and
1083
+ * sizing a column writes no cell.
1084
+ *
1085
+ * @param {ReadonlyArray<unknown>} list
1086
+ */
1087
+ widths(list) {
1088
+ if (!Array.isArray(list))
1089
+ throw TypeError(`widths: expected an array of widths, got ${JSON.stringify(list)}`);
1090
+ if (list.length > MAX_COLUMN)
1091
+ throw RangeError(`widths: a sheet holds at most ${MAX_COLUMN} columns`);
1092
+ list.forEach(checkWidth);
1093
+ columns = list.slice();
1094
+ },
1095
+
1096
+ /**
1097
+ * Put an autofilter over a range, 1-based and inclusive. A worksheet takes
1098
+ * one, so this replaces rather than merges, and `null` clears it.
1099
+ *
1100
+ * @param {unknown} range
1101
+ */
1102
+ filter(range) {
1103
+ // Excel also records an autofilter as a sheet-scoped
1104
+ // `_xlnm._FilterDatabase` defined name; this writes none. Measured in
1105
+ // both readers: Excel opens such a file with no repair prompt and
1106
+ // reports the filter as on, and LibreOffice writes the name itself on
1107
+ // save. The name is a reader's bookkeeping.
1108
+ filtered =
1109
+ range === null
1110
+ ? ""
1111
+ : `<autoFilter ref="${rangeRef(requireFilter(range, rows.length, widest))}"/>`;
1112
+ },
1113
+
833
1114
  /**
834
1115
  * @param {unknown} id
835
1116
  * @param {{ row: number, col?: number, width: number, height: number }} at
836
1117
  */
837
1118
  place(id, at) {
838
1119
  if (!knows(id)) throw TypeError(`place: no image with id ${JSON.stringify(id)}`);
839
- if (at === null || typeof at !== "object")
840
- throw TypeError(`place: expected { row, col, width, height }, got ${JSON.stringify(at)}`);
1120
+ requireRecord(at, "place", "{ row, col, width, height }");
841
1121
  const row = requireIndex(at.row, MAX_ROW, "place: row");
842
1122
  const col = requireIndex(at.col === undefined ? 1 : at.col, MAX_COLUMN, "place: col");
843
1123
  const width = requireNumber(at.width, "place: width");
844
1124
  const height = requireNumber(at.height, "place: height");
845
- if (width <= 0 || height <= 0)
846
- throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
1125
+ requireSize(width, height);
847
1126
  pictures.push({ id: /** @type {number} */ (id), row, col, width, height });
848
1127
  },
849
1128
 
@@ -854,21 +1133,6 @@ const worksheet = (name, styles, sst, knows) => {
854
1133
  /** @param {number | null} drawing the relationship id of this sheet's drawing part */
855
1134
  xml(drawing) {
856
1135
  const dimension = `A1:${letters(widest)}${Math.max(rows.length, 1)}`;
857
- const pane =
858
- frozen === 0
859
- ? '<sheetView workbookViewId="0"/>'
860
- : `<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>`;
861
- const merged =
862
- merges.length === 0
863
- ? ""
864
- : `<mergeCells count="${merges.length}">` +
865
- merges
866
- .map(
867
- (range) =>
868
- `<mergeCell ref="${letters(range.left)}${range.top}:${letters(range.right)}${range.bottom}"/>`,
869
- )
870
- .join("") +
871
- `</mergeCells>`;
872
1136
 
873
1137
  // `fitToPage` lives on `sheetPr`, which the schema puts before every
874
1138
  // other child of a worksheet.
@@ -880,9 +1144,11 @@ const worksheet = (name, styles, sst, knows) => {
880
1144
  `<worksheet xmlns="${NS}" xmlns:r="${NS_R}">` +
881
1145
  properties +
882
1146
  `<dimension ref="${dimension}"/>` +
883
- `<sheetViews>${pane}</sheetViews>` +
1147
+ `<sheetViews>${paneXml(frozen)}</sheetViews>` +
1148
+ colsXml(columns) +
884
1149
  `<sheetData>${rows.join("")}</sheetData>` +
885
- merged +
1150
+ filtered +
1151
+ mergesXml(merges) +
886
1152
  printXml(printing) +
887
1153
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
888
1154
  `</worksheet>`
@@ -892,10 +1158,9 @@ const worksheet = (name, styles, sst, knows) => {
892
1158
  };
893
1159
 
894
1160
  /**
895
- * The drawing part for one sheet: a `oneCellAnchor` per placement, anchored to
896
- * the top-left of its cell at the size the caller asked for. The XML counts
897
- * rows and columns from zero and the subtraction happens here, so the surface
898
- * stays 1-based throughout.
1161
+ * The drawing part for one sheet: a `oneCellAnchor` per placement, anchored
1162
+ * to the top-left of its cell. The XML counts rows and columns from zero and
1163
+ * the subtraction happens here, so the surface stays 1-based throughout.
899
1164
  *
900
1165
  * @param {ReturnType<typeof worksheet>["pictures"]} pictures
901
1166
  * @param {ReadonlyArray<number>} media the media index each picture points at
@@ -935,26 +1200,214 @@ const FORMATS = new Map([
935
1200
  // "the same input" as "the same input at the same wall time".
936
1201
  const CREATED = "1970-01-01T00:00:00Z";
937
1202
 
938
- /** @param {Uint8Array} a @param {Uint8Array} b */
939
- const same = (a, b) => {
940
- // The same array twice is the common case, and the one the byte loop is
941
- // worst at: equal bytes never exit early, so it reads the whole image every
942
- // time. A caller placing one logo on a thousand rows pays 54 ms for that,
943
- // and nothing for this.
944
- if (a === b) return true;
945
- if (a.length !== b.length) return false;
1203
+ /** @param {Uint8Array} a @param {Uint8Array} b of the same length */
1204
+ const sameBytes = (a, b) => {
946
1205
  for (let at = 0; at < a.length; at++) if (a[at] !== b[at]) return false;
947
1206
  return true;
948
1207
  };
949
1208
 
1209
+ // The same array twice is the common case, and the one the byte loop is worst
1210
+ // at: equal bytes never exit early, so it reads the whole image every time. A
1211
+ // caller placing one logo on a thousand rows pays 54 ms for that, and nothing
1212
+ // for the identity check.
1213
+ /** @param {Uint8Array} a @param {Uint8Array} b */
1214
+ const same = (a, b) => a === b || (a.length === b.length && sameBytes(a, b));
1215
+
1216
+ /**
1217
+ * @param {unknown} name
1218
+ * @returns {string}
1219
+ */
1220
+ const requireSheetName = (name) => {
1221
+ if (typeof name !== "string" || name === "")
1222
+ throw RangeError(`sheet: expected a name, got ${JSON.stringify(name)}`);
1223
+ if (name.length > NAME_LIMIT)
1224
+ throw RangeError(`sheet: a name is at most ${NAME_LIMIT} characters, got ${name.length}`);
1225
+ return name;
1226
+ };
1227
+
1228
+ /**
1229
+ * @param {number} sheets how many worksheets the workbook holds
1230
+ * @param {number} drawings how many of them carry a drawing part
1231
+ * @param {ReadonlyArray<{ format: string }>} media
1232
+ */
1233
+ const contentTypesXml = (sheets, drawings, media) => {
1234
+ const overrides = Array.from(
1235
+ { length: sheets },
1236
+ (_sheet, index) =>
1237
+ `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="${CT}.spreadsheetml.worksheet+xml"/>`,
1238
+ ).join("");
1239
+ const drawingOverrides = Array.from(
1240
+ { length: drawings },
1241
+ (_each, at) =>
1242
+ `<Override PartName="/xl/drawings/drawing${at + 1}.xml" ContentType="${CT}.drawing+xml"/>`,
1243
+ ).join("");
1244
+ const defaults = [...new Set(media.map((each) => each.format))]
1245
+ .map((format) => `<Default Extension="${format}" ContentType="${FORMATS.get(format)}"/>`)
1246
+ .join("");
1247
+
1248
+ return (
1249
+ DECLARATION +
1250
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
1251
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
1252
+ `<Default Extension="xml" ContentType="application/xml"/>` +
1253
+ defaults +
1254
+ `<Override PartName="/xl/workbook.xml" ContentType="${CT}.spreadsheetml.sheet.main+xml"/>` +
1255
+ overrides +
1256
+ `<Override PartName="/xl/styles.xml" ContentType="${CT}.spreadsheetml.styles+xml"/>` +
1257
+ `<Override PartName="/xl/sharedStrings.xml" ContentType="${CT}.spreadsheetml.sharedStrings+xml"/>` +
1258
+ drawingOverrides +
1259
+ `<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>` +
1260
+ `<Override PartName="/docProps/app.xml" ContentType="${CT}.extended-properties+xml"/>` +
1261
+ `</Types>`
1262
+ );
1263
+ };
1264
+
1265
+ const ROOT_RELS =
1266
+ DECLARATION +
1267
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1268
+ `<Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/>` +
1269
+ `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>` +
1270
+ `<Relationship Id="rId3" Type="${REL}/extended-properties" Target="docProps/app.xml"/>` +
1271
+ `</Relationships>`;
1272
+
1273
+ const APP_XML =
1274
+ DECLARATION +
1275
+ `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>werkmap</Application></Properties>`;
1276
+
1277
+ /** @param {string} name @param {string | undefined} value */
1278
+ const tag = (name, value) => (value === undefined ? "" : `<${name}>${esc(value)}</${name}>`);
1279
+
1280
+ /**
1281
+ * @param {{ title?: string, creator?: string, subject?: string, description?: string }} meta
1282
+ */
1283
+ const coreXml = (meta) =>
1284
+ DECLARATION +
1285
+ `<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">` +
1286
+ tag("dc:title", meta.title) +
1287
+ tag("dc:subject", meta.subject) +
1288
+ tag("dc:creator", meta.creator) +
1289
+ tag("dc:description", meta.description) +
1290
+ `<dcterms:created xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:created>` +
1291
+ `<dcterms:modified xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:modified>` +
1292
+ `</cp:coreProperties>`;
1293
+
1294
+ // A sheet reference inside a defined name is always quoted, so a name holding
1295
+ // a space or a symbol needs no special case. An apostrophe in the name closes
1296
+ // the quote early unless it doubles, and the whole reference is XML-escaped
1297
+ // after that like every other name this writer emits.
1298
+ /** @param {string} name @param {number} rows */
1299
+ const titlesRef = (name, rows) => `${esc(`'${name.replace(/'/g, "''")}'`)}!$1:$${rows}`;
1300
+
1301
+ /**
1302
+ * The built-in name that repeats the top rows of a sheet on every printed
1303
+ * page. `localSheetId`, the sheet's 0-based position in `<sheets>`, is what
1304
+ * scopes the name to that worksheet rather than to the whole workbook.
1305
+ *
1306
+ * @param {ReadonlyArray<{ name: string, titles: number }>} sheets
1307
+ */
1308
+ const definedNamesXml = (sheets) => {
1309
+ const names = sheets
1310
+ .map((sheet, index) =>
1311
+ sheet.titles === 0
1312
+ ? ""
1313
+ : `<definedName name="_xlnm.Print_Titles" localSheetId="${index}">${titlesRef(sheet.name, sheet.titles)}</definedName>`,
1314
+ )
1315
+ .join("");
1316
+ return names === "" ? "" : `<definedNames>${names}</definedNames>`;
1317
+ };
1318
+
1319
+ /** @param {ReadonlyArray<{ name: string, titles: number }>} sheets */
1320
+ const workbookXml = (sheets) =>
1321
+ DECLARATION +
1322
+ `<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` +
1323
+ sheets
1324
+ .map(
1325
+ (sheet, index) =>
1326
+ `<sheet name="${esc(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`,
1327
+ )
1328
+ .join("") +
1329
+ `</sheets>` +
1330
+ definedNamesXml(sheets) +
1331
+ `</workbook>`;
1332
+
1333
+ /** @param {number} sheets how many worksheets the workbook holds */
1334
+ const workbookRelsXml = (sheets) =>
1335
+ DECLARATION +
1336
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1337
+ Array.from(
1338
+ { length: sheets },
1339
+ (_sheet, index) =>
1340
+ `<Relationship Id="rId${index + 1}" Type="${REL}/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`,
1341
+ ).join("") +
1342
+ `<Relationship Id="rId${sheets + 1}" Type="${REL}/styles" Target="styles.xml"/>` +
1343
+ `<Relationship Id="rId${sheets + 2}" Type="${REL}/sharedStrings" Target="sharedStrings.xml"/>` +
1344
+ `</Relationships>`;
1345
+
1346
+ /**
1347
+ * @typedef {(name: string, body: string | Uint8Array, store?: boolean) => void} Part
1348
+ * @typedef {{ sheet: ReturnType<typeof worksheet>, index: number }} Drawing
1349
+ */
1350
+
1351
+ /**
1352
+ * Every worksheet part, and the relationship part of each sheet that carries
1353
+ * a drawing.
1354
+ * @param {Part} part
1355
+ * @param {ReadonlyArray<ReturnType<typeof worksheet>>} sheets
1356
+ * @param {ReadonlyArray<Drawing>} drawings
1357
+ */
1358
+ const sheetParts = (part, sheets, drawings) => {
1359
+ for (const [index, sheet] of sheets.entries()) {
1360
+ const at = drawings.findIndex((each) => each.index === index);
1361
+ part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
1362
+ if (at !== -1)
1363
+ part(
1364
+ `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
1365
+ DECLARATION +
1366
+ `<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
1367
+ );
1368
+ }
1369
+ };
1370
+
1371
+ /**
1372
+ * Each drawing part and its relationships. A drawing numbers its own
1373
+ * relationships from one, in the order its pictures were placed, so they line
1374
+ * up with its own part rather than with the workbook-wide media list.
1375
+ * @param {Part} part
1376
+ * @param {ReadonlyArray<Drawing>} drawings
1377
+ * @param {ReadonlyArray<{ format: string }>} media
1378
+ */
1379
+ const drawingParts = (part, drawings, media) => {
1380
+ for (const [at, each] of drawings.entries()) {
1381
+ const used = [...new Set(each.sheet.pictures.map((picture) => picture.id))];
1382
+ part(
1383
+ `xl/drawings/drawing${at + 1}.xml`,
1384
+ drawingXml(
1385
+ each.sheet.pictures,
1386
+ each.sheet.pictures.map((picture) => used.indexOf(picture.id)),
1387
+ ),
1388
+ );
1389
+ part(
1390
+ `xl/drawings/_rels/drawing${at + 1}.xml.rels`,
1391
+ DECLARATION +
1392
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1393
+ used
1394
+ .map(
1395
+ (id, index) =>
1396
+ `<Relationship Id="rId${index + 1}" Type="${REL}/image" Target="../media/image${id + 1}.${media[id].format}"/>`,
1397
+ )
1398
+ .join("") +
1399
+ `</Relationships>`,
1400
+ );
1401
+ }
1402
+ };
1403
+
950
1404
  /**
951
1405
  * Open a workbook.
952
1406
  *
953
1407
  * @param {{ title?: string, creator?: string, subject?: string, description?: string }} [meta]
954
1408
  */
955
1409
  export const workbook = (meta = {}) => {
956
- if (meta === null || typeof meta !== "object")
957
- throw TypeError(`workbook: expected a metadata object, got ${JSON.stringify(meta)}`);
1410
+ requireRecord(meta, "workbook", "a metadata object");
958
1411
 
959
1412
  const styles = stylesheet();
960
1413
  const sst = strings();
@@ -972,10 +1425,18 @@ export const workbook = (meta = {}) => {
972
1425
  return value;
973
1426
  };
974
1427
 
975
- const title = property("title");
976
- const creator = property("creator");
977
- const subject = property("subject");
978
- const description = property("description");
1428
+ const properties = {
1429
+ title: property("title"),
1430
+ creator: property("creator"),
1431
+ subject: property("subject"),
1432
+ description: property("description"),
1433
+ };
1434
+
1435
+ /** @param {unknown} id */
1436
+ const knows = (id) =>
1437
+ Number.isInteger(id) &&
1438
+ /** @type {number} */ (id) >= 0 &&
1439
+ /** @type {number} */ (id) < media.length;
979
1440
 
980
1441
  return {
981
1442
  /**
@@ -989,30 +1450,22 @@ export const workbook = (meta = {}) => {
989
1450
  throw TypeError(`image: expected png or jpeg, got ${JSON.stringify(format)}`);
990
1451
  // Identical bytes deduplicate: a caller embedding the same logo on every
991
1452
  // sheet pays for it once, and does so without keeping a tally of its own.
992
- for (let at = 0; at < media.length; at++)
993
- if (media[at].format === format && same(media[at].bytes, bytes)) return at;
1453
+ const seen = media.findIndex((each) => each.format === format && same(each.bytes, bytes));
1454
+ if (seen !== -1) return seen;
994
1455
  media.push({ bytes, format });
995
1456
  return media.length - 1;
996
1457
  },
997
1458
 
998
1459
  /** @param {string} name */
999
1460
  sheet(name) {
1000
- if (typeof name !== "string" || name === "")
1001
- throw RangeError(`sheet: expected a name, got ${JSON.stringify(name)}`);
1002
- if (name.length > NAME_LIMIT)
1003
- throw RangeError(`sheet: a name is at most ${NAME_LIMIT} characters, got ${name.length}`);
1461
+ requireSheetName(name);
1004
1462
  if (FORBIDDEN_IN_NAME.test(name))
1005
1463
  throw RangeError(
1006
1464
  `sheet: a name cannot contain : \\ / ? * [ ], got ${JSON.stringify(name)}`,
1007
1465
  );
1008
1466
  if (sheets.some((each) => each.name === name))
1009
1467
  throw RangeError(`sheet: a sheet named ${JSON.stringify(name)} already exists`);
1010
- const made = worksheet(
1011
- name,
1012
- styles,
1013
- sst,
1014
- (id) => typeof id === "number" && Number.isInteger(id) && id >= 0 && id < media.length,
1015
- );
1468
+ const made = worksheet(name, styles, sst, knows);
1016
1469
  sheets.push(made);
1017
1470
  return made;
1018
1471
  },
@@ -1022,11 +1475,7 @@ export const workbook = (meta = {}) => {
1022
1475
 
1023
1476
  /** @type {Array<{ name: string, body: Uint8Array, store?: boolean }>} */
1024
1477
  const entries = [];
1025
- /**
1026
- * @param {string} name
1027
- * @param {string | Uint8Array} body
1028
- * @param {boolean} [store]
1029
- */
1478
+ /** @type {Part} */
1030
1479
  const part = (name, body, store) =>
1031
1480
  entries.push({
1032
1481
  name,
@@ -1034,144 +1483,22 @@ export const workbook = (meta = {}) => {
1034
1483
  store,
1035
1484
  });
1036
1485
 
1037
- // Which media each sheet's drawing references, and in what order, so a
1038
- // drawing's relationship ids line up with its own part rather than with
1039
- // the workbook-wide media list.
1486
+ // The sheets that carry a drawing part, in sheet order: drawing parts
1487
+ // are numbered by this list, not by sheet index.
1040
1488
  const drawings = sheets
1041
1489
  .map((sheet, index) => ({ sheet, index }))
1042
1490
  .filter((each) => each.sheet.pictures.length > 0);
1043
1491
 
1044
- const overrides = sheets
1045
- .map(
1046
- (_sheet, index) =>
1047
- `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="${CT}.spreadsheetml.worksheet+xml"/>`,
1048
- )
1049
- .join("");
1050
- const drawingOverrides = drawings
1051
- .map(
1052
- (_each, at) =>
1053
- `<Override PartName="/xl/drawings/drawing${at + 1}.xml" ContentType="${CT}.drawing+xml"/>`,
1054
- )
1055
- .join("");
1056
- const defaults = [...new Set(media.map((each) => each.format))]
1057
- .map((format) => `<Default Extension="${format}" ContentType="${FORMATS.get(format)}"/>`)
1058
- .join("");
1059
-
1060
- part(
1061
- "[Content_Types].xml",
1062
- DECLARATION +
1063
- `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
1064
- `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
1065
- `<Default Extension="xml" ContentType="application/xml"/>` +
1066
- defaults +
1067
- `<Override PartName="/xl/workbook.xml" ContentType="${CT}.spreadsheetml.sheet.main+xml"/>` +
1068
- overrides +
1069
- `<Override PartName="/xl/styles.xml" ContentType="${CT}.spreadsheetml.styles+xml"/>` +
1070
- `<Override PartName="/xl/sharedStrings.xml" ContentType="${CT}.spreadsheetml.sharedStrings+xml"/>` +
1071
- drawingOverrides +
1072
- `<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>` +
1073
- `<Override PartName="/docProps/app.xml" ContentType="${CT}.extended-properties+xml"/>` +
1074
- `</Types>`,
1075
- );
1076
-
1077
- part(
1078
- "_rels/.rels",
1079
- DECLARATION +
1080
- `<Relationships xmlns="${NS_PKG_REL}">` +
1081
- `<Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/>` +
1082
- `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>` +
1083
- `<Relationship Id="rId3" Type="${REL}/extended-properties" Target="docProps/app.xml"/>` +
1084
- `</Relationships>`,
1085
- );
1086
-
1087
- /** @param {string} name @param {string | undefined} value */
1088
- const tag = (name, value) => (value === undefined ? "" : `<${name}>${esc(value)}</${name}>`);
1089
- part(
1090
- "docProps/core.xml",
1091
- DECLARATION +
1092
- `<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">` +
1093
- tag("dc:title", title) +
1094
- tag("dc:subject", subject) +
1095
- tag("dc:creator", creator) +
1096
- tag("dc:description", description) +
1097
- `<dcterms:created xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:created>` +
1098
- `<dcterms:modified xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:modified>` +
1099
- `</cp:coreProperties>`,
1100
- );
1101
-
1102
- part(
1103
- "docProps/app.xml",
1104
- DECLARATION +
1105
- `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>werkmap</Application></Properties>`,
1106
- );
1107
-
1108
- part(
1109
- "xl/workbook.xml",
1110
- DECLARATION +
1111
- `<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` +
1112
- sheets
1113
- .map(
1114
- (sheet, index) =>
1115
- `<sheet name="${esc(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`,
1116
- )
1117
- .join("") +
1118
- `</sheets></workbook>`,
1119
- );
1120
-
1121
- part(
1122
- "xl/_rels/workbook.xml.rels",
1123
- DECLARATION +
1124
- `<Relationships xmlns="${NS_PKG_REL}">` +
1125
- sheets
1126
- .map(
1127
- (_sheet, index) =>
1128
- `<Relationship Id="rId${index + 1}" Type="${REL}/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`,
1129
- )
1130
- .join("") +
1131
- `<Relationship Id="rId${sheets.length + 1}" Type="${REL}/styles" Target="styles.xml"/>` +
1132
- `<Relationship Id="rId${sheets.length + 2}" Type="${REL}/sharedStrings" Target="sharedStrings.xml"/>` +
1133
- `</Relationships>`,
1134
- );
1135
-
1136
- for (const [index, sheet] of sheets.entries()) {
1137
- const at = drawings.findIndex((each) => each.index === index);
1138
- part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
1139
- if (at !== -1)
1140
- part(
1141
- `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
1142
- DECLARATION +
1143
- `<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
1144
- );
1145
- }
1146
-
1492
+ part("[Content_Types].xml", contentTypesXml(sheets.length, drawings.length, media));
1493
+ part("_rels/.rels", ROOT_RELS);
1494
+ part("docProps/core.xml", coreXml(properties));
1495
+ part("docProps/app.xml", APP_XML);
1496
+ part("xl/workbook.xml", workbookXml(sheets));
1497
+ part("xl/_rels/workbook.xml.rels", workbookRelsXml(sheets.length));
1498
+ sheetParts(part, sheets, drawings);
1147
1499
  part("xl/styles.xml", styles.xml());
1148
1500
  part("xl/sharedStrings.xml", sst.xml());
1149
-
1150
- for (const [at, each] of drawings.entries()) {
1151
- // Each drawing numbers its own relationships from one, in the order
1152
- // its pictures were placed.
1153
- const used = [...new Set(each.sheet.pictures.map((picture) => picture.id))];
1154
- part(
1155
- `xl/drawings/drawing${at + 1}.xml`,
1156
- drawingXml(
1157
- each.sheet.pictures,
1158
- each.sheet.pictures.map((picture) => used.indexOf(picture.id)),
1159
- ),
1160
- );
1161
- part(
1162
- `xl/drawings/_rels/drawing${at + 1}.xml.rels`,
1163
- DECLARATION +
1164
- `<Relationships xmlns="${NS_PKG_REL}">` +
1165
- used
1166
- .map(
1167
- (id, index) =>
1168
- `<Relationship Id="rId${index + 1}" Type="${REL}/image" Target="../media/image${id + 1}.${media[id].format}"/>`,
1169
- )
1170
- .join("") +
1171
- `</Relationships>`,
1172
- );
1173
- }
1174
-
1501
+ drawingParts(part, drawings, media);
1175
1502
  for (const [index, each] of media.entries())
1176
1503
  part(`xl/media/image${index + 1}.${each.format}`, each.bytes, true);
1177
1504