@xlsxflow/core 1.1.3 → 1.1.4

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