werkmap 0.2.0 → 0.3.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
@@ -57,6 +57,66 @@ const DOS_DATE = (1 << 5) | 1;
57
57
  const STORED = 0;
58
58
  const DEFLATED = 8;
59
59
 
60
+ /**
61
+ * @typedef {{ method: number, sum: number, packed: Uint8Array, raw: Uint8Array, name: Uint8Array }} Packed
62
+ */
63
+
64
+ /** @param {Packed} entry */
65
+ const localHeader = ({ method, sum, packed, raw, name }) => {
66
+ const head = new DataView(new ArrayBuffer(30));
67
+ head.setUint32(0, 0x04034b50, true);
68
+ head.setUint16(4, 20, true);
69
+ head.setUint16(6, 0x0800, true); // the name is UTF-8
70
+ head.setUint16(8, method, true);
71
+ head.setUint16(10, DOS_TIME, true);
72
+ head.setUint16(12, DOS_DATE, true);
73
+ head.setUint32(14, sum, true);
74
+ head.setUint32(18, packed.length, true);
75
+ head.setUint32(22, raw.length, true);
76
+ head.setUint16(26, name.length, true);
77
+ return new Uint8Array(head.buffer);
78
+ };
79
+
80
+ /** @param {Packed} entry @param {number} offset */
81
+ const centralRecord = ({ method, sum, packed, raw, name }, offset) => {
82
+ const record = new DataView(new ArrayBuffer(46));
83
+ record.setUint32(0, 0x02014b50, true);
84
+ record.setUint16(4, 20, true);
85
+ record.setUint16(6, 20, true);
86
+ record.setUint16(8, 0x0800, true);
87
+ record.setUint16(10, method, true);
88
+ record.setUint16(12, DOS_TIME, true);
89
+ record.setUint16(14, DOS_DATE, true);
90
+ record.setUint32(16, sum, true);
91
+ record.setUint32(20, packed.length, true);
92
+ record.setUint32(24, raw.length, true);
93
+ record.setUint16(28, name.length, true);
94
+ record.setUint32(42, offset, true);
95
+ return new Uint8Array(record.buffer);
96
+ };
97
+
98
+ /** @param {number} count @param {number} size @param {number} offset */
99
+ const endRecord = (count, size, offset) => {
100
+ const end = new DataView(new ArrayBuffer(22));
101
+ end.setUint32(0, 0x06054b50, true);
102
+ end.setUint16(8, count, true);
103
+ end.setUint16(10, count, true);
104
+ end.setUint32(12, size, true);
105
+ end.setUint32(16, offset, true);
106
+ return new Uint8Array(end.buffer);
107
+ };
108
+
109
+ /** @param {ReadonlyArray<Uint8Array>} chunks */
110
+ const concat = (chunks) => {
111
+ const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
112
+ let at = 0;
113
+ for (const chunk of chunks) {
114
+ bytes.set(chunk, at);
115
+ at += chunk.length;
116
+ }
117
+ return bytes;
118
+ };
119
+
60
120
  /**
61
121
  * @param {ReadonlyArray<{ name: string, body: Uint8Array, store?: boolean }>} entries
62
122
  * @returns {Promise<Uint8Array>}
@@ -68,58 +128,16 @@ const zip = async (entries) => {
68
128
 
69
129
  for (const entry of entries) {
70
130
  const raw = entry.body;
71
- const packed = entry.store ? raw : await deflate(raw);
72
- const method = entry.store ? STORED : DEFLATED;
131
+ const [packed, method] = entry.store ? [raw, STORED] : [await deflate(raw), DEFLATED];
73
132
  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
-
133
+ const item = { method, sum: crc32(raw), packed, raw, name };
134
+ local.push(localHeader(item), name, packed);
135
+ central.push(centralRecord(item, offset), name);
104
136
  offset += 30 + name.length + packed.length;
105
137
  }
106
138
 
107
139
  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;
140
+ return concat([...local, ...central, endRecord(entries.length, size, offset)]);
123
141
  };
124
142
 
125
143
  // ---------------------------------------------------------------- XML ----
@@ -163,6 +181,70 @@ const esc = (text) =>
163
181
  /** @param {string} text */
164
182
  const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
165
183
 
184
+ // ---------------------------------------------------------- validation ----
185
+
186
+ // Absent means the caller said nothing: `undefined` and `null` read the same
187
+ // everywhere on this surface.
188
+ /** @param {unknown} value */
189
+ const absent = (value) => value === undefined || value === null;
190
+
191
+ /**
192
+ * @param {unknown} value
193
+ * @param {string} where
194
+ * @param {string} expected what the message names when the value is not an object
195
+ * @returns {Record<string, unknown>}
196
+ */
197
+ const requireRecord = (value, where, expected) => {
198
+ if (value === null || typeof value !== "object")
199
+ throw TypeError(`${where}: expected ${expected}, got ${JSON.stringify(value)}`);
200
+ return /** @type {Record<string, unknown>} */ (value);
201
+ };
202
+
203
+ /**
204
+ * @param {unknown} value
205
+ * @param {string} where
206
+ */
207
+ const requireString = (value, where) => {
208
+ if (typeof value !== "string")
209
+ throw TypeError(`${where}: expected a string, got ${JSON.stringify(value)}`);
210
+ return value;
211
+ };
212
+
213
+ /**
214
+ * @param {unknown} value
215
+ * @param {string} where
216
+ * @param {string} fallback
217
+ */
218
+ const requireText = (value, where, fallback) =>
219
+ value === undefined ? fallback : requireString(value, where);
220
+
221
+ /**
222
+ * @param {unknown} value
223
+ * @param {string} where
224
+ */
225
+ const requireNumber = (value, where) => {
226
+ if (typeof value !== "number" || !Number.isFinite(value))
227
+ throw RangeError(`${where}: expected a finite number, got ${JSON.stringify(value)}`);
228
+ return value;
229
+ };
230
+
231
+ /** @param {unknown} value @param {number} limit */
232
+ const isIndex = (value, limit) =>
233
+ typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= limit;
234
+
235
+ /**
236
+ * @param {unknown} value
237
+ * @param {number} limit
238
+ * @param {string} where
239
+ */
240
+ const requireIndex = (value, limit, where) => {
241
+ if (!isIndex(value, limit))
242
+ throw RangeError(
243
+ `${where}: expected an integer between 1 and ${limit}, got ${JSON.stringify(value)}`,
244
+ );
245
+ return /** @type {number} */ (value);
246
+ };
247
+
166
248
  /**
167
249
  * One run of rich text, written in full: an OOXML run inherits nothing from
168
250
  * the cell font.
@@ -170,17 +252,12 @@ const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
170
252
  * @param {string} at
171
253
  */
