werkmap 0.1.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
@@ -26,10 +26,13 @@ for (let n = 0; n < 256; n++) {
26
26
  CRC[n] = c;
27
27
  }
28
28
 
29
+ // Indexed rather than `for...of`: this runs over every part's uncompressed
30
+ // bytes, so it scales with the whole document, and the iterator protocol costs
31
+ // about four times the arithmetic it wraps (32 ms against 9 ms over 3 MB).
29
32
  /** @param {Uint8Array} bytes */
30
33
  const crc32 = (bytes) => {
31
34
  let c = -1;
32
- for (const b of bytes) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
35
+ for (let at = 0; at < bytes.length; at++) c = CRC[(c ^ bytes[at]) & 0xff] ^ (c >>> 8);
33
36
  return (c ^ -1) >>> 0;
34
37
  };
35
38
 
@@ -54,6 +57,66 @@ const DOS_DATE = (1 << 5) | 1;
54
57
  const STORED = 0;
55
58
  const DEFLATED = 8;
56
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
+
57
120
  /**
58
121
  * @param {ReadonlyArray<{ name: string, body: Uint8Array, store?: boolean }>} entries
59
122
  * @returns {Promise<Uint8Array>}
@@ -65,58 +128,16 @@ const zip = async (entries) => {
65
128
 
66
129
  for (const entry of entries) {
67
130
  const raw = entry.body;
68
- const packed = entry.store ? raw : await deflate(raw);
69
- const method = entry.store ? STORED : DEFLATED;
131
+ const [packed, method] = entry.store ? [raw, STORED] : [await deflate(raw), DEFLATED];
70
132
  const name = utf8(entry.name);
71
- const sum = crc32(raw);
72
-
73
- const head = new DataView(new ArrayBuffer(30));
74
- head.setUint32(0, 0x04034b50, true);
75
- head.setUint16(4, 20, true);
76
- head.setUint16(6, 0x0800, true); // the name is UTF-8
77
- head.setUint16(8, method, true);
78
- head.setUint16(10, DOS_TIME, true);
79
- head.setUint16(12, DOS_DATE, true);
80
- head.setUint32(14, sum, true);
81
- head.setUint32(18, packed.length, true);
82
- head.setUint32(22, raw.length, true);
83
- head.setUint16(26, name.length, true);
84
- local.push(new Uint8Array(head.buffer), name, packed);
85
-
86
- const record = new DataView(new ArrayBuffer(46));
87
- record.setUint32(0, 0x02014b50, true);
88
- record.setUint16(4, 20, true);
89
- record.setUint16(6, 20, true);
90
- record.setUint16(8, 0x0800, true);
91
- record.setUint16(10, method, true);
92
- record.setUint16(12, DOS_TIME, true);
93
- record.setUint16(14, DOS_DATE, true);
94
- record.setUint32(16, sum, true);
95
- record.setUint32(20, packed.length, true);
96
- record.setUint32(24, raw.length, true);
97
- record.setUint16(28, name.length, true);
98
- record.setUint32(42, offset, true);
99
- central.push(new Uint8Array(record.buffer), name);
100
-
133
+ const item = { method, sum: crc32(raw), packed, raw, name };
134
+ local.push(localHeader(item), name, packed);
135
+ central.push(centralRecord(item, offset), name);
101
136
  offset += 30 + name.length + packed.length;
102
137
  }
103
138
 
104
139
  const size = central.reduce((total, chunk) => total + chunk.length, 0);
105
- const end = new DataView(new ArrayBuffer(22));
106
- end.setUint32(0, 0x06054b50, true);
107
- end.setUint16(8, entries.length, true);
108
- end.setUint16(10, entries.length, true);
109
- end.setUint32(12, size, true);
110
- end.setUint32(16, offset, true);
111
-
112
- const all = [...local, ...central, new Uint8Array(end.buffer)];
113
- const bytes = new Uint8Array(all.reduce((total, chunk) => total + chunk.length, 0));
114
- let at = 0;
115
- for (const chunk of all) {
116
- bytes.set(chunk, at);
117
- at += chunk.length;
118
- }
119
- return bytes;
140
+ return concat([...local, ...central, endRecord(entries.length, size, offset)]);
120
141
  };
121
142
 
122
143
  // ---------------------------------------------------------------- XML ----
@@ -160,6 +181,70 @@ const esc = (text) =>
160
181
  /** @param {string} text */
161
182
  const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
162
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
+
163
248
  /**
164
249
  * One run of rich text, written in full: an OOXML run inherits nothing from
165
250
  * the cell font.
@@ -167,17 +252,12 @@ const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
167
252
  * @param {string} at
168
253
  */
