@xlsxflow/core 1.1.3 → 1.1.4

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