172
254
  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>`;
255
+ const { text, font } = requireRecord(run, at, "a { text, font } run");
256
+ const body = textNode(requireString(text, `${at}.text`));
257
+ const properties = absent(font)
258
+ ? ""
259
+ : `<rPr>${runFontXml(/** @type {Record<string, unknown>} */ (font), `${at}.font`)}</rPr>`;
260
+ return `<r>${properties}${body}</r>`;
184
261
  };
185
262
 
186
263
  /**
@@ -336,6 +413,15 @@ const table = (initial) => {
336
413
 
337
414
  // ------------------------------------------------------------- styles ----
338
415
 
416
+ // The boolean half of a font, in the order `CT_Font` writes them.
417
+ /** @type {ReadonlyArray<[string, string]>} */
418
+ const FONT_FLAGS = [
419
+ ["bold", "<b/>"],
420
+ ["italic", "<i/>"],
421
+ ["strikethrough", "<strike/>"],
422
+ ["underline", "<u/>"],
423
+ ];
424
+
339
425
  /**
340
426
  * The font half of a style, written in the SDK's `CT_Font` sequence:
341
427
  * b i strike u sz color name.
@@ -343,14 +429,14 @@ const table = (initial) => {
343
429
  * @param {string} where
344
430
  */
345
431
  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"))}"/>`;
432
+ const flags = FONT_FLAGS.filter(([key]) => font[key])
433
+ .map(([, tag]) => tag)
434
+ .join("");
435
+ const size = font.size === undefined ? 11 : requireNumber(font.size, `${where}.size`);
436
+ const colour =
437
+ font.color === undefined ? "" : `<color rgb="${rgb(font.color, `${where}.color`)}"/>`;
438
+ const name = esc(requireText(font.name, `${where}.name`, "Calibri"));
439
+ return `${flags}<sz val="${size}"/>${colour}<name val="${name}"/>`;
354
440
  };
355
441
 
356
442
  /**
@@ -368,93 +454,92 @@ const SIDES = ["left", "right", "top", "bottom"];
368
454
  * @param {Record<string, unknown>} border
369
455
  * @param {string} where
370
456
  */
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/>";
457
+ /**
458
+ * @param {unknown} style
459
+ * @param {string} at
460
+ */
461
+ const edgeStyle = (style, at) => {
462
+ if (typeof style !== "string" || !BORDER_STYLES.has(style))
463
+ throw RangeError(`${at}.style: expected thin, dashed or dotted, got ${JSON.stringify(style)}`);
464
+ return style;
392
465
  };
393
466
 
394
- const HORIZONTAL = new Set(["left", "center", "right", "justify", "fill"]);
395
- const VERTICAL = new Set(["top", "center", "bottom", "justify"]);
467
+ /**
468
+ * One side of a border. An absent side is written empty rather than skipped,
469
+ * because `CT_Border` lists all four in a fixed order.
470
+ * @param {string} side
471
+ * @param {unknown} edge
472
+ * @param {string} at
473
+ */
474
+ const sideXml = (side, edge, at) => {
475
+ if (absent(edge)) return `<${side}/>`;
476
+ const style = edgeStyle(/** @type {Record<string, unknown>} */ (edge).style, at);
477
+ const colour = /** @type {Record<string, unknown>} */ (edge).color;
478
+ return colour === undefined
479
+ ? `<${side} style="${style}"/>`
480
+ : `<${side} style="${style}"><color rgb="${rgb(colour, `${at}.color`)}"/></${side}>`;
481
+ };
396
482
 
397
483
  /**
398
- * @param {Record<string, unknown>} alignment
484
+ * @param {Record<string, unknown>} border
399
485
  * @param {string} where
400
486
  */
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
- };
487
+ const borderXml = (border, where) =>
488
+ SIDES.map((side) => sideXml(side, border[side], `${where}.${side}`)).join("") + "<diagonal/>";
489
+
490
+ const HORIZONTAL = new Set(["left", "center", "right", "justify", "fill"]);
491
+ const VERTICAL = new Set(["top", "center", "bottom", "justify"]);
418
492
 
419
493
  /**
494
+ * One alignment axis as its attribute, or nothing when the caller left it out.
420
495
  * @param {unknown} value
496
+ * @param {ReadonlySet<string>} known
497
+ * @param {string} axis
421
498
  * @param {string} where
422
- * @param {string} fallback
423
499
  */
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;
500
+ const axisXml = (value, known, axis, where) => {
501
+ if (value === undefined) return "";
502
+ const named = /** @type {string} */ (value);
503
+ if (!known.has(named))
504
+ throw RangeError(`${where}.${axis}: unknown alignment ${JSON.stringify(named)}`);
505
+ return ` ${axis}="${named}"`;
429
506
  };
