@xlsxflow/core 1.1.2 → 1.1.4

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