@xlsxflow/core 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +5 -4
- package/dist/index.cjs +1081 -740
- 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 +1081 -740
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,367 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const CRC_TABLE = (() => {
|
|
4
|
+
const t = new Uint32Array(256);
|
|
5
|
+
for (let n = 0; n < 256; n++) {
|
|
6
|
+
let c = n;
|
|
7
|
+
for (let k = 0; k < 8; k++)
|
|
8
|
+
c = (c & 1) ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
|
9
|
+
t[n] = c >>> 0;
|
|
10
|
+
}
|
|
11
|
+
return t;
|
|
12
|
+
})();
|
|
13
|
+
function crc32(bytes) {
|
|
14
|
+
return (crc32Update(0xffffffff, bytes) ^ 0xffffffff) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
// Running CRC-32 over chunks: start at 0xffffffff, finish with (crc ^ 0xffffffff) >>> 0
|
|
17
|
+
function crc32Update(crc, bytes) {
|
|
18
|
+
for (let i = 0; i < bytes.length; i++)
|
|
19
|
+
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
20
|
+
return crc;
|
|
21
|
+
}
|
|
22
|
+
const MAX_U32 = 0xffffffff;
|
|
23
|
+
// General-purpose flag bit 11: the entry name is UTF-8 (set only when it isn't plain ASCII)
|
|
24
|
+
const utf8Flag = (name) => name.some(b => b > 0x7f) ? 0x0800 : 0;
|
|
25
|
+
// No ZIP64 support: fail loudly instead of writing a corrupt archive.
|
|
26
|
+
function assertNoZip64(value, what) {
|
|
27
|
+
if (value > MAX_U32)
|
|
28
|
+
throw new Error(`ZIP64 not supported: ${what} exceeds 4 GiB.`);
|
|
29
|
+
}
|
|
30
|
+
// A true Single-Pass Streaming ZIP Writer
|
|
31
|
+
class ZipStreamWriter {
|
|
32
|
+
cdEntries = [];
|
|
33
|
+
offset = 0;
|
|
34
|
+
streamController;
|
|
35
|
+
waiting = [];
|
|
36
|
+
cancelled = false;
|
|
37
|
+
stream;
|
|
38
|
+
textEncoder = new TextEncoder();
|
|
39
|
+
constructor(highWaterMarkBytes = 1 << 20) {
|
|
40
|
+
this.stream = new ReadableStream({
|
|
41
|
+
start: (controller) => {
|
|
42
|
+
this.streamController = controller;
|
|
43
|
+
},
|
|
44
|
+
pull: () => this.wake(),
|
|
45
|
+
// Consumer gone: unblock any pending write so the producer can see the error.
|
|
46
|
+
cancel: () => {
|
|
47
|
+
this.cancelled = true;
|
|
48
|
+
this.wake();
|
|
49
|
+
}
|
|
50
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark: highWaterMarkBytes }));
|
|
51
|
+
}
|
|
52
|
+
// Adds a file to the zip. `inputStream` MUST be raw uncompressed data.
|
|
53
|
+
async addFile(filenameStr, inputStream) {
|
|
54
|
+
const filename = this.textEncoder.encode(filenameStr);
|
|
55
|
+
const flags = 0x0008 | utf8Flag(filename);
|
|
56
|
+
const startOffset = this.offset;
|
|
57
|
+
await this.pushChunk(this.localHeader(filename, flags, 8, 0, 0, 0));
|
|
58
|
+
// Stream data, tracking sizes and CRC32
|
|
59
|
+
let uncompressedSize = 0;
|
|
60
|
+
let crc = 0xffffffff;
|
|
61
|
+
const crcStream = new TransformStream({
|
|
62
|
+
// CompressionStream takes every write at once, so the input is held back here instead,
|
|
63
|
+
// while nobody reads the output
|
|
64
|
+
transform: async (chunk, controller) => {
|
|
65
|
+
await this.roomInQueue();
|
|
66
|
+
uncompressedSize += chunk.length;
|
|
67
|
+
crc = crc32Update(crc, chunk);
|
|
68
|
+
controller.enqueue(chunk);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
let compressedSize = 0;
|
|
72
|
+
const reader = inputStream
|
|
73
|
+
.pipeThrough(crcStream)
|
|
74
|
+
.pipeThrough(new CompressionStream('deflate-raw'))
|
|
75
|
+
.getReader();
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
const { done, value } = await reader.read();
|
|
79
|
+
if (done)
|
|
80
|
+
break;
|
|
81
|
+
compressedSize += value.length;
|
|
82
|
+
await this.pushChunk(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
// Stop the source too (a row generator's finally runs, a database cursor closes)
|
|
87
|
+
await reader.cancel(err).catch(() => { });
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
91
|
+
assertNoZip64(uncompressedSize, filenameStr);
|
|
92
|
+
assertNoZip64(compressedSize, filenameStr);
|
|
93
|
+
// Data Descriptor
|
|
94
|
+
const desc = new Uint8Array(16);
|
|
95
|
+
const descView = new DataView(desc.buffer);
|
|
96
|
+
descView.setUint32(0, 0x08074b50, true);
|
|
97
|
+
descView.setUint32(4, crc, true);
|
|
98
|
+
descView.setUint32(8, compressedSize, true);
|
|
99
|
+
descView.setUint32(12, uncompressedSize, true);
|
|
100
|
+
await this.pushChunk(desc);
|
|
101
|
+
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags, method: 8 });
|
|
102
|
+
}
|
|
103
|
+
// Adds a file that is ALREADY compressed (pass-through for the Editor)
|
|
104
|
+
async addCompressedFile(filenameStr, compressedStream, uncompressedSize, compressedSize, crc, method = 8) {
|
|
105
|
+
const filename = this.textEncoder.encode(filenameStr);
|
|
106
|
+
const flags = utf8Flag(filename);
|
|
107
|
+
const startOffset = this.offset;
|
|
108
|
+
await this.pushChunk(this.localHeader(filename, flags, method, crc, compressedSize, uncompressedSize));
|
|
109
|
+
const reader = compressedStream.getReader();
|
|
110
|
+
while (true) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done)
|
|
113
|
+
break;
|
|
114
|
+
await this.pushChunk(value);
|
|
115
|
+
}
|
|
116
|
+
this.cdEntries.push({ filename, offset: startOffset, uncompressedSize, compressedSize, crc, flags, method });
|
|
117
|
+
}
|
|
118
|
+
async close() {
|
|
119
|
+
// 0xFFFF in the end record means "see the ZIP64 record", which readers then look for
|
|
120
|
+
if (this.cdEntries.length >= 0xffff)
|
|
121
|
+
throw new Error('ZIP64 not supported: 65535 entries or more.');
|
|
122
|
+
const cdStartOffset = this.offset;
|
|
123
|
+
for (const entry of this.cdEntries) {
|
|
124
|
+
const cd = new Uint8Array(46 + entry.filename.length);
|
|
125
|
+
const view = new DataView(cd.buffer);
|
|
126
|
+
view.setUint32(0, 0x02014b50, true);
|
|
127
|
+
view.setUint16(4, 20, true); // version made by
|
|
128
|
+
view.setUint16(6, 20, true); // version needed
|
|
129
|
+
view.setUint16(8, entry.flags, true);
|
|
130
|
+
view.setUint16(10, entry.method, true);
|
|
131
|
+
view.setUint32(16, entry.crc, true);
|
|
132
|
+
view.setUint32(20, entry.compressedSize, true);
|
|
133
|
+
view.setUint32(24, entry.uncompressedSize, true);
|
|
134
|
+
view.setUint16(28, entry.filename.length, true);
|
|
135
|
+
view.setUint32(42, entry.offset, true);
|
|
136
|
+
cd.set(entry.filename, 46);
|
|
137
|
+
await this.pushChunk(cd);
|
|
138
|
+
}
|
|
139
|
+
const cdSize = this.offset - cdStartOffset;
|
|
140
|
+
assertNoZip64(this.offset, 'archive');
|
|
141
|
+
const eocd = new Uint8Array(22);
|
|
142
|
+
const eocdView = new DataView(eocd.buffer);
|
|
143
|
+
eocdView.setUint32(0, 0x06054b50, true);
|
|
144
|
+
eocdView.setUint16(8, this.cdEntries.length, true);
|
|
145
|
+
eocdView.setUint16(10, this.cdEntries.length, true);
|
|
146
|
+
eocdView.setUint32(12, cdSize, true);
|
|
147
|
+
eocdView.setUint32(16, cdStartOffset, true);
|
|
148
|
+
await this.pushChunk(eocd);
|
|
149
|
+
this.streamController.close();
|
|
150
|
+
}
|
|
151
|
+
// Propagate a producer failure to whoever is reading `stream`.
|
|
152
|
+
error(err) {
|
|
153
|
+
try {
|
|
154
|
+
this.streamController.error(err);
|
|
155
|
+
}
|
|
156
|
+
catch { /* already closed/errored */ }
|
|
157
|
+
}
|
|
158
|
+
localHeader(filename, flags, method, crc, compressedSize, uncompressedSize) {
|
|
159
|
+
assertNoZip64(this.offset, 'archive');
|
|
160
|
+
const header = new Uint8Array(30 + filename.length);
|
|
161
|
+
const view = new DataView(header.buffer);
|
|
162
|
+
view.setUint32(0, 0x04034b50, true);
|
|
163
|
+
view.setUint16(4, 20, true);
|
|
164
|
+
view.setUint16(6, flags, true);
|
|
165
|
+
view.setUint16(8, method, true);
|
|
166
|
+
view.setUint32(14, crc, true);
|
|
167
|
+
view.setUint32(18, compressedSize, true);
|
|
168
|
+
view.setUint32(22, uncompressedSize, true);
|
|
169
|
+
view.setUint16(26, filename.length, true);
|
|
170
|
+
header.set(filename, 30);
|
|
171
|
+
return header;
|
|
172
|
+
}
|
|
173
|
+
// Enqueue and wait while the consumer's queue is full, so memory stays bounded.
|
|
174
|
+
async pushChunk(chunk) {
|
|
175
|
+
if (this.cancelled)
|
|
176
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
177
|
+
this.streamController.enqueue(chunk);
|
|
178
|
+
this.offset += chunk.length;
|
|
179
|
+
await this.roomInQueue();
|
|
180
|
+
}
|
|
181
|
+
async roomInQueue() {
|
|
182
|
+
while ((this.streamController.desiredSize ?? 1) <= 0) {
|
|
183
|
+
if (this.cancelled)
|
|
184
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
185
|
+
await new Promise(resolve => this.waiting.push(resolve));
|
|
186
|
+
}
|
|
187
|
+
if (this.cancelled)
|
|
188
|
+
throw new Error('ZIP stream cancelled by consumer.');
|
|
189
|
+
}
|
|
190
|
+
wake() {
|
|
191
|
+
const waiting = this.waiting;
|
|
192
|
+
this.waiting = [];
|
|
193
|
+
for (const resolve of waiting)
|
|
194
|
+
resolve();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Reader for Compound File Binary files [MS-CFB]: the container of .xls workbooks and of
|
|
199
|
+
// password-protected .xlsx files. Streams are read from an in-memory copy of the file.
|
|
200
|
+
const SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
201
|
+
const END_OF_CHAIN = 0xfffffffe;
|
|
202
|
+
const MAX_REGULAR_SECTOR = 0xfffffffa;
|
|
203
|
+
function isCfb(bytes) {
|
|
204
|
+
return bytes.length >= 8 && SIGNATURE.every((b, i) => bytes[i] === b);
|
|
205
|
+
}
|
|
206
|
+
const corrupt$1 = (why) => new Error(`Corrupt compound file: ${why}`);
|
|
207
|
+
class CfbReader {
|
|
208
|
+
bytes;
|
|
209
|
+
view;
|
|
210
|
+
sectorSize;
|
|
211
|
+
miniSectorSize;
|
|
212
|
+
miniCutoff;
|
|
213
|
+
fat;
|
|
214
|
+
miniFat;
|
|
215
|
+
miniStream;
|
|
216
|
+
dir;
|
|
217
|
+
byPath = new Map();
|
|
218
|
+
constructor(bytes) {
|
|
219
|
+
this.bytes = bytes;
|
|
220
|
+
if (!isCfb(bytes))
|
|
221
|
+
throw new Error('Not a compound file (no D0CF11E0 signature)');
|
|
222
|
+
if (bytes.length < 512)
|
|
223
|
+
throw corrupt$1(`only ${bytes.length} bytes, shorter than its 512-byte header`);
|
|
224
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
225
|
+
const u16 = (o) => this.view.getUint16(o, true);
|
|
226
|
+
const u32 = (o) => this.view.getUint32(o, true);
|
|
227
|
+
const sectorShift = u16(0x1e);
|
|
228
|
+
if (sectorShift !== 9 && sectorShift !== 12)
|
|
229
|
+
throw corrupt$1(`sector shift ${sectorShift}`);
|
|
230
|
+
this.sectorSize = 1 << sectorShift;
|
|
231
|
+
const miniShift = u16(0x20);
|
|
232
|
+
if (miniShift !== 6)
|
|
233
|
+
throw corrupt$1(`mini sector shift ${miniShift}`);
|
|
234
|
+
this.miniSectorSize = 1 << miniShift;
|
|
235
|
+
this.miniCutoff = u32(0x38);
|
|
236
|
+
// The FAT's own sectors are listed in the header (109 slots) and then in a chain of DIFAT sectors
|
|
237
|
+
const numFatSectors = u32(0x2c);
|
|
238
|
+
const sectorCount = Math.ceil(bytes.length / this.sectorSize) - 1;
|
|
239
|
+
if (numFatSectors > sectorCount)
|
|
240
|
+
throw corrupt$1(`${numFatSectors} FAT sectors in a ${bytes.length}-byte file`);
|
|
241
|
+
const fatSectors = [];
|
|
242
|
+
for (let i = 0; i < 109 && fatSectors.length < numFatSectors; i++)
|
|
243
|
+
fatSectors.push(u32(0x4c + i * 4));
|
|
244
|
+
const perDifat = this.sectorSize / 4 - 1;
|
|
245
|
+
for (let s = u32(0x44), hops = 0; fatSectors.length < numFatSectors; hops++) {
|
|
246
|
+
if (s > MAX_REGULAR_SECTOR || hops > sectorCount)
|
|
247
|
+
throw corrupt$1('DIFAT chain ends early');
|
|
248
|
+
const at = this.sectorOffset(s);
|
|
249
|
+
for (let i = 0; i < perDifat && fatSectors.length < numFatSectors; i++)
|
|
250
|
+
fatSectors.push(u32(at + i * 4));
|
|
251
|
+
s = u32(at + perDifat * 4);
|
|
252
|
+
}
|
|
253
|
+
const perSector = this.sectorSize / 4;
|
|
254
|
+
this.fat = new Uint32Array(numFatSectors * perSector);
|
|
255
|
+
fatSectors.forEach((s, i) => {
|
|
256
|
+
const at = this.sectorOffset(s);
|
|
257
|
+
for (let j = 0; j < perSector; j++)
|
|
258
|
+
this.fat[i * perSector + j] = u32(at + j * 4);
|
|
259
|
+
});
|
|
260
|
+
// Directory: 128-byte entries in the chain starting at the header's first directory sector
|
|
261
|
+
const dirBytes = this.readChain(u32(0x30), Infinity);
|
|
262
|
+
this.dir = [];
|
|
263
|
+
for (let at = 0; at + 128 <= dirBytes.length; at += 128) {
|
|
264
|
+
const d = new DataView(dirBytes.buffer, dirBytes.byteOffset + at, 128);
|
|
265
|
+
const nameLen = Math.min(d.getUint16(64, true), 64);
|
|
266
|
+
let name = '';
|
|
267
|
+
for (let i = 0; i + 2 < nameLen; i += 2)
|
|
268
|
+
name += String.fromCharCode(d.getUint16(i, true));
|
|
269
|
+
// Version 3 files may leave garbage in the size's high half
|
|
270
|
+
const size = sectorShift === 9 ? d.getUint32(120, true) : d.getUint32(120, true) + d.getUint32(124, true) * 2 ** 32;
|
|
271
|
+
this.dir.push({ name, type: d.getUint8(66), left: d.getUint32(68, true), right: d.getUint32(72, true),
|
|
272
|
+
child: d.getUint32(76, true), start: d.getUint32(116, true), size });
|
|
273
|
+
}
|
|
274
|
+
if (this.dir[0]?.type !== 5)
|
|
275
|
+
throw corrupt$1('no root entry');
|
|
276
|
+
this.walk(this.dir[0].child, '');
|
|
277
|
+
}
|
|
278
|
+
// Start of a sector, checked to hold `need` bytes (the last sector of a file may be cut short)
|
|
279
|
+
sectorOffset(sector, need = this.sectorSize) {
|
|
280
|
+
const at = (sector + 1) * this.sectorSize;
|
|
281
|
+
if (sector > MAX_REGULAR_SECTOR || at + need > this.bytes.length) {
|
|
282
|
+
throw corrupt$1(`sector ${sector} is outside the file`);
|
|
283
|
+
}
|
|
284
|
+
return at;
|
|
285
|
+
}
|
|
286
|
+
// Sibling entries form a red-black tree; children of a storage hang off its `child`
|
|
287
|
+
walk(root, prefix) {
|
|
288
|
+
const stack = [root];
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
while (stack.length) {
|
|
291
|
+
const id = stack.pop();
|
|
292
|
+
if (id > MAX_REGULAR_SECTOR)
|
|
293
|
+
continue; // NOSTREAM
|
|
294
|
+
if (seen.has(id) || !this.dir[id])
|
|
295
|
+
throw corrupt$1('directory tree loops or points outside the directory');
|
|
296
|
+
seen.add(id);
|
|
297
|
+
const e = this.dir[id];
|
|
298
|
+
stack.push(e.left, e.right);
|
|
299
|
+
if (e.type !== 1 && e.type !== 2)
|
|
300
|
+
continue;
|
|
301
|
+
const path = prefix + e.name;
|
|
302
|
+
this.byPath.set(path.toLowerCase(), { ...e, path });
|
|
303
|
+
if (e.type === 1)
|
|
304
|
+
this.walk(e.child, path + '/');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
readChain(start, size, mini = false) {
|
|
308
|
+
const table = mini ? this.miniFat : this.fat;
|
|
309
|
+
const unit = mini ? this.miniSectorSize : this.sectorSize;
|
|
310
|
+
const source = mini ? this.miniStream : this.bytes;
|
|
311
|
+
const sectors = [];
|
|
312
|
+
for (let s = start; s !== END_OF_CHAIN && sectors.length * unit < size; s = table[s]) {
|
|
313
|
+
if (s >= table.length || sectors.length > table.length)
|
|
314
|
+
throw corrupt$1(`${mini ? 'mini ' : ''}sector chain is broken or loops`);
|
|
315
|
+
sectors.push(s);
|
|
316
|
+
}
|
|
317
|
+
const total = Math.min(size, sectors.length * unit);
|
|
318
|
+
if (size !== Infinity && total < size)
|
|
319
|
+
throw corrupt$1(`stream is shorter (${total} bytes) than its stated ${size}`);
|
|
320
|
+
const out = new Uint8Array(total);
|
|
321
|
+
sectors.forEach((s, i) => {
|
|
322
|
+
const len = Math.min(unit, total - i * unit);
|
|
323
|
+
const at = mini ? s * unit : this.sectorOffset(s, len);
|
|
324
|
+
if (at + len > source.length)
|
|
325
|
+
throw corrupt$1(`mini sector ${s} is outside the mini stream`);
|
|
326
|
+
out.set(source.subarray(at, at + len), i * unit);
|
|
327
|
+
});
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
entries() {
|
|
331
|
+
return [...this.byPath.values()].map(e => ({ path: e.path, type: e.type === 1 ? 'storage' : 'stream', size: e.size }));
|
|
332
|
+
}
|
|
333
|
+
has(path) {
|
|
334
|
+
return this.byPath.get(path.toLowerCase())?.type === 2;
|
|
335
|
+
}
|
|
336
|
+
// A stream's bytes, by path (case-insensitive, as in the format); undefined if there is none
|
|
337
|
+
read(path) {
|
|
338
|
+
const e = this.byPath.get(path.toLowerCase());
|
|
339
|
+
if (!e || e.type !== 2)
|
|
340
|
+
return undefined;
|
|
341
|
+
if (e.size >= this.miniCutoff) {
|
|
342
|
+
if (e.size > this.bytes.length)
|
|
343
|
+
throw corrupt$1(`stream ${e.path} is larger than the file`);
|
|
344
|
+
return this.readChain(e.start, e.size);
|
|
345
|
+
}
|
|
346
|
+
if (!this.miniStream) {
|
|
347
|
+
const root = this.dir[0];
|
|
348
|
+
this.miniStream = this.readChain(root.start, root.size);
|
|
349
|
+
const view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
|
|
350
|
+
const miniFatBytes = this.readChain(view.getUint32(0x3c, true), view.getUint32(0x40, true) * this.sectorSize);
|
|
351
|
+
this.miniFat = new Uint32Array(miniFatBytes.length / 4);
|
|
352
|
+
const mv = new DataView(miniFatBytes.buffer, miniFatBytes.byteOffset, miniFatBytes.byteLength);
|
|
353
|
+
for (let i = 0; i < this.miniFat.length; i++)
|
|
354
|
+
this.miniFat[i] = mv.getUint32(i * 4, true);
|
|
355
|
+
}
|
|
356
|
+
return this.readChain(e.start, e.size, true);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
3
360
|
class ZipRandomAccessParser {
|
|
4
361
|
reader;
|
|
5
362
|
records = new Map();
|
|
363
|
+
// Part names are case-insensitive in OPC: a rels target "Sheet1.xml" finds "sheet1.xml"
|
|
364
|
+
lowerCase = new Map();
|
|
6
365
|
constructor(reader) {
|
|
7
366
|
this.reader = reader;
|
|
8
367
|
}
|
|
@@ -15,15 +374,24 @@ class ZipRandomAccessParser {
|
|
|
15
374
|
const searchStart = size - searchSize;
|
|
16
375
|
const buffer = await this.reader.read(searchStart, searchSize);
|
|
17
376
|
const dataView = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
18
|
-
// Search backwards for the EOCD signature (0x06054b50)
|
|
377
|
+
// Search backwards for the EOCD signature (0x06054b50). The real record's comment reaches the end
|
|
378
|
+
// of the file, so the signature bytes inside a comment are skipped; failing that (junk appended
|
|
379
|
+
// after the archive), the last signature is used.
|
|
19
380
|
let eocdOffset = -1;
|
|
20
381
|
for (let i = buffer.length - 22; i >= 0; i--) {
|
|
21
|
-
if (dataView.getUint32(i, true)
|
|
382
|
+
if (dataView.getUint32(i, true) !== 0x06054b50)
|
|
383
|
+
continue;
|
|
384
|
+
if (eocdOffset === -1)
|
|
385
|
+
eocdOffset = i;
|
|
386
|
+
if (i + 22 + dataView.getUint16(i + 20, true) === buffer.length) {
|
|
22
387
|
eocdOffset = i;
|
|
23
388
|
break;
|
|
24
389
|
}
|
|
25
390
|
}
|
|
26
391
|
if (eocdOffset === -1) {
|
|
392
|
+
if (isCfb(buffer.subarray(0, 8)) || searchStart > 0 && isCfb(await this.reader.read(0, 8))) {
|
|
393
|
+
throw new Error('This file is a compound file, not a ZIP: an .xls, or a workbook saved with a password. Decrypt it first with decryptWorkbook from @xlsxflow/pro.');
|
|
394
|
+
}
|
|
27
395
|
throw new Error("End of Central Directory (EOCD) not found. This may not be a valid ZIP file.");
|
|
28
396
|
}
|
|
29
397
|
const totalRecords = dataView.getUint16(eocdOffset + 10, true);
|
|
@@ -60,14 +428,10 @@ class ZipRandomAccessParser {
|
|
|
60
428
|
if (filename.includes('../') || filename.includes('..\\')) {
|
|
61
429
|
throw new Error(`Security Exception: Path traversal detected in ZIP filename: ${filename}`);
|
|
62
430
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
compressedSize,
|
|
68
|
-
uncompressedSize,
|
|
69
|
-
localHeaderOffset
|
|
70
|
-
});
|
|
431
|
+
const record = { filename, compressionMethod, crc, compressedSize, uncompressedSize, localHeaderOffset };
|
|
432
|
+
this.records.set(filename, record);
|
|
433
|
+
if (!this.lowerCase.has(filename.toLowerCase()))
|
|
434
|
+
this.lowerCase.set(filename.toLowerCase(), record);
|
|
71
435
|
offset += 46 + filenameLength + extraFieldLength + fileCommentLength;
|
|
72
436
|
}
|
|
73
437
|
// Entries must not share bytes: aliased names would let one small deflated entry be inflated many times
|
|
@@ -80,13 +444,13 @@ class ZipRandomAccessParser {
|
|
|
80
444
|
}
|
|
81
445
|
}
|
|
82
446
|
has(filename) {
|
|
83
|
-
return this.records.has(filename);
|
|
447
|
+
return this.records.has(filename) || this.lowerCase.has(filename.toLowerCase());
|
|
84
448
|
}
|
|
85
449
|
getFiles() {
|
|
86
450
|
return Array.from(this.records.keys());
|
|
87
451
|
}
|
|
88
452
|
getRecord(filename) {
|
|
89
|
-
const record = this.records.get(filename);
|
|
453
|
+
const record = this.records.get(filename) ?? this.lowerCase.get(filename.toLowerCase());
|
|
90
454
|
if (!record)
|
|
91
455
|
throw new Error(`File ${filename} not found in ZIP.`);
|
|
92
456
|
return record;
|
|
@@ -118,17 +482,24 @@ class ZipRandomAccessParser {
|
|
|
118
482
|
throw new Error(`Unsupported compression method ${record.compressionMethod} for ${filename}`);
|
|
119
483
|
}
|
|
120
484
|
const stream = await this.extractRawStream(filename);
|
|
121
|
-
|
|
122
|
-
|
|
485
|
+
const data = record.compressionMethod === 0 ? stream
|
|
486
|
+
: stream.pipeThrough(new DecompressionStream('deflate-raw'));
|
|
123
487
|
// Node reports bad deflate data as a bare TypeError; name the entry instead
|
|
124
|
-
const inflated =
|
|
488
|
+
const inflated = data.getReader();
|
|
125
489
|
let total = 0;
|
|
490
|
+
let crc = 0xffffffff;
|
|
126
491
|
return new ReadableStream({
|
|
127
492
|
async pull(controller) {
|
|
128
493
|
try {
|
|
129
494
|
const { done, value } = await inflated.read();
|
|
130
|
-
if (done)
|
|
495
|
+
if (done) {
|
|
496
|
+
// Changed bytes must fail, not read as different data
|
|
497
|
+
if (total !== record.uncompressedSize || ((crc ^ 0xffffffff) >>> 0) !== record.crc) {
|
|
498
|
+
return controller.error(new Error(`Corrupt ZIP entry ${filename}: its data does not match the size and CRC-32 in the directory`));
|
|
499
|
+
}
|
|
131
500
|
return controller.close();
|
|
501
|
+
}
|
|
502
|
+
crc = crc32Update(crc, value);
|
|
132
503
|
// The directory states each entry's size; inflating past it means a crafted entry (a zip bomb)
|
|
133
504
|
total += value.length;
|
|
134
505
|
if (total > record.uncompressedSize) {
|
|
@@ -181,7 +552,7 @@ function stripNamespace(name) {
|
|
|
181
552
|
// Emits one array of tokens per input chunk. Per-token stream chunks cost a promise round-trip
|
|
182
553
|
// each (~5 per cell), which dominated read time; batching removes that overhead.
|
|
183
554
|
function createXmlBatchParser() {
|
|
184
|
-
|
|
555
|
+
let decoder;
|
|
185
556
|
let buffer = '';
|
|
186
557
|
let isFirstChunk = true;
|
|
187
558
|
// When a construct spans chunks, remember how far it was already scanned (and the quote state
|
|
@@ -192,6 +563,8 @@ function createXmlBatchParser() {
|
|
|
192
563
|
let pendingQuote = '';
|
|
193
564
|
return new TransformStream({
|
|
194
565
|
transform(chunk, controller) {
|
|
566
|
+
// XML parsers must read UTF-16 as well as UTF-8; a UTF-16 part starts with its byte-order mark
|
|
567
|
+
decoder ??= new TextDecoder(chunk[0] === 0xff && chunk[1] === 0xfe ? 'utf-16le' : chunk[0] === 0xfe && chunk[1] === 0xff ? 'utf-16be' : 'utf-8');
|
|
195
568
|
buffer += decoder.decode(chunk, { stream: true });
|
|
196
569
|
if (isFirstChunk) {
|
|
197
570
|
if (buffer.charCodeAt(0) === 0xFEFF) {
|
|
@@ -290,7 +663,7 @@ function createXmlBatchParser() {
|
|
|
290
663
|
controller.enqueue(out);
|
|
291
664
|
},
|
|
292
665
|
flush(controller) {
|
|
293
|
-
buffer += decoder
|
|
666
|
+
buffer += decoder?.decode() ?? '';
|
|
294
667
|
// Handle trailing text if any
|
|
295
668
|
if (buffer.length > 0 && pending !== 'tag' && pending !== 'cdata' && pending !== 'comment') {
|
|
296
669
|
controller.enqueue([{ type: 'text', value: unescapeXml$1(normalizeEol(buffer)) }]);
|
|
@@ -362,6 +735,17 @@ async function sheetToJson(parseResult, headerRowIndex = 0) {
|
|
|
362
735
|
});
|
|
363
736
|
continue;
|
|
364
737
|
}
|
|
738
|
+
// Values right of the header row get their own column name, as blank headers do
|
|
739
|
+
for (let i = headers.length; i < row.cells.length; i++) {
|
|
740
|
+
if (row.cells[i] === null || row.cells[i] === undefined)
|
|
741
|
+
continue;
|
|
742
|
+
let name = `Column${i + 1}`;
|
|
743
|
+
for (let n = 2; headers.includes(name); n++)
|
|
744
|
+
name = `Column${i + 1}_${n}`;
|
|
745
|
+
while (headers.length < i)
|
|
746
|
+
headers.push(`Column${headers.length + 1}`);
|
|
747
|
+
headers.push(name);
|
|
748
|
+
}
|
|
365
749
|
const obj = {};
|
|
366
750
|
for (let i = 0; i < headers.length; i++) {
|
|
367
751
|
const value = row.cells[i] ?? null;
|
|
@@ -386,9 +770,13 @@ function escapeCsv(val) {
|
|
|
386
770
|
}
|
|
387
771
|
async function streamToCsv(parseResult) {
|
|
388
772
|
const rows = [];
|
|
773
|
+
let last = 0;
|
|
389
774
|
for await (const row of parseResult) {
|
|
390
|
-
|
|
391
|
-
|
|
775
|
+
// Rows missing between rows are blank lines, as in Excel's CSV export, so nothing shifts up
|
|
776
|
+
for (let gap = last ? row.rowNumber - last - 1 : 0; gap > 0; gap--)
|
|
777
|
+
rows.push('');
|
|
778
|
+
last = row.rowNumber;
|
|
779
|
+
rows.push(row.cells.map(escapeCsv).join(','));
|
|
392
780
|
}
|
|
393
781
|
return rows.join('\n');
|
|
394
782
|
}
|
|
@@ -434,15 +822,18 @@ async function resolveWorkbookParts(readText) {
|
|
|
434
822
|
const workbookXml = await readText(workbookPath);
|
|
435
823
|
const rels = parseRels(await readText(relsPathOf(workbookPath)), dirOf(workbookPath));
|
|
436
824
|
const sheets = new Map();
|
|
825
|
+
const chartsheets = new Set();
|
|
437
826
|
for (const [tag] of workbookXml.matchAll(/<(?:\w+:)?sheet\b[^>]*>/g)) {
|
|
438
827
|
const name = attr(tag, 'name');
|
|
439
828
|
const rId = attr(tag, 'r:id') ?? attr(tag, '\\w+:id');
|
|
440
829
|
const rel = rId ? rels.find(r => r.id === rId && !r.external) : undefined;
|
|
441
830
|
if (name && rel)
|
|
442
831
|
sheets.set(name, rel.path);
|
|
832
|
+
if (rel?.type.endsWith('/chartsheet'))
|
|
833
|
+
chartsheets.add(rel.path);
|
|
443
834
|
}
|
|
444
835
|
return {
|
|
445
|
-
workbookPath, workbookXml, sheets,
|
|
836
|
+
workbookPath, workbookXml, sheets, chartsheets,
|
|
446
837
|
sharedStrings: byType(rels, '/sharedStrings'), styles: byType(rels, '/styles'), theme: byType(rels, '/theme'),
|
|
447
838
|
};
|
|
448
839
|
}
|
|
@@ -519,16 +910,23 @@ function mapFormulaRefs(formula, mapCol, mapRow) {
|
|
|
519
910
|
if (isEnd)
|
|
520
911
|
sheet = prevSheet;
|
|
521
912
|
else if (before.endsWith('!')) {
|
|
522
|
-
|
|
523
|
-
|
|
913
|
+
// [1]Sheet!A1 points into another workbook: "[" can't be in a sheet name, so no sheet matches it
|
|
914
|
+
const plain = /(\]?)([A-Za-z0-9_.\u00C0-\uFFFF]+)!$/.exec(before);
|
|
915
|
+
sheet = plain ? (plain[1] ? `[external]${plain[2]}` : plain[2])
|
|
916
|
+
: offset === 1 && i > 0 && parts[i - 1].startsWith("'") ? unquoteSheet(parts[i - 1]) : undefined;
|
|
524
917
|
}
|
|
525
918
|
const role = isEnd ? 'end' : part[offset + m.length] === ':' ? 'start' : 'single';
|
|
919
|
+
// The end of a range pushed past the sheet's edge stays at the edge, as in Excel (SUM(B1:B1048576))
|
|
526
920
|
const col = (abs, letters, r) => {
|
|
527
|
-
|
|
921
|
+
let c = mapCol(colIndex(letters), !!abs, r, sheet);
|
|
922
|
+
if (c !== null && c >= MAX_COLUMNS$1 && r === 'end' && colIndex(letters) < MAX_COLUMNS$1)
|
|
923
|
+
c = MAX_COLUMNS$1 - 1;
|
|
528
924
|
return c !== null && c >= 0 && c < MAX_COLUMNS$1 ? abs + colLetter(c) : null;
|
|
529
925
|
};
|
|
530
926
|
const row = (abs, digits, r) => {
|
|
531
|
-
|
|
927
|
+
let n = mapRow(parseInt(digits, 10), !!abs, r, sheet);
|
|
928
|
+
if (n !== null && n > MAX_ROWS$1 && r === 'end' && parseInt(digits, 10) <= MAX_ROWS$1)
|
|
929
|
+
n = MAX_ROWS$1;
|
|
532
930
|
return n !== null && n >= 1 && n <= MAX_ROWS$1 ? n : null;
|
|
533
931
|
};
|
|
534
932
|
prevEnd = offset + m.length;
|
|
@@ -576,7 +974,7 @@ function dateToSerial(d) {
|
|
|
576
974
|
function encodeXString(s) {
|
|
577
975
|
return s
|
|
578
976
|
.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')}_`);
|
|
977
|
+
.replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F\uFFFE\uFFFF]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
|
|
580
978
|
}
|
|
581
979
|
// The format SheetWriter gives a date without one: the time only when there is one
|
|
582
980
|
const defaultDateFormat = (d) => d.getTime() % 86400000 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm:ss';
|
|
@@ -620,15 +1018,18 @@ async function recalcOnOpen(readText, parts, workbookXml = parts.workbookXml) {
|
|
|
620
1018
|
}
|
|
621
1019
|
// Excel refuses to open a workbook that breaks these rules
|
|
622
1020
|
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.`);
|
|
1021
|
+
if (!name || name.length > 31 || /[\\/?*:[\]\x00-\x1F\uFFFE\uFFFF]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
|
|
1022
|
+
throw new Error(`Invalid sheet name "${name}": 1-31 characters, none of \\ / ? * : [ ] or control characters, and no leading or trailing apostrophe.`);
|
|
625
1023
|
}
|
|
626
1024
|
for (const other of existing) {
|
|
627
1025
|
if (other.toLowerCase() === name.toLowerCase())
|
|
628
1026
|
throw new Error(`Duplicate sheet name "${name}".`);
|
|
629
1027
|
}
|
|
630
1028
|
}
|
|
631
|
-
|
|
1029
|
+
// Characters XML 1.0 can't carry at all. Text that allows _xHHHH_ escapes keeps them through
|
|
1030
|
+
// encodeXString first; anywhere else (names, properties, formulas) they are dropped.
|
|
1031
|
+
const INVALID_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g;
|
|
1032
|
+
const escapeXml$4 = (val) => val.replace(INVALID_XML_CHARS, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
632
1033
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
633
1034
|
// Out-of-range references (�) stay as written instead of throwing
|
|
634
1035
|
const fromCodePoint = (cp, m) => (cp <= 0x10ffff ? String.fromCodePoint(cp) : m);
|
|
@@ -1284,6 +1685,13 @@ class ParseResult {
|
|
|
1284
1685
|
return this.comments();
|
|
1285
1686
|
}
|
|
1286
1687
|
}
|
|
1688
|
+
// "2026-10-08T14:05:00" or "2026-10-08" (a t="d" cell, no zone: UTC) as an ISO string; undefined when unreadable
|
|
1689
|
+
function isoDateCell(text) {
|
|
1690
|
+
const t = text.trim();
|
|
1691
|
+
const zoned = /(?:Z|[+-]\d\d:?\d\d)$/.test(t) ? t : t.includes('T') ? t + 'Z' : t + 'T00:00:00Z';
|
|
1692
|
+
const d = new Date(zoned);
|
|
1693
|
+
return isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
1694
|
+
}
|
|
1287
1695
|
function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, options = {}) {
|
|
1288
1696
|
let resolveMeta;
|
|
1289
1697
|
let rejectMeta;
|
|
@@ -1319,12 +1727,14 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1319
1727
|
let rowStyles;
|
|
1320
1728
|
let rowRichText;
|
|
1321
1729
|
let rowFormatted;
|
|
1730
|
+
let rowErrors;
|
|
1322
1731
|
let rawNumber; // a date cell's serial, for formatted text
|
|
1323
1732
|
const runs = options.richText ? new RichTextCollector(options.richText) : undefined;
|
|
1324
1733
|
let skipDepth = 0;
|
|
1325
1734
|
// Shared formula anchors by si: the text and the cell it was written for
|
|
1326
1735
|
const sharedFormulas = new Map();
|
|
1327
1736
|
let currentRowNumber = 0;
|
|
1737
|
+
let openedWorksheet = false, closedWorksheet = false;
|
|
1328
1738
|
let currentColIndex = 0;
|
|
1329
1739
|
let currentCellCol = 0;
|
|
1330
1740
|
try {
|
|
@@ -1345,18 +1755,17 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1345
1755
|
}
|
|
1346
1756
|
continue;
|
|
1347
1757
|
}
|
|
1758
|
+
if (token.name === 'worksheet')
|
|
1759
|
+
openedWorksheet = true;
|
|
1348
1760
|
if (token.name === 'row') {
|
|
1349
1761
|
inRow = true;
|
|
1350
1762
|
currentRow = [];
|
|
1351
1763
|
rowFormulas = rowStyles = rowRichText = rowFormatted = undefined;
|
|
1764
|
+
rowErrors = undefined;
|
|
1352
1765
|
currentColIndex = 0;
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
}
|
|
1357
|
-
else {
|
|
1358
|
-
currentRowNumber++;
|
|
1359
|
-
}
|
|
1766
|
+
// A missing or unusable r ("abc", "0") means the row after the previous one
|
|
1767
|
+
const r = Number(token.attributes['r']);
|
|
1768
|
+
currentRowNumber = Number.isInteger(r) && r >= 1 ? r : currentRowNumber + 1;
|
|
1360
1769
|
if (token.attributes['hidden'] === '1' || token.attributes['hidden'] === 'true') {
|
|
1361
1770
|
hiddenRows.push(currentRowNumber);
|
|
1362
1771
|
}
|
|
@@ -1446,6 +1855,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1446
1855
|
}
|
|
1447
1856
|
if (skipDepth > 0)
|
|
1448
1857
|
continue;
|
|
1858
|
+
if (token.name === 'worksheet')
|
|
1859
|
+
closedWorksheet = true;
|
|
1449
1860
|
if (token.name === 'row') {
|
|
1450
1861
|
const row = { rowNumber: currentRowNumber, cells: currentRow };
|
|
1451
1862
|
if (rowFormulas)
|
|
@@ -1456,6 +1867,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1456
1867
|
row.richText = rowRichText;
|
|
1457
1868
|
if (rowFormatted)
|
|
1458
1869
|
row.formatted = rowFormatted;
|
|
1870
|
+
if (rowErrors)
|
|
1871
|
+
row.errors = rowErrors;
|
|
1459
1872
|
yield row;
|
|
1460
1873
|
inRow = false;
|
|
1461
1874
|
}
|
|
@@ -1484,11 +1897,19 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1484
1897
|
}
|
|
1485
1898
|
else if (currentCellType === 'e') {
|
|
1486
1899
|
resolvedValue = currentCellValue;
|
|
1900
|
+
if (options.errors)
|
|
1901
|
+
(rowErrors ??= [])[currentCellCol] = true;
|
|
1487
1902
|
}
|
|
1488
1903
|
else {
|
|
1489
1904
|
const num = Number(currentCellValue);
|
|
1490
|
-
|
|
1491
|
-
|
|
1905
|
+
const iso = currentCellType === 'd' ? isoDateCell(currentCellValue) : undefined;
|
|
1906
|
+
if (iso) {
|
|
1907
|
+
// t="d": ISO text without a zone, read as UTC like serial dates
|
|
1908
|
+
rawNumber = dateToSerial(new Date(iso)) - (is1904 ? 1462 : 0);
|
|
1909
|
+
resolvedValue = iso;
|
|
1910
|
+
}
|
|
1911
|
+
else if (isNaN(num)) {
|
|
1912
|
+
resolvedValue = currentCellValue;
|
|
1492
1913
|
}
|
|
1493
1914
|
else if (currentStyleId !== null && styles.get(currentStyleId) === 14) {
|
|
1494
1915
|
rawNumber = num;
|
|
@@ -1551,6 +1972,9 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1551
1972
|
}
|
|
1552
1973
|
}
|
|
1553
1974
|
}
|
|
1975
|
+
// A part cut off inside a row (an intact zip around a truncated sheet) must not read as a shorter sheet
|
|
1976
|
+
if (inRow || inCell || openedWorksheet && !closedWorksheet)
|
|
1977
|
+
throw new Error('Worksheet XML is cut off: the sheet ends before its closing tags.');
|
|
1554
1978
|
const hiddenCols = [];
|
|
1555
1979
|
hiddenColMarks.forEach((hidden, c) => { if (hidden)
|
|
1556
1980
|
hiddenCols.push(c); });
|
|
@@ -1570,6 +1994,13 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
|
|
|
1570
1994
|
const EMU_PER_PX = 9525;
|
|
1571
1995
|
// Format and pixel size from the file header
|
|
1572
1996
|
function imageInfo(b) {
|
|
1997
|
+
const info = headerInfo(b);
|
|
1998
|
+
// A truncated header reads past the end of the bytes and gives NaN (or 0) sizes
|
|
1999
|
+
if (!(info.width > 0 && info.height > 0))
|
|
2000
|
+
throw new Error(`The ${info.ext.toUpperCase()} image is truncated or has no size.`);
|
|
2001
|
+
return info;
|
|
2002
|
+
}
|
|
2003
|
+
function headerInfo(b) {
|
|
1573
2004
|
const be16 = (i) => (b[i] << 8) | b[i + 1];
|
|
1574
2005
|
const be32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
1575
2006
|
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
@@ -1648,7 +2079,6 @@ async function readSheetImages(readText, readBytes, sheetPath) {
|
|
|
1648
2079
|
}
|
|
1649
2080
|
return images;
|
|
1650
2081
|
}
|
|
1651
|
-
const escapeXml$4 = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1652
2082
|
const cellPos = (ref) => {
|
|
1653
2083
|
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(ref.trim());
|
|
1654
2084
|
const col = m ? colIndex(m[1]) : -1, row = m ? parseInt(m[2], 10) - 1 : -1;
|
|
@@ -1664,6 +2094,9 @@ const DRAWING_NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/
|
|
|
1664
2094
|
// none; a missing side keeps its aspect ratio. `attrs` lands on the anchor element, e.g. DRAWING_NS
|
|
1665
2095
|
// when the anchor is added to a drawing that declares other prefixes.
|
|
1666
2096
|
function anchorXml(place, natural, body, attrs = '') {
|
|
2097
|
+
if (!place || typeof place.range !== 'string' && typeof place.at !== 'string') {
|
|
2098
|
+
throw new Error('A picture or chart needs a placement: "at" (a cell) or "range".');
|
|
2099
|
+
}
|
|
1667
2100
|
if ('range' in place) {
|
|
1668
2101
|
const [from, to = from] = place.range.split(':');
|
|
1669
2102
|
const a = cellPos(from), b = cellPos(to);
|
|
@@ -1675,6 +2108,9 @@ function anchorXml(place, natural, body, attrs = '') {
|
|
|
1675
2108
|
const { width: w, height: h } = natural;
|
|
1676
2109
|
const width = place.width ?? (place.height ? w * place.height / h : w);
|
|
1677
2110
|
const height = place.height ?? h * width / w;
|
|
2111
|
+
if (!(width > 0 && height > 0 && isFinite(width) && isFinite(height))) {
|
|
2112
|
+
throw new Error(`Invalid size ${place.width ?? ''}x${place.height ?? ''} at ${place.at}: width and height must be positive numbers.`);
|
|
2113
|
+
}
|
|
1678
2114
|
return `<xdr:oneCellAnchor${attrs}>${marker('from', cellPos(place.at))}<xdr:ext cx="${Math.round(width * EMU_PER_PX)}" cy="${Math.round(height * EMU_PER_PX)}"/>${body}<xdr:clientData/></xdr:oneCellAnchor>`;
|
|
1679
2115
|
}
|
|
1680
2116
|
const drawingPartXml = (anchors) => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<xdr:wsDr ${DRAWING_NS}>${anchors}</xdr:wsDr>`;
|
|
@@ -1686,166 +2122,6 @@ function drawingXml(images, infos, rIds) {
|
|
|
1686
2122
|
`<xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic>`)).join(''));
|
|
1687
2123
|
}
|
|
1688
2124
|
|
|
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
2125
|
// Reader for Excel 97-2003 workbooks (.xls, BIFF8 records in a compound file) [MS-XLS].
|
|
1850
2126
|
// Returns the same rows and metadata as the .xlsx reader. The file is held in memory; .xls sheets
|
|
1851
2127
|
// are limited to 65,536 rows, so that stays modest.
|
|
@@ -2390,8 +2666,10 @@ async function readOdsWorkbook(content, readText) {
|
|
|
2390
2666
|
if (!properties.creator && text('dc:creator'))
|
|
2391
2667
|
properties.creator = text('dc:creator');
|
|
2392
2668
|
const created = text('meta:creation-date');
|
|
2393
|
-
|
|
2394
|
-
|
|
2669
|
+
// Without a zone the time is UTC, as written by OdsWriter (and stored by Excel)
|
|
2670
|
+
const createdAt = created && new Date(/(?:Z|[+-]\d\d:?\d\d)$/.test(created) ? created : created + 'Z');
|
|
2671
|
+
if (createdAt && !isNaN(createdAt.getTime()))
|
|
2672
|
+
properties.created = createdAt;
|
|
2395
2673
|
return { sheets, definedNames, properties };
|
|
2396
2674
|
}
|
|
2397
2675
|
// Frozen panes are view settings, kept in settings.xml per sheet
|
|
@@ -2640,9 +2918,7 @@ function parseOds(content, readText, options) {
|
|
|
2640
2918
|
return new ParseResult(rows, metadata, async () => [], async () => { await finished; return comments; });
|
|
2641
2919
|
}
|
|
2642
2920
|
|
|
2643
|
-
|
|
2644
|
-
return String(val).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2645
|
-
}
|
|
2921
|
+
const escapeXml$3 = (val) => escapeXml$4(String(val));
|
|
2646
2922
|
// Child elements of a <font> (styles) or, with nameTag "rFont", of a rich text run's <rPr>
|
|
2647
2923
|
function fontXml(font, nameTag = 'name') {
|
|
2648
2924
|
let xml = '';
|
|
@@ -2837,9 +3113,7 @@ ${this.dxfs.size ? `<dxfs count="${this.dxfs.size}">${[...this.dxfs.keys()].map(
|
|
|
2837
3113
|
}
|
|
2838
3114
|
}
|
|
2839
3115
|
|
|
2840
|
-
|
|
2841
|
-
return String(val).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2842
|
-
}
|
|
3116
|
+
const escapeXml$2 = (val) => escapeXml$4(String(val));
|
|
2843
3117
|
const color = (rgb) => `<color rgb="${argb(rgb)}"/>`;
|
|
2844
3118
|
const bound = (type, value) => value === undefined ? `<cfvo type="${type}"/>` : `<cfvo type="num" val="${escapeXml$2(value)}"/>`;
|
|
2845
3119
|
const formula = (f) => `<formula>${escapeXml$2(String(f).replace(/^=/, ''))}</formula>`;
|
|
@@ -2911,10 +3185,32 @@ class ConditionalFormatter {
|
|
|
2911
3185
|
}
|
|
2912
3186
|
}
|
|
2913
3187
|
|
|
3188
|
+
// An Excel error value (#DIV/0!, #VALUE!), kept apart from text that happens to start with "#"
|
|
3189
|
+
class FormulaError {
|
|
3190
|
+
code;
|
|
3191
|
+
constructor(code) {
|
|
3192
|
+
this.code = code;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
// Syntax or functions this engine doesn't know: no cached value, Excel computes it on open
|
|
3196
|
+
class Unsupported extends Error {
|
|
3197
|
+
}
|
|
3198
|
+
// A formula that reads its own cell, directly or through others
|
|
3199
|
+
class Circular extends Error {
|
|
3200
|
+
}
|
|
3201
|
+
const DIV0 = new FormulaError('#DIV/0!');
|
|
3202
|
+
const VALUE = new FormulaError('#VALUE!');
|
|
3203
|
+
// Numbers turned into text keep Excel's 15 significant digits ("0.333333333333333", 1E+21)
|
|
3204
|
+
function numberText(n) {
|
|
3205
|
+
return String(Number(n.toPrecision(15))).replace('e', 'E');
|
|
3206
|
+
}
|
|
2914
3207
|
class FormulaEngine {
|
|
2915
3208
|
cells = new Map();
|
|
3209
|
+
results = new Map();
|
|
3210
|
+
evaluating = new Set();
|
|
2916
3211
|
clear() {
|
|
2917
3212
|
this.cells.clear();
|
|
3213
|
+
this.results.clear();
|
|
2918
3214
|
}
|
|
2919
3215
|
loadData(data, startRow = 1) {
|
|
2920
3216
|
data.forEach((row, ri) => {
|
|
@@ -2924,25 +3220,73 @@ class FormulaEngine {
|
|
|
2924
3220
|
});
|
|
2925
3221
|
});
|
|
2926
3222
|
}
|
|
3223
|
+
// Errors come back as their code ("#DIV/0!")
|
|
2927
3224
|
evaluate(formula) {
|
|
3225
|
+
const v = this.evaluateRaw(formula);
|
|
3226
|
+
return v instanceof FormulaError ? v.code : v;
|
|
3227
|
+
}
|
|
3228
|
+
// Like evaluate, but errors stay FormulaError so text such as "#1 pick" is told apart from them
|
|
3229
|
+
evaluateRaw(formula) {
|
|
3230
|
+
try {
|
|
3231
|
+
return this.run(formula);
|
|
3232
|
+
}
|
|
3233
|
+
catch (err) {
|
|
3234
|
+
// A circular reference is stored as 0, as Excel does
|
|
3235
|
+
if (err instanceof Circular)
|
|
3236
|
+
return 0;
|
|
3237
|
+
return null;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
// The value of a loaded cell, computing its formula (and the formulas it reads) when it has one
|
|
3241
|
+
cellValue(ref) {
|
|
2928
3242
|
try {
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
return
|
|
3243
|
+
return this.lookup(ref.toUpperCase());
|
|
3244
|
+
}
|
|
3245
|
+
catch (err) {
|
|
3246
|
+
if (err instanceof Circular)
|
|
3247
|
+
return 0;
|
|
3248
|
+
return null;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
run(formula) {
|
|
3252
|
+
const clean = formula.replace(/^=/, '').trim();
|
|
3253
|
+
if (!clean)
|
|
3254
|
+
return null;
|
|
3255
|
+
// A formula is never blank: =A1 over an empty cell is 0
|
|
3256
|
+
return this.scalar(this.parse(this.tokenize(clean))) ?? 0;
|
|
3257
|
+
}
|
|
3258
|
+
lookup(ref) {
|
|
3259
|
+
const cell = this.cells.get(ref);
|
|
3260
|
+
if (cell === null || typeof cell !== 'object')
|
|
3261
|
+
return cell ?? null;
|
|
3262
|
+
if (this.results.has(ref)) {
|
|
3263
|
+
const known = this.results.get(ref);
|
|
3264
|
+
if (known === undefined)
|
|
3265
|
+
throw new Unsupported();
|
|
3266
|
+
return known;
|
|
3267
|
+
}
|
|
3268
|
+
if (this.evaluating.has(ref))
|
|
3269
|
+
throw new Circular();
|
|
3270
|
+
this.evaluating.add(ref);
|
|
3271
|
+
try {
|
|
3272
|
+
const v = this.run(cell.formula);
|
|
3273
|
+
this.results.set(ref, v);
|
|
3274
|
+
return v;
|
|
3275
|
+
}
|
|
3276
|
+
catch (err) {
|
|
3277
|
+
if (!(err instanceof Circular))
|
|
3278
|
+
this.results.set(ref, undefined);
|
|
3279
|
+
throw err;
|
|
2935
3280
|
}
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
return null;
|
|
3281
|
+
finally {
|
|
3282
|
+
this.evaluating.delete(ref);
|
|
2939
3283
|
}
|
|
2940
3284
|
}
|
|
2941
3285
|
tokenize(expr) {
|
|
2942
3286
|
const tokens = [];
|
|
2943
3287
|
let i = 0;
|
|
2944
3288
|
while (i < expr.length) {
|
|
2945
|
-
|
|
3289
|
+
const char = expr[i];
|
|
2946
3290
|
if (/\s/.test(char)) {
|
|
2947
3291
|
i++;
|
|
2948
3292
|
continue;
|
|
@@ -2962,7 +3306,7 @@ class FormulaEngine {
|
|
|
2962
3306
|
i++;
|
|
2963
3307
|
continue;
|
|
2964
3308
|
}
|
|
2965
|
-
if (/[
|
|
3309
|
+
if (/[+\-*/^<>=%&]/.test(char)) {
|
|
2966
3310
|
let op = char;
|
|
2967
3311
|
if (char === '<' || char === '>') {
|
|
2968
3312
|
if (expr[i + 1] === '=') {
|
|
@@ -2978,29 +3322,34 @@ class FormulaEngine {
|
|
|
2978
3322
|
i++;
|
|
2979
3323
|
continue;
|
|
2980
3324
|
}
|
|
2981
|
-
if (char === '"'
|
|
2982
|
-
|
|
3325
|
+
if (char === '"') {
|
|
3326
|
+
// "" inside a string is one quote
|
|
2983
3327
|
let str = '';
|
|
2984
3328
|
i++;
|
|
2985
|
-
|
|
3329
|
+
for (;;) {
|
|
3330
|
+
if (i >= expr.length)
|
|
3331
|
+
throw new Unsupported();
|
|
3332
|
+
if (expr[i] === '"') {
|
|
3333
|
+
if (expr[i + 1] !== '"')
|
|
3334
|
+
break;
|
|
3335
|
+
i++;
|
|
3336
|
+
}
|
|
2986
3337
|
str += expr[i++];
|
|
2987
3338
|
}
|
|
2988
3339
|
i++;
|
|
2989
3340
|
tokens.push({ type: 'STRING', value: str });
|
|
2990
3341
|
continue;
|
|
2991
3342
|
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
}
|
|
2997
|
-
tokens.push({ type: 'NUMBER', value: num });
|
|
3343
|
+
const num = /^(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/.exec(expr.slice(i));
|
|
3344
|
+
if (num) {
|
|
3345
|
+
tokens.push({ type: 'NUMBER', value: num[0] });
|
|
3346
|
+
i += num[0].length;
|
|
2998
3347
|
continue;
|
|
2999
3348
|
}
|
|
3000
3349
|
if (/[A-Za-z$]/.test(char)) {
|
|
3001
3350
|
// "$" only pins a reference when copied; it does not change what it points at
|
|
3002
3351
|
let id = '';
|
|
3003
|
-
while (i < expr.length && /[A-Za-z0-9$]/.test(expr[i])) {
|
|
3352
|
+
while (i < expr.length && /[A-Za-z0-9$_.]/.test(expr[i])) {
|
|
3004
3353
|
id += expr[i++];
|
|
3005
3354
|
}
|
|
3006
3355
|
id = id.replace(/\$/g, '');
|
|
@@ -3011,9 +3360,11 @@ class FormulaEngine {
|
|
|
3011
3360
|
endId += expr[i++];
|
|
3012
3361
|
}
|
|
3013
3362
|
endId = endId.replace(/\$/g, '');
|
|
3363
|
+
if (!/^[A-Z]{1,3}\d+$/i.test(id) || !/^[A-Z]{1,3}\d+$/i.test(endId))
|
|
3364
|
+
throw new Unsupported();
|
|
3014
3365
|
tokens.push({ type: 'RANGE', value: id.toUpperCase() + ':' + endId.toUpperCase() });
|
|
3015
3366
|
}
|
|
3016
|
-
else if (/^[A-Z]
|
|
3367
|
+
else if (/^[A-Z]{1,3}\d+$/i.test(id)) {
|
|
3017
3368
|
tokens.push({ type: 'CELL', value: id.toUpperCase() });
|
|
3018
3369
|
}
|
|
3019
3370
|
else {
|
|
@@ -3021,16 +3372,23 @@ class FormulaEngine {
|
|
|
3021
3372
|
}
|
|
3022
3373
|
continue;
|
|
3023
3374
|
}
|
|
3024
|
-
|
|
3375
|
+
// Sheet references ('Q1'!A1, Data!A1), arrays, error literals and the rest
|
|
3376
|
+
throw new Unsupported();
|
|
3025
3377
|
}
|
|
3026
3378
|
return tokens;
|
|
3027
3379
|
}
|
|
3028
3380
|
parse(tokens) {
|
|
3029
3381
|
let pos = 0;
|
|
3030
|
-
const
|
|
3382
|
+
const isOp = (...ops) => pos < tokens.length && tokens[pos].type === 'OP' && ops.includes(tokens[pos].value);
|
|
3383
|
+
const expect = (type) => {
|
|
3384
|
+
if (tokens[pos]?.type !== type)
|
|
3385
|
+
throw new Unsupported();
|
|
3386
|
+
pos++;
|
|
3387
|
+
};
|
|
3388
|
+
const parseAtom = () => {
|
|
3031
3389
|
const token = tokens[pos];
|
|
3032
3390
|
if (!token)
|
|
3033
|
-
throw new
|
|
3391
|
+
throw new Unsupported();
|
|
3034
3392
|
if (token.type === 'NUMBER') {
|
|
3035
3393
|
pos++;
|
|
3036
3394
|
return { type: 'NUMBER', value: parseFloat(token.value) };
|
|
@@ -3050,146 +3408,188 @@ class FormulaEngine {
|
|
|
3050
3408
|
if (token.type === 'IDENTIFIER') {
|
|
3051
3409
|
const name = token.value;
|
|
3052
3410
|
pos++;
|
|
3053
|
-
if (
|
|
3054
|
-
pos++;
|
|
3411
|
+
if (tokens[pos]?.type === 'PAREN_L') {
|
|
3412
|
+
pos++;
|
|
3055
3413
|
const args = [];
|
|
3056
|
-
if (tokens[pos]
|
|
3414
|
+
if (tokens[pos]?.type !== 'PAREN_R') {
|
|
3057
3415
|
args.push(parseExpression());
|
|
3058
|
-
while (
|
|
3416
|
+
while (tokens[pos]?.type === 'COMMA') {
|
|
3059
3417
|
pos++;
|
|
3060
3418
|
args.push(parseExpression());
|
|
3061
3419
|
}
|
|
3062
3420
|
}
|
|
3063
|
-
|
|
3064
|
-
throw new Error('Expected )');
|
|
3065
|
-
pos++;
|
|
3421
|
+
expect('PAREN_R');
|
|
3066
3422
|
return { type: 'CALL', name, args };
|
|
3067
3423
|
}
|
|
3068
|
-
// Handle true/false constants
|
|
3069
3424
|
if (name === 'TRUE')
|
|
3070
|
-
return { type: '
|
|
3425
|
+
return { type: 'BOOL', value: true };
|
|
3071
3426
|
if (name === 'FALSE')
|
|
3072
|
-
return { type: '
|
|
3073
|
-
throw new
|
|
3427
|
+
return { type: 'BOOL', value: false };
|
|
3428
|
+
throw new Unsupported(); // defined names
|
|
3074
3429
|
}
|
|
3075
3430
|
if (token.type === 'PAREN_L') {
|
|
3076
3431
|
pos++;
|
|
3077
3432
|
const node = parseExpression();
|
|
3078
|
-
|
|
3079
|
-
throw new Error('Expected )');
|
|
3080
|
-
pos++;
|
|
3433
|
+
expect('PAREN_R');
|
|
3081
3434
|
return node;
|
|
3082
3435
|
}
|
|
3083
|
-
throw new
|
|
3436
|
+
throw new Unsupported();
|
|
3084
3437
|
};
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
const op = tokens[pos].value;
|
|
3438
|
+
// Unary minus binds tighter than ^ in Excel (-2^2 is 4); % divides by 100
|
|
3439
|
+
const parseUnary = () => {
|
|
3440
|
+
if (isOp('-')) {
|
|
3089
3441
|
pos++;
|
|
3090
|
-
|
|
3442
|
+
return { type: 'NEG', left: parseUnary() };
|
|
3091
3443
|
}
|
|
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;
|
|
3444
|
+
if (isOp('+')) {
|
|
3098
3445
|
pos++;
|
|
3099
|
-
|
|
3446
|
+
return parseUnary();
|
|
3100
3447
|
}
|
|
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;
|
|
3448
|
+
let node = parseAtom();
|
|
3449
|
+
while (isOp('%')) {
|
|
3107
3450
|
pos++;
|
|
3108
|
-
node = { type: '
|
|
3451
|
+
node = { type: 'PERCENT', left: node };
|
|
3109
3452
|
}
|
|
3110
3453
|
return node;
|
|
3111
3454
|
};
|
|
3112
|
-
const
|
|
3113
|
-
let node =
|
|
3114
|
-
while (
|
|
3115
|
-
const
|
|
3116
|
-
|
|
3117
|
-
node = { type: 'BINARY', operator: op, left: node, right: parseTerm() };
|
|
3455
|
+
const binary = (next, ...ops) => () => {
|
|
3456
|
+
let node = next();
|
|
3457
|
+
while (isOp(...ops)) {
|
|
3458
|
+
const operator = tokens[pos++].value;
|
|
3459
|
+
node = { type: 'BINARY', operator, left: node, right: next() };
|
|
3118
3460
|
}
|
|
3119
3461
|
return node;
|
|
3120
3462
|
};
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3463
|
+
// Excel's ^ is left-associative: 2^3^2 is 64
|
|
3464
|
+
const parsePower = binary(parseUnary, '^');
|
|
3465
|
+
const parseFactor = binary(parsePower, '*', '/');
|
|
3466
|
+
const parseTerm = binary(parseFactor, '+', '-');
|
|
3467
|
+
const parseConcat = binary(parseTerm, '&');
|
|
3468
|
+
const parseExpression = binary(parseConcat, '=', '<>', '<', '>', '<=', '>=');
|
|
3469
|
+
const ast = parseExpression();
|
|
3470
|
+
if (pos !== tokens.length)
|
|
3471
|
+
throw new Unsupported();
|
|
3472
|
+
return ast;
|
|
3473
|
+
}
|
|
3474
|
+
// A single value: a range used where one value is expected is not supported (implicit intersection)
|
|
3475
|
+
scalar(node) {
|
|
3476
|
+
const v = this.evaluateAst(node);
|
|
3477
|
+
if (Array.isArray(v))
|
|
3478
|
+
throw new Unsupported();
|
|
3479
|
+
return v;
|
|
3125
3480
|
}
|
|
3126
3481
|
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
|
-
|
|
3482
|
+
switch (node.type) {
|
|
3483
|
+
case 'NUMBER':
|
|
3484
|
+
case 'STRING':
|
|
3485
|
+
case 'BOOL': return node.value;
|
|
3486
|
+
case 'CELL': return this.lookup(node.value);
|
|
3487
|
+
case 'RANGE': return this.getRangeValues(node.value);
|
|
3488
|
+
case 'NEG': {
|
|
3489
|
+
const n = toNumber(this.scalar(node.left));
|
|
3490
|
+
return n instanceof FormulaError ? n : -n;
|
|
3491
|
+
}
|
|
3492
|
+
case 'PERCENT': {
|
|
3493
|
+
const n = toNumber(this.scalar(node.left));
|
|
3494
|
+
return n instanceof FormulaError ? n : n / 100;
|
|
3495
|
+
}
|
|
3496
|
+
case 'BINARY': return this.binary(node.operator, this.scalar(node.left), this.scalar(node.right));
|
|
3497
|
+
case 'CALL': return this.call(node.name, node.args);
|
|
3498
|
+
}
|
|
3499
|
+
throw new Unsupported();
|
|
3500
|
+
}
|
|
3501
|
+
binary(op, left, right) {
|
|
3502
|
+
if (left instanceof FormulaError)
|
|
3503
|
+
return left;
|
|
3504
|
+
if (right instanceof FormulaError)
|
|
3505
|
+
return right;
|
|
3506
|
+
if (op === '&')
|
|
3507
|
+
return toText(left) + toText(right);
|
|
3508
|
+
if (['=', '<>', '<', '>', '<=', '>='].includes(op)) {
|
|
3509
|
+
const c = compare(left, right);
|
|
3510
|
+
return op === '=' ? c === 0 : op === '<>' ? c !== 0 : op === '<' ? c < 0 : op === '>' ? c > 0 : op === '<=' ? c <= 0 : c >= 0;
|
|
3511
|
+
}
|
|
3512
|
+
const l = toNumber(left), r = toNumber(right);
|
|
3513
|
+
if (l instanceof FormulaError)
|
|
3514
|
+
return l;
|
|
3515
|
+
if (r instanceof FormulaError)
|
|
3516
|
+
return r;
|
|
3517
|
+
switch (op) {
|
|
3518
|
+
case '+': return l + r;
|
|
3519
|
+
case '-': return l - r;
|
|
3520
|
+
case '*': return l * r;
|
|
3521
|
+
case '/': return r === 0 ? DIV0 : l / r;
|
|
3522
|
+
case '^': {
|
|
3523
|
+
const p = Math.pow(l, r);
|
|
3524
|
+
return isFinite(p) ? p : new FormulaError('#NUM!');
|
|
3152
3525
|
}
|
|
3153
3526
|
}
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3527
|
+
throw new Unsupported();
|
|
3528
|
+
}
|
|
3529
|
+
call(name, argNodes) {
|
|
3530
|
+
if (name === 'IF') {
|
|
3531
|
+
if (argNodes.length < 1 || argNodes.length > 3)
|
|
3532
|
+
throw new Unsupported();
|
|
3533
|
+
const cond = toBool(this.scalar(argNodes[0]));
|
|
3534
|
+
if (cond instanceof FormulaError)
|
|
3535
|
+
return cond;
|
|
3536
|
+
if (cond)
|
|
3537
|
+
return argNodes.length > 1 ? this.scalar(argNodes[1]) ?? 0 : true;
|
|
3538
|
+
return argNodes.length > 2 ? this.scalar(argNodes[2]) ?? 0 : false;
|
|
3539
|
+
}
|
|
3540
|
+
if (name === 'CONCATENATE') {
|
|
3541
|
+
let out = '';
|
|
3542
|
+
for (const a of argNodes) {
|
|
3543
|
+
const v = this.scalar(a);
|
|
3544
|
+
if (v instanceof FormulaError)
|
|
3545
|
+
return v;
|
|
3546
|
+
out += toText(v);
|
|
3547
|
+
}
|
|
3548
|
+
return out;
|
|
3549
|
+
}
|
|
3550
|
+
if (!['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN'].includes(name))
|
|
3551
|
+
throw new Unsupported();
|
|
3552
|
+
// Values typed into the call count (TRUE as 1, "3" as 3); in referenced cells only numbers do
|
|
3553
|
+
const nums = [];
|
|
3554
|
+
for (const a of argNodes) {
|
|
3555
|
+
const v = this.evaluateAst(a);
|
|
3556
|
+
const referenced = a.type === 'CELL' || a.type === 'RANGE';
|
|
3557
|
+
for (const x of Array.isArray(v) ? v : [v]) {
|
|
3558
|
+
if (x instanceof FormulaError) {
|
|
3559
|
+
if (name === 'COUNT')
|
|
3560
|
+
continue;
|
|
3561
|
+
return x;
|
|
3166
3562
|
}
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3563
|
+
if (typeof x === 'number')
|
|
3564
|
+
nums.push(x);
|
|
3565
|
+
else if (!referenced && x !== null) {
|
|
3566
|
+
const n = toNumber(x);
|
|
3567
|
+
if (n instanceof FormulaError) {
|
|
3568
|
+
if (name === 'COUNT')
|
|
3569
|
+
continue;
|
|
3570
|
+
return n;
|
|
3571
|
+
}
|
|
3572
|
+
nums.push(n);
|
|
3170
3573
|
}
|
|
3171
|
-
case 'IF': return args[0] ? args[1] : args[2];
|
|
3172
|
-
case 'CONCATENATE': return this.flatten(args).join('');
|
|
3173
3574
|
}
|
|
3174
3575
|
}
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
return arr.flat(Infinity);
|
|
3576
|
+
switch (name) {
|
|
3577
|
+
case 'SUM': return nums.reduce((a, b) => a + b, 0);
|
|
3578
|
+
case 'COUNT': return nums.length;
|
|
3579
|
+
case 'AVERAGE': return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : DIV0;
|
|
3580
|
+
case 'MAX': return nums.length ? Math.max(...nums) : 0;
|
|
3581
|
+
default: return nums.length ? Math.min(...nums) : 0;
|
|
3582
|
+
}
|
|
3183
3583
|
}
|
|
3184
3584
|
getRangeValues(range) {
|
|
3185
3585
|
const [startRef, endRef] = range.split(':');
|
|
3186
|
-
const [
|
|
3187
|
-
const [
|
|
3586
|
+
const [c1, r1] = this.parseRef(startRef);
|
|
3587
|
+
const [c2, r2] = this.parseRef(endRef);
|
|
3188
3588
|
const values = [];
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
values.push(this.
|
|
3589
|
+
// A3:A1 is the same range as A1:A3
|
|
3590
|
+
for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
|
|
3591
|
+
for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
|
|
3592
|
+
values.push(this.lookup(this.toRef(r, c - 1)));
|
|
3193
3593
|
}
|
|
3194
3594
|
}
|
|
3195
3595
|
return values;
|
|
@@ -3210,185 +3610,72 @@ class FormulaEngine {
|
|
|
3210
3610
|
return `${col}${row}`;
|
|
3211
3611
|
}
|
|
3212
3612
|
}
|
|
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
|
-
}
|
|
3613
|
+
// Excel's conversions: blank is 0 / "" / FALSE, booleans are 1 and 0, text must read as a number
|
|
3614
|
+
function toNumber(v) {
|
|
3615
|
+
if (v instanceof FormulaError)
|
|
3616
|
+
return v;
|
|
3617
|
+
if (v === null)
|
|
3618
|
+
return 0;
|
|
3619
|
+
if (typeof v === 'boolean')
|
|
3620
|
+
return v ? 1 : 0;
|
|
3621
|
+
if (typeof v === 'number')
|
|
3622
|
+
return v;
|
|
3623
|
+
const t = v.trim();
|
|
3624
|
+
const n = t === '' ? NaN : Number(t);
|
|
3625
|
+
return isFinite(n) ? n : VALUE;
|
|
3626
|
+
}
|
|
3627
|
+
function toText(v) {
|
|
3628
|
+
if (v === null)
|
|
3629
|
+
return '';
|
|
3630
|
+
if (typeof v === 'boolean')
|
|
3631
|
+
return v ? 'TRUE' : 'FALSE';
|
|
3632
|
+
if (typeof v === 'number')
|
|
3633
|
+
return numberText(v);
|
|
3634
|
+
return String(v);
|
|
3635
|
+
}
|
|
3636
|
+
function toBool(v) {
|
|
3637
|
+
if (v instanceof FormulaError)
|
|
3638
|
+
return v;
|
|
3639
|
+
if (v === null)
|
|
3640
|
+
return false;
|
|
3641
|
+
if (typeof v === 'boolean')
|
|
3642
|
+
return v;
|
|
3643
|
+
if (typeof v === 'number')
|
|
3644
|
+
return v !== 0;
|
|
3645
|
+
const t = v.toUpperCase();
|
|
3646
|
+
return t === 'TRUE' ? true : t === 'FALSE' ? false : VALUE;
|
|
3647
|
+
}
|
|
3648
|
+
// Excel orders numbers < text < booleans; text compares without case. A blank takes the other side's type.
|
|
3649
|
+
function compare(a, b) {
|
|
3650
|
+
if (a === null)
|
|
3651
|
+
a = typeof b === 'string' ? '' : typeof b === 'boolean' ? false : 0;
|
|
3652
|
+
if (b === null)
|
|
3653
|
+
b = typeof a === 'string' ? '' : typeof a === 'boolean' ? false : 0;
|
|
3654
|
+
const rank = (v) => typeof v === 'number' ? 0 : typeof v === 'string' ? 1 : 2;
|
|
3655
|
+
if (rank(a) !== rank(b))
|
|
3656
|
+
return rank(a) - rank(b);
|
|
3657
|
+
if (typeof a === 'string') {
|
|
3658
|
+
const x = a.toLowerCase(), y = b.toLowerCase();
|
|
3659
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
3660
|
+
}
|
|
3661
|
+
const x = Number(a), y = Number(b);
|
|
3662
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
3382
3663
|
}
|
|
3383
3664
|
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3665
|
+
const escapeXml$1 = (val) => escapeXml$4(String(val)).replace(/'/g, ''');
|
|
3666
|
+
// "A1:B2" (or one cell) -> 0-based corners, checked against Excel's sheet size
|
|
3667
|
+
function parseRange$1(ref, what) {
|
|
3668
|
+
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/.exec(String(ref).trim());
|
|
3669
|
+
const box = m && {
|
|
3670
|
+
c1: colIndex(m[1]), r1: parseInt(m[2], 10) - 1,
|
|
3671
|
+
c2: colIndex(m[3] ?? m[1]), r2: parseInt(m[4] ?? m[2], 10) - 1,
|
|
3672
|
+
};
|
|
3673
|
+
if (!box || [box.c1, box.c2].some(c => c >= MAX_COLUMNS$1) || [box.r1, box.r2].some(r => r < 0 || r >= MAX_ROWS$1)) {
|
|
3674
|
+
throw new Error(`Invalid ${what} "${ref}".`);
|
|
3675
|
+
}
|
|
3676
|
+
return { c1: Math.min(box.c1, box.c2), r1: Math.min(box.r1, box.r2), c2: Math.max(box.c1, box.c2), r2: Math.max(box.r1, box.r2) };
|
|
3391
3677
|
}
|
|
3678
|
+
const overlaps = (a, b) => a.c1 <= b.c2 && b.c1 <= a.c2 && a.r1 <= b.r2 && b.r1 <= a.r2;
|
|
3392
3679
|
function isStyledCell$1(v) {
|
|
3393
3680
|
return typeof v === 'object' && v !== null && 'value' in v;
|
|
3394
3681
|
}
|
|
@@ -3429,7 +3716,7 @@ function appPropsXml(p) {
|
|
|
3429
3716
|
}
|
|
3430
3717
|
// A name Excel accepts: letters, digits, _ . and \, not a cell reference (A1, R1C1) and not reserved
|
|
3431
3718
|
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)) {
|
|
3719
|
+
if (!/^[A-Za-z_\\][A-Za-z0-9_.\\]*$/.test(name) || name.length > 255 || /^[A-Za-z]{1,3}\d+$/.test(name) || /^([Rr]\d*)?([Cc]\d*)?$/.test(name) || /^_xlnm\./i.test(name)) {
|
|
3433
3720
|
throw new Error(`Invalid defined name "${name}"`);
|
|
3434
3721
|
}
|
|
3435
3722
|
}
|
|
@@ -3504,6 +3791,8 @@ class SheetWriter {
|
|
|
3504
3791
|
this.formulaEngine.clear();
|
|
3505
3792
|
if (Array.isArray(sheet.rows)) {
|
|
3506
3793
|
this.formulaEngine.loadData(sheet.rows.map(row => row.map(cell => {
|
|
3794
|
+
if (isStyledCell$1(cell) && cell.formula)
|
|
3795
|
+
return { formula: cell.formula };
|
|
3507
3796
|
const v = !isStyledCell$1(cell) ? cell : cell.richText && cell.value == null ? cell.richText.map(r => r.text).join('') : cell.value;
|
|
3508
3797
|
return v instanceof Date ? dateToSerial(v) : v;
|
|
3509
3798
|
})));
|
|
@@ -3512,7 +3801,7 @@ class SheetWriter {
|
|
|
3512
3801
|
}
|
|
3513
3802
|
const parts = { links: [], comments: [] };
|
|
3514
3803
|
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));
|
|
3804
|
+
await zip.addFile(`xl/worksheets/sheet${i + 1}.xml`, this.buildWorksheetXmlStream(sheet.rows, sheet.options, parts, sheetTables.length, Array.isArray(sheet.rows)));
|
|
3516
3805
|
const images = sheet.options.images ?? [];
|
|
3517
3806
|
const drawing = images.length ? ++drawings : 0;
|
|
3518
3807
|
const rels = this.buildSheetRels(parts.links, drawing, parts.comments.length ? i + 1 : 0, sheetTables.map(t => t.id));
|
|
@@ -3550,14 +3839,26 @@ class SheetWriter {
|
|
|
3550
3839
|
const plan = [];
|
|
3551
3840
|
const names = new Set();
|
|
3552
3841
|
this.sheets.forEach((sheet, i) => {
|
|
3842
|
+
const boxes = [];
|
|
3553
3843
|
for (const table of sheet.options.tables ?? []) {
|
|
3554
|
-
|
|
3555
|
-
|
|
3844
|
+
// Table names follow the defined-name rules: no "AB12", "R1C1" or "C"
|
|
3845
|
+
let valid = !names.has(String(table.name).toLowerCase());
|
|
3846
|
+
try {
|
|
3847
|
+
validateDefinedName(table.name);
|
|
3556
3848
|
}
|
|
3849
|
+
catch {
|
|
3850
|
+
valid = false;
|
|
3851
|
+
}
|
|
3852
|
+
if (!valid)
|
|
3853
|
+
throw new Error(`Invalid or duplicate table name "${table.name}".`);
|
|
3557
3854
|
names.add(table.name.toLowerCase());
|
|
3558
3855
|
const m = /^([A-Za-z]{1,3})(\d+):([A-Za-z]{1,3})(\d+)$/.exec(table.ref.replace(/\$/g, ''));
|
|
3559
3856
|
if (!m)
|
|
3560
3857
|
throw new Error(`Invalid table range "${table.ref}".`);
|
|
3858
|
+
const box = parseRange$1(table.ref, 'table range');
|
|
3859
|
+
if (boxes.some(b => overlaps(b, box)))
|
|
3860
|
+
throw new Error(`Table "${table.name}" overlaps another table on sheet "${sheet.name}".`);
|
|
3861
|
+
boxes.push(box);
|
|
3561
3862
|
const first = colIndex(m[1]);
|
|
3562
3863
|
const width = colIndex(m[3]) - first + 1;
|
|
3563
3864
|
let columns = table.columns;
|
|
@@ -3623,9 +3924,9 @@ class SheetWriter {
|
|
|
3623
3924
|
await zip.addFile(filename, stream);
|
|
3624
3925
|
}
|
|
3625
3926
|
// Pull-based so rows are only generated as fast as the ZIP consumer drains them.
|
|
3626
|
-
buildWorksheetXmlStream(rows, options, parts, tables) {
|
|
3927
|
+
buildWorksheetXmlStream(rows, options, parts, tables, evaluate) {
|
|
3627
3928
|
const encoder = new TextEncoder();
|
|
3628
|
-
const chunks = this.worksheetXmlChunks(rows, options, parts, tables);
|
|
3929
|
+
const chunks = this.worksheetXmlChunks(rows, options, parts, tables, evaluate);
|
|
3629
3930
|
return new ReadableStream({
|
|
3630
3931
|
async pull(controller) {
|
|
3631
3932
|
const { done, value } = await chunks.next();
|
|
@@ -3640,7 +3941,8 @@ class SheetWriter {
|
|
|
3640
3941
|
});
|
|
3641
3942
|
}
|
|
3642
3943
|
// Hyperlink targets and comments are collected in `parts` for the parts written after the sheet
|
|
3643
|
-
|
|
3944
|
+
// `evaluate`: the rows are an array loaded into the formula engine, so formulas get cached results
|
|
3945
|
+
async *worksheetXmlChunks(rows, options, parts, tables, evaluate) {
|
|
3644
3946
|
const links = parts.links;
|
|
3645
3947
|
const hyperlinks = [];
|
|
3646
3948
|
const colCount = Math.max(options.columnWidths?.length ?? 0, options.columns?.length ?? 0);
|
|
@@ -3653,7 +3955,12 @@ class SheetWriter {
|
|
|
3653
3955
|
colWidths += `<col min="${i + 1}" max="${i + 1}"${width !== undefined ? ` width="${escapeXml$1(width)}" customWidth="1"` : ''}` +
|
|
3654
3956
|
`${c.hidden ? ' hidden="1"' : ''}${c.outlineLevel ? ` outlineLevel="${escapeXml$1(c.outlineLevel)}"` : ''}/>`;
|
|
3655
3957
|
}
|
|
3656
|
-
const rowOptions = Object.entries(options.rows ?? {}).map(([r, o]) =>
|
|
3958
|
+
const rowOptions = Object.entries(options.rows ?? {}).map(([r, o]) => {
|
|
3959
|
+
const n = Number(r);
|
|
3960
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ROWS$1)
|
|
3961
|
+
throw new Error(`Row options for "${r}": rows are numbered 1 to ${MAX_ROWS$1}.`);
|
|
3962
|
+
return [n, o];
|
|
3963
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
3657
3964
|
const rowAttrs = new Map(rowOptions.map(([r, o]) => [r, `${o.height !== undefined ? ` ht="${escapeXml$1(o.height)}" customHeight="1"` : ''}${o.hidden ? ' hidden="1"' : ''}${o.outlineLevel ? ` outlineLevel="${escapeXml$1(o.outlineLevel)}"` : ''}`]));
|
|
3658
3965
|
let nextRowOption = 0;
|
|
3659
3966
|
// Rows that only exist for their options (height, hidden, outline) and hold no cells
|
|
@@ -3702,97 +4009,108 @@ class SheetWriter {
|
|
|
3702
4009
|
: (async function* () { for (const r of rows)
|
|
3703
4010
|
yield r; })();
|
|
3704
4011
|
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))}"/>`);
|
|
4012
|
+
// Ends the caller's row source when the output is cancelled or fails (its finally blocks run)
|
|
4013
|
+
try {
|
|
4014
|
+
while (true) {
|
|
4015
|
+
const { done, value: row } = await iterator.next();
|
|
4016
|
+
if (done)
|
|
4017
|
+
break;
|
|
4018
|
+
const rowNum = ri + 1;
|
|
4019
|
+
// Past Excel's sheet size the file is damaged, so refuse it instead
|
|
4020
|
+
if (rowNum > MAX_ROWS$1)
|
|
4021
|
+
throw new Error(`Row ${rowNum} is past Excel's last row (${MAX_ROWS$1}).`);
|
|
4022
|
+
if (row.length > MAX_COLUMNS$1)
|
|
4023
|
+
throw new Error(`Row ${rowNum} has ${row.length} cells, more than Excel's ${MAX_COLUMNS$1} columns.`);
|
|
4024
|
+
chunkStr += optionRowsBefore(rowNum);
|
|
4025
|
+
if (rowOptions[nextRowOption]?.[0] === rowNum)
|
|
4026
|
+
nextRowOption++;
|
|
4027
|
+
const cellsXml = row.map((cell, ci) => {
|
|
4028
|
+
const colRef = colLetter(ci) + rowNum;
|
|
4029
|
+
const styledCell = isStyledCell$1(cell) ? cell : { value: cell };
|
|
4030
|
+
const val = styledCell.value;
|
|
4031
|
+
let cellStyle = styledCell.style;
|
|
4032
|
+
if (val instanceof Date && !cellStyle?.numFmt) {
|
|
4033
|
+
// A date needs a date format, or Excel shows the bare serial number
|
|
4034
|
+
cellStyle = { ...cellStyle, numFmt: defaultDateFormat(val) };
|
|
3736
4035
|
}
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
4036
|
+
const style = cellStyle ? this.styleEngine.registerStyle(cellStyle) : 0;
|
|
4037
|
+
const sAttr = style > 0 ? ` s="${style}"` : '';
|
|
4038
|
+
if (styledCell.comment !== undefined) {
|
|
4039
|
+
const comment = typeof styledCell.comment === 'string' ? { text: styledCell.comment } : styledCell.comment;
|
|
4040
|
+
parts.comments.push({ ref: colRef, row: rowNum - 1, col: ci, comment });
|
|
3740
4041
|
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
tAttr = ' t="b"';
|
|
3750
|
-
cachedVal = `<v>${result ? 1 : 0}</v>`;
|
|
4042
|
+
if (styledCell.hyperlink) {
|
|
4043
|
+
if (styledCell.hyperlink.startsWith('#')) {
|
|
4044
|
+
hyperlinks.push(`<hyperlink ref="${colRef}" location="${escapeXml$1(styledCell.hyperlink.slice(1))}"/>`);
|
|
4045
|
+
}
|
|
4046
|
+
else {
|
|
4047
|
+
links.push(styledCell.hyperlink);
|
|
4048
|
+
hyperlinks.push(`<hyperlink ref="${colRef}" r:id="rId${links.length}"/>`);
|
|
4049
|
+
}
|
|
3751
4050
|
}
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
4051
|
+
if (styledCell.formula) {
|
|
4052
|
+
// Streamed rows aren't in the engine, so their formulas get no (made-up) cached value
|
|
4053
|
+
const result = evaluate ? this.formulaEngine.cellValue(colRef) : null;
|
|
4054
|
+
let tAttr = '';
|
|
4055
|
+
let cachedVal = '';
|
|
4056
|
+
if (typeof result === 'number' && isFinite(result))
|
|
4057
|
+
cachedVal = `<v>${result}</v>`;
|
|
4058
|
+
else if (typeof result === 'boolean') {
|
|
4059
|
+
tAttr = ' t="b"';
|
|
4060
|
+
cachedVal = `<v>${result ? 1 : 0}</v>`;
|
|
4061
|
+
}
|
|
4062
|
+
else if (result instanceof FormulaError) {
|
|
4063
|
+
tAttr = ' t="e"';
|
|
4064
|
+
cachedVal = `<v>${escapeXml$1(result.code)}</v>`;
|
|
4065
|
+
}
|
|
4066
|
+
else if (typeof result === 'string') {
|
|
4067
|
+
tAttr = ' t="str"';
|
|
4068
|
+
cachedVal = `<v>${escapeXml$1(encodeXString(result))}</v>`;
|
|
4069
|
+
}
|
|
4070
|
+
return `<c r="${colRef}"${tAttr}${sAttr}><f>${escapeXml$1(styledCell.formula.replace(/^=/, ''))}</f>${cachedVal}</c>`;
|
|
3755
4071
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
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>`;
|
|
4072
|
+
if (styledCell.richText) {
|
|
4073
|
+
// Rich text is always inline, even with sharedStrings on
|
|
4074
|
+
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${runsXml(styledCell.richText)}</is></c>`;
|
|
4075
|
+
}
|
|
4076
|
+
if (val === null || val === undefined)
|
|
4077
|
+
return `<c r="${colRef}"${sAttr}/>`;
|
|
4078
|
+
if (typeof val === 'boolean')
|
|
4079
|
+
return `<c r="${colRef}" t="b"${sAttr}><v>${val ? 1 : 0}</v></c>`;
|
|
4080
|
+
if (typeof val === 'number') {
|
|
4081
|
+
// NaN/Infinity have no representation in a cell
|
|
4082
|
+
return isFinite(val) ? `<c r="${colRef}"${sAttr}><v>${val}</v></c>` : `<c r="${colRef}" t="e"${sAttr}><v>#NUM!</v></c>`;
|
|
4083
|
+
}
|
|
4084
|
+
if (val instanceof Date) {
|
|
4085
|
+
if (isNaN(val.getTime()))
|
|
4086
|
+
throw new Error(`Invalid Date in cell ${colRef}`);
|
|
4087
|
+
return `<c r="${colRef}"${sAttr}><v>${dateToSerial(val)}</v></c>`;
|
|
3783
4088
|
}
|
|
3784
|
-
|
|
4089
|
+
if (typeof val === 'string') {
|
|
4090
|
+
checkCellText(val, colRef);
|
|
4091
|
+
if (this.writerOptions.sharedStrings) {
|
|
4092
|
+
let index = this.sharedStrings.get(val);
|
|
4093
|
+
if (index === undefined)
|
|
4094
|
+
this.sharedStrings.set(val, index = this.sharedStrings.size);
|
|
4095
|
+
this.sharedStringRefs++;
|
|
4096
|
+
return `<c r="${colRef}" t="s"${sAttr}><v>${index}</v></c>`;
|
|
4097
|
+
}
|
|
4098
|
+
return `<c r="${colRef}" t="inlineStr"${sAttr}><is>${stringXml(val)}</is></c>`;
|
|
4099
|
+
}
|
|
4100
|
+
return `<c r="${colRef}"${sAttr}/>`;
|
|
4101
|
+
}).join('');
|
|
4102
|
+
chunkStr += ` <row r="${rowNum}"${rowAttrs.get(rowNum) ?? ''}>${cellsXml}</row>\n`;
|
|
4103
|
+
ri++;
|
|
4104
|
+
// Flush every ~64KB of string data to keep O(1) memory while avoiding chunk overhead
|
|
4105
|
+
if (chunkStr.length > 65536) {
|
|
4106
|
+
yield chunkStr;
|
|
4107
|
+
chunkStr = '';
|
|
3785
4108
|
}
|
|
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
4109
|
}
|
|
3795
4110
|
}
|
|
4111
|
+
finally {
|
|
4112
|
+
await iterator.return?.();
|
|
4113
|
+
}
|
|
3796
4114
|
chunkStr += optionRowsBefore(Infinity);
|
|
3797
4115
|
if (chunkStr.length > 0) {
|
|
3798
4116
|
yield chunkStr;
|
|
@@ -3808,6 +4126,14 @@ class SheetWriter {
|
|
|
3808
4126
|
if (options.autoFilter)
|
|
3809
4127
|
footer += ` <autoFilter ref="${escapeXml$1(options.autoFilter)}"/>\n`;
|
|
3810
4128
|
if (options.mergeCells && options.mergeCells.length > 0) {
|
|
4129
|
+
// Overlapping or malformed merges make Excel repair the file
|
|
4130
|
+
const boxes = [];
|
|
4131
|
+
for (const ref of options.mergeCells) {
|
|
4132
|
+
const box = parseRange$1(ref, 'merge range');
|
|
4133
|
+
if (boxes.some(b => overlaps(b, box)))
|
|
4134
|
+
throw new Error(`Merge "${ref}" overlaps another merge.`);
|
|
4135
|
+
boxes.push(box);
|
|
4136
|
+
}
|
|
3811
4137
|
const merges = options.mergeCells.map(ref => `<mergeCell ref="${escapeXml$1(ref)}"/>`).join('');
|
|
3812
4138
|
footer += ` <mergeCells count="${options.mergeCells.length}">${merges}</mergeCells>\n`;
|
|
3813
4139
|
}
|
|
@@ -3835,11 +4161,16 @@ class SheetWriter {
|
|
|
3835
4161
|
if (dv[k])
|
|
3836
4162
|
attr += ` ${k}="${escapeXml$1(dv[k])}"`;
|
|
3837
4163
|
}
|
|
4164
|
+
// The file stores formulas without "="; a typed list ("a,b,c") holds at most 255 characters
|
|
4165
|
+
const f1 = dv.formula1?.replace(/^=/, ''), f2 = dv.formula2?.replace(/^=/, '');
|
|
4166
|
+
if (dv.type === 'list' && f1?.startsWith('"') && f1.length - 2 > 255) {
|
|
4167
|
+
throw new Error(`List validation for ${dv.sqref} has ${f1.length - 2} characters; Excel allows 255. Put the items in cells and refer to the range.`);
|
|
4168
|
+
}
|
|
3838
4169
|
let inner = '';
|
|
3839
|
-
if (
|
|
3840
|
-
inner += `<formula1>${escapeXml$1(
|
|
3841
|
-
if (
|
|
3842
|
-
inner += `<formula2>${escapeXml$1(
|
|
4170
|
+
if (f1)
|
|
4171
|
+
inner += `<formula1>${escapeXml$1(f1)}</formula1>`;
|
|
4172
|
+
if (f2)
|
|
4173
|
+
inner += `<formula2>${escapeXml$1(f2)}</formula2>`;
|
|
3843
4174
|
return `<dataValidation ${attr}>${inner}</dataValidation>`;
|
|
3844
4175
|
}).join('');
|
|
3845
4176
|
footer += ` <dataValidations count="${options.dataValidations.length}">${dvs}</dataValidations>\n`;
|
|
@@ -3893,8 +4224,10 @@ class SheetWriter {
|
|
|
3893
4224
|
: '').join('') + this.sheets.map((s, i) => {
|
|
3894
4225
|
const page = s.options.pageSetup;
|
|
3895
4226
|
let names = '';
|
|
3896
|
-
|
|
3897
|
-
|
|
4227
|
+
// Every range of a multi-range print area names its sheet
|
|
4228
|
+
const area = page?.printArea?.split(',').map(r => `${quoteSheet(s.name)}!${absoluteRef(r.trim())}`).join(',');
|
|
4229
|
+
if (area)
|
|
4230
|
+
names += `<definedName name="_xlnm.Print_Area" localSheetId="${i}">${escapeXml$1(area)}</definedName>`;
|
|
3898
4231
|
if (page?.printTitleRows) {
|
|
3899
4232
|
const rows = page.printTitleRows.replace(/\$/g, '').split(':').map(r => `$${r}`).join(':');
|
|
3900
4233
|
names += `<definedName name="_xlnm.Print_Titles" localSheetId="${i}">${escapeXml$1(`${quoteSheet(s.name)}!${rows.includes(':') ? rows : `${rows}:${rows}`}`)}</definedName>`;
|
|
@@ -4072,7 +4405,7 @@ var randomAccess = /*#__PURE__*/Object.freeze({
|
|
|
4072
4405
|
createFileReader: createFileReader
|
|
4073
4406
|
});
|
|
4074
4407
|
|
|
4075
|
-
const escapeAttr =
|
|
4408
|
+
const escapeAttr = escapeXml$4;
|
|
4076
4409
|
const FONT_TAGS = [
|
|
4077
4410
|
['bold', 'b'], ['italic', 'i'], ['underline', 'u'], ['size', 'sz'], ['color', 'color'], ['name', 'name'],
|
|
4078
4411
|
];
|
|
@@ -4103,7 +4436,7 @@ class StylePatcher {
|
|
|
4103
4436
|
this.xml = xml;
|
|
4104
4437
|
this.p = /<((?:\w+:)?)styleSheet\b/.exec(xml)?.[1] ?? '';
|
|
4105
4438
|
this.fonts = this.items('fonts', 'font');
|
|
4106
|
-
this.fills = this.items('fills', 'fill')
|
|
4439
|
+
this.fills = this.items('fills', 'fill');
|
|
4107
4440
|
this.borders = this.items('borders', 'border');
|
|
4108
4441
|
this.xfs = this.items('cellXfs', 'xf');
|
|
4109
4442
|
for (const tag of this.items('numFmts', 'numFmt')) {
|
|
@@ -4125,12 +4458,13 @@ class StylePatcher {
|
|
|
4125
4458
|
if (id !== undefined)
|
|
4126
4459
|
return id;
|
|
4127
4460
|
const p = this.p;
|
|
4128
|
-
|
|
4461
|
+
// `base` may be a format added earlier in this edit (a date format, then a style)
|
|
4462
|
+
const xf = this.at('cellXfs', base) ?? this.xfs[0] ?? `<${p}xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>`;
|
|
4129
4463
|
let open = /^<[^>]*>/.exec(xf)[0];
|
|
4130
4464
|
let inner = open.endsWith('/>') ? '' : xf.slice(open.length, xf.lastIndexOf('<'));
|
|
4131
4465
|
open = open.replace(/\s*\/>$/, '>');
|
|
4132
4466
|
if (style.font) {
|
|
4133
|
-
const old = this.fonts
|
|
4467
|
+
const old = this.at('fonts', parseInt(attr(open, 'fontId') ?? '0', 10)) ?? '';
|
|
4134
4468
|
let font = old.endsWith('/>') ? '' : old.replace(/^<[^>]*>/, '').replace(/<\/[^>]*>$/, '');
|
|
4135
4469
|
for (const [key, tag] of FONT_TAGS) {
|
|
4136
4470
|
if (!(key in style.font))
|
|
@@ -4147,7 +4481,7 @@ class StylePatcher {
|
|
|
4147
4481
|
open = setAttr(setAttr(open, 'fillId', String(this.add('fills', this.prefix(fillXml(style.fill))))), 'applyFill', '1');
|
|
4148
4482
|
}
|
|
4149
4483
|
if (style.border) {
|
|
4150
|
-
const old = this.borders
|
|
4484
|
+
const old = this.at('borders', parseInt(attr(open, 'borderId') ?? '0', 10)) ?? `<${p}border/>`;
|
|
4151
4485
|
const element = (tag) => new RegExp(`<${p}${tag}\\b[^>]*?(?:/>|>[\\s\\S]*?</${p}${tag}>)`).exec(old)?.[0];
|
|
4152
4486
|
const sides = SIDES.map(side => side in style.border
|
|
4153
4487
|
? this.prefix(borderSideXml(side, style.border[side])) : element(side) ?? `<${p}${side}/>`);
|
|
@@ -4182,7 +4516,7 @@ class StylePatcher {
|
|
|
4182
4516
|
// The format for date `d` in a cell of format `base`: `base` when it already shows dates, otherwise
|
|
4183
4517
|
// `base` with a date format, since a date in a General cell shows as its serial number
|
|
4184
4518
|
dateFormat(base, d) {
|
|
4185
|
-
const xf = this.
|
|
4519
|
+
const xf = this.at('cellXfs', base) ?? '';
|
|
4186
4520
|
const id = parseInt(attr(/^<[^>]*>/.exec(xf)?.[0] ?? '', 'numFmtId') ?? '0', 10);
|
|
4187
4521
|
const code = [...this.numFmts].find(([, v]) => v === id)?.[0];
|
|
4188
4522
|
if (isBuiltinDateFormat(id) || (code !== undefined && isDateFormatCode(code)))
|
|
@@ -4218,14 +4552,28 @@ class StylePatcher {
|
|
|
4218
4552
|
return xml;
|
|
4219
4553
|
}
|
|
4220
4554
|
existing(section) {
|
|
4221
|
-
return section === '
|
|
4222
|
-
: section === 'borders' ? this.borders.length : section === 'cellXfs' ? this.xfs.length
|
|
4223
|
-
: this.items('numFmts', 'numFmt').length;
|
|
4555
|
+
return section === 'numFmts' ? this.items('numFmts', 'numFmt').length : this.list(section).length;
|
|
4224
4556
|
}
|
|
4225
|
-
|
|
4557
|
+
list(section) {
|
|
4558
|
+
return section === 'fonts' ? this.fonts : section === 'fills' ? this.fills : section === 'borders' ? this.borders : this.xfs;
|
|
4559
|
+
}
|
|
4560
|
+
// Item `i` of a section, counting the ones added in this edit after the file's own
|
|
4561
|
+
at(section, i) {
|
|
4562
|
+
const own = this.list(section);
|
|
4563
|
+
return i < own.length ? own[i] : this.added[section][i - own.length];
|
|
4564
|
+
}
|
|
4565
|
+
// The index of `element` in a section, appending it unless an identical one is already there:
|
|
4566
|
+
// repeating the same restyle on an edited file reuses the formats the first run added
|
|
4226
4567
|
add(section, element) {
|
|
4568
|
+
const own = this.list(section);
|
|
4569
|
+
const found = own.indexOf(element);
|
|
4570
|
+
if (found >= 0)
|
|
4571
|
+
return found;
|
|
4572
|
+
const added = this.added[section].indexOf(element);
|
|
4573
|
+
if (added >= 0)
|
|
4574
|
+
return own.length + added;
|
|
4227
4575
|
this.added[section].push(element);
|
|
4228
|
-
return
|
|
4576
|
+
return own.length + this.added[section].length - 1;
|
|
4229
4577
|
}
|
|
4230
4578
|
items(section, tag) {
|
|
4231
4579
|
const p = this.p;
|
|
@@ -4366,12 +4714,12 @@ function mapSheetXml(xml, sheet, maps) {
|
|
|
4366
4714
|
if (child)
|
|
4367
4715
|
inner = inner.replace(child[0], `<${child[1]}sqref>${mapped}</${child[1]}sqref>`);
|
|
4368
4716
|
}
|
|
4369
|
-
inner = inner.replace(/<((?:\w+:)?)(formula1|formula2|formula|f)>([^<]*)<\/\1\2>/g, (_m, fp, ftag, f) => `<${fp}${ftag}>${escapeXml$
|
|
4717
|
+
inner = inner.replace(/<((?:\w+:)?)(formula1|formula2|formula|f)>([^<]*)<\/\1\2>/g, (_m, fp, ftag, f) => `<${fp}${ftag}>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps, anchor))}</${fp}${ftag}>`);
|
|
4370
4718
|
return whole.endsWith('/>') && !inner ? `<${p}${tag}${attrs}/>` : `<${p}${tag}${attrs}>${inner}</${p}${tag}>`;
|
|
4371
4719
|
});
|
|
4372
4720
|
xml = recount(xml, 'dataValidations', 'dataValidation');
|
|
4373
4721
|
// 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$
|
|
4722
|
+
const mapF = (s) => s.replace(/<((?:\w+:)?)f>([^<]*)<\/\1f>/g, (_m, p, f) => `<${p}f>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps))}</${p}f>`);
|
|
4375
4723
|
xml = xml.replace(/<((?:\w+:)?)sparklineGroup\b([^>]*)>([\s\S]*?)<\/\1sparklineGroup>/g, (whole, p, attrs, inner) => {
|
|
4376
4724
|
const list = /<((?:\w+:)?)sparklines>([\s\S]*?)<\/\1sparklines>/.exec(inner);
|
|
4377
4725
|
if (!list)
|
|
@@ -4500,12 +4848,17 @@ function createShiftTransform(sheet, maps) {
|
|
|
4500
4848
|
const expand = !!own || mapped !== text;
|
|
4501
4849
|
shared.set(si, { text, row: r, col: c, expand });
|
|
4502
4850
|
if (expand)
|
|
4503
|
-
xml = `<${p}f>${escapeXml$
|
|
4851
|
+
xml = `<${p}f>${escapeXml$4(mapped)}</${p}f>`;
|
|
4504
4852
|
}
|
|
4505
4853
|
else {
|
|
4506
4854
|
const a = shared.get(si);
|
|
4507
|
-
if (a
|
|
4508
|
-
|
|
4855
|
+
if (a) {
|
|
4856
|
+
// Even when the anchor's own references stay, this cell's may point into moved rows
|
|
4857
|
+
const own = shiftFormula(a.text, r - a.row, c - a.col);
|
|
4858
|
+
const mapped = mapF(own);
|
|
4859
|
+
if (a.expand || mapped !== own)
|
|
4860
|
+
xml = `<${p}f>${escapeXml$4(mapped)}</${p}f>`;
|
|
4861
|
+
}
|
|
4509
4862
|
}
|
|
4510
4863
|
}
|
|
4511
4864
|
else if (kind === 'dataTable') {
|
|
@@ -4514,7 +4867,7 @@ function createShiftTransform(sheet, maps) {
|
|
|
4514
4867
|
const range = attr(fAttrs, 'ref');
|
|
4515
4868
|
if (own && range)
|
|
4516
4869
|
fAttrs = fAttrs.replace(/\sref="[^"]*"/, ` ref="${mapArea(range, own) ?? range}"`);
|
|
4517
|
-
fAttrs = fAttrs.replace(/\s(r1|r2)="([^"]*)"/g, (_m, name, ref) => ` ${name}="${escapeXml$
|
|
4870
|
+
fAttrs = fAttrs.replace(/\s(r1|r2)="([^"]*)"/g, (_m, name, ref) => ` ${name}="${escapeXml$4(mapF(unescapeXml(ref)))}"`);
|
|
4518
4871
|
xml = f[0].replace(f[1], () => fAttrs);
|
|
4519
4872
|
}
|
|
4520
4873
|
else if (text) {
|
|
@@ -4522,7 +4875,7 @@ function createShiftTransform(sheet, maps) {
|
|
|
4522
4875
|
const range = attr(fAttrs, 'ref');
|
|
4523
4876
|
if (own && range)
|
|
4524
4877
|
fAttrs = fAttrs.replace(/\sref="[^"]*"/, ` ref="${mapArea(range, own) ?? range}"`);
|
|
4525
|
-
xml = `<${p}f${fAttrs}>${escapeXml$
|
|
4878
|
+
xml = `<${p}f${fAttrs}>${escapeXml$4(mapF(text))}</${p}f>`;
|
|
4526
4879
|
}
|
|
4527
4880
|
return xml === undefined ? cell : cell.replace(f[0], () => xml);
|
|
4528
4881
|
});
|
|
@@ -4682,7 +5035,7 @@ function mapTable(xml, sheet, maps) {
|
|
|
4682
5035
|
}
|
|
4683
5036
|
xml = mapAutoFilter(xml, own).replace(/(<(?:\w+:)?(?:table|sortState|sortCondition)\b[^>]*?\sref=")([^"]*)"/g, (_m, pre, area) => `${pre}${mapArea(area, own) ?? area}"`);
|
|
4684
5037
|
}
|
|
4685
|
-
xml = xml.replace(/<((?:\w+:)?)(calculatedColumnFormula|totalsRowFormula)\b([^>]*)>([^<]*)<\/\1\2>/g, (_m, p, tag, attrs, f) => `<${p}${tag}${attrs}>${escapeXml$
|
|
5038
|
+
xml = xml.replace(/<((?:\w+:)?)(calculatedColumnFormula|totalsRowFormula)\b([^>]*)>([^<]*)<\/\1\2>/g, (_m, p, tag, attrs, f) => `<${p}${tag}${attrs}>${escapeXml$4(mapFormula(unescapeXml(f), sheet, maps))}</${p}${tag}>`);
|
|
4686
5039
|
return { xml, headers };
|
|
4687
5040
|
}
|
|
4688
5041
|
// Chart series follow the cells they plot. Cached values are dropped when a series moves, and Excel
|
|
@@ -4694,10 +5047,14 @@ function mapChart(xml, maps) {
|
|
|
4694
5047
|
if (mapped === unescapeXml(f))
|
|
4695
5048
|
return whole;
|
|
4696
5049
|
changed = true;
|
|
4697
|
-
return open + escapeXml$
|
|
5050
|
+
return open + escapeXml$4(mapped) + close;
|
|
4698
5051
|
});
|
|
4699
5052
|
return changed ? out.replace(/<((?:\w+:)?)(numCache|strCache)\b[\s\S]*?<\/\1\2>/g, '') : xml;
|
|
4700
5053
|
}
|
|
5054
|
+
// A pivot table on a sheet whose rows or columns move is drawn where its cells went
|
|
5055
|
+
function mapPivotTable(xml, s) {
|
|
5056
|
+
return xml.replace(/(<(?:\w+:)?location\b[^>]*?\sref=")([^"]*)"/, (_m, pre, ref) => `${pre}${mapArea(ref, s) ?? ref}"`);
|
|
5057
|
+
}
|
|
4701
5058
|
// A pivot cache over moved cells reads the new range and refreshes when the file opens
|
|
4702
5059
|
function mapPivotCache(xml, maps) {
|
|
4703
5060
|
let changed = false;
|
|
@@ -4716,7 +5073,7 @@ function mapPivotCache(xml, maps) {
|
|
|
4716
5073
|
}
|
|
4717
5074
|
// Workbook defined names (print areas, named ranges) follow the cells they point at
|
|
4718
5075
|
function mapDefinedNames(wb, maps) {
|
|
4719
|
-
return wb.replace(/(<(?:\w+:)?definedName\b[^>]*>)([^<]*)(<\/(?:\w+:)?definedName>)/g, (_m, open, f, close) => open + escapeXml$
|
|
5076
|
+
return wb.replace(/(<(?:\w+:)?definedName\b[^>]*>)([^<]*)(<\/(?:\w+:)?definedName>)/g, (_m, open, f, close) => open + escapeXml$4(mapFormula(unescapeXml(f), '', maps)) + close);
|
|
4720
5077
|
}
|
|
4721
5078
|
|
|
4722
5079
|
const KEEP = Symbol('keep');
|
|
@@ -4743,12 +5100,12 @@ function editedCell(p, ref, attrs, edit) {
|
|
|
4743
5100
|
return `<${p}c r="${ref}"${attrs}><${p}v>${dateToSerial(edit)}</${p}v></${p}c>`;
|
|
4744
5101
|
}
|
|
4745
5102
|
if (typeof edit === 'object')
|
|
4746
|
-
return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$
|
|
4747
|
-
const text = escapeXml$
|
|
5103
|
+
return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$4(edit.formula.replace(/^=/, ''))}</${p}f></${p}c>`;
|
|
5104
|
+
const text = escapeXml$4(encodeXString(checkCellText(edit, ref)));
|
|
4748
5105
|
const t = /^\s|\s$/.test(edit) ? `<${p}t xml:space="preserve">${text}</${p}t>` : `<${p}t>${text}</${p}t>`;
|
|
4749
5106
|
return `<${p}c r="${ref}"${attrs} t="inlineStr"><${p}is>${t}</${p}is></${p}c>`;
|
|
4750
5107
|
}
|
|
4751
|
-
// The cells of a new sheet, as edits
|
|
5108
|
+
// The cells of new rows (a new sheet, or rows appended to one) as edits, rows numbered from 1
|
|
4752
5109
|
function rowsToEdits(name, rows) {
|
|
4753
5110
|
const edits = new Map();
|
|
4754
5111
|
rows.forEach((row, ri) => {
|
|
@@ -4761,7 +5118,7 @@ function rowsToEdits(name, rows) {
|
|
|
4761
5118
|
return;
|
|
4762
5119
|
}
|
|
4763
5120
|
if (cell.hyperlink !== undefined || cell.comment !== undefined) {
|
|
4764
|
-
throw new Error(`Sheet "${name}": SheetEditor
|
|
5121
|
+
throw new Error(`Sheet "${name}": SheetEditor does not write hyperlinks or notes in new rows; use SheetWriter.`);
|
|
4765
5122
|
}
|
|
4766
5123
|
// Rich text is written as plain text here
|
|
4767
5124
|
const value = cell.richText && cell.value == null ? cell.richText.map(r => r.text).join('') : cell.value;
|
|
@@ -4777,14 +5134,19 @@ const WORKSHEET_CONTENT = 'application/vnd.openxmlformats-officedocument.spreads
|
|
|
4777
5134
|
// An element in the shape of a regex match: [whole, attributes, body], body undefined when self-closing
|
|
4778
5135
|
const asMatch = (el) => [el.whole, el.attrs, el.whole.endsWith('/>') ? undefined : el.body];
|
|
4779
5136
|
class SheetEditor {
|
|
4780
|
-
|
|
5137
|
+
appends = new Map();
|
|
4781
5138
|
cellEdits = new Map();
|
|
4782
5139
|
additions = new Map();
|
|
4783
5140
|
deletions = new Set();
|
|
4784
5141
|
shiftOps = new Map();
|
|
4785
|
-
// Appends `rows` after the last existing row of sheet `sheetName`.
|
|
4786
|
-
|
|
4787
|
-
|
|
5142
|
+
// Appends `rows` after the last existing row of sheet `sheetName`. Cells take values, formulas and
|
|
5143
|
+
// styles, as in addSheet. Called again for the same sheet, the rows go after the earlier ones.
|
|
5144
|
+
appendSheet(sheetName, rows, _options = {}) {
|
|
5145
|
+
let batches = this.appends.get(sheetName);
|
|
5146
|
+
if (!batches)
|
|
5147
|
+
this.appends.set(sheetName, batches = []);
|
|
5148
|
+
batches.push(rows);
|
|
5149
|
+
return this;
|
|
4788
5150
|
}
|
|
4789
5151
|
// Sets cells of an existing sheet by address, e.g. { B2: 42, C2: { formula: 'B2*2' } }. A cell
|
|
4790
5152
|
// keeps its style, so a date written over a date-formatted cell shows as a date. Formulas are
|
|
@@ -4884,8 +5246,8 @@ class SheetEditor {
|
|
|
4884
5246
|
return path;
|
|
4885
5247
|
};
|
|
4886
5248
|
const appendByPath = new Map();
|
|
4887
|
-
for (const [sheetName,
|
|
4888
|
-
appendByPath.set(pathOf(sheetName),
|
|
5249
|
+
for (const [sheetName, batches] of this.appends)
|
|
5250
|
+
appendByPath.set(pathOf(sheetName), rowsToEdits(sheetName, batches.flat()));
|
|
4889
5251
|
const editsByPath = new Map();
|
|
4890
5252
|
for (const [sheetName, rows] of this.cellEdits)
|
|
4891
5253
|
editsByPath.set(pathOf(sheetName), rows);
|
|
@@ -4953,6 +5315,8 @@ class SheetEditor {
|
|
|
4953
5315
|
await update(rel.path, xml => mapVml(xml, map));
|
|
4954
5316
|
if (type === '/drawing')
|
|
4955
5317
|
await update(rel.path, xml => mapDrawing(xml, map));
|
|
5318
|
+
if (type === '/pivotTable')
|
|
5319
|
+
await update(rel.path, xml => mapPivotTable(xml, map));
|
|
4956
5320
|
}
|
|
4957
5321
|
}
|
|
4958
5322
|
// Charts on any sheet can plot the moved rows, and so can pivot tables
|
|
@@ -4972,7 +5336,7 @@ class SheetEditor {
|
|
|
4972
5336
|
}
|
|
4973
5337
|
}
|
|
4974
5338
|
// Edited cells invalidate cached formula results
|
|
4975
|
-
if (editsByPath.size || added.size || this.deletions.size || rowMaps.size) {
|
|
5339
|
+
if (editsByPath.size || added.size || appendByPath.size || this.deletions.size || rowMaps.size) {
|
|
4976
5340
|
const { replace, drop } = await recalcOnOpen(read, parts, await read(parts.workbookPath));
|
|
4977
5341
|
for (const [path, xml] of replace)
|
|
4978
5342
|
overlay.set(path, xml);
|
|
@@ -4981,10 +5345,10 @@ class SheetEditor {
|
|
|
4981
5345
|
}
|
|
4982
5346
|
// Styles gain the formats of restyled cells as the sheets stream, so they are written last
|
|
4983
5347
|
let patcher;
|
|
4984
|
-
const styled = [...editsByPath.values(), ...added.values()]
|
|
5348
|
+
const styled = [...editsByPath.values(), ...added.values(), ...appendByPath.values()]
|
|
4985
5349
|
.some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => styleOf(e))));
|
|
4986
5350
|
// Dates may need a date format added to their cells
|
|
4987
|
-
const dated = [...editsByPath.values(), ...added.values()]
|
|
5351
|
+
const dated = [...editsByPath.values(), ...added.values(), ...appendByPath.values()]
|
|
4988
5352
|
.some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => contentOf(e) instanceof Date)));
|
|
4989
5353
|
if (styled || (dated && parts.styles)) {
|
|
4990
5354
|
if (!parts.styles)
|
|
@@ -5004,7 +5368,7 @@ class SheetEditor {
|
|
|
5004
5368
|
// A new sheet uses the workbook's namespace (Transitional or Strict)
|
|
5005
5369
|
const root = /<((?:\w+:)?)workbook\b[^>]*>/.exec(await read(parts.workbookPath));
|
|
5006
5370
|
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$
|
|
5371
|
+
const empty = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="${escapeXml$4(ns)}"><sheetData/></worksheet>`;
|
|
5008
5372
|
for (const [path, edits] of added) {
|
|
5009
5373
|
await zipOut.addFile(path, encode(decode(new Response(empty).body).pipeThrough(this.createEditTransform(edits, patcher))));
|
|
5010
5374
|
}
|
|
@@ -5012,18 +5376,16 @@ class SheetEditor {
|
|
|
5012
5376
|
for (const filename of zipIn.getFiles()) {
|
|
5013
5377
|
if (written.has(filename))
|
|
5014
5378
|
continue;
|
|
5015
|
-
const
|
|
5379
|
+
const append = appendByPath.get(filename);
|
|
5016
5380
|
const edits = editsByPath.get(filename);
|
|
5017
5381
|
const shift = shiftByPath.get(filename);
|
|
5018
|
-
if (
|
|
5382
|
+
if (append || edits || shift !== undefined) {
|
|
5019
5383
|
checkSize(filename, options?.maxUncompressedBytes ?? Infinity);
|
|
5020
5384
|
let text = decode(await zipIn.extractStream(filename));
|
|
5021
5385
|
if (shift !== undefined)
|
|
5022
5386
|
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));
|
|
5387
|
+
if (edits || append)
|
|
5388
|
+
text = text.pipeThrough(this.createEditTransform(edits ?? new Map(), patcher, append));
|
|
5027
5389
|
await zipOut.addFile(filename, encode(text));
|
|
5028
5390
|
}
|
|
5029
5391
|
else {
|
|
@@ -5043,10 +5405,12 @@ class SheetEditor {
|
|
|
5043
5405
|
return zipOut.stream;
|
|
5044
5406
|
}
|
|
5045
5407
|
// Streams a worksheet, rewriting edited cells as their rows pass by and adding rows and cells
|
|
5046
|
-
// that did not exist.
|
|
5047
|
-
|
|
5408
|
+
// that did not exist. `append` rows (numbered from 1) go after the last row. Only one row at a
|
|
5409
|
+
// time is held in memory.
|
|
5410
|
+
createEditTransform(edits, patcher, append) {
|
|
5048
5411
|
const pending = [...edits.keys()].sort((a, b) => a - b);
|
|
5049
5412
|
let next = 0;
|
|
5413
|
+
let lastRow = 0; // number of the last row read, for rows without an r attribute
|
|
5050
5414
|
let buffer = '';
|
|
5051
5415
|
let p = ''; // namespace prefix of the sheet's elements
|
|
5052
5416
|
let state = 'head';
|
|
@@ -5073,8 +5437,8 @@ class SheetEditor {
|
|
|
5073
5437
|
// Keeps the style; drops the type and the metadata of rich values and dynamic arrays
|
|
5074
5438
|
return editedCell(p, ref, attrs.replace(/\s(?:t|vm|cm)="[^"]*"/g, ''), content);
|
|
5075
5439
|
};
|
|
5076
|
-
const newRow = (r) => {
|
|
5077
|
-
const cells = [...
|
|
5440
|
+
const newRow = (r, rowEdits = edits.get(r)) => {
|
|
5441
|
+
const cells = [...rowEdits.entries()].sort((a, b) => a[0] - b[0])
|
|
5078
5442
|
.map(([c, e]) => cellXml(colLetter(c) + r, e)).join('');
|
|
5079
5443
|
return `<${p}row r="${r}">${cells}</${p}row>`;
|
|
5080
5444
|
};
|
|
@@ -5084,6 +5448,19 @@ class SheetEditor {
|
|
|
5084
5448
|
xml += newRow(pending[next]);
|
|
5085
5449
|
return xml;
|
|
5086
5450
|
};
|
|
5451
|
+
// The rows still to come at the end of sheetData: new edited rows, then the appended rows
|
|
5452
|
+
const lastRows = () => {
|
|
5453
|
+
let xml = rowsBefore(Infinity);
|
|
5454
|
+
if (!append?.size)
|
|
5455
|
+
return xml;
|
|
5456
|
+
const base = Math.max(lastRow, pending[pending.length - 1] ?? 0);
|
|
5457
|
+
const last = base + Math.max(...append.keys());
|
|
5458
|
+
if (last > MAX_ROWS$1)
|
|
5459
|
+
throw new Error(`Appending rows would reach row ${last}, past Excel's last row (${MAX_ROWS$1}).`);
|
|
5460
|
+
for (const [r, cells] of [...append].sort((a, b) => a[0] - b[0]))
|
|
5461
|
+
xml += newRow(base + r, cells);
|
|
5462
|
+
return xml;
|
|
5463
|
+
};
|
|
5087
5464
|
const rewriteRow = (open, inner, r) => {
|
|
5088
5465
|
// A copy: the edits stay intact for another edit() call
|
|
5089
5466
|
const rowEdits = edits.has(r) ? new Map(edits.get(r)) : undefined;
|
|
@@ -5115,7 +5492,7 @@ class SheetEditor {
|
|
|
5115
5492
|
if (brokenShared.has(si)) {
|
|
5116
5493
|
const a = sharedAnchors.get(si);
|
|
5117
5494
|
if (a) {
|
|
5118
|
-
xml = xml.replace(f[0], `<${p}f>${escapeXml$
|
|
5495
|
+
xml = xml.replace(f[0], `<${p}f>${escapeXml$4(shiftFormula(a.text, r - a.row, col - a.col))}</${p}f>`);
|
|
5119
5496
|
changed = true;
|
|
5120
5497
|
}
|
|
5121
5498
|
}
|
|
@@ -5142,7 +5519,7 @@ class SheetEditor {
|
|
|
5142
5519
|
p = m[1];
|
|
5143
5520
|
if (m[2]) {
|
|
5144
5521
|
// Empty sheet: <sheetData/> becomes a pair holding the new rows
|
|
5145
|
-
out += buffer.slice(0, m.index) + `<${p}sheetData>${
|
|
5522
|
+
out += buffer.slice(0, m.index) + `<${p}sheetData>${lastRows()}</${p}sheetData>`;
|
|
5146
5523
|
buffer = buffer.slice(m.index + m[0].length);
|
|
5147
5524
|
state = 'tail';
|
|
5148
5525
|
}
|
|
@@ -5169,11 +5546,14 @@ class SheetEditor {
|
|
|
5169
5546
|
if (!m)
|
|
5170
5547
|
break;
|
|
5171
5548
|
if (m[0].trimStart().startsWith(`</${p}sheetData`)) {
|
|
5172
|
-
out +=
|
|
5549
|
+
out += lastRows() + m[0];
|
|
5173
5550
|
state = 'tail';
|
|
5174
5551
|
}
|
|
5175
5552
|
else {
|
|
5176
|
-
|
|
5553
|
+
// A row without r is the one after the previous row, as the reader counts it
|
|
5554
|
+
const rAttr = /\sr="(\d+)"/.exec(m[1])?.[1];
|
|
5555
|
+
const r = rAttr ? parseInt(rAttr, 10) : lastRow + 1;
|
|
5556
|
+
lastRow = Math.max(lastRow, r);
|
|
5177
5557
|
out += rowsBefore(r);
|
|
5178
5558
|
const open = m[0].slice(0, m[0].indexOf('>') + 1);
|
|
5179
5559
|
// Rows are only parsed when they have edits or take part in a shared formula
|
|
@@ -5200,72 +5580,6 @@ class SheetEditor {
|
|
|
5200
5580
|
},
|
|
5201
5581
|
});
|
|
5202
5582
|
}
|
|
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
5583
|
async readStreamToString(stream) {
|
|
5270
5584
|
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
5271
5585
|
let result = '';
|
|
@@ -5306,7 +5620,7 @@ async function removeSheet(name, parts, read, overlay, dropped) {
|
|
|
5306
5620
|
let mapped = formula.replace(quoted, '#REF!');
|
|
5307
5621
|
if (plain)
|
|
5308
5622
|
mapped = mapped.replace(plain, '$1#REF!');
|
|
5309
|
-
return mapped === formula ? whole : whole.replace(`>${text}<`, `>${escapeXml$
|
|
5623
|
+
return mapped === formula ? whole : whole.replace(`>${text}<`, `>${escapeXml$4(mapped)}<`);
|
|
5310
5624
|
});
|
|
5311
5625
|
wb = wb.replace(/<(?:\w+:)?workbookView\b[^>]*>/g, view => view.replace(/\s(activeTab|firstSheet)="(\d+)"/g, (_m, a, v) => {
|
|
5312
5626
|
const n = parseInt(v, 10);
|
|
@@ -5346,7 +5660,7 @@ async function insertSheet(name, parts, read, overlay, free) {
|
|
|
5346
5660
|
overlay.set(relsPath, rels);
|
|
5347
5661
|
const sheetId = Math.max(0, ...tags.map(t => parseInt(attr(t, 'sheetId') ?? '0', 10))) + 1;
|
|
5348
5662
|
const rPrefix = /<(?:\w+:)?sheet\b[^>]*?\s(\w+):id="/.exec(wb)?.[1] ?? 'r';
|
|
5349
|
-
wb = wb.replace(/<\/((?:\w+:)?)sheets>/, (close, p) => `<${p}sheet name="${escapeXml$
|
|
5663
|
+
wb = wb.replace(/<\/((?:\w+:)?)sheets>/, (close, p) => `<${p}sheet name="${escapeXml$4(name)}" sheetId="${sheetId}" ${rPrefix}:id="rId${k}"/>${close}`);
|
|
5350
5664
|
overlay.set(parts.workbookPath, wb);
|
|
5351
5665
|
const types = await read('[Content_Types].xml');
|
|
5352
5666
|
const contentType = sheetRel && /ContentType="([^"]*)"/.exec(overrideTag(types, sheetRel.path) ?? '')?.[1];
|
|
@@ -5417,17 +5731,18 @@ class SheetReader {
|
|
|
5417
5731
|
const name = attr(tag, 'name');
|
|
5418
5732
|
const state = attr(tag, 'state');
|
|
5419
5733
|
if (name !== null)
|
|
5420
|
-
sheets.push({ name
|
|
5734
|
+
sheets.push({ name, state: state === 'hidden' || state === 'veryHidden' ? state : 'visible' });
|
|
5421
5735
|
}
|
|
5422
5736
|
const definedNames = [];
|
|
5423
5737
|
for (const { attrs: open, body: text } of xmlElements(parts.workbookXml, 'definedName')) {
|
|
5424
5738
|
const local = attr(open, 'localSheetId');
|
|
5425
5739
|
const comment = attr(open, 'comment');
|
|
5426
|
-
|
|
5740
|
+
// attr() has already unescaped attribute values; only element text is still escaped
|
|
5741
|
+
const name = { name: attr(open, 'name') ?? '', ref: unescapeXml(text) };
|
|
5427
5742
|
if (local !== null && sheets[+local])
|
|
5428
5743
|
name.sheet = sheets[+local].name;
|
|
5429
5744
|
if (comment !== null)
|
|
5430
|
-
name.comment =
|
|
5745
|
+
name.comment = comment;
|
|
5431
5746
|
if (/^(?:1|true)$/.test(attr(open, 'hidden') ?? ''))
|
|
5432
5747
|
name.hidden = true;
|
|
5433
5748
|
definedNames.push(name);
|
|
@@ -5475,7 +5790,8 @@ class SheetReader {
|
|
|
5475
5790
|
throw new Error(`Sheet with name "${options.sheetName}" not found in workbook.`);
|
|
5476
5791
|
}
|
|
5477
5792
|
else {
|
|
5478
|
-
|
|
5793
|
+
// The first tab with cells: a chart sheet first in the tab order is skipped, as the .xls reader does
|
|
5794
|
+
worksheetZipPath = [...parts.sheets.values()].find(path => !parts.chartsheets.has(path))
|
|
5479
5795
|
?? zip.getFiles().find(f => /^xl\/worksheets\/[^/]+\.xml$/.test(f));
|
|
5480
5796
|
if (!worksheetZipPath)
|
|
5481
5797
|
throw new Error("No worksheets found in ZIP.");
|
|
@@ -5507,6 +5823,7 @@ class SheetReader {
|
|
|
5507
5823
|
.pipeThrough(createXmlBatchParser());
|
|
5508
5824
|
return parseWorksheet(xmlStream, sharedStrings, styles, is1904, {
|
|
5509
5825
|
formulas: options?.formulas,
|
|
5826
|
+
errors: options?.errors,
|
|
5510
5827
|
cellStyles: options?.styles ? cellStyles : undefined,
|
|
5511
5828
|
numFmts: options?.formatted ? formats : undefined,
|
|
5512
5829
|
richText: options?.richText ? color : undefined,
|
|
@@ -5807,7 +6124,8 @@ const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
|
5807
6124
|
function convertField(s) {
|
|
5808
6125
|
if (s === '')
|
|
5809
6126
|
return null;
|
|
5810
|
-
|
|
6127
|
+
// "1e400" overflows a double: keep the text rather than lose it to Infinity
|
|
6128
|
+
if (NUMBER.test(s) && isFinite(Number(s)))
|
|
5811
6129
|
return Number(s);
|
|
5812
6130
|
const upper = s.toUpperCase();
|
|
5813
6131
|
return upper === 'TRUE' ? true : upper === 'FALSE' ? false : s;
|
|
@@ -5911,14 +6229,11 @@ async function* parseCsv(input, options = {}) {
|
|
|
5911
6229
|
// Writer for OpenDocument spreadsheets (.ods). Rows stream into content.xml one at a time.
|
|
5912
6230
|
// Writes values, formulas, dates, merged cells, column widths, frozen panes, hidden sheets and
|
|
5913
6231
|
// 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
|
-
}
|
|
6232
|
+
const escapeXml = (val) => escapeXml$4(String(val));
|
|
5919
6233
|
const isStyledCell = (v) => v !== null && typeof v === 'object' && !(v instanceof Date);
|
|
5920
6234
|
// Excel syntax to OpenFormula: "SUM(A1:B2,Sheet2!C3)" -> "of:=SUM([.A1:.B2];[$Sheet2.C3])"
|
|
5921
|
-
|
|
6235
|
+
// Cell references and ranges, then whole columns (C:C) and whole rows (1:3)
|
|
6236
|
+
const REF = /((?:'(?:[^']|'')+'|[A-Za-z_][\w.]*)!)?(\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?|\$?[A-Za-z]{1,3}:\$?[A-Za-z]{1,3}|\$?\d+:\$?\d+)(?![\w(!:])/y;
|
|
5922
6237
|
function toOpenFormula(formula) {
|
|
5923
6238
|
const f = formula.replace(/^=/, '');
|
|
5924
6239
|
let out = '';
|
|
@@ -5943,7 +6258,7 @@ function toOpenFormula(formula) {
|
|
|
5943
6258
|
const m = REF.exec(f);
|
|
5944
6259
|
if (m) {
|
|
5945
6260
|
const sheet = m[1] ? `$${m[1].slice(0, -1)}` : '';
|
|
5946
|
-
out += `[${
|
|
6261
|
+
out += `[${m[2].split(':').map(part => `${sheet}.${part}`).join(':')}]`;
|
|
5947
6262
|
i = REF.lastIndex;
|
|
5948
6263
|
continue;
|
|
5949
6264
|
}
|
|
@@ -5959,8 +6274,10 @@ function textXml(s) {
|
|
|
5959
6274
|
.replace(/\t/g, '<text:tab/>')
|
|
5960
6275
|
.replace(/^ | {2,}/g, m => m === ' ' ? '<text:s/>' : ` <text:s text:c="${m.length - 1}"/>`)}</text:p>`).join('');
|
|
5961
6276
|
}
|
|
5962
|
-
|
|
6277
|
+
// UTC, like SheetWriter; milliseconds only when there are some
|
|
6278
|
+
const dateValue = (d) => d.toISOString().slice(0, d.getUTCMilliseconds() ? 23 : 19);
|
|
5963
6279
|
const hasTime = (d) => d.getTime() % 86400000 !== 0;
|
|
6280
|
+
// `evaluate` gives the formula's result, or null when it isn't known (streamed rows, errors)
|
|
5964
6281
|
function cellXml(cell, span, covered, evaluate) {
|
|
5965
6282
|
if (covered)
|
|
5966
6283
|
return '<table:covered-table-cell/>';
|
|
@@ -5968,7 +6285,7 @@ function cellXml(cell, span, covered, evaluate) {
|
|
|
5968
6285
|
const formula = isStyledCell(cell) && cell.formula ? ` table:formula="${escapeXml(toOpenFormula(cell.formula))}"` : '';
|
|
5969
6286
|
// A formula without a value gets its result stored, as SheetWriter does, for readers that don't recalculate
|
|
5970
6287
|
if (formula && value == null)
|
|
5971
|
-
value = evaluate(
|
|
6288
|
+
value = evaluate();
|
|
5972
6289
|
if (value === null || value === undefined || (typeof value === 'number' && !isFinite(value))) {
|
|
5973
6290
|
return formula || span ? `<table:table-cell${formula}${span}/>` : '<table:table-cell/>';
|
|
5974
6291
|
}
|
|
@@ -6065,11 +6382,18 @@ class OdsWriter {
|
|
|
6065
6382
|
const engine = new FormulaEngine();
|
|
6066
6383
|
if (Array.isArray(sheet.rows)) {
|
|
6067
6384
|
engine.loadData(sheet.rows.map(row => row.map(cell => {
|
|
6385
|
+
if (isStyledCell(cell) && cell.formula)
|
|
6386
|
+
return { formula: cell.formula };
|
|
6068
6387
|
const v = isStyledCell(cell) ? cell.value : cell;
|
|
6069
6388
|
return v instanceof Date ? dateToSerial(v) : v ?? null;
|
|
6070
6389
|
})));
|
|
6071
6390
|
}
|
|
6072
|
-
const evaluate = (
|
|
6391
|
+
const evaluate = (ref) => {
|
|
6392
|
+
if (!Array.isArray(sheet.rows))
|
|
6393
|
+
return null;
|
|
6394
|
+
const v = engine.cellValue(ref);
|
|
6395
|
+
return v instanceof FormulaError ? v.code : v;
|
|
6396
|
+
};
|
|
6073
6397
|
const covered = (r, c) => merges.some(m => r >= m.r1 && r <= m.r2 && c >= m.c1 && c <= m.c2 && (r !== m.r1 || c !== m.c1));
|
|
6074
6398
|
let r = 0, batch = '';
|
|
6075
6399
|
const writeRow = (row) => {
|
|
@@ -6078,7 +6402,7 @@ class OdsWriter {
|
|
|
6078
6402
|
for (let c = 0; c < width; c++) {
|
|
6079
6403
|
const m = mergeAt(r, c);
|
|
6080
6404
|
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);
|
|
6405
|
+
xml += cellXml(row[c], span, covered(r, c), () => evaluate(colLetter(c) + (r + 1)));
|
|
6082
6406
|
}
|
|
6083
6407
|
r++;
|
|
6084
6408
|
return xml + (width ? '' : '<table:table-cell/>') + '</table:table-row>';
|
|
@@ -6152,11 +6476,28 @@ const XlsxFlow = {
|
|
|
6152
6476
|
const { SheetReader } = await Promise.resolve().then(function () { return index; });
|
|
6153
6477
|
const reader = new SheetReader();
|
|
6154
6478
|
const fileReader = await createFileReader(filePath);
|
|
6155
|
-
//
|
|
6479
|
+
// Comments, images and .ods content are read after parse() returns, when the handle is closed:
|
|
6480
|
+
// those reads open a short-lived handle of their own
|
|
6481
|
+
let closed = false;
|
|
6482
|
+
const lateRead = async (offset, length) => {
|
|
6483
|
+
const late = await createFileReader(filePath);
|
|
6484
|
+
try {
|
|
6485
|
+
return await late.read(offset, length);
|
|
6486
|
+
}
|
|
6487
|
+
finally {
|
|
6488
|
+
await late.close();
|
|
6489
|
+
}
|
|
6490
|
+
};
|
|
6156
6491
|
try {
|
|
6157
|
-
return await reader.parse(
|
|
6492
|
+
return await reader.parse({
|
|
6493
|
+
size: fileReader.size,
|
|
6494
|
+
read: (offset, length) => closed ? lateRead(offset, length) : fileReader.read(offset, length),
|
|
6495
|
+
stream: (offset, length) => fileReader.stream(offset, length),
|
|
6496
|
+
close: async () => { },
|
|
6497
|
+
}, options);
|
|
6158
6498
|
}
|
|
6159
6499
|
finally {
|
|
6500
|
+
closed = true;
|
|
6160
6501
|
await fileReader.close();
|
|
6161
6502
|
}
|
|
6162
6503
|
}
|