169
254
  const richRun = (run, at) => {
170
- if (run === null || typeof run !== "object")
171
- throw TypeError(`${at}: expected a { text, font } run, got ${JSON.stringify(run)}`);
172
- const text = /** @type {Record<string, unknown>} */ (run).text;
173
- if (typeof text !== "string")
174
- throw TypeError(`${at}.text: expected a string, got ${JSON.stringify(text)}`);
175
- const font = /** @type {Record<string, unknown>} */ (run).font;
176
- const properties =
177
- font === undefined || font === null
178
- ? ""
179
- : `<rPr>${runFontXml(/** @type {Record<string, unknown>} */ (font), `${at}.font`)}</rPr>`;
180
- return `<r>${properties}${textNode(text)}</r>`;
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>`;
181
261
  };
182
262
 
183
263
  /**
@@ -333,6 +413,15 @@ const table = (initial) => {
333
413
 
334
414
  // ------------------------------------------------------------- styles ----
335
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
+
336
425
  /**
337
426
  * The font half of a style, written in the SDK's `CT_Font` sequence:
338
427
  * b i strike u sz color name.
@@ -340,14 +429,14 @@ const table = (initial) => {
340
429
  * @param {string} where
341
430
  */
342
431
  const fontXml = (font, where) => {
343
- let out = "";
344
- if (font.bold) out += "<b/>";
345
- if (font.italic) out += "<i/>";
346
- if (font.strikethrough) out += "<strike/>";
347
- if (font.underline) out += "<u/>";
348
- out += `<sz val="${font.size === undefined ? 11 : requireNumber(font.size, `${where}.size`)}"/>`;
349
- if (font.color !== undefined) out += `<color rgb="${rgb(font.color, `${where}.color`)}"/>`;
350
- return out + `<name val="${esc(requireText(font.name, `${where}.name`, "Calibri"))}"/>`;
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}"/>`;
351
440
  };
352
441
 
353
442
  /**
@@ -365,93 +454,92 @@ const SIDES = ["left", "right", "top", "bottom"];
365
454
  * @param {Record<string, unknown>} border
366
455
  * @param {string} where
367
456
  */
368
- const borderXml = (border, where) => {
369
- let out = "";
370
- for (const side of SIDES) {
371
- const edge = border[side];
372
- if (edge === undefined || edge === null) {
373
- out += `<${side}/>`;
374
- continue;
375
- }
376
- const at = `${where}.${side}`;
377
- const style = /** @type {Record<string, unknown>} */ (edge).style;
378
- if (typeof style !== "string" || !BORDER_STYLES.has(style))
379
- throw RangeError(
380
- `${at}.style: expected thin, dashed or dotted, got ${JSON.stringify(style)}`,
381
- );
382
- const colour = /** @type {Record<string, unknown>} */ (edge).color;
383
- out +=
384
- colour === undefined
385
- ? `<${side} style="${style}"/>`
386
- : `<${side} style="${style}"><color rgb="${rgb(colour, `${at}.color`)}"/></${side}>`;
387
- }
388
- return out + "<diagonal/>";
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;
389
465
  };
390
466
 
391
- const HORIZONTAL = new Set(["left", "center", "right", "justify", "fill"]);
392
- 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
+ };
393
482
 
394
483
  /**
395
- * @param {Record<string, unknown>} alignment
484
+ * @param {Record<string, unknown>} border
396
485
  * @param {string} where
397
486
  */
398
- const alignmentXml = (alignment, where) => {
399
- let out = "";
400
- if (alignment.horizontal !== undefined) {
401
- const horizontal = /** @type {string} */ (alignment.horizontal);
402
- if (!HORIZONTAL.has(horizontal))
403
- throw RangeError(`${where}.horizontal: unknown alignment ${JSON.stringify(horizontal)}`);
404
- out += ` horizontal="${horizontal}"`;
405
- }
406
- if (alignment.vertical !== undefined) {
407
- const vertical = /** @type {string} */ (alignment.vertical);
408
- if (!VERTICAL.has(vertical))
409
- throw RangeError(`${where}.vertical: unknown alignment ${JSON.stringify(vertical)}`);
410
- out += ` vertical="${vertical}"`;
411
- }
412
- if (alignment.wrapText) out += ' wrapText="1"';
413
- return out;
414
- };
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"]);
415
492
 
416
493
  /**
494
+ * One alignment axis as its attribute, or nothing when the caller left it out.
417
495
  * @param {unknown} value
496
+ * @param {ReadonlySet<string>} known
497
+ * @param {string} axis
418
498
  * @param {string} where
419
- * @param {string} fallback
420
499
  */
