@xlsxflow/core 1.1.2 → 1.1.4
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/CHANGELOG.md +146 -108
- package/README.md +7 -4
- package/dist/index.cjs +1151 -789
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -8
- package/dist/index.d.ts +32 -8
- package/dist/index.mjs +1151 -790
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,367 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const CRC_TABLE = (() => {
|
|
4
|
+
const t = new Uint32Array(256);
|
|
5
|
+
for (let n = 0; n < 256; n++) {
|
|
6
|
+
let c = n;
|
|
7
|
+
for (let k = 0; k < 8; k++)
|
|
8
|
+
c = (c & 1) ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
|
9
|
+
t[n] = c >>> 0;
|
|
10
|
+
}
|
|
11
|
+
return t;
|
|
12
|
+
})();
|
|
13
|
+
function crc32(bytes) {
|
|
14
|
+
return (crc32Update(0xffffffff, bytes) ^ 0xffffffff) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
// Running CRC-32 over chunks: start at 0xffffffff, finish with (crc ^ 0xffffffff) >>> 0
|
|
17
|
+
function crc32Update(crc, bytes) {
|
|
18
|
+
for (let i = 0; i < bytes.length; i++)
|
|
19
|
+
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
20
|
+
return crc;
|
|
21
|
+
}
|
|
22
|
+
const MAX_U32 = 0xffffffff;
|
|
23
|
+
// General-purpose flag bit 11: the entry name is UTF-8 (set only when it isn't plain ASCII)
|
|
24
|
+
const utf8Flag = (name) => name.some(b => b > 0x7f) ? 0x0800 : 0;
|
|
25
|
+
// No ZIP64 support: fail loudly instead of writing a corrupt archive.
|
|
26
|
+
function assertNoZip64(value, what) {
|
|
27
|
+
if (value > MAX_U32)
|
|
28
|
+
throw new Error(`ZIP64 not supported: ${what} exceeds 4 GiB.`);
|
|
29
|
+
}
|
|
30
|
+
// A true Single-Pass Streaming ZIP Writer
|
|
31
|
+
class ZipStreamWriter {
|
|
32
|
+
cdEntries = [];
|
|
33
|
+
offset = 0;
|
|
34
|
+
streamController;
|
|
35
|
+
waiting = [];
|
|
36
|
+
cancelled = false;
|
|
37
|
+
stream;
|
|
38
|
+
textEncoder = new TextEncoder();
|
|
39
|
+
constructor(highWaterMarkBytes = 1 << 20) {
|
|
40
|
+
this.stream = new ReadableStream({
|
|
41
|
+
start: (controller) => {
|
|
42
|
+
this.streamController = controller;
|
|
43
|
+
},
|
|
44
|
+
pull: () => this.wake(),
|
|
45
|
+
// Consumer gone: unblock any pending write so the producer can see the error.
|
|
46
|
+
cancel: () => {
|
|
47
|
+
this.cancelled = true;
|
|
48
|
+
this.wake();
|
|
49
|
+
}
|
|
50
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark: highWaterMarkBytes }));
|
|
51
|
+
}
|
|
52
|
+
// Adds a file to the zip. `inputStream` MUST be raw uncompressed data.
|
|
53
|
+
async addFile(filenameStr, inputStream) {
|
|
54
|
+
const filename = this.textEncoder.encode(filenameStr);
|
|
55
|
+
const flags = 0x0008 | utf8Flag(filename);
|
|
56
|
+
const startOffset = this.offset;
|
|
57
|
+
await this.pushChunk(this.localHeader(filename, flags, 8, 0, 0, 0));
|
|
58
|
+
// Stream data, tracking sizes and CRC32
|
|
59
|
+
let uncompressedSize = 0;
|
|
60
|
+
let crc = 0xffffffff;
|
|
61
|
+
const crcStream = new TransformStream({
|
|
62
|
+
// CompressionStream takes every write at once, so the input is held back here instead,
|
|
63
|
+
// while nobody reads the output
|
|
64
|
+
transform: async (chunk, controller) => {
|
|
65
|
+
await this.roomInQueue();
|
|
66
|
+
uncompressedSize += chunk.length;
|
|
67
|
+
crc = crc32Update(crc, chunk);
|
|
68
|
+
controller.enqueue(chunk);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
let compressedSize = 0;
|
|
72
|
+
const reader = inputStream
|
|
73
|
+
.pipeThrough(crcStream)
|
|
74
|
+
.pipeThrough(new CompressionStream('deflate-raw'))
|
|
75
|
+
.getReader();
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
const { done, value } = await reader.read();
|
|
79
|
+
if (done)
|
|
80
|
+
break;
|
|
81
|
+
compressedSize += value.length;
|
|
82
|
+
await this.pushChunk(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
// Stop the source too (a row generator's finally runs, a database cursor closes)
|
|
87
|
+
await reader.cancel(err).catch(() => { });
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
91
|
+
assertNoZip64(uncompressedSize, filenameStr);
|
|
92
|
+
assertNoZip64(compressedSize, filenameStr);
|
|
93
|
+
// Data Descriptor
|
|
94
|
+
const desc = new Uint8Array(16);
|
|
95
|
+
const descView = new DataView(desc.buffer);
|
|
96
|
+
descView.setUint32(0, 0x08074b50, true);
|
|
97
|
+
descView.setUint32(4, crc, true);
|
|
98
|
+
descView.setUint32(8, compressedSize, true);
|
|
99
|
+
descView.setUint32(12, uncompressedSize, true);
|
|
100
|
+
await this.pushChunk(desc);
|
|
101
|
+
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags, method: 8 });
|
|
102
|
+
}
|
|
103
|
+
// Adds a file that is ALREADY compressed (pass-through for the Editor)
|
|
104
|
+
async addCompressedFile(filenameStr, compressedStream, uncompressedSize, compressedSize, crc, method = 8) {
|
|
105
|
+
const filename = this.textEncoder.encode(filenameStr);
|
|
106
|
+
const flags = utf8Flag(filename);
|
|
107
|
+
const startOffset = this.offset;
|
|
108
|
+
await this.pushChunk(this.localHeader(filename, flags, method, crc, compressedSize, uncompressedSize));
|
|
109
|
+
const reader = compressedStream.getReader();
|
|
110
|
+
while (true) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done)
|
|
113
|
+
break;
|
|
114
|
+
await this.pushChunk(value);
|
|
115
|
+
}
|
|
116
|
+
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags, method });
|
|
117
|
+
}
|
|
118
|
+
async close() {
|
|
119
|
+
// 0xFFFF in the end record means "see the ZIP64 record", which readers then look for
|
|
120
|
+
if (this.cdEntries.length >= 0xffff)
|
|
121
|
+
throw new Error('ZIP64 not supported: 65535 entries or more.');
|
|
122
|
+
const cdStartOffset = this.offset;
|
|
123
|
+
for (const entry of this.cdEntries) {
|
|
124
|
+
const cd = new Uint8Array(46 + entry.filename.length);
|
|
125
|
+
const view = new DataView(cd.buffer);
|
|
126
|
+
view.setUint32(0, 0x02014b50, true);
|
|
127
|
+
view.setUint16(4, 20, true); // version made by
|
|
128
|
+
view.setUint16(6, 20, true); // version needed
|
|
129
|
+
view.setUint16(8, entry.flags, true);
|
|
130
|
+
view.setUint16(10, entry.method, true);
|
|
131
|
+
view.setUint32(16, entry.crc, true);
|
|
132
|
+
view.setUint32(20, entry.compressedSize, true);
|
|
133
|
+
view.setUint32(24, entry.uncompressedSize, true);
|
|
134
|
+
view.setUint16(28, entry.filename.length, true);
|
|
135
|
+
view.setUint32(42, entry.offset, true);
|
|
136
|
+
cd.set(entry.filename, 46);
|
|
137
|
+
await this.pushChunk(cd);
|
|
138
|
+
}
|
|
139
|
+
const cdSize = this.offset - cdStartOffset;
|
|
140
|
+
assertNoZip64(this.offset, 'archive');
|
|
141
|
+
const eocd = new Uint8Array(22);
|
|
142
|
+
const eocdView = new DataView(eocd.buffer);
|
|
143
|
+
eocdView.setUint32(0, 0x06054b50, true);
|
|
144
|
+
eocdView.setUint16(8, this.cdEntries.length, true);
|
|
145
|
+
eocdView.setUint16(10, this.cdEntries.length, true);
|
|
146
|
+
eocdView.setUint32(12, cdSize, true);
|
|
147
|
+
eocdView.setUint32(16, cdStartOffset, true);
|
|
148
|
+
await this.pushChunk(eocd);
|
|
149
|
+
this.streamController.close();
|
|
150
|
+
}
|
|
151
|
+
// Propagate a producer failure to whoever is reading `stream`.
|
|
152
|
+
error(err) {
|
|
153
|
+
try {
|
|
154
|
+
this.streamController.error(err);
|
|
155
|
+
}
|
|
156
|
+
catch { /* already closed/errored */ }
|
|
157
|
+
}
|
|
158
|
+
localHeader(filename, flags, method, crc, compressedSize, uncompressedSize) {
|
|
159
|
+
assertNoZip64(this.offset, 'archive');
|
|
160
|
+
const header = new Uint8Array(30 + filename.length);
|
|
161
|
+
const view = new DataView(header.buffer);
|
|
162
|
+
view.setUint32(0, 0x04034b50, true);
|
|
163
|
+
view.setUint16(4, 20, true);
|
|
164
|
+
view.setUint16(6, flags, true);
|
|
165
|
+
view.setUint16(8, method, true);
|
|
166
|
+
view.setUint32(14, crc, true);
|
|
167
|
+
view.setUint32(18, compressedSize, true);
|
|
168
|
+
view.setUint32(22, uncompressedSize, true);
|
|
169
|
+
view.setUint16(26, filename.length, true);
|
|
170
|
+
header.set(filename, 30);
|
|
171
|
+
return header;
|
|
172
|
+
}
|
|
173
|
+
// Enqueue and wait while the consumer's queue is full, so memory stays bounded.
|
|
174
|
+
async pushChunk(chunk) {
|
|
175
|
+
if (this.cancelled)
|
|
176
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
177
|
+
this.streamController.enqueue(chunk);
|
|
178
|
+
this.offset += chunk.length;
|
|
179
|
+
await this.roomInQueue();
|
|
180
|
+
}
|
|
181
|
+
async roomInQueue() {
|
|
182
|
+
while ((this.streamController.desiredSize ?? 1) <= 0) {
|
|
183
|
+
if (this.cancelled)
|
|
184
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
185
|
+
await new Promise(resolve => this.waiting.push(resolve));
|
|
186
|
+
}
|
|
187
|
+
if (this.cancelled)
|
|
188
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
189
|
+
}
|
|
190
|
+
wake() {
|
|
191
|
+
const waiting = this.waiting;
|
|
192
|
+
this.waiting = [];
|
|
193
|
+
for (const resolve of waiting)
|
|
194
|
+
resolve();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Reader for Compound File Binary files [MS-CFB]: the container of .xls workbooks and of
|
|
199
|
+
// password-protected .xlsx files. Streams are read from an in-memory copy of the file.
|
|
200
|
+
const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
201
|
+
const END_OF_CHAIN = 0xfffffffe;
|
|
202
|
+
const MAX_REGULAR_SECTOR = 0xfffffffa;
|
|
203
|
+
function isCfb(bytes) {
|
|
204
|
+
return bytes.length >= 8 && SIGNATURE.every((b, i) => bytes[i] === b);
|
|
205
|
+
}
|
|
206
|
+
const corrupt$1 = (why) => new Error(`Corrupt compound file: ${why}`);
|
|
207
|
+
class CfbReader {
|
|
208
|
+
bytes;
|
|
209
|
+
view;
|
|
210
|
+
sectorSize;
|
|
211
|
+
miniSectorSize;
|
|
212
|
+
miniCutoff;
|
|
213
|
+
fat;
|
|
214
|
+
miniFat;
|
|
215
|
+
miniStream;
|
|
216
|
+
dir;
|
|
217
|
+
byPath = new Map();
|
|
218
|
+
constructor(bytes) {
|
|
219
|
+
this.bytes = bytes;
|
|
220
|
+
if (!isCfb(bytes))
|
|
221
|
+
throw new Error('Not a compound file (no D0CF11E0 signature)');
|
|
222
|
+
if (bytes.length < 512)
|
|
223
|
+
throw corrupt$1(`only ${bytes.length} bytes, shorter than its 512-byte header`);
|
|
224
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
225
|
+
const u16 = (o) => this.view.getUint16(o, true);
|
|
226
|
+
const u32 = (o) => this.view.getUint32(o, true);
|
|
227
|
+
const sectorShift = u16(0x1e);
|
|
228
|
+
if (sectorShift !== 9 && sectorShift !== 12)
|
|
229
|
+
throw corrupt$1(`sector shift ${sectorShift}`);
|
|
230
|
+
this.sectorSize = 1 << sectorShift;
|
|
231
|
+
const miniShift = u16(0x20);
|
|
232
|
+
if (miniShift !== 6)
|
|
233
|
+
throw corrupt$1(`mini sector shift ${miniShift}`);
|
|
234
|
+
this.miniSectorSize = 1 << miniShift;
|
|
235
|
+
this.miniCutoff = u32(0x38);
|
|
236
|
+
// The FAT's own sectors are listed in the header (109 slots) and then in a chain of DIFAT sectors
|
|
237
|
+
const numFatSectors = u32(0x2c);
|
|
238
|
+
const sectorCount = Math.ceil(bytes.length / this.sectorSize) - 1;
|
|
239
|
+
if (numFatSectors > sectorCount)
|
|
240
|
+
throw corrupt$1(`${numFatSectors} FAT sectors in a ${bytes.length}-byte file`);
|
|
241
|
+
const fatSectors = [];
|
|
242
|
+
for (let i = 0; i < 109 && fatSectors.length < numFatSectors; i++)
|
|
243
|
+
fatSectors.push(u32(0x4c + i * 4));
|
|
244
|
+
const perDifat = this.sectorSize / 4 - 1;
|
|
245
|
+
for (let s = u32(0x44), hops = 0; fatSectors.length < numFatSectors; hops++) {
|
|
246
|
+
if (s > MAX_REGULAR_SECTOR || hops > sectorCount)
|
|
247
|
+
throw corrupt$1('DIFAT chain ends early');
|
|
248
|
+
const at = this.sectorOffset(s);
|
|
249
|
+
for (let i = 0; i < perDifat && fatSectors.length < numFatSectors; i++)
|
|
250
|
+
fatSectors.push(u32(at + i * 4));
|
|
251
|
+
s = u32(at + perDifat * 4);
|
|
252
|
+
}
|
|
253
|
+
const perSector = this.sectorSize / 4;
|
|
254
|
+
this.fat = new Uint32Array(numFatSectors * perSector);
|
|
255
|
+
fatSectors.forEach((s, i) => {
|
|
256
|
+
const at = this.sectorOffset(s);
|
|
257
|
+
for (let j = 0; j < perSector; j++)
|
|
258
|
+
this.fat[i * perSector + j] = u32(at + j * 4);
|
|
259
|
+
});
|
|
260
|
+
// Directory: 128-byte entries in the chain starting at the header's first directory sector
|
|
261
|
+
const dirBytes = this.readChain(u32(0x30), Infinity);
|
|
262
|
+
this.dir = [];
|
|
263
|
+
for (let at = 0; at + 128 <= dirBytes.length; at += 128) {
|
|
264
|
+
const d = new DataView(dirBytes.buffer, dirBytes.byteOffset + at, 128);
|
|
265
|
+
const nameLen = Math.min(d.getUint16(64, true), 64);
|
|
266
|
+
let name = '';
|
|
267
|
+
for (let i = 0; i + 2 < nameLen; i += 2)
|
|
268
|
+
name += String.fromCharCode(d.getUint16(i, true));
|
|
269
|
+
// Version 3 files may leave garbage in the size's high half
|
|
270
|
+
const size = sectorShift === 9 ? d.getUint32(120, true) : d.getUint32(120, true) + d.getUint32(124, true) * 2 ** 32;
|
|
271
|
+
this.dir.push({ name, type: d.getUint8(66), left: d.getUint32(68, true), right: d.getUint32(72, true),
|
|
272
|
+
child: d.getUint32(76, true), start: d.getUint32(116, true), size });
|
|
273
|
+
}
|
|
274
|
+
if (this.dir[0]?.type !== 5)
|
|
275
|
+
throw corrupt$1('no root entry');
|
|
276
|
+
this.walk(this.dir[0].child, '');
|
|
277
|
+
}
|
|
278
|
+
// Start of a sector, checked to hold `need` bytes (the last sector of a file may be cut short)
|
|
279
|
+
sectorOffset(sector, need = this.sectorSize) {
|
|
280
|
+
const at = (sector + 1) * this.sectorSize;
|
|
281
|
+
if (sector > MAX_REGULAR_SECTOR || at + need > this.bytes.length) {
|
|
282
|
+
throw corrupt$1(`sector ${sector} is outside the file`);
|
|
283
|
+
}
|
|
284
|
+
return at;
|
|
285
|
+
}
|
|
286
|
+
// Sibling entries form a red-black tree; children of a storage hang off its `child`
|
|
287
|
+
walk(root, prefix) {
|
|
288
|
+
const stack = [root];
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
while (stack.length) {
|
|
291
|
+
const id = stack.pop();
|
|
292
|
+
if (id > MAX_REGULAR_SECTOR)
|
|
293
|
+
continue; // NOSTREAM
|
|
294
|
+
if (seen.has(id) || !this.dir[id])
|
|
295
|
+
throw corrupt$1('directory tree loops or points outside the directory');
|
|
296
|
+
seen.add(id);
|
|
297
|
+
const e = this.dir[id];
|
|
298
|
+
stack.push(e.left, e.right);
|
|
299
|
+
if (e.type !== 1 && e.type !== 2)
|
|
300
|
+
continue;
|
|
301
|
+
const path = prefix + e.name;
|
|
302
|
+
this.byPath.set(path.toLowerCase(), { ...e, path });
|
|
303
|
+
if (e.type === 1)
|
|
304
|
+
this.walk(e.child, path + '/');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
readChain(start, size, mini = false) {
|
|
308
|
+
const table = mini ? this.miniFat : this.fat;
|
|
309
|
+
const unit = mini ? this.miniSectorSize : this.sectorSize;
|
|
310
|
+
const source = mini ? this.miniStream : this.bytes;
|
|
311
|
+
const sectors = [];
|
|
312
|
+
for (let s = start; s !== END_OF_CHAIN && sectors.length * unit < size; s = table[s]) {
|
|
313
|
+
if (s >= table.length || sectors.length > table.length)
|
|
314
|
+
throw corrupt$1(`${mini ? 'mini ' : ''}sector chain is broken or loops`);
|
|
315
|
+
sectors.push(s);
|
|
316
|
+
}
|
|
317
|
+
const total = Math.min(size, sectors.length * unit);
|
|
318
|
+
if (size !== Infinity && total < size)
|
|
319
|
+
throw corrupt$1(`stream is shorter (${total} bytes) than its stated ${size}`);
|
|
320
|
+
const out = new Uint8Array(total);
|
|
321
|
+
sectors.forEach((s, i) => {
|
|
322
|
+
const len = Math.min(unit, total - i * unit);
|
|
323
|
+
const at = mini ? s * unit : this.sectorOffset(s, len);
|
|
324
|
+
if (at + len > source.length)
|
|
325
|
+
throw corrupt$1(`mini sector ${s} is outside the mini stream`);
|
|
326
|
+
out.set(source.subarray(at, at + len), i * unit);
|
|
327
|
+
});
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
entries() {
|
|
331
|
+
return [...this.byPath.values()].map(e => ({ path: e.path, type: e.type === 1 ? 'storage' : 'stream', size: e.size }));
|
|
332
|
+
}
|
|
333
|
+
has(path) {
|
|
334
|
+
return this.byPath.get(path.toLowerCase())?.type === 2;
|
|
335
|
+
}
|
|
336
|
+
// A stream's bytes, by path (case-insensitive, as in the format); undefined if there is none
|
|
337
|
+
read(path) {
|
|
338
|
+
const e = this.byPath.get(path.toLowerCase());
|
|
339
|
+
if (!e || e.type !== 2)
|
|
340
|
+
return undefined;
|
|
341
|
+
if (e.size >= this.miniCutoff) {
|
|
342
|
+
if (e.size > this.bytes.length)
|
|
343
|
+
throw corrupt$1(`stream ${e.path} is larger than the file`);
|
|
344
|
+
return this.readChain(e.start, e.size);
|
|
345
|
+
}
|
|
346
|
+
if (!this.miniStream) {
|
|
347
|
+
const root = this.dir[0];
|
|
348
|
+
this.miniStream = this.readChain(root.start, root.size);
|
|
349
|
+
const view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
|
|
350
|
+
const miniFatBytes = this.readChain(view.getUint32(0x3c, true), view.getUint32(0x40, true) * this.sectorSize);
|
|
351
|
+
this.miniFat = new Uint32Array(miniFatBytes.length / 4);
|
|
352
|
+
const mv = new DataView(miniFatBytes.buffer, miniFatBytes.byteOffset, miniFatBytes.byteLength);
|
|
353
|
+
for (let i = 0; i < this.miniFat.length; i++)
|
|
354
|
+
this.miniFat[i] = mv.getUint32(i * 4, true);
|
|
355
|
+
}
|
|
356
|
+
return this.readChain(e.start, e.size, true);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
3
360
|
class ZipRandomAccessParser {
|
|
4
361
|
reader;
|
|
5
362
|
records = new Map();
|
|
363
|
+
// Part names are case-insensitive in OPC: a rels target "Sheet1.xml" finds "sheet1.xml"
|
|
364
|
+
lowerCase = new Map();
|
|
6
365
|
constructor(reader) {
|
|
7
366
|
this.reader = reader;
|
|
8
367
|
}
|
|
@@ -15,15 +374,24 @@ class ZipRandomAccessParser {
|
|
|
15
374
|
const searchStart = size - searchSize;
|
|
16
375
|
const buffer = await this.reader.read(searchStart, searchSize);
|
|
17
376
|
const dataView = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
18
|
-
// Search backwards for the EOCD signature (0x06054b50)
|
|
377
|
+
// Search backwards for the EOCD signature (0x06054b50). The real record's comment reaches the end
|
|
378
|
+
// of the file, so the signature bytes inside a comment are skipped; failing that (junk appended
|
|
379
|
+
// after the archive), the last signature is used.
|
|
19
380
|
let eocdOffset = -1;
|
|
20
381
|
for (let i = buffer.length - 22; i >= 0; i--) {
|
|
21
|
-
if (dataView.getUint32(i, true)
|
|
382
|
+
if (dataView.getUint32(i, true) !== 0x06054b50)
|
|
383
|
+
continue;
|
|
384
|
+
if (eocdOffset === -1)
|
|
385
|
+
eocdOffset = i;
|
|
386
|
+
if (i + 22 + dataView.getUint16(i + 20, true) === buffer.length) {
|
|
22
387
|
eocdOffset = i;
|
|
23
388
|
break;
|
|
24
389
|
}
|
|
25
390
|
}
|
|
26
391
|
if (eocdOffset === -1) {
|
|
392
|
+
if (isCfb(buffer.subarray(0, 8)) || searchStart > 0 && isCfb(await this.reader.read(0, 8))) {
|
|
393
|
+
throw new Error('This file is a compound file, not a ZIP: an .xls, or a workbook saved with a password. Decrypt it first with decryptWorkbook from @xlsxflow/pro.');
|
|
394
|
+
}
|
|
27
395
|
throw new Error("End of Central Directory (EOCD) not found. This may not be a valid ZIP file.");
|
|
28
396
|
}
|
|
29
397
|
const totalRecords = dataView.getUint16(eocdOffset + 10, true);
|
|
@@ -60,14 +428,10 @@ class ZipRandomAccessParser {
|
|
|
60
428
|
if (filename.includes('../') || filename.includes('..\\')) {
|
|
61
429
|
throw new Error(`Security Exception: Path traversal detected in ZIP filename: ${filename}`);
|
|
62
430
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
compressedSize,
|
|
68
|
-
uncompressedSize,
|
|
69
|
-
localHeaderOffset
|
|
70
|
-
});
|
|
431
|
+
const record = { filename, compressionMethod, crc, compressedSize, uncompressedSize, localHeaderOffset };
|
|
432
|
+
this.records.set(filename, record);
|
|
433
|
+
if (!this.lowerCase.has(filename.toLowerCase()))
|
|
434
|
+
this.lowerCase.set(filename.toLowerCase(), record);
|
|
71
435
|
offset += 46 + filenameLength + extraFieldLength + fileCommentLength;
|
|
72
436
|
}
|
|
73
437
|
// Entries must not share bytes: aliased names would let one small deflated entry be inflated many times
|
|
@@ -80,13 +444,13 @@ class ZipRandomAccessParser {
|
|
|
80
444
|
}
|
|
81
445
|
}
|
|
82
446
|
has(filename) {
|
|
83
|
-
return this.records.has(filename);
|
|
447
|
+
return this.records.has(filename) || this.lowerCase.has(filename.toLowerCase());
|
|
84
448
|
}
|
|
85
449
|
getFiles() {
|
|
86
450
|
return Array.from(this.records.keys());
|
|
87
451
|
}
|
|
88
452
|
getRecord(filename) {
|
|
89
|
-
const record = this.records.get(filename);
|
|
453
|
+
const record = this.records.get(filename) ?? this.lowerCase.get(filename.toLowerCase());
|
|
90
454
|
if (!record)
|
|
91
455
|
throw new Error(`File ${filename} not found in ZIP.`);
|
|
92
456
|
return record;
|
|
@@ -118,17 +482,24 @@ class ZipRandomAccessParser {
|
|
|
118
482
|
throw new Error(`Unsupported compression method ${record.compressionMethod} for ${filename}`);
|
|
119
483
|
}
|
|
120
484
|
const stream = await this.extractRawStream(filename);
|
|
121
|
-
|
|
122
|
-
|
|
485
|
+
const data = record.compressionMethod === 0 ? stream
|
|
486
|
+
: stream.pipeThrough(new DecompressionStream('deflate-raw'));
|
|
123
487
|
// Node reports bad deflate data as a bare TypeError; name the entry instead
|
|
124
|
-
const inflated =
|
|
488
|
+
const inflated = data.getReader();
|
|
125
489
|
let total = 0;
|
|
490
|
+
let crc = 0xffffffff;
|
|
126
491
|
return new ReadableStream({
|
|
127
492
|
async pull(controller) {
|
|
128
493
|
try {
|
|
129
494
|
const { done, value } = await inflated.read();
|
|
130
|
-
if (done)
|
|
495
|
+
if (done) {
|
|
496
|
+
// Changed bytes must fail, not read as different data
|
|
497
|
+
if (total !== record.uncompressedSize || ((crc ^ 0xffffffff) >>> 0) !== record.crc) {
|
|
498
|
+
return controller.error(new Error(`Corrupt ZIP entry ${filename}: its data does not match the size and CRC-32 in the directory`));
|
|
499
|
+
}
|
|
131
500
|
return controller.close();
|
|
501
|
+
}
|
|
502
|
+
crc = crc32Update(crc, value);
|
|
132
503
|
// The directory states each entry's size; inflating past it means a crafted entry (a zip bomb)
|
|
133
504
|
total += value.length;
|
|
134
505
|
if (total > record.uncompressedSize) {
|
|
@@ -181,7 +552,7 @@ function stripNamespace(name) {
|
|
|
181
552
|
// Emits one array of tokens per input chunk. Per-token stream chunks cost a promise round-trip
|
|
182
553
|
// each (~5 per cell), which dominated read time; batching removes that overhead.
|
|
183
554
|
function createXmlBatchParser() {
|
|
184
|
-
|
|
555
|
+
let decoder;
|
|
185
556
|
let buffer = '';
|
|
186
557
|
let isFirstChunk = true;
|
|
187
558
|
// When a construct spans chunks, remember how far it was already scanned (and the quote state
|
|
@@ -192,6 +563,8 @@ function createXmlBatchParser() {
|
|
|
192
563
|
let pendingQuote = '';
|
|
193
564
|
return new TransformStream({
|
|
194
565
|
transform(chunk, controller) {
|
|
566
|
+
// XML parsers must read UTF-16 as well as UTF-8; a UTF-16 part starts with its byte-order mark
|
|
567
|
+
decoder ??= new TextDecoder(chunk[0] === 0xff && chunk[1] === 0xfe ? 'utf-16le' : chunk[0] === 0xfe && chunk[1] === 0xff ? 'utf-16be' : 'utf-8');
|
|
195
568
|
buffer += decoder.decode(chunk, { stream: true });
|
|
196
569
|
if (isFirstChunk) {
|
|
197
570
|
if (buffer.charCodeAt(0) === 0xFEFF) {
|
|
@@ -290,7 +663,7 @@ function createXmlBatchParser() {
|
|
|
290
663
|
controller.enqueue(out);
|
|
291
664
|
},
|
|
292
665
|
flush(controller) {
|
|
293
|
-
buffer += decoder
|
|
666
|
+
buffer += decoder?.decode() ?? '';
|
|
294
667
|
// Handle trailing text if any
|
|
295
668
|
if (buffer.length > 0 && pending !== 'tag' && pending !== 'cdata' && pending !== 'comment') {
|
|
296
669
|
controller.enqueue([{ type: 'text', value: unescapeXml$1(normalizeEol(buffer)) }]);
|
|
@@ -362,6 +735,17 @@ async function sheetToJson(parseResult, headerRowIndex = 0) {
|
|
|
362
735
|
});
|
|
363
736
|
continue;
|
|
364
737
|
}
|
|
738
|
+
// Values right of the header row get their own column name, as blank headers do
|
|
739
|
+
for (let i = headers.length; i < row.cells.length; i++) {
|
|
740
|
+
if (row.cells[i] === null || row.cells[i] === undefined)
|
|
741
|
+
continue;
|
|
742
|
+
let name = `Column${i + 1}`;
|
|
743
|
+
for (let n = 2; headers.includes(name); n++)
|
|
744
|
+
name = `Column${i + 1}_${n}`;
|
|
745
|
+
while (headers.length < i)
|
|
746
|
+
headers.push(`Column${headers.length + 1}`);
|
|
747
|
+
headers.push(name);
|
|
748
|
+
}
|
|
365
749
|
const obj = {};
|
|
366
750
|
for (let i = 0; i < headers.length; i++) {
|
|
367
751
|
const value = row.cells[i] ?? null;
|
|
@@ -386,9 +770,13 @@ function escapeCsv(val) {
|
|
|
386
770
|
}
|
|
387
771
|
async function streamToCsv(parseResult) {
|
|
388
772
|
const rows = [];
|
|
773
|
+
let last = 0;
|
|
389
774
|
for await (const row of parseResult) {
|
|
390
|
-
|
|
391
|
-
|
|
775
|
+
// Rows missing between rows are blank lines, as in Excel's CSV export, so nothing shifts up
|
|
776
|
+
for (let gap = last ? row.rowNumber - last - 1 : 0; gap > 0; gap--)
|
|
777
|
+
rows.push('');
|
|
778
|
+
last = row.rowNumber;
|
|
779
|
+
rows.push(row.cells.map(escapeCsv).join(','));
|
|
392
780
|
}
|
|
393
781
|
return rows.join('\n');
|
|
394
782
|
}
|
|
@@ -434,15 +822,18 @@ async function resolveWorkbookParts(readText) {
|
|
|
434
822
|
const workbookXml = await readText(workbookPath);
|
|
435
823
|
const rels = parseRels(await readText(relsPathOf(workbookPath)), dirOf(workbookPath));
|
|
436
824
|
const sheets = new Map();
|
|
825
|
+
const chartsheets = new Set();
|
|
437
826
|
for (const [tag] of workbookXml.matchAll(/<(?:\w+:)?sheet\b[^>]*>/g)) {
|
|
438
827
|
const name = attr(tag, 'name');
|
|
439
828
|
const rId = attr(tag, 'r:id') ?? attr(tag, '\\w+:id');
|
|
440
829
|
const rel = rId ? rels.find(r => r.id === rId && !r.external) : undefined;
|
|
441
830
|
if (name && rel)
|
|
442
831
|
sheets.set(name, rel.path);
|
|
832
|
+
if (rel?.type.endsWith('/chartsheet'))
|
|
833
|
+
chartsheets.add(rel.path);
|
|
443
834
|
}
|
|
444
835
|
return {
|
|
445
|
-
workbookPath, workbookXml, sheets,
|
|
836
|
+
workbookPath, workbookXml, sheets, chartsheets,
|
|
446
837
|
sharedStrings: byType(rels, '/sharedStrings'), styles: byType(rels, '/styles'), theme: byType(rels, '/theme'),
|
|
447
838
|
};
|
|
448
839
|
}
|
|
@@ -464,6 +855,21 @@ function decodeXString(s) {
|
|
|
464
855
|
// Excel's sheet size (A..XFD, 1..1048576)
|
|
465
856
|
const MAX_COLUMNS$1 = 16384;
|
|
466
857
|
const MAX_ROWS$1 = 1048576;
|
|
858
|
+
// Excel's limit on the characters in one cell
|
|
859
|
+
const MAX_CELL_TEXT = 32767;
|
|
860
|
+
function checkCellText(s, ref) {
|
|
861
|
+
if (s.length > MAX_CELL_TEXT)
|
|
862
|
+
throw new Error(`Cell ${ref} has ${s.length} characters, more than Excel's ${MAX_CELL_TEXT}.`);
|
|
863
|
+
return s;
|
|
864
|
+
}
|
|
865
|
+
// Colours are ARGB hex ("FFFF0000"); "FF0000" and "#FF0000" get an opaque alpha. Anything else
|
|
866
|
+
// makes Excel repair the file.
|
|
867
|
+
function argb(c) {
|
|
868
|
+
const m = /^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.exec(String(c));
|
|
869
|
+
if (!m)
|
|
870
|
+
throw new Error(`Invalid colour "${c}": use ARGB hex such as "FFFF0000".`);
|
|
871
|
+
return (m[1].length === 6 ? 'FF' + m[1] : m[1]).toUpperCase();
|
|
872
|
+
}
|
|
467
873
|
// 0-based column of letters like "BC" (or a reference like "BC12"); -1 when there are none
|
|
468
874
|
function colIndex(ref) {
|
|
469
875
|
const match = ref.match(/[A-Za-z]+/);
|
|
@@ -504,16 +910,23 @@ function mapFormulaRefs(formula, mapCol, mapRow) {
|
|
|
504
910
|
if (isEnd)
|
|
505
911
|
sheet = prevSheet;
|
|
506
912
|
else if (before.endsWith('!')) {
|
|
507
|
-
|
|
508
|
-
|
|
913
|
+
// [1]Sheet!A1 points into another workbook: "[" can't be in a sheet name, so no sheet matches it
|
|
914
|
+
const plain = /(\]?)([A-Za-z0-9_.\u00C0-\uFFFF]+)!$/.exec(before);
|
|
915
|
+
sheet = plain ? (plain[1] ? `[external]${plain[2]}` : plain[2])
|
|
916
|
+
: offset === 1 && i > 0 && parts[i - 1].startsWith("'") ? unquoteSheet(parts[i - 1]) : undefined;
|
|
509
917
|
}
|
|
510
918
|
const role = isEnd ? 'end' : part[offset + m.length] === ':' ? 'start' : 'single';
|
|
919
|
+
// The end of a range pushed past the sheet's edge stays at the edge, as in Excel (SUM(B1:B1048576))
|
|
511
920
|
const col = (abs, letters, r) => {
|
|
512
|
-
|
|
921
|
+
let c = mapCol(colIndex(letters), !!abs, r, sheet);
|
|
922
|
+
if (c !== null && c >= MAX_COLUMNS$1 && r === 'end' && colIndex(letters) < MAX_COLUMNS$1)
|
|
923
|
+
c = MAX_COLUMNS$1 - 1;
|
|
513
924
|
return c !== null && c >= 0 && c < MAX_COLUMNS$1 ? abs + colLetter(c) : null;
|
|
514
925
|
};
|
|
515
926
|
const row = (abs, digits, r) => {
|
|
516
|
-
|
|
927
|
+
let n = mapRow(parseInt(digits, 10), !!abs, r, sheet);
|
|
928
|
+
if (n !== null && n > MAX_ROWS$1 && r === 'end' && parseInt(digits, 10) <= MAX_ROWS$1)
|
|
929
|
+
n = MAX_ROWS$1;
|
|
517
930
|
return n !== null && n >= 1 && n <= MAX_ROWS$1 ? n : null;
|
|
518
931
|
};
|
|
519
932
|
prevEnd = offset + m.length;
|
|
@@ -561,7 +974,7 @@ function dateToSerial(d) {
|
|
|
561
974
|
function encodeXString(s) {
|
|
562
975
|
return s
|
|
563
976
|
.replace(/_(?=x[0-9A-Fa-f]{4}_)/g, '_x005F_')
|
|
564
|
-
.replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
|
|
977
|
+
.replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F\uFFFE\uFFFF]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
|
|
565
978
|
}
|
|
566
979
|
// The format SheetWriter gives a date without one: the time only when there is one
|
|
567
980
|
const defaultDateFormat = (d) => d.getTime() % 86400000 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm:ss';
|
|
@@ -605,15 +1018,18 @@ async function recalcOnOpen(readText, parts, workbookXml = parts.workbookXml) {
|
|
|
605
1018
|
}
|
|
606
1019
|
// Excel refuses to open a workbook that breaks these rules
|
|
607
1020
|
function validateSheetName(name, existing) {
|
|
608
|
-
if (!name || name.length > 31 || /[\\/?*:[\]]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
|
|
609
|
-
throw new Error(`Invalid sheet name "${name}": 1-31 characters, none of \\ / ? * : [ ], and no leading or trailing apostrophe.`);
|
|
1021
|
+
if (!name || name.length > 31 || /[\\/?*:[\]\x00-\x1F\uFFFE\uFFFF]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
|
|
1022
|
+
throw new Error(`Invalid sheet name "${name}": 1-31 characters, none of \\ / ? * : [ ] or control characters, and no leading or trailing apostrophe.`);
|
|
610
1023
|
}
|
|
611
1024
|
for (const other of existing) {
|
|
612
1025
|
if (other.toLowerCase() === name.toLowerCase())
|
|
613
1026
|
throw new Error(`Duplicate sheet name "${name}".`);
|
|
614
1027
|
}
|
|
615
1028
|
}
|
|
616
|
-
|
|
1029
|
+
// Characters XML 1.0 can't carry at all. Text that allows _xHHHH_ escapes keeps them through
|
|
1030
|
+
// encodeXString first; anywhere else (names, properties, formulas) they are dropped.
|
|
1031
|
+
const INVALID_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g;
|
|
1032
|
+
const escapeXml$4 = (val) => val.replace(INVALID_XML_CHARS, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
617
1033
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
618
1034
|
// Out-of-range references (�) stay as written instead of throwing
|
|
619
1035
|
const fromCodePoint = (cp, m) => (cp <= 0x10ffff ? String.fromCodePoint(cp) : m);
|
|
@@ -1269,6 +1685,13 @@ class ParseResult {
|
|
|
1269
1685
|
return this.comments();
|
|
1270
1686
|
}
|
|
1271
1687
|
}
|
|
1688
|
+
// "2026-10-08T14:05:00" or "2026-10-08" (a t="d" cell, no zone: UTC) as an ISO string; undefined when unreadable
|
|
1689
|
+
function isoDateCell(text) {
|
|
1690
|
+
const t = text.trim();
|
|
1691
|
+
const zoned = /(?:Z|[+-]\d\d:?\d\d)$/.test(t) ? t : t.includes('T') ? t + 'Z' : t + 'T00:00:00Z';
|
|
1692
|
+
const d = new Date(zoned);
|
|
1693
|
+
return isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
1694
|
+
}
|
|
1272
1695
|
function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, options = {}) {
|
|
1273
1696
|
let resolveMeta;
|
|
1274
1697
|
let rejectMeta;
|
|
@@ -1304,12 +1727,14 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1304
1727
|
let rowStyles;
|
|
1305
1728
|
let rowRichText;
|
|
1306
1729
|
let rowFormatted;
|
|
1730
|
+
let rowErrors;
|
|
1307
1731
|
let rawNumber; // a date cell's serial, for formatted text
|
|
1308
1732
|
const runs = options.richText ? new RichTextCollector(options.richText) : undefined;
|
|
1309
1733
|
let skipDepth = 0;
|
|
1310
1734
|
// Shared formula anchors by si: the text and the cell it was written for
|
|
1311
1735
|
const sharedFormulas = new Map();
|
|
1312
1736
|
let currentRowNumber = 0;
|
|
1737
|
+
let openedWorksheet = false, closedWorksheet = false;
|
|
1313
1738
|
let currentColIndex = 0;
|
|
1314
1739
|
let currentCellCol = 0;
|
|
1315
1740
|
try {
|
|
@@ -1330,18 +1755,17 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1330
1755
|
}
|
|
1331
1756
|
continue;
|
|
1332
1757
|
}
|
|
1758
|
+
if (token.name === 'worksheet')
|
|
1759
|
+
openedWorksheet = true;
|
|
1333
1760
|
if (token.name === 'row') {
|
|
1334
1761
|
inRow = true;
|
|
1335
1762
|
currentRow = [];
|
|
1336
1763
|
rowFormulas = rowStyles = rowRichText = rowFormatted = undefined;
|
|
1764
|
+
rowErrors = undefined;
|
|
1337
1765
|
currentColIndex = 0;
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
}
|
|
1342
|
-
else {
|
|
1343
|
-
currentRowNumber++;
|
|
1344
|
-
}
|
|
1766
|
+
// A missing or unusable r ("abc", "0") means the row after the previous one
|
|
1767
|
+
const r = Number(token.attributes['r']);
|
|
1768
|
+
currentRowNumber = Number.isInteger(r) && r >= 1 ? r : currentRowNumber + 1;
|
|
1345
1769
|
if (token.attributes['hidden'] === '1' || token.attributes['hidden'] === 'true') {
|
|
1346
1770
|
hiddenRows.push(currentRowNumber);
|
|
1347
1771
|
}
|
|
@@ -1431,6 +1855,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1431
1855
|
}
|
|
1432
1856
|
if (skipDepth > 0)
|
|
1433
1857
|
continue;
|
|
1858
|
+
if (token.name === 'worksheet')
|
|
1859
|
+
closedWorksheet = true;
|
|
1434
1860
|
if (token.name === 'row') {
|
|
1435
1861
|
const row = { rowNumber: currentRowNumber, cells: currentRow };
|
|
1436
1862
|
if (rowFormulas)
|
|
@@ -1441,6 +1867,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1441
1867
|
row.richText = rowRichText;
|
|
1442
1868
|
if (rowFormatted)
|
|
1443
1869
|
row.formatted = rowFormatted;
|
|
1870
|
+
if (rowErrors)
|
|
1871
|
+
row.errors = rowErrors;
|
|
1444
1872
|
yield row;
|
|
1445
1873
|
inRow = false;
|
|
1446
1874
|
}
|
|
@@ -1461,18 +1889,27 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1461
1889
|
}
|
|
1462
1890
|
else if (currentCellType === 's') {
|
|
1463
1891
|
const index = parseInt(currentCellValue, 10);
|
|
1464
|
-
|
|
1892
|
+
// An index past the table points nowhere: the cell is empty
|
|
1893
|
+
resolvedValue = sharedStrings.get(index) ?? null;
|
|
1465
1894
|
}
|
|
1466
1895
|
else if (currentCellType === 'b') {
|
|
1467
1896
|
resolvedValue = currentCellValue === '1' || currentCellValue === 'true';
|
|
1468
1897
|
}
|
|
1469
1898
|
else if (currentCellType === 'e') {
|
|
1470
1899
|
resolvedValue = currentCellValue;
|
|
1900
|
+
if (options.errors)
|
|
1901
|
+
(rowErrors ??= [])[currentCellCol] = true;
|
|
1471
1902
|
}
|
|
1472
1903
|
else {
|
|
1473
1904
|
const num = Number(currentCellValue);
|
|
1474
|
-
|
|
1475
|
-
|
|
1905
|
+
const iso = currentCellType === 'd' ? isoDateCell(currentCellValue) : undefined;
|
|
1906
|
+
if (iso) {
|
|
1907
|
+
// t="d": ISO text without a zone, read as UTC like serial dates
|
|
1908
|
+
rawNumber = dateToSerial(new Date(iso)) - (is1904 ? 1462 : 0);
|
|
1909
|
+
resolvedValue = iso;
|
|
1910
|
+
}
|
|
1911
|
+
else if (isNaN(num)) {
|
|
1912
|
+
resolvedValue = currentCellValue;
|
|
1476
1913
|
}
|
|
1477
1914
|
else if (currentStyleId !== null && styles.get(currentStyleId) === 14) {
|
|
1478
1915
|
rawNumber = num;
|
|
@@ -1535,6 +1972,9 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1535
1972
|
}
|
|
1536
1973
|
}
|
|
1537
1974
|
}
|
|
1975
|
+
// A part cut off inside a row (an intact zip around a truncated sheet) must not read as a shorter sheet
|
|
1976
|
+
if (inRow || inCell || openedWorksheet && !closedWorksheet)
|
|
1977
|
+
throw new Error('Worksheet XML is cut off: the sheet ends before its closing tags.');
|
|
1538
1978
|
const hiddenCols = [];
|
|
1539
1979
|
hiddenColMarks.forEach((hidden, c) => { if (hidden)
|
|
1540
1980
|
hiddenCols.push(c); });
|
|
@@ -1554,6 +1994,13 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1554
1994
|
const EMU_PER_PX = 9525;
|
|
1555
1995
|
// Format and pixel size from the file header
|
|
1556
1996
|
function imageInfo(b) {
|
|
1997
|
+
const info = headerInfo(b);
|
|
1998
|
+
// A truncated header reads past the end of the bytes and gives NaN (or 0) sizes
|
|
1999
|
+
if (!(info.width > 0 && info.height > 0))
|
|
2000
|
+
throw new Error(`The ${info.ext.toUpperCase()} image is truncated or has no size.`);
|
|
2001
|
+
return info;
|
|
2002
|
+
}
|
|
2003
|
+
function headerInfo(b) {
|
|
1557
2004
|
const be16 = (i) => (b[i] << 8) | b[i + 1];
|
|
1558
2005
|
const be32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
1559
2006
|
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
@@ -1632,12 +2079,12 @@ async function readSheetImages(readText, readBytes, sheetPath) {
|
|
|
1632
2079
|
}
|
|
1633
2080
|
return images;
|
|
1634
2081
|
}
|
|
1635
|
-
const escapeXml$4 = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1636
2082
|
const cellPos = (ref) => {
|
|
1637
2083
|
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(ref.trim());
|
|
1638
|
-
|
|
2084
|
+
const col = m ? colIndex(m[1]) : -1, row = m ? parseInt(m[2], 10) - 1 : -1;
|
|
2085
|
+
if (col < 0 || col >= MAX_COLUMNS$1 || row < 0 || row >= MAX_ROWS$1)
|
|
1639
2086
|
throw new Error(`Invalid anchor cell "${ref}".`);
|
|
1640
|
-
return { col
|
|
2087
|
+
return { col, row };
|
|
1641
2088
|
};
|
|
1642
2089
|
const marker = (tag, { col, row }) => `<xdr:${tag}><xdr:col>${col}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>${row}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:${tag}>`;
|
|
1643
2090
|
const DRAWING_NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" ' +
|
|
@@ -1647,15 +2094,23 @@ const DRAWING_NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/
|
|
|
1647
2094
|
// none; a missing side keeps its aspect ratio. `attrs` lands on the anchor element, e.g. DRAWING_NS
|
|
1648
2095
|
// when the anchor is added to a drawing that declares other prefixes.
|
|
1649
2096
|
function anchorXml(place, natural, body, attrs = '') {
|
|
2097
|
+
if (!place || typeof place.range !== 'string' && typeof place.at !== 'string') {
|
|
2098
|
+
throw new Error('A picture or chart needs a placement: "at" (a cell) or "range".');
|
|
2099
|
+
}
|
|
1650
2100
|
if ('range' in place) {
|
|
1651
2101
|
const [from, to = from] = place.range.split(':');
|
|
1652
|
-
const
|
|
1653
|
-
// The object fills the range: it ends at the far edge of the last cell
|
|
1654
|
-
|
|
2102
|
+
const a = cellPos(from), b = cellPos(to);
|
|
2103
|
+
// The object fills the range, given either way round: it ends at the far edge of the last cell
|
|
2104
|
+
const start = { col: Math.min(a.col, b.col), row: Math.min(a.row, b.row) };
|
|
2105
|
+
const end = { col: Math.max(a.col, b.col) + 1, row: Math.max(a.row, b.row) + 1 };
|
|
2106
|
+
return `<xdr:twoCellAnchor editAs="oneCell"${attrs}>${marker('from', start)}${marker('to', end)}${body}<xdr:clientData/></xdr:twoCellAnchor>`;
|
|
1655
2107
|
}
|
|
1656
2108
|
const { width: w, height: h } = natural;
|
|
1657
2109
|
const width = place.width ?? (place.height ? w * place.height / h : w);
|
|
1658
2110
|
const height = place.height ?? h * width / w;
|
|
2111
|
+
if (!(width > 0 && height > 0 && isFinite(width) && isFinite(height))) {
|
|
2112
|
+
throw new Error(`Invalid size ${place.width ?? ''}x${place.height ?? ''} at ${place.at}: width and height must be positive numbers.`);
|
|
2113
|
+
}
|
|
1659
2114
|
return `<xdr:oneCellAnchor${attrs}>${marker('from', cellPos(place.at))}<xdr:ext cx="${Math.round(width * EMU_PER_PX)}" cy="${Math.round(height * EMU_PER_PX)}"/>${body}<xdr:clientData/></xdr:oneCellAnchor>`;
|
|
1660
2115
|
}
|
|
1661
2116
|
const drawingPartXml = (anchors) => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<xdr:wsDr ${DRAWING_NS}>${anchors}</xdr:wsDr>`;
|
|
@@ -1667,166 +2122,6 @@ function drawingXml(images, infos, rIds) {
|
|
|
1667
2122
|
`<xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic>`)).join(''));
|
|
1668
2123
|
}
|
|
1669
2124
|
|
|
1670
|
-
// Reader for Compound File Binary files [MS-CFB]: the container of .xls workbooks and of
|
|
1671
|
-
// password-protected .xlsx files. Streams are read from an in-memory copy of the file.
|
|
1672
|
-
const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
1673
|
-
const END_OF_CHAIN = 0xfffffffe;
|
|
1674
|
-
const MAX_REGULAR_SECTOR = 0xfffffffa;
|
|
1675
|
-
function isCfb(bytes) {
|
|
1676
|
-
return bytes.length >= 8 && SIGNATURE.every((b, i) => bytes[i] === b);
|
|
1677
|
-
}
|
|
1678
|
-
const corrupt$1 = (why) => new Error(`Corrupt compound file: ${why}`);
|
|
1679
|
-
class CfbReader {
|
|
1680
|
-
bytes;
|
|
1681
|
-
view;
|
|
1682
|
-
sectorSize;
|
|
1683
|
-
miniSectorSize;
|
|
1684
|
-
miniCutoff;
|
|
1685
|
-
fat;
|
|
1686
|
-
miniFat;
|
|
1687
|
-
miniStream;
|
|
1688
|
-
dir;
|
|
1689
|
-
byPath = new Map();
|
|
1690
|
-
constructor(bytes) {
|
|
1691
|
-
this.bytes = bytes;
|
|
1692
|
-
if (!isCfb(bytes) || bytes.length < 512)
|
|
1693
|
-
throw new Error('Not a compound file (no D0CF11E0 signature)');
|
|
1694
|
-
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1695
|
-
const u16 = (o) => this.view.getUint16(o, true);
|
|
1696
|
-
const u32 = (o) => this.view.getUint32(o, true);
|
|
1697
|
-
const sectorShift = u16(0x1e);
|
|
1698
|
-
if (sectorShift !== 9 && sectorShift !== 12)
|
|
1699
|
-
throw corrupt$1(`sector shift ${sectorShift}`);
|
|
1700
|
-
this.sectorSize = 1 << sectorShift;
|
|
1701
|
-
const miniShift = u16(0x20);
|
|
1702
|
-
if (miniShift !== 6)
|
|
1703
|
-
throw corrupt$1(`mini sector shift ${miniShift}`);
|
|
1704
|
-
this.miniSectorSize = 1 << miniShift;
|
|
1705
|
-
this.miniCutoff = u32(0x38);
|
|
1706
|
-
// The FAT's own sectors are listed in the header (109 slots) and then in a chain of DIFAT sectors
|
|
1707
|
-
const numFatSectors = u32(0x2c);
|
|
1708
|
-
const sectorCount = Math.ceil(bytes.length / this.sectorSize) - 1;
|
|
1709
|
-
if (numFatSectors > sectorCount)
|
|
1710
|
-
throw corrupt$1(`${numFatSectors} FAT sectors in a ${bytes.length}-byte file`);
|
|
1711
|
-
const fatSectors = [];
|
|
1712
|
-
for (let i = 0; i < 109 && fatSectors.length < numFatSectors; i++)
|
|
1713
|
-
fatSectors.push(u32(0x4c + i * 4));
|
|
1714
|
-
const perDifat = this.sectorSize / 4 - 1;
|
|
1715
|
-
for (let s = u32(0x44), hops = 0; fatSectors.length < numFatSectors; hops++) {
|
|
1716
|
-
if (s > MAX_REGULAR_SECTOR || hops > sectorCount)
|
|
1717
|
-
throw corrupt$1('DIFAT chain ends early');
|
|
1718
|
-
const at = this.sectorOffset(s);
|
|
1719
|
-
for (let i = 0; i < perDifat && fatSectors.length < numFatSectors; i++)
|
|
1720
|
-
fatSectors.push(u32(at + i * 4));
|
|
1721
|
-
s = u32(at + perDifat * 4);
|
|
1722
|
-
}
|
|
1723
|
-
const perSector = this.sectorSize / 4;
|
|
1724
|
-
this.fat = new Uint32Array(numFatSectors * perSector);
|
|
1725
|
-
fatSectors.forEach((s, i) => {
|
|
1726
|
-
const at = this.sectorOffset(s);
|
|
1727
|
-
for (let j = 0; j < perSector; j++)
|
|
1728
|
-
this.fat[i * perSector + j] = u32(at + j * 4);
|
|
1729
|
-
});
|
|
1730
|
-
// Directory: 128-byte entries in the chain starting at the header's first directory sector
|
|
1731
|
-
const dirBytes = this.readChain(u32(0x30), Infinity);
|
|
1732
|
-
this.dir = [];
|
|
1733
|
-
for (let at = 0; at + 128 <= dirBytes.length; at += 128) {
|
|
1734
|
-
const d = new DataView(dirBytes.buffer, dirBytes.byteOffset + at, 128);
|
|
1735
|
-
const nameLen = Math.min(d.getUint16(64, true), 64);
|
|
1736
|
-
let name = '';
|
|
1737
|
-
for (let i = 0; i + 2 < nameLen; i += 2)
|
|
1738
|
-
name += String.fromCharCode(d.getUint16(i, true));
|
|
1739
|
-
// Version 3 files may leave garbage in the size's high half
|
|
1740
|
-
const size = sectorShift === 9 ? d.getUint32(120, true) : d.getUint32(120, true) + d.getUint32(124, true) * 2 ** 32;
|
|
1741
|
-
this.dir.push({ name, type: d.getUint8(66), left: d.getUint32(68, true), right: d.getUint32(72, true),
|
|
1742
|
-
child: d.getUint32(76, true), start: d.getUint32(116, true), size });
|
|
1743
|
-
}
|
|
1744
|
-
if (this.dir[0]?.type !== 5)
|
|
1745
|
-
throw corrupt$1('no root entry');
|
|
1746
|
-
this.walk(this.dir[0].child, '');
|
|
1747
|
-
}
|
|
1748
|
-
// Start of a sector, checked to hold `need` bytes (the last sector of a file may be cut short)
|
|
1749
|
-
sectorOffset(sector, need = this.sectorSize) {
|
|
1750
|
-
const at = (sector + 1) * this.sectorSize;
|
|
1751
|
-
if (sector > MAX_REGULAR_SECTOR || at + need > this.bytes.length) {
|
|
1752
|
-
throw corrupt$1(`sector ${sector} is outside the file`);
|
|
1753
|
-
}
|
|
1754
|
-
return at;
|
|
1755
|
-
}
|
|
1756
|
-
// Sibling entries form a red-black tree; children of a storage hang off its `child`
|
|
1757
|
-
walk(root, prefix) {
|
|
1758
|
-
const stack = [root];
|
|
1759
|
-
const seen = new Set();
|
|
1760
|
-
while (stack.length) {
|
|
1761
|
-
const id = stack.pop();
|
|
1762
|
-
if (id > MAX_REGULAR_SECTOR)
|
|
1763
|
-
continue; // NOSTREAM
|
|
1764
|
-
if (seen.has(id) || !this.dir[id])
|
|
1765
|
-
throw corrupt$1('directory tree loops or points outside the directory');
|
|
1766
|
-
seen.add(id);
|
|
1767
|
-
const e = this.dir[id];
|
|
1768
|
-
stack.push(e.left, e.right);
|
|
1769
|
-
if (e.type !== 1 && e.type !== 2)
|
|
1770
|
-
continue;
|
|
1771
|
-
const path = prefix + e.name;
|
|
1772
|
-
this.byPath.set(path.toLowerCase(), { ...e, path });
|
|
1773
|
-
if (e.type === 1)
|
|
1774
|
-
this.walk(e.child, path + '/');
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
readChain(start, size, mini = false) {
|
|
1778
|
-
const table = mini ? this.miniFat : this.fat;
|
|
1779
|
-
const unit = mini ? this.miniSectorSize : this.sectorSize;
|
|
1780
|
-
const source = mini ? this.miniStream : this.bytes;
|
|
1781
|
-
const sectors = [];
|
|
1782
|
-
for (let s = start; s !== END_OF_CHAIN && sectors.length * unit < size; s = table[s]) {
|
|
1783
|
-
if (s >= table.length || sectors.length > table.length)
|
|
1784
|
-
throw corrupt$1(`${mini ? 'mini ' : ''}sector chain is broken or loops`);
|
|
1785
|
-
sectors.push(s);
|
|
1786
|
-
}
|
|
1787
|
-
const total = Math.min(size, sectors.length * unit);
|
|
1788
|
-
if (size !== Infinity && total < size)
|
|
1789
|
-
throw corrupt$1(`stream is shorter (${total} bytes) than its stated ${size}`);
|
|
1790
|
-
const out = new Uint8Array(total);
|
|
1791
|
-
sectors.forEach((s, i) => {
|
|
1792
|
-
const len = Math.min(unit, total - i * unit);
|
|
1793
|
-
const at = mini ? s * unit : this.sectorOffset(s, len);
|
|
1794
|
-
if (at + len > source.length)
|
|
1795
|
-
throw corrupt$1(`mini sector ${s} is outside the mini stream`);
|
|
1796
|
-
out.set(source.subarray(at, at + len), i * unit);
|
|
1797
|
-
});
|
|
1798
|
-
return out;
|
|
1799
|
-
}
|
|
1800
|
-
entries() {
|
|
1801
|
-
return [...this.byPath.values()].map(e => ({ path: e.path, type: e.type === 1 ? 'storage' : 'stream', size: e.size }));
|
|
1802
|
-
}
|
|
1803
|
-
has(path) {
|
|
1804
|
-
return this.byPath.get(path.toLowerCase())?.type === 2;
|
|
1805
|
-
}
|
|
1806
|
-
// A stream's bytes, by path (case-insensitive, as in the format); undefined if there is none
|
|
1807
|
-
read(path) {
|
|
1808
|
-
const e = this.byPath.get(path.toLowerCase());
|
|
1809
|
-
if (!e || e.type !== 2)
|
|
1810
|
-
return undefined;
|
|
1811
|
-
if (e.size >= this.miniCutoff) {
|
|
1812
|
-
if (e.size > this.bytes.length)
|
|
1813
|
-
throw corrupt$1(`stream ${e.path} is larger than the file`);
|
|
1814
|
-
return this.readChain(e.start, e.size);
|
|
1815
|
-
}
|
|
1816
|
-
if (!this.miniStream) {
|
|
1817
|
-
const root = this.dir[0];
|
|
1818
|
-
this.miniStream = this.readChain(root.start, root.size);
|
|
1819
|
-
const view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
|
|
1820
|
-
const miniFatBytes = this.readChain(view.getUint32(0x3c, true), view.getUint32(0x40, true) * this.sectorSize);
|
|
1821
|
-
this.miniFat = new Uint32Array(miniFatBytes.length / 4);
|
|
1822
|
-
const mv = new DataView(miniFatBytes.buffer, miniFatBytes.byteOffset, miniFatBytes.byteLength);
|
|
1823
|
-
for (let i = 0; i < this.miniFat.length; i++)
|
|
1824
|
-
this.miniFat[i] = mv.getUint32(i * 4, true);
|
|
1825
|
-
}
|
|
1826
|
-
return this.readChain(e.start, e.size, true);
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
2125
|
// Reader for Excel 97-2003 workbooks (.xls, BIFF8 records in a compound file) [MS-XLS].
|
|
1831
2126
|
// Returns the same rows and metadata as the .xlsx reader. The file is held in memory; .xls sheets
|
|
1832
2127
|
// are limited to 65,536 rows, so that stays modest.
|
|
@@ -2371,8 +2666,10 @@ async function readOdsWorkbook(content, readText) {
|
|
|
2371
2666
|
if (!properties.creator && text('dc:creator'))
|
|
2372
2667
|
properties.creator = text('dc:creator');
|
|
2373
2668
|
const created = text('meta:creation-date');
|
|
2374
|
-
|
|
2375
|
-
|
|
2669
|
+
// Without a zone the time is UTC, as written by OdsWriter (and stored by Excel)
|
|
2670
|
+
const createdAt = created && new Date(/(?:Z|[+-]\d\d:?\d\d)$/.test(created) ? created : created + 'Z');
|
|
2671
|
+
if (createdAt && !isNaN(createdAt.getTime()))
|
|
2672
|
+
properties.created = createdAt;
|
|
2376
2673
|
return { sheets, definedNames, properties };
|
|
2377
2674
|
}
|
|
2378
2675
|
// Frozen panes are view settings, kept in settings.xml per sheet
|
|
@@ -2621,9 +2918,7 @@ function parseOds(content, readText, options) {
|
|
|
2621
2918
|
return new ParseResult(rows, metadata, async () => [], async () => { await finished; return comments; });
|
|
2622
2919
|
}
|
|
2623
2920
|
|
|
2624
|
-
|
|
2625
|
-
return String(val).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2626
|
-
}
|
|
2921
|
+
const escapeXml$3 = (val) => escapeXml$4(String(val));
|
|
2627
2922
|
// Child elements of a <font> (styles) or, with nameTag "rFont", of a rich text run's <rPr>
|
|
2628
2923
|
function fontXml(font, nameTag = 'name') {
|
|
2629
2924
|
let xml = '';
|
|
@@ -2636,22 +2931,22 @@ function fontXml(font, nameTag = 'name') {
|
|
|
2636
2931
|
if (font.size)
|
|
2637
2932
|
xml += `<sz val="${escapeXml$3(font.size)}"/>`;
|
|
2638
2933
|
if (font.color)
|
|
2639
|
-
xml += `<color rgb="${
|
|
2934
|
+
xml += `<color rgb="${argb(font.color)}"/>`;
|
|
2640
2935
|
if (font.name)
|
|
2641
2936
|
xml += `<${nameTag} val="${escapeXml$3(font.name)}"/>`;
|
|
2642
2937
|
return xml;
|
|
2643
2938
|
}
|
|
2644
|
-
const borderSideXml = (tag, s) => s ? `<${tag} style="${escapeXml$3(s.style)}">${s.color ? `<color rgb="${
|
|
2939
|
+
const borderSideXml = (tag, s) => s ? `<${tag} style="${escapeXml$3(s.style)}">${s.color ? `<color rgb="${argb(s.color)}"/>` : ''}</${tag}>` : `<${tag}/>`;
|
|
2645
2940
|
function borderXml(b) {
|
|
2646
2941
|
const side = borderSideXml;
|
|
2647
2942
|
return `<border>${side('left', b.left)}${side('right', b.right)}${side('top', b.top)}${side('bottom', b.bottom)}<diagonal/></border>`;
|
|
2648
2943
|
}
|
|
2649
2944
|
function fillXml(fill) {
|
|
2650
2945
|
if (fill.type === 'gradient') {
|
|
2651
|
-
const stops = fill.stops.map(s => `<stop position="${s.position}"><color rgb="${
|
|
2946
|
+
const stops = fill.stops.map(s => `<stop position="${s.position}"><color rgb="${argb(s.color)}"/></stop>`).join('');
|
|
2652
2947
|
return `<fill><gradientFill degree="${escapeXml$3(fill.degree ?? 0)}">${stops}</gradientFill></fill>`;
|
|
2653
2948
|
}
|
|
2654
|
-
return `<fill><patternFill patternType="solid"><fgColor rgb="${
|
|
2949
|
+
return `<fill><patternFill patternType="solid"><fgColor rgb="${argb(fill.fgColor)}"/></patternFill></fill>`;
|
|
2655
2950
|
}
|
|
2656
2951
|
// Attributes of an <alignment> element
|
|
2657
2952
|
function alignmentAttrs(alignment) {
|
|
@@ -2693,7 +2988,7 @@ class StyleEngine {
|
|
|
2693
2988
|
xml += `<numFmt numFmtId="${this.registerNumFmt(style.numFmt)}" formatCode="${escapeXml$3(style.numFmt)}"/>`;
|
|
2694
2989
|
if (style.fill?.type === 'solid') {
|
|
2695
2990
|
// A differential solid fill takes its colour from bgColor
|
|
2696
|
-
const c =
|
|
2991
|
+
const c = argb(style.fill.fgColor);
|
|
2697
2992
|
xml += `<fill><patternFill patternType="solid"><fgColor rgb="${c}"/><bgColor rgb="${c}"/></patternFill></fill>`;
|
|
2698
2993
|
}
|
|
2699
2994
|
if (style.border)
|
|
@@ -2818,10 +3113,8 @@ ${this.dxfs.size ? `<dxfs count="${this.dxfs.size}">${[...this.dxfs.keys()].map(
|
|
|
2818
3113
|
}
|
|
2819
3114
|
}
|
|
2820
3115
|
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
}
|
|
2824
|
-
const color = (rgb) => `<color rgb="${escapeXml$2(rgb)}"/>`;
|
|
3116
|
+
const escapeXml$2 = (val) => escapeXml$4(String(val));
|
|
3117
|
+
const color = (rgb) => `<color rgb="${argb(rgb)}"/>`;
|
|
2825
3118
|
const bound = (type, value) => value === undefined ? `<cfvo type="${type}"/>` : `<cfvo type="num" val="${escapeXml$2(value)}"/>`;
|
|
2826
3119
|
const formula = (f) => `<formula>${escapeXml$2(String(f).replace(/^=/, ''))}</formula>`;
|
|
2827
3120
|
// Excel string literal inside a formula
|
|
@@ -2892,10 +3185,32 @@ class ConditionalFormatter {
|
|
|
2892
3185
|
}
|
|
2893
3186
|
}
|
|
2894
3187
|
|
|
3188
|
+
// An Excel error value (#DIV/0!, #VALUE!), kept apart from text that happens to start with "#"
|
|
3189
|
+
class FormulaError {
|
|
3190
|
+
code;
|
|
3191
|
+
constructor(code) {
|
|
3192
|
+
this.code = code;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
// Syntax or functions this engine doesn't know: no cached value, Excel computes it on open
|
|
3196
|
+
class Unsupported extends Error {
|
|
3197
|
+
}
|
|
3198
|
+
// A formula that reads its own cell, directly or through others
|
|
3199
|
+
class Circular extends Error {
|
|
3200
|
+
}
|
|
3201
|
+
const DIV0 = new FormulaError('#DIV/0!');
|
|
3202
|
+
const VALUE = new FormulaError('#VALUE!');
|
|
3203
|
+
// Numbers turned into text keep Excel's 15 significant digits ("0.333333333333333", 1E+21)
|
|
3204
|
+
function numberText(n) {
|
|
3205
|
+
return String(Number(n.toPrecision(15))).replace('e', 'E');
|
|
3206
|
+
}
|
|
2895
3207
|
class FormulaEngine {
|
|
2896
3208
|
cells = new Map();
|
|
3209
|
+
results = new Map();
|
|
3210
|
+
evaluating = new Set();
|
|
2897
3211
|
clear() {
|
|
2898
3212
|
this.cells.clear();
|
|
3213
|
+
this.results.clear();
|
|
2899
3214
|
}
|
|
2900
3215
|
loadData(data, startRow = 1) {
|
|
2901
3216
|
data.forEach((row, ri) => {
|
|
@@ -2905,25 +3220,73 @@ class FormulaEngine {
|
|
|
2905
3220
|
});
|
|
2906
3221
|
});
|
|
2907
3222
|
}
|
|
3223
|
+
// Errors come back as their code ("#DIV/0!")
|
|
2908
3224
|
evaluate(formula) {
|
|
3225
|
+
const v = this.evaluateRaw(formula);
|
|
3226
|
+
return v instanceof FormulaError ? v.code : v;
|
|
3227
|
+
}
|
|
3228
|
+
// Like evaluate, but errors stay FormulaError so text such as "#1 pick" is told apart from them
|
|
3229
|
+
evaluateRaw(formula) {
|
|
2909
3230
|
try {
|
|
2910
|
-
|
|
2911
|
-
if (!clean)
|
|
2912
|
-
return null;
|
|
2913
|
-
const tokens = this.tokenize(clean);
|
|
2914
|
-
const ast = this.parse(tokens);
|
|
2915
|
-
return this.evaluateAst(ast);
|
|
3231
|
+
return this.run(formula);
|
|
2916
3232
|
}
|
|
2917
|
-
catch {
|
|
2918
|
-
//
|
|
3233
|
+
catch (err) {
|
|
3234
|
+
// A circular reference is stored as 0, as Excel does
|
|
3235
|
+
if (err instanceof Circular)
|
|
3236
|
+
return 0;
|
|
3237
|
+
return null;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
// The value of a loaded cell, computing its formula (and the formulas it reads) when it has one
|
|
3241
|
+
cellValue(ref) {
|
|
3242
|
+
try {
|
|
3243
|
+
return this.lookup(ref.toUpperCase());
|
|
3244
|
+
}
|
|
3245
|
+
catch (err) {
|
|
3246
|
+
if (err instanceof Circular)
|
|
3247
|
+
return 0;
|
|
3248
|
+
return null;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
run(formula) {
|
|
3252
|
+
const clean = formula.replace(/^=/, '').trim();
|
|
3253
|
+
if (!clean)
|
|
2919
3254
|
return null;
|
|
3255
|
+
// A formula is never blank: =A1 over an empty cell is 0
|
|
3256
|
+
return this.scalar(this.parse(this.tokenize(clean))) ?? 0;
|
|
3257
|
+
}
|
|
3258
|
+
lookup(ref) {
|
|
3259
|
+
const cell = this.cells.get(ref);
|
|
3260
|
+
if (cell === null || typeof cell !== 'object')
|
|
3261
|
+
return cell ?? null;
|
|
3262
|
+
if (this.results.has(ref)) {
|
|
3263
|
+
const known = this.results.get(ref);
|
|
3264
|
+
if (known === undefined)
|
|
3265
|
+
throw new Unsupported();
|
|
3266
|
+
return known;
|
|
3267
|
+
}
|
|
3268
|
+
if (this.evaluating.has(ref))
|
|
3269
|
+
throw new Circular();
|
|
3270
|
+
this.evaluating.add(ref);
|
|
3271
|
+
try {
|
|
3272
|
+
const v = this.run(cell.formula);
|
|
3273
|
+
this.results.set(ref, v);
|
|
3274
|
+
return v;
|
|
3275
|
+
}
|
|
3276
|
+
catch (err) {
|
|
3277
|
+
if (!(err instanceof Circular))
|
|
3278
|
+
this.results.set(ref, undefined);
|
|
3279
|
+
throw err;
|
|
3280
|
+
}
|
|
3281
|
+
finally {
|
|
3282
|
+
this.evaluating.delete(ref);
|
|
2920
3283
|
}
|
|
2921
3284
|
}
|
|
2922
3285
|
tokenize(expr) {
|
|
2923
3286
|
const tokens = [];
|
|
2924
3287
|
let i = 0;
|
|
2925
3288
|
while (i < expr.length) {
|
|
2926
|
-
|
|
3289
|
+
const char = expr[i];
|
|
2927
3290
|
if (/\s/.test(char)) {
|
|
2928
3291
|
i++;
|
|
2929
3292
|
continue;
|
|
@@ -2943,7 +3306,7 @@ class FormulaEngine {
|
|
|
2943
3306
|
i++;
|
|
2944
3307
|
continue;
|
|
2945
3308
|
}
|
|
2946
|
-
if (/[
|
|
3309
|
+
if (/[+\-*/^<>=%&]/.test(char)) {
|
|
2947
3310
|
let op = char;
|
|
2948
3311
|
if (char === '<' || char === '>') {
|
|
2949
3312
|
if (expr[i + 1] === '=') {
|
|
@@ -2959,29 +3322,34 @@ class FormulaEngine {
|
|
|
2959
3322
|
i++;
|
|
2960
3323
|
continue;
|
|
2961
3324
|
}
|
|
2962
|
-
if (char === '"'
|
|
2963
|
-
|
|
3325
|
+
if (char === '"') {
|
|
3326
|
+
// "" inside a string is one quote
|
|
2964
3327
|
let str = '';
|
|
2965
3328
|
i++;
|
|
2966
|
-
|
|
3329
|
+
for (;;) {
|
|
3330
|
+
if (i >= expr.length)
|
|
3331
|
+
throw new Unsupported();
|
|
3332
|
+
if (expr[i] === '"') {
|
|
3333
|
+
if (expr[i + 1] !== '"')
|
|
3334
|
+
break;
|
|
3335
|
+
i++;
|
|
3336
|
+
}
|
|
2967
3337
|
str += expr[i++];
|
|
2968
3338
|
}
|
|
2969
3339
|
i++;
|
|
2970
3340
|
tokens.push({ type: 'STRING', value: str });
|
|
2971
3341
|
continue;
|
|
2972
3342
|
}
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
}
|
|
2978
|
-
tokens.push({ type: 'NUMBER', value: num });
|
|
3343
|
+
const num = /^(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/.exec(expr.slice(i));
|
|
3344
|
+
if (num) {
|
|
3345
|
+
tokens.push({ type: 'NUMBER', value: num[0] });
|
|
3346
|
+
i += num[0].length;
|
|
2979
3347
|
continue;
|
|
2980
3348
|
}
|
|
2981
3349
|
if (/[A-Za-z$]/.test(char)) {
|
|
2982
3350
|
// "$" only pins a reference when copied; it does not change what it points at
|
|
2983
3351
|
let id = '';
|
|
2984
|
-
while (i < expr.length && /[A-Za-z0-9$]/.test(expr[i])) {
|
|
3352
|
+
while (i < expr.length && /[A-Za-z0-9$_.]/.test(expr[i])) {
|
|
2985
3353
|
id += expr[i++];
|
|
2986
3354
|
}
|
|
2987
3355
|
id = id.replace(/\$/g, '');
|
|
@@ -2992,9 +3360,11 @@ class FormulaEngine {
|
|
|
2992
3360
|
endId += expr[i++];
|
|
2993
3361
|
}
|
|
2994
3362
|
endId = endId.replace(/\$/g, '');
|
|
3363
|
+
if (!/^[A-Z]{1,3}\d+$/i.test(id) || !/^[A-Z]{1,3}\d+$/i.test(endId))
|
|
3364
|
+
throw new Unsupported();
|
|
2995
3365
|
tokens.push({ type: 'RANGE', value: id.toUpperCase() + ':' + endId.toUpperCase() });
|
|
2996
3366
|
}
|
|
2997
|
-
else if (/^[A-Z]
|
|
3367
|
+
else if (/^[A-Z]{1,3}\d+$/i.test(id)) {
|
|
2998
3368
|
tokens.push({ type: 'CELL', value: id.toUpperCase() });
|
|
2999
3369
|
}
|
|
3000
3370
|
else {
|
|
@@ -3002,16 +3372,23 @@ class FormulaEngine {
|
|
|
3002
3372
|
}
|
|
3003
3373
|
continue;
|
|
3004
3374
|
}
|
|
3005
|
-
|
|
3375
|
+
// Sheet references ('Q1'!A1, Data!A1), arrays, error literals and the rest
|
|
3376
|
+
throw new Unsupported();
|
|
3006
3377
|
}
|
|
3007
3378
|
return tokens;
|
|
3008
3379
|
}
|
|
3009
3380
|
parse(tokens) {
|
|
3010
3381
|
let pos = 0;
|
|
3011
|
-
const
|
|
3382
|
+
const isOp = (...ops) => pos < tokens.length && tokens[pos].type === 'OP' && ops.includes(tokens[pos].value);
|
|
3383
|
+
const expect = (type) => {
|
|
3384
|
+
if (tokens[pos]?.type !== type)
|
|
3385
|
+
throw new Unsupported();
|
|
3386
|
+
pos++;
|
|
3387
|
+
};
|
|
3388
|
+
const parseAtom = () => {
|
|
3012
3389
|
const token = tokens[pos];
|
|
3013
3390
|
if (!token)
|
|
3014
|
-
throw new
|
|
3391
|
+
throw new Unsupported();
|
|
3015
3392
|
if (token.type === 'NUMBER') {
|
|
3016
3393
|
pos++;
|
|
3017
3394
|
return { type: 'NUMBER', value: parseFloat(token.value) };
|
|
@@ -3031,146 +3408,188 @@ class FormulaEngine {
|
|
|
3031
3408
|
if (token.type === 'IDENTIFIER') {
|
|
3032
3409
|
const name = token.value;
|
|
3033
3410
|
pos++;
|
|
3034
|
-
if (
|
|
3035
|
-
pos++;
|
|
3411
|
+
if (tokens[pos]?.type === 'PAREN_L') {
|
|
3412
|
+
pos++;
|
|
3036
3413
|
const args = [];
|
|
3037
|
-
if (tokens[pos]
|
|
3414
|
+
if (tokens[pos]?.type !== 'PAREN_R') {
|
|
3038
3415
|
args.push(parseExpression());
|
|
3039
|
-
while (
|
|
3416
|
+
while (tokens[pos]?.type === 'COMMA') {
|
|
3040
3417
|
pos++;
|
|
3041
3418
|
args.push(parseExpression());
|
|
3042
3419
|
}
|
|
3043
3420
|
}
|
|
3044
|
-
|
|
3045
|
-
throw new Error('Expected )');
|
|
3046
|
-
pos++;
|
|
3421
|
+
expect('PAREN_R');
|
|
3047
3422
|
return { type: 'CALL', name, args };
|
|
3048
3423
|
}
|
|
3049
|
-
// Handle true/false constants
|
|
3050
3424
|
if (name === 'TRUE')
|
|
3051
|
-
return { type: '
|
|
3425
|
+
return { type: 'BOOL', value: true };
|
|
3052
3426
|
if (name === 'FALSE')
|
|
3053
|
-
return { type: '
|
|
3054
|
-
throw new
|
|
3427
|
+
return { type: 'BOOL', value: false };
|
|
3428
|
+
throw new Unsupported(); // defined names
|
|
3055
3429
|
}
|
|
3056
3430
|
if (token.type === 'PAREN_L') {
|
|
3057
3431
|
pos++;
|
|
3058
3432
|
const node = parseExpression();
|
|
3059
|
-
|
|
3060
|
-
throw new Error('Expected )');
|
|
3061
|
-
pos++;
|
|
3433
|
+
expect('PAREN_R');
|
|
3062
3434
|
return node;
|
|
3063
3435
|
}
|
|
3064
|
-
throw new
|
|
3436
|
+
throw new Unsupported();
|
|
3065
3437
|
};
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
const op = tokens[pos].value;
|
|
3438
|
+
// Unary minus binds tighter than ^ in Excel (-2^2 is 4); % divides by 100
|
|
3439
|
+
const parseUnary = () => {
|
|
3440
|
+
if (isOp('-')) {
|
|
3070
3441
|
pos++;
|
|
3071
|
-
|
|
3442
|
+
return { type: 'NEG', left: parseUnary() };
|
|
3072
3443
|
}
|
|
3073
|
-
|
|
3074
|
-
};
|
|
3075
|
-
const parseFactor = () => {
|
|
3076
|
-
let node = parsePower();
|
|
3077
|
-
while (pos < tokens.length && (tokens[pos].value === '*' || tokens[pos].value === '/')) {
|
|
3078
|
-
const op = tokens[pos].value;
|
|
3444
|
+
if (isOp('+')) {
|
|
3079
3445
|
pos++;
|
|
3080
|
-
|
|
3446
|
+
return parseUnary();
|
|
3081
3447
|
}
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
const parseTerm = () => {
|
|
3085
|
-
let node = parseFactor();
|
|
3086
|
-
while (pos < tokens.length && (tokens[pos].value === '+' || tokens[pos].value === '-')) {
|
|
3087
|
-
const op = tokens[pos].value;
|
|
3448
|
+
let node = parseAtom();
|
|
3449
|
+
while (isOp('%')) {
|
|
3088
3450
|
pos++;
|
|
3089
|
-
node = { type: '
|
|
3451
|
+
node = { type: 'PERCENT', left: node };
|
|
3090
3452
|
}
|
|
3091
3453
|
return node;
|
|
3092
3454
|
};
|
|
3093
|
-
const
|
|
3094
|
-
let node =
|
|
3095
|
-
while (
|
|
3096
|
-
const
|
|
3097
|
-
|
|
3098
|
-
node = { type: 'BINARY', operator: op, left: node, right: parseTerm() };
|
|
3455
|
+
const binary = (next, ...ops) => () => {
|
|
3456
|
+
let node = next();
|
|
3457
|
+
while (isOp(...ops)) {
|
|
3458
|
+
const operator = tokens[pos++].value;
|
|
3459
|
+
node = { type: 'BINARY', operator, left: node, right: next() };
|
|
3099
3460
|
}
|
|
3100
3461
|
return node;
|
|
3101
3462
|
};
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3463
|
+
// Excel's ^ is left-associative: 2^3^2 is 64
|
|
3464
|
+
const parsePower = binary(parseUnary, '^');
|
|
3465
|
+
const parseFactor = binary(parsePower, '*', '/');
|
|
3466
|
+
const parseTerm = binary(parseFactor, '+', '-');
|
|
3467
|
+
const parseConcat = binary(parseTerm, '&');
|
|
3468
|
+
const parseExpression = binary(parseConcat, '=', '<>', '<', '>', '<=', '>=');
|
|
3469
|
+
const ast = parseExpression();
|
|
3470
|
+
if (pos !== tokens.length)
|
|
3471
|
+
throw new Unsupported();
|
|
3472
|
+
return ast;
|
|
3473
|
+
}
|
|
3474
|
+
// A single value: a range used where one value is expected is not supported (implicit intersection)
|
|
3475
|
+
scalar(node) {
|
|
3476
|
+
const v = this.evaluateAst(node);
|
|
3477
|
+
if (Array.isArray(v))
|
|
3478
|
+
throw new Unsupported();
|
|
3479
|
+
return v;
|
|
3106
3480
|
}
|
|
3107
3481
|
evaluateAst(node) {
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
return node.value;
|
|
3112
|
-
|
|
3113
|
-
return this.
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3482
|
+
switch (node.type) {
|
|
3483
|
+
case 'NUMBER':
|
|
3484
|
+
case 'STRING':
|
|
3485
|
+
case 'BOOL': return node.value;
|
|
3486
|
+
case 'CELL': return this.lookup(node.value);
|
|
3487
|
+
case 'RANGE': return this.getRangeValues(node.value);
|
|
3488
|
+
case 'NEG': {
|
|
3489
|
+
const n = toNumber(this.scalar(node.left));
|
|
3490
|
+
return n instanceof FormulaError ? n : -n;
|
|
3491
|
+
}
|
|
3492
|
+
case 'PERCENT': {
|
|
3493
|
+
const n = toNumber(this.scalar(node.left));
|
|
3494
|
+
return n instanceof FormulaError ? n : n / 100;
|
|
3495
|
+
}
|
|
3496
|
+
case 'BINARY': return this.binary(node.operator, this.scalar(node.left), this.scalar(node.right));
|
|
3497
|
+
case 'CALL': return this.call(node.name, node.args);
|
|
3498
|
+
}
|
|
3499
|
+
throw new Unsupported();
|
|
3500
|
+
}
|
|
3501
|
+
binary(op, left, right) {
|
|
3502
|
+
if (left instanceof FormulaError)
|
|
3503
|
+
return left;
|
|
3504
|
+
if (right instanceof FormulaError)
|
|
3505
|
+
return right;
|
|
3506
|
+
if (op === '&')
|
|
3507
|
+
return toText(left) + toText(right);
|
|
3508
|
+
if (['=', '<>', '<', '>', '<=', '>='].includes(op)) {
|
|
3509
|
+
const c = compare(left, right);
|
|
3510
|
+
return op === '=' ? c === 0 : op === '<>' ? c !== 0 : op === '<' ? c < 0 : op === '>' ? c > 0 : op === '<=' ? c <= 0 : c >= 0;
|
|
3511
|
+
}
|
|
3512
|
+
const l = toNumber(left), r = toNumber(right);
|
|
3513
|
+
if (l instanceof FormulaError)
|
|
3514
|
+
return l;
|
|
3515
|
+
if (r instanceof FormulaError)
|
|
3516
|
+
return r;
|
|
3517
|
+
switch (op) {
|
|
3518
|
+
case '+': return l + r;
|
|
3519
|
+
case '-': return l - r;
|
|
3520
|
+
case '*': return l * r;
|
|
3521
|
+
case '/': return r === 0 ? DIV0 : l / r;
|
|
3522
|
+
case '^': {
|
|
3523
|
+
const p = Math.pow(l, r);
|
|
3524
|
+
return isFinite(p) ? p : new FormulaError('#NUM!');
|
|
3133
3525
|
}
|
|
3134
3526
|
}
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3527
|
+
throw new Unsupported();
|
|
3528
|
+
}
|
|
3529
|
+
call(name, argNodes) {
|
|
3530
|
+
if (name === 'IF') {
|
|
3531
|
+
if (argNodes.length < 1 || argNodes.length > 3)
|
|
3532
|
+
throw new Unsupported();
|
|
3533
|
+
const cond = toBool(this.scalar(argNodes[0]));
|
|
3534
|
+
if (cond instanceof FormulaError)
|
|
3535
|
+
return cond;
|
|
3536
|
+
if (cond)
|
|
3537
|
+
return argNodes.length > 1 ? this.scalar(argNodes[1]) ?? 0 : true;
|
|
3538
|
+
return argNodes.length > 2 ? this.scalar(argNodes[2]) ?? 0 : false;
|
|
3539
|
+
}
|
|
3540
|
+
if (name === 'CONCATENATE') {
|
|
3541
|
+
let out = '';
|
|
3542
|
+
for (const a of argNodes) {
|
|
3543
|
+
const v = this.scalar(a);
|
|
3544
|
+
if (v instanceof FormulaError)
|
|
3545
|
+
return v;
|
|
3546
|
+
out += toText(v);
|
|
3547
|
+
}
|
|
3548
|
+
return out;
|
|
3549
|
+
}
|
|
3550
|
+
if (!['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN'].includes(name))
|
|
3551
|
+
throw new Unsupported();
|
|
3552
|
+
// Values typed into the call count (TRUE as 1, "3" as 3); in referenced cells only numbers do
|
|
3553
|
+
const nums = [];
|
|
3554
|
+
for (const a of argNodes) {
|
|
3555
|
+
const v = this.evaluateAst(a);
|
|
3556
|
+
const referenced = a.type === 'CELL' || a.type === 'RANGE';
|
|
3557
|
+
for (const x of Array.isArray(v) ? v : [v]) {
|
|
3558
|
+
if (x instanceof FormulaError) {
|
|
3559
|
+
if (name === 'COUNT')
|
|
3560
|
+
continue;
|
|
3561
|
+
return x;
|
|
3147
3562
|
}
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3563
|
+
if (typeof x === 'number')
|
|
3564
|
+
nums.push(x);
|
|
3565
|
+
else if (!referenced && x !== null) {
|
|
3566
|
+
const n = toNumber(x);
|
|
3567
|
+
if (n instanceof FormulaError) {
|
|
3568
|
+
if (name === 'COUNT')
|
|
3569
|
+
continue;
|
|
3570
|
+
return n;
|
|
3571
|
+
}
|
|
3572
|
+
nums.push(n);
|
|
3151
3573
|
}
|
|
3152
|
-
case 'IF': return args[0] ? args[1] : args[2];
|
|
3153
|
-
case 'CONCATENATE': return this.flatten(args).join('');
|
|
3154
3574
|
}
|
|
3155
3575
|
}
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
return arr.flat(Infinity);
|
|
3576
|
+
switch (name) {
|
|
3577
|
+
case 'SUM': return nums.reduce((a, b) => a + b, 0);
|
|
3578
|
+
case 'COUNT': return nums.length;
|
|
3579
|
+
case 'AVERAGE': return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : DIV0;
|
|
3580
|
+
case 'MAX': return nums.length ? Math.max(...nums) : 0;
|
|
3581
|
+
default: return nums.length ? Math.min(...nums) : 0;
|
|
3582
|
+
}
|
|
3164
3583
|
}
|
|
3165
3584
|
getRangeValues(range) {
|
|
3166
3585
|
const [startRef, endRef] = range.split(':');
|
|
3167
|
-
const [
|
|
3168
|
-
const [
|
|
3586
|
+
const [c1, r1] = this.parseRef(startRef);
|
|
3587
|
+
const [c2, r2] = this.parseRef(endRef);
|
|
3169
3588
|
const values = [];
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
values.push(this.
|
|
3589
|
+
// A3:A1 is the same range as A1:A3
|
|
3590
|
+
for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
|
|
3591
|
+
for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
|
|
3592
|
+
values.push(this.lookup(this.toRef(r, c - 1)));
|
|
3174
3593
|
}
|
|
3175
3594
|
}
|
|
3176
3595
|
return values;
|
|
@@ -3191,185 +3610,72 @@ class FormulaEngine {
|
|
|
3191
3610
|
return `${col}${row}`;
|
|
3192
3611
|
}
|
|
3193
3612
|
}
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
async addFile(filenameStr, inputStream) {
|
|
3245
|
-
const filename = this.textEncoder.encode(filenameStr);
|
|
3246
|
-
const startOffset = this.offset;
|
|
3247
|
-
await this.pushChunk(this.localHeader(filename, 0x0008, 8, 0, 0, 0));
|
|
3248
|
-
// Stream data, tracking sizes and CRC32
|
|
3249
|
-
let uncompressedSize = 0;
|
|
3250
|
-
let crc = 0xffffffff;
|
|
3251
|
-
const crcStream = new TransformStream({
|
|
3252
|
-
transform: (chunk, controller) => {
|
|
3253
|
-
uncompressedSize += chunk.length;
|
|
3254
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
3255
|
-
crc = CRC_TABLE[(crc ^ chunk[i]) & 0xff] ^ (crc >>> 8);
|
|
3256
|
-
}
|
|
3257
|
-
controller.enqueue(chunk);
|
|
3258
|
-
}
|
|
3259
|
-
});
|
|
3260
|
-
let compressedSize = 0;
|
|
3261
|
-
const reader = inputStream
|
|
3262
|
-
.pipeThrough(crcStream)
|
|
3263
|
-
.pipeThrough(new CompressionStream('deflate-raw'))
|
|
3264
|
-
.getReader();
|
|
3265
|
-
while (true) {
|
|
3266
|
-
const { done, value } = await reader.read();
|
|
3267
|
-
if (done)
|
|
3268
|
-
break;
|
|
3269
|
-
compressedSize += value.length;
|
|
3270
|
-
await this.pushChunk(value);
|
|
3271
|
-
}
|
|
3272
|
-
crc = (crc ^ 0xffffffff) >>> 0;
|
|
3273
|
-
assertNoZip64(uncompressedSize, filenameStr);
|
|
3274
|
-
assertNoZip64(compressedSize, filenameStr);
|
|
3275
|
-
// Data Descriptor
|
|
3276
|
-
const desc = new Uint8Array(16);
|
|
3277
|
-
const descView = new DataView(desc.buffer);
|
|
3278
|
-
descView.setUint32(0, 0x08074b50, true);
|
|
3279
|
-
descView.setUint32(4, crc, true);
|
|
3280
|
-
descView.setUint32(8, compressedSize, true);
|
|
3281
|
-
descView.setUint32(12, uncompressedSize, true);
|
|
3282
|
-
await this.pushChunk(desc);
|
|
3283
|
-
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags: 0x0008, method: 8 });
|
|
3284
|
-
}
|
|
3285
|
-
// Adds a file that is ALREADY compressed (pass-through for the Editor)
|
|
3286
|
-
async addCompressedFile(filenameStr, compressedStream, uncompressedSize, compressedSize, crc, method = 8) {
|
|
3287
|
-
const filename = this.textEncoder.encode(filenameStr);
|
|
3288
|
-
const startOffset = this.offset;
|
|
3289
|
-
await this.pushChunk(this.localHeader(filename, 0, method, crc, compressedSize, uncompressedSize));
|
|
3290
|
-
const reader = compressedStream.getReader();
|
|
3291
|
-
while (true) {
|
|
3292
|
-
const { done, value } = await reader.read();
|
|
3293
|
-
if (done)
|
|
3294
|
-
break;
|
|
3295
|
-
await this.pushChunk(value);
|
|
3296
|
-
}
|
|
3297
|
-
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags: 0, method });
|
|
3298
|
-
}
|
|
3299
|
-
async close() {
|
|
3300
|
-
if (this.cdEntries.length > 0xffff)
|
|
3301
|
-
throw new Error('ZIP64 not supported: more than 65535 entries.');
|
|
3302
|
-
const cdStartOffset = this.offset;
|
|
3303
|
-
for (const entry of this.cdEntries) {
|
|
3304
|
-
const cd = new Uint8Array(46 + entry.filename.length);
|
|
3305
|
-
const view = new DataView(cd.buffer);
|
|
3306
|
-
view.setUint32(0, 0x02014b50, true);
|
|
3307
|
-
view.setUint16(4, 20, true); // version made by
|
|
3308
|
-
view.setUint16(6, 20, true); // version needed
|
|
3309
|
-
view.setUint16(8, entry.flags, true);
|
|
3310
|
-
view.setUint16(10, entry.method, true);
|
|
3311
|
-
view.setUint32(16, entry.crc, true);
|
|
3312
|
-
view.setUint32(20, entry.compressedSize, true);
|
|
3313
|
-
view.setUint32(24, entry.uncompressedSize, true);
|
|
3314
|
-
view.setUint16(28, entry.filename.length, true);
|
|
3315
|
-
view.setUint32(42, entry.offset, true);
|
|
3316
|
-
cd.set(entry.filename, 46);
|
|
3317
|
-
await this.pushChunk(cd);
|
|
3318
|
-
}
|
|
3319
|
-
const cdSize = this.offset - cdStartOffset;
|
|
3320
|
-
assertNoZip64(this.offset, 'archive');
|
|
3321
|
-
const eocd = new Uint8Array(22);
|
|
3322
|
-
const eocdView = new DataView(eocd.buffer);
|
|
3323
|
-
eocdView.setUint32(0, 0x06054b50, true);
|
|
3324
|
-
eocdView.setUint16(8, this.cdEntries.length, true);
|
|
3325
|
-
eocdView.setUint16(10, this.cdEntries.length, true);
|
|
3326
|
-
eocdView.setUint32(12, cdSize, true);
|
|
3327
|
-
eocdView.setUint32(16, cdStartOffset, true);
|
|
3328
|
-
await this.pushChunk(eocd);
|
|
3329
|
-
this.streamController.close();
|
|
3330
|
-
}
|
|
3331
|
-
// Propagate a producer failure to whoever is reading `stream`.
|
|
3332
|
-
error(err) {
|
|
3333
|
-
try {
|
|
3334
|
-
this.streamController.error(err);
|
|
3335
|
-
}
|
|
3336
|
-
catch { /* already closed/errored */ }
|
|
3337
|
-
}
|
|
3338
|
-
localHeader(filename, flags, method, crc, compressedSize, uncompressedSize) {
|
|
3339
|
-
assertNoZip64(this.offset, 'archive');
|
|
3340
|
-
const header = new Uint8Array(30 + filename.length);
|
|
3341
|
-
const view = new DataView(header.buffer);
|
|
3342
|
-
view.setUint32(0, 0x04034b50, true);
|
|
3343
|
-
view.setUint16(4, 20, true);
|
|
3344
|
-
view.setUint16(6, flags, true);
|
|
3345
|
-
view.setUint16(8, method, true);
|
|
3346
|
-
view.setUint32(14, crc, true);
|
|
3347
|
-
view.setUint32(18, compressedSize, true);
|
|
3348
|
-
view.setUint32(22, uncompressedSize, true);
|
|
3349
|
-
view.setUint16(26, filename.length, true);
|
|
3350
|
-
header.set(filename, 30);
|
|
3351
|
-
return header;
|
|
3352
|
-
}
|
|
3353
|
-
// Enqueue and wait while the consumer's queue is full, so memory stays bounded.
|
|
3354
|
-
async pushChunk(chunk) {
|
|
3355
|
-
this.streamController.enqueue(chunk);
|
|
3356
|
-
this.offset += chunk.length;
|
|
3357
|
-
while ((this.streamController.desiredSize ?? 1) <= 0) {
|
|
3358
|
-
if (this.cancelled)
|
|
3359
|
-
throw new Error('ZIP stream cancelled by consumer.');
|
|
3360
|
-
await new Promise(resolve => { this.resumePull = resolve; });
|
|
3361
|
-
}
|
|
3362
|
-
}
|
|
3613
|
+
// Excel's conversions: blank is 0 / "" / FALSE, booleans are 1 and 0, text must read as a number
|
|
3614
|
+
function toNumber(v) {
|
|
3615
|
+
if (v instanceof FormulaError)
|
|
3616
|
+
return v;
|
|
3617
|
+
if (v === null)
|
|
3618
|
+
return 0;
|
|
3619
|
+
if (typeof v === 'boolean')
|
|
3620
|
+
return v ? 1 : 0;
|
|
3621
|
+
if (typeof v === 'number')
|
|
3622
|
+
return v;
|
|
3623
|
+
const t = v.trim();
|
|
3624
|
+
const n = t === '' ? NaN : Number(t);
|
|
3625
|
+
return isFinite(n) ? n : VALUE;
|
|
3626
|
+
}
|
|
3627
|
+
function toText(v) {
|
|
3628
|
+
if (v === null)
|
|
3629
|
+
return '';
|
|
3630
|
+
if (typeof v === 'boolean')
|
|
3631
|
+
return v ? 'TRUE' : 'FALSE';
|
|
3632
|
+
if (typeof v === 'number')
|
|
3633
|
+
return numberText(v);
|
|
3634
|
+
return String(v);
|
|
3635
|
+
}
|
|
3636
|
+
function toBool(v) {
|
|
3637
|
+
if (v instanceof FormulaError)
|
|
3638
|
+
return v;
|
|
3639
|
+
if (v === null)
|
|
3640
|
+
return false;
|
|
3641
|
+
if (typeof v === 'boolean')
|
|
3642
|
+
return v;
|
|
3643
|
+
if (typeof v === 'number')
|
|
3644
|
+
return v !== 0;
|
|
3645
|
+
const t = v.toUpperCase();
|
|
3646
|
+
return t === 'TRUE' ? true : t === 'FALSE' ? false : VALUE;
|
|
3647
|
+
}
|
|
3648
|
+
// Excel orders numbers < text < booleans; text compares without case. A blank takes the other side's type.
|
|
3649
|
+
function compare(a, b) {
|
|
3650
|
+
if (a === null)
|
|
3651
|
+
a = typeof b === 'string' ? '' : typeof b === 'boolean' ? false : 0;
|
|
3652
|
+
if (b === null)
|
|
3653
|
+
b = typeof a === 'string' ? '' : typeof a === 'boolean' ? false : 0;
|
|
3654
|
+
const rank = (v) => typeof v === 'number' ? 0 : typeof v === 'string' ? 1 : 2;
|
|
3655
|
+
if (rank(a) !== rank(b))
|
|
3656
|
+
return rank(a) - rank(b);
|
|
3657
|
+
if (typeof a === 'string') {
|
|
3658
|
+
const x = a.toLowerCase(), y = b.toLowerCase();
|
|
3659
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
3660
|
+
}
|
|
3661
|
+
const x = Number(a), y = Number(b);
|
|
3662
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
3363
3663
|
}
|
|
3364
3664
|
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3665
|
+
const escapeXml$1 = (val) => escapeXml$4(String(val)).replace(/'/g, ''');
|
|
3666
|
+
// "A1:B2" (or one cell) -> 0-based corners, checked against Excel's sheet size
|
|
3667
|
+
function parseRange$1(ref, what) {
|
|
3668
|
+
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/.exec(String(ref).trim());
|
|
3669
|
+
const box = m && {
|
|
3670
|
+
c1: colIndex(m[1]), r1: parseInt(m[2], 10) - 1,
|
|
3671
|
+
c2: colIndex(m[3] ?? m[1]), r2: parseInt(m[4] ?? m[2], 10) - 1,
|
|
3672
|
+
};
|
|
3673
|
+
if (!box || [box.c1, box.c2].some(c => c >= MAX_COLUMNS$1) || [box.r1, box.r2].some(r => r < 0 || r >= MAX_ROWS$1)) {
|
|
3674
|
+
throw new Error(`Invalid ${what} "${ref}".`);
|
|
3675
|
+
}
|
|
3676
|
+
return { c1: Math.min(box.c1, box.c2), r1: Math.min(box.r1, box.r2), c2: Math.max(box.c1, box.c2), r2: Math.max(box.r1, box.r2) };
|
|
3372
3677
|
}
|
|
3678
|
+
const overlaps = (a, b) => a.c1 <= b.c2 && b.c1 <= a.c2 && a.r1 <= b.r2 && b.r1 <= a.r2;
|
|
3373
3679
|
function isStyledCell$1(v) {
|
|
3374
3680
|
return typeof v === 'object' && v !== null && 'value' in v;
|
|
3375
3681
|
}
|
|
@@ -3399,18 +3705,18 @@ const W3CDTF = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
|
3399
3705
|
const propXml = (tag, v) => v === undefined ? '' : `<${tag}>${escapeXml$1(v)}</${tag}>`;
|
|
3400
3706
|
function corePropsXml(p) {
|
|
3401
3707
|
const created = p.created ? `<dcterms:created xsi:type="dcterms:W3CDTF">${W3CDTF(p.created)}</dcterms:created>` : '';
|
|
3402
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3708
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3403
3709
|
<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:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
|
|
3404
3710
|
propXml('dc:title', p.title) + propXml('dc:subject', p.subject) + propXml('dc:creator', p.creator) + propXml('cp:keywords', p.keywords) +
|
|
3405
3711
|
propXml('dc:description', p.description) + propXml('cp:category', p.category) + created + '</cp:coreProperties>';
|
|
3406
3712
|
}
|
|
3407
3713
|
function appPropsXml(p) {
|
|
3408
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3714
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3409
3715
|
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>XlsxFlow</Application>${propXml('Manager', p.manager)}${propXml('Company', p.company)}</Properties>`;
|
|
3410
3716
|
}
|
|
3411
3717
|
// A name Excel accepts: letters, digits, _ . and \, not a cell reference (A1, R1C1) and not reserved
|
|
3412
3718
|
function validateDefinedName(name) {
|
|
3413
|
-
if (!/^[A-Za-z_\\][A-Za-z0-9_.\\]*$/.test(name) || /^[A-Za-z]{1,3}\d+$/.test(name) || /^([Rr]\d*)?([Cc]\d*)?$/.test(name) || /^_xlnm\./i.test(name)) {
|
|
3719
|
+
if (!/^[A-Za-z_\\][A-Za-z0-9_.\\]*$/.test(name) || name.length > 255 || /^[A-Za-z]{1,3}\d+$/.test(name) || /^([Rr]\d*)?([Cc]\d*)?$/.test(name) || /^_xlnm\./i.test(name)) {
|
|
3414
3720
|
throw new Error(`Invalid defined name "${name}"`);
|
|
3415
3721
|
}
|
|
3416
3722
|
}
|
|
@@ -3485,6 +3791,8 @@ class SheetWriter {
|
|
|
3485
3791
|
this.formulaEngine.clear();
|
|
3486
3792
|
if (Array.isArray(sheet.rows)) {
|
|
3487
3793
|
this.formulaEngine.loadData(sheet.rows.map(row => row.map(cell => {
|
|
3794
|
+
if (isStyledCell$1(cell) && cell.formula)
|
|
3795
|
+
return { formula: cell.formula };
|
|
3488
3796
|
const v = !isStyledCell$1(cell) ? cell : cell.richText && cell.value == null ? cell.richText.map(r => r.text).join('') : cell.value;
|
|
3489
3797
|
return v instanceof Date ? dateToSerial(v) : v;
|
|
3490
3798
|
})));
|
|
@@ -3493,7 +3801,7 @@ class SheetWriter {
|
|
|
3493
3801
|
}
|
|
3494
3802
|
const parts = { links: [], comments: [] };
|
|
3495
3803
|
const sheetTables = tables.filter(t => t.sheet === i);
|
|
3496
|
-
await zip.addFile(`xl/worksheets/sheet${i + 1}.xml`, this.buildWorksheetXmlStream(sheet.rows, sheet.options, parts, sheetTables.length));
|
|
3804
|
+
await zip.addFile(`xl/worksheets/sheet${i + 1}.xml`, this.buildWorksheetXmlStream(sheet.rows, sheet.options, parts, sheetTables.length, Array.isArray(sheet.rows)));
|
|
3497
3805
|
const images = sheet.options.images ?? [];
|
|
3498
3806
|
const drawing = images.length ? ++drawings : 0;
|
|
3499
3807
|
const rels = this.buildSheetRels(parts.links, drawing, parts.comments.length ? i + 1 : 0, sheetTables.map(t => t.id));
|
|
@@ -3531,14 +3839,26 @@ class SheetWriter {
|
|
|
3531
3839
|
const plan = [];
|
|
3532
3840
|
const names = new Set();
|
|
3533
3841
|
this.sheets.forEach((sheet, i) => {
|
|
3842
|
+
const boxes = [];
|
|
3534
3843
|
for (const table of sheet.options.tables ?? []) {
|
|
3535
|
-
|
|
3536
|
-
|
|
3844
|
+
// Table names follow the defined-name rules: no "AB12", "R1C1" or "C"
|
|
3845
|
+
let valid = !names.has(String(table.name).toLowerCase());
|
|
3846
|
+
try {
|
|
3847
|
+
validateDefinedName(table.name);
|
|
3537
3848
|
}
|
|
3849
|
+
catch {
|
|
3850
|
+
valid = false;
|
|
3851
|
+
}
|
|
3852
|
+
if (!valid)
|
|
3853
|
+
throw new Error(`Invalid or duplicate table name "${table.name}".`);
|
|
3538
3854
|
names.add(table.name.toLowerCase());
|
|
3539
3855
|
const m = /^([A-Za-z]{1,3})(\d+):([A-Za-z]{1,3})(\d+)$/.exec(table.ref.replace(/\$/g, ''));
|
|
3540
3856
|
if (!m)
|
|
3541
3857
|
throw new Error(`Invalid table range "${table.ref}".`);
|
|
3858
|
+
const box = parseRange$1(table.ref, 'table range');
|
|
3859
|
+
if (boxes.some(b => overlaps(b, box)))
|
|
3860
|
+
throw new Error(`Table "${table.name}" overlaps another table on sheet "${sheet.name}".`);
|
|
3861
|
+
boxes.push(box);
|
|
3542
3862
|
const first = colIndex(m[1]);
|
|
3543
3863
|
const width = colIndex(m[3]) - first + 1;
|
|
3544
3864
|
let columns = table.columns;
|
|
@@ -3573,7 +3893,7 @@ class SheetWriter {
|
|
|
3573
3893
|
});
|
|
3574
3894
|
await this.streamString(zip, `xl/drawings/drawing${n}.xml`, drawingXml(images, images.map(img => this.media.get(img.data).info), rIds));
|
|
3575
3895
|
const rels = targets.map((t, k) => `<Relationship Id="rId${k + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${t}"/>`).join('');
|
|
3576
|
-
await this.streamString(zip, `xl/drawings/_rels/drawing${n}.xml.rels`, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3896
|
+
await this.streamString(zip, `xl/drawings/_rels/drawing${n}.xml.rels`, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3577
3897
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rels}</Relationships>`);
|
|
3578
3898
|
}
|
|
3579
3899
|
applyAutoFit(rows, options) {
|
|
@@ -3604,9 +3924,9 @@ class SheetWriter {
|
|
|
3604
3924
|
await zip.addFile(filename, stream);
|
|
3605
3925
|
}
|
|
3606
3926
|
// Pull-based so rows are only generated as fast as the ZIP consumer drains them.
|
|
3607
|
-
buildWorksheetXmlStream(rows, options, parts, tables) {
|
|
3927
|
+
buildWorksheetXmlStream(rows, options, parts, tables, evaluate) {
|
|
3608
3928
|
const encoder = new TextEncoder();
|
|
3609
|
-
const chunks = this.worksheetXmlChunks(rows, options, parts, tables);
|
|
3929
|
+
const chunks = this.worksheetXmlChunks(rows, options, parts, tables, evaluate);
|
|
3610
3930
|
return new ReadableStream({
|
|
3611
3931
|
async pull(controller) {
|
|
3612
3932
|
const { done, value } = await chunks.next();
|
|
@@ -3621,7 +3941,8 @@ class SheetWriter {
|
|
|
3621
3941
|
});
|
|
3622
3942
|
}
|
|
3623
3943
|
// Hyperlink targets and comments are collected in `parts` for the parts written after the sheet
|
|
3624
|
-
|
|
3944
|
+
// `evaluate`: the rows are an array loaded into the formula engine, so formulas get cached results
|
|
3945
|
+
async *worksheetXmlChunks(rows, options, parts, tables, evaluate) {
|
|
3625
3946
|
const links = parts.links;
|
|
3626
3947
|
const hyperlinks = [];
|
|
3627
3948
|
const colCount = Math.max(options.columnWidths?.length ?? 0, options.columns?.length ?? 0);
|
|
@@ -3634,7 +3955,12 @@ class SheetWriter {
|
|
|
3634
3955
|
colWidths += `<col min="${i + 1}" max="${i + 1}"${width !== undefined ? ` width="${escapeXml$1(width)}" customWidth="1"` : ''}` +
|
|
3635
3956
|
`${c.hidden ? ' hidden="1"' : ''}${c.outlineLevel ? ` outlineLevel="${escapeXml$1(c.outlineLevel)}"` : ''}/>`;
|
|
3636
3957
|
}
|
|
3637
|
-
const rowOptions = Object.entries(options.rows ?? {}).map(([r, o]) =>
|
|
3958
|
+
const rowOptions = Object.entries(options.rows ?? {}).map(([r, o]) => {
|
|
3959
|
+
const n = Number(r);
|
|
3960
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ROWS$1)
|
|
3961
|
+
throw new Error(`Row options for "${r}": rows are numbered 1 to ${MAX_ROWS$1}.`);
|
|
3962
|
+
return [n, o];
|
|
3963
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
3638
3964
|
const rowAttrs = new Map(rowOptions.map(([r, o]) => [r, `${o.height !== undefined ? ` ht="${escapeXml$1(o.height)}" customHeight="1"` : ''}${o.hidden ? ' hidden="1"' : ''}${o.outlineLevel ? ` outlineLevel="${escapeXml$1(o.outlineLevel)}"` : ''}`]));
|
|
3639
3965
|
let nextRowOption = 0;
|
|
3640
3966
|
// Rows that only exist for their options (height, hidden, outline) and hold no cells
|
|
@@ -3650,7 +3976,7 @@ class SheetWriter {
|
|
|
3650
3976
|
const page = options.pageSetup;
|
|
3651
3977
|
const fitToPage = page && (page.fitToWidth !== undefined || page.fitToHeight !== undefined);
|
|
3652
3978
|
const sheetPr = options.tabColor || fitToPage
|
|
3653
|
-
? `<sheetPr>${options.tabColor ? `<tabColor rgb="${
|
|
3979
|
+
? `<sheetPr>${options.tabColor ? `<tabColor rgb="${argb(options.tabColor)}"/>` : ''}${fitToPage ? '<pageSetUpPr fitToPage="1"/>' : ''}</sheetPr>`
|
|
3654
3980
|
: '';
|
|
3655
3981
|
const sheetFormatPr = outlineRow || outlineCol
|
|
3656
3982
|
? `<sheetFormatPr defaultRowHeight="15"${outlineRow ? ` outlineLevelRow="${outlineRow}"` : ''}${outlineCol ? ` outlineLevelCol="${outlineCol}"` : ''}/>`
|
|
@@ -3683,96 +4009,108 @@ class SheetWriter {
|
|
|
3683
4009
|
: (async function* () { for (const r of rows)
|
|
3684
4010
|
yield r; })();
|
|
3685
4011
|
let chunkStr = '';
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
const
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
const sAttr = style > 0 ? ` s="${style}"` : '';
|
|
3710
|
-
if (styledCell.comment !== undefined) {
|
|
3711
|
-
const comment = typeof styledCell.comment === 'string' ? { text: styledCell.comment } : styledCell.comment;
|
|
3712
|
-
parts.comments.push({ ref: colRef, row: rowNum - 1, col: ci, comment });
|
|
3713
|
-
}
|
|
3714
|
-
if (styledCell.hyperlink) {
|
|
3715
|
-
if (styledCell.hyperlink.startsWith('#')) {
|
|
3716
|
-
hyperlinks.push(`<hyperlink ref="${colRef}" location="${escapeXml$1(styledCell.hyperlink.slice(1))}"/>`);
|
|
4012
|
+
// Ends the caller's row source when the output is cancelled or fails (its finally blocks run)
|
|
4013
|
+
try {
|
|
4014
|
+
while (true) {
|
|
4015
|
+
const { done, value: row } = await iterator.next();
|
|
4016
|
+
if (done)
|
|
4017
|
+
break;
|
|
4018
|
+
const rowNum = ri + 1;
|
|
4019
|
+
// Past Excel's sheet size the file is damaged, so refuse it instead
|
|
4020
|
+
if (rowNum > MAX_ROWS$1)
|
|
4021
|
+
throw new Error(`Row ${rowNum} is past Excel's last row (${MAX_ROWS$1}).`);
|
|
4022
|
+
if (row.length > MAX_COLUMNS$1)
|
|
4023
|
+
throw new Error(`Row ${rowNum} has ${row.length} cells, more than Excel's ${MAX_COLUMNS$1} columns.`);
|
|
4024
|
+
chunkStr += optionRowsBefore(rowNum);
|
|
4025
|
+
if (rowOptions[nextRowOption]?.[0] === rowNum)
|
|
4026
|
+
nextRowOption++;
|
|
4027
|
+
const cellsXml = row.map((cell, ci) => {
|
|
4028
|
+
const colRef = colLetter(ci) + rowNum;
|
|
4029
|
+
const styledCell = isStyledCell$1(cell) ? cell : { value: cell };
|
|
4030
|
+
const val = styledCell.value;
|
|
4031
|
+
let cellStyle = styledCell.style;
|
|
4032
|
+
if (val instanceof Date && !cellStyle?.numFmt) {
|
|
4033
|
+
// A date needs a date format, or Excel shows the bare serial number
|
|
4034
|
+
cellStyle = { ...cellStyle, numFmt: defaultDateFormat(val) };
|
|
3717
4035
|
}
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
4036
|
+
const style = cellStyle ? this.styleEngine.registerStyle(cellStyle) : 0;
|
|
4037
|
+
const sAttr = style > 0 ? ` s="${style}"` : '';
|
|
4038
|
+
if (styledCell.comment !== undefined) {
|
|
4039
|
+
const comment = typeof styledCell.comment === 'string' ? { text: styledCell.comment } : styledCell.comment;
|
|
4040
|
+
parts.comments.push({ ref: colRef, row: rowNum - 1, col: ci, comment });
|
|
3721
4041
|
}
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
tAttr = ' t="b"';
|
|
3731
|
-
cachedVal = `<v>${result ? 1 : 0}</v>`;
|
|
4042
|
+
if (styledCell.hyperlink) {
|
|
4043
|
+
if (styledCell.hyperlink.startsWith('#')) {
|
|
4044
|
+
hyperlinks.push(`<hyperlink ref="${colRef}" location="${escapeXml$1(styledCell.hyperlink.slice(1))}"/>`);
|
|
4045
|
+
}
|
|
4046
|
+
else {
|
|
4047
|
+
links.push(styledCell.hyperlink);
|
|
4048
|
+
hyperlinks.push(`<hyperlink ref="${colRef}" r:id="rId${links.length}"/>`);
|
|
4049
|
+
}
|
|
3732
4050
|
}
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
4051
|
+
if (styledCell.formula) {
|
|
4052
|
+
// Streamed rows aren't in the engine, so their formulas get no (made-up) cached value
|
|
4053
|
+
const result = evaluate ? this.formulaEngine.cellValue(colRef) : null;
|
|
4054
|
+
let tAttr = '';
|
|
4055
|
+
let cachedVal = '';
|
|
4056
|
+
if (typeof result === 'number' && isFinite(result))
|
|
4057
|
+
cachedVal = `<v>${result}</v>`;
|
|
4058
|
+
else if (typeof result === 'boolean') {
|
|
4059
|
+
tAttr = ' t="b"';
|
|
4060
|
+
cachedVal = `<v>${result ? 1 : 0}</v>`;
|
|
4061
|
+
}
|
|
4062
|
+
else if (result instanceof FormulaError) {
|
|
4063
|
+
tAttr = ' t="e"';
|
|
4064
|
+
cachedVal = `<v>${escapeXml$1(result.code)}</v>`;
|
|
4065
|
+
}
|
|
4066
|
+
else if (typeof result === 'string') {
|
|
4067
|
+
tAttr = ' t="str"';
|
|
4068
|
+
cachedVal = `<v>${escapeXml$1(encodeXString(result))}</v>`;
|
|
4069
|
+
}
|
|
4070
|
+
return `<c r="${colRef}"${tAttr}${sAttr}><f>${escapeXml$1(styledCell.formula.replace(/^=/, ''))}</f>${cachedVal}</c>`;
|
|
3736
4071
|
}
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
this.
|
|
3761
|
-
|
|
3762
|
-
|
|
4072
|
+
if (styledCell.richText) {
|
|
4073
|
+
// Rich text is always inline, even with sharedStrings on
|
|
4074
|
+
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${runsXml(styledCell.richText)}</is></c>`;
|
|
4075
|
+
}
|
|
4076
|
+
if (val === null || val === undefined)
|
|
4077
|
+
return `<c r="${colRef}"${sAttr}/>`;
|
|
4078
|
+
if (typeof val === 'boolean')
|
|
4079
|
+
return `<c r="${colRef}" t="b"${sAttr}><v>${val ? 1 : 0}</v></c>`;
|
|
4080
|
+
if (typeof val === 'number') {
|
|
4081
|
+
// NaN/Infinity have no representation in a cell
|
|
4082
|
+
return isFinite(val) ? `<c r="${colRef}"${sAttr}><v>${val}</v></c>` : `<c r="${colRef}" t="e"${sAttr}><v>#NUM!</v></c>`;
|
|
4083
|
+
}
|
|
4084
|
+
if (val instanceof Date) {
|
|
4085
|
+
if (isNaN(val.getTime()))
|
|
4086
|
+
throw new Error(`Invalid Date in cell ${colRef}`);
|
|
4087
|
+
return `<c r="${colRef}"${sAttr}><v>${dateToSerial(val)}</v></c>`;
|
|
4088
|
+
}
|
|
4089
|
+
if (typeof val === 'string') {
|
|
4090
|
+
checkCellText(val, colRef);
|
|
4091
|
+
if (this.writerOptions.sharedStrings) {
|
|
4092
|
+
let index = this.sharedStrings.get(val);
|
|
4093
|
+
if (index === undefined)
|
|
4094
|
+
this.sharedStrings.set(val, index = this.sharedStrings.size);
|
|
4095
|
+
this.sharedStringRefs++;
|
|
4096
|
+
return `<c r="${colRef}" t="s"${sAttr}><v>${index}</v></c>`;
|
|
4097
|
+
}
|
|
4098
|
+
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${stringXml(val)}</is></c>`;
|
|
3763
4099
|
}
|
|
3764
|
-
return `<c r="${colRef}"
|
|
4100
|
+
return `<c r="${colRef}"${sAttr}/>`;
|
|
4101
|
+
}).join('');
|
|
4102
|
+
chunkStr += ` <row r="${rowNum}"${rowAttrs.get(rowNum) ?? ''}>${cellsXml}</row>\n`;
|
|
4103
|
+
ri++;
|
|
4104
|
+
// Flush every ~64KB of string data to keep O(1) memory while avoiding chunk overhead
|
|
4105
|
+
if (chunkStr.length > 65536) {
|
|
4106
|
+
yield chunkStr;
|
|
4107
|
+
chunkStr = '';
|
|
3765
4108
|
}
|
|
3766
|
-
return `<c r="${colRef}"${sAttr}/>`;
|
|
3767
|
-
}).join('');
|
|
3768
|
-
chunkStr += ` <row r="${rowNum}"${rowAttrs.get(rowNum) ?? ''}>${cellsXml}</row>\n`;
|
|
3769
|
-
ri++;
|
|
3770
|
-
// Flush every ~64KB of string data to keep O(1) memory while avoiding chunk overhead
|
|
3771
|
-
if (chunkStr.length > 65536) {
|
|
3772
|
-
yield chunkStr;
|
|
3773
|
-
chunkStr = '';
|
|
3774
4109
|
}
|
|
3775
4110
|
}
|
|
4111
|
+
finally {
|
|
4112
|
+
await iterator.return?.();
|
|
4113
|
+
}
|
|
3776
4114
|
chunkStr += optionRowsBefore(Infinity);
|
|
3777
4115
|
if (chunkStr.length > 0) {
|
|
3778
4116
|
yield chunkStr;
|
|
@@ -3788,6 +4126,14 @@ class SheetWriter {
|
|
|
3788
4126
|
if (options.autoFilter)
|
|
3789
4127
|
footer += ` <autoFilter ref="${escapeXml$1(options.autoFilter)}"/>\n`;
|
|
3790
4128
|
if (options.mergeCells && options.mergeCells.length > 0) {
|
|
4129
|
+
// Overlapping or malformed merges make Excel repair the file
|
|
4130
|
+
const boxes = [];
|
|
4131
|
+
for (const ref of options.mergeCells) {
|
|
4132
|
+
const box = parseRange$1(ref, 'merge range');
|
|
4133
|
+
if (boxes.some(b => overlaps(b, box)))
|
|
4134
|
+
throw new Error(`Merge "${ref}" overlaps another merge.`);
|
|
4135
|
+
boxes.push(box);
|
|
4136
|
+
}
|
|
3791
4137
|
const merges = options.mergeCells.map(ref => `<mergeCell ref="${escapeXml$1(ref)}"/>`).join('');
|
|
3792
4138
|
footer += ` <mergeCells count="${options.mergeCells.length}">${merges}</mergeCells>\n`;
|
|
3793
4139
|
}
|
|
@@ -3815,11 +4161,16 @@ class SheetWriter {
|
|
|
3815
4161
|
if (dv[k])
|
|
3816
4162
|
attr += ` ${k}="${escapeXml$1(dv[k])}"`;
|
|
3817
4163
|
}
|
|
4164
|
+
// The file stores formulas without "="; a typed list ("a,b,c") holds at most 255 characters
|
|
4165
|
+
const f1 = dv.formula1?.replace(/^=/, ''), f2 = dv.formula2?.replace(/^=/, '');
|
|
4166
|
+
if (dv.type === 'list' && f1?.startsWith('"') && f1.length - 2 > 255) {
|
|
4167
|
+
throw new Error(`List validation for ${dv.sqref} has ${f1.length - 2} characters; Excel allows 255. Put the items in cells and refer to the range.`);
|
|
4168
|
+
}
|
|
3818
4169
|
let inner = '';
|
|
3819
|
-
if (
|
|
3820
|
-
inner += `<formula1>${escapeXml$1(
|
|
3821
|
-
if (
|
|
3822
|
-
inner += `<formula2>${escapeXml$1(
|
|
4170
|
+
if (f1)
|
|
4171
|
+
inner += `<formula1>${escapeXml$1(f1)}</formula1>`;
|
|
4172
|
+
if (f2)
|
|
4173
|
+
inner += `<formula2>${escapeXml$1(f2)}</formula2>`;
|
|
3823
4174
|
return `<dataValidation ${attr}>${inner}</dataValidation>`;
|
|
3824
4175
|
}).join('');
|
|
3825
4176
|
footer += ` <dataValidations count="${options.dataValidations.length}">${dvs}</dataValidations>\n`;
|
|
@@ -3841,7 +4192,7 @@ class SheetWriter {
|
|
|
3841
4192
|
let items = '';
|
|
3842
4193
|
for (const s of this.sharedStrings.keys())
|
|
3843
4194
|
items += `<si>${stringXml(s)}</si>`;
|
|
3844
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4195
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3845
4196
|
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${this.sharedStringRefs}" uniqueCount="${this.sharedStrings.size}">${items}</sst>`;
|
|
3846
4197
|
}
|
|
3847
4198
|
// '' when the sheet has no relationships
|
|
@@ -3856,7 +4207,7 @@ class SheetWriter {
|
|
|
3856
4207
|
tableIds.forEach((id, k) => { rels += `<Relationship Id="rIdTable${k + 1}" Type="${REL}/table" Target="../tables/table${id}.xml"/>`; });
|
|
3857
4208
|
if (!rels)
|
|
3858
4209
|
return '';
|
|
3859
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4210
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3860
4211
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rels}</Relationships>`;
|
|
3861
4212
|
}
|
|
3862
4213
|
buildWorkbookXml() {
|
|
@@ -3873,22 +4224,24 @@ class SheetWriter {
|
|
|
3873
4224
|
: '').join('') + this.sheets.map((s, i) => {
|
|
3874
4225
|
const page = s.options.pageSetup;
|
|
3875
4226
|
let names = '';
|
|
3876
|
-
|
|
3877
|
-
|
|
4227
|
+
// Every range of a multi-range print area names its sheet
|
|
4228
|
+
const area = page?.printArea?.split(',').map(r => `${quoteSheet(s.name)}!${absoluteRef(r.trim())}`).join(',');
|
|
4229
|
+
if (area)
|
|
4230
|
+
names += `<definedName name="_xlnm.Print_Area" localSheetId="${i}">${escapeXml$1(area)}</definedName>`;
|
|
3878
4231
|
if (page?.printTitleRows) {
|
|
3879
4232
|
const rows = page.printTitleRows.replace(/\$/g, '').split(':').map(r => `$${r}`).join(':');
|
|
3880
4233
|
names += `<definedName name="_xlnm.Print_Titles" localSheetId="${i}">${escapeXml$1(`${quoteSheet(s.name)}!${rows.includes(':') ? rows : `${rows}:${rows}`}`)}</definedName>`;
|
|
3881
4234
|
}
|
|
3882
4235
|
return names;
|
|
3883
4236
|
}).join('');
|
|
3884
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3885
|
-
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
3886
|
-
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
3887
|
-
${active > 0 ? `<bookViews><workbookView firstSheet="${active}" activeTab="${active}"/></bookViews>` : ''}
|
|
3888
|
-
<sheets>
|
|
3889
|
-
${sheetsXml}
|
|
3890
|
-
</sheets>
|
|
3891
|
-
${filterNames || userNames ? `<definedNames>${filterNames}${userNames}</definedNames>` : ''}
|
|
4237
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4238
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
4239
|
+
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
4240
|
+
${active > 0 ? `<bookViews><workbookView firstSheet="${active}" activeTab="${active}"/></bookViews>` : ''}
|
|
4241
|
+
<sheets>
|
|
4242
|
+
${sheetsXml}
|
|
4243
|
+
</sheets>
|
|
4244
|
+
${filterNames || userNames ? `<definedNames>${filterNames}${userNames}</definedNames>` : ''}
|
|
3892
4245
|
</workbook>`;
|
|
3893
4246
|
}
|
|
3894
4247
|
// The first visible sheet, which Excel opens on
|
|
@@ -3899,19 +4252,19 @@ class SheetWriter {
|
|
|
3899
4252
|
const sheetRels = this.sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('\n ');
|
|
3900
4253
|
const ssId = this.sheets.length + 1;
|
|
3901
4254
|
const stylesId = this.sheets.length + 2;
|
|
3902
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3903
|
-
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
3904
|
-
${sheetRels}
|
|
3905
|
-
<Relationship Id="rId${ssId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
|
3906
|
-
<Relationship Id="rId${stylesId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
4255
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4256
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
4257
|
+
${sheetRels}
|
|
4258
|
+
<Relationship Id="rId${ssId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
|
4259
|
+
<Relationship Id="rId${stylesId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
3907
4260
|
</Relationships>`;
|
|
3908
4261
|
}
|
|
3909
4262
|
buildRootRels() {
|
|
3910
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3911
|
-
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
3912
|
-
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>${this.writerOptions.properties ? `
|
|
3913
|
-
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
|
3914
|
-
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>` : ''}
|
|
4263
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4264
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
4265
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>${this.writerOptions.properties ? `
|
|
4266
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
|
4267
|
+
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>` : ''}
|
|
3915
4268
|
</Relationships>`;
|
|
3916
4269
|
}
|
|
3917
4270
|
buildContentTypes(drawings, commentSheets, tables) {
|
|
@@ -3922,17 +4275,17 @@ class SheetWriter {
|
|
|
3922
4275
|
commentSheets.map(n => `<Override PartName="/xl/comments${n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>`).join('') +
|
|
3923
4276
|
Array.from({ length: tables }, (_, i) => `<Override PartName="/xl/tables/table${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>`).join('') +
|
|
3924
4277
|
(commentSheets.length ? '<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>' : '');
|
|
3925
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3926
|
-
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
3927
|
-
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
3928
|
-
<Default Extension="xml" ContentType="application/xml"/>
|
|
3929
|
-
${imageTypes}${drawingOverrides}
|
|
3930
|
-
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
3931
|
-
${sheetOverrides}
|
|
3932
|
-
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
|
3933
|
-
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${this.writerOptions.properties ? `
|
|
3934
|
-
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
|
3935
|
-
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>` : ''}
|
|
4278
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4279
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
4280
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
4281
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
4282
|
+
${imageTypes}${drawingOverrides}
|
|
4283
|
+
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
4284
|
+
${sheetOverrides}
|
|
4285
|
+
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
|
4286
|
+
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${this.writerOptions.properties ? `
|
|
4287
|
+
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
|
4288
|
+
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>` : ''}
|
|
3936
4289
|
</Types>`;
|
|
3937
4290
|
}
|
|
3938
4291
|
}
|
|
@@ -3964,7 +4317,7 @@ function pageSetupXml(page) {
|
|
|
3964
4317
|
function commentsXml(comments) {
|
|
3965
4318
|
const authors = [...new Set(comments.map(c => c.comment.author ?? ''))];
|
|
3966
4319
|
const list = comments.map(c => `<comment ref="${c.ref}" authorId="${authors.indexOf(c.comment.author ?? '')}"><text>${runsXml(c.comment.text)}</text></comment>`).join('');
|
|
3967
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4320
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3968
4321
|
<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors>${authors.map(a => `<author>${escapeXml$1(a)}</author>`).join('')}</authors><commentList>${list}</commentList></comments>`;
|
|
3969
4322
|
}
|
|
3970
4323
|
// The legacy VML shapes Excel needs to show the note boxes (hidden until the cell is hovered)
|
|
@@ -3984,7 +4337,7 @@ function tableXml(table, id, columns) {
|
|
|
3984
4337
|
const ref = table.ref.replace(/\$/g, '');
|
|
3985
4338
|
const filter = table.autoFilter === false ? '' : `<autoFilter ref="${ref}"/>`;
|
|
3986
4339
|
const cols = columns.map((c, k) => `<tableColumn id="${k + 1}" name="${escapeXml$1(c)}"/>`).join('');
|
|
3987
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4340
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
3988
4341
|
<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="${id}" name="${escapeXml$1(table.name)}" displayName="${escapeXml$1(table.name)}" ref="${ref}" totalsRowShown="0">` +
|
|
3989
4342
|
`${filter}<tableColumns count="${columns.length}">${cols}</tableColumns>` +
|
|
3990
4343
|
`<tableStyleInfo name="${escapeXml$1(table.style ?? 'TableStyleMedium2')}" showFirstColumn="0" showLastColumn="0" showRowStripes="${table.showRowStripes === false ? 0 : 1}" showColumnStripes="0"/></table>`;
|
|
@@ -4052,7 +4405,7 @@ var randomAccess = /*#__PURE__*/Object.freeze({
|
|
|
4052
4405
|
createFileReader: createFileReader
|
|
4053
4406
|
});
|
|
4054
4407
|
|
|
4055
|
-
const escapeAttr =
|
|
4408
|
+
const escapeAttr = escapeXml$4;
|
|
4056
4409
|
const FONT_TAGS = [
|
|
4057
4410
|
['bold', 'b'], ['italic', 'i'], ['underline', 'u'], ['size', 'sz'], ['color', 'color'], ['name', 'name'],
|
|
4058
4411
|
];
|
|
@@ -4083,7 +4436,7 @@ class StylePatcher {
|
|
|
4083
4436
|
this.xml = xml;
|
|
4084
4437
|
this.p = /<((?:\w+:)?)styleSheet\b/.exec(xml)?.[1] ?? '';
|
|
4085
4438
|
this.fonts = this.items('fonts', 'font');
|
|
4086
|
-
this.fills = this.items('fills', 'fill')
|
|
4439
|
+
this.fills = this.items('fills', 'fill');
|
|
4087
4440
|
this.borders = this.items('borders', 'border');
|
|
4088
4441
|
this.xfs = this.items('cellXfs', 'xf');
|
|
4089
4442
|
for (const tag of this.items('numFmts', 'numFmt')) {
|
|
@@ -4105,12 +4458,13 @@ class StylePatcher {
|
|
|
4105
4458
|
if (id !== undefined)
|
|
4106
4459
|
return id;
|
|
4107
4460
|
const p = this.p;
|
|
4108
|
-
|
|
4461
|
+
// `base` may be a format added earlier in this edit (a date format, then a style)
|
|
4462
|
+
const xf = this.at('cellXfs', base) ?? this.xfs[0] ?? `<${p}xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>`;
|
|
4109
4463
|
let open = /^<[^>]*>/.exec(xf)[0];
|
|
4110
4464
|
let inner = open.endsWith('/>') ? '' : xf.slice(open.length, xf.lastIndexOf('<'));
|
|
4111
4465
|
open = open.replace(/\s*\/>$/, '>');
|
|
4112
4466
|
if (style.font) {
|
|
4113
|
-
const old = this.fonts
|
|
4467
|
+
const old = this.at('fonts', parseInt(attr(open, 'fontId') ?? '0', 10)) ?? '';
|
|
4114
4468
|
let font = old.endsWith('/>') ? '' : old.replace(/^<[^>]*>/, '').replace(/<\/[^>]*>$/, '');
|
|
4115
4469
|
for (const [key, tag] of FONT_TAGS) {
|
|
4116
4470
|
if (!(key in style.font))
|
|
@@ -4127,7 +4481,7 @@ class StylePatcher {
|
|
|
4127
4481
|
open = setAttr(setAttr(open, 'fillId', String(this.add('fills', this.prefix(fillXml(style.fill))))), 'applyFill', '1');
|
|
4128
4482
|
}
|
|
4129
4483
|
if (style.border) {
|
|
4130
|
-
const old = this.borders
|
|
4484
|
+
const old = this.at('borders', parseInt(attr(open, 'borderId') ?? '0', 10)) ?? `<${p}border/>`;
|
|
4131
4485
|
const element = (tag) => new RegExp(`<${p}${tag}\\b[^>]*?(?:/>|>[\\s\\S]*?</${p}${tag}>)`).exec(old)?.[0];
|
|
4132
4486
|
const sides = SIDES.map(side => side in style.border
|
|
4133
4487
|
? this.prefix(borderSideXml(side, style.border[side])) : element(side) ?? `<${p}${side}/>`);
|
|
@@ -4162,7 +4516,7 @@ class StylePatcher {
|
|
|
4162
4516
|
// The format for date `d` in a cell of format `base`: `base` when it already shows dates, otherwise
|
|
4163
4517
|
// `base` with a date format, since a date in a General cell shows as its serial number
|
|
4164
4518
|
dateFormat(base, d) {
|
|
4165
|
-
const xf = this.
|
|
4519
|
+
const xf = this.at('cellXfs', base) ?? '';
|
|
4166
4520
|
const id = parseInt(attr(/^<[^>]*>/.exec(xf)?.[0] ?? '', 'numFmtId') ?? '0', 10);
|
|
4167
4521
|
const code = [...this.numFmts].find(([, v]) => v === id)?.[0];
|
|
4168
4522
|
if (isBuiltinDateFormat(id) || (code !== undefined && isDateFormatCode(code)))
|
|
@@ -4198,14 +4552,28 @@ class StylePatcher {
|
|
|
4198
4552
|
return xml;
|
|
4199
4553
|
}
|
|
4200
4554
|
existing(section) {
|
|
4201
|
-
return section === '
|
|
4202
|
-
: section === 'borders' ? this.borders.length : section === 'cellXfs' ? this.xfs.length
|
|
4203
|
-
: this.items('numFmts', 'numFmt').length;
|
|
4555
|
+
return section === 'numFmts' ? this.items('numFmts', 'numFmt').length : this.list(section).length;
|
|
4204
4556
|
}
|
|
4205
|
-
|
|
4557
|
+
list(section) {
|
|
4558
|
+
return section === 'fonts' ? this.fonts : section === 'fills' ? this.fills : section === 'borders' ? this.borders : this.xfs;
|
|
4559
|
+
}
|
|
4560
|
+
// Item `i` of a section, counting the ones added in this edit after the file's own
|
|
4561
|
+
at(section, i) {
|
|
4562
|
+
const own = this.list(section);
|
|
4563
|
+
return i < own.length ? own[i] : this.added[section][i - own.length];
|
|
4564
|
+
}
|
|
4565
|
+
// The index of `element` in a section, appending it unless an identical one is already there:
|
|
4566
|
+
// repeating the same restyle on an edited file reuses the formats the first run added
|
|
4206
4567
|
add(section, element) {
|
|
4568
|
+
const own = this.list(section);
|
|
4569
|
+
const found = own.indexOf(element);
|
|
4570
|
+
if (found >= 0)
|
|
4571
|
+
return found;
|
|
4572
|
+
const added = this.added[section].indexOf(element);
|
|
4573
|
+
if (added >= 0)
|
|
4574
|
+
return own.length + added;
|
|
4207
4575
|
this.added[section].push(element);
|
|
4208
|
-
return
|
|
4576
|
+
return own.length + this.added[section].length - 1;
|
|
4209
4577
|
}
|
|
4210
4578
|
items(section, tag) {
|
|
4211
4579
|
const p = this.p;
|
|
@@ -4346,12 +4714,12 @@ function mapSheetXml(xml, sheet, maps) {
|
|
|
4346
4714
|
if (child)
|
|
4347
4715
|
inner = inner.replace(child[0], `<${child[1]}sqref>${mapped}</${child[1]}sqref>`);
|
|
4348
4716
|
}
|
|
4349
|
-
inner = inner.replace(/<((?:\w+:)?)(formula1|formula2|formula|f)>([^<]*)<\/\1\2>/g, (_m, fp, ftag, f) => `<${fp}${ftag}>${escapeXml$
|
|
4717
|
+
inner = inner.replace(/<((?:\w+:)?)(formula1|formula2|formula|f)>([^<]*)<\/\1\2>/g, (_m, fp, ftag, f) => `<${fp}${ftag}>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps, anchor))}</${fp}${ftag}>`);
|
|
4350
4718
|
return whole.endsWith('/>') && !inner ? `<${p}${tag}${attrs}/>` : `<${p}${tag}${attrs}>${inner}</${p}${tag}>`;
|
|
4351
4719
|
});
|
|
4352
4720
|
xml = recount(xml, 'dataValidations', 'dataValidation');
|
|
4353
4721
|
// Sparklines (x14): the data range follows its cells; a sparkline whose own cell is deleted goes
|
|
4354
|
-
const mapF = (s) => s.replace(/<((?:\w+:)?)f>([^<]*)<\/\1f>/g, (_m, p, f) => `<${p}f>${escapeXml$
|
|
4722
|
+
const mapF = (s) => s.replace(/<((?:\w+:)?)f>([^<]*)<\/\1f>/g, (_m, p, f) => `<${p}f>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps))}</${p}f>`);
|
|
4355
4723
|
xml = xml.replace(/<((?:\w+:)?)sparklineGroup\b([^>]*)>([\s\S]*?)<\/\1sparklineGroup>/g, (whole, p, attrs, inner) => {
|
|
4356
4724
|
const list = /<((?:\w+:)?)sparklines>([\s\S]*?)<\/\1sparklines>/.exec(inner);
|
|
4357
4725
|
if (!list)
|
|
@@ -4480,12 +4848,17 @@ function createShiftTransform(sheet, maps) {
|
|
|
4480
4848
|
const expand = !!own || mapped !== text;
|
|
4481
4849
|
shared.set(si, { text, row: r, col: c, expand });
|
|
4482
4850
|
if (expand)
|
|
4483
|
-
xml = `<${p}f>${escapeXml$
|
|
4851
|
+
xml = `<${p}f>${escapeXml$4(mapped)}</${p}f>`;
|
|
4484
4852
|
}
|
|
4485
4853
|
else {
|
|
4486
4854
|
const a = shared.get(si);
|
|
4487
|
-
if (a
|
|
4488
|
-
|
|
4855
|
+
if (a) {
|
|
4856
|
+
// Even when the anchor's own references stay, this cell's may point into moved rows
|
|
4857
|
+
const own = shiftFormula(a.text, r - a.row, c - a.col);
|
|
4858
|
+
const mapped = mapF(own);
|
|
4859
|
+
if (a.expand || mapped !== own)
|
|
4860
|
+
xml = `<${p}f>${escapeXml$4(mapped)}</${p}f>`;
|
|
4861
|
+
}
|
|
4489
4862
|
}
|
|
4490
4863
|
}
|
|
4491
4864
|
else if (kind === 'dataTable') {
|
|
@@ -4494,7 +4867,7 @@ function createShiftTransform(sheet, maps) {
|
|
|
4494
4867
|
const range = attr(fAttrs, 'ref');
|
|
4495
4868
|
if (own && range)
|
|
4496
4869
|
fAttrs = fAttrs.replace(/\sref="[^"]*"/, ` ref="${mapArea(range, own) ?? range}"`);
|
|
4497
|
-
fAttrs = fAttrs.replace(/\s(r1|r2)="([^"]*)"/g, (_m, name, ref) => ` ${name}="${escapeXml$
|
|
4870
|
+
fAttrs = fAttrs.replace(/\s(r1|r2)="([^"]*)"/g, (_m, name, ref) => ` ${name}="${escapeXml$4(mapF(unescapeXml(ref)))}"`);
|
|
4498
4871
|
xml = f[0].replace(f[1], () => fAttrs);
|
|
4499
4872
|
}
|
|
4500
4873
|
else if (text) {
|
|
@@ -4502,7 +4875,7 @@ function createShiftTransform(sheet, maps) {
|
|
|
4502
4875
|
const range = attr(fAttrs, 'ref');
|
|
4503
4876
|
if (own && range)
|
|
4504
4877
|
fAttrs = fAttrs.replace(/\sref="[^"]*"/, ` ref="${mapArea(range, own) ?? range}"`);
|
|
4505
|
-
xml = `<${p}f${fAttrs}>${escapeXml$
|
|
4878
|
+
xml = `<${p}f${fAttrs}>${escapeXml$4(mapF(text))}</${p}f>`;
|
|
4506
4879
|
}
|
|
4507
4880
|
return xml === undefined ? cell : cell.replace(f[0], () => xml);
|
|
4508
4881
|
});
|
|
@@ -4662,7 +5035,7 @@ function mapTable(xml, sheet, maps) {
|
|
|
4662
5035
|
}
|
|
4663
5036
|
xml = mapAutoFilter(xml, own).replace(/(<(?:\w+:)?(?:table|sortState|sortCondition)\b[^>]*?\sref=")([^"]*)"/g, (_m, pre, area) => `${pre}${mapArea(area, own) ?? area}"`);
|
|
4664
5037
|
}
|
|
4665
|
-
xml = xml.replace(/<((?:\w+:)?)(calculatedColumnFormula|totalsRowFormula)\b([^>]*)>([^<]*)<\/\1\2>/g, (_m, p, tag, attrs, f) => `<${p}${tag}${attrs}>${escapeXml$
|
|
5038
|
+
xml = xml.replace(/<((?:\w+:)?)(calculatedColumnFormula|totalsRowFormula)\b([^>]*)>([^<]*)<\/\1\2>/g, (_m, p, tag, attrs, f) => `<${p}${tag}${attrs}>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps))}</${p}${tag}>`);
|
|
4666
5039
|
return { xml, headers };
|
|
4667
5040
|
}
|
|
4668
5041
|
// Chart series follow the cells they plot. Cached values are dropped when a series moves, and Excel
|
|
@@ -4674,10 +5047,14 @@ function mapChart(xml, maps) {
|
|
|
4674
5047
|
if (mapped === unescapeXml(f))
|
|
4675
5048
|
return whole;
|
|
4676
5049
|
changed = true;
|
|
4677
|
-
return open + escapeXml$
|
|
5050
|
+
return open + escapeXml$4(mapped) + close;
|
|
4678
5051
|
});
|
|
4679
5052
|
return changed ? out.replace(/<((?:\w+:)?)(numCache|strCache)\b[\s\S]*?<\/\1\2>/g, '') : xml;
|
|
4680
5053
|
}
|
|
5054
|
+
// A pivot table on a sheet whose rows or columns move is drawn where its cells went
|
|
5055
|
+
function mapPivotTable(xml, s) {
|
|
5056
|
+
return xml.replace(/(<(?:\w+:)?location\b[^>]*?\sref=")([^"]*)"/, (_m, pre, ref) => `${pre}${mapArea(ref, s) ?? ref}"`);
|
|
5057
|
+
}
|
|
4681
5058
|
// A pivot cache over moved cells reads the new range and refreshes when the file opens
|
|
4682
5059
|
function mapPivotCache(xml, maps) {
|
|
4683
5060
|
let changed = false;
|
|
@@ -4696,7 +5073,7 @@ function mapPivotCache(xml, maps) {
|
|
|
4696
5073
|
}
|
|
4697
5074
|
// Workbook defined names (print areas, named ranges) follow the cells they point at
|
|
4698
5075
|
function mapDefinedNames(wb, maps) {
|
|
4699
|
-
return wb.replace(/(<(?:\w+:)?definedName\b[^>]*>)([^<]*)(<\/(?:\w+:)?definedName>)/g, (_m, open, f, close) => open + escapeXml$
|
|
5076
|
+
return wb.replace(/(<(?:\w+:)?definedName\b[^>]*>)([^<]*)(<\/(?:\w+:)?definedName>)/g, (_m, open, f, close) => open + escapeXml$4(mapFormula(unescapeXml(f), '', maps)) + close);
|
|
4700
5077
|
}
|
|
4701
5078
|
|
|
4702
5079
|
const KEEP = Symbol('keep');
|
|
@@ -4723,12 +5100,12 @@ function editedCell(p, ref, attrs, edit) {
|
|
|
4723
5100
|
return `<${p}c r="${ref}"${attrs}><${p}v>${dateToSerial(edit)}</${p}v></${p}c>`;
|
|
4724
5101
|
}
|
|
4725
5102
|
if (typeof edit === 'object')
|
|
4726
|
-
return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$
|
|
4727
|
-
const text = escapeXml$
|
|
5103
|
+
return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$4(edit.formula.replace(/^=/, ''))}</${p}f></${p}c>`;
|
|
5104
|
+
const text = escapeXml$4(encodeXString(checkCellText(edit, ref)));
|
|
4728
5105
|
const t = /^\s|\s$/.test(edit) ? `<${p}t xml:space="preserve">${text}</${p}t>` : `<${p}t>${text}</${p}t>`;
|
|
4729
5106
|
return `<${p}c r="${ref}"${attrs} t="inlineStr"><${p}is>${t}</${p}is></${p}c>`;
|
|
4730
5107
|
}
|
|
4731
|
-
// The cells of a new sheet, as edits
|
|
5108
|
+
// The cells of new rows (a new sheet, or rows appended to one) as edits, rows numbered from 1
|
|
4732
5109
|
function rowsToEdits(name, rows) {
|
|
4733
5110
|
const edits = new Map();
|
|
4734
5111
|
rows.forEach((row, ri) => {
|
|
@@ -4741,7 +5118,7 @@ function rowsToEdits(name, rows) {
|
|
|
4741
5118
|
return;
|
|
4742
5119
|
}
|
|
4743
5120
|
if (cell.hyperlink !== undefined || cell.comment !== undefined) {
|
|
4744
|
-
throw new Error(`Sheet "${name}": SheetEditor
|
|
5121
|
+
throw new Error(`Sheet "${name}": SheetEditor does not write hyperlinks or notes in new rows; use SheetWriter.`);
|
|
4745
5122
|
}
|
|
4746
5123
|
// Rich text is written as plain text here
|
|
4747
5124
|
const value = cell.richText && cell.value == null ? cell.richText.map(r => r.text).join('') : cell.value;
|
|
@@ -4757,14 +5134,19 @@ const WORKSHEET_CONTENT = 'application/vnd.openxmlformats-officedocument.spreads
|
|
|
4757
5134
|
// An element in the shape of a regex match: [whole, attributes, body], body undefined when self-closing
|
|
4758
5135
|
const asMatch = (el) => [el.whole, el.attrs, el.whole.endsWith('/>') ? undefined : el.body];
|
|
4759
5136
|
class SheetEditor {
|
|
4760
|
-
|
|
5137
|
+
appends = new Map();
|
|
4761
5138
|
cellEdits = new Map();
|
|
4762
5139
|
additions = new Map();
|
|
4763
5140
|
deletions = new Set();
|
|
4764
5141
|
shiftOps = new Map();
|
|
4765
|
-
// Appends `rows` after the last existing row of sheet `sheetName`.
|
|
4766
|
-
|
|
4767
|
-
|
|
5142
|
+
// Appends `rows` after the last existing row of sheet `sheetName`. Cells take values, formulas and
|
|
5143
|
+
// styles, as in addSheet. Called again for the same sheet, the rows go after the earlier ones.
|
|
5144
|
+
appendSheet(sheetName, rows, _options = {}) {
|
|
5145
|
+
let batches = this.appends.get(sheetName);
|
|
5146
|
+
if (!batches)
|
|
5147
|
+
this.appends.set(sheetName, batches = []);
|
|
5148
|
+
batches.push(rows);
|
|
5149
|
+
return this;
|
|
4768
5150
|
}
|
|
4769
5151
|
// Sets cells of an existing sheet by address, e.g. { B2: 42, C2: { formula: 'B2*2' } }. A cell
|
|
4770
5152
|
// keeps its style, so a date written over a date-formatted cell shows as a date. Formulas are
|
|
@@ -4775,9 +5157,9 @@ class SheetEditor {
|
|
|
4775
5157
|
this.cellEdits.set(sheetName, rows = new Map());
|
|
4776
5158
|
for (const [ref, edit] of Object.entries(cells)) {
|
|
4777
5159
|
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(ref);
|
|
4778
|
-
|
|
5160
|
+
const r = m ? parseInt(m[2], 10) : 0;
|
|
5161
|
+
if (!m || r < 1 || r > MAX_ROWS$1 || colIndex(m[1]) >= MAX_COLUMNS$1)
|
|
4779
5162
|
throw new Error(`Invalid cell reference "${ref}".`);
|
|
4780
|
-
const r = parseInt(m[2], 10);
|
|
4781
5163
|
if (!rows.has(r))
|
|
4782
5164
|
rows.set(r, new Map());
|
|
4783
5165
|
rows.get(r).set(colIndex(m[1]), edit);
|
|
@@ -4864,8 +5246,8 @@ class SheetEditor {
|
|
|
4864
5246
|
return path;
|
|
4865
5247
|
};
|
|
4866
5248
|
const appendByPath = new Map();
|
|
4867
|
-
for (const [sheetName,
|
|
4868
|
-
appendByPath.set(pathOf(sheetName),
|
|
5249
|
+
for (const [sheetName, batches] of this.appends)
|
|
5250
|
+
appendByPath.set(pathOf(sheetName), rowsToEdits(sheetName, batches.flat()));
|
|
4869
5251
|
const editsByPath = new Map();
|
|
4870
5252
|
for (const [sheetName, rows] of this.cellEdits)
|
|
4871
5253
|
editsByPath.set(pathOf(sheetName), rows);
|
|
@@ -4933,6 +5315,8 @@ class SheetEditor {
|
|
|
4933
5315
|
await update(rel.path, xml => mapVml(xml, map));
|
|
4934
5316
|
if (type === '/drawing')
|
|
4935
5317
|
await update(rel.path, xml => mapDrawing(xml, map));
|
|
5318
|
+
if (type === '/pivotTable')
|
|
5319
|
+
await update(rel.path, xml => mapPivotTable(xml, map));
|
|
4936
5320
|
}
|
|
4937
5321
|
}
|
|
4938
5322
|
// Charts on any sheet can plot the moved rows, and so can pivot tables
|
|
@@ -4952,7 +5336,7 @@ class SheetEditor {
|
|
|
4952
5336
|
}
|
|
4953
5337
|
}
|
|
4954
5338
|
// Edited cells invalidate cached formula results
|
|
4955
|
-
if (editsByPath.size || added.size || this.deletions.size || rowMaps.size) {
|
|
5339
|
+
if (editsByPath.size || added.size || appendByPath.size || this.deletions.size || rowMaps.size) {
|
|
4956
5340
|
const { replace, drop } = await recalcOnOpen(read, parts, await read(parts.workbookPath));
|
|
4957
5341
|
for (const [path, xml] of replace)
|
|
4958
5342
|
overlay.set(path, xml);
|
|
@@ -4961,10 +5345,10 @@ class SheetEditor {
|
|
|
4961
5345
|
}
|
|
4962
5346
|
// Styles gain the formats of restyled cells as the sheets stream, so they are written last
|
|
4963
5347
|
let patcher;
|
|
4964
|
-
const styled = [...editsByPath.values(), ...added.values()]
|
|
5348
|
+
const styled = [...editsByPath.values(), ...added.values(), ...appendByPath.values()]
|
|
4965
5349
|
.some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => styleOf(e))));
|
|
4966
5350
|
// Dates may need a date format added to their cells
|
|
4967
|
-
const dated = [...editsByPath.values(), ...added.values()]
|
|
5351
|
+
const dated = [...editsByPath.values(), ...added.values(), ...appendByPath.values()]
|
|
4968
5352
|
.some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => contentOf(e) instanceof Date)));
|
|
4969
5353
|
if (styled || (dated && parts.styles)) {
|
|
4970
5354
|
if (!parts.styles)
|
|
@@ -4984,7 +5368,7 @@ class SheetEditor {
|
|
|
4984
5368
|
// A new sheet uses the workbook's namespace (Transitional or Strict)
|
|
4985
5369
|
const root = /<((?:\w+:)?)workbook\b[^>]*>/.exec(await read(parts.workbookPath));
|
|
4986
5370
|
const ns = (root && attr(root[0], root[1] ? `xmlns:${root[1].slice(0, -1)}` : 'xmlns')) ?? 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
|
4987
|
-
const empty = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="${escapeXml$
|
|
5371
|
+
const empty = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="${escapeXml$4(ns)}"><sheetData/></worksheet>`;
|
|
4988
5372
|
for (const [path, edits] of added) {
|
|
4989
5373
|
await zipOut.addFile(path, encode(decode(new Response(empty).body).pipeThrough(this.createEditTransform(edits, patcher))));
|
|
4990
5374
|
}
|
|
@@ -4992,18 +5376,16 @@ class SheetEditor {
|
|
|
4992
5376
|
for (const filename of zipIn.getFiles()) {
|
|
4993
5377
|
if (written.has(filename))
|
|
4994
5378
|
continue;
|
|
4995
|
-
const
|
|
5379
|
+
const append = appendByPath.get(filename);
|
|
4996
5380
|
const edits = editsByPath.get(filename);
|
|
4997
5381
|
const shift = shiftByPath.get(filename);
|
|
4998
|
-
if (
|
|
5382
|
+
if (append || edits || shift !== undefined) {
|
|
4999
5383
|
checkSize(filename, options?.maxUncompressedBytes ?? Infinity);
|
|
5000
5384
|
let text = decode(await zipIn.extractStream(filename));
|
|
5001
5385
|
if (shift !== undefined)
|
|
5002
5386
|
text = text.pipeThrough(createShiftTransform(shift, rowMaps));
|
|
5003
|
-
if (edits)
|
|
5004
|
-
text = text.pipeThrough(this.createEditTransform(edits, patcher));
|
|
5005
|
-
if (rows)
|
|
5006
|
-
text = text.pipeThrough(this.createInjectTransform(rows));
|
|
5387
|
+
if (edits || append)
|
|
5388
|
+
text = text.pipeThrough(this.createEditTransform(edits ?? new Map(), patcher, append));
|
|
5007
5389
|
await zipOut.addFile(filename, encode(text));
|
|
5008
5390
|
}
|
|
5009
5391
|
else {
|
|
@@ -5023,10 +5405,12 @@ class SheetEditor {
|
|
|
5023
5405
|
return zipOut.stream;
|
|
5024
5406
|
}
|
|
5025
5407
|
// Streams a worksheet, rewriting edited cells as their rows pass by and adding rows and cells
|
|
5026
|
-
// that did not exist.
|
|
5027
|
-
|
|
5408
|
+
// that did not exist. `append` rows (numbered from 1) go after the last row. Only one row at a
|
|
5409
|
+
// time is held in memory.
|
|
5410
|
+
createEditTransform(edits, patcher, append) {
|
|
5028
5411
|
const pending = [...edits.keys()].sort((a, b) => a - b);
|
|
5029
5412
|
let next = 0;
|
|
5413
|
+
let lastRow = 0; // number of the last row read, for rows without an r attribute
|
|
5030
5414
|
let buffer = '';
|
|
5031
5415
|
let p = ''; // namespace prefix of the sheet's elements
|
|
5032
5416
|
let state = 'head';
|
|
@@ -5053,8 +5437,8 @@ class SheetEditor {
|
|
|
5053
5437
|
// Keeps the style; drops the type and the metadata of rich values and dynamic arrays
|
|
5054
5438
|
return editedCell(p, ref, attrs.replace(/\s(?:t|vm|cm)="[^"]*"/g, ''), content);
|
|
5055
5439
|
};
|
|
5056
|
-
const newRow = (r) => {
|
|
5057
|
-
const cells = [...
|
|
5440
|
+
const newRow = (r, rowEdits = edits.get(r)) => {
|
|
5441
|
+
const cells = [...rowEdits.entries()].sort((a, b) => a[0] - b[0])
|
|
5058
5442
|
.map(([c, e]) => cellXml(colLetter(c) + r, e)).join('');
|
|
5059
5443
|
return `<${p}row r="${r}">${cells}</${p}row>`;
|
|
5060
5444
|
};
|
|
@@ -5064,6 +5448,19 @@ class SheetEditor {
|
|
|
5064
5448
|
xml += newRow(pending[next]);
|
|
5065
5449
|
return xml;
|
|
5066
5450
|
};
|
|
5451
|
+
// The rows still to come at the end of sheetData: new edited rows, then the appended rows
|
|
5452
|
+
const lastRows = () => {
|
|
5453
|
+
let xml = rowsBefore(Infinity);
|
|
5454
|
+
if (!append?.size)
|
|
5455
|
+
return xml;
|
|
5456
|
+
const base = Math.max(lastRow, pending[pending.length - 1] ?? 0);
|
|
5457
|
+
const last = base + Math.max(...append.keys());
|
|
5458
|
+
if (last > MAX_ROWS$1)
|
|
5459
|
+
throw new Error(`Appending rows would reach row ${last}, past Excel's last row (${MAX_ROWS$1}).`);
|
|
5460
|
+
for (const [r, cells] of [...append].sort((a, b) => a[0] - b[0]))
|
|
5461
|
+
xml += newRow(base + r, cells);
|
|
5462
|
+
return xml;
|
|
5463
|
+
};
|
|
5067
5464
|
const rewriteRow = (open, inner, r) => {
|
|
5068
5465
|
// A copy: the edits stay intact for another edit() call
|
|
5069
5466
|
const rowEdits = edits.has(r) ? new Map(edits.get(r)) : undefined;
|
|
@@ -5095,7 +5492,7 @@ class SheetEditor {
|
|
|
5095
5492
|
if (brokenShared.has(si)) {
|
|
5096
5493
|
const a = sharedAnchors.get(si);
|
|
5097
5494
|
if (a) {
|
|
5098
|
-
xml = xml.replace(f[0], `<${p}f>${escapeXml$
|
|
5495
|
+
xml = xml.replace(f[0], `<${p}f>${escapeXml$4(shiftFormula(a.text, r - a.row, col - a.col))}</${p}f>`);
|
|
5099
5496
|
changed = true;
|
|
5100
5497
|
}
|
|
5101
5498
|
}
|
|
@@ -5122,7 +5519,7 @@ class SheetEditor {
|
|
|
5122
5519
|
p = m[1];
|
|
5123
5520
|
if (m[2]) {
|
|
5124
5521
|
// Empty sheet: <sheetData/> becomes a pair holding the new rows
|
|
5125
|
-
out += buffer.slice(0, m.index) + `<${p}sheetData>${
|
|
5522
|
+
out += buffer.slice(0, m.index) + `<${p}sheetData>${lastRows()}</${p}sheetData>`;
|
|
5126
5523
|
buffer = buffer.slice(m.index + m[0].length);
|
|
5127
5524
|
state = 'tail';
|
|
5128
5525
|
}
|
|
@@ -5149,11 +5546,14 @@ class SheetEditor {
|
|
|
5149
5546
|
if (!m)
|
|
5150
5547
|
break;
|
|
5151
5548
|
if (m[0].trimStart().startsWith(`</${p}sheetData`)) {
|
|
5152
|
-
out +=
|
|
5549
|
+
out += lastRows() + m[0];
|
|
5153
5550
|
state = 'tail';
|
|
5154
5551
|
}
|
|
5155
5552
|
else {
|
|
5156
|
-
|
|
5553
|
+
// A row without r is the one after the previous row, as the reader counts it
|
|
5554
|
+
const rAttr = /\sr="(\d+)"/.exec(m[1])?.[1];
|
|
5555
|
+
const r = rAttr ? parseInt(rAttr, 10) : lastRow + 1;
|
|
5556
|
+
lastRow = Math.max(lastRow, r);
|
|
5157
5557
|
out += rowsBefore(r);
|
|
5158
5558
|
const open = m[0].slice(0, m[0].indexOf('>') + 1);
|
|
5159
5559
|
// Rows are only parsed when they have edits or take part in a shared formula
|
|
@@ -5180,72 +5580,6 @@ class SheetEditor {
|
|
|
5180
5580
|
},
|
|
5181
5581
|
});
|
|
5182
5582
|
}
|
|
5183
|
-
createInjectTransform(rows) {
|
|
5184
|
-
let buffer = '';
|
|
5185
|
-
let maxRow = 0;
|
|
5186
|
-
let done = false;
|
|
5187
|
-
const rowsXml = () => rows.map((row, ri) => {
|
|
5188
|
-
const rowNum = maxRow + ri + 1;
|
|
5189
|
-
const cellsXml = row.map((cell, ci) => {
|
|
5190
|
-
const colRef = colLetter(ci) + rowNum;
|
|
5191
|
-
const val = typeof cell === 'object' && cell !== null && 'value' in cell ? cell.value : cell;
|
|
5192
|
-
if (val === null || val === undefined)
|
|
5193
|
-
return `<c r="${colRef}"/>`;
|
|
5194
|
-
if (typeof val === 'boolean')
|
|
5195
|
-
return `<c r="${colRef}" t="b"><v>${val ? 1 : 0}</v></c>`;
|
|
5196
|
-
if (typeof val === 'number')
|
|
5197
|
-
return `<c r="${colRef}"><v>${val}</v></c>`;
|
|
5198
|
-
if (typeof val === 'string') {
|
|
5199
|
-
return `<c r="${colRef}" t="inlineStr"><is><t xml:space="preserve">${escapeXml$5(encodeXString(val))}</t></is></c>`;
|
|
5200
|
-
}
|
|
5201
|
-
return `<c r="${colRef}"/>`;
|
|
5202
|
-
}).join('');
|
|
5203
|
-
return `<row r="${rowNum}">${cellsXml}</row>`;
|
|
5204
|
-
}).join('');
|
|
5205
|
-
return new TransformStream({
|
|
5206
|
-
transform(chunk, controller) {
|
|
5207
|
-
if (done) {
|
|
5208
|
-
controller.enqueue(chunk);
|
|
5209
|
-
return;
|
|
5210
|
-
}
|
|
5211
|
-
buffer += chunk;
|
|
5212
|
-
for (const m of buffer.matchAll(/<(?:\w+:)?row\b[^>]*?\sr="(\d+)"/g)) {
|
|
5213
|
-
const r = parseInt(m[1], 10);
|
|
5214
|
-
if (r > maxRow)
|
|
5215
|
-
maxRow = r;
|
|
5216
|
-
}
|
|
5217
|
-
const close = /<\/(?:\w+:)?sheetData>|<((?:\w+:)?sheetData)\b[^>]*\/>/.exec(buffer);
|
|
5218
|
-
if (close) {
|
|
5219
|
-
const before = buffer.slice(0, close.index);
|
|
5220
|
-
const after = buffer.slice(close.index + close[0].length);
|
|
5221
|
-
// Self-closing <sheetData/> (empty sheet) becomes an open/close pair
|
|
5222
|
-
const tagName = close[1];
|
|
5223
|
-
const inject = tagName
|
|
5224
|
-
? `${close[0].slice(0, -2)}>${rowsXml()}</${tagName}>`
|
|
5225
|
-
: `${rowsXml()}${close[0]}`;
|
|
5226
|
-
controller.enqueue(before + inject + after);
|
|
5227
|
-
buffer = '';
|
|
5228
|
-
done = true;
|
|
5229
|
-
}
|
|
5230
|
-
else {
|
|
5231
|
-
// Hold back from the last '<' so a split tag is never emitted half-scanned
|
|
5232
|
-
const cut = buffer.lastIndexOf('<');
|
|
5233
|
-
if (cut > 0) {
|
|
5234
|
-
controller.enqueue(buffer.slice(0, cut));
|
|
5235
|
-
buffer = buffer.slice(cut);
|
|
5236
|
-
}
|
|
5237
|
-
}
|
|
5238
|
-
},
|
|
5239
|
-
flush(controller) {
|
|
5240
|
-
if (!done) {
|
|
5241
|
-
controller.error(new Error('Worksheet has no <sheetData> element.'));
|
|
5242
|
-
return;
|
|
5243
|
-
}
|
|
5244
|
-
if (buffer.length > 0)
|
|
5245
|
-
controller.enqueue(buffer);
|
|
5246
|
-
}
|
|
5247
|
-
});
|
|
5248
|
-
}
|
|
5249
5583
|
async readStreamToString(stream) {
|
|
5250
5584
|
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
5251
5585
|
let result = '';
|
|
@@ -5286,7 +5620,7 @@ async function removeSheet(name, parts, read, overlay, dropped) {
|
|
|
5286
5620
|
let mapped = formula.replace(quoted, '#REF!');
|
|
5287
5621
|
if (plain)
|
|
5288
5622
|
mapped = mapped.replace(plain, '$1#REF!');
|
|
5289
|
-
return mapped === formula ? whole : whole.replace(`>${text}<`, `>${escapeXml$
|
|
5623
|
+
return mapped === formula ? whole : whole.replace(`>${text}<`, `>${escapeXml$4(mapped)}<`);
|
|
5290
5624
|
});
|
|
5291
5625
|
wb = wb.replace(/<(?:\w+:)?workbookView\b[^>]*>/g, view => view.replace(/\s(activeTab|firstSheet)="(\d+)"/g, (_m, a, v) => {
|
|
5292
5626
|
const n = parseInt(v, 10);
|
|
@@ -5326,7 +5660,7 @@ async function insertSheet(name, parts, read, overlay, free) {
|
|
|
5326
5660
|
overlay.set(relsPath, rels);
|
|
5327
5661
|
const sheetId = Math.max(0, ...tags.map(t => parseInt(attr(t, 'sheetId') ?? '0', 10))) + 1;
|
|
5328
5662
|
const rPrefix = /<(?:\w+:)?sheet\b[^>]*?\s(\w+):id="/.exec(wb)?.[1] ?? 'r';
|
|
5329
|
-
wb = wb.replace(/<\/((?:\w+:)?)sheets>/, (close, p) => `<${p}sheet name="${escapeXml$
|
|
5663
|
+
wb = wb.replace(/<\/((?:\w+:)?)sheets>/, (close, p) => `<${p}sheet name="${escapeXml$4(name)}" sheetId="${sheetId}" ${rPrefix}:id="rId${k}"/>${close}`);
|
|
5330
5664
|
overlay.set(parts.workbookPath, wb);
|
|
5331
5665
|
const types = await read('[Content_Types].xml');
|
|
5332
5666
|
const contentType = sheetRel && /ContentType="([^"]*)"/.exec(overrideTag(types, sheetRel.path) ?? '')?.[1];
|
|
@@ -5397,17 +5731,18 @@ class SheetReader {
|
|
|
5397
5731
|
const name = attr(tag, 'name');
|
|
5398
5732
|
const state = attr(tag, 'state');
|
|
5399
5733
|
if (name !== null)
|
|
5400
|
-
sheets.push({ name
|
|
5734
|
+
sheets.push({ name, state: state === 'hidden' || state === 'veryHidden' ? state : 'visible' });
|
|
5401
5735
|
}
|
|
5402
5736
|
const definedNames = [];
|
|
5403
5737
|
for (const { attrs: open, body: text } of xmlElements(parts.workbookXml, 'definedName')) {
|
|
5404
5738
|
const local = attr(open, 'localSheetId');
|
|
5405
5739
|
const comment = attr(open, 'comment');
|
|
5406
|
-
|
|
5740
|
+
// attr() has already unescaped attribute values; only element text is still escaped
|
|
5741
|
+
const name = { name: attr(open, 'name') ?? '', ref: unescapeXml(text) };
|
|
5407
5742
|
if (local !== null && sheets[+local])
|
|
5408
5743
|
name.sheet = sheets[+local].name;
|
|
5409
5744
|
if (comment !== null)
|
|
5410
|
-
name.comment =
|
|
5745
|
+
name.comment = comment;
|
|
5411
5746
|
if (/^(?:1|true)$/.test(attr(open, 'hidden') ?? ''))
|
|
5412
5747
|
name.hidden = true;
|
|
5413
5748
|
definedNames.push(name);
|
|
@@ -5455,7 +5790,8 @@ class SheetReader {
|
|
|
5455
5790
|
throw new Error(`Sheet with name "${options.sheetName}" not found in workbook.`);
|
|
5456
5791
|
}
|
|
5457
5792
|
else {
|
|
5458
|
-
|
|
5793
|
+
// The first tab with cells: a chart sheet first in the tab order is skipped, as the .xls reader does
|
|
5794
|
+
worksheetZipPath = [...parts.sheets.values()].find(path => !parts.chartsheets.has(path))
|
|
5459
5795
|
?? zip.getFiles().find(f => /^xl\/worksheets\/[^/]+\.xml$/.test(f));
|
|
5460
5796
|
if (!worksheetZipPath)
|
|
5461
5797
|
throw new Error("No worksheets found in ZIP.");
|
|
@@ -5487,6 +5823,7 @@ class SheetReader {
|
|
|
5487
5823
|
.pipeThrough(createXmlBatchParser());
|
|
5488
5824
|
return parseWorksheet(xmlStream, sharedStrings, styles, is1904, {
|
|
5489
5825
|
formulas: options?.formulas,
|
|
5826
|
+
errors: options?.errors,
|
|
5490
5827
|
cellStyles: options?.styles ? cellStyles : undefined,
|
|
5491
5828
|
numFmts: options?.formatted ? formats : undefined,
|
|
5492
5829
|
richText: options?.richText ? color : undefined,
|
|
@@ -5787,7 +6124,8 @@ const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
|
5787
6124
|
function convertField(s) {
|
|
5788
6125
|
if (s === '')
|
|
5789
6126
|
return null;
|
|
5790
|
-
|
|
6127
|
+
// "1e400" overflows a double: keep the text rather than lose it to Infinity
|
|
6128
|
+
if (NUMBER.test(s) && isFinite(Number(s)))
|
|
5791
6129
|
return Number(s);
|
|
5792
6130
|
const upper = s.toUpperCase();
|
|
5793
6131
|
return upper === 'TRUE' ? true : upper === 'FALSE' ? false : s;
|
|
@@ -5891,14 +6229,11 @@ async function* parseCsv(input, options = {}) {
|
|
|
5891
6229
|
// Writer for OpenDocument spreadsheets (.ods). Rows stream into content.xml one at a time.
|
|
5892
6230
|
// Writes values, formulas, dates, merged cells, column widths, frozen panes, hidden sheets and
|
|
5893
6231
|
// document properties; styles and the other .xlsx sheet options are left out.
|
|
5894
|
-
|
|
5895
|
-
// XML 1.0 cannot hold most control characters at all, so they are dropped
|
|
5896
|
-
return String(val).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
5897
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
5898
|
-
}
|
|
6232
|
+
const escapeXml = (val) => escapeXml$4(String(val));
|
|
5899
6233
|
const isStyledCell = (v) => v !== null && typeof v === 'object' && !(v instanceof Date);
|
|
5900
6234
|
// Excel syntax to OpenFormula: "SUM(A1:B2,Sheet2!C3)" -> "of:=SUM([.A1:.B2];[$Sheet2.C3])"
|
|
5901
|
-
|
|
6235
|
+
// Cell references and ranges, then whole columns (C:C) and whole rows (1:3)
|
|
6236
|
+
const REF = /((?:'(?:[^']|'')+'|[A-Za-z_][\w.]*)!)?(\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?|\$?[A-Za-z]{1,3}:\$?[A-Za-z]{1,3}|\$?\d+:\$?\d+)(?![\w(!:])/y;
|
|
5902
6237
|
function toOpenFormula(formula) {
|
|
5903
6238
|
const f = formula.replace(/^=/, '');
|
|
5904
6239
|
let out = '';
|
|
@@ -5923,7 +6258,7 @@ function toOpenFormula(formula) {
|
|
|
5923
6258
|
const m = REF.exec(f);
|
|
5924
6259
|
if (m) {
|
|
5925
6260
|
const sheet = m[1] ? `$${m[1].slice(0, -1)}` : '';
|
|
5926
|
-
out += `[${
|
|
6261
|
+
out += `[${m[2].split(':').map(part => `${sheet}.${part}`).join(':')}]`;
|
|
5927
6262
|
i = REF.lastIndex;
|
|
5928
6263
|
continue;
|
|
5929
6264
|
}
|
|
@@ -5939,8 +6274,10 @@ function textXml(s) {
|
|
|
5939
6274
|
.replace(/\t/g, '<text:tab/>')
|
|
5940
6275
|
.replace(/^ | {2,}/g, m => m === ' ' ? '<text:s/>' : ` <text:s text:c="${m.length - 1}"/>`)}</text:p>`).join('');
|
|
5941
6276
|
}
|
|
5942
|
-
|
|
6277
|
+
// UTC, like SheetWriter; milliseconds only when there are some
|
|
6278
|
+
const dateValue = (d) => d.toISOString().slice(0, d.getUTCMilliseconds() ? 23 : 19);
|
|
5943
6279
|
const hasTime = (d) => d.getTime() % 86400000 !== 0;
|
|
6280
|
+
// `evaluate` gives the formula's result, or null when it isn't known (streamed rows, errors)
|
|
5944
6281
|
function cellXml(cell, span, covered, evaluate) {
|
|
5945
6282
|
if (covered)
|
|
5946
6283
|
return '<table:covered-table-cell/>';
|
|
@@ -5948,7 +6285,7 @@ function cellXml(cell, span, covered, evaluate) {
|
|
|
5948
6285
|
const formula = isStyledCell(cell) && cell.formula ? ` table:formula="${escapeXml(toOpenFormula(cell.formula))}"` : '';
|
|
5949
6286
|
// A formula without a value gets its result stored, as SheetWriter does, for readers that don't recalculate
|
|
5950
6287
|
if (formula && value == null)
|
|
5951
|
-
value = evaluate(
|
|
6288
|
+
value = evaluate();
|
|
5952
6289
|
if (value === null || value === undefined || (typeof value === 'number' && !isFinite(value))) {
|
|
5953
6290
|
return formula || span ? `<table:table-cell${formula}${span}/>` : '<table:table-cell/>';
|
|
5954
6291
|
}
|
|
@@ -6045,11 +6382,18 @@ class OdsWriter {
|
|
|
6045
6382
|
const engine = new FormulaEngine();
|
|
6046
6383
|
if (Array.isArray(sheet.rows)) {
|
|
6047
6384
|
engine.loadData(sheet.rows.map(row => row.map(cell => {
|
|
6385
|
+
if (isStyledCell(cell) && cell.formula)
|
|
6386
|
+
return { formula: cell.formula };
|
|
6048
6387
|
const v = isStyledCell(cell) ? cell.value : cell;
|
|
6049
6388
|
return v instanceof Date ? dateToSerial(v) : v ?? null;
|
|
6050
6389
|
})));
|
|
6051
6390
|
}
|
|
6052
|
-
const evaluate = (
|
|
6391
|
+
const evaluate = (ref) => {
|
|
6392
|
+
if (!Array.isArray(sheet.rows))
|
|
6393
|
+
return null;
|
|
6394
|
+
const v = engine.cellValue(ref);
|
|
6395
|
+
return v instanceof FormulaError ? v.code : v;
|
|
6396
|
+
};
|
|
6053
6397
|
const covered = (r, c) => merges.some(m => r >= m.r1 && r <= m.r2 && c >= m.c1 && c <= m.c2 && (r !== m.r1 || c !== m.c1));
|
|
6054
6398
|
let r = 0, batch = '';
|
|
6055
6399
|
const writeRow = (row) => {
|
|
@@ -6058,7 +6402,7 @@ class OdsWriter {
|
|
|
6058
6402
|
for (let c = 0; c < width; c++) {
|
|
6059
6403
|
const m = mergeAt(r, c);
|
|
6060
6404
|
const span = m ? ` table:number-columns-spanned="${m.c2 - m.c1 + 1}" table:number-rows-spanned="${m.r2 - m.r1 + 1}"` : '';
|
|
6061
|
-
xml += cellXml(row[c], span, covered(r, c), evaluate);
|
|
6405
|
+
xml += cellXml(row[c], span, covered(r, c), () => evaluate(colLetter(c) + (r + 1)));
|
|
6062
6406
|
}
|
|
6063
6407
|
r++;
|
|
6064
6408
|
return xml + (width ? '' : '<table:table-cell/>') + '</table:table-row>';
|
|
@@ -6132,11 +6476,28 @@ const XlsxFlow = {
|
|
|
6132
6476
|
const { SheetReader } = await Promise.resolve().then(function () { return index; });
|
|
6133
6477
|
const reader = new SheetReader();
|
|
6134
6478
|
const fileReader = await createFileReader(filePath);
|
|
6135
|
-
//
|
|
6479
|
+
// Comments, images and .ods content are read after parse() returns, when the handle is closed:
|
|
6480
|
+
// those reads open a short-lived handle of their own
|
|
6481
|
+
let closed = false;
|
|
6482
|
+
const lateRead = async (offset, length) => {
|
|
6483
|
+
const late = await createFileReader(filePath);
|
|
6484
|
+
try {
|
|
6485
|
+
return await late.read(offset, length);
|
|
6486
|
+
}
|
|
6487
|
+
finally {
|
|
6488
|
+
await late.close();
|
|
6489
|
+
}
|
|
6490
|
+
};
|
|
6136
6491
|
try {
|
|
6137
|
-
return await reader.parse(
|
|
6492
|
+
return await reader.parse({
|
|
6493
|
+
size: fileReader.size,
|
|
6494
|
+
read: (offset, length) => closed ? lateRead(offset, length) : fileReader.read(offset, length),
|
|
6495
|
+
stream: (offset, length) => fileReader.stream(offset, length),
|
|
6496
|
+
close: async () => { },
|
|
6497
|
+
}, options);
|
|
6138
6498
|
}
|
|
6139
6499
|
finally {
|
|
6500
|
+
closed = true;
|
|
6140
6501
|
await fileReader.close();
|
|
6141
6502
|
}
|
|
6142
6503
|
}
|
|
@@ -6156,6 +6517,7 @@ exports.XlsxFlow = XlsxFlow;
|
|
|
6156
6517
|
exports.ZipRandomAccessParser = ZipRandomAccessParser;
|
|
6157
6518
|
exports.ZipStreamWriter = ZipStreamWriter;
|
|
6158
6519
|
exports.anchorXml = anchorXml;
|
|
6520
|
+
exports.checkCellText = checkCellText;
|
|
6159
6521
|
exports.colIndex = colIndex;
|
|
6160
6522
|
exports.colLetter = colLetter;
|
|
6161
6523
|
exports.crc32 = crc32;
|