430
507
 
431
508
  /**
432
- * @param {unknown} value
509
+ * @param {Record<string, unknown>} alignment
433
510
  * @param {string} where
434
511
  */
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
- };
512
+ const alignmentXml = (alignment, where) =>
513
+ axisXml(alignment.horizontal, HORIZONTAL, "horizontal", where) +
514
+ axisXml(alignment.vertical, VERTICAL, "vertical", where) +
515
+ (alignment.wrapText ? ' wrapText="1"' : "");
440
516
 
441
517
  /**
442
518
  * @typedef {{ numFmtId: number, fontId: number, fillId: number, borderId: number, alignment: string }} Xf
443
519
  */
444
520
 
521
+ // Which `apply*` flag each table id switches on, in the order `CT_Xf` lists
522
+ // them. Alignment is not a table, so it is written apart.
523
+ /** @type {ReadonlyArray<["numFmtId" | "fontId" | "fillId" | "borderId", string]>} */
524
+ const APPLIES = [
525
+ ["numFmtId", "applyNumberFormat"],
526
+ ["fontId", "applyFont"],
527
+ ["fillId", "applyFill"],
528
+ ["borderId", "applyBorder"],
529
+ ];
530
+
445
531
  /**
446
532
  * One `xf`. Each `apply*` flag says which table this format actually reaches
447
533
  * into; a reader ignores a table entry the flag does not claim.
448
534
  * @param {Xf} xf
449
535
  */
