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