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