421
- const requireText = (value, where, fallback) => {
422
- if (value === undefined) return fallback;
423
- if (typeof value !== "string")
424
- throw TypeError(`${where}: expected a string, got ${JSON.stringify(value)}`);
425
- return value;
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}"`;
426
506
  };
427
507
 
428
508
  /**
429
- * @param {unknown} value
509
+ * @param {Record<string, unknown>} alignment
430
510
  * @param {string} where
431
511
  */
432
- const requireNumber = (value, where) => {
433
- if (typeof value !== "number" || !Number.isFinite(value))
434
- throw RangeError(`${where}: expected a finite number, got ${JSON.stringify(value)}`);
435
- return value;
436
- };
512
+ const alignmentXml = (alignment, where) =>
513
+ axisXml(alignment.horizontal, HORIZONTAL, "horizontal", where) +
514
+ axisXml(alignment.vertical, VERTICAL, "vertical", where) +
515
+ (alignment.wrapText ? ' wrapText="1"' : "");
437
516
 
438
517
  /**
439
518
  * @typedef {{ numFmtId: number, fontId: number, fillId: number, borderId: number, alignment: string }} Xf
440
519
  */
441
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
+
442
531
  /**
443
532
  * One `xf`. Each `apply*` flag says which table this format actually reaches
444
533
  * into; a reader ignores a table entry the flag does not claim.
445
534
  * @param {Xf} xf
446
535
  */
