Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b8e31c2
Implement BIFF8 enhancements for XLS writer
Oct 13, 2026
c5b09a6
refactor: optimize cell record writing for improved performance
Oct 13, 2026
69b2c32
feat: implement RK and MULRK record handling for improved number enco…
Oct 13, 2026
58ccf40
refactor: streamline cell writing methods and enhance Rk number encoding
Oct 13, 2026
0eeff88
feat: add vectorized CRC-32 for the ZIP writer
Oct 13, 2026
35193f5
feat: add streaming fixed-Huffman deflate encoder
Oct 13, 2026
f347c90
fix: flush bit buffer at deflate block start
Oct 13, 2026
9edc304
feat: add ZIP package writer mirroring ZipArchive's layout with Zip64
Oct 13, 2026
9a699b3
fix: a failed entry write kills the entry and the archive
Oct 13, 2026
f8617ba
style: block bodies in FailOnceWriteStream
Oct 13, 2026
45e95e7
feat: Abandon writes the central directory best-effort and entry Flus…
Oct 13, 2026
091e7a4
feat: write XLSX and XLSB packages through the managed ZIP writer
Oct 13, 2026
ac2fe91
fix: abandon the ZIP archive asynchronously after an async fault
Oct 13, 2026
5db9ada
test: fuzz the deflate encoder through both inflaters
Oct 13, 2026
5d70e79
docs: record the managed ZIP write path and its benchmarks
Oct 13, 2026
fe02b33
fix: fault the active sheet before abandoning the ZIP
Oct 13, 2026
0be7a6a
fix: harden the ZIP writer against disposed use, huge writes and big-…
Oct 13, 2026
2f1cd5a
docs: align write benchmark tables with the latest run
Oct 13, 2026
6709416
fix: update assertions in tests for corrupt sheet entries and XLS wri…
Oct 13, 2026
222a043
fix: update MaxEncodedBlock calculation and optimize EncodeMatches me…
Oct 13, 2026
f22a3aa
feat: combine two CRC-32 values without the data
Oct 13, 2026
869ae44
feat: compress byte-aligned deflate segments that concatenate
Oct 13, 2026
91aee75
feat: append pre-compressed segments to a ZIP entry
Oct 13, 2026
cf5b753
feat: drain the offload writer without ending it
Oct 13, 2026
de31f34
feat: run record batches in parallel with ordered, bounded output
Oct 13, 2026
e4b8ecf
fix: keep a cancelled drain from leaving its barrier for the next one
Oct 13, 2026
3acd81e
feat: write XLSX records in parallel batches
Oct 13, 2026
6534120
feat: write XLSB records in parallel batches
Oct 13, 2026
b513887
docs: describe parallel record writes and their benchmarks
Oct 13, 2026
e7ac95f
fix: harden parallel record writes after the final review
Oct 13, 2026
fb49d58
docs: say a cancelled parallel write ends the sheet and workbook
Oct 13, 2026
0d5e828
docs: update the parallel write memory figures after pre-sizing worke…
Oct 13, 2026
5a8a794
fix: adjust row limit checks and add test for exceeding row limit
Oct 13, 2026
25f4857
fix: reject or escape writer input that Excel refuses or alters
Oct 13, 2026
f93ab72
refactor: improve readability by expanding single-line methods into m…
Oct 13, 2026
0394099
feat: verify ZIP entry checksums on read; unify writer exceptions
Oct 13, 2026
b106c9d
feat: expose verify_zip_checksums in the bindings; checksum on the pr…
Oct 13, 2026
0f038ae
fix: read Excel's line breaks, attributed <v> and XLS shared-formula …
Oct 13, 2026
904063a
fix: bindings timestamp zone handling, Python int overflow and as_date
Oct 13, 2026
3e7eebe
perf(native): regenerate the static PGO profile
Oct 13, 2026
a70c5ae
fix: bind numeric cells to bool, enums and parsable types in XLSB and…
Oct 13, 2026
05684b6
Merge branch 'master' into develop
GabrielMarquezMatte Oct 13, 2026
6c46480
fix: enhance Zip package writer tests to ignore OS-specific fields in…
Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,23 @@ against a shared bug, so the fixtures in `tests/ExcelReader.Tests/data/encrypted
plaintext produced by an independent implementation) remain the only correctness oracle for
decryption specifically.

XLSX and XLSB packages are written by `Writer/Zip/ZipPackageWriter`, not `ZipArchive`. It writes the
layout `ZipArchive` uses in create mode (patched local headers on seekable streams, data descriptors
otherwise, Zip64 only when a field overflows). `CompressionLevel.Fastest` entries go through
`DeflateEncoder`, a fixed-Huffman deflate with a stored-block fallback, and are checksummed by
`Crc32`, which folds 128 or 256 bits at a time with PCLMULQDQ. `Optimal` and `SmallestSize` keep
zlib through `DeflateStream`. When a write faults, `Abandon` still writes a central directory for
the entries that completed, as disposing `ZipArchive` did; a failed entry write marks the whole
archive failed, so nothing more can be added to it.

`WriteRecordsParallelAsync` and `WriteTableParallelAsync` (`Writer/Parallel/`) cut a record source into
batches. Each batch is formatted on a pool thread by a detached copy of the sheet writer, which has
its own buffer and never touches the ZIP, then compressed into a byte-aligned deflate segment that
refers to nothing outside itself. The calling flow appends segments in order through
`ZipEntryStream.AppendSegmentAsync`, which merges CRCs with `Crc32.Combine`. Once an entry has
received a segment, everything written to it is a segment, including later serial rows, so no
compressor ever carries history across one.

## The `excelreader` CLI

`src/ExcelReader.Cli/` is a thin `dotnet tool` shell (`excelreader`) over Core's public API — it
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ foreach (Row row in workbook.FirstSheet)

- [Reading rows](docs/guide/reading.md) — opening a workbook, enumerating rows, auto-detecting the format, async reads, prefetch decompression, and the ADO.NET `IDataReader` bridge.
- [Typed parsing](docs/guide/parsing.md) — binding rows to your own types with attributes, the compile-time generator (Native AOT / trimming), the fluent API, required columns, custom converters, and the zero-copy `ref struct` path.
- [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, and prefetch compression.
- [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, prefetch compression, and parallel record writes.
- [CSV](docs/guide/csv.md) — reading, dialect sniffing, parallel parsing, and writing delimited text.
- [Encrypted workbooks](docs/guide/encryption.md) — opening password-protected packages and encrypting written ones.
- [Reading untrusted files](docs/guide/untrusted-input.md) — the resource limits that bound what one hostile file can cost, and how to reject one cleanly.
Expand Down Expand Up @@ -85,8 +85,10 @@ errors to stderr, so `convert` is safe to pipe.
- String conversion allocates only when you call `GetString()`.
- The XLSX scanner accepts the SpreadsheetML shapes commonly emitted by non-Excel producers, including single-quoted attributes, comments in `sheetData`, and CDATA text runs.
- Readers bound untrusted input by default: 512 MB total decompressed ZIP data, 32 MB per cell/row value buffer, and 128 MB for shared strings. Pass `ExcelReaderOptions` to the `Excel.From*`/`Excel.Open*` factories to tune these limits; set a limit to `0` to opt out and restore unlimited behavior for that limit. `CsvReader` has its own `CsvReaderOptions.MaxCellBytes` (default 32 MB) for the same purpose.
- Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32 as it is read, so a damaged file throws `InvalidDataException` instead of returning wrong cells. `ExcelReaderOptions.VerifyZipChecksums = false` turns the check off.
- The XLSX writer emits a compact workbook with strings, numbers, booleans, dates, and blank cells; shared strings are opt-in.
- The XLSB writer emits BIFF12 workbook parts inside the standard XLSB ZIP package; shared strings are opt-in.
- `WriteRecordsParallelAsync` and `WriteTableParallelAsync` format and compress batches of records on several cores and write them in order, for XLSX and XLSB. On a Ryzen 7 5700X a 1,000,000-row sheet writes in 50 ms as XLSX with 4 batches in flight and 36 ms with 8, against 187 ms serially; XLSB takes 41 ms and 28 ms against 124 ms. Memory stays at about 3 MB per batch in flight. See [Write records in parallel](docs/guide/writing.md#write-records-in-parallel-xlsxxlsb) and the [benchmarks](docs/performance/benchmarks.md#parallel-record-writes).
- The XLS writer buffers records in memory and assembles the OLE container at `EndAsync`; choose it when write throughput matters more than peak allocation.
- `Excel.EncryptPackage`/`EncryptPackageAsync` wrap a written XLSX/XLSB package in agile ECMA-376 encryption; see [Encrypted workbooks](docs/guide/encryption.md).

Expand Down
18 changes: 18 additions & 0 deletions docs/guide/migrating-to-v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ Two behavior changes come with them:
- The CLI `convert` command no longer shifts dates by four years when the source workbook uses the 1904 date system.
- [`XlsxWriterOptions`](xref:ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions) gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged.

## Stricter reading and writing

v7 refuses several inputs that v6 accepted and then got wrong.

- **Damaged packages.** Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32. A mismatch throws `InvalidDataException` when the entry's last byte is read, so rows before the damage may already have been returned. v6 checked only the entry's size. Set [`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums) to `false` to read a package whose producer wrote wrong checksums; the bindings have the same switch as `verify_zip_checksums`. The check costs 2% to 4% of a read, and under 1% with `PrefetchDecompression`, where it runs on the background thread.
- **Line breaks in XLSX text.** A cell with a line break now reads with `
`, as XLSB and XLS already did. Excel stores the break as a CR LF pair in the XML, which v6 returned as `
`. A carriage return stored as data (`_x000D_` or `&#13;`) is still returned. Code that split XLSX text on `
` should split on `
`.
- **Sheet names.** `AddSheet` throws `ArgumentException` for a name that begins or ends with an apostrophe, or contains a control character (tab and line feed included), U+FFFE or U+FFFF. Excel refuses a workbook with such a name.
- **Column settings.** `SetColumnWidth` and `SetColumnStyle` throw `ArgumentOutOfRangeException` for a column past the format's last one (16,384 columns; 256 for XLS) and for a width above 255, Excel's maximum. v6 wrote a file Excel refused for the first, and an XLS width of 256 came out as a hidden column.
- **Number formats.** `AddStyle` throws `ExcelLimitExceededException` for the 201st distinct number format of a workbook, and `ArgumentException` for a format longer than 255 characters. Excel does not open a workbook past either.
- **Rows in XLSB.** A sheet stops at 1,048,576 rows. v6 accepted one more, which Excel does not read.
- **Values with no numeric form.** `Write<T>` with a type that does not convert to a number, such as `char`, throws `ArgumentException` in every writer. XLSB and XLS threw `InvalidCastException`.
- **Text in XLSX.** A carriage return, U+FFFE and U+FFFF in cell text are now written escaped. v6 wrote them raw: Excel read the carriage return as a line feed and refused a file with either of the other two.
- **`ExcelLimitExceededException.Message`** no longer says "bytes" for limits counted in rows, columns or entries. `LimitName`, `Limit` and `Actual` are unchanged.

## Native bindings

The C ABI is unchanged: no signature moved, and `XL_ABI_VERSION` stays 6. Behavior changed. `degree_of_parallelism` of `0` on `xl_parse_typed` and the Arrow exports now parallelises XLSX and XLSB as well as CSV, using every processor. Pass `1` for the old sequential behavior. The Rust, Python and C++ bindings take the same value (`degree_of_parallelism`, `parallelism`) and need no code change; a `parallelism=0` call on an XLSX or XLSB sheet is now faster, and holds every partition's columns until they merge, so its peak memory is higher.
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/untrusted-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ bytes. Anything else escaping a read of a file — an `IndexOutOfRangeException`
[SECURITY.md](../../SECURITY.md). See [Errors across languages](errors.md) for how these map onto the
bindings.

## Damaged packages

An XLSX or XLSB package is a ZIP archive, and each of its entries carries a CRC-32 of its data.
The reader checks it as the entry is read and throws `InvalidDataException` on a mismatch, so a file
cut short or altered in transit does not come back as plausible but wrong cells. A sheet is checked
when its last byte is read, which means the rows before the damage may already have been returned:
treat the exception as invalidating everything read from that file. The checksum guards against
accidents, not tampering, since whoever alters the data can recompute it.
[`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums)
turns the check off.

## What the reader does not do

- **It evaluates nothing.** A formula cell reports the result Excel cached when it saved the file,
Expand Down
71 changes: 68 additions & 3 deletions docs/guide/writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,13 +263,78 @@ Writing a 50,000-row workbook (`WriteBenchmark`, both figures from one run):

| Workload | Default | `PrefetchWrite = true` | Gain |
|---|---:|---:|---:|
| XLSX | 10.910 ms | 6.865 ms | 37% |
| XLSB | 7.555 ms | 5.614 ms | 26% |
| XLSX | 8.337 ms | 5.175 ms | 38% |
| XLSB | 5.572 ms | 3.419 ms | 39% |

Allocations are unchanged (4.02 MB vs. 4.03–4.04 MB) — the background writer hands over buffers the row
Allocations are unchanged (4.02 MB either way) — the background writer hands over buffers the row
writer already owns rather than copying them.

The same caveat as the read side applies: **do not enable it for concurrent server workloads**. A
caller already writing many files in parallel is CPU-saturated, and an extra background thread per
writer only doubles thread demand for no gain. It's meant for single-file batch work.

## Write records in parallel (XLSX/XLSB)

`PrefetchWrite` moves compression off the calling thread, but the rows are still built on one thread,
and building them is most of a write. `WriteRecordsParallelAsync` and `WriteTableParallelAsync` use
several cores for both: the records are cut into batches, each batch is formatted and compressed on
a pool thread, and the batches reach the file in order. They are extensions on
[`XlsxSheetWriter`](xref:ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter) and
[`XlsbSheetWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter), and take an `IEnumerable<T>`
or an `IAsyncEnumerable<T>`. The cell-by-cell API stays single-threaded, and XLS and CSV have no
parallel form.

<!-- snippet: writing-parallel -->
```csharp
await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true);
XlsxSheetWriter sheet = wb.AddSheet("S1");

// Header row from the layout, then one row per record.
await sheet.WriteRecordsParallelAsync(records, ExcelRecordLayout.FromAttributes<Metric>());

// Or format each row yourself; this delegate runs on several threads at once.
await sheet.WriteRecordsParallelAsync(records, static (XlsxRowWriter row, Metric record) =>
{
row.Write(record.Name);
row.Write(record.Value);
}, new ParallelWriteOptions { DegreeOfParallelism = 4 });

await sheet.EndAsync();
```

The rules:

- **Your delegate runs on several threads at once**, though never twice at once on the same row
writer. Whatever it captures must be thread-safe. The library's own layouts are.
- **Order is kept.** Rows come out in the order the source yields them, and the source itself is
enumerated by one flow at a time, never concurrently.
- **Rows may be written before and after the call, not during it.** Row numbering carries on, and
the call can be made more than once on a sheet.
- **A failure or a cancellation ends the sheet and the workbook.** A serial `WriteRecordsAsync`
cancelled between rows leaves the sheet usable; a parallel call does not. The original exception is
rethrown once every batch in flight has stopped.
- **With `UseSharedStrings`, string indexes depend on thread timing**, so two runs give files that
differ byte for byte. Their content is the same.
- More than 1,048,576 rows throws `ExcelLimitExceededException`.

[`ParallelWriteOptions`](xref:ExcelReader.Core.Writer.ParallelWriteOptions) has two knobs.
`DegreeOfParallelism` is the most batches in flight at once (0, the default, means one per core).
`BatchRows` is the rows per batch, 8,192 by default. Memory grows with their product: at the
defaults each batch in flight holds about 3 MB (its formatted rows, the compressor's window and the
compressed result), whatever the size of the sheet.

Writing 1,000,000 rows of four columns (text, integer, date, number) to a `MemoryStream` at
`Fastest`, on a Ryzen 7 5700X, median of 9 runs:

| Write | XLSX | XLSB |
|---|---:|---:|
| Serial | 187.3 ms | 123.6 ms |
| Serial, `PrefetchWrite` | 117.8 ms | 67.6 ms |
| Parallel, 1 batch in flight | 172.7 ms | 120.6 ms |
| Parallel, 2 | 92.1 ms | 63.2 ms |
| Parallel, 4 | 49.9 ms | 40.9 ms |
| Parallel, 8 | 35.7 ms | 27.7 ms |

The file comes out 0.18% (XLSX) or 0.04% (XLSB) larger, because each batch is compressed on its own.
As with `PrefetchWrite`, this is for single-file batch work: a server already writing many files at
once has no idle cores to give it.
Loading
Loading