450
536
  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>`;
537
+ const ids = `numFmtId="${xf.numFmtId}" fontId="${xf.fontId}" fillId="${xf.fillId}" borderId="${xf.borderId}"`;
538
+ const applies = APPLIES.filter(([key]) => xf[key] !== 0)
539
+ .map(([, flag]) => ` ${flag}="1"`)
540
+ .join("");
541
+ if (xf.alignment === "") return `<xf ${ids} xfId="0"${applies}/>`;
542
+ return `<xf ${ids} xfId="0"${applies} applyAlignment="1"><alignment${xf.alignment}/></xf>`;
458
543
  };
459
544
 
460
545
  /**
@@ -483,11 +568,14 @@ const stylesheet = () => {
483
568
  * @param {(value: Record<string, unknown>) => [string, () => T]} describe
484
569
  */
485
570
  const optional = (part, into, describe) => {
486
- if (part === undefined || part === null) return 0;
571
+ if (absent(part)) return 0;
487
572
  const [key, make] = describe(/** @type {Record<string, unknown>} */ (part));
488
573
  return into.intern(key, make);
489
574
  };
490
575
 
576
+ /** @param {string} code */
577
+ const knownFormat = (code) => BUILT_IN.get(code) ?? numFmts.get(code);
578
+
491
579
  /**
492
580
  * A format code a reader knows by id keeps that id; anything else is
493
581
  * interned from 164 up, in first-seen order.
@@ -495,12 +583,10 @@ const stylesheet = () => {
495
583
  * @param {string} where
496
584
  */
497
585
  const formatId = (code, where) => {
498
- if (code === undefined || code === null) return 0;
586
+ if (absent(code)) return 0;
499
587
  if (typeof code !== "string")
500
588
  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);
589
+ const seen = knownFormat(code);
504
590
  if (seen !== undefined) return seen;
505
591
  const id = FIRST_CUSTOM + numFmts.size;
506
592
  numFmts.set(code, id);
@@ -514,7 +600,7 @@ const stylesheet = () => {
514
600
  * @returns {number}
515
601
  */
516
602
  intern(style, where) {
517
- if (style === undefined || style === null) return 0;
603
+ if (absent(style)) return 0;
518
604
  if (typeof style !== "object")
519
605
  throw TypeError(`${where}: expected a style object, got ${JSON.stringify(style)}`);
520
606
 
@@ -536,13 +622,12 @@ const stylesheet = () => {
536
622
  JSON.stringify(border),
537
623
  () => `<border>${borderXml(border, `${where}.border`)}</border>`,
538
624
  ]),
539
- alignment:
540
- style.alignment === undefined || style.alignment === null
541
- ? ""
542
- : alignmentXml(
543
- /** @type {Record<string, unknown>} */ (style.alignment),
544
- `${where}.alignment`,
545
- ),
625
+ alignment: absent(style.alignment)
626
+ ? ""
627
+ : alignmentXml(
628
+ /** @type {Record<string, unknown>} */ (style.alignment),
629
+ `${where}.alignment`,
630
+ ),
546
631
  };
547
632
  return formats.intern(JSON.stringify(record), () => record);
548
633
  },
@@ -622,24 +707,33 @@ const FURNITURE = 0.3;
622
707
  * two this surface does not take keep Excel's own gap for a header and a
623
708
  * footer.
624
709
  *
625
- * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean } | null} setup
710
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} setup
626
711
  */
627
712
  const printXml = (setup) => {
628
713
  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
- }
714
+ const attributes = setupAttributes(setup);
715
+ return marginsXml(setup.margin) + (attributes === "" ? "" : `<pageSetup${attributes}/>`);
716
+ };
717
+
718
+ /** @param {number | undefined} margin in points */
719
+ const marginsXml = (margin) => {
720
+ if (margin === undefined) return "";
721
+ const inches = (margin / POINTS_PER_INCH).toFixed(3);
722
+ return (
723
+ `<pageMargins left="${inches}" right="${inches}" top="${inches}" bottom="${inches}"` +
724
+ ` header="${FURNITURE}" footer="${FURNITURE}"/>`
725
+ );
726
+ };
727
+
728
+ /** @param {{ size?: string, orientation?: string, fit?: boolean }} setup */
729
+ const setupAttributes = (setup) => {
636
730
  let attributes = "";
637
731
  if (setup.size !== undefined) attributes += ` paperSize="${PAPER.get(setup.size)}"`;
638
732
  if (setup.orientation !== undefined) attributes += ` orientation="${setup.orientation}"`;
639
733
  // `fitToHeight="0"` is what makes it *width* the document fits to: one page
640
734
  // across, as many down as it takes.
641
735
  if (setup.fit === true) attributes += ' fitToWidth="1" fitToHeight="0"';
642
- return attributes === "" ? out : out + `<pageSetup${attributes}/>`;
736
+ return attributes;
643
737
  };
644
738
 
645
739
  // -------------------------------------------------------------- sheet ----
@@ -652,21 +746,170 @@ const NAME_LIMIT = 31;
652
746
  const MAX_ROW = 1_048_576;
653
747
  const MAX_COLUMN = 16_384;
654
748
 
749
+ // EMU per CSS pixel at 96 dpi.
750
+ const EMU = 9525;
751
+
655
752
  /**
753
+ * A `Date` as the serial Excel stores. An invalid one has no serial to give.
754
+ * @param {Date} date
755
+ * @param {string} at
756
+ */
757
+ const dateSerial = (date, at) => {
758
+ if (Number.isNaN(date.getTime()))
759
+ throw RangeError(`${at}: expected a valid Date, got an invalid one`);
760
+ return serial(date);
761
+ };
762
+
763
+ /**
764
+ * The shared-string index of a cell's text, plain or rich.
765
+ * @param {ReturnType<typeof strings>} sst
766
+ * @param {unknown} value
767
+ * @param {string} at
768
+ */
769
+ const textIndex = (sst, value, at) => {
770
+ if (typeof value === "string")
771
+ return sst.intern("s" + value, () => `<si>${textNode(value)}</si>`);
772
+ if (Array.isArray(value)) return sst.intern(richKey(value, at), () => richXml(value, at));
773
+ throw TypeError(`${at}: unsupported cell value ${JSON.stringify(value)}`);
774
+ };
775
+
776
+ /**
777
+ * The type attribute and `<v>` of a present cell value.
778
+ * @param {ReturnType<typeof strings>} sst
656
779
  * @param {unknown} value
657
- * @param {number} limit
658
780
  * @param {string} where
781
+ * @returns {[string, string | number]}
659
782
  */
660
- const requireIndex = (value, limit, where) => {
661
- if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > limit)
783
+ const cellBody = (sst, value, where) => {
784
+ const at = `${where}.value`;
785
+ if (typeof value === "number") return ["", number(requireNumber(value, at))];
786
+ if (typeof value === "boolean") return [' t="b"', Number(value)];
787
+ if (value instanceof Date) return ["", number(dateSerial(value, at))];
788
+ return [' t="s"', textIndex(sst, value, at)];
789
+ };
790
+
791
+ /**
792
+ * @param {ReturnType<typeof strings>} sst
793
+ * @param {unknown} value
794
+ * @param {number} styleId
795
+ * @param {string} ref
796
+ * @param {string} where
797
+ */
798
+ const cellXml = (sst, value, styleId, ref, where) => {
799
+ const s = styleId === 0 ? "" : ` s="${styleId}"`;
800
+ if (absent(value)) return s === "" ? "" : `<c r="${ref}"${s}/>`;
801
+ const [type, v] = cellBody(sst, value, where);
802
+ return `<c r="${ref}"${s}${type}><v>${v}</v></c>`;
803
+ };
804
+
805
+ // A style that is not an object passes through untouched, so the interning
806
+ // below is still the one place that refuses it.
807
+ /** @param {unknown} declared */
808
+ const usable = (declared) => absent(declared) || typeof declared === "object";
809
+
810
+ // A declared style that names no number format of its own.
811
+ /** @param {Record<string, unknown> | null | undefined} declared */
812
+ const undated = (declared) => usable(declared) && absent(declared?.numberFormat);
813
+
814
+ /**
815
+ * A date with no format of its own gets the short-date built-in, so a `Date`
816
+ * reads back as a day rather than as the number underneath it.
817
+ * @param {unknown} value
818
+ * @param {Record<string, unknown> | null | undefined} declared
819
+ */
820
+ const cellStyle = (value, declared) =>
821
+ value instanceof Date && undated(declared) ? { ...declared, numberFormat: SHORT_DATE } : declared;
822
+
823
+ /** @param {number} frozen */
824
+ const paneXml = (frozen) =>
825
+ frozen === 0
826
+ ? '<sheetView workbookViewId="0"/>'
827
+ : `<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>`;
828
+
829
+ /**
830
+ * @typedef {{ top: number, left: number, bottom: number, right: number }} Range
831
+ */
832
+
833
+ /** @param {Range} range */
834
+ const rangeRef = (range) =>
835
+ `${letters(range.left)}${range.top}:${letters(range.right)}${range.bottom}`;
836
+
837
+ /** @param {Range} a @param {Range} b */
838
+ const overlaps = (a, b) =>
839
+ a.top <= b.bottom && a.bottom >= b.top && a.left <= b.right && a.right >= b.left;
840
+
841
+ /** @param {ReadonlyArray<Range>} merges */
842
+ const mergesXml = (merges) =>
843
+ merges.length === 0
844
+ ? ""
845
+ : `<mergeCells count="${merges.length}">` +
846
+ merges.map((range) => `<mergeCell ref="${rangeRef(range)}"/>`).join("") +
847
+ `</mergeCells>`;
848
+
849
+ /**
850
+ * The last column a merge reaches, once its width is known to be a count of
851
+ * two or more that stays on the sheet.
852
+ * @param {unknown} width
853
+ * @param {number} at
854
+ */
855
+ const mergeEnd = (width, at) => {
856
+ if (!Number.isInteger(width) || /** @type {number} */ (width) < 2)
857
+ throw RangeError(`merge: expected a width of 2 or more, got ${JSON.stringify(width)}`);
858
+ const right = at + /** @type {number} */ (width) - 1;
859
+ if (right > MAX_COLUMN) throw RangeError(`merge: the range ends past column ${MAX_COLUMN}`);
860
+ return right;
861
+ };
862
+
863
+ /** @param {unknown} margin */
864
+ const requireMargin = (margin) => {
865
+ const points = requireNumber(margin, "print: margin");
866
+ if (points < 0) throw RangeError(`print: margin cannot be negative, got ${points}`);
867
+ };
868
+
869
+ /** @param {unknown} size */
870
+ const requirePaper = (size) => {
871
+ if (!PAPER.has(/** @type {string} */ (size)))
662
872
  throw RangeError(
663
- `${where}: expected an integer between 1 and ${limit}, got ${JSON.stringify(value)}`,
873
+ `print: unknown paper size ${JSON.stringify(size)} -- known: ${[...PAPER.keys()].join(", ")}`,
664
874
  );
665
- return value;
666
875
  };
667
876
 
668
- // EMU per CSS pixel at 96 dpi.
669
- const EMU = 9525;
877
+ // Zero clears, exactly as it does for a freeze, so a caller that set a count
878
+ // has a way back from it. That is why this does not reach for `requireIndex`:
879
+ // its message names 1 as the floor, and here the floor is 0.
880
+ /** @param {unknown} rows */
881
+ const requireTitles = (rows) => {
882
+ if (rows === 0) return;
883
+ if (!isIndex(rows, MAX_ROW))
884
+ throw RangeError(
885
+ `print: titles expected 0, or a row count between 1 and ${MAX_ROW}, got ${JSON.stringify(rows)}`,
886
+ );
887
+ };
888
+
889
+ /** @param {unknown} orientation */
890
+ const requireOrientation = (orientation) => {
891
+ if (!ORIENTATION.has(/** @type {string} */ (orientation)))
892
+ throw RangeError(`print: expected portrait or landscape, got ${JSON.stringify(orientation)}`);
893
+ };
894
+
895
+ /** @param {number} width @param {number} height */
896
+ const requireSize = (width, height) => {
897
+ if (width <= 0 || height <= 0)
898
+ throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
899
+ };
900
+
901
+ // Every print key this surface checks, and the check it gets. The order is the
902
+ // order a caller hears about a mistake, so it is this object's key order and
903
+ // not a detail. A table rather than a run of guards because a guard apiece
904
+ // puts `print` over the cyclomatic ceiling this package holds every function
905
+ // to; the table keeps the branch count flat as keys are added. `fit` is absent
906
+ // because only `true` writes anything, so no value of it is a mistake.
907
+ const PRINT_CHECKS = {
908
+ margin: requireMargin,
909
+ size: requirePaper,
910
+ orientation: requireOrientation,
911
+ titles: requireTitles,
912
+ };
670
913
 
671
914
  /**
672
915
  * One worksheet. Rows accumulate in call order; everything else is bookkeeping
@@ -688,46 +931,24 @@ const worksheet = (name, styles, sst, knows) => {
688
931
  let widest = 1;
689
932
  // What `print` was told, or null. Nothing reaches the file until a caller
690
933
  // asks: a reader's own print defaults are better than this writer guessing.
691
- /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean } | null} */
934
+ /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} */
692
935
  let printing = null;
693
936
 
694
937
  /**
695
- * @param {unknown} value
696
- * @param {number} styleId
697
- * @param {string} ref
698
- * @param {string} where
938
+ * One cell of a row: the empty string for a gap, else its `<c>`.
939
+ * @param {unknown} cell
940
+ * @param {number} index 0-based column
941
+ * @param {number} at 1-based row
699
942
  */
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)}`);
943
+ const cellAt = (cell, index, at) => {
944
+ if (absent(cell)) return "";
945
+ if (typeof cell !== "object")
946
+ throw TypeError(`row: cell ${index + 1} is not a { value, style } object`);
947
+ const where = `row ${at}, cell ${index + 1}`;
948
+ const { value, style } = /** @type {Record<string, unknown>} */ (cell);
949
+ const declared = /** @type {Record<string, unknown> | null | undefined} */ (style);
950
+ const styleId = styles.intern(cellStyle(value, declared), `${where}.style`);
951
+ return cellXml(sst, value, styleId, letters(index + 1) + at, where);
731
952
  };
