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/README.md +10 -2
- package/lib/index.d.ts +22 -0
- package/lib/index.js +726 -403
- package/package.json +2 -1
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 (
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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>}
|
|
484
|
+
* @param {Record<string, unknown>} border
|
|
396
485
|
* @param {string} where
|
|
397
486
|
*/
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
422
|
-
if (value === undefined) return
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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}
|
|
509
|
+
* @param {Record<string, unknown>} alignment
|
|
430
510
|
* @param {string} where
|
|
431
511
|
*/
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (xf.
|
|
453
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
609
|
-
|
|
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
|
-
|
|
873
|
+
`print: unknown paper size ${JSON.stringify(size)} -- known: ${[...PAPER.keys()].join(", ")}`,
|
|
612
874
|
);
|
|
613
|
-
return value;
|
|
614
875
|
};
|
|
615
876
|
|
|
616
|
-
//
|
|
617
|
-
|
|
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
|
-
*
|
|
640
|
-
* @param {
|
|
641
|
-
* @param {
|
|
642
|
-
* @param {
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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>${
|
|
1051
|
+
`<sheetViews>${paneXml(frozen)}</sheetViews>` +
|
|
798
1052
|
`<sheetData>${rows.join("")}</sheetData>` +
|
|
799
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
946
|
-
//
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|