447
536
  const xfXml = (xf) => {
448
- let out = `<xf numFmtId="${xf.numFmtId}" fontId="${xf.fontId}" fillId="${xf.fillId}" borderId="${xf.borderId}" xfId="0"`;
449
- if (xf.numFmtId !== 0) out += ' applyNumberFormat="1"';
450
- if (xf.fontId !== 0) out += ' applyFont="1"';
451
- if (xf.fillId !== 0) out += ' applyFill="1"';
452
- if (xf.borderId !== 0) out += ' applyBorder="1"';
453
- if (xf.alignment !== "") out += ' applyAlignment="1"';
454
- return xf.alignment === "" ? out + "/>" : `${out}><alignment${xf.alignment}/></xf>`;
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>`;
455
543
  };
456
544
 
457
545
  /**
@@ -480,11 +568,14 @@ const stylesheet = () => {
480
568
  * @param {(value: Record<string, unknown>) => [string, () => T]} describe
481
569
  */
482
570
  const optional = (part, into, describe) => {
483
- if (part === undefined || part === null) return 0;
571
+ if (absent(part)) return 0;
484
572
  const [key, make] = describe(/** @type {Record<string, unknown>} */ (part));
485
573
  return into.intern(key, make);
486
574
  };
487
575
 
576
+ /** @param {string} code */
577
+ const knownFormat = (code) => BUILT_IN.get(code) ?? numFmts.get(code);
578
+
488
579
  /**
489
580
  * A format code a reader knows by id keeps that id; anything else is
490
581
  * interned from 164 up, in first-seen order.
@@ -492,12 +583,10 @@ const stylesheet = () => {
492
583
  * @param {string} where
493
584
  */
494
585
  const formatId = (code, where) => {
495
- if (code === undefined || code === null) return 0;
586
+ if (absent(code)) return 0;
496
587
  if (typeof code !== "string")
497
588
  throw TypeError(`${where}: expected a format code, got ${JSON.stringify(code)}`);
498
- const builtIn = BUILT_IN.get(code);
499
- if (builtIn !== undefined) return builtIn;
500
- const seen = numFmts.get(code);
589
+ const seen = knownFormat(code);
501
590
  if (seen !== undefined) return seen;
502
591
  const id = FIRST_CUSTOM + numFmts.size;
503
592
  numFmts.set(code, id);
@@ -511,7 +600,7 @@ const stylesheet = () => {
511
600
  * @returns {number}
512
601
  */
513
602
  intern(style, where) {
514
- if (style === undefined || style === null) return 0;
603
+ if (absent(style)) return 0;
515
604
  if (typeof style !== "object")
516
605
  throw TypeError(`${where}: expected a style object, got ${JSON.stringify(style)}`);
517
606
 
@@ -533,13 +622,12 @@ const stylesheet = () => {
533
622
  JSON.stringify(border),
534
623
  () => `<border>${borderXml(border, `${where}.border`)}</border>`,
535
624
  ]),
536
- alignment:
537
- style.alignment === undefined || style.alignment === null
538
- ? ""
539
- : alignmentXml(
540
- /** @type {Record<string, unknown>} */ (style.alignment),
541
- `${where}.alignment`,
542
- ),
625
+ alignment: absent(style.alignment)
626
+ ? ""
627
+ : alignmentXml(
628
+ /** @type {Record<string, unknown>} */ (style.alignment),
629
+ `${where}.alignment`,
630
+ ),
543
631
  };
544
632
  return formats.intern(JSON.stringify(record), () => record);
545
633
  },
@@ -590,6 +678,64 @@ const strings = () => {
590
678
  };
591
679
  };
592
680
 
681
+ // --------------------------------------------------------- print setup ----
682
+
683
+ // The paper sizes OOXML names by number. A workbook carries a code, not a
684
+ // width and a height, so this is the whole of what a caller may ask for.
685
+ const PAPER = new Map([
686
+ ["letter", 1],
687
+ ["tabloid", 3],
688
+ ["legal", 5],
689
+ ["A3", 8],
690
+ ["A4", 9],
691
+ ["A5", 11],
692
+ ]);
693
+
694
+ const ORIENTATION = new Set(["portrait", "landscape"]);
695
+
696
+ // OOXML states margins in inches; this surface takes points, the unit a print
697
+ // margin is written in everywhere else. 72 points to the inch.
698
+ const POINTS_PER_INCH = 72;
699
+
700
+ // The gap Excel leaves for a header and a footer when a caller says nothing.
701
+ // `pageMargins` has no optional attributes, so a value is owed either way.
702
+ const FURNITURE = 0.3;
703
+
704
+ /**
705
+ * `pageMargins` and `pageSetup`, or nothing at all. `pageMargins` has no
706
+ * optional attributes, so asking for any margin means writing all six — the
707
+ * two this surface does not take keep Excel's own gap for a header and a
708
+ * footer.
709
+ *
710
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} setup
711
+ */
712
+ const printXml = (setup) => {
713
+ if (setup === null) return "";
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) => {
730
+ let attributes = "";
731
+ if (setup.size !== undefined) attributes += ` paperSize="${PAPER.get(setup.size)}"`;
732
+ if (setup.orientation !== undefined) attributes += ` orientation="${setup.orientation}"`;
733
+ // `fitToHeight="0"` is what makes it *width* the document fits to: one page
734
+ // across, as many down as it takes.
735
+ if (setup.fit === true) attributes += ' fitToWidth="1" fitToHeight="0"';
736
+ return attributes;
737
+ };
738
+
593
739
  // -------------------------------------------------------------- sheet ----
594
740
 
595
741
  const FORBIDDEN_IN_NAME = /[:\\/?*[\]]/;
@@ -600,21 +746,170 @@ const NAME_LIMIT = 31;
600
746
  const MAX_ROW = 1_048_576;
601
747
  const MAX_COLUMN = 16_384;
602
748
 
749
+ // EMU per CSS pixel at 96 dpi.
750
+ const EMU = 9525;
751
+
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
+
603
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
604
779
  * @param {unknown} value
605
- * @param {number} limit
606
780
  * @param {string} where
781
+ * @returns {[string, string | number]}
607
782
  */
608
- const requireIndex = (value, limit, where) => {
609
- 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)))
610
872
  throw RangeError(
611
- `${where}: expected an integer between 1 and ${limit}, got ${JSON.stringify(value)}`,
873
+ `print: unknown paper size ${JSON.stringify(size)} -- known: ${[...PAPER.keys()].join(", ")}`,
612
874
  );
613
- return value;
614
875
  };
615
876
 
616
- // EMU per CSS pixel at 96 dpi.
617
- 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
+ };
618
913
 
619
914
  /**
620
915
  * One worksheet. Rows accumulate in call order; everything else is bookkeeping
@@ -634,44 +929,26 @@ const worksheet = (name, styles, sst, knows) => {
634
929
  const pictures = [];
635
930
  let frozen = 0;
636
931
  let widest = 1;
932
+ // What `print` was told, or null. Nothing reaches the file until a caller
933
+ // asks: a reader's own print defaults are better than this writer guessing.
934
+ /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} */
935
+ let printing = null;
637
936
 
638
937
  /**
639
- * @param {unknown} value
640
- * @param {number} styleId
641
- * @param {string} ref
642
- * @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
643
942
  */
644
- const cellXml = (value, styleId, ref, where) => {
645
- const s = styleId === 0 ? "" : ` s="${styleId}"`;
646
-
647
- if (value === undefined || value === null) return s === "" ? "" : `<c r="${ref}"${s}/>`;
648
-
649
- if (typeof value === "number") {
650
- requireNumber(value, `${where}.value`);
651
- return `<c r="${ref}"${s}><v>${number(value)}</v></c>`;
652
- }
653
-
654
- if (typeof value === "boolean") return `<c r="${ref}"${s} t="b"><v>${value ? 1 : 0}</v></c>`;
655
-
656
- if (value instanceof Date) {
657
- if (Number.isNaN(value.getTime()))
658
- throw RangeError(`${where}.value: expected a valid Date, got an invalid one`);
659
- return `<c r="${ref}"${s}><v>${number(serial(value))}</v></c>`;
660
- }
661
-
662
- if (typeof value === "string") {
663
- const at = sst.intern("s" + value, () => `<si>${textNode(value)}</si>`);
664
- return `<c r="${ref}"${s} t="s"><v>${at}</v></c>`;
665
- }
666
-
667
- if (Array.isArray(value)) {
668
- const at = sst.intern(richKey(value, `${where}.value`), () =>
669
- richXml(value, `${where}.value`),
670
- );
671
- return `<c r="${ref}"${s} t="s"><v>${at}</v></c>`;
672
- }
673
-
674
- throw TypeError(`${where}.value: unsupported cell value ${JSON.stringify(value)}`);
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);
675
952
  };
676
953
 
677
954
  return {
@@ -686,35 +963,8 @@ const worksheet = (name, styles, sst, knows) => {
686
963
  throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
687
964
 
688
965
  let body = "";
689
- for (let index = 0; index < cells.length; index++) {
690
- const cell = cells[index];
691
- if (cell === undefined || cell === null) continue;
692
- if (typeof cell !== "object")
693
- throw TypeError(`row: cell ${index + 1} is not a { value, style } object`);
694
- const where = `row ${at}, cell ${index + 1}`;
695
- const declared = /** @type {Record<string, unknown> | null | undefined} */ (
696
- /** @type {Record<string, unknown>} */ (cell).style
697
- );
698
- // A date with no format of its own gets the short-date built-in, so a
699
- // `Date` reads back as a day rather than as the number underneath it.
700
- // A style that is not an object passes through untouched, so the
701
- // interning below is still the one place that refuses it.
702
- const usable = declared === null || declared === undefined || typeof declared === "object";
703
- const style =
704
- /** @type {Record<string, unknown>} */ (cell).value instanceof Date &&
705
- usable &&
706
- (declared?.numberFormat ?? null) === null
707
- ? { ...declared, numberFormat: SHORT_DATE }
708
- : /** @type {Record<string, unknown> | null | undefined} */ (declared);
709
- const styleId = styles.intern(style, `${where}.style`);
710
- body += cellXml(
711
- /** @type {Record<string, unknown>} */ (cell).value,
712
- styleId,
713
- letters(index + 1) + at,
714
- where,
715
- );
716
- }
717
- if (cells.length > widest) widest = cells.length;
966
+ for (let index = 0; index < cells.length; index++) body += cellAt(cells[index], index, at);
967
+ widest = Math.max(widest, cells.length);
718
968
  rows.push(`<row r="${at}">${body}</row>`);
719
969
  return at;
720
970
  },
@@ -729,23 +979,38 @@ const worksheet = (name, styles, sst, knows) => {
729
979
  requireIndex(at, MAX_COLUMN, "merge: at");
730
980
  if (row > rows.length)
731
981
  throw RangeError(`merge: row ${row} does not exist yet (the sheet has ${rows.length})`);
732
- if (typeof width !== "number" || !Number.isInteger(width) || width < 2)
733
- throw RangeError(`merge: expected a width of 2 or more, got ${JSON.stringify(width)}`);
734
- const right = at + width - 1;
735
- if (right > MAX_COLUMN) throw RangeError(`merge: the range ends past column ${MAX_COLUMN}`);
736
- for (const other of merges)
737
- if (row >= other.top && row <= other.bottom && at <= other.right && right >= other.left)
738
- throw RangeError(
739
- `merge: the range ${letters(at)}${row}:${letters(right)}${row} overlaps ` +
740
- `${letters(other.left)}${other.top}:${letters(other.right)}${other.bottom}`,
741
- );
742
- merges.push({ top: row, left: at, bottom: row, right });
743
- if (right > widest) widest = right;
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);
988
+ },
989
+
990
+ /**
991
+ * How this worksheet prints. Every key is optional, and a worksheet that
992
+ * never calls this carries no print setup at all a reader's own defaults
993
+ * are better than a guess, and writing one would put this writer's opinion
994
+ * in every file.
995
+ *
996
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number }} setup
997
+ */
998
+ print(setup) {
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]);
1002
+ printing = { ...printing, ...setup };
1003
+ },
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;
744
1009
  },
745
1010
 
746
1011
  /** @param {number} count */
747
1012
  freeze(count) {
748
- if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count >= MAX_ROW)
1013
+ if (!Number.isInteger(count) || count < 0 || count >= MAX_ROW)
749
1014
  throw RangeError(`freeze: expected a row count of 0 or more, got ${JSON.stringify(count)}`);
750
1015
  frozen = count;
751
1016
  },
@@ -756,14 +1021,12 @@ const worksheet = (name, styles, sst, knows) => {
756
1021
  */
757
1022
  place(id, at) {
758
1023
  if (!knows(id)) throw TypeError(`place: no image with id ${JSON.stringify(id)}`);
759
- if (at === null || typeof at !== "object")
760
- throw TypeError(`place: expected { row, col, width, height }, got ${JSON.stringify(at)}`);
1024
+ requireRecord(at, "place", "{ row, col, width, height }");
761
1025
  const row = requireIndex(at.row, MAX_ROW, "place: row");
762
1026
  const col = requireIndex(at.col === undefined ? 1 : at.col, MAX_COLUMN, "place: col");
763
1027
  const width = requireNumber(at.width, "place: width");
764
1028
  const height = requireNumber(at.height, "place: height");
765
- if (width <= 0 || height <= 0)
766
- throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
1029
+ requireSize(width, height);
767
1030
  pictures.push({ id: /** @type {number} */ (id), row, col, width, height });
768
1031
  },
769
1032
 
@@ -774,29 +1037,21 @@ const worksheet = (name, styles, sst, knows) => {
774
1037
  /** @param {number | null} drawing the relationship id of this sheet's drawing part */
775
1038
  xml(drawing) {
776
1039
  const dimension = `A1:${letters(widest)}${Math.max(rows.length, 1)}`;
777
- const pane =
778
- frozen === 0
779
- ? '<sheetView workbookViewId="0"/>'
780
- : `<sheetView workbookViewId="0"><pane ySplit="${frozen}" topLeftCell="A${frozen + 1}" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft" activeCell="A${frozen + 1}" sqref="A${frozen + 1}"/></sheetView>`;
781
- const merged =
782
- merges.length === 0
783
- ? ""
784
- : `<mergeCells count="${merges.length}">` +
785
- merges
786
- .map(
787
- (range) =>
788
- `<mergeCell ref="${letters(range.left)}${range.top}:${letters(range.right)}${range.bottom}"/>`,
789
- )
790
- .join("") +
791
- `</mergeCells>`;
1040
+
1041
+ // `fitToPage` lives on `sheetPr`, which the schema puts before every
1042
+ // other child of a worksheet.
1043
+ const properties =
1044
+ printing?.fit === true ? `<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr>` : "";
792
1045
 
793
1046
  return (
794
1047
  DECLARATION +
795
1048
  `<worksheet xmlns="${NS}" xmlns:r="${NS_R}">` +
1049
+ properties +
796
1050
  `<dimension ref="${dimension}"/>` +
797
- `<sheetViews>${pane}</sheetViews>` +
1051
+ `<sheetViews>${paneXml(frozen)}</sheetViews>` +
798
1052
  `<sheetData>${rows.join("")}</sheetData>` +
799
- merged +
1053
+ mergesXml(merges) +
1054
+ printXml(printing) +
800
1055
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
801
1056
  `</worksheet>`
802
1057
  );
@@ -848,21 +1103,215 @@ const FORMATS = new Map([
848
1103
  // "the same input" as "the same input at the same wall time".
849
1104
  const CREATED = "1970-01-01T00:00:00Z";
850
1105
 
851
- /** @param {Uint8Array} a @param {Uint8Array} b */
852
- const same = (a, b) => {
853
- if (a.length !== b.length) return false;
1106
+ /** @param {Uint8Array} a @param {Uint8Array} b of the same length */
1107
+ const sameBytes = (a, b) => {
854
1108
  for (let at = 0; at < a.length; at++) if (a[at] !== b[at]) return false;
855
1109
  return true;
856
1110
  };
857
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
+
858
1308
  /**
859
1309
  * Open a workbook.
860
1310
  *
861
1311
  * @param {{ title?: string, creator?: string, subject?: string, description?: string }} [meta]
862
1312
  */
863
1313
  export const workbook = (meta = {}) => {
864
- if (meta === null || typeof meta !== "object")
865
- throw TypeError(`workbook: expected a metadata object, got ${JSON.stringify(meta)}`);
1314
+ requireRecord(meta, "workbook", "a metadata object");
866
1315
 
867
1316
  const styles = stylesheet();
868
1317
  const sst = strings();
@@ -880,10 +1329,18 @@ export const workbook = (meta = {}) => {
880
1329
  return value;
881
1330
  };
882
1331
 
883
- const title = property("title");
884
- const creator = property("creator");
885
- const subject = property("subject");
886
- 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;
887
1344
 
888
1345
  return {
889
1346
  /**
@@ -897,30 +1354,22 @@ export const workbook = (meta = {}) => {
897
1354
  throw TypeError(`image: expected png or jpeg, got ${JSON.stringify(format)}`);
898
1355
  // Identical bytes deduplicate: a caller embedding the same logo on every
899
1356
  // sheet pays for it once, and does so without keeping a tally of its own.
900
- for (let at = 0; at < media.length; at++)
901
- if (media[at].format === format && same(media[at].bytes, bytes)) return at;
1357
+ const seen = media.findIndex((each) => each.format === format && same(each.bytes, bytes));
1358
+ if (seen !== -1) return seen;
902
1359
  media.push({ bytes, format });
903
1360
  return media.length - 1;
904
1361
  },
905
1362
 
906
1363
  /** @param {string} name */
907
1364
  sheet(name) {
908
- if (typeof name !== "string" || name === "")
909
- throw RangeError(`sheet: expected a name, got ${JSON.stringify(name)}`);
910
- if (name.length > NAME_LIMIT)
911
- throw RangeError(`sheet: a name is at most ${NAME_LIMIT} characters, got ${name.length}`);
1365
+ requireSheetName(name);
912
1366
  if (FORBIDDEN_IN_NAME.test(name))
913
1367
  throw RangeError(
914
1368
  `sheet: a name cannot contain : \\ / ? * [ ], got ${JSON.stringify(name)}`,
915
1369
  );
916
1370
  if (sheets.some((each) => each.name === name))
917
1371
  throw RangeError(`sheet: a sheet named ${JSON.stringify(name)} already exists`);
918
- const made = worksheet(
919
- name,
920
- styles,
921
- sst,
922
- (id) => typeof id === "number" && Number.isInteger(id) && id >= 0 && id < media.length,
923
- );
1372
+ const made = worksheet(name, styles, sst, knows);
924
1373
  sheets.push(made);
925
1374
  return made;
926
1375
  },
@@ -930,11 +1379,7 @@ export const workbook = (meta = {}) => {
930
1379
 
931
1380
  /** @type {Array<{ name: string, body: Uint8Array, store?: boolean }>} */
932
1381
  const entries = [];
933
- /**
934
- * @param {string} name
935
- * @param {string | Uint8Array} body
936
- * @param {boolean} [store]
937
- */
1382
+ /** @type {Part} */
938
1383
  const part = (name, body, store) =>
939
1384
  entries.push({
940
1385
  name,
@@ -942,144 +1387,22 @@ export const workbook = (meta = {}) => {
942
1387
  store,
943
1388
  });
944
1389
 
945
- // Which media each sheet's drawing references, and in what order, so a
946
- // drawing's relationship ids line up with its own part rather than with
947
- // the workbook-wide media list.
1390
+ // The sheets that carry a drawing part, in sheet order: drawing parts
1391
+ // are numbered by this list, not by sheet index.
948
1392
  const drawings = sheets
949
1393
  .map((sheet, index) => ({ sheet, index }))
950
1394
  .filter((each) => each.sheet.pictures.length > 0);
951
1395
 
952
- const overrides = sheets
953
- .map(
954
- (_sheet, index) =>
955
- `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="${CT}.spreadsheetml.worksheet+xml"/>`,
956
- )
957
- .join("");
958
- const drawingOverrides = drawings
959
- .map(
960
- (_each, at) =>
961
- `<Override PartName="/xl/drawings/drawing${at + 1}.xml" ContentType="${CT}.drawing+xml"/>`,
962
- )
963
- .join("");
964
- const defaults = [...new Set(media.map((each) => each.format))]
965
- .map((format) => `<Default Extension="${format}" ContentType="${FORMATS.get(format)}"/>`)
966
- .join("");
967
-
968
- part(
969
- "[Content_Types].xml",
970
- DECLARATION +
971
- `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
972
- `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
973
- `<Default Extension="xml" ContentType="application/xml"/>` +
974
- defaults +
975
- `<Override PartName="/xl/workbook.xml" ContentType="${CT}.spreadsheetml.sheet.main+xml"/>` +
976
- overrides +
977
- `<Override PartName="/xl/styles.xml" ContentType="${CT}.spreadsheetml.styles+xml"/>` +
978
- `<Override PartName="/xl/sharedStrings.xml" ContentType="${CT}.spreadsheetml.sharedStrings+xml"/>` +
979
- drawingOverrides +
980
- `<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>` +
981
- `<Override PartName="/docProps/app.xml" ContentType="${CT}.extended-properties+xml"/>` +
982
- `</Types>`,
983
- );
984
-
985
- part(
986
- "_rels/.rels",
987
- DECLARATION +
988
- `<Relationships xmlns="${NS_PKG_REL}">` +
989
- `<Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/>` +
990
- `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>` +
991
- `<Relationship Id="rId3" Type="${REL}/extended-properties" Target="docProps/app.xml"/>` +
992
- `</Relationships>`,
993
- );
994
-
995
- /** @param {string} name @param {string | undefined} value */
996
- const tag = (name, value) => (value === undefined ? "" : `<${name}>${esc(value)}</${name}>`);
997
- part(
998
- "docProps/core.xml",
999
- DECLARATION +
1000
- `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
1001
- tag("dc:title", title) +
1002
- tag("dc:subject", subject) +
1003
- tag("dc:creator", creator) +
1004
- tag("dc:description", description) +
1005
- `<dcterms:created xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:created>` +
1006
- `<dcterms:modified xsi:type="dcterms:W3CDTF">${CREATED}</dcterms:modified>` +
1007
- `</cp:coreProperties>`,
1008
- );
1009
-
1010
- part(
1011
- "docProps/app.xml",
1012
- DECLARATION +
1013
- `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>werkmap</Application></Properties>`,
1014
- );
1015
-
1016
- part(
1017
- "xl/workbook.xml",
1018
- DECLARATION +
1019
- `<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` +
1020
- sheets
1021
- .map(
1022
- (sheet, index) =>
1023
- `<sheet name="${esc(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`,
1024
- )
1025
- .join("") +
1026
- `</sheets></workbook>`,
1027
- );
1028
-
1029
- part(
1030
- "xl/_rels/workbook.xml.rels",
1031
- DECLARATION +
1032
- `<Relationships xmlns="${NS_PKG_REL}">` +
1033
- sheets
1034
- .map(
1035
- (_sheet, index) =>
1036
- `<Relationship Id="rId${index + 1}" Type="${REL}/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`,
1037
- )
1038
- .join("") +
1039
- `<Relationship Id="rId${sheets.length + 1}" Type="${REL}/styles" Target="styles.xml"/>` +
1040
- `<Relationship Id="rId${sheets.length + 2}" Type="${REL}/sharedStrings" Target="sharedStrings.xml"/>` +
1041
- `</Relationships>`,
1042
- );
1043
-
1044
- for (const [index, sheet] of sheets.entries()) {
1045
- const at = drawings.findIndex((each) => each.index === index);
1046
- part(`xl/worksheets/sheet${index + 1}.xml`, sheet.xml(at === -1 ? null : 1));
1047
- if (at !== -1)
1048
- part(
1049
- `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
1050
- DECLARATION +
1051
- `<Relationships xmlns="${NS_PKG_REL}"><Relationship Id="rId1" Type="${REL}/drawing" Target="../drawings/drawing${at + 1}.xml"/></Relationships>`,
1052
- );
1053
- }
1054
-
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);
1055
1403
  part("xl/styles.xml", styles.xml());
1056
1404
  part("xl/sharedStrings.xml", sst.xml());
1057
-
1058
- for (const [at, each] of drawings.entries()) {
1059
- // Each drawing numbers its own relationships from one, in the order
1060
- // its pictures were placed.
1061
- const used = [...new Set(each.sheet.pictures.map((picture) => picture.id))];
1062
- part(
1063
- `xl/drawings/drawing${at + 1}.xml`,
1064
- drawingXml(
1065
- each.sheet.pictures,
1066
- each.sheet.pictures.map((picture) => used.indexOf(picture.id)),
1067
- ),
1068
- );
1069
- part(
1070
- `xl/drawings/_rels/drawing${at + 1}.xml.rels`,
1071
- DECLARATION +
1072
- `<Relationships xmlns="${NS_PKG_REL}">` +
1073
- used
1074
- .map(
1075
- (id, index) =>
1076
- `<Relationship Id="rId${index + 1}" Type="${REL}/image" Target="../media/image${id + 1}.${media[id].format}"/>`,
1077
- )
1078
- .join("") +
1079
- `</Relationships>`,
1080
- );
1081
- }
1082
-
1405
+ drawingParts(part, drawings, media);
1083
1406
  for (const [index, each] of media.entries())
1084
1407
  part(`xl/media/image${index + 1}.${each.format}`, each.bytes, true);
1085
1408