732
953
 
733
954
  return {
@@ -742,35 +963,8 @@ const worksheet = (name, styles, sst, knows) => {
742
963
  throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
743
964
 
744
965
  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;
966
+ for (let index = 0; index < cells.length; index++) body += cellAt(cells[index], index, at);
967
+ widest = Math.max(widest, cells.length);
774
968
  rows.push(`<row r="${at}">${body}</row>`);
775
969
  return at;
776
970
  },
@@ -785,18 +979,12 @@ const worksheet = (name, styles, sst, knows) => {
785
979
  requireIndex(at, MAX_COLUMN, "merge: at");
786
980
  if (row > rows.length)
787
981
  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;
982
+ const range = { top: row, left: at, bottom: row, right: mergeEnd(width, at) };
983
+ const clash = merges.find((other) => overlaps(range, other));
984
+ if (clash !== undefined)
985
+ throw RangeError(`merge: the range ${rangeRef(range)} overlaps ${rangeRef(clash)}`);
986
+ merges.push(range);
987
+ widest = Math.max(widest, range.right);
800
988
  },
801
989
 
802
990
  /**
@@ -805,27 +993,24 @@ const worksheet = (name, styles, sst, knows) => {
805
993
  * are better than a guess, and writing one would put this writer's opinion
806
994
  * in every file.
807
995
  *
808
- * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean }} setup
996
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number }} setup
809
997
  */
810
998
  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
- );
999
+ const declared = requireRecord(setup, "print", "a setup object");
1000
+ for (const [key, check] of Object.entries(PRINT_CHECKS))
1001
+ if (declared[key] !== undefined) check(declared[key]);
823
1002
  printing = { ...printing, ...setup };
