@xlsxflow/core 1.1.3 → 1.1.5

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