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