824
1003
  },
825
1004
 
1005
+ // Read by the workbook part rather than by this sheet's own: a print title
1006
+ // is a defined name, and defined names live in `xl/workbook.xml`.
1007
+ get titles() {
1008
+ return printing?.titles ?? 0;
1009
+ },
1010
+
826
1011
  /** @param {number} count */
827
1012
  freeze(count) {
828
- if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count >= MAX_ROW)
1013
+ if (!Number.isInteger(count) || count < 0 || count >= MAX_ROW)
829
1014
  throw RangeError(`freeze: expected a row count of 0 or more, got ${JSON.stringify(count)}`);
830
1015
  frozen = count;
831
1016
  },
@@ -836,14 +1021,12 @@ const worksheet = (name, styles, sst, knows) => {
836
1021
  */
837
1022
  place(id, at) {
838
1023
  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)}`);
1024
+ requireRecord(at, "place", "{ row, col, width, height }");
841
1025
  const row = requireIndex(at.row, MAX_ROW, "place: row");
842
1026
  const col = requireIndex(at.col === undefined ? 1 : at.col, MAX_COLUMN, "place: col");
843
1027
  const width = requireNumber(at.width, "place: width");
844
1028
  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}`);
1029
+ requireSize(width, height);
847
1030
  pictures.push({ id: /** @type {number} */ (id), row, col, width, height });
848
1031
  },
849
1032
 
