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