@xlsxflow/core 1.1.4 → 1.1.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.6
4
+
5
+ ### Changed
6
+
7
+ - `SheetWriter`, `SheetEditor` and `OdsWriter` keep up to four chunks queued for compression instead of one, so building the next chunk overlaps compressing the last. Writing 10M cells takes about 10% less time; the files are the same, and the writer still waits while nobody reads its output.
8
+
9
+ ### Testing
10
+
11
+ - Public CI on every push and pull request: the tests on Node 20, 22 and 24 with coverage; the README's claims in Node, Bun, Deno, Chromium, Firefox, WebKit and Cloudflare Workers; and written and edited files validated with Microsoft's Open XML SDK and opened in LibreOffice. OpenSSF Scorecard and CodeQL check the repository.
12
+
13
+ ## 1.1.5
14
+
15
+ Fixes found while testing every claim in the README on Node 20.12 and 25, Bun, Deno, Chrome and Cloudflare Workers.
16
+
17
+ ### Fixed
18
+
19
+ - `SheetWriter`, `SheetEditor` and `OdsWriter` wait while nobody reads their output on Node and Bun too. Their `CompressionStream` takes thousands of chunks without pushing back, so a slow consumer let the writer pull the whole row source into memory.
20
+ - On Bun, `.ods` files and other ZIP entries stored without compression read correctly. Bun's `Blob.slice().stream()` runs past the end of the slice, which 1.1.4's size check rejected.
21
+ - `SheetWriter` refuses a merge that overlaps a table. Excel tables cannot hold merged cells, and Excel repaired such files by removing the table.
22
+
23
+ ### Docs
24
+
25
+ - The README says to close a `createFileReader` reader (Node 25 stops the process when an open file is garbage-collected), lists Deno as tested, fixes the sheet-options example that merged cells inside a table, and corrects the comparison table: ExcelJS 4.4 has no pivot tables.
26
+
3
27
  ## 1.1.4
4
28
 
5
29
  Fixes from the third round of end-user tests (700 cases).
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  [![npm version](https://img.shields.io/npm/v/@xlsxflow/core.svg?style=flat-square)](https://www.npmjs.com/package/@xlsxflow/core)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)
10
+ [![CI](https://img.shields.io/github/actions/workflow/status/xlsxflow/xlsxflow/ci.yml?branch=main&label=CI&style=flat-square)](https://github.com/xlsxflow/xlsxflow/actions/workflows/ci.yml)
11
+ [![OpenSSF Scorecard](https://img.shields.io/ossf-scorecard/github.com/xlsxflow/xlsxflow?label=OpenSSF%20Scorecard&style=flat-square)](https://scorecard.dev/viewer/?uri=github.com/xlsxflow/xlsxflow)
10
12
 
11
13
  <p>
12
14
  <a href="#features">Features</a> •
@@ -28,8 +30,8 @@ Rows are read and written one at a time instead of loading the whole workbook, s
28
30
  ## Features
29
31
 
30
32
  - **No dependencies**: TypeScript on Web APIs (`ReadableStream`, `CompressionStream`, `Blob`).
31
- - **Streaming**: rows are read and written one at a time, so memory stays flat as files grow (10M cells written with about 1 MB of extra heap; see [Benchmarks](#benchmarks)).
32
- - **Runs anywhere with Web APIs**: tested on Node 20.12+, Bun, browsers and Cloudflare Workers (without `nodejs_compat`). Deno provides the same APIs but is not tested yet.
33
+ - **Streaming**: rows are read and written one at a time, so memory stays flat as files grow (10M cells written with about 2 MB of extra heap; see [Benchmarks](#benchmarks)).
34
+ - **Runs anywhere with Web APIs**: tested on Node 20.12 and later, Bun, Deno, Chrome, Firefox, Safari (WebKit) and Cloudflare Workers (without `nodejs_compat`).
33
35
  - **Read, write and edit**: stream rows out of a file, generate one on the fly, or change cells, rows, columns and sheets of an existing file while keeping everything else in it.
34
36
  - **Styles and formulas**: fonts, fills, borders, alignment, number formats, conditional formats, validations, tables, notes, hyperlinks, autofilters, images, protection and page setup. Formulas and styles read back too.
35
37
  - **Older and open formats**: the same reader opens Excel 97-2003 `.xls` files and OpenDocument `.ods` files, and `OdsWriter` writes `.ods`.
@@ -89,11 +91,20 @@ Parts held in memory (workbook, shared strings, styles) are capped at 1 GiB unco
89
91
  In Node.js, read straight from disk with `XlsxFlow.readFile`, or pass `await createFileReader(path)` to any function that takes a reader:
90
92
 
91
93
  ```typescript
92
- import { XlsxFlow } from '@xlsxflow/core';
94
+ import { XlsxFlow, SheetReader, createFileReader } from '@xlsxflow/core';
93
95
 
94
96
  for await (const row of await XlsxFlow.readFile('./data.xlsx')) {
95
97
  console.log(row.cells);
96
98
  }
99
+
100
+ // createFileReader keeps the file open until you close it, after you are done with
101
+ // everything read through it (Node 25 stops the process when an open file is garbage-collected)
102
+ const file = await createFileReader('./data.xlsx');
103
+ try {
104
+ console.log((await new SheetReader().readWorkbook(file)).sheets);
105
+ } finally {
106
+ await file.close();
107
+ }
97
108
  ```
98
109
 
99
110
  ### Writing an Excel File
@@ -243,7 +254,7 @@ writer.addSheet('Report', rows, {
243
254
  protection: { password: 'secret', sort: true }, // Excel's legacy hash: deters edits, is not encryption
244
255
  pageSetup: { orientation: 'landscape', paperSize: 9, fitToWidth: 1, fitToHeight: 0, printArea: 'A1:C100', printTitleRows: '1', footer: '&CPage &P of &N' },
245
256
  tabColor: 'FF00B050',
246
- mergeCells: ['A1:C1'],
257
+ mergeCells: ['E1:G1'], // not inside a table: Excel tables cannot hold merged cells
247
258
  columnWidths: [30, 12], // in characters; `columns[i].width` wins where both are set
248
259
  autoFitColumns: true, // widths from the longest value (array rows only)
249
260
  });
@@ -253,11 +264,11 @@ writer.addSheet('Report', rows, {
253
264
 
254
265
  Formulas are stored for Excel to calculate when it opens the file. For array rows, the writer also stores a cached result for simple formulas (`SUM`, `AVERAGE`, `COUNT`, `MIN`, `MAX`, `IF`, `CONCATENATE`, `&`, comparisons and arithmetic, including over other formula cells), so other readers see a value. Errors are stored as error values (`#DIV/0!`). Rows from an AsyncIterable get no cached results, since the writer cannot look back at them.
255
266
 
256
- Strings are written inline, which keeps memory flat. `new SheetWriter({ sharedStrings: true })` stores each distinct string once instead. Files are smaller when values repeat, but the distinct strings stay in memory until the file is finished.
267
+ Strings are written inline, which keeps memory flat. `new SheetWriter({ sharedStrings: true })` stores each distinct string once instead. Files are smaller when many different strings repeat (with only a handful of distinct values, inline strings compress as well or better), but the distinct strings stay in memory until the file is finished.
257
268
 
258
269
  ## Compared with SheetJS and ExcelJS
259
270
 
260
- Checked against each project's own documentation on 8 October 2026. "Pro" means a paid add-on.
271
+ Checked against each project's own documentation, and ExcelJS 4.4.0's published code, on 9 October 2026. "Pro" means a paid add-on.
261
272
 
262
273
  | | XlsxFlow | SheetJS Community Edition | ExcelJS 4.4 |
263
274
  |---|---|---|---|
@@ -268,7 +279,7 @@ Checked against each project's own documentation on 8 October 2026. "Pro" means
268
279
  | `.ods` | Read and write | Read and write | No |
269
280
  | `.xlsb`, `.numbers` and other formats | No | Yes | No |
270
281
  | Charts | Add (Pro) | No (SheetJS Pro) | No |
271
- | Pivot tables | Add (Pro) | No (SheetJS Pro) | Partial, undocumented |
282
+ | Pivot tables | Add (Pro) | No (SheetJS Pro) | No |
272
283
  | Password-protected files | Open and save (Pro) | Old `.xls` obfuscation only (SheetJS Pro opens AES files) | No |
273
284
  | Licence | MIT, Pro is paid | Apache 2.0 | MIT |
274
285
 
@@ -276,16 +287,26 @@ SheetJS reads and writes far more formats, and ExcelJS has a longer track record
276
287
  October 2023). XlsxFlow focuses on `.xlsx`: streaming in flat memory, keeping everything in a file it edits,
277
288
  and running on Web APIs alone.
278
289
 
290
+ ## Testing
291
+
292
+ Every push and pull request runs, [in public CI](https://github.com/xlsxflow/xlsxflow/actions/workflows/ci.yml):
293
+
294
+ - the test suite (nearly 400 tests, including fuzzing with malformed ZIP and XML input and files saved by Excel, LibreOffice, SheetJS, ExcelJS, openpyxl and xlsx-populate) on Node 20, 22 and 24, with 96% line coverage;
295
+ - the README's claims, checked in Node, Bun, Deno, Chromium, Firefox, WebKit and Cloudflare Workers;
296
+ - written and edited workbooks validated against the Office Open XML schema with Microsoft's [Open XML SDK](https://github.com/dotnet/Open-XML-SDK), then opened and recalculated in LibreOffice.
297
+
298
+ [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/xlsxflow/xlsxflow) and CodeQL check the repository, and npm releases are published from CI with [provenance](https://docs.npmjs.com/generating-provenance-statements).
299
+
279
300
  ## Benchmarks
280
301
 
281
- Write benchmark: 10 numeric columns, at 100,000 rows (1M cells) and 1,000,000 rows (10M cells). Each library ran in its own process on Node v25.8.2 with a 4 GB heap limit, and "Heap" is the growth in heap usage. Times are from one run on a laptop with other apps open; runs on that machine varied by up to 2×, so treat differences under about 20% as a tie. Reproduce with `npx tsx scripts/benchmark-competitors.ts` (inside `packages/core`, after `pnpm build`). `BENCH_ROWS=1000000` runs 10M cells, `BENCH_LIBS=xlsxflow,exceljs` runs a subset, and a library still writing after `BENCH_TIMEOUT_MIN` minutes (default 10) is stopped. The 10M runs for SheetJS and excel4node used a 30-minute limit.
302
+ Write benchmark: 10 numeric columns, at 100,000 rows (1M cells) and 1,000,000 rows (10M cells). Each library ran in its own process on Node v25.8.2 with a 4 GB heap limit, and "Heap" is the growth in heap usage. XlsxFlow and ExcelJS times are medians of five alternating runs (XlsxFlow 1.1.6 was faster in all five at both sizes); the other libraries' times are from one run. The laptop had other apps open and single runs varied by up to 2×, so treat differences under about 20% as a tie. Reproduce with `npx tsx scripts/benchmark-competitors.ts` (inside `packages/core`, after `pnpm build`). `BENCH_ROWS=1000000` runs 10M cells, `BENCH_LIBS=xlsxflow,exceljs` runs a subset, and a library still writing after `BENCH_TIMEOUT_MIN` minutes (default 10) is stopped. The 10M runs for SheetJS and excel4node used a 30-minute limit.
282
303
 
283
304
  1M cells:
284
305
 
285
306
  | Library | Write Time | File Size | Heap |
286
307
  |---|---|---|---|
287
- | **XlsxFlow** | **2,899 ms** | **2.9 MB** | **+2 MB** |
288
- | ExcelJS 4.4 (streaming writer) | 3,397 ms | 3.0 MB | +9 MB |
308
+ | **XlsxFlow** | **1,723 ms** | **2.9 MB** | **+2 MB** |
309
+ | ExcelJS 4.4 (streaming writer) | 2,290 ms | 3.0 MB | +9 MB |
289
310
  | SheetJS 0.20.3, `compression: true` | 4,370 ms | 8.4 MB | +140 MB |
290
311
  | SheetJS 0.20.3, default options | 5,379 ms | 31.4 MB | +140 MB |
291
312
  | write-excel-file | 7,681 ms | 2.8 MB | +2 MB |
@@ -297,8 +318,8 @@ Write benchmark: 10 numeric columns, at 100,000 rows (1M cells) and 1,000,000 ro
297
318
 
298
319
  | Library | Write Time | File Size | Heap |
299
320
  |---|---|---|---|
300
- | **XlsxFlow** | **19.5 s** | **29.9 MB** | **+1 MB** |
301
- | ExcelJS 4.4 (streaming writer) | 22.0 s | 31.2 MB | +7 MB |
321
+ | **XlsxFlow** | **19.1 s** | **29.9 MB** | **+2 MB** |
322
+ | ExcelJS 4.4 (streaming writer) | 20.5 s | 31.2 MB | +7 MB |
302
323
  | write-excel-file | 68.1 s | 29.3 MB | +1 MB |
303
324
  | xlsx-populate | 75.3 s | 30.0 MB | +1,118 MB |
304
325
  | SheetJS 0.20.3 (with and without compression) | not finished after 30 min | | |
package/dist/index.cjs CHANGED
@@ -58,21 +58,41 @@ class ZipStreamWriter {
58
58
  // Stream data, tracking sizes and CRC32
59
59
  let uncompressedSize = 0;
60
60
  let crc = 0xffffffff;
61
- const crcStream = new TransformStream({
62
- // CompressionStream takes every write at once, so the input is held back here instead,
63
- // while nobody reads the output
64
- transform: async (chunk, controller) => {
65
- await this.roomInQueue();
66
- uncompressedSize += chunk.length;
67
- crc = crc32Update(crc, chunk);
68
- controller.enqueue(chunk);
61
+ const compressor = new CompressionStream('deflate-raw');
62
+ const writer = compressor.writable.getWriter();
63
+ const reader = compressor.readable.getReader();
64
+ const input = inputStream.getReader();
65
+ // Node's and Bun's CompressionStream accept thousands of writes without backpressure, so only a few
66
+ // chunks are fed ahead: a write resolves once the chunk is compressed, which waits while nobody reads
67
+ // the output. Rows are then only pulled as fast as the ZIP is consumed.
68
+ // A few writes in flight keep the compressor busy while the next chunk is built
69
+ const inFlight = [];
70
+ const feed = (async () => {
71
+ try {
72
+ while (true) {
73
+ const { done, value } = await input.read();
74
+ if (done)
75
+ break;
76
+ await this.roomInQueue();
77
+ uncompressedSize += value.length;
78
+ crc = crc32Update(crc, value);
79
+ const written = writer.write(value);
80
+ written.catch(() => { }); // after a failure, writes nobody awaits any more must not go unhandled
81
+ inFlight.push(written);
82
+ if (inFlight.length >= 4)
83
+ await inFlight.shift();
84
+ }
85
+ await Promise.all(inFlight);
86
+ await writer.close();
69
87
  }
70
- });
88
+ catch (err) {
89
+ // Stop the source too (a row generator's finally runs, a database cursor closes)
90
+ await input.cancel(err).catch(() => { });
91
+ await writer.abort(err).catch(() => { });
92
+ throw err;
93
+ }
94
+ })();
71
95
  let compressedSize = 0;
72
- const reader = inputStream
73
- .pipeThrough(crcStream)
74
- .pipeThrough(new CompressionStream('deflate-raw'))
75
- .getReader();
76
96
  try {
77
97
  while (true) {
78
98
  const { done, value } = await reader.read();
@@ -81,10 +101,12 @@ class ZipStreamWriter {
81
101
  compressedSize += value.length;
82
102
  await this.pushChunk(value);
83
103
  }
104
+ await feed;
84
105
  }
85
106
  catch (err) {
86
- // Stop the source too (a row generator's finally runs, a database cursor closes)
87
107
  await reader.cancel(err).catch(() => { });
108
+ await input.cancel(err).catch(() => { });
109
+ await feed.catch(() => { });
88
110
  throw err;
89
111
  }
90
112
  crc = (crc ^ 0xffffffff) >>> 0;
@@ -3858,6 +3880,10 @@ class SheetWriter {
3858
3880
  const box = parseRange$1(table.ref, 'table range');
3859
3881
  if (boxes.some(b => overlaps(b, box)))
3860
3882
  throw new Error(`Table "${table.name}" overlaps another table on sheet "${sheet.name}".`);
3883
+ // Excel tables cannot hold merged cells: it repairs the file by removing the table
3884
+ const merge = sheet.options.mergeCells?.find(ref => overlaps(parseRange$1(ref, 'merge range'), box));
3885
+ if (merge)
3886
+ throw new Error(`Merge "${merge}" overlaps table "${table.name}" on sheet "${sheet.name}"; Excel tables cannot contain merged cells.`);
3861
3887
  boxes.push(box);
3862
3888
  const first = colIndex(m[1]);
3863
3889
  const width = colIndex(m[3]) - first + 1;
@@ -4359,8 +4385,18 @@ function createBlobReader(blob) {
4359
4385
  return new Uint8Array(await slice.arrayBuffer());
4360
4386
  },
4361
4387
  stream(offset, length) {
4362
- const slice = blob.slice(offset, offset + length);
4363
- return slice.stream();
4388
+ // Bun's Blob.slice(start, end).stream() runs on to the end of the original blob, so stop at length
4389
+ let left = length;
4390
+ return blob.slice(offset, offset + length).stream().pipeThrough(new TransformStream({
4391
+ start(controller) { if (left <= 0)
4392
+ controller.terminate(); },
4393
+ transform(chunk, controller) {
4394
+ controller.enqueue(chunk.length > left ? chunk.subarray(0, left) : chunk);
4395
+ left -= chunk.length;
4396
+ if (left <= 0)
4397
+ controller.terminate();
4398
+ }
4399
+ }));
4364
4400
  },
4365
4401
  async close() { }
4366
4402
  };