@@ -854,21 +1037,6 @@ const worksheet = (name, styles, sst, knows) => {
854
1037
  /** @param {number | null} drawing the relationship id of this sheet's drawing part */
855
1038
  xml(drawing) {
856
1039
  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
1040
 
873
1041
  // `fitToPage` lives on `sheetPr`, which the schema puts before every
874
1042
  // other child of a worksheet.
@@ -880,9 +1048,9 @@ const worksheet = (name, styles, sst, knows) => {
880
1048
  `<worksheet xmlns="${NS}" xmlns:r="${NS_R}">` +
881
1049
  properties +
882
1050
  `<dimension ref="${dimension}"/>` +
883
- `<sheetViews>${pane}</sheetViews>` +
1051
+ `<sheetViews>${paneXml(frozen)}</sheetViews>` +
884
1052
  `<sheetData>${rows.join("")}</sheetData>` +
885
- merged +
1053
+ mergesXml(merges) +
886
1054
  printXml(printing) +
887
1055
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
888
1056
  `</worksheet>`
@@ -935,26 +1103,215 @@ const FORMATS = new Map([
935
1103
  // "the same input" as "the same input at the same wall time".
936
1104
  const CREATED = "1970-01-01T00:00:00Z";
937
1105
 
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;
1106
+ /** @param {Uint8Array} a @param {Uint8Array} b of the same length */
1107
+ const sameBytes = (a, b) => {
946
1108
  for (let at = 0; at < a.length; at++) if (a[at] !== b[at]) return false;
947
1109
  return true;
948
1110
  };
949
1111
 
1112
+ // The same array twice is the common case, and the one the byte loop is worst
1113
+ // at: equal bytes never exit early, so it reads the whole image every time. A
1114
+ // caller placing one logo on a thousand rows pays 54 ms for that, and nothing
1115
+ // for the identity check.
1116
+ /** @param {Uint8Array} a @param {Uint8Array} b */
1117
+ const same = (a, b) => a === b || (a.length === b.length && sameBytes(a, b));
1118
+
1119
+ /**
1120
+ * @param {unknown} name
1121
+ * @returns {string}
1122
+ */
1123
+ const requireSheetName = (name) => {
1124
+ if (typeof name !== "string" || name === "")
1125
+ throw RangeError(`sheet: expected a name, got ${JSON.stringify(name)}`);
1126
+ if (name.length > NAME_LIMIT)
1127
+ throw RangeError(`sheet: a name is at most ${NAME_LIMIT} characters, got ${name.length}`);
1128
+ return name;
1129
+ };
1130
+
1131
+ /**
1132
+ * @param {number} sheets how many worksheets the workbook holds
1133
+ * @param {number} drawings how many of them carry a drawing part
1134
+ * @param {ReadonlyArray<{ format: string }>} media
1135
+ */
1136
+ const contentTypesXml = (sheets, drawings, media) => {
1137
+ const overrides = Array.from(
1138
+ { length: sheets },
1139
+ (_sheet, index) =>
1140
+ `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="${CT}.spreadsheetml.worksheet+xml"/>`,
1141
+ ).join("");
1142
+ const drawingOverrides = Array.from(
1143
+ { length: drawings },
1144
+ (_each, at) =>
1145
+ `<Override PartName="/xl/drawings/drawing${at + 1}.xml" ContentType="${CT}.drawing+xml"/>`,
1146
+ ).join("");
1147
+ const defaults = [...new Set(media.map((each) => each.format))]
1148
+ .map((format) => `<Default Extension="${format}" ContentType="${FORMATS.get(format)}"/>`)
1149
+ .join("");
1150
+
1151
+ return (
1152
+ DECLARATION +
1153
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
1154
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
1155
+ `<Default Extension="xml" ContentType="application/xml"/>` +
1156
+ defaults +
1157
+ `<Override PartName="/xl/workbook.xml" ContentType="${CT}.spreadsheetml.sheet.main+xml"/>` +
1158
+ overrides +
1159
+ `<Override PartName="/xl/styles.xml" ContentType="${CT}.spreadsheetml.styles+xml"/>` +
1160
+ `<Override PartName="/xl/sharedStrings.xml" ContentType="${CT}.spreadsheetml.sharedStrings+xml"/>` +
1161
+ drawingOverrides +
1162
+ `<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>` +
1163
+ `<Override PartName="/docProps/app.xml" ContentType="${CT}.extended-properties+xml"/>` +
1164
+ `</Types>`
1165
+ );
1166
+ };
1167
+
1168
+ const ROOT_RELS =
1169
+ DECLARATION +
1170
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1171
+ `<Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/>` +
1172
+ `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>` +
1173
+ `<Relationship Id="rId3" Type="${REL}/extended-properties" Target="docProps/app.xml"/>` +
1174
+ `</Relationships>`;
1175
+
1176
+ const APP_XML =
1177
+ DECLARATION +
1178
+ `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>werkmap</Application></Properties>`;
1179
+
1180
+ /** @param {string} name @param {string | undefined} value */
1181
+ const tag = (name, value) => (value === undefined ? "" : `<${name}>${esc(value)}</${name}>`);
1182
+
1183
+ /**
1184
+ * @param {{ title?: string, creator?: string, subject?: string, description?: string }} meta
1185
+ */
1186
+ const coreXml = (meta) =>
1187
+ DECLARATION +
1188
+ `<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">` +
1189
+ tag("dc:title", meta.title) +
1190
+ tag("dc:subject", meta.subject) +
1191
+ tag("dc:creator", meta.creator) +
1192
+ tag("dc:description", meta.description) +
1193
+ `<dcterms:created xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:created>` +
1194
+ `<dcterms:modified xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:modified>` +
1195
+ `</cp:coreProperties>`;
1196
+
1197
+ // A sheet reference inside a defined name is always quoted, so a name holding
1198
+ // a space or a symbol needs no special case. An apostrophe in the name closes
1199
+ // the quote early unless it doubles, and the whole reference is XML-escaped
1200
+ // after that like every other name this writer emits.
1201
+ /** @param {string} name @param {number} rows */
1202
+ const titlesRef = (name, rows) => `${esc(`'${name.replace(/'/g, "''")}'`)}!$1:$${rows}`;
1203
+
1204
+ /**
1205
+ * The built-in name that makes the top rows of a sheet repeat on every printed
1206
+ * page. `localSheetId` is the sheet's 0-based position in `<sheets>`, which is
1207
+ * what scopes the name to that one worksheet -- a workbook-scoped name would
1208
+ * make one range serve them all.
1209
+ *
1210
+ * @param {ReadonlyArray<{ name: string, titles: number }>} sheets
1211
+ */
1212
+ const definedNamesXml = (sheets) => {
1213
+ const names = sheets
1214
+ .map((sheet, index) =>
1215
+ sheet.titles === 0
1216
+ ? ""
1217
+ : `<definedName name="_xlnm.Print_Titles" localSheetId="${index}">${titlesRef(sheet.name, sheet.titles)}</definedName>`,
1218
+ )
1219
+ .join("");
1220
+ return names === "" ? "" : `<definedNames>${names}</definedNames>`;
1221
+ };
1222
+
1223
+ /** @param {ReadonlyArray<{ name: string, titles: number }>} sheets */
1224
+ const workbookXml = (sheets) =>
1225
+ DECLARATION +
1226
+ `<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` +
1227
+ sheets
1228
+ .map(
1229
+ (sheet, index) =>
1230
+ `<sheet name="${esc(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`,
1231
+ )
1232
+ .join("") +
1233
+ `</sheets>` +
1234
+ definedNamesXml(sheets) +
1235
+ `</workbook>`;
1236
+
1237
+ /** @param {number} sheets how many worksheets the workbook holds */
1238
+ const workbookRelsXml = (sheets) =>
1239
+ DECLARATION +
1240
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1241
+ Array.from(
1242
+ { length: sheets },
1243
+ (_sheet, index) =>
1244
+ `<Relationship Id="rId${index + 1}" Type="${REL}/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`,
1245
+ ).join("") +
1246
+ `<Relationship Id="rId${sheets + 1}" Type="${REL}/styles" Target="styles.xml"/>` +
1247
+ `<Relationship Id="rId${sheets + 2}" Type="${REL}/sharedStrings" Target="sharedStrings.xml"/>` +
1248
+ `</Relationships>`;
1249
+
1250
+ /**
1251
+ * @typedef {(name: string, body: string | Uint8Array, store?: boolean) => void} Part
1252
+ * @typedef {{ sheet: ReturnType<typeof worksheet>, index: number }} Drawing
1253
+ */
1254
+
1255
+ /**
1256
+ * Every worksheet part, and the relationship part of each sheet that carries
1257
+ * a drawing.
1258
+ * @param {Part} part
1259
+ * @param {ReadonlyArray<ReturnType<typeof worksheet>>} sheets
1260
+ * @param {ReadonlyArray<Drawing>} drawings
1261
+ */
1262
+ const sheetParts = (part, sheets, drawings) => {
1263
+ for (const [index, sheet] of sheets.entries()) {
1264
+ const at = drawings.findIndex((each) => each.index === index);
1265
+ part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
1266
+ if (at !== -1)
1267
+ part(
1268
+ `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
1269
+ DECLARATION +
1270
+ `<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
1271
+ );
1272
+ }
1273
+ };
1274
+
1275
+ /**
1276
+ * Each drawing part and its relationships. A drawing numbers its own
1277
+ * relationships from one, in the order its pictures were placed, so they line
1278
+ * up with its own part rather than with the workbook-wide media list.
1279
+ * @param {Part} part
1280
+ * @param {ReadonlyArray<Drawing>} drawings
1281
+ * @param {ReadonlyArray<{ format: string }>} media
1282
+ */
1283
+ const drawingParts = (part, drawings, media) => {
1284
+ for (const [at, each] of drawings.entries()) {
1285
+ const used = [...new Set(each.sheet.pictures.map((picture) => picture.id))];
1286
+ part(
1287
+ `xl/drawings/drawing${at + 1}.xml`,
1288
+ drawingXml(
1289
+ each.sheet.pictures,
1290
+ each.sheet.pictures.map((picture) => used.indexOf(picture.id)),
1291
+ ),
1292
+ );
1293
+ part(
1294
+ `xl/drawings/_rels/drawing${at + 1}.xml.rels`,
1295
+ DECLARATION +
1296
+ `<Relationships xmlns="${NS_PKG_REL}">` +
1297
+ used
1298
+ .map(
1299
+ (id, index) =>
1300
+ `<Relationship Id="rId${index + 1}" Type="${REL}/image" Target="../media/image${id + 1}.${media[id].format}"/>`,
1301
+ )
1302
+ .join("") +
1303
+ `</Relationships>`,
1304
+ );
1305
+ }
1306
+ };
1307
+
950
1308
  /**
951
1309
  * Open a workbook.
952
1310
  *
953
1311
  * @param {{ title?: string, creator?: string, subject?: string, description?: string }} [meta]
954
1312
  */
955
1313
  export const workbook = (meta = {}) => {
956
- if (meta === null || typeof meta !== "object")
957
- throw TypeError(`workbook: expected a metadata object, got ${JSON.stringify(meta)}`);
1314
+ requireRecord(meta, "workbook", "a metadata object");
958
1315
 
959
1316
  const styles = stylesheet();
960
1317
  const sst = strings();
@@ -972,10 +1329,18 @@ export const workbook = (meta = {}) => {
972
1329
  return value;
973
1330
  };
974
1331
 
975
- const title = property("title");
976
- const creator = property("creator");
977
- const subject = property("subject");
978
- const description = property("description");
1332
+ const properties = {
1333
+ title: property("title"),
1334
+ creator: property("creator"),
1335
+ subject: property("subject"),
1336
+ description: property("description"),
1337
+ };
1338
+
1339
+ /** @param {unknown} id */
1340
+ const knows = (id) =>
1341
+ Number.isInteger(id) &&
1342
+ /** @type {number} */ (id) >= 0 &&
1343
+ /** @type {number} */ (id) < media.length;
979
1344
 
980
1345
  return {
981
1346
  /**
@@ -989,30 +1354,22 @@ export const workbook = (meta = {}) => {
989
1354
  throw TypeError(`image: expected png or jpeg, got ${JSON.stringify(format)}`);
990
1355
  // Identical bytes deduplicate: a caller embedding the same logo on every
991
1356
  // 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;
1357
+ const seen = media.findIndex((each) => each.format === format && same(each.bytes, bytes));
1358
+ if (seen !== -1) return seen;
994
1359
  media.push({ bytes, format });
995
1360
  return media.length - 1;
996
1361
  },
997
1362
 
998
1363
  /** @param {string} name */
999
1364
  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}`);
1365
+ requireSheetName(name);
1004
1366
  if (FORBIDDEN_IN_NAME.test(name))
1005
1367
  throw RangeError(
1006
1368
  `sheet: a name cannot contain : \\ / ? * [ ], got ${JSON.stringify(name)}`,
1007
1369
  );
1008
1370
  if (sheets.some((each) => each.name === name))
1009
1371
  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
- );
1372
+ const made = worksheet(name, styles, sst, knows);
1016
1373
  sheets.push(made);
1017
1374
  return made;
1018
1375
  },
@@ -1022,11 +1379,7 @@ export const workbook = (meta = {}) => {
1022
1379
 
1023
1380
  /** @type {Array<{ name: string, body: Uint8Array, store?: boolean }>} */
1024
1381
  const entries = [];
1025
- /**
1026
- * @param {string} name
1027
- * @param {string | Uint8Array} body
1028
- * @param {boolean} [store]
1029
- */
1382
+ /** @type {Part} */
1030
1383
  const part = (name, body, store) =>
1031
1384
  entries.push({
1032
1385
  name,
@@ -1034,144 +1387,22 @@ export const workbook = (meta = {}) => {
1034
1387
  store,
1035
1388
  });
1036
1389
 
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.
1390
+ // The sheets that carry a drawing part, in sheet order: drawing parts
1391
+ // are numbered by this list, not by sheet index.
1040
1392
  const drawings = sheets
1041
1393
  .map((sheet, index) => ({ sheet, index }))
1042
1394
  .filter((each) => each.sheet.pictures.length > 0);
1043
1395
 
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
-
1396
+ part("[Content_Types].xml", contentTypesXml(sheets.length, drawings.length, media));
1397
+ part("_rels/.rels", ROOT_RELS);
1398
+ part("docProps/core.xml", coreXml(properties));
1399
+ part("docProps/app.xml", APP_XML);
1400
+ part("xl/workbook.xml", workbookXml(sheets));
1401
+ part("xl/_rels/workbook.xml.rels", workbookRelsXml(sheets.length));
1402
+ sheetParts(part, sheets, drawings);
1147
1403
  part("xl/styles.xml", styles.xml());
1148
1404
  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
-
1405
+ drawingParts(part, drawings, media);
1175
1406
  for (const [index, each] of media.entries())
1176
1407
  part(`xl/media/image${index + 1}.${each.format}`, each.bytes, true);
1177
1408