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