@xlsxflow/core 1.1.3 → 1.1.5
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 +38 -0
- package/README.md +20 -10
- package/dist/index.cjs +1113 -743
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -7
- package/dist/index.d.ts +30 -7
- package/dist/index.mjs +1113 -743
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,382 @@
|
|
|
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 compressor = new CompressionStream('deflate-raw');
|
|
62
|
+
const writer = compressor.writable.getWriter();
|
|
63
|
+
const reader = compressor.readable.getReader();
|
|
64
|
+
const input = inputStream.getReader();
|
|
65
|
+
// Node's and Bun's CompressionStream accept thousands of writes without backpressure, so one chunk
|
|
66
|
+
// is fed at a time: a write resolves once the chunk is compressed, which waits while nobody reads
|
|
67
|
+
// the output. Rows are then only pulled as fast as the ZIP is consumed.
|
|
68
|
+
const feed = (async () => {
|
|
69
|
+
try {
|
|
70
|
+
while (true) {
|
|
71
|
+
const { done, value } = await input.read();
|
|
72
|
+
if (done)
|
|
73
|
+
break;
|
|
74
|
+
await this.roomInQueue();
|
|
75
|
+
uncompressedSize += value.length;
|
|
76
|
+
crc = crc32Update(crc, value);
|
|
77
|
+
await writer.write(value);
|
|
78
|
+
}
|
|
79
|
+
await writer.close();
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Stop the source too (a row generator's finally runs, a database cursor closes)
|
|
83
|
+
await input.cancel(err).catch(() => { });
|
|
84
|
+
await writer.abort(err).catch(() => { });
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
let compressedSize = 0;
|
|
89
|
+
try {
|
|
90
|
+
while (true) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done)
|
|
93
|
+
break;
|
|
94
|
+
compressedSize += value.length;
|
|
95
|
+
await this.pushChunk(value);
|
|
96
|
+
}
|
|
97
|
+
await feed;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
await reader.cancel(err).catch(() => { });
|
|
101
|
+
await input.cancel(err).catch(() => { });
|
|
102
|
+
await feed.catch(() => { });
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
106
|
+
assertNoZip64(uncompressedSize, filenameStr);
|
|
107
|
+
assertNoZip64(compressedSize, filenameStr);
|
|
108
|
+
// Data Descriptor
|
|
109
|
+
const desc = new Uint8Array(16);
|
|
110
|
+
const descView = new DataView(desc.buffer);
|
|
111
|
+
descView.setUint32(0, 0x08074b50, true);
|
|
112
|
+
descView.setUint32(4, crc, true);
|
|
113
|
+
descView.setUint32(8, compressedSize, true);
|
|
114
|
+
descView.setUint32(12, uncompressedSize, true);
|
|
115
|
+
await this.pushChunk(desc);
|
|
116
|
+
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags, method: 8 });
|
|
117
|
+
}
|
|
118
|
+
// Adds a file that is ALREADY compressed (pass-through for the Editor)
|
|
119
|
+
async addCompressedFile(filenameStr, compressedStream, uncompressedSize, compressedSize, crc, method = 8) {
|
|
120
|
+
const filename = this.textEncoder.encode(filenameStr);
|
|
121
|
+
const flags = utf8Flag(filename);
|
|
122
|
+
const startOffset = this.offset;
|
|
123
|
+
await this.pushChunk(this.localHeader(filename, flags, method, crc, compressedSize, uncompressedSize));
|
|
124
|
+
const reader = compressedStream.getReader();
|
|
125
|
+
while (true) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done)
|
|
128
|
+
break;
|
|
129
|
+
await this.pushChunk(value);
|
|
130
|
+
}
|
|
131
|
+
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags, method });
|
|
132
|
+
}
|
|
133
|
+
async close() {
|
|
134
|
+
// 0xFFFF in the end record means "see the ZIP64 record", which readers then look for
|
|
135
|
+
if (this.cdEntries.length >= 0xffff)
|
|
136
|
+
throw new Error('ZIP64 not supported: 65535 entries or more.');
|
|
137
|
+
const cdStartOffset = this.offset;
|
|
138
|
+
for (const entry of this.cdEntries) {
|
|
139
|
+
const cd = new Uint8Array(46 + entry.filename.length);
|
|
140
|
+
const view = new DataView(cd.buffer);
|
|
141
|
+
view.setUint32(0, 0x02014b50, true);
|
|
142
|
+
view.setUint16(4, 20, true); // version made by
|
|
143
|
+
view.setUint16(6, 20, true); // version needed
|
|
144
|
+
view.setUint16(8, entry.flags, true);
|
|
145
|
+
view.setUint16(10, entry.method, true);
|
|
146
|
+
view.setUint32(16, entry.crc, true);
|
|
147
|
+
view.setUint32(20, entry.compressedSize, true);
|
|
148
|
+
view.setUint32(24, entry.uncompressedSize, true);
|
|
149
|
+
view.setUint16(28, entry.filename.length, true);
|
|
150
|
+
view.setUint32(42, entry.offset, true);
|
|
151
|
+
cd.set(entry.filename, 46);
|
|
152
|
+
await this.pushChunk(cd);
|
|
153
|
+
}
|
|
154
|
+
const cdSize = this.offset - cdStartOffset;
|
|
155
|
+
assertNoZip64(this.offset, 'archive');
|
|
156
|
+
const eocd = new Uint8Array(22);
|
|
157
|
+
const eocdView = new DataView(eocd.buffer);
|
|
158
|
+
eocdView.setUint32(0, 0x06054b50, true);
|
|
159
|
+
eocdView.setUint16(8, this.cdEntries.length, true);
|
|
160
|
+
eocdView.setUint16(10, this.cdEntries.length, true);
|
|
161
|
+
eocdView.setUint32(12, cdSize, true);
|
|
162
|
+
eocdView.setUint32(16, cdStartOffset, true);
|
|
163
|
+
await this.pushChunk(eocd);
|
|
164
|
+
this.streamController.close();
|
|
165
|
+
}
|
|
166
|
+
// Propagate a producer failure to whoever is reading `stream`.
|
|
167
|
+
error(err) {
|
|
168
|
+
try {
|
|
169
|
+
this.streamController.error(err);
|
|
170
|
+
}
|
|
171
|
+
catch { /* already closed/errored */ }
|
|
172
|
+
}
|
|
173
|
+
localHeader(filename, flags, method, crc, compressedSize, uncompressedSize) {
|
|
174
|
+
assertNoZip64(this.offset, 'archive');
|
|
175
|
+
const header = new Uint8Array(30 + filename.length);
|
|
176
|
+
const view = new DataView(header.buffer);
|
|
177
|
+
view.setUint32(0, 0x04034b50, true);
|
|
178
|
+
view.setUint16(4, 20, true);
|
|
179
|
+
view.setUint16(6, flags, true);
|
|
180
|
+
view.setUint16(8, method, true);
|
|
181
|
+
view.setUint32(14, crc, true);
|
|
182
|
+
view.setUint32(18, compressedSize, true);
|
|
183
|
+
view.setUint32(22, uncompressedSize, true);
|
|
184
|
+
view.setUint16(26, filename.length, true);
|
|
185
|
+
header.set(filename, 30);
|
|
186
|
+
return header;
|
|
187
|
+
}
|
|
188
|
+
// Enqueue and wait while the consumer's queue is full, so memory stays bounded.
|
|
189
|
+
async pushChunk(chunk) {
|
|
190
|
+
if (this.cancelled)
|
|
191
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
192
|
+
this.streamController.enqueue(chunk);
|
|
193
|
+
this.offset += chunk.length;
|
|
194
|
+
await this.roomInQueue();
|
|
195
|
+
}
|
|
196
|
+
async roomInQueue() {
|
|
197
|
+
while ((this.streamController.desiredSize ?? 1) <= 0) {
|
|
198
|
+
if (this.cancelled)
|
|
199
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
200
|
+
await new Promise(resolve => this.waiting.push(resolve));
|
|
201
|
+
}
|
|
202
|
+
if (this.cancelled)
|
|
203
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
204
|
+
}
|
|
205
|
+
wake() {
|
|
206
|
+
const waiting = this.waiting;
|
|
207
|
+
this.waiting = [];
|
|
208
|
+
for (const resolve of waiting)
|
|
209
|
+
resolve();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Reader for Compound File Binary files [MS-CFB]: the container of .xls workbooks and of
|
|
214
|
+
// password-protected .xlsx files. Streams are read from an in-memory copy of the file.
|
|
215
|
+
const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
216
|
+
const END_OF_CHAIN = 0xfffffffe;
|
|
217
|
+
const MAX_REGULAR_SECTOR = 0xfffffffa;
|
|
218
|
+
function isCfb(bytes) {
|
|
219
|
+
return bytes.length >= 8 && SIGNATURE.every((b, i) => bytes[i] === b);
|
|
220
|
+
}
|
|
221
|
+
const corrupt$1 = (why) => new Error(`Corrupt compound file: ${why}`);
|
|
222
|
+
class CfbReader {
|
|
223
|
+
bytes;
|
|
224
|
+
view;
|
|
225
|
+
sectorSize;
|
|
226
|
+
miniSectorSize;
|
|
227
|
+
miniCutoff;
|
|
228
|
+
fat;
|
|
229
|
+
miniFat;
|
|
230
|
+
miniStream;
|
|
231
|
+
dir;
|
|
232
|
+
byPath = new Map();
|
|
233
|
+
constructor(bytes) {
|
|
234
|
+
this.bytes = bytes;
|
|
235
|
+
if (!isCfb(bytes))
|
|
236
|
+
throw new Error('Not a compound file (no D0CF11E0 signature)');
|
|
237
|
+
if (bytes.length < 512)
|
|
238
|
+
throw corrupt$1(`only ${bytes.length} bytes, shorter than its 512-byte header`);
|
|
239
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
240
|
+
const u16 = (o) => this.view.getUint16(o, true);
|
|
241
|
+
const u32 = (o) => this.view.getUint32(o, true);
|
|
242
|
+
const sectorShift = u16(0x1e);
|
|
243
|
+
if (sectorShift !== 9 && sectorShift !== 12)
|
|
244
|
+
throw corrupt$1(`sector shift ${sectorShift}`);
|
|
245
|
+
this.sectorSize = 1 << sectorShift;
|
|
246
|
+
const miniShift = u16(0x20);
|
|
247
|
+
if (miniShift !== 6)
|
|
248
|
+
throw corrupt$1(`mini sector shift ${miniShift}`);
|
|
249
|
+
this.miniSectorSize = 1 << miniShift;
|
|
250
|
+
this.miniCutoff = u32(0x38);
|
|
251
|
+
// The FAT's own sectors are listed in the header (109 slots) and then in a chain of DIFAT sectors
|
|
252
|
+
const numFatSectors = u32(0x2c);
|
|
253
|
+
const sectorCount = Math.ceil(bytes.length / this.sectorSize) - 1;
|
|
254
|
+
if (numFatSectors > sectorCount)
|
|
255
|
+
throw corrupt$1(`${numFatSectors} FAT sectors in a ${bytes.length}-byte file`);
|
|
256
|
+
const fatSectors = [];
|
|
257
|
+
for (let i = 0; i < 109 && fatSectors.length < numFatSectors; i++)
|
|
258
|
+
fatSectors.push(u32(0x4c + i * 4));
|
|
259
|
+
const perDifat = this.sectorSize / 4 - 1;
|
|
260
|
+
for (let s = u32(0x44), hops = 0; fatSectors.length < numFatSectors; hops++) {
|
|
261
|
+
if (s > MAX_REGULAR_SECTOR || hops > sectorCount)
|
|
262
|
+
throw corrupt$1('DIFAT chain ends early');
|
|
263
|
+
const at = this.sectorOffset(s);
|
|
264
|
+
for (let i = 0; i < perDifat && fatSectors.length < numFatSectors; i++)
|
|
265
|
+
fatSectors.push(u32(at + i * 4));
|
|
266
|
+
s = u32(at + perDifat * 4);
|
|
267
|
+
}
|
|
268
|
+
const perSector = this.sectorSize / 4;
|
|
269
|
+
this.fat = new Uint32Array(numFatSectors * perSector);
|
|
270
|
+
fatSectors.forEach((s, i) => {
|
|
271
|
+
const at = this.sectorOffset(s);
|
|
272
|
+
for (let j = 0; j < perSector; j++)
|
|
273
|
+
this.fat[i * perSector + j] = u32(at + j * 4);
|
|
274
|
+
});
|
|
275
|
+
// Directory: 128-byte entries in the chain starting at the header's first directory sector
|
|
276
|
+
const dirBytes = this.readChain(u32(0x30), Infinity);
|
|
277
|
+
this.dir = [];
|
|
278
|
+
for (let at = 0; at + 128 <= dirBytes.length; at += 128) {
|
|
279
|
+
const d = new DataView(dirBytes.buffer, dirBytes.byteOffset + at, 128);
|
|
280
|
+
const nameLen = Math.min(d.getUint16(64, true), 64);
|
|
281
|
+
let name = '';
|
|
282
|
+
for (let i = 0; i + 2 < nameLen; i += 2)
|
|
283
|
+
name += String.fromCharCode(d.getUint16(i, true));
|
|
284
|
+
// Version 3 files may leave garbage in the size's high half
|
|
285
|
+
const size = sectorShift === 9 ? d.getUint32(120, true) : d.getUint32(120, true) + d.getUint32(124, true) * 2 ** 32;
|
|
286
|
+
this.dir.push({ name, type: d.getUint8(66), left: d.getUint32(68, true), right: d.getUint32(72, true),
|
|
287
|
+
child: d.getUint32(76, true), start: d.getUint32(116, true), size });
|
|
288
|
+
}
|
|
289
|
+
if (this.dir[0]?.type !== 5)
|
|
290
|
+
throw corrupt$1('no root entry');
|
|
291
|
+
this.walk(this.dir[0].child, '');
|
|
292
|
+
}
|
|
293
|
+
// Start of a sector, checked to hold `need` bytes (the last sector of a file may be cut short)
|
|
294
|
+
sectorOffset(sector, need = this.sectorSize) {
|
|
295
|
+
const at = (sector + 1) * this.sectorSize;
|
|
296
|
+
if (sector > MAX_REGULAR_SECTOR || at + need > this.bytes.length) {
|
|
297
|
+
throw corrupt$1(`sector ${sector} is outside the file`);
|
|
298
|
+
}
|
|
299
|
+
return at;
|
|
300
|
+
}
|
|
301
|
+
// Sibling entries form a red-black tree; children of a storage hang off its `child`
|
|
302
|
+
walk(root, prefix) {
|
|
303
|
+
const stack = [root];
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
while (stack.length) {
|
|
306
|
+
const id = stack.pop();
|
|
307
|
+
if (id > MAX_REGULAR_SECTOR)
|
|
308
|
+
continue; // NOSTREAM
|
|
309
|
+
if (seen.has(id) || !this.dir[id])
|
|
310
|
+
throw corrupt$1('directory tree loops or points outside the directory');
|
|
311
|
+
seen.add(id);
|
|
312
|
+
const e = this.dir[id];
|
|
313
|
+
stack.push(e.left, e.right);
|
|
314
|
+
if (e.type !== 1 && e.type !== 2)
|
|
315
|
+
continue;
|
|
316
|
+
const path = prefix + e.name;
|
|
317
|
+
this.byPath.set(path.toLowerCase(), { ...e, path });
|
|
318
|
+
if (e.type === 1)
|
|
319
|
+
this.walk(e.child, path + '/');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
readChain(start, size, mini = false) {
|
|
323
|
+
const table = mini ? this.miniFat : this.fat;
|
|
324
|
+
const unit = mini ? this.miniSectorSize : this.sectorSize;
|
|
325
|
+
const source = mini ? this.miniStream : this.bytes;
|
|
326
|
+
const sectors = [];
|
|
327
|
+
for (let s = start; s !== END_OF_CHAIN && sectors.length * unit < size; s = table[s]) {
|
|
328
|
+
if (s >= table.length || sectors.length > table.length)
|
|
329
|
+
throw corrupt$1(`${mini ? 'mini ' : ''}sector chain is broken or loops`);
|
|
330
|
+
sectors.push(s);
|
|
331
|
+
}
|
|
332
|
+
const total = Math.min(size, sectors.length * unit);
|
|
333
|
+
if (size !== Infinity && total < size)
|
|
334
|
+
throw corrupt$1(`stream is shorter (${total} bytes) than its stated ${size}`);
|
|
335
|
+
const out = new Uint8Array(total);
|
|
336
|
+
sectors.forEach((s, i) => {
|
|
337
|
+
const len = Math.min(unit, total - i * unit);
|
|
338
|
+
const at = mini ? s * unit : this.sectorOffset(s, len);
|
|
339
|
+
if (at + len > source.length)
|
|
340
|
+
throw corrupt$1(`mini sector ${s} is outside the mini stream`);
|
|
341
|
+
out.set(source.subarray(at, at + len), i * unit);
|
|
342
|
+
});
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
entries() {
|
|
346
|
+
return [...this.byPath.values()].map(e => ({ path: e.path, type: e.type === 1 ? 'storage' : 'stream', size: e.size }));
|
|
347
|
+
}
|
|
348
|
+
has(path) {
|
|
349
|
+
return this.byPath.get(path.toLowerCase())?.type === 2;
|
|
350
|
+
}
|
|
351
|
+
// A stream's bytes, by path (case-insensitive, as in the format); undefined if there is none
|
|
352
|
+
read(path) {
|
|
353
|
+
const e = this.byPath.get(path.toLowerCase());
|
|
354
|
+
if (!e || e.type !== 2)
|
|
355
|
+
return undefined;
|
|
356
|
+
if (e.size >= this.miniCutoff) {
|
|
357
|
+
if (e.size > this.bytes.length)
|
|
358
|
+
throw corrupt$1(`stream ${e.path} is larger than the file`);
|
|
359
|
+
return this.readChain(e.start, e.size);
|
|
360
|
+
}
|
|
361
|
+
if (!this.miniStream) {
|
|
362
|
+
const root = this.dir[0];
|
|
363
|
+
this.miniStream = this.readChain(root.start, root.size);
|
|
364
|
+
const view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
|
|
365
|
+
const miniFatBytes = this.readChain(view.getUint32(0x3c, true), view.getUint32(0x40, true) * this.sectorSize);
|
|
366
|
+
this.miniFat = new Uint32Array(miniFatBytes.length / 4);
|
|
367
|
+
const mv = new DataView(miniFatBytes.buffer, miniFatBytes.byteOffset, miniFatBytes.byteLength);
|
|
368
|
+
for (let i = 0; i < this.miniFat.length; i++)
|
|
369
|
+
this.miniFat[i] = mv.getUint32(i * 4, true);
|
|
370
|
+
}
|
|
371
|
+
return this.readChain(e.start, e.size, true);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
3
375
|
class ZipRandomAccessParser {
|
|
4
376
|
reader;
|
|
5
377
|
records = new Map();
|
|
378
|
+
// Part names are case-insensitive in OPC: a rels target "Sheet1.xml" finds "sheet1.xml"
|
|
379
|
+
lowerCase = new Map();
|
|
6
380
|
constructor(reader) {
|
|
7
381
|
this.reader = reader;
|
|
8
382
|
}
|
|
@@ -15,15 +389,24 @@ class ZipRandomAccessParser {
|
|
|
15
389
|
const searchStart = size - searchSize;
|
|
16
390
|
const buffer = await this.reader.read(searchStart, searchSize);
|
|
17
391
|
const dataView = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
18
|
-
// Search backwards for the EOCD signature (0x06054b50)
|
|
392
|
+
// Search backwards for the EOCD signature (0x06054b50). The real record's comment reaches the end
|
|
393
|
+
// of the file, so the signature bytes inside a comment are skipped; failing that (junk appended
|
|
394
|
+
// after the archive), the last signature is used.
|
|
19
395
|
let eocdOffset = -1;
|
|
20
396
|
for (let i = buffer.length - 22; i >= 0; i--) {
|
|
21
|
-
if (dataView.getUint32(i, true)
|
|
397
|
+
if (dataView.getUint32(i, true) !== 0x06054b50)
|
|
398
|
+
continue;
|
|
399
|
+
if (eocdOffset === -1)
|
|
400
|
+
eocdOffset = i;
|
|
401
|
+
if (i + 22 + dataView.getUint16(i + 20, true) === buffer.length) {
|
|
22
402
|
eocdOffset = i;
|
|
23
403
|
break;
|
|
24
404
|
}
|
|
25
405
|
}
|
|
26
406
|
if (eocdOffset === -1) {
|
|
407
|
+
if (isCfb(buffer.subarray(0, 8)) || searchStart > 0 && isCfb(await this.reader.read(0, 8))) {
|
|
408
|
+
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.');
|
|
409
|
+
}
|
|
27
410
|
throw new Error("End of Central Directory (EOCD) not found. This may not be a valid ZIP file.");
|
|
28
411
|
}
|
|
29
412
|
const totalRecords = dataView.getUint16(eocdOffset + 10, true);
|
|
@@ -60,14 +443,10 @@ class ZipRandomAccessParser {
|
|
|
60
443
|
if (filename.includes('../') || filename.includes('..\\')) {
|
|
61
444
|
throw new Error(`Security Exception: Path traversal detected in ZIP filename: ${filename}`);
|
|
62
445
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
compressedSize,
|
|
68
|
-
uncompressedSize,
|
|
69
|
-
localHeaderOffset
|
|
70
|
-
});
|
|
446
|
+
const record = { filename, compressionMethod, crc, compressedSize, uncompressedSize, localHeaderOffset };
|
|
447
|
+
this.records.set(filename, record);
|
|
448
|
+
if (!this.lowerCase.has(filename.toLowerCase()))
|
|
449
|
+
this.lowerCase.set(filename.toLowerCase(), record);
|
|
71
450
|
offset += 46 + filenameLength + extraFieldLength + fileCommentLength;
|
|
72
451
|
}
|
|
73
452
|
// Entries must not share bytes: aliased names would let one small deflated entry be inflated many times
|
|
@@ -80,13 +459,13 @@ class ZipRandomAccessParser {
|
|
|
80
459
|
}
|
|
81
460
|
}
|
|
82
461
|
has(filename) {
|
|
83
|
-
return this.records.has(filename);
|
|
462
|
+
return this.records.has(filename) || this.lowerCase.has(filename.toLowerCase());
|
|
84
463
|
}
|
|
85
464
|
getFiles() {
|
|
86
465
|
return Array.from(this.records.keys());
|
|
87
466
|
}
|
|
88
467
|
getRecord(filename) {
|
|
89
|
-
const record = this.records.get(filename);
|
|
468
|
+
const record = this.records.get(filename) ?? this.lowerCase.get(filename.toLowerCase());
|
|
90
469
|
if (!record)
|
|
91
470
|
throw new Error(`File ${filename} not found in ZIP.`);
|
|
92
471
|
return record;
|
|
@@ -118,17 +497,24 @@ class ZipRandomAccessParser {
|
|
|
118
497
|
throw new Error(`Unsupported compression method ${record.compressionMethod} for ${filename}`);
|
|
119
498
|
}
|
|
120
499
|
const stream = await this.extractRawStream(filename);
|
|
121
|
-
|
|
122
|
-
|
|
500
|
+
const data = record.compressionMethod === 0 ? stream
|
|
501
|
+
: stream.pipeThrough(new DecompressionStream('deflate-raw'));
|
|
123
502
|
// Node reports bad deflate data as a bare TypeError; name the entry instead
|
|
124
|
-
const inflated =
|
|
503
|
+
const inflated = data.getReader();
|
|
125
504
|
let total = 0;
|
|
505
|
+
let crc = 0xffffffff;
|
|
126
506
|
return new ReadableStream({
|
|
127
507
|
async pull(controller) {
|
|
128
508
|
try {
|
|
129
509
|
const { done, value } = await inflated.read();
|
|
130
|
-
if (done)
|
|
510
|
+
if (done) {
|
|
511
|
+
// Changed bytes must fail, not read as different data
|
|
512
|
+
if (total !== record.uncompressedSize || ((crc ^ 0xffffffff) >>> 0) !== record.crc) {
|
|
513
|
+
return controller.error(new Error(`Corrupt ZIP entry ${filename}: its data does not match the size and CRC-32 in the directory`));
|
|
514
|
+
}
|
|
131
515
|
return controller.close();
|
|
516
|
+
}
|
|
517
|
+
crc = crc32Update(crc, value);
|
|
132
518
|
// The directory states each entry's size; inflating past it means a crafted entry (a zip bomb)
|
|
133
519
|
total += value.length;
|
|
134
520
|
if (total > record.uncompressedSize) {
|
|
@@ -181,7 +567,7 @@ function stripNamespace(name) {
|
|
|
181
567
|
// Emits one array of tokens per input chunk. Per-token stream chunks cost a promise round-trip
|
|
182
568
|
// each (~5 per cell), which dominated read time; batching removes that overhead.
|
|
183
569
|
function createXmlBatchParser() {
|
|
184
|
-
|
|
570
|
+
let decoder;
|
|
185
571
|
let buffer = '';
|
|
186
572
|
let isFirstChunk = true;
|
|
187
573
|
// When a construct spans chunks, remember how far it was already scanned (and the quote state
|
|
@@ -192,6 +578,8 @@ function createXmlBatchParser() {
|
|
|
192
578
|
let pendingQuote = '';
|
|
193
579
|
return new TransformStream({
|
|
194
580
|
transform(chunk, controller) {
|
|
581
|
+
// XML parsers must read UTF-16 as well as UTF-8; a UTF-16 part starts with its byte-order mark
|
|
582
|
+
decoder ??= new TextDecoder(chunk[0] === 0xff && chunk[1] === 0xfe ? 'utf-16le' : chunk[0] === 0xfe && chunk[1] === 0xff ? 'utf-16be' : 'utf-8');
|
|
195
583
|
buffer += decoder.decode(chunk, { stream: true });
|
|
196
584
|
if (isFirstChunk) {
|
|
197
585
|
if (buffer.charCodeAt(0) === 0xFEFF) {
|
|
@@ -290,7 +678,7 @@ function createXmlBatchParser() {
|
|
|
290
678
|
controller.enqueue(out);
|
|
291
679
|
},
|
|
292
680
|
flush(controller) {
|
|
293
|
-
buffer += decoder
|
|
681
|
+
buffer += decoder?.decode() ?? '';
|
|
294
682
|
// Handle trailing text if any
|
|
295
683
|
if (buffer.length > 0 && pending !== 'tag' && pending !== 'cdata' && pending !== 'comment') {
|
|
296
684
|
controller.enqueue([{ type: 'text', value: unescapeXml$1(normalizeEol(buffer)) }]);
|
|
@@ -362,6 +750,17 @@ async function sheetToJson(parseResult, headerRowIndex = 0) {
|
|
|
362
750
|
});
|
|
363
751
|
continue;
|
|
364
752
|
}
|
|
753
|
+
// Values right of the header row get their own column name, as blank headers do
|
|
754
|
+
for (let i = headers.length; i < row.cells.length; i++) {
|
|
755
|
+
if (row.cells[i] === null || row.cells[i] === undefined)
|
|
756
|
+
continue;
|
|
757
|
+
let name = `Column${i + 1}`;
|
|
758
|
+
for (let n = 2; headers.includes(name); n++)
|
|
759
|
+
name = `Column${i + 1}_${n}`;
|
|
760
|
+
while (headers.length < i)
|
|
761
|
+
headers.push(`Column${headers.length + 1}`);
|
|
762
|
+
headers.push(name);
|
|
763
|
+
}
|
|
365
764
|
const obj = {};
|
|
366
765
|
for (let i = 0; i < headers.length; i++) {
|
|
367
766
|
const value = row.cells[i] ?? null;
|
|
@@ -386,9 +785,13 @@ function escapeCsv(val) {
|
|
|
386
785
|
}
|
|
387
786
|
async function streamToCsv(parseResult) {
|
|
388
787
|
const rows = [];
|
|
788
|
+
let last = 0;
|
|
389
789
|
for await (const row of parseResult) {
|
|
390
|
-
|
|
391
|
-
|
|
790
|
+
// Rows missing between rows are blank lines, as in Excel's CSV export, so nothing shifts up
|
|
791
|
+
for (let gap = last ? row.rowNumber - last - 1 : 0; gap > 0; gap--)
|
|
792
|
+
rows.push('');
|
|
793
|
+
last = row.rowNumber;
|
|
794
|
+
rows.push(row.cells.map(escapeCsv).join(','));
|
|
392
795
|
}
|
|
393
796
|
return rows.join('\n');
|
|
394
797
|
}
|
|
@@ -434,15 +837,18 @@ async function resolveWorkbookParts(readText) {
|
|
|
434
837
|
const workbookXml = await readText(workbookPath);
|
|
435
838
|
const rels = parseRels(await readText(relsPathOf(workbookPath)), dirOf(workbookPath));
|
|
436
839
|
const sheets = new Map();
|
|
840
|
+
const chartsheets = new Set();
|
|
437
841
|
for (const [tag] of workbookXml.matchAll(/<(?:\w+:)?sheet\b[^>]*>/g)) {
|
|
438
842
|
const name = attr(tag, 'name');
|
|
439
843
|
const rId = attr(tag, 'r:id') ?? attr(tag, '\\w+:id');
|
|
440
844
|
const rel = rId ? rels.find(r => r.id === rId && !r.external) : undefined;
|
|
441
845
|
if (name && rel)
|
|
442
846
|
sheets.set(name, rel.path);
|
|
847
|
+
if (rel?.type.endsWith('/chartsheet'))
|
|
848
|
+
chartsheets.add(rel.path);
|
|
443
849
|
}
|
|
444
850
|
return {
|
|
445
|
-
workbookPath, workbookXml, sheets,
|
|
851
|
+
workbookPath, workbookXml, sheets, chartsheets,
|
|
446
852
|
sharedStrings: byType(rels, '/sharedStrings'), styles: byType(rels, '/styles'), theme: byType(rels, '/theme'),
|
|
447
853
|
};
|
|
448
854
|
}
|
|
@@ -519,16 +925,23 @@ function mapFormulaRefs(formula, mapCol, mapRow) {
|
|
|
519
925
|
if (isEnd)
|
|
520
926
|
sheet = prevSheet;
|
|
521
927
|
else if (before.endsWith('!')) {
|
|
522
|
-
|
|
523
|
-
|
|
928
|
+
// [1]Sheet!A1 points into another workbook: "[" can't be in a sheet name, so no sheet matches it
|
|
929
|
+
const plain = /(\]?)([A-Za-z0-9_.\u00C0-\uFFFF]+)!$/.exec(before);
|
|
930
|
+
sheet = plain ? (plain[1] ? `[external]${plain[2]}` : plain[2])
|
|
931
|
+
: offset === 1 && i > 0 && parts[i - 1].startsWith("'") ? unquoteSheet(parts[i - 1]) : undefined;
|
|
524
932
|
}
|
|
525
933
|
const role = isEnd ? 'end' : part[offset + m.length] === ':' ? 'start' : 'single';
|
|
934
|
+
// The end of a range pushed past the sheet's edge stays at the edge, as in Excel (SUM(B1:B1048576))
|
|
526
935
|
const col = (abs, letters, r) => {
|
|
527
|
-
|
|
936
|
+
let c = mapCol(colIndex(letters), !!abs, r, sheet);
|
|
937
|
+
if (c !== null && c >= MAX_COLUMNS$1 && r === 'end' && colIndex(letters) < MAX_COLUMNS$1)
|
|
938
|
+
c = MAX_COLUMNS$1 - 1;
|
|
528
939
|
return c !== null && c >= 0 && c < MAX_COLUMNS$1 ? abs + colLetter(c) : null;
|
|
529
940
|
};
|
|
530
941
|
const row = (abs, digits, r) => {
|
|
531
|
-
|
|
942
|
+
let n = mapRow(parseInt(digits, 10), !!abs, r, sheet);
|
|
943
|
+
if (n !== null && n > MAX_ROWS$1 && r === 'end' && parseInt(digits, 10) <= MAX_ROWS$1)
|
|
944
|
+
n = MAX_ROWS$1;
|
|
532
945
|
return n !== null && n >= 1 && n <= MAX_ROWS$1 ? n : null;
|
|
533
946
|
};
|
|
534
947
|
prevEnd = offset + m.length;
|
|
@@ -576,7 +989,7 @@ function dateToSerial(d) {
|
|
|
576
989
|
function encodeXString(s) {
|
|
577
990
|
return s
|
|
578
991
|
.replace(/_(?=x[0-9A-Fa-f]{4}_)/g, '_x005F_')
|
|
579
|
-
.replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
|
|
992
|
+
.replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F\uFFFE\uFFFF]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
|
|
580
993
|
}
|
|
581
994
|
// The format SheetWriter gives a date without one: the time only when there is one
|
|
582
995
|
const defaultDateFormat = (d) => d.getTime() % 86400000 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm:ss';
|
|
@@ -620,15 +1033,18 @@ async function recalcOnOpen(readText, parts, workbookXml = parts.workbookXml) {
|
|
|
620
1033
|
}
|
|
621
1034
|
// Excel refuses to open a workbook that breaks these rules
|
|
622
1035
|
function validateSheetName(name, existing) {
|
|
623
|
-
if (!name || name.length > 31 || /[\\/?*:[\]]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
|
|
624
|
-
throw new Error(`Invalid sheet name "${name}": 1-31 characters, none of \\ / ? * : [ ], and no leading or trailing apostrophe.`);
|
|
1036
|
+
if (!name || name.length > 31 || /[\\/?*:[\]\x00-\x1F\uFFFE\uFFFF]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
|
|
1037
|
+
throw new Error(`Invalid sheet name "${name}": 1-31 characters, none of \\ / ? * : [ ] or control characters, and no leading or trailing apostrophe.`);
|
|
625
1038
|
}
|
|
626
1039
|
for (const other of existing) {
|
|
627
1040
|
if (other.toLowerCase() === name.toLowerCase())
|
|
628
1041
|
throw new Error(`Duplicate sheet name "${name}".`);
|
|
629
1042
|
}
|
|
630
1043
|
}
|
|
631
|
-
|
|
1044
|
+
// Characters XML 1.0 can't carry at all. Text that allows _xHHHH_ escapes keeps them through
|
|
1045
|
+
// encodeXString first; anywhere else (names, properties, formulas) they are dropped.
|
|
1046
|
+
const INVALID_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g;
|
|
1047
|
+
const escapeXml$4 = (val) => val.replace(INVALID_XML_CHARS, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
632
1048
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
633
1049
|
// Out-of-range references (�) stay as written instead of throwing
|
|
634
1050
|
const fromCodePoint = (cp, m) => (cp <= 0x10ffff ? String.fromCodePoint(cp) : m);
|
|
@@ -1284,6 +1700,13 @@ class ParseResult {
|
|
|
1284
1700
|
return this.comments();
|
|
1285
1701
|
}
|
|
1286
1702
|
}
|
|
1703
|
+
// "2026-10-08T14:05:00" or "2026-10-08" (a t="d" cell, no zone: UTC) as an ISO string; undefined when unreadable
|
|
1704
|
+
function isoDateCell(text) {
|
|
1705
|
+
const t = text.trim();
|
|
1706
|
+
const zoned = /(?:Z|[+-]\d\d:?\d\d)$/.test(t) ? t : t.includes('T') ? t + 'Z' : t + 'T00:00:00Z';
|
|
1707
|
+
const d = new Date(zoned);
|
|
1708
|
+
return isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
1709
|
+
}
|
|
1287
1710
|
function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, options = {}) {
|
|
1288
1711
|
let resolveMeta;
|
|
1289
1712
|
let rejectMeta;
|
|
@@ -1319,12 +1742,14 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1319
1742
|
let rowStyles;
|
|
1320
1743
|
let rowRichText;
|
|
1321
1744
|
let rowFormatted;
|
|
1745
|
+
let rowErrors;
|
|
1322
1746
|
let rawNumber; // a date cell's serial, for formatted text
|
|
1323
1747
|
const runs = options.richText ? new RichTextCollector(options.richText) : undefined;
|
|
1324
1748
|
let skipDepth = 0;
|
|
1325
1749
|
// Shared formula anchors by si: the text and the cell it was written for
|
|
1326
1750
|
const sharedFormulas = new Map();
|
|
1327
1751
|
let currentRowNumber = 0;
|
|
1752
|
+
let openedWorksheet = false, closedWorksheet = false;
|
|
1328
1753
|
let currentColIndex = 0;
|
|
1329
1754
|
let currentCellCol = 0;
|
|
1330
1755
|
try {
|
|
@@ -1345,18 +1770,17 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1345
1770
|
}
|
|
1346
1771
|
continue;
|
|
1347
1772
|
}
|
|
1773
|
+
if (token.name === 'worksheet')
|
|
1774
|
+
openedWorksheet = true;
|
|
1348
1775
|
if (token.name === 'row') {
|
|
1349
1776
|
inRow = true;
|
|
1350
1777
|
currentRow = [];
|
|
1351
1778
|
rowFormulas = rowStyles = rowRichText = rowFormatted = undefined;
|
|
1779
|
+
rowErrors = undefined;
|
|
1352
1780
|
currentColIndex = 0;
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
}
|
|
1357
|
-
else {
|
|
1358
|
-
currentRowNumber++;
|
|
1359
|
-
}
|
|
1781
|
+
// A missing or unusable r ("abc", "0") means the row after the previous one
|
|
1782
|
+
const r = Number(token.attributes['r']);
|
|
1783
|
+
currentRowNumber = Number.isInteger(r) && r >= 1 ? r : currentRowNumber + 1;
|
|
1360
1784
|
if (token.attributes['hidden'] === '1' || token.attributes['hidden'] === 'true') {
|
|
1361
1785
|
hiddenRows.push(currentRowNumber);
|
|
1362
1786
|
}
|
|
@@ -1446,6 +1870,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1446
1870
|
}
|
|
1447
1871
|
if (skipDepth > 0)
|
|
1448
1872
|
continue;
|
|
1873
|
+
if (token.name === 'worksheet')
|
|
1874
|
+
closedWorksheet = true;
|
|
1449
1875
|
if (token.name === 'row') {
|
|
1450
1876
|
const row = { rowNumber: currentRowNumber, cells: currentRow };
|
|
1451
1877
|
if (rowFormulas)
|
|
@@ -1456,6 +1882,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1456
1882
|
row.richText = rowRichText;
|
|
1457
1883
|
if (rowFormatted)
|
|
1458
1884
|
row.formatted = rowFormatted;
|
|
1885
|
+
if (rowErrors)
|
|
1886
|
+
row.errors = rowErrors;
|
|
1459
1887
|
yield row;
|
|
1460
1888
|
inRow = false;
|
|
1461
1889
|
}
|
|
@@ -1484,11 +1912,19 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1484
1912
|
}
|
|
1485
1913
|
else if (currentCellType === 'e') {
|
|
1486
1914
|
resolvedValue = currentCellValue;
|
|
1915
|
+
if (options.errors)
|
|
1916
|
+
(rowErrors ??= [])[currentCellCol] = true;
|
|
1487
1917
|
}
|
|
1488
1918
|
else {
|
|
1489
1919
|
const num = Number(currentCellValue);
|
|
1490
|
-
|
|
1491
|
-
|
|
1920
|
+
const iso = currentCellType === 'd' ? isoDateCell(currentCellValue) : undefined;
|
|
1921
|
+
if (iso) {
|
|
1922
|
+
// t="d": ISO text without a zone, read as UTC like serial dates
|
|
1923
|
+
rawNumber = dateToSerial(new Date(iso)) - (is1904 ? 1462 : 0);
|
|
1924
|
+
resolvedValue = iso;
|
|
1925
|
+
}
|
|
1926
|
+
else if (isNaN(num)) {
|
|
1927
|
+
resolvedValue = currentCellValue;
|
|
1492
1928
|
}
|
|
1493
1929
|
else if (currentStyleId !== null && styles.get(currentStyleId) === 14) {
|
|
1494
1930
|
rawNumber = num;
|
|
@@ -1551,6 +1987,9 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1551
1987
|
}
|
|
1552
1988
|
}
|
|
1553
1989
|
}
|
|
1990
|
+
// A part cut off inside a row (an intact zip around a truncated sheet) must not read as a shorter sheet
|
|
1991
|
+
if (inRow || inCell || openedWorksheet && !closedWorksheet)
|
|
1992
|
+
throw new Error('Worksheet XML is cut off: the sheet ends before its closing tags.');
|
|
1554
1993
|
const hiddenCols = [];
|
|
1555
1994
|
hiddenColMarks.forEach((hidden, c) => { if (hidden)
|
|
1556
1995
|
hiddenCols.push(c); });
|
|
@@ -1570,6 +2009,13 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1570
2009
|
const EMU_PER_PX = 9525;
|
|
1571
2010
|
// Format and pixel size from the file header
|
|
1572
2011
|
function imageInfo(b) {
|
|
2012
|
+
const info = headerInfo(b);
|
|
2013
|
+
// A truncated header reads past the end of the bytes and gives NaN (or 0) sizes
|
|
2014
|
+
if (!(info.width > 0 && info.height > 0))
|
|
2015
|
+
throw new Error(`The ${info.ext.toUpperCase()} image is truncated or has no size.`);
|
|
2016
|
+
return info;
|
|
2017
|
+
}
|
|
2018
|
+
function headerInfo(b) {
|
|
1573
2019
|
const be16 = (i) => (b[i] << 8) | b[i + 1];
|
|
1574
2020
|
const be32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
1575
2021
|
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
@@ -1648,7 +2094,6 @@ async function readSheetImages(readText, readBytes, sheetPath) {
|
|
|
1648
2094
|
}
|
|
1649
2095
|
return images;
|
|
1650
2096
|
}
|
|
1651
|
-
const escapeXml$4 = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1652
2097
|
const cellPos = (ref) => {
|
|
1653
2098
|
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(ref.trim());
|
|
1654
2099
|
const col = m ? colIndex(m[1]) : -1, row = m ? parseInt(m[2], 10) - 1 : -1;
|
|
@@ -1664,6 +2109,9 @@ const DRAWING_NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/
|
|
|
1664
2109
|
// none; a missing side keeps its aspect ratio. `attrs` lands on the anchor element, e.g. DRAWING_NS
|
|
1665
2110
|
// when the anchor is added to a drawing that declares other prefixes.
|
|
1666
2111
|
function anchorXml(place, natural, body, attrs = '') {
|
|
2112
|
+
if (!place || typeof place.range !== 'string' && typeof place.at !== 'string') {
|
|
2113
|
+
throw new Error('A picture or chart needs a placement: "at" (a cell) or "range".');
|
|
2114
|
+
}
|
|
1667
2115
|
if ('range' in place) {
|
|
1668
2116
|
const [from, to = from] = place.range.split(':');
|
|
1669
2117
|
const a = cellPos(from), b = cellPos(to);
|
|
@@ -1675,6 +2123,9 @@ function anchorXml(place, natural, body, attrs = '') {
|
|
|
1675
2123
|
const { width: w, height: h } = natural;
|
|
1676
2124
|
const width = place.width ?? (place.height ? w * place.height / h : w);
|
|
1677
2125
|
const height = place.height ?? h * width / w;
|
|
2126
|
+
if (!(width > 0 && height > 0 && isFinite(width) && isFinite(height))) {
|
|
2127
|
+
throw new Error(`Invalid size ${place.width ?? ''}x${place.height ?? ''} at ${place.at}: width and height must be positive numbers.`);
|
|
2128
|
+
}
|
|
1678
2129
|
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>`;
|
|
1679
2130
|
}
|
|
1680
2131
|
const drawingPartXml = (anchors) => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<xdr:wsDr ${DRAWING_NS}>${anchors}</xdr:wsDr>`;
|
|
@@ -1686,166 +2137,6 @@ function drawingXml(images, infos, rIds) {
|
|
|
1686
2137
|
`<xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic>`)).join(''));
|
|
1687
2138
|
}
|
|
1688
2139
|
|
|
1689
|
-
// Reader for Compound File Binary files [MS-CFB]: the container of .xls workbooks and of
|
|
1690
|
-
// password-protected .xlsx files. Streams are read from an in-memory copy of the file.
|
|
1691
|
-
const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
1692
|
-
const END_OF_CHAIN = 0xfffffffe;
|
|
1693
|
-
const MAX_REGULAR_SECTOR = 0xfffffffa;
|
|
1694
|
-
function isCfb(bytes) {
|
|
1695
|
-
return bytes.length >= 8 && SIGNATURE.every((b, i) => bytes[i] === b);
|
|
1696
|
-
}
|
|
1697
|
-
const corrupt$1 = (why) => new Error(`Corrupt compound file: ${why}`);
|
|
1698
|
-
class CfbReader {
|
|
1699
|
-
bytes;
|
|
1700
|
-
view;
|
|
1701
|
-
sectorSize;
|
|
1702
|
-
miniSectorSize;
|
|
1703
|
-
miniCutoff;
|
|
1704
|
-
fat;
|
|
1705
|
-
miniFat;
|
|
1706
|
-
miniStream;
|
|
1707
|
-
dir;
|
|
1708
|
-
byPath = new Map();
|
|
1709
|
-
constructor(bytes) {
|
|
1710
|
-
this.bytes = bytes;
|
|
1711
|
-
if (!isCfb(bytes) || bytes.length < 512)
|
|
1712
|
-
throw new Error('Not a compound file (no D0CF11E0 signature)');
|
|
1713
|
-
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1714
|
-
const u16 = (o) => this.view.getUint16(o, true);
|
|
1715
|
-
const u32 = (o) => this.view.getUint32(o, true);
|
|
1716
|
-
const sectorShift = u16(0x1e);
|
|
1717
|
-
if (sectorShift !== 9 && sectorShift !== 12)
|
|
1718
|
-
throw corrupt$1(`sector shift ${sectorShift}`);
|
|
1719
|
-
this.sectorSize = 1 << sectorShift;
|
|
1720
|
-
const miniShift = u16(0x20);
|
|
1721
|
-
if (miniShift !== 6)
|
|
1722
|
-
throw corrupt$1(`mini sector shift ${miniShift}`);
|
|
1723
|
-
this.miniSectorSize = 1 << miniShift;
|
|
1724
|
-
this.miniCutoff = u32(0x38);
|
|
1725
|
-
// The FAT's own sectors are listed in the header (109 slots) and then in a chain of DIFAT sectors
|
|
1726
|
-
const numFatSectors = u32(0x2c);
|
|
1727
|
-
const sectorCount = Math.ceil(bytes.length / this.sectorSize) - 1;
|
|
1728
|
-
if (numFatSectors > sectorCount)
|
|
1729
|
-
throw corrupt$1(`${numFatSectors} FAT sectors in a ${bytes.length}-byte file`);
|
|
1730
|
-
const fatSectors = [];
|
|
1731
|
-
for (let i = 0; i < 109 && fatSectors.length < numFatSectors; i++)
|
|
1732
|
-
fatSectors.push(u32(0x4c + i * 4));
|
|
1733
|
-
const perDifat = this.sectorSize / 4 - 1;
|
|
1734
|
-
for (let s = u32(0x44), hops = 0; fatSectors.length < numFatSectors; hops++) {
|
|
1735
|
-
if (s > MAX_REGULAR_SECTOR || hops > sectorCount)
|
|
1736
|
-
throw corrupt$1('DIFAT chain ends early');
|
|
1737
|
-
const at = this.sectorOffset(s);
|
|
1738
|
-
for (let i = 0; i < perDifat && fatSectors.length < numFatSectors; i++)
|
|
1739
|
-
fatSectors.push(u32(at + i * 4));
|
|
1740
|
-
s = u32(at + perDifat * 4);
|
|
1741
|
-
}
|
|
1742
|
-
const perSector = this.sectorSize / 4;
|
|
1743
|
-
this.fat = new Uint32Array(numFatSectors * perSector);
|
|
1744
|
-
fatSectors.forEach((s, i) => {
|
|
1745
|
-
const at = this.sectorOffset(s);
|
|
1746
|
-
for (let j = 0; j < perSector; j++)
|
|
1747
|
-
this.fat[i * perSector + j] = u32(at + j * 4);
|
|
1748
|
-
});
|
|
1749
|
-
// Directory: 128-byte entries in the chain starting at the header's first directory sector
|
|
1750
|
-
const dirBytes = this.readChain(u32(0x30), Infinity);
|
|
1751
|
-
this.dir = [];
|
|
1752
|
-
for (let at = 0; at + 128 <= dirBytes.length; at += 128) {
|
|
1753
|
-
const d = new DataView(dirBytes.buffer, dirBytes.byteOffset + at, 128);
|
|
1754
|
-
const nameLen = Math.min(d.getUint16(64, true), 64);
|
|
1755
|
-
let name = '';
|
|
1756
|
-
for (let i = 0; i + 2 < nameLen; i += 2)
|
|
1757
|
-
name += String.fromCharCode(d.getUint16(i, true));
|
|
1758
|
-
// Version 3 files may leave garbage in the size's high half
|
|
1759
|
-
const size = sectorShift === 9 ? d.getUint32(120, true) : d.getUint32(120, true) + d.getUint32(124, true) * 2 ** 32;
|
|
1760
|
-
this.dir.push({ name, type: d.getUint8(66), left: d.getUint32(68, true), right: d.getUint32(72, true),
|
|
1761
|
-
child: d.getUint32(76, true), start: d.getUint32(116, true), size });
|
|
1762
|
-
}
|
|
1763
|
-
if (this.dir[0]?.type !== 5)
|
|
1764
|
-
throw corrupt$1('no root entry');
|
|
1765
|
-
this.walk(this.dir[0].child, '');
|
|
1766
|
-
}
|
|
1767
|
-
// Start of a sector, checked to hold `need` bytes (the last sector of a file may be cut short)
|
|
1768
|
-
sectorOffset(sector, need = this.sectorSize) {
|
|
1769
|
-
const at = (sector + 1) * this.sectorSize;
|
|
1770
|
-
if (sector > MAX_REGULAR_SECTOR || at + need > this.bytes.length) {
|
|
1771
|
-
throw corrupt$1(`sector ${sector} is outside the file`);
|
|
1772
|
-
}
|
|
1773
|
-
return at;
|
|
1774
|
-
}
|
|
1775
|
-
// Sibling entries form a red-black tree; children of a storage hang off its `child`
|
|
1776
|
-
walk(root, prefix) {
|
|
1777
|
-
const stack = [root];
|
|
1778
|
-
const seen = new Set();
|
|
1779
|
-
while (stack.length) {
|
|
1780
|
-
const id = stack.pop();
|
|
1781
|
-
if (id > MAX_REGULAR_SECTOR)
|
|
1782
|
-
continue; // NOSTREAM
|
|
1783
|
-
if (seen.has(id) || !this.dir[id])
|
|
1784
|
-
throw corrupt$1('directory tree loops or points outside the directory');
|
|
1785
|
-
seen.add(id);
|
|
1786
|
-
const e = this.dir[id];
|
|
1787
|
-
stack.push(e.left, e.right);
|
|
1788
|
-
if (e.type !== 1 && e.type !== 2)
|
|
1789
|
-
continue;
|
|
1790
|
-
const path = prefix + e.name;
|
|
1791
|
-
this.byPath.set(path.toLowerCase(), { ...e, path });
|
|
1792
|
-
if (e.type === 1)
|
|
1793
|
-
this.walk(e.child, path + '/');
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
readChain(start, size, mini = false) {
|
|
1797
|
-
const table = mini ? this.miniFat : this.fat;
|
|
1798
|
-
const unit = mini ? this.miniSectorSize : this.sectorSize;
|
|
1799
|
-
const source = mini ? this.miniStream : this.bytes;
|
|
1800
|
-
const sectors = [];
|
|
1801
|
-
for (let s = start; s !== END_OF_CHAIN && sectors.length * unit < size; s = table[s]) {
|
|
1802
|
-
if (s >= table.length || sectors.length > table.length)
|
|
1803
|
-
throw corrupt$1(`${mini ? 'mini ' : ''}sector chain is broken or loops`);
|
|
1804
|
-
sectors.push(s);
|
|
1805
|
-
}
|
|
1806
|
-
const total = Math.min(size, sectors.length * unit);
|
|
1807
|
-
if (size !== Infinity && total < size)
|
|
1808
|
-
throw corrupt$1(`stream is shorter (${total} bytes) than its stated ${size}`);
|
|
1809
|
-
const out = new Uint8Array(total);
|
|
1810
|
-
sectors.forEach((s, i) => {
|
|
1811
|
-
const len = Math.min(unit, total - i * unit);
|
|
1812
|
-
const at = mini ? s * unit : this.sectorOffset(s, len);
|
|
1813
|
-
if (at + len > source.length)
|
|
1814
|
-
throw corrupt$1(`mini sector ${s} is outside the mini stream`);
|
|
1815
|
-
out.set(source.subarray(at, at + len), i * unit);
|
|
1816
|
-
});
|
|
1817
|
-
return out;
|
|
1818
|
-
}
|
|
1819
|
-
entries() {
|
|
1820
|
-
return [...this.byPath.values()].map(e => ({ path: e.path, type: e.type === 1 ? 'storage' : 'stream', size: e.size }));
|
|
1821
|
-
}
|
|
1822
|
-
has(path) {
|
|
1823
|
-
return this.byPath.get(path.toLowerCase())?.type === 2;
|
|
1824
|
-
}
|
|
1825
|
-
// A stream's bytes, by path (case-insensitive, as in the format); undefined if there is none
|
|
1826
|
-
read(path) {
|
|
1827
|
-
const e = this.byPath.get(path.toLowerCase());
|
|
1828
|
-
if (!e || e.type !== 2)
|
|
1829
|
-
return undefined;
|
|
1830
|
-
if (e.size >= this.miniCutoff) {
|
|
1831
|
-
if (e.size > this.bytes.length)
|
|
1832
|
-
throw corrupt$1(`stream ${e.path} is larger than the file`);
|
|
1833
|
-
return this.readChain(e.start, e.size);
|
|
1834
|
-
}
|
|
1835
|
-
if (!this.miniStream) {
|
|
1836
|
-
const root = this.dir[0];
|
|
1837
|
-
this.miniStream = this.readChain(root.start, root.size);
|
|
1838
|
-
const view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
|
|
1839
|
-
const miniFatBytes = this.readChain(view.getUint32(0x3c, true), view.getUint32(0x40, true) * this.sectorSize);
|
|
1840
|
-
this.miniFat = new Uint32Array(miniFatBytes.length / 4);
|
|
1841
|
-
const mv = new DataView(miniFatBytes.buffer, miniFatBytes.byteOffset, miniFatBytes.byteLength);
|
|
1842
|
-
for (let i = 0; i < this.miniFat.length; i++)
|
|
1843
|
-
this.miniFat[i] = mv.getUint32(i * 4, true);
|
|
1844
|
-
}
|
|
1845
|
-
return this.readChain(e.start, e.size, true);
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
2140
|
// Reader for Excel 97-2003 workbooks (.xls, BIFF8 records in a compound file) [MS-XLS].
|
|
1850
2141
|
// Returns the same rows and metadata as the .xlsx reader. The file is held in memory; .xls sheets
|
|
1851
2142
|
// are limited to 65,536 rows, so that stays modest.
|
|
@@ -2390,8 +2681,10 @@ async function readOdsWorkbook(content, readText) {
|
|
|
2390
2681
|
if (!properties.creator && text('dc:creator'))
|
|
2391
2682
|
properties.creator = text('dc:creator');
|
|
2392
2683
|
const created = text('meta:creation-date');
|
|
2393
|
-
|
|
2394
|
-
|
|
2684
|
+
// Without a zone the time is UTC, as written by OdsWriter (and stored by Excel)
|
|
2685
|
+
const createdAt = created && new Date(/(?:Z|[+-]\d\d:?\d\d)$/.test(created) ? created : created + 'Z');
|
|
2686
|
+
if (createdAt && !isNaN(createdAt.getTime()))
|
|
2687
|
+
properties.created = createdAt;
|
|
2395
2688
|
return { sheets, definedNames, properties };
|
|
2396
2689
|
}
|
|
2397
2690
|
// Frozen panes are view settings, kept in settings.xml per sheet
|
|
@@ -2640,9 +2933,7 @@ function parseOds(content, readText, options) {
|
|
|
2640
2933
|
return new ParseResult(rows, metadata, async () => [], async () => { await finished; return comments; });
|
|
2641
2934
|
}
|
|
2642
2935
|
|
|
2643
|
-
|
|
2644
|
-
return String(val).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2645
|
-
}
|
|
2936
|
+
const escapeXml$3 = (val) => escapeXml$4(String(val));
|
|
2646
2937
|
// Child elements of a <font> (styles) or, with nameTag "rFont", of a rich text run's <rPr>
|
|
2647
2938
|
function fontXml(font, nameTag = 'name') {
|
|
2648
2939
|
let xml = '';
|
|
@@ -2837,9 +3128,7 @@ ${this.dxfs.size ? `<dxfs count="${this.dxfs.size}">${[...this.dxfs.keys()].map(
|
|
|
2837
3128
|
}
|
|
2838
3129
|
}
|
|
2839
3130
|
|
|
2840
|
-
|
|
2841
|
-
return String(val).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2842
|
-
}
|
|
3131
|
+
const escapeXml$2 = (val) => escapeXml$4(String(val));
|
|
2843
3132
|
const color = (rgb) => `<color rgb="${argb(rgb)}"/>`;
|
|
2844
3133
|
const bound = (type, value) => value === undefined ? `<cfvo type="${type}"/>` : `<cfvo type="num" val="${escapeXml$2(value)}"/>`;
|
|
2845
3134
|
const formula = (f) => `<formula>${escapeXml$2(String(f).replace(/^=/, ''))}</formula>`;
|
|
@@ -2911,10 +3200,32 @@ class ConditionalFormatter {
|
|
|
2911
3200
|
}
|
|
2912
3201
|
}
|
|
2913
3202
|
|
|
3203
|
+
// An Excel error value (#DIV/0!, #VALUE!), kept apart from text that happens to start with "#"
|
|
3204
|
+
class FormulaError {
|
|
3205
|
+
code;
|
|
3206
|
+
constructor(code) {
|
|
3207
|
+
this.code = code;
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
// Syntax or functions this engine doesn't know: no cached value, Excel computes it on open
|
|
3211
|
+
class Unsupported extends Error {
|
|
3212
|
+
}
|
|
3213
|
+
// A formula that reads its own cell, directly or through others
|
|
3214
|
+
class Circular extends Error {
|
|
3215
|
+
}
|
|
3216
|
+
const DIV0 = new FormulaError('#DIV/0!');
|
|
3217
|
+
const VALUE = new FormulaError('#VALUE!');
|
|
3218
|
+
// Numbers turned into text keep Excel's 15 significant digits ("0.333333333333333", 1E+21)
|
|
3219
|
+
function numberText(n) {
|
|
3220
|
+
return String(Number(n.toPrecision(15))).replace('e', 'E');
|
|
3221
|
+
}
|
|
2914
3222
|
class FormulaEngine {
|
|
2915
3223
|
cells = new Map();
|
|
3224
|
+
results = new Map();
|
|
3225
|
+
evaluating = new Set();
|
|
2916
3226
|
clear() {
|
|
2917
3227
|
this.cells.clear();
|
|
3228
|
+
this.results.clear();
|
|
2918
3229
|
}
|
|
2919
3230
|
loadData(data, startRow = 1) {
|
|
2920
3231
|
data.forEach((row, ri) => {
|
|
@@ -2924,25 +3235,73 @@ class FormulaEngine {
|
|
|
2924
3235
|
});
|
|
2925
3236
|
});
|
|
2926
3237
|
}
|
|
2927
|
-
|
|
3238
|
+
// Errors come back as their code ("#DIV/0!")
|
|
3239
|
+
evaluate(formula) {
|
|
3240
|
+
const v = this.evaluateRaw(formula);
|
|
3241
|
+
return v instanceof FormulaError ? v.code : v;
|
|
3242
|
+
}
|
|
3243
|
+
// Like evaluate, but errors stay FormulaError so text such as "#1 pick" is told apart from them
|
|
3244
|
+
evaluateRaw(formula) {
|
|
3245
|
+
try {
|
|
3246
|
+
return this.run(formula);
|
|
3247
|
+
}
|
|
3248
|
+
catch (err) {
|
|
3249
|
+
// A circular reference is stored as 0, as Excel does
|
|
3250
|
+
if (err instanceof Circular)
|
|
3251
|
+
return 0;
|
|
3252
|
+
return null;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
// The value of a loaded cell, computing its formula (and the formulas it reads) when it has one
|
|
3256
|
+
cellValue(ref) {
|
|
3257
|
+
try {
|
|
3258
|
+
return this.lookup(ref.toUpperCase());
|
|
3259
|
+
}
|
|
3260
|
+
catch (err) {
|
|
3261
|
+
if (err instanceof Circular)
|
|
3262
|
+
return 0;
|
|
3263
|
+
return null;
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
run(formula) {
|
|
3267
|
+
const clean = formula.replace(/^=/, '').trim();
|
|
3268
|
+
if (!clean)
|
|
3269
|
+
return null;
|
|
3270
|
+
// A formula is never blank: =A1 over an empty cell is 0
|
|
3271
|
+
return this.scalar(this.parse(this.tokenize(clean))) ?? 0;
|
|
3272
|
+
}
|
|
3273
|
+
lookup(ref) {
|
|
3274
|
+
const cell = this.cells.get(ref);
|
|
3275
|
+
if (cell === null || typeof cell !== 'object')
|
|
3276
|
+
return cell ?? null;
|
|
3277
|
+
if (this.results.has(ref)) {
|
|
3278
|
+
const known = this.results.get(ref);
|
|
3279
|
+
if (known === undefined)
|
|
3280
|
+
throw new Unsupported();
|
|
3281
|
+
return known;
|
|
3282
|
+
}
|
|
3283
|
+
if (this.evaluating.has(ref))
|
|
3284
|
+
throw new Circular();
|
|
3285
|
+
this.evaluating.add(ref);
|
|
2928
3286
|
try {
|
|
2929
|
-
const
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
const tokens = this.tokenize(clean);
|
|
2933
|
-
const ast = this.parse(tokens);
|
|
2934
|
-
return this.evaluateAst(ast);
|
|
3287
|
+
const v = this.run(cell.formula);
|
|
3288
|
+
this.results.set(ref, v);
|
|
3289
|
+
return v;
|
|
2935
3290
|
}
|
|
2936
|
-
catch {
|
|
2937
|
-
|
|
2938
|
-
|
|
3291
|
+
catch (err) {
|
|
3292
|
+
if (!(err instanceof Circular))
|
|
3293
|
+
this.results.set(ref, undefined);
|
|
3294
|
+
throw err;
|
|
3295
|
+
}
|
|
3296
|
+
finally {
|
|
3297
|
+
this.evaluating.delete(ref);
|
|
2939
3298
|
}
|
|
2940
3299
|
}
|
|
2941
3300
|
tokenize(expr) {
|
|
2942
3301
|
const tokens = [];
|
|
2943
3302
|
let i = 0;
|
|
2944
3303
|
while (i < expr.length) {
|
|
2945
|
-
|
|
3304
|
+
const char = expr[i];
|
|
2946
3305
|
if (/\s/.test(char)) {
|
|
2947
3306
|
i++;
|
|
2948
3307
|
continue;
|
|
@@ -2962,7 +3321,7 @@ class FormulaEngine {
|
|
|
2962
3321
|
i++;
|
|
2963
3322
|
continue;
|
|
2964
3323
|
}
|
|
2965
|
-
if (/[
|
|
3324
|
+
if (/[+\-*/^<>=%&]/.test(char)) {
|
|
2966
3325
|
let op = char;
|
|
2967
3326
|
if (char === '<' || char === '>') {
|
|
2968
3327
|
if (expr[i + 1] === '=') {
|
|
@@ -2978,29 +3337,34 @@ class FormulaEngine {
|
|
|
2978
3337
|
i++;
|
|
2979
3338
|
continue;
|
|
2980
3339
|
}
|
|
2981
|
-
if (char === '"'
|
|
2982
|
-
|
|
3340
|
+
if (char === '"') {
|
|
3341
|
+
// "" inside a string is one quote
|
|
2983
3342
|
let str = '';
|
|
2984
3343
|
i++;
|
|
2985
|
-
|
|
3344
|
+
for (;;) {
|
|
3345
|
+
if (i >= expr.length)
|
|
3346
|
+
throw new Unsupported();
|
|
3347
|
+
if (expr[i] === '"') {
|
|
3348
|
+
if (expr[i + 1] !== '"')
|
|
3349
|
+
break;
|
|
3350
|
+
i++;
|
|
3351
|
+
}
|
|
2986
3352
|
str += expr[i++];
|
|
2987
3353
|
}
|
|
2988
3354
|
i++;
|
|
2989
3355
|
tokens.push({ type: 'STRING', value: str });
|
|
2990
3356
|
continue;
|
|
2991
3357
|
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
}
|
|
2997
|
-
tokens.push({ type: 'NUMBER', value: num });
|
|
3358
|
+
const num = /^(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/.exec(expr.slice(i));
|
|
3359
|
+
if (num) {
|
|
3360
|
+
tokens.push({ type: 'NUMBER', value: num[0] });
|
|
3361
|
+
i += num[0].length;
|
|
2998
3362
|
continue;
|
|
2999
3363
|
}
|
|
3000
3364
|
if (/[A-Za-z$]/.test(char)) {
|
|
3001
3365
|
// "$" only pins a reference when copied; it does not change what it points at
|
|
3002
3366
|
let id = '';
|
|
3003
|
-
while (i < expr.length && /[A-Za-z0-9$]/.test(expr[i])) {
|
|
3367
|
+
while (i < expr.length && /[A-Za-z0-9$_.]/.test(expr[i])) {
|
|
3004
3368
|
id += expr[i++];
|
|
3005
3369
|
}
|
|
3006
3370
|
id = id.replace(/\$/g, '');
|
|
@@ -3011,9 +3375,11 @@ class FormulaEngine {
|
|
|
3011
3375
|
endId += expr[i++];
|
|
3012
3376
|
}
|
|
3013
3377
|
endId = endId.replace(/\$/g, '');
|
|
3378
|
+
if (!/^[A-Z]{1,3}\d+$/i.test(id) || !/^[A-Z]{1,3}\d+$/i.test(endId))
|
|
3379
|
+
throw new Unsupported();
|
|
3014
3380
|
tokens.push({ type: 'RANGE', value: id.toUpperCase() + ':' + endId.toUpperCase() });
|
|
3015
3381
|
}
|
|
3016
|
-
else if (/^[A-Z]
|
|
3382
|
+
else if (/^[A-Z]{1,3}\d+$/i.test(id)) {
|
|
3017
3383
|
tokens.push({ type: 'CELL', value: id.toUpperCase() });
|
|
3018
3384
|
}
|
|
3019
3385
|
else {
|
|
@@ -3021,16 +3387,23 @@ class FormulaEngine {
|
|
|
3021
3387
|
}
|
|
3022
3388
|
continue;
|
|
3023
3389
|
}
|
|
3024
|
-
|
|
3390
|
+
// Sheet references ('Q1'!A1, Data!A1), arrays, error literals and the rest
|
|
3391
|
+
throw new Unsupported();
|
|
3025
3392
|
}
|
|
3026
3393
|
return tokens;
|
|
3027
3394
|
}
|
|
3028
3395
|
parse(tokens) {
|
|
3029
3396
|
let pos = 0;
|
|
3030
|
-
const
|
|
3397
|
+
const isOp = (...ops) => pos < tokens.length && tokens[pos].type === 'OP' && ops.includes(tokens[pos].value);
|
|
3398
|
+
const expect = (type) => {
|
|
3399
|
+
if (tokens[pos]?.type !== type)
|
|
3400
|
+
throw new Unsupported();
|
|
3401
|
+
pos++;
|
|
3402
|
+
};
|
|
3403
|
+
const parseAtom = () => {
|
|
3031
3404
|
const token = tokens[pos];
|
|
3032
3405
|
if (!token)
|
|
3033
|
-
throw new
|
|
3406
|
+
throw new Unsupported();
|
|
3034
3407
|
if (token.type === 'NUMBER') {
|
|
3035
3408
|
pos++;
|
|
3036
3409
|
return { type: 'NUMBER', value: parseFloat(token.value) };
|
|
@@ -3050,146 +3423,188 @@ class FormulaEngine {
|
|
|
3050
3423
|
if (token.type === 'IDENTIFIER') {
|
|
3051
3424
|
const name = token.value;
|
|
3052
3425
|
pos++;
|
|
3053
|
-
if (
|
|
3054
|
-
pos++;
|
|
3426
|
+
if (tokens[pos]?.type === 'PAREN_L') {
|
|
3427
|
+
pos++;
|
|
3055
3428
|
const args = [];
|
|
3056
|
-
if (tokens[pos]
|
|
3429
|
+
if (tokens[pos]?.type !== 'PAREN_R') {
|
|
3057
3430
|
args.push(parseExpression());
|
|
3058
|
-
while (
|
|
3431
|
+
while (tokens[pos]?.type === 'COMMA') {
|
|
3059
3432
|
pos++;
|
|
3060
3433
|
args.push(parseExpression());
|
|
3061
3434
|
}
|
|
3062
3435
|
}
|
|
3063
|
-
|
|
3064
|
-
throw new Error('Expected )');
|
|
3065
|
-
pos++;
|
|
3436
|
+
expect('PAREN_R');
|
|
3066
3437
|
return { type: 'CALL', name, args };
|
|
3067
3438
|
}
|
|
3068
|
-
// Handle true/false constants
|
|
3069
3439
|
if (name === 'TRUE')
|
|
3070
|
-
return { type: '
|
|
3440
|
+
return { type: 'BOOL', value: true };
|
|
3071
3441
|
if (name === 'FALSE')
|
|
3072
|
-
return { type: '
|
|
3073
|
-
throw new
|
|
3442
|
+
return { type: 'BOOL', value: false };
|
|
3443
|
+
throw new Unsupported(); // defined names
|
|
3074
3444
|
}
|
|
3075
3445
|
if (token.type === 'PAREN_L') {
|
|
3076
3446
|
pos++;
|
|
3077
3447
|
const node = parseExpression();
|
|
3078
|
-
|
|
3079
|
-
throw new Error('Expected )');
|
|
3080
|
-
pos++;
|
|
3448
|
+
expect('PAREN_R');
|
|
3081
3449
|
return node;
|
|
3082
3450
|
}
|
|
3083
|
-
throw new
|
|
3451
|
+
throw new Unsupported();
|
|
3084
3452
|
};
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
const op = tokens[pos].value;
|
|
3453
|
+
// Unary minus binds tighter than ^ in Excel (-2^2 is 4); % divides by 100
|
|
3454
|
+
const parseUnary = () => {
|
|
3455
|
+
if (isOp('-')) {
|
|
3089
3456
|
pos++;
|
|
3090
|
-
|
|
3457
|
+
return { type: 'NEG', left: parseUnary() };
|
|
3091
3458
|
}
|
|
3092
|
-
|
|
3093
|
-
};
|
|
3094
|
-
const parseFactor = () => {
|
|
3095
|
-
let node = parsePower();
|
|
3096
|
-
while (pos < tokens.length && (tokens[pos].value === '*' || tokens[pos].value === '/')) {
|
|
3097
|
-
const op = tokens[pos].value;
|
|
3459
|
+
if (isOp('+')) {
|
|
3098
3460
|
pos++;
|
|
3099
|
-
|
|
3461
|
+
return parseUnary();
|
|
3100
3462
|
}
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
const parseTerm = () => {
|
|
3104
|
-
let node = parseFactor();
|
|
3105
|
-
while (pos < tokens.length && (tokens[pos].value === '+' || tokens[pos].value === '-')) {
|
|
3106
|
-
const op = tokens[pos].value;
|
|
3463
|
+
let node = parseAtom();
|
|
3464
|
+
while (isOp('%')) {
|
|
3107
3465
|
pos++;
|
|
3108
|
-
node = { type: '
|
|
3466
|
+
node = { type: 'PERCENT', left: node };
|
|
3109
3467
|
}
|
|
3110
3468
|
return node;
|
|
3111
3469
|
};
|
|
3112
|
-
const
|
|
3113
|
-
let node =
|
|
3114
|
-
while (
|
|
3115
|
-
const
|
|
3116
|
-
|
|
3117
|
-
node = { type: 'BINARY', operator: op, left: node, right: parseTerm() };
|
|
3470
|
+
const binary = (next, ...ops) => () => {
|
|
3471
|
+
let node = next();
|
|
3472
|
+
while (isOp(...ops)) {
|
|
3473
|
+
const operator = tokens[pos++].value;
|
|
3474
|
+
node = { type: 'BINARY', operator, left: node, right: next() };
|
|
3118
3475
|
}
|
|
3119
3476
|
return node;
|
|
3120
3477
|
};
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3478
|
+
// Excel's ^ is left-associative: 2^3^2 is 64
|
|
3479
|
+
const parsePower = binary(parseUnary, '^');
|
|
3480
|
+
const parseFactor = binary(parsePower, '*', '/');
|
|
3481
|
+
const parseTerm = binary(parseFactor, '+', '-');
|
|
3482
|
+
const parseConcat = binary(parseTerm, '&');
|
|
3483
|
+
const parseExpression = binary(parseConcat, '=', '<>', '<', '>', '<=', '>=');
|
|
3484
|
+
const ast = parseExpression();
|
|
3485
|
+
if (pos !== tokens.length)
|
|
3486
|
+
throw new Unsupported();
|
|
3487
|
+
return ast;
|
|
3488
|
+
}
|
|
3489
|
+
// A single value: a range used where one value is expected is not supported (implicit intersection)
|
|
3490
|
+
scalar(node) {
|
|
3491
|
+
const v = this.evaluateAst(node);
|
|
3492
|
+
if (Array.isArray(v))
|
|
3493
|
+
throw new Unsupported();
|
|
3494
|
+
return v;
|
|
3125
3495
|
}
|
|
3126
3496
|
evaluateAst(node) {
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
return node.value;
|
|
3131
|
-
|
|
3132
|
-
return this.
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3497
|
+
switch (node.type) {
|
|
3498
|
+
case 'NUMBER':
|
|
3499
|
+
case 'STRING':
|
|
3500
|
+
case 'BOOL': return node.value;
|
|
3501
|
+
case 'CELL': return this.lookup(node.value);
|
|
3502
|
+
case 'RANGE': return this.getRangeValues(node.value);
|
|
3503
|
+
case 'NEG': {
|
|
3504
|
+
const n = toNumber(this.scalar(node.left));
|
|
3505
|
+
return n instanceof FormulaError ? n : -n;
|
|
3506
|
+
}
|
|
3507
|
+
case 'PERCENT': {
|
|
3508
|
+
const n = toNumber(this.scalar(node.left));
|
|
3509
|
+
return n instanceof FormulaError ? n : n / 100;
|
|
3510
|
+
}
|
|
3511
|
+
case 'BINARY': return this.binary(node.operator, this.scalar(node.left), this.scalar(node.right));
|
|
3512
|
+
case 'CALL': return this.call(node.name, node.args);
|
|
3513
|
+
}
|
|
3514
|
+
throw new Unsupported();
|
|
3515
|
+
}
|
|
3516
|
+
binary(op, left, right) {
|
|
3517
|
+
if (left instanceof FormulaError)
|
|
3518
|
+
return left;
|
|
3519
|
+
if (right instanceof FormulaError)
|
|
3520
|
+
return right;
|
|
3521
|
+
if (op === '&')
|
|
3522
|
+
return toText(left) + toText(right);
|
|
3523
|
+
if (['=', '<>', '<', '>', '<=', '>='].includes(op)) {
|
|
3524
|
+
const c = compare(left, right);
|
|
3525
|
+
return op === '=' ? c === 0 : op === '<>' ? c !== 0 : op === '<' ? c < 0 : op === '>' ? c > 0 : op === '<=' ? c <= 0 : c >= 0;
|
|
3526
|
+
}
|
|
3527
|
+
const l = toNumber(left), r = toNumber(right);
|
|
3528
|
+
if (l instanceof FormulaError)
|
|
3529
|
+
return l;
|
|
3530
|
+
if (r instanceof FormulaError)
|
|
3531
|
+
return r;
|
|
3532
|
+
switch (op) {
|
|
3533
|
+
case '+': return l + r;
|
|
3534
|
+
case '-': return l - r;
|
|
3535
|
+
case '*': return l * r;
|
|
3536
|
+
case '/': return r === 0 ? DIV0 : l / r;
|
|
3537
|
+
case '^': {
|
|
3538
|
+
const p = Math.pow(l, r);
|
|
3539
|
+
return isFinite(p) ? p : new FormulaError('#NUM!');
|
|
3152
3540
|
}
|
|
3153
3541
|
}
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3542
|
+
throw new Unsupported();
|
|
3543
|
+
}
|
|
3544
|
+
call(name, argNodes) {
|
|
3545
|
+
if (name === 'IF') {
|
|
3546
|
+
if (argNodes.length < 1 || argNodes.length > 3)
|
|
3547
|
+
throw new Unsupported();
|
|
3548
|
+
const cond = toBool(this.scalar(argNodes[0]));
|
|
3549
|
+
if (cond instanceof FormulaError)
|
|
3550
|
+
return cond;
|
|
3551
|
+
if (cond)
|
|
3552
|
+
return argNodes.length > 1 ? this.scalar(argNodes[1]) ?? 0 : true;
|
|
3553
|
+
return argNodes.length > 2 ? this.scalar(argNodes[2]) ?? 0 : false;
|
|
3554
|
+
}
|
|
3555
|
+
if (name === 'CONCATENATE') {
|
|
3556
|
+
let out = '';
|
|
3557
|
+
for (const a of argNodes) {
|
|
3558
|
+
const v = this.scalar(a);
|
|
3559
|
+
if (v instanceof FormulaError)
|
|
3560
|
+
return v;
|
|
3561
|
+
out += toText(v);
|
|
3562
|
+
}
|
|
3563
|
+
return out;
|
|
3564
|
+
}
|
|
3565
|
+
if (!['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN'].includes(name))
|
|
3566
|
+
throw new Unsupported();
|
|
3567
|
+
// Values typed into the call count (TRUE as 1, "3" as 3); in referenced cells only numbers do
|
|
3568
|
+
const nums = [];
|
|
3569
|
+
for (const a of argNodes) {
|
|
3570
|
+
const v = this.evaluateAst(a);
|
|
3571
|
+
const referenced = a.type === 'CELL' || a.type === 'RANGE';
|
|
3572
|
+
for (const x of Array.isArray(v) ? v : [v]) {
|
|
3573
|
+
if (x instanceof FormulaError) {
|
|
3574
|
+
if (name === 'COUNT')
|
|
3575
|
+
continue;
|
|
3576
|
+
return x;
|
|
3166
3577
|
}
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3578
|
+
if (typeof x === 'number')
|
|
3579
|
+
nums.push(x);
|
|
3580
|
+
else if (!referenced && x !== null) {
|
|
3581
|
+
const n = toNumber(x);
|
|
3582
|
+
if (n instanceof FormulaError) {
|
|
3583
|
+
if (name === 'COUNT')
|
|
3584
|
+
continue;
|
|
3585
|
+
return n;
|
|
3586
|
+
}
|
|
3587
|
+
nums.push(n);
|
|
3170
3588
|
}
|
|
3171
|
-
case 'IF': return args[0] ? args[1] : args[2];
|
|
3172
|
-
case 'CONCATENATE': return this.flatten(args).join('');
|
|
3173
3589
|
}
|
|
3174
3590
|
}
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
return arr.flat(Infinity);
|
|
3591
|
+
switch (name) {
|
|
3592
|
+
case 'SUM': return nums.reduce((a, b) => a + b, 0);
|
|
3593
|
+
case 'COUNT': return nums.length;
|
|
3594
|
+
case 'AVERAGE': return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : DIV0;
|
|
3595
|
+
case 'MAX': return nums.length ? Math.max(...nums) : 0;
|
|
3596
|
+
default: return nums.length ? Math.min(...nums) : 0;
|
|
3597
|
+
}
|
|
3183
3598
|
}
|
|
3184
3599
|
getRangeValues(range) {
|
|
3185
3600
|
const [startRef, endRef] = range.split(':');
|
|
3186
|
-
const [
|
|
3187
|
-
const [
|
|
3601
|
+
const [c1, r1] = this.parseRef(startRef);
|
|
3602
|
+
const [c2, r2] = this.parseRef(endRef);
|
|
3188
3603
|
const values = [];
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
values.push(this.
|
|
3604
|
+
// A3:A1 is the same range as A1:A3
|
|
3605
|
+
for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
|
|
3606
|
+
for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
|
|
3607
|
+
values.push(this.lookup(this.toRef(r, c - 1)));
|
|
3193
3608
|
}
|
|
3194
3609
|
}
|
|
3195
3610
|
return values;
|
|
@@ -3210,185 +3625,72 @@ class FormulaEngine {
|
|
|
3210
3625
|
return `${col}${row}`;
|
|
3211
3626
|
}
|
|
3212
3627
|
}
|
|
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
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
async addFile(filenameStr, inputStream) {
|
|
3264
|
-
const filename = this.textEncoder.encode(filenameStr);
|
|
3265
|
-
const startOffset = this.offset;
|
|
3266
|
-
await this.pushChunk(this.localHeader(filename, 0x0008, 8, 0, 0, 0));
|
|
3267
|
-
// Stream data, tracking sizes and CRC32
|
|
3268
|
-
let uncompressedSize = 0;
|
|
3269
|
-
let crc = 0xffffffff;
|
|
3270
|
-
const crcStream = new TransformStream({
|
|
3271
|
-
transform: (chunk, controller) => {
|
|
3272
|
-
uncompressedSize += chunk.length;
|
|
3273
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
3274
|
-
crc = CRC_TABLE[(crc ^ chunk[i]) & 0xff] ^ (crc >>> 8);
|
|
3275
|
-
}
|
|
3276
|
-
controller.enqueue(chunk);
|
|
3277
|
-
}
|
|
3278
|
-
});
|
|
3279
|
-
let compressedSize = 0;
|
|
3280
|
-
const reader = inputStream
|
|
3281
|
-
.pipeThrough(crcStream)
|
|
3282
|
-
.pipeThrough(new CompressionStream('deflate-raw'))
|
|
3283
|
-
.getReader();
|
|
3284
|
-
while (true) {
|
|
3285
|
-
const { done, value } = await reader.read();
|
|
3286
|
-
if (done)
|
|
3287
|
-
break;
|
|
3288
|
-
compressedSize += value.length;
|
|
3289
|
-
await this.pushChunk(value);
|
|
3290
|
-
}
|
|
3291
|
-
crc = (crc ^ 0xffffffff) >>> 0;
|
|
3292
|
-
assertNoZip64(uncompressedSize, filenameStr);
|
|
3293
|
-
assertNoZip64(compressedSize, filenameStr);
|
|
3294
|
-
// Data Descriptor
|
|
3295
|
-
const desc = new Uint8Array(16);
|
|
3296
|
-
const descView = new DataView(desc.buffer);
|
|
3297
|
-
descView.setUint32(0, 0x08074b50, true);
|
|
3298
|
-
descView.setUint32(4, crc, true);
|
|
3299
|
-
descView.setUint32(8, compressedSize, true);
|
|
3300
|
-
descView.setUint32(12, uncompressedSize, true);
|
|
3301
|
-
await this.pushChunk(desc);
|
|
3302
|
-
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags: 0x0008, method: 8 });
|
|
3303
|
-
}
|
|
3304
|
-
// Adds a file that is ALREADY compressed (pass-through for the Editor)
|
|
3305
|
-
async addCompressedFile(filenameStr, compressedStream, uncompressedSize, compressedSize, crc, method = 8) {
|
|
3306
|
-
const filename = this.textEncoder.encode(filenameStr);
|
|
3307
|
-
const startOffset = this.offset;
|
|
3308
|
-
await this.pushChunk(this.localHeader(filename, 0, method, crc, compressedSize, uncompressedSize));
|
|
3309
|
-
const reader = compressedStream.getReader();
|
|
3310
|
-
while (true) {
|
|
3311
|
-
const { done, value } = await reader.read();
|
|
3312
|
-
if (done)
|
|
3313
|
-
break;
|
|
3314
|
-
await this.pushChunk(value);
|
|
3315
|
-
}
|
|
3316
|
-
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags: 0, method });
|
|
3317
|
-
}
|
|
3318
|
-
async close() {
|
|
3319
|
-
if (this.cdEntries.length > 0xffff)
|
|
3320
|
-
throw new Error('ZIP64 not supported: more than 65535 entries.');
|
|
3321
|
-
const cdStartOffset = this.offset;
|
|
3322
|
-
for (const entry of this.cdEntries) {
|
|
3323
|
-
const cd = new Uint8Array(46 + entry.filename.length);
|
|
3324
|
-
const view = new DataView(cd.buffer);
|
|
3325
|
-
view.setUint32(0, 0x02014b50, true);
|
|
3326
|
-
view.setUint16(4, 20, true); // version made by
|
|
3327
|
-
view.setUint16(6, 20, true); // version needed
|
|
3328
|
-
view.setUint16(8, entry.flags, true);
|
|
3329
|
-
view.setUint16(10, entry.method, true);
|
|
3330
|
-
view.setUint32(16, entry.crc, true);
|
|
3331
|
-
view.setUint32(20, entry.compressedSize, true);
|
|
3332
|
-
view.setUint32(24, entry.uncompressedSize, true);
|
|
3333
|
-
view.setUint16(28, entry.filename.length, true);
|
|
3334
|
-
view.setUint32(42, entry.offset, true);
|
|
3335
|
-
cd.set(entry.filename, 46);
|
|
3336
|
-
await this.pushChunk(cd);
|
|
3337
|
-
}
|
|
3338
|
-
const cdSize = this.offset - cdStartOffset;
|
|
3339
|
-
assertNoZip64(this.offset, 'archive');
|
|
3340
|
-
const eocd = new Uint8Array(22);
|
|
3341
|
-
const eocdView = new DataView(eocd.buffer);
|
|
3342
|
-
eocdView.setUint32(0, 0x06054b50, true);
|
|
3343
|
-
eocdView.setUint16(8, this.cdEntries.length, true);
|
|
3344
|
-
eocdView.setUint16(10, this.cdEntries.length, true);
|
|
3345
|
-
eocdView.setUint32(12, cdSize, true);
|
|
3346
|
-
eocdView.setUint32(16, cdStartOffset, true);
|
|
3347
|
-
await this.pushChunk(eocd);
|
|
3348
|
-
this.streamController.close();
|
|
3349
|
-
}
|
|
3350
|
-
// Propagate a producer failure to whoever is reading `stream`.
|
|
3351
|
-
error(err) {
|
|
3352
|
-
try {
|
|
3353
|
-
this.streamController.error(err);
|
|
3354
|
-
}
|
|
3355
|
-
catch { /* already closed/errored */ }
|
|
3356
|
-
}
|
|
3357
|
-
localHeader(filename, flags, method, crc, compressedSize, uncompressedSize) {
|
|
3358
|
-
assertNoZip64(this.offset, 'archive');
|
|
3359
|
-
const header = new Uint8Array(30 + filename.length);
|
|
3360
|
-
const view = new DataView(header.buffer);
|
|
3361
|
-
view.setUint32(0, 0x04034b50, true);
|
|
3362
|
-
view.setUint16(4, 20, true);
|
|
3363
|
-
view.setUint16(6, flags, true);
|
|
3364
|
-
view.setUint16(8, method, true);
|
|
3365
|
-
view.setUint32(14, crc, true);
|
|
3366
|
-
view.setUint32(18, compressedSize, true);
|
|
3367
|
-
view.setUint32(22, uncompressedSize, true);
|
|
3368
|
-
view.setUint16(26, filename.length, true);
|
|
3369
|
-
header.set(filename, 30);
|
|
3370
|
-
return header;
|
|
3371
|
-
}
|
|
3372
|
-
// Enqueue and wait while the consumer's queue is full, so memory stays bounded.
|
|
3373
|
-
async pushChunk(chunk) {
|
|
3374
|
-
this.streamController.enqueue(chunk);
|
|
3375
|
-
this.offset += chunk.length;
|
|
3376
|
-
while ((this.streamController.desiredSize ?? 1) <= 0) {
|
|
3377
|
-
if (this.cancelled)
|
|
3378
|
-
throw new Error('ZIP stream cancelled by consumer.');
|
|
3379
|
-
await new Promise(resolve => { this.resumePull = resolve; });
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3628
|
+
// Excel's conversions: blank is 0 / "" / FALSE, booleans are 1 and 0, text must read as a number
|
|
3629
|
+
function toNumber(v) {
|
|
3630
|
+
if (v instanceof FormulaError)
|
|
3631
|
+
return v;
|
|
3632
|
+
if (v === null)
|
|
3633
|
+
return 0;
|
|
3634
|
+
if (typeof v === 'boolean')
|
|
3635
|
+
return v ? 1 : 0;
|
|
3636
|
+
if (typeof v === 'number')
|
|
3637
|
+
return v;
|
|
3638
|
+
const t = v.trim();
|
|
3639
|
+
const n = t === '' ? NaN : Number(t);
|
|
3640
|
+
return isFinite(n) ? n : VALUE;
|
|
3641
|
+
}
|
|
3642
|
+
function toText(v) {
|
|
3643
|
+
if (v === null)
|
|
3644
|
+
return '';
|
|
3645
|
+
if (typeof v === 'boolean')
|
|
3646
|
+
return v ? 'TRUE' : 'FALSE';
|
|
3647
|
+
if (typeof v === 'number')
|
|
3648
|
+
return numberText(v);
|
|
3649
|
+
return String(v);
|
|
3650
|
+
}
|
|
3651
|
+
function toBool(v) {
|
|
3652
|
+
if (v instanceof FormulaError)
|
|
3653
|
+
return v;
|
|
3654
|
+
if (v === null)
|
|
3655
|
+
return false;
|
|
3656
|
+
if (typeof v === 'boolean')
|
|
3657
|
+
return v;
|
|
3658
|
+
if (typeof v === 'number')
|
|
3659
|
+
return v !== 0;
|
|
3660
|
+
const t = v.toUpperCase();
|
|
3661
|
+
return t === 'TRUE' ? true : t === 'FALSE' ? false : VALUE;
|
|
3662
|
+
}
|
|
3663
|
+
// Excel orders numbers < text < booleans; text compares without case. A blank takes the other side's type.
|
|
3664
|
+
function compare(a, b) {
|
|
3665
|
+
if (a === null)
|
|
3666
|
+
a = typeof b === 'string' ? '' : typeof b === 'boolean' ? false : 0;
|
|
3667
|
+
if (b === null)
|
|
3668
|
+
b = typeof a === 'string' ? '' : typeof a === 'boolean' ? false : 0;
|
|
3669
|
+
const rank = (v) => typeof v === 'number' ? 0 : typeof v === 'string' ? 1 : 2;
|
|
3670
|
+
if (rank(a) !== rank(b))
|
|
3671
|
+
return rank(a) - rank(b);
|
|
3672
|
+
if (typeof a === 'string') {
|
|
3673
|
+
const x = a.toLowerCase(), y = b.toLowerCase();
|
|
3674
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
3675
|
+
}
|
|
3676
|
+
const x = Number(a), y = Number(b);
|
|
3677
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
3382
3678
|
}
|
|
3383
3679
|
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3680
|
+
const escapeXml$1 = (val) => escapeXml$4(String(val)).replace(/'/g, ''');
|
|
3681
|
+
// "A1:B2" (or one cell) -> 0-based corners, checked against Excel's sheet size
|
|
3682
|
+
function parseRange$1(ref, what) {
|
|
3683
|
+
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/.exec(String(ref).trim());
|
|
3684
|
+
const box = m && {
|
|
3685
|
+
c1: colIndex(m[1]), r1: parseInt(m[2], 10) - 1,
|
|
3686
|
+
c2: colIndex(m[3] ?? m[1]), r2: parseInt(m[4] ?? m[2], 10) - 1,
|
|
3687
|
+
};
|
|
3688
|
+
if (!box || [box.c1, box.c2].some(c => c >= MAX_COLUMNS$1) || [box.r1, box.r2].some(r => r < 0 || r >= MAX_ROWS$1)) {
|
|
3689
|
+
throw new Error(`Invalid ${what} "${ref}".`);
|
|
3690
|
+
}
|
|
3691
|
+
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) };
|
|
3391
3692
|
}
|
|
3693
|
+
const overlaps = (a, b) => a.c1 <= b.c2 && b.c1 <= a.c2 && a.r1 <= b.r2 && b.r1 <= a.r2;
|
|
3392
3694
|
function isStyledCell$1(v) {
|
|
3393
3695
|
return typeof v === 'object' && v !== null && 'value' in v;
|
|
3394
3696
|
}
|
|
@@ -3429,7 +3731,7 @@ function appPropsXml(p) {
|
|
|
3429
3731
|
}
|
|
3430
3732
|
// A name Excel accepts: letters, digits, _ . and \, not a cell reference (A1, R1C1) and not reserved
|
|
3431
3733
|
function validateDefinedName(name) {
|
|
3432
|
-
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)) {
|
|
3734
|
+
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)) {
|
|
3433
3735
|
throw new Error(`Invalid defined name "${name}"`);
|
|
3434
3736
|
}
|
|
3435
3737
|
}
|
|
@@ -3504,6 +3806,8 @@ class SheetWriter {
|
|
|
3504
3806
|
this.formulaEngine.clear();
|
|
3505
3807
|
if (Array.isArray(sheet.rows)) {
|
|
3506
3808
|
this.formulaEngine.loadData(sheet.rows.map(row => row.map(cell => {
|
|
3809
|
+
if (isStyledCell$1(cell) && cell.formula)
|
|
3810
|
+
return { formula: cell.formula };
|
|
3507
3811
|
const v = !isStyledCell$1(cell) ? cell : cell.richText && cell.value == null ? cell.richText.map(r => r.text).join('') : cell.value;
|
|
3508
3812
|
return v instanceof Date ? dateToSerial(v) : v;
|
|
3509
3813
|
})));
|
|
@@ -3512,7 +3816,7 @@ class SheetWriter {
|
|
|
3512
3816
|
}
|
|
3513
3817
|
const parts = { links: [], comments: [] };
|
|
3514
3818
|
const sheetTables = tables.filter(t => t.sheet === i);
|
|
3515
|
-
await zip.addFile(`xl/worksheets/sheet${i + 1}.xml`, this.buildWorksheetXmlStream(sheet.rows, sheet.options, parts, sheetTables.length));
|
|
3819
|
+
await zip.addFile(`xl/worksheets/sheet${i + 1}.xml`, this.buildWorksheetXmlStream(sheet.rows, sheet.options, parts, sheetTables.length, Array.isArray(sheet.rows)));
|
|
3516
3820
|
const images = sheet.options.images ?? [];
|
|
3517
3821
|
const drawing = images.length ? ++drawings : 0;
|
|
3518
3822
|
const rels = this.buildSheetRels(parts.links, drawing, parts.comments.length ? i + 1 : 0, sheetTables.map(t => t.id));
|
|
@@ -3550,14 +3854,30 @@ class SheetWriter {
|
|
|
3550
3854
|
const plan = [];
|
|
3551
3855
|
const names = new Set();
|
|
3552
3856
|
this.sheets.forEach((sheet, i) => {
|
|
3857
|
+
const boxes = [];
|
|
3553
3858
|
for (const table of sheet.options.tables ?? []) {
|
|
3554
|
-
|
|
3555
|
-
|
|
3859
|
+
// Table names follow the defined-name rules: no "AB12", "R1C1" or "C"
|
|
3860
|
+
let valid = !names.has(String(table.name).toLowerCase());
|
|
3861
|
+
try {
|
|
3862
|
+
validateDefinedName(table.name);
|
|
3556
3863
|
}
|
|
3864
|
+
catch {
|
|
3865
|
+
valid = false;
|
|
3866
|
+
}
|
|
3867
|
+
if (!valid)
|
|
3868
|
+
throw new Error(`Invalid or duplicate table name "${table.name}".`);
|
|
3557
3869
|
names.add(table.name.toLowerCase());
|
|
3558
3870
|
const m = /^([A-Za-z]{1,3})(\d+):([A-Za-z]{1,3})(\d+)$/.exec(table.ref.replace(/\$/g, ''));
|
|
3559
3871
|
if (!m)
|
|
3560
3872
|
throw new Error(`Invalid table range "${table.ref}".`);
|
|
3873
|
+
const box = parseRange$1(table.ref, 'table range');
|
|
3874
|
+
if (boxes.some(b => overlaps(b, box)))
|
|
3875
|
+
throw new Error(`Table "${table.name}" overlaps another table on sheet "${sheet.name}".`);
|
|
3876
|
+
// Excel tables cannot hold merged cells: it repairs the file by removing the table
|
|
3877
|
+
const merge = sheet.options.mergeCells?.find(ref => overlaps(parseRange$1(ref, 'merge range'), box));
|
|
3878
|
+
if (merge)
|
|
3879
|
+
throw new Error(`Merge "${merge}" overlaps table "${table.name}" on sheet "${sheet.name}"; Excel tables cannot contain merged cells.`);
|
|
3880
|
+
boxes.push(box);
|
|
3561
3881
|
const first = colIndex(m[1]);
|
|
3562
3882
|
const width = colIndex(m[3]) - first + 1;
|
|
3563
3883
|
let columns = table.columns;
|
|
@@ -3623,9 +3943,9 @@ class SheetWriter {
|
|
|
3623
3943
|
await zip.addFile(filename, stream);
|
|
3624
3944
|
}
|
|
3625
3945
|
// Pull-based so rows are only generated as fast as the ZIP consumer drains them.
|
|
3626
|
-
buildWorksheetXmlStream(rows, options, parts, tables) {
|
|
3946
|
+
buildWorksheetXmlStream(rows, options, parts, tables, evaluate) {
|
|
3627
3947
|
const encoder = new TextEncoder();
|
|
3628
|
-
const chunks = this.worksheetXmlChunks(rows, options, parts, tables);
|
|
3948
|
+
const chunks = this.worksheetXmlChunks(rows, options, parts, tables, evaluate);
|
|
3629
3949
|
return new ReadableStream({
|
|
3630
3950
|
async pull(controller) {
|
|
3631
3951
|
const { done, value } = await chunks.next();
|
|
@@ -3640,7 +3960,8 @@ class SheetWriter {
|
|
|
3640
3960
|
});
|
|
3641
3961
|
}
|
|
3642
3962
|
// Hyperlink targets and comments are collected in `parts` for the parts written after the sheet
|
|
3643
|
-
|
|
3963
|
+
// `evaluate`: the rows are an array loaded into the formula engine, so formulas get cached results
|
|
3964
|
+
async *worksheetXmlChunks(rows, options, parts, tables, evaluate) {
|
|
3644
3965
|
const links = parts.links;
|
|
3645
3966
|
const hyperlinks = [];
|
|
3646
3967
|
const colCount = Math.max(options.columnWidths?.length ?? 0, options.columns?.length ?? 0);
|
|
@@ -3653,7 +3974,12 @@ class SheetWriter {
|
|
|
3653
3974
|
colWidths += `<col min="${i + 1}" max="${i + 1}"${width !== undefined ? ` width="${escapeXml$1(width)}" customWidth="1"` : ''}` +
|
|
3654
3975
|
`${c.hidden ? ' hidden="1"' : ''}${c.outlineLevel ? ` outlineLevel="${escapeXml$1(c.outlineLevel)}"` : ''}/>`;
|
|
3655
3976
|
}
|
|
3656
|
-
const rowOptions = Object.entries(options.rows ?? {}).map(([r, o]) =>
|
|
3977
|
+
const rowOptions = Object.entries(options.rows ?? {}).map(([r, o]) => {
|
|
3978
|
+
const n = Number(r);
|
|
3979
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ROWS$1)
|
|
3980
|
+
throw new Error(`Row options for "${r}": rows are numbered 1 to ${MAX_ROWS$1}.`);
|
|
3981
|
+
return [n, o];
|
|
3982
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
3657
3983
|
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)}"` : ''}`]));
|
|
3658
3984
|
let nextRowOption = 0;
|
|
3659
3985
|
// Rows that only exist for their options (height, hidden, outline) and hold no cells
|
|
@@ -3702,97 +4028,108 @@ class SheetWriter {
|
|
|
3702
4028
|
: (async function* () { for (const r of rows)
|
|
3703
4029
|
yield r; })();
|
|
3704
4030
|
let chunkStr = '';
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
const
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
const sAttr = style > 0 ? ` s="${style}"` : '';
|
|
3729
|
-
if (styledCell.comment !== undefined) {
|
|
3730
|
-
const comment = typeof styledCell.comment === 'string' ? { text: styledCell.comment } : styledCell.comment;
|
|
3731
|
-
parts.comments.push({ ref: colRef, row: rowNum - 1, col: ci, comment });
|
|
3732
|
-
}
|
|
3733
|
-
if (styledCell.hyperlink) {
|
|
3734
|
-
if (styledCell.hyperlink.startsWith('#')) {
|
|
3735
|
-
hyperlinks.push(`<hyperlink ref="${colRef}" location="${escapeXml$1(styledCell.hyperlink.slice(1))}"/>`);
|
|
4031
|
+
// Ends the caller's row source when the output is cancelled or fails (its finally blocks run)
|
|
4032
|
+
try {
|
|
4033
|
+
while (true) {
|
|
4034
|
+
const { done, value: row } = await iterator.next();
|
|
4035
|
+
if (done)
|
|
4036
|
+
break;
|
|
4037
|
+
const rowNum = ri + 1;
|
|
4038
|
+
// Past Excel's sheet size the file is damaged, so refuse it instead
|
|
4039
|
+
if (rowNum > MAX_ROWS$1)
|
|
4040
|
+
throw new Error(`Row ${rowNum} is past Excel's last row (${MAX_ROWS$1}).`);
|
|
4041
|
+
if (row.length > MAX_COLUMNS$1)
|
|
4042
|
+
throw new Error(`Row ${rowNum} has ${row.length} cells, more than Excel's ${MAX_COLUMNS$1} columns.`);
|
|
4043
|
+
chunkStr += optionRowsBefore(rowNum);
|
|
4044
|
+
if (rowOptions[nextRowOption]?.[0] === rowNum)
|
|
4045
|
+
nextRowOption++;
|
|
4046
|
+
const cellsXml = row.map((cell, ci) => {
|
|
4047
|
+
const colRef = colLetter(ci) + rowNum;
|
|
4048
|
+
const styledCell = isStyledCell$1(cell) ? cell : { value: cell };
|
|
4049
|
+
const val = styledCell.value;
|
|
4050
|
+
let cellStyle = styledCell.style;
|
|
4051
|
+
if (val instanceof Date && !cellStyle?.numFmt) {
|
|
4052
|
+
// A date needs a date format, or Excel shows the bare serial number
|
|
4053
|
+
cellStyle = { ...cellStyle, numFmt: defaultDateFormat(val) };
|
|
3736
4054
|
}
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
4055
|
+
const style = cellStyle ? this.styleEngine.registerStyle(cellStyle) : 0;
|
|
4056
|
+
const sAttr = style > 0 ? ` s="${style}"` : '';
|
|
4057
|
+
if (styledCell.comment !== undefined) {
|
|
4058
|
+
const comment = typeof styledCell.comment === 'string' ? { text: styledCell.comment } : styledCell.comment;
|
|
4059
|
+
parts.comments.push({ ref: colRef, row: rowNum - 1, col: ci, comment });
|
|
3740
4060
|
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
tAttr = ' t="b"';
|
|
3750
|
-
cachedVal = `<v>${result ? 1 : 0}</v>`;
|
|
4061
|
+
if (styledCell.hyperlink) {
|
|
4062
|
+
if (styledCell.hyperlink.startsWith('#')) {
|
|
4063
|
+
hyperlinks.push(`<hyperlink ref="${colRef}" location="${escapeXml$1(styledCell.hyperlink.slice(1))}"/>`);
|
|
4064
|
+
}
|
|
4065
|
+
else {
|
|
4066
|
+
links.push(styledCell.hyperlink);
|
|
4067
|
+
hyperlinks.push(`<hyperlink ref="${colRef}" r:id="rId${links.length}"/>`);
|
|
4068
|
+
}
|
|
3751
4069
|
}
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
4070
|
+
if (styledCell.formula) {
|
|
4071
|
+
// Streamed rows aren't in the engine, so their formulas get no (made-up) cached value
|
|
4072
|
+
const result = evaluate ? this.formulaEngine.cellValue(colRef) : null;
|
|
4073
|
+
let tAttr = '';
|
|
4074
|
+
let cachedVal = '';
|
|
4075
|
+
if (typeof result === 'number' && isFinite(result))
|
|
4076
|
+
cachedVal = `<v>${result}</v>`;
|
|
4077
|
+
else if (typeof result === 'boolean') {
|
|
4078
|
+
tAttr = ' t="b"';
|
|
4079
|
+
cachedVal = `<v>${result ? 1 : 0}</v>`;
|
|
4080
|
+
}
|
|
4081
|
+
else if (result instanceof FormulaError) {
|
|
4082
|
+
tAttr = ' t="e"';
|
|
4083
|
+
cachedVal = `<v>${escapeXml$1(result.code)}</v>`;
|
|
4084
|
+
}
|
|
4085
|
+
else if (typeof result === 'string') {
|
|
4086
|
+
tAttr = ' t="str"';
|
|
4087
|
+
cachedVal = `<v>${escapeXml$1(encodeXString(result))}</v>`;
|
|
4088
|
+
}
|
|
4089
|
+
return `<c r="${colRef}"${tAttr}${sAttr}><f>${escapeXml$1(styledCell.formula.replace(/^=/, ''))}</f>${cachedVal}</c>`;
|
|
3755
4090
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
// Rich text is always inline, even with sharedStrings on
|
|
3760
|
-
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${runsXml(styledCell.richText)}</is></c>`;
|
|
3761
|
-
}
|
|
3762
|
-
if (val === null || val === undefined)
|
|
3763
|
-
return `<c r="${colRef}"${sAttr}/>`;
|
|
3764
|
-
if (typeof val === 'boolean')
|
|
3765
|
-
return `<c r="${colRef}" t="b"${sAttr}><v>${val ? 1 : 0}</v></c>`;
|
|
3766
|
-
if (typeof val === 'number') {
|
|
3767
|
-
// NaN/Infinity have no representation in a cell
|
|
3768
|
-
return isFinite(val) ? `<c r="${colRef}"${sAttr}><v>${val}</v></c>` : `<c r="${colRef}" t="e"${sAttr}><v>#NUM!</v></c>`;
|
|
3769
|
-
}
|
|
3770
|
-
if (val instanceof Date) {
|
|
3771
|
-
if (isNaN(val.getTime()))
|
|
3772
|
-
throw new Error(`Invalid Date in cell ${colRef}`);
|
|
3773
|
-
return `<c r="${colRef}"${sAttr}><v>${dateToSerial(val)}</v></c>`;
|
|
3774
|
-
}
|
|
3775
|
-
if (typeof val === 'string') {
|
|
3776
|
-
checkCellText(val, colRef);
|
|
3777
|
-
if (this.writerOptions.sharedStrings) {
|
|
3778
|
-
let index = this.sharedStrings.get(val);
|
|
3779
|
-
if (index === undefined)
|
|
3780
|
-
this.sharedStrings.set(val, index = this.sharedStrings.size);
|
|
3781
|
-
this.sharedStringRefs++;
|
|
3782
|
-
return `<c r="${colRef}" t="s"${sAttr}><v>${index}</v></c>`;
|
|
4091
|
+
if (styledCell.richText) {
|
|
4092
|
+
// Rich text is always inline, even with sharedStrings on
|
|
4093
|
+
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${runsXml(styledCell.richText)}</is></c>`;
|
|
3783
4094
|
}
|
|
3784
|
-
|
|
4095
|
+
if (val === null || val === undefined)
|
|
4096
|
+
return `<c r="${colRef}"${sAttr}/>`;
|
|
4097
|
+
if (typeof val === 'boolean')
|
|
4098
|
+
return `<c r="${colRef}" t="b"${sAttr}><v>${val ? 1 : 0}</v></c>`;
|
|
4099
|
+
if (typeof val === 'number') {
|
|
4100
|
+
// NaN/Infinity have no representation in a cell
|
|
4101
|
+
return isFinite(val) ? `<c r="${colRef}"${sAttr}><v>${val}</v></c>` : `<c r="${colRef}" t="e"${sAttr}><v>#NUM!</v></c>`;
|
|
4102
|
+
}
|
|
4103
|
+
if (val instanceof Date) {
|
|
4104
|
+
if (isNaN(val.getTime()))
|
|
4105
|
+
throw new Error(`Invalid Date in cell ${colRef}`);
|
|
4106
|
+
return `<c r="${colRef}"${sAttr}><v>${dateToSerial(val)}</v></c>`;
|
|
4107
|
+
}
|
|
4108
|
+
if (typeof val === 'string') {
|
|
4109
|
+
checkCellText(val, colRef);
|
|
4110
|
+
if (this.writerOptions.sharedStrings) {
|
|
4111
|
+
let index = this.sharedStrings.get(val);
|
|
4112
|
+
if (index === undefined)
|
|
4113
|
+
this.sharedStrings.set(val, index = this.sharedStrings.size);
|
|
4114
|
+
this.sharedStringRefs++;
|
|
4115
|
+
return `<c r="${colRef}" t="s"${sAttr}><v>${index}</v></c>`;
|
|
4116
|
+
}
|
|
4117
|
+
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${stringXml(val)}</is></c>`;
|
|
4118
|
+
}
|
|
4119
|
+
return `<c r="${colRef}"${sAttr}/>`;
|
|
4120
|
+
}).join('');
|
|
4121
|
+
chunkStr += ` <row r="${rowNum}"${rowAttrs.get(rowNum) ?? ''}>${cellsXml}</row>\n`;
|
|
4122
|
+
ri++;
|
|
4123
|
+
// Flush every ~64KB of string data to keep O(1) memory while avoiding chunk overhead
|
|
4124
|
+
if (chunkStr.length > 65536) {
|
|
4125
|
+
yield chunkStr;
|
|
4126
|
+
chunkStr = '';
|
|
3785
4127
|
}
|
|
3786
|
-
return `<c r="${colRef}"${sAttr}/>`;
|
|
3787
|
-
}).join('');
|
|
3788
|
-
chunkStr += ` <row r="${rowNum}"${rowAttrs.get(rowNum) ?? ''}>${cellsXml}</row>\n`;
|
|
3789
|
-
ri++;
|
|
3790
|
-
// Flush every ~64KB of string data to keep O(1) memory while avoiding chunk overhead
|
|
3791
|
-
if (chunkStr.length > 65536) {
|
|
3792
|
-
yield chunkStr;
|
|
3793
|
-
chunkStr = '';
|
|
3794
4128
|
}
|
|
3795
4129
|
}
|
|
4130
|
+
finally {
|
|
4131
|
+
await iterator.return?.();
|
|
4132
|
+
}
|
|
3796
4133
|
chunkStr += optionRowsBefore(Infinity);
|
|
3797
4134
|
if (chunkStr.length > 0) {
|
|
3798
4135
|
yield chunkStr;
|
|
@@ -3808,6 +4145,14 @@ class SheetWriter {
|
|
|
3808
4145
|
if (options.autoFilter)
|
|
3809
4146
|
footer += ` <autoFilter ref="${escapeXml$1(options.autoFilter)}"/>\n`;
|
|
3810
4147
|
if (options.mergeCells && options.mergeCells.length > 0) {
|
|
4148
|
+
// Overlapping or malformed merges make Excel repair the file
|
|
4149
|
+
const boxes = [];
|
|
4150
|
+
for (const ref of options.mergeCells) {
|
|
4151
|
+
const box = parseRange$1(ref, 'merge range');
|
|
4152
|
+
if (boxes.some(b => overlaps(b, box)))
|
|
4153
|
+
throw new Error(`Merge "${ref}" overlaps another merge.`);
|
|
4154
|
+
boxes.push(box);
|
|
4155
|
+
}
|
|
3811
4156
|
const merges = options.mergeCells.map(ref => `<mergeCell ref="${escapeXml$1(ref)}"/>`).join('');
|
|
3812
4157
|
footer += ` <mergeCells count="${options.mergeCells.length}">${merges}</mergeCells>\n`;
|
|
3813
4158
|
}
|
|
@@ -3835,11 +4180,16 @@ class SheetWriter {
|
|
|
3835
4180
|
if (dv[k])
|
|
3836
4181
|
attr += ` ${k}="${escapeXml$1(dv[k])}"`;
|
|
3837
4182
|
}
|
|
4183
|
+
// The file stores formulas without "="; a typed list ("a,b,c") holds at most 255 characters
|
|
4184
|
+
const f1 = dv.formula1?.replace(/^=/, ''), f2 = dv.formula2?.replace(/^=/, '');
|
|
4185
|
+
if (dv.type === 'list' && f1?.startsWith('"') && f1.length - 2 > 255) {
|
|
4186
|
+
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.`);
|
|
4187
|
+
}
|
|
3838
4188
|
let inner = '';
|
|
3839
|
-
if (
|
|
3840
|
-
inner += `<formula1>${escapeXml$1(
|
|
3841
|
-
if (
|
|
3842
|
-
inner += `<formula2>${escapeXml$1(
|
|
4189
|
+
if (f1)
|
|
4190
|
+
inner += `<formula1>${escapeXml$1(f1)}</formula1>`;
|
|
4191
|
+
if (f2)
|
|
4192
|
+
inner += `<formula2>${escapeXml$1(f2)}</formula2>`;
|
|
3843
4193
|
return `<dataValidation ${attr}>${inner}</dataValidation>`;
|
|
3844
4194
|
}).join('');
|
|
3845
4195
|
footer += ` <dataValidations count="${options.dataValidations.length}">${dvs}</dataValidations>\n`;
|
|
@@ -3893,8 +4243,10 @@ class SheetWriter {
|
|
|
3893
4243
|
: '').join('') + this.sheets.map((s, i) => {
|
|
3894
4244
|
const page = s.options.pageSetup;
|
|
3895
4245
|
let names = '';
|
|
3896
|
-
|
|
3897
|
-
|
|
4246
|
+
// Every range of a multi-range print area names its sheet
|
|
4247
|
+
const area = page?.printArea?.split(',').map(r => `${quoteSheet(s.name)}!${absoluteRef(r.trim())}`).join(',');
|
|
4248
|
+
if (area)
|
|
4249
|
+
names += `<definedName name="_xlnm.Print_Area" localSheetId="${i}">${escapeXml$1(area)}</definedName>`;
|
|
3898
4250
|
if (page?.printTitleRows) {
|
|
3899
4251
|
const rows = page.printTitleRows.replace(/\$/g, '').split(':').map(r => `$${r}`).join(':');
|
|
3900
4252
|
names += `<definedName name="_xlnm.Print_Titles" localSheetId="${i}">${escapeXml$1(`${quoteSheet(s.name)}!${rows.includes(':') ? rows : `${rows}:${rows}`}`)}</definedName>`;
|
|
@@ -4026,8 +4378,18 @@ function createBlobReader(blob) {
|
|
|
4026
4378
|
return new Uint8Array(await slice.arrayBuffer());
|
|
4027
4379
|
},
|
|
4028
4380
|
stream(offset, length) {
|
|
4029
|
-
|
|
4030
|
-
|
|
4381
|
+
// Bun's Blob.slice(start, end).stream() runs on to the end of the original blob, so stop at length
|
|
4382
|
+
let left = length;
|
|
4383
|
+
return blob.slice(offset, offset + length).stream().pipeThrough(new TransformStream({
|
|
4384
|
+
start(controller) { if (left <= 0)
|
|
4385
|
+
controller.terminate(); },
|
|
4386
|
+
transform(chunk, controller) {
|
|
4387
|
+
controller.enqueue(chunk.length > left ? chunk.subarray(0, left) : chunk);
|
|
4388
|
+
left -= chunk.length;
|
|
4389
|
+
if (left <= 0)
|
|
4390
|
+
controller.terminate();
|
|
4391
|
+
}
|
|
4392
|
+
}));
|
|
4031
4393
|
},
|
|
4032
4394
|
async close() { }
|
|
4033
4395
|
};
|
|
@@ -4072,7 +4434,7 @@ var randomAccess = /*#__PURE__*/Object.freeze({
|
|
|
4072
4434
|
createFileReader: createFileReader
|
|
4073
4435
|
});
|
|
4074
4436
|
|
|
4075
|
-
const escapeAttr =
|
|
4437
|
+
const escapeAttr = escapeXml$4;
|
|
4076
4438
|
const FONT_TAGS = [
|
|
4077
4439
|
['bold', 'b'], ['italic', 'i'], ['underline', 'u'], ['size', 'sz'], ['color', 'color'], ['name', 'name'],
|
|
4078
4440
|
];
|
|
@@ -4103,7 +4465,7 @@ class StylePatcher {
|
|
|
4103
4465
|
this.xml = xml;
|
|
4104
4466
|
this.p = /<((?:\w+:)?)styleSheet\b/.exec(xml)?.[1] ?? '';
|
|
4105
4467
|
this.fonts = this.items('fonts', 'font');
|
|
4106
|
-
this.fills = this.items('fills', 'fill')
|
|
4468
|
+
this.fills = this.items('fills', 'fill');
|
|
4107
4469
|
this.borders = this.items('borders', 'border');
|
|
4108
4470
|
this.xfs = this.items('cellXfs', 'xf');
|
|
4109
4471
|
for (const tag of this.items('numFmts', 'numFmt')) {
|
|
@@ -4125,12 +4487,13 @@ class StylePatcher {
|
|
|
4125
4487
|
if (id !== undefined)
|
|
4126
4488
|
return id;
|
|
4127
4489
|
const p = this.p;
|
|
4128
|
-
|
|
4490
|
+
// `base` may be a format added earlier in this edit (a date format, then a style)
|
|
4491
|
+
const xf = this.at('cellXfs', base) ?? this.xfs[0] ?? `<${p}xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>`;
|
|
4129
4492
|
let open = /^<[^>]*>/.exec(xf)[0];
|
|
4130
4493
|
let inner = open.endsWith('/>') ? '' : xf.slice(open.length, xf.lastIndexOf('<'));
|
|
4131
4494
|
open = open.replace(/\s*\/>$/, '>');
|
|
4132
4495
|
if (style.font) {
|
|
4133
|
-
const old = this.fonts
|
|
4496
|
+
const old = this.at('fonts', parseInt(attr(open, 'fontId') ?? '0', 10)) ?? '';
|
|
4134
4497
|
let font = old.endsWith('/>') ? '' : old.replace(/^<[^>]*>/, '').replace(/<\/[^>]*>$/, '');
|
|
4135
4498
|
for (const [key, tag] of FONT_TAGS) {
|
|
4136
4499
|
if (!(key in style.font))
|
|
@@ -4147,7 +4510,7 @@ class StylePatcher {
|
|
|
4147
4510
|
open = setAttr(setAttr(open, 'fillId', String(this.add('fills', this.prefix(fillXml(style.fill))))), 'applyFill', '1');
|
|
4148
4511
|
}
|
|
4149
4512
|
if (style.border) {
|
|
4150
|
-
const old = this.borders
|
|
4513
|
+
const old = this.at('borders', parseInt(attr(open, 'borderId') ?? '0', 10)) ?? `<${p}border/>`;
|
|
4151
4514
|
const element = (tag) => new RegExp(`<${p}${tag}\\b[^>]*?(?:/>|>[\\s\\S]*?</${p}${tag}>)`).exec(old)?.[0];
|
|
4152
4515
|
const sides = SIDES.map(side => side in style.border
|
|
4153
4516
|
? this.prefix(borderSideXml(side, style.border[side])) : element(side) ?? `<${p}${side}/>`);
|
|
@@ -4182,7 +4545,7 @@ class StylePatcher {
|
|
|
4182
4545
|
// The format for date `d` in a cell of format `base`: `base` when it already shows dates, otherwise
|
|
4183
4546
|
// `base` with a date format, since a date in a General cell shows as its serial number
|
|
4184
4547
|
dateFormat(base, d) {
|
|
4185
|
-
const xf = this.
|
|
4548
|
+
const xf = this.at('cellXfs', base) ?? '';
|
|
4186
4549
|
const id = parseInt(attr(/^<[^>]*>/.exec(xf)?.[0] ?? '', 'numFmtId') ?? '0', 10);
|
|
4187
4550
|
const code = [...this.numFmts].find(([, v]) => v === id)?.[0];
|
|
4188
4551
|
if (isBuiltinDateFormat(id) || (code !== undefined && isDateFormatCode(code)))
|
|
@@ -4218,14 +4581,28 @@ class StylePatcher {
|
|
|
4218
4581
|
return xml;
|
|
4219
4582
|
}
|
|
4220
4583
|
existing(section) {
|
|
4221
|
-
return section === '
|
|
4222
|
-
: section === 'borders' ? this.borders.length : section === 'cellXfs' ? this.xfs.length
|
|
4223
|
-
: this.items('numFmts', 'numFmt').length;
|
|
4584
|
+
return section === 'numFmts' ? this.items('numFmts', 'numFmt').length : this.list(section).length;
|
|
4224
4585
|
}
|
|
4225
|
-
|
|
4586
|
+
list(section) {
|
|
4587
|
+
return section === 'fonts' ? this.fonts : section === 'fills' ? this.fills : section === 'borders' ? this.borders : this.xfs;
|
|
4588
|
+
}
|
|
4589
|
+
// Item `i` of a section, counting the ones added in this edit after the file's own
|
|
4590
|
+
at(section, i) {
|
|
4591
|
+
const own = this.list(section);
|
|
4592
|
+
return i < own.length ? own[i] : this.added[section][i - own.length];
|
|
4593
|
+
}
|
|
4594
|
+
// The index of `element` in a section, appending it unless an identical one is already there:
|
|
4595
|
+
// repeating the same restyle on an edited file reuses the formats the first run added
|
|
4226
4596
|
add(section, element) {
|
|
4597
|
+
const own = this.list(section);
|
|
4598
|
+
const found = own.indexOf(element);
|
|
4599
|
+
if (found >= 0)
|
|
4600
|
+
return found;
|
|
4601
|
+
const added = this.added[section].indexOf(element);
|
|
4602
|
+
if (added >= 0)
|
|
4603
|
+
return own.length + added;
|
|
4227
4604
|
this.added[section].push(element);
|
|
4228
|
-
return
|
|
4605
|
+
return own.length + this.added[section].length - 1;
|
|
4229
4606
|
}
|
|
4230
4607
|
items(section, tag) {
|
|
4231
4608
|
const p = this.p;
|
|
@@ -4366,12 +4743,12 @@ function mapSheetXml(xml, sheet, maps) {
|
|
|
4366
4743
|
if (child)
|
|
4367
4744
|
inner = inner.replace(child[0], `<${child[1]}sqref>${mapped}</${child[1]}sqref>`);
|
|
4368
4745
|
}
|
|
4369
|
-
inner = inner.replace(/<((?:\w+:)?)(formula1|formula2|formula|f)>([^<]*)<\/\1\2>/g, (_m, fp, ftag, f) => `<${fp}${ftag}>${escapeXml$
|
|
4746
|
+
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}>`);
|
|
4370
4747
|
return whole.endsWith('/>') && !inner ? `<${p}${tag}${attrs}/>` : `<${p}${tag}${attrs}>${inner}</${p}${tag}>`;
|
|
4371
4748
|
});
|
|
4372
4749
|
xml = recount(xml, 'dataValidations', 'dataValidation');
|
|
4373
4750
|
// Sparklines (x14): the data range follows its cells; a sparkline whose own cell is deleted goes
|
|
4374
|
-
const mapF = (s) => s.replace(/<((?:\w+:)?)f>([^<]*)<\/\1f>/g, (_m, p, f) => `<${p}f>${escapeXml$
|
|
4751
|
+
const mapF = (s) => s.replace(/<((?:\w+:)?)f>([^<]*)<\/\1f>/g, (_m, p, f) => `<${p}f>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps))}</${p}f>`);
|
|
4375
4752
|
xml = xml.replace(/<((?:\w+:)?)sparklineGroup\b([^>]*)>([\s\S]*?)<\/\1sparklineGroup>/g, (whole, p, attrs, inner) => {
|
|
4376
4753
|
const list = /<((?:\w+:)?)sparklines>([\s\S]*?)<\/\1sparklines>/.exec(inner);
|
|
4377
4754
|
if (!list)
|
|
@@ -4500,12 +4877,17 @@ function createShiftTransform(sheet, maps) {
|
|
|
4500
4877
|
const expand = !!own || mapped !== text;
|
|
4501
4878
|
shared.set(si, { text, row: r, col: c, expand });
|
|
4502
4879
|
if (expand)
|
|
4503
|
-
xml = `<${p}f>${escapeXml$
|
|
4880
|
+
xml = `<${p}f>${escapeXml$4(mapped)}</${p}f>`;
|
|
4504
4881
|
}
|
|
4505
4882
|
else {
|
|
4506
4883
|
const a = shared.get(si);
|
|
4507
|
-
if (a
|
|
4508
|
-
|
|
4884
|
+
if (a) {
|
|
4885
|
+
// Even when the anchor's own references stay, this cell's may point into moved rows
|
|
4886
|
+
const own = shiftFormula(a.text, r - a.row, c - a.col);
|
|
4887
|
+
const mapped = mapF(own);
|
|
4888
|
+
if (a.expand || mapped !== own)
|
|
4889
|
+
xml = `<${p}f>${escapeXml$4(mapped)}</${p}f>`;
|
|
4890
|
+
}
|
|
4509
4891
|
}
|
|
4510
4892
|
}
|
|
4511
4893
|
else if (kind === 'dataTable') {
|
|
@@ -4514,7 +4896,7 @@ function createShiftTransform(sheet, maps) {
|
|
|
4514
4896
|
const range = attr(fAttrs, 'ref');
|
|
4515
4897
|
if (own && range)
|
|
4516
4898
|
fAttrs = fAttrs.replace(/\sref="[^"]*"/, ` ref="${mapArea(range, own) ?? range}"`);
|
|
4517
|
-
fAttrs = fAttrs.replace(/\s(r1|r2)="([^"]*)"/g, (_m, name, ref) => ` ${name}="${escapeXml$
|
|
4899
|
+
fAttrs = fAttrs.replace(/\s(r1|r2)="([^"]*)"/g, (_m, name, ref) => ` ${name}="${escapeXml$4(mapF(unescapeXml(ref)))}"`);
|
|
4518
4900
|
xml = f[0].replace(f[1], () => fAttrs);
|
|
4519
4901
|
}
|
|
4520
4902
|
else if (text) {
|
|
@@ -4522,7 +4904,7 @@ function createShiftTransform(sheet, maps) {
|
|
|
4522
4904
|
const range = attr(fAttrs, 'ref');
|
|
4523
4905
|
if (own && range)
|
|
4524
4906
|
fAttrs = fAttrs.replace(/\sref="[^"]*"/, ` ref="${mapArea(range, own) ?? range}"`);
|
|
4525
|
-
xml = `<${p}f${fAttrs}>${escapeXml$
|
|
4907
|
+
xml = `<${p}f${fAttrs}>${escapeXml$4(mapF(text))}</${p}f>`;
|
|
4526
4908
|
}
|
|
4527
4909
|
return xml === undefined ? cell : cell.replace(f[0], () => xml);
|
|
4528
4910
|
});
|
|
@@ -4682,7 +5064,7 @@ function mapTable(xml, sheet, maps) {
|
|
|
4682
5064
|
}
|
|
4683
5065
|
xml = mapAutoFilter(xml, own).replace(/(<(?:\w+:)?(?:table|sortState|sortCondition)\b[^>]*?\sref=")([^"]*)"/g, (_m, pre, area) => `${pre}${mapArea(area, own) ?? area}"`);
|
|
4684
5066
|
}
|
|
4685
|
-
xml = xml.replace(/<((?:\w+:)?)(calculatedColumnFormula|totalsRowFormula)\b([^>]*)>([^<]*)<\/\1\2>/g, (_m, p, tag, attrs, f) => `<${p}${tag}${attrs}>${escapeXml$
|
|
5067
|
+
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}>`);
|
|
4686
5068
|
return { xml, headers };
|
|
4687
5069
|
}
|
|
4688
5070
|
// Chart series follow the cells they plot. Cached values are dropped when a series moves, and Excel
|
|
@@ -4694,10 +5076,14 @@ function mapChart(xml, maps) {
|
|
|
4694
5076
|
if (mapped === unescapeXml(f))
|
|
4695
5077
|
return whole;
|
|
4696
5078
|
changed = true;
|
|
4697
|
-
return open + escapeXml$
|
|
5079
|
+
return open + escapeXml$4(mapped) + close;
|
|
4698
5080
|
});
|
|
4699
5081
|
return changed ? out.replace(/<((?:\w+:)?)(numCache|strCache)\b[\s\S]*?<\/\1\2>/g, '') : xml;
|
|
4700
5082
|
}
|
|
5083
|
+
// A pivot table on a sheet whose rows or columns move is drawn where its cells went
|
|
5084
|
+
function mapPivotTable(xml, s) {
|
|
5085
|
+
return xml.replace(/(<(?:\w+:)?location\b[^>]*?\sref=")([^"]*)"/, (_m, pre, ref) => `${pre}${mapArea(ref, s) ?? ref}"`);
|
|
5086
|
+
}
|
|
4701
5087
|
// A pivot cache over moved cells reads the new range and refreshes when the file opens
|
|
4702
5088
|
function mapPivotCache(xml, maps) {
|
|
4703
5089
|
let changed = false;
|
|
@@ -4716,7 +5102,7 @@ function mapPivotCache(xml, maps) {
|
|
|
4716
5102
|
}
|
|
4717
5103
|
// Workbook defined names (print areas, named ranges) follow the cells they point at
|
|
4718
5104
|
function mapDefinedNames(wb, maps) {
|
|
4719
|
-
return wb.replace(/(<(?:\w+:)?definedName\b[^>]*>)([^<]*)(<\/(?:\w+:)?definedName>)/g, (_m, open, f, close) => open + escapeXml$
|
|
5105
|
+
return wb.replace(/(<(?:\w+:)?definedName\b[^>]*>)([^<]*)(<\/(?:\w+:)?definedName>)/g, (_m, open, f, close) => open + escapeXml$4(mapFormula(unescapeXml(f), '', maps)) + close);
|
|
4720
5106
|
}
|
|
4721
5107
|
|
|
4722
5108
|
const KEEP = Symbol('keep');
|
|
@@ -4743,12 +5129,12 @@ function editedCell(p, ref, attrs, edit) {
|
|
|
4743
5129
|
return `<${p}c r="${ref}"${attrs}><${p}v>${dateToSerial(edit)}</${p}v></${p}c>`;
|
|
4744
5130
|
}
|
|
4745
5131
|
if (typeof edit === 'object')
|
|
4746
|
-
return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$
|
|
4747
|
-
const text = escapeXml$
|
|
5132
|
+
return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$4(edit.formula.replace(/^=/, ''))}</${p}f></${p}c>`;
|
|
5133
|
+
const text = escapeXml$4(encodeXString(checkCellText(edit, ref)));
|
|
4748
5134
|
const t = /^\s|\s$/.test(edit) ? `<${p}t xml:space="preserve">${text}</${p}t>` : `<${p}t>${text}</${p}t>`;
|
|
4749
5135
|
return `<${p}c r="${ref}"${attrs} t="inlineStr"><${p}is>${t}</${p}is></${p}c>`;
|
|
4750
5136
|
}
|
|
4751
|
-
// The cells of a new sheet, as edits
|
|
5137
|
+
// The cells of new rows (a new sheet, or rows appended to one) as edits, rows numbered from 1
|
|
4752
5138
|
function rowsToEdits(name, rows) {
|
|
4753
5139
|
const edits = new Map();
|
|
4754
5140
|
rows.forEach((row, ri) => {
|
|
@@ -4761,7 +5147,7 @@ function rowsToEdits(name, rows) {
|
|
|
4761
5147
|
return;
|
|
4762
5148
|
}
|
|
4763
5149
|
if (cell.hyperlink !== undefined || cell.comment !== undefined) {
|
|
4764
|
-
throw new Error(`Sheet "${name}": SheetEditor
|
|
5150
|
+
throw new Error(`Sheet "${name}": SheetEditor does not write hyperlinks or notes in new rows; use SheetWriter.`);
|
|
4765
5151
|
}
|
|
4766
5152
|
// Rich text is written as plain text here
|
|
4767
5153
|
const value = cell.richText && cell.value == null ? cell.richText.map(r => r.text).join('') : cell.value;
|
|
@@ -4777,14 +5163,19 @@ const WORKSHEET_CONTENT = 'application/vnd.openxmlformats-officedocument.spreads
|
|
|
4777
5163
|
// An element in the shape of a regex match: [whole, attributes, body], body undefined when self-closing
|
|
4778
5164
|
const asMatch = (el) => [el.whole, el.attrs, el.whole.endsWith('/>') ? undefined : el.body];
|
|
4779
5165
|
class SheetEditor {
|
|
4780
|
-
|
|
5166
|
+
appends = new Map();
|
|
4781
5167
|
cellEdits = new Map();
|
|
4782
5168
|
additions = new Map();
|
|
4783
5169
|
deletions = new Set();
|
|
4784
5170
|
shiftOps = new Map();
|
|
4785
|
-
// Appends `rows` after the last existing row of sheet `sheetName`.
|
|
4786
|
-
|
|
4787
|
-
|
|
5171
|
+
// Appends `rows` after the last existing row of sheet `sheetName`. Cells take values, formulas and
|
|
5172
|
+
// styles, as in addSheet. Called again for the same sheet, the rows go after the earlier ones.
|
|
5173
|
+
appendSheet(sheetName, rows, _options = {}) {
|
|
5174
|
+
let batches = this.appends.get(sheetName);
|
|
5175
|
+
if (!batches)
|
|
5176
|
+
this.appends.set(sheetName, batches = []);
|
|
5177
|
+
batches.push(rows);
|
|
5178
|
+
return this;
|
|
4788
5179
|
}
|
|
4789
5180
|
// Sets cells of an existing sheet by address, e.g. { B2: 42, C2: { formula: 'B2*2' } }. A cell
|
|
4790
5181
|
// keeps its style, so a date written over a date-formatted cell shows as a date. Formulas are
|
|
@@ -4884,8 +5275,8 @@ class SheetEditor {
|
|
|
4884
5275
|
return path;
|
|
4885
5276
|
};
|
|
4886
5277
|
const appendByPath = new Map();
|
|
4887
|
-
for (const [sheetName,
|
|
4888
|
-
appendByPath.set(pathOf(sheetName),
|
|
5278
|
+
for (const [sheetName, batches] of this.appends)
|
|
5279
|
+
appendByPath.set(pathOf(sheetName), rowsToEdits(sheetName, batches.flat()));
|
|
4889
5280
|
const editsByPath = new Map();
|
|
4890
5281
|
for (const [sheetName, rows] of this.cellEdits)
|
|
4891
5282
|
editsByPath.set(pathOf(sheetName), rows);
|
|
@@ -4953,6 +5344,8 @@ class SheetEditor {
|
|
|
4953
5344
|
await update(rel.path, xml => mapVml(xml, map));
|
|
4954
5345
|
if (type === '/drawing')
|
|
4955
5346
|
await update(rel.path, xml => mapDrawing(xml, map));
|
|
5347
|
+
if (type === '/pivotTable')
|
|
5348
|
+
await update(rel.path, xml => mapPivotTable(xml, map));
|
|
4956
5349
|
}
|
|
4957
5350
|
}
|
|
4958
5351
|
// Charts on any sheet can plot the moved rows, and so can pivot tables
|
|
@@ -4972,7 +5365,7 @@ class SheetEditor {
|
|
|
4972
5365
|
}
|
|
4973
5366
|
}
|
|
4974
5367
|
// Edited cells invalidate cached formula results
|
|
4975
|
-
if (editsByPath.size || added.size || this.deletions.size || rowMaps.size) {
|
|
5368
|
+
if (editsByPath.size || added.size || appendByPath.size || this.deletions.size || rowMaps.size) {
|
|
4976
5369
|
const { replace, drop } = await recalcOnOpen(read, parts, await read(parts.workbookPath));
|
|
4977
5370
|
for (const [path, xml] of replace)
|
|
4978
5371
|
overlay.set(path, xml);
|
|
@@ -4981,10 +5374,10 @@ class SheetEditor {
|
|
|
4981
5374
|
}
|
|
4982
5375
|
// Styles gain the formats of restyled cells as the sheets stream, so they are written last
|
|
4983
5376
|
let patcher;
|
|
4984
|
-
const styled = [...editsByPath.values(), ...added.values()]
|
|
5377
|
+
const styled = [...editsByPath.values(), ...added.values(), ...appendByPath.values()]
|
|
4985
5378
|
.some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => styleOf(e))));
|
|
4986
5379
|
// Dates may need a date format added to their cells
|
|
4987
|
-
const dated = [...editsByPath.values(), ...added.values()]
|
|
5380
|
+
const dated = [...editsByPath.values(), ...added.values(), ...appendByPath.values()]
|
|
4988
5381
|
.some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => contentOf(e) instanceof Date)));
|
|
4989
5382
|
if (styled || (dated && parts.styles)) {
|
|
4990
5383
|
if (!parts.styles)
|
|
@@ -5004,7 +5397,7 @@ class SheetEditor {
|
|
|
5004
5397
|
// A new sheet uses the workbook's namespace (Transitional or Strict)
|
|
5005
5398
|
const root = /<((?:\w+:)?)workbook\b[^>]*>/.exec(await read(parts.workbookPath));
|
|
5006
5399
|
const ns = (root && attr(root[0], root[1] ? `xmlns:${root[1].slice(0, -1)}` : 'xmlns')) ?? 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
|
5007
|
-
const empty = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="${escapeXml$
|
|
5400
|
+
const empty = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="${escapeXml$4(ns)}"><sheetData/></worksheet>`;
|
|
5008
5401
|
for (const [path, edits] of added) {
|
|
5009
5402
|
await zipOut.addFile(path, encode(decode(new Response(empty).body).pipeThrough(this.createEditTransform(edits, patcher))));
|
|
5010
5403
|
}
|
|
@@ -5012,18 +5405,16 @@ class SheetEditor {
|
|
|
5012
5405
|
for (const filename of zipIn.getFiles()) {
|
|
5013
5406
|
if (written.has(filename))
|
|
5014
5407
|
continue;
|
|
5015
|
-
const
|
|
5408
|
+
const append = appendByPath.get(filename);
|
|
5016
5409
|
const edits = editsByPath.get(filename);
|
|
5017
5410
|
const shift = shiftByPath.get(filename);
|
|
5018
|
-
if (
|
|
5411
|
+
if (append || edits || shift !== undefined) {
|
|
5019
5412
|
checkSize(filename, options?.maxUncompressedBytes ?? Infinity);
|
|
5020
5413
|
let text = decode(await zipIn.extractStream(filename));
|
|
5021
5414
|
if (shift !== undefined)
|
|
5022
5415
|
text = text.pipeThrough(createShiftTransform(shift, rowMaps));
|
|
5023
|
-
if (edits)
|
|
5024
|
-
text = text.pipeThrough(this.createEditTransform(edits, patcher));
|
|
5025
|
-
if (rows)
|
|
5026
|
-
text = text.pipeThrough(this.createInjectTransform(rows));
|
|
5416
|
+
if (edits || append)
|
|
5417
|
+
text = text.pipeThrough(this.createEditTransform(edits ?? new Map(), patcher, append));
|
|
5027
5418
|
await zipOut.addFile(filename, encode(text));
|
|
5028
5419
|
}
|
|
5029
5420
|
else {
|
|
@@ -5043,10 +5434,12 @@ class SheetEditor {
|
|
|
5043
5434
|
return zipOut.stream;
|
|
5044
5435
|
}
|
|
5045
5436
|
// Streams a worksheet, rewriting edited cells as their rows pass by and adding rows and cells
|
|
5046
|
-
// that did not exist.
|
|
5047
|
-
|
|
5437
|
+
// that did not exist. `append` rows (numbered from 1) go after the last row. Only one row at a
|
|
5438
|
+
// time is held in memory.
|
|
5439
|
+
createEditTransform(edits, patcher, append) {
|
|
5048
5440
|
const pending = [...edits.keys()].sort((a, b) => a - b);
|
|
5049
5441
|
let next = 0;
|
|
5442
|
+
let lastRow = 0; // number of the last row read, for rows without an r attribute
|
|
5050
5443
|
let buffer = '';
|
|
5051
5444
|
let p = ''; // namespace prefix of the sheet's elements
|
|
5052
5445
|
let state = 'head';
|
|
@@ -5073,8 +5466,8 @@ class SheetEditor {
|
|
|
5073
5466
|
// Keeps the style; drops the type and the metadata of rich values and dynamic arrays
|
|
5074
5467
|
return editedCell(p, ref, attrs.replace(/\s(?:t|vm|cm)="[^"]*"/g, ''), content);
|
|
5075
5468
|
};
|
|
5076
|
-
const newRow = (r) => {
|
|
5077
|
-
const cells = [...
|
|
5469
|
+
const newRow = (r, rowEdits = edits.get(r)) => {
|
|
5470
|
+
const cells = [...rowEdits.entries()].sort((a, b) => a[0] - b[0])
|
|
5078
5471
|
.map(([c, e]) => cellXml(colLetter(c) + r, e)).join('');
|
|
5079
5472
|
return `<${p}row r="${r}">${cells}</${p}row>`;
|
|
5080
5473
|
};
|
|
@@ -5084,6 +5477,19 @@ class SheetEditor {
|
|
|
5084
5477
|
xml += newRow(pending[next]);
|
|
5085
5478
|
return xml;
|
|
5086
5479
|
};
|
|
5480
|
+
// The rows still to come at the end of sheetData: new edited rows, then the appended rows
|
|
5481
|
+
const lastRows = () => {
|
|
5482
|
+
let xml = rowsBefore(Infinity);
|
|
5483
|
+
if (!append?.size)
|
|
5484
|
+
return xml;
|
|
5485
|
+
const base = Math.max(lastRow, pending[pending.length - 1] ?? 0);
|
|
5486
|
+
const last = base + Math.max(...append.keys());
|
|
5487
|
+
if (last > MAX_ROWS$1)
|
|
5488
|
+
throw new Error(`Appending rows would reach row ${last}, past Excel's last row (${MAX_ROWS$1}).`);
|
|
5489
|
+
for (const [r, cells] of [...append].sort((a, b) => a[0] - b[0]))
|
|
5490
|
+
xml += newRow(base + r, cells);
|
|
5491
|
+
return xml;
|
|
5492
|
+
};
|
|
5087
5493
|
const rewriteRow = (open, inner, r) => {
|
|
5088
5494
|
// A copy: the edits stay intact for another edit() call
|
|
5089
5495
|
const rowEdits = edits.has(r) ? new Map(edits.get(r)) : undefined;
|
|
@@ -5115,7 +5521,7 @@ class SheetEditor {
|
|
|
5115
5521
|
if (brokenShared.has(si)) {
|
|
5116
5522
|
const a = sharedAnchors.get(si);
|
|
5117
5523
|
if (a) {
|
|
5118
|
-
xml = xml.replace(f[0], `<${p}f>${escapeXml$
|
|
5524
|
+
xml = xml.replace(f[0], `<${p}f>${escapeXml$4(shiftFormula(a.text, r - a.row, col - a.col))}</${p}f>`);
|
|
5119
5525
|
changed = true;
|
|
5120
5526
|
}
|
|
5121
5527
|
}
|
|
@@ -5142,7 +5548,7 @@ class SheetEditor {
|
|
|
5142
5548
|
p = m[1];
|
|
5143
5549
|
if (m[2]) {
|
|
5144
5550
|
// Empty sheet: <sheetData/> becomes a pair holding the new rows
|
|
5145
|
-
out += buffer.slice(0, m.index) + `<${p}sheetData>${
|
|
5551
|
+
out += buffer.slice(0, m.index) + `<${p}sheetData>${lastRows()}</${p}sheetData>`;
|
|
5146
5552
|
buffer = buffer.slice(m.index + m[0].length);
|
|
5147
5553
|
state = 'tail';
|
|
5148
5554
|
}
|
|
@@ -5169,11 +5575,14 @@ class SheetEditor {
|
|
|
5169
5575
|
if (!m)
|
|
5170
5576
|
break;
|
|
5171
5577
|
if (m[0].trimStart().startsWith(`</${p}sheetData`)) {
|
|
5172
|
-
out +=
|
|
5578
|
+
out += lastRows() + m[0];
|
|
5173
5579
|
state = 'tail';
|
|
5174
5580
|
}
|
|
5175
5581
|
else {
|
|
5176
|
-
|
|
5582
|
+
// A row without r is the one after the previous row, as the reader counts it
|
|
5583
|
+
const rAttr = /\sr="(\d+)"/.exec(m[1])?.[1];
|
|
5584
|
+
const r = rAttr ? parseInt(rAttr, 10) : lastRow + 1;
|
|
5585
|
+
lastRow = Math.max(lastRow, r);
|
|
5177
5586
|
out += rowsBefore(r);
|
|
5178
5587
|
const open = m[0].slice(0, m[0].indexOf('>') + 1);
|
|
5179
5588
|
// Rows are only parsed when they have edits or take part in a shared formula
|
|
@@ -5200,72 +5609,6 @@ class SheetEditor {
|
|
|
5200
5609
|
},
|
|
5201
5610
|
});
|
|
5202
5611
|
}
|
|
5203
|
-
createInjectTransform(rows) {
|
|
5204
|
-
let buffer = '';
|
|
5205
|
-
let maxRow = 0;
|
|
5206
|
-
let done = false;
|
|
5207
|
-
const rowsXml = () => rows.map((row, ri) => {
|
|
5208
|
-
const rowNum = maxRow + ri + 1;
|
|
5209
|
-
const cellsXml = row.map((cell, ci) => {
|
|
5210
|
-
const colRef = colLetter(ci) + rowNum;
|
|
5211
|
-
const val = typeof cell === 'object' && cell !== null && 'value' in cell ? cell.value : cell;
|
|
5212
|
-
if (val === null || val === undefined)
|
|
5213
|
-
return `<c r="${colRef}"/>`;
|
|
5214
|
-
if (typeof val === 'boolean')
|
|
5215
|
-
return `<c r="${colRef}" t="b"><v>${val ? 1 : 0}</v></c>`;
|
|
5216
|
-
if (typeof val === 'number')
|
|
5217
|
-
return `<c r="${colRef}"><v>${val}</v></c>`;
|
|
5218
|
-
if (typeof val === 'string') {
|
|
5219
|
-
return `<c r="${colRef}" t="inlineStr"><is><t xml:space="preserve">${escapeXml$5(encodeXString(checkCellText(val, colRef)))}</t></is></c>`;
|
|
5220
|
-
}
|
|
5221
|
-
return `<c r="${colRef}"/>`;
|
|
5222
|
-
}).join('');
|
|
5223
|
-
return `<row r="${rowNum}">${cellsXml}</row>`;
|
|
5224
|
-
}).join('');
|
|
5225
|
-
return new TransformStream({
|
|
5226
|
-
transform(chunk, controller) {
|
|
5227
|
-
if (done) {
|
|
5228
|
-
controller.enqueue(chunk);
|
|
5229
|
-
return;
|
|
5230
|
-
}
|
|
5231
|
-
buffer += chunk;
|
|
5232
|
-
for (const m of buffer.matchAll(/<(?:\w+:)?row\b[^>]*?\sr="(\d+)"/g)) {
|
|
5233
|
-
const r = parseInt(m[1], 10);
|
|
5234
|
-
if (r > maxRow)
|
|
5235
|
-
maxRow = r;
|
|
5236
|
-
}
|
|
5237
|
-
const close = /<\/(?:\w+:)?sheetData>|<((?:\w+:)?sheetData)\b[^>]*\/>/.exec(buffer);
|
|
5238
|
-
if (close) {
|
|
5239
|
-
const before = buffer.slice(0, close.index);
|
|
5240
|
-
const after = buffer.slice(close.index + close[0].length);
|
|
5241
|
-
// Self-closing <sheetData/> (empty sheet) becomes an open/close pair
|
|
5242
|
-
const tagName = close[1];
|
|
5243
|
-
const inject = tagName
|
|
5244
|
-
? `${close[0].slice(0, -2)}>${rowsXml()}</${tagName}>`
|
|
5245
|
-
: `${rowsXml()}${close[0]}`;
|
|
5246
|
-
controller.enqueue(before + inject + after);
|
|
5247
|
-
buffer = '';
|
|
5248
|
-
done = true;
|
|
5249
|
-
}
|
|
5250
|
-
else {
|
|
5251
|
-
// Hold back from the last '<' so a split tag is never emitted half-scanned
|
|
5252
|
-
const cut = buffer.lastIndexOf('<');
|
|
5253
|
-
if (cut > 0) {
|
|
5254
|
-
controller.enqueue(buffer.slice(0, cut));
|
|
5255
|
-
buffer = buffer.slice(cut);
|
|
5256
|
-
}
|
|
5257
|
-
}
|
|
5258
|
-
},
|
|
5259
|
-
flush(controller) {
|
|
5260
|
-
if (!done) {
|
|
5261
|
-
controller.error(new Error('Worksheet has no <sheetData> element.'));
|
|
5262
|
-
return;
|
|
5263
|
-
}
|
|
5264
|
-
if (buffer.length > 0)
|
|
5265
|
-
controller.enqueue(buffer);
|
|
5266
|
-
}
|
|
5267
|
-
});
|
|
5268
|
-
}
|
|
5269
5612
|
async readStreamToString(stream) {
|
|
5270
5613
|
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
5271
5614
|
let result = '';
|
|
@@ -5306,7 +5649,7 @@ async function removeSheet(name, parts, read, overlay, dropped) {
|
|
|
5306
5649
|
let mapped = formula.replace(quoted, '#REF!');
|
|
5307
5650
|
if (plain)
|
|
5308
5651
|
mapped = mapped.replace(plain, '$1#REF!');
|
|
5309
|
-
return mapped === formula ? whole : whole.replace(`>${text}<`, `>${escapeXml$
|
|
5652
|
+
return mapped === formula ? whole : whole.replace(`>${text}<`, `>${escapeXml$4(mapped)}<`);
|
|
5310
5653
|
});
|
|
5311
5654
|
wb = wb.replace(/<(?:\w+:)?workbookView\b[^>]*>/g, view => view.replace(/\s(activeTab|firstSheet)="(\d+)"/g, (_m, a, v) => {
|
|
5312
5655
|
const n = parseInt(v, 10);
|
|
@@ -5346,7 +5689,7 @@ async function insertSheet(name, parts, read, overlay, free) {
|
|
|
5346
5689
|
overlay.set(relsPath, rels);
|
|
5347
5690
|
const sheetId = Math.max(0, ...tags.map(t => parseInt(attr(t, 'sheetId') ?? '0', 10))) + 1;
|
|
5348
5691
|
const rPrefix = /<(?:\w+:)?sheet\b[^>]*?\s(\w+):id="/.exec(wb)?.[1] ?? 'r';
|
|
5349
|
-
wb = wb.replace(/<\/((?:\w+:)?)sheets>/, (close, p) => `<${p}sheet name="${escapeXml$
|
|
5692
|
+
wb = wb.replace(/<\/((?:\w+:)?)sheets>/, (close, p) => `<${p}sheet name="${escapeXml$4(name)}" sheetId="${sheetId}" ${rPrefix}:id="rId${k}"/>${close}`);
|
|
5350
5693
|
overlay.set(parts.workbookPath, wb);
|
|
5351
5694
|
const types = await read('[Content_Types].xml');
|
|
5352
5695
|
const contentType = sheetRel && /ContentType="([^"]*)"/.exec(overrideTag(types, sheetRel.path) ?? '')?.[1];
|
|
@@ -5417,17 +5760,18 @@ class SheetReader {
|
|
|
5417
5760
|
const name = attr(tag, 'name');
|
|
5418
5761
|
const state = attr(tag, 'state');
|
|
5419
5762
|
if (name !== null)
|
|
5420
|
-
sheets.push({ name
|
|
5763
|
+
sheets.push({ name, state: state === 'hidden' || state === 'veryHidden' ? state : 'visible' });
|
|
5421
5764
|
}
|
|
5422
5765
|
const definedNames = [];
|
|
5423
5766
|
for (const { attrs: open, body: text } of xmlElements(parts.workbookXml, 'definedName')) {
|
|
5424
5767
|
const local = attr(open, 'localSheetId');
|
|
5425
5768
|
const comment = attr(open, 'comment');
|
|
5426
|
-
|
|
5769
|
+
// attr() has already unescaped attribute values; only element text is still escaped
|
|
5770
|
+
const name = { name: attr(open, 'name') ?? '', ref: unescapeXml(text) };
|
|
5427
5771
|
if (local !== null && sheets[+local])
|
|
5428
5772
|
name.sheet = sheets[+local].name;
|
|
5429
5773
|
if (comment !== null)
|
|
5430
|
-
name.comment =
|
|
5774
|
+
name.comment = comment;
|
|
5431
5775
|
if (/^(?:1|true)$/.test(attr(open, 'hidden') ?? ''))
|
|
5432
5776
|
name.hidden = true;
|
|
5433
5777
|
definedNames.push(name);
|
|
@@ -5475,7 +5819,8 @@ class SheetReader {
|
|
|
5475
5819
|
throw new Error(`Sheet with name "${options.sheetName}" not found in workbook.`);
|
|
5476
5820
|
}
|
|
5477
5821
|
else {
|
|
5478
|
-
|
|
5822
|
+
// The first tab with cells: a chart sheet first in the tab order is skipped, as the .xls reader does
|
|
5823
|
+
worksheetZipPath = [...parts.sheets.values()].find(path => !parts.chartsheets.has(path))
|
|
5479
5824
|
?? zip.getFiles().find(f => /^xl\/worksheets\/[^/]+\.xml$/.test(f));
|
|
5480
5825
|
if (!worksheetZipPath)
|
|
5481
5826
|
throw new Error("No worksheets found in ZIP.");
|
|
@@ -5507,6 +5852,7 @@ class SheetReader {
|
|
|
5507
5852
|
.pipeThrough(createXmlBatchParser());
|
|
5508
5853
|
return parseWorksheet(xmlStream, sharedStrings, styles, is1904, {
|
|
5509
5854
|
formulas: options?.formulas,
|
|
5855
|
+
errors: options?.errors,
|
|
5510
5856
|
cellStyles: options?.styles ? cellStyles : undefined,
|
|
5511
5857
|
numFmts: options?.formatted ? formats : undefined,
|
|
5512
5858
|
richText: options?.richText ? color : undefined,
|
|
@@ -5807,7 +6153,8 @@ const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
|
5807
6153
|
function convertField(s) {
|
|
5808
6154
|
if (s === '')
|
|
5809
6155
|
return null;
|
|
5810
|
-
|
|
6156
|
+
// "1e400" overflows a double: keep the text rather than lose it to Infinity
|
|
6157
|
+
if (NUMBER.test(s) && isFinite(Number(s)))
|
|
5811
6158
|
return Number(s);
|
|
5812
6159
|
const upper = s.toUpperCase();
|
|
5813
6160
|
return upper === 'TRUE' ? true : upper === 'FALSE' ? false : s;
|
|
@@ -5911,14 +6258,11 @@ async function* parseCsv(input, options = {}) {
|
|
|
5911
6258
|
// Writer for OpenDocument spreadsheets (.ods). Rows stream into content.xml one at a time.
|
|
5912
6259
|
// Writes values, formulas, dates, merged cells, column widths, frozen panes, hidden sheets and
|
|
5913
6260
|
// document properties; styles and the other .xlsx sheet options are left out.
|
|
5914
|
-
|
|
5915
|
-
// XML 1.0 cannot hold most control characters at all, so they are dropped
|
|
5916
|
-
return String(val).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
5917
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
5918
|
-
}
|
|
6261
|
+
const escapeXml = (val) => escapeXml$4(String(val));
|
|
5919
6262
|
const isStyledCell = (v) => v !== null && typeof v === 'object' && !(v instanceof Date);
|
|
5920
6263
|
// Excel syntax to OpenFormula: "SUM(A1:B2,Sheet2!C3)" -> "of:=SUM([.A1:.B2];[$Sheet2.C3])"
|
|
5921
|
-
|
|
6264
|
+
// Cell references and ranges, then whole columns (C:C) and whole rows (1:3)
|
|
6265
|
+
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;
|
|
5922
6266
|
function toOpenFormula(formula) {
|
|
5923
6267
|
const f = formula.replace(/^=/, '');
|
|
5924
6268
|
let out = '';
|
|
@@ -5943,7 +6287,7 @@ function toOpenFormula(formula) {
|
|
|
5943
6287
|
const m = REF.exec(f);
|
|
5944
6288
|
if (m) {
|
|
5945
6289
|
const sheet = m[1] ? `$${m[1].slice(0, -1)}` : '';
|
|
5946
|
-
out += `[${
|
|
6290
|
+
out += `[${m[2].split(':').map(part => `${sheet}.${part}`).join(':')}]`;
|
|
5947
6291
|
i = REF.lastIndex;
|
|
5948
6292
|
continue;
|
|
5949
6293
|
}
|
|
@@ -5959,8 +6303,10 @@ function textXml(s) {
|
|
|
5959
6303
|
.replace(/\t/g, '<text:tab/>')
|
|
5960
6304
|
.replace(/^ | {2,}/g, m => m === ' ' ? '<text:s/>' : ` <text:s text:c="${m.length - 1}"/>`)}</text:p>`).join('');
|
|
5961
6305
|
}
|
|
5962
|
-
|
|
6306
|
+
// UTC, like SheetWriter; milliseconds only when there are some
|
|
6307
|
+
const dateValue = (d) => d.toISOString().slice(0, d.getUTCMilliseconds() ? 23 : 19);
|
|
5963
6308
|
const hasTime = (d) => d.getTime() % 86400000 !== 0;
|
|
6309
|
+
// `evaluate` gives the formula's result, or null when it isn't known (streamed rows, errors)
|
|
5964
6310
|
function cellXml(cell, span, covered, evaluate) {
|
|
5965
6311
|
if (covered)
|
|
5966
6312
|
return '<table:covered-table-cell/>';
|
|
@@ -5968,7 +6314,7 @@ function cellXml(cell, span, covered, evaluate) {
|
|
|
5968
6314
|
const formula = isStyledCell(cell) && cell.formula ? ` table:formula="${escapeXml(toOpenFormula(cell.formula))}"` : '';
|
|
5969
6315
|
// A formula without a value gets its result stored, as SheetWriter does, for readers that don't recalculate
|
|
5970
6316
|
if (formula && value == null)
|
|
5971
|
-
value = evaluate(
|
|
6317
|
+
value = evaluate();
|
|
5972
6318
|
if (value === null || value === undefined || (typeof value === 'number' && !isFinite(value))) {
|
|
5973
6319
|
return formula || span ? `<table:table-cell${formula}${span}/>` : '<table:table-cell/>';
|
|
5974
6320
|
}
|
|
@@ -6065,11 +6411,18 @@ class OdsWriter {
|
|
|
6065
6411
|
const engine = new FormulaEngine();
|
|
6066
6412
|
if (Array.isArray(sheet.rows)) {
|
|
6067
6413
|
engine.loadData(sheet.rows.map(row => row.map(cell => {
|
|
6414
|
+
if (isStyledCell(cell) && cell.formula)
|
|
6415
|
+
return { formula: cell.formula };
|
|
6068
6416
|
const v = isStyledCell(cell) ? cell.value : cell;
|
|
6069
6417
|
return v instanceof Date ? dateToSerial(v) : v ?? null;
|
|
6070
6418
|
})));
|
|
6071
6419
|
}
|
|
6072
|
-
const evaluate = (
|
|
6420
|
+
const evaluate = (ref) => {
|
|
6421
|
+
if (!Array.isArray(sheet.rows))
|
|
6422
|
+
return null;
|
|
6423
|
+
const v = engine.cellValue(ref);
|
|
6424
|
+
return v instanceof FormulaError ? v.code : v;
|
|
6425
|
+
};
|
|
6073
6426
|
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));
|
|
6074
6427
|
let r = 0, batch = '';
|
|
6075
6428
|
const writeRow = (row) => {
|
|
@@ -6078,7 +6431,7 @@ class OdsWriter {
|
|
|
6078
6431
|
for (let c = 0; c < width; c++) {
|
|
6079
6432
|
const m = mergeAt(r, c);
|
|
6080
6433
|
const span = m ? ` table:number-columns-spanned="${m.c2 - m.c1 + 1}" table:number-rows-spanned="${m.r2 - m.r1 + 1}"` : '';
|
|
6081
|
-
xml += cellXml(row[c], span, covered(r, c), evaluate);
|
|
6434
|
+
xml += cellXml(row[c], span, covered(r, c), () => evaluate(colLetter(c) + (r + 1)));
|
|
6082
6435
|
}
|
|
6083
6436
|
r++;
|
|
6084
6437
|
return xml + (width ? '' : '<table:table-cell/>') + '</table:table-row>';
|
|
@@ -6152,11 +6505,28 @@ const XlsxFlow = {
|
|
|
6152
6505
|
const { SheetReader } = await Promise.resolve().then(function () { return index; });
|
|
6153
6506
|
const reader = new SheetReader();
|
|
6154
6507
|
const fileReader = await createFileReader(filePath);
|
|
6155
|
-
//
|
|
6508
|
+
// Comments, images and .ods content are read after parse() returns, when the handle is closed:
|
|
6509
|
+
// those reads open a short-lived handle of their own
|
|
6510
|
+
let closed = false;
|
|
6511
|
+
const lateRead = async (offset, length) => {
|
|
6512
|
+
const late = await createFileReader(filePath);
|
|
6513
|
+
try {
|
|
6514
|
+
return await late.read(offset, length);
|
|
6515
|
+
}
|
|
6516
|
+
finally {
|
|
6517
|
+
await late.close();
|
|
6518
|
+
}
|
|
6519
|
+
};
|
|
6156
6520
|
try {
|
|
6157
|
-
return await reader.parse(
|
|
6521
|
+
return await reader.parse({
|
|
6522
|
+
size: fileReader.size,
|
|
6523
|
+
read: (offset, length) => closed ? lateRead(offset, length) : fileReader.read(offset, length),
|
|
6524
|
+
stream: (offset, length) => fileReader.stream(offset, length),
|
|
6525
|
+
close: async () => { },
|
|
6526
|
+
}, options);
|
|
6158
6527
|
}
|
|
6159
6528
|
finally {
|
|
6529
|
+
closed = true;
|
|
6160
6530
|
await fileReader.close();
|
|
6161
6531
|
}
|
|
6162
6532
|
}
|