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