Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e378d35
feat(native): write a whole table as one sheet of an open writer handle
GabrielMarquezMatte Oct 22, 2026
532807f
feat(python): write several sheets from whole tables with SheetWriter…
GabrielMarquezMatte Oct 22, 2026
13a5428
feat(rust): write whole sheets through WriterHandle
GabrielMarquezMatte Oct 22, 2026
238c541
feat(cpp): write whole sheets through xl::WriterHandle
GabrielMarquezMatte Oct 22, 2026
60f47e3
perf(python): free a write's column copies when the call returns
GabrielMarquezMatte Oct 22, 2026
08dfe99
perf(writer): look up a worker's uncached text in the shared table by…
GabrielMarquezMatte Oct 22, 2026
5262f34
perf(writer): shard the shared-string table so parallel workers stop …
GabrielMarquezMatte Oct 22, 2026
4d1595e
feat(native): read and write Excel tables through the C ABI
GabrielMarquezMatte Oct 22, 2026
906ca3c
feat(python): read Excel tables and write a sheet as one
GabrielMarquezMatte Oct 22, 2026
44f5ffa
feat(rust): read Excel tables and write a sheet as one
GabrielMarquezMatte Oct 22, 2026
2c27e97
feat(cpp): read Excel tables and write a sheet as one
GabrielMarquezMatte Oct 22, 2026
72f8662
Merge branch 'master' into develop
GabrielMarquezMatte Oct 22, 2026
5d216ff
refactor(writer): share the ZIP package lifecycle of the XLSX and XLS…
GabrielMarquezMatte Oct 22, 2026
ad4449f
refactor(streams): share read-only and write-only stream boilerplate
GabrielMarquezMatte Oct 22, 2026
96edd2e
refactor(rust): share native slices, panic guards and the batch reader
GabrielMarquezMatte Oct 22, 2026
c330fe1
fix(reader): read a stored number as the same decimal on every runtime
GabrielMarquezMatte Oct 22, 2026
1c5145a
refactor(reader): stream caller memory without copying it
GabrielMarquezMatte Oct 22, 2026
9600cf6
ci: run the test suite on the .NET 11 runtime
GabrielMarquezMatte Oct 22, 2026
9be10ef
refactor(ci): separate .NET setup steps for clarity and versioning
GabrielMarquezMatte Oct 22, 2026
c23746c
refactor(tests): remove redundant ZipArchiveEntry test and associated…
Oct 23, 2026
061514a
Update BenchmarkDotNet package to version 0.16.0-preview.1 in ExcelRe…
Oct 23, 2026
b03b9cd
fix(reader): keep colliding interned strings cached together
Oct 23, 2026
050a3bb
perf(parser): compile class setters and the model factory
Oct 23, 2026
2d55837
perf(parser): walk a row's cell descriptors by index when projecting
Oct 23, 2026
fd11fba
docs: refresh typed-parse and parallel benchmarks after the parser ch…
Oct 23, 2026
c4a3b10
docs: rerun the encrypted-workbook benchmarks with five launches
Oct 23, 2026
747985a
ci: stop testing on the .NET 11 runtime for now
Oct 23, 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
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ but because a row holding spans into a worker's buffer cannot cross to the consu

That constraint also settles what an ordered `ref struct` enumerator would cost. Since a worker cannot
convert a row it must hand onward, conversion would run on the consumer thread — and conversion is the
floor here: the `ref struct` aggregate path, which shares this projection code, costs 833.8 ms
single-threaded on the conversion-heavy corpus, against 272.5 ms for the typed path at dop 8. Such an
API would be ~3.1x slower than what already ships while adding nothing over the
floor here: the `ref struct` aggregate path, which shares this projection code, costs 453.5 ms
single-threaded on the conversion-heavy corpus, against 219.9 ms for the typed path at dop 8. Such an
API would be ~2.1x slower than what already ships while adding nothing over the
sequential `ref struct` parse, so it was not built. Ordered delivery at full speed needs workers to
convert into a per-chunk arena of packed fields that the consumer rehydrates; that remains unbuilt.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ errors to stderr, so `convert` is safe to pipe.
- 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 46 ms as XLSX with 4 batches in flight and 31 ms with 8, against 172 ms serially; XLSB takes 31 ms and 21 ms against 115 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).
- `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 44 ms as XLSX with 4 batches in flight and 28 ms with 8, against 165 ms serially; XLSB takes 30 ms and 19 ms against 114 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
43 changes: 42 additions & 1 deletion cpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,27 @@ for (std::thread &thread : threads)
A cursor, a typed reader and an Arrow stream are each used by one thread at a time, and keep
working after the workbook is destroyed. A `Sheet` does not: take what you need from it first.

### Tables

`tables()` lists the workbook's Excel tables ("Format as Table" ranges) as `xl::TableInfo`: name, the
sheet holding it, and its 0-based range, header and totals rows included. `table(index)` and
`table_by_name(name)` (ignoring case) return an `xl::Sheet` bounded to the table, so every read works
on it and starts at the table's header row:

```cpp
for (const xl::TableInfo &table : *workbook->tables())
{
std::println("{} on sheet {}: rows {}..{}", table.name, table.sheet, table.first_row, table.last_row);
}
if (auto sales = workbook->table_by_name("Sales").value())
{
auto rows = xl::parse<Row>(*sales);
}
```

Cells keep the column indexes of the sheet holding the table, and the table's `name()` is that
sheet's. A table is read on one thread whatever the degree of parallelism asks for.

## Reading rows one at a time

`Sheet::rows()` returns a cursor over that sheet. Each row borrows a buffer the cursor
Expand Down Expand Up @@ -381,7 +402,7 @@ For XLSX and XLSB, `degree_of_parallelism` formats and compresses batches of `ba
several threads: `0` uses every processor, `1` (the default) writes serially, and `batch_rows = 0`
keeps the library default of 8,192. The columns must not change while the write runs. With
`use_shared_strings` on, two parallel runs give the same content in files that differ byte for byte.
XLS, CSV and `xl::WriterHandle` ignore both fields.
XLS, CSV and `xl::WriterHandle`'s per-cell writes ignore both fields.

```cpp
xl::write_columns("big.xlsx", columns, {.degree_of_parallelism = 0});
Expand Down Expand Up @@ -412,6 +433,26 @@ if (auto done = writer.finish(); !done) { /* done.error().message */ }
Every call returns `std::expected<void, xl::Error>`; one out of order fails instead of corrupting the
file.

To write several sheets from whole tables, call the handle's `write_sheet` or `write_columns` once
per sheet. They take what the free functions take, plus the sheet's name, and write it in one call
instead of one per cell, using the handle's `degree_of_parallelism` and `batch_rows`. Between sheets
they mix with `start_sheet`..`end_sheet`; while a sheet is open they fail.

```cpp
auto writer = xl::WriterHandle::open("report.xlsx", {.degree_of_parallelism = 0}).value();
writer.write_sheet("Sales", sales); // a range of ExcelMapper rows
writer.write_columns("Stock", columns); // xl::ColumnRefs, such as an xl::Table's
if (auto done = writer.finish(); !done) { /* done.error().message */ }
```

An `xl::TableOptions` as the last argument writes the sheet as an Excel table, with filter buttons and
a table style (XLSX and XLSB only, every column named). The name must be unique in the workbook; the
style defaults to `TableStyleMedium2`, and `.style = std::nullopt` leaves the table unstyled:

```cpp
writer.write_sheet("Sales", sales, xl::TableOptions{.name = "Sales", .style = "TableStyleLight9"});
```

## Aggregating a CSV in parallel

`xl::aggregate_csv<Acc>` folds a CSV file or buffer across worker threads. Each partition starts from
Expand Down
144 changes: 144 additions & 0 deletions cpp/tests/write.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,138 @@ static int test_writer_handle_class_to_memory_round_trip()
return 0;
}

static std::vector<std::string> read_cells(const xl::Workbook &workbook, int32_t sheet)
{
std::vector<std::string> rows;
auto cursor = workbook.sheet(sheet).rows();
if (!cursor.has_value())
{
return rows;
}
for (auto row = cursor->next_row(); row.has_value(); row = cursor->next_row())
{
std::string line;
for (auto cell : *row)
{
line += (line.empty() ? "" : "|") + std::string(cell.value);
}
rows.push_back(std::move(line));
}
return rows;
}

static int test_writer_handle_writes_whole_sheets_beside_streamed_ones()
{
const std::vector<NamedRow> sales{{"widget", "w"}, {"gadget", "g"}};
const std::vector<int32_t> offsets{0, 1, 2, 3, 4, 5};
const std::vector<uint8_t> blob{'a', 'b', 'c', 'd', 'e'};
const std::vector<int64_t> values{10, 20, 30, 40, 50};
const std::array<xl::ColumnRef, 2> stock{xl::string_column("name", offsets, blob), xl::i64_column("qty", values)};

for (const int32_t format : {XL_FORMAT_XLSX, XL_FORMAT_XLSB})
{
for (const int32_t degree : {1, 4})
{
std::vector<uint8_t> bytes;
auto handle = xl::WriterHandle::open(bytes, {.format = format, .degree_of_parallelism = degree, .batch_rows = 2});
CHECK(handle.has_value(), "WriterHandle::open over a vector must succeed");

CHECK(handle->write_sheet("Vendas", sales).has_value(), "write_sheet must succeed");
CHECK(handle->start_sheet("Resumo").has_value(), "start_sheet must succeed");
CHECK(handle->start_row().has_value(), "start_row must succeed");
CHECK(handle->write(std::string_view("total")).has_value(), "write must succeed");
CHECK(handle->end_row().has_value(), "end_row must succeed");
CHECK(handle->end_sheet().has_value(), "end_sheet must succeed");
CHECK(handle->write_columns("Estoque", stock).has_value(), "write_columns must succeed");
CHECK(handle->finish().has_value(), "finish must succeed");

auto workbook = xl::Workbook::open(bytes, {.format = format});
CHECK(workbook.has_value(), "the bytes finish wrote must open");
CHECK(workbook->sheet(0).name() == "Vendas", "write_sheet's name must reach the bytes");
CHECK(workbook->sheet(1).name() == "Resumo", "start_sheet's name must reach the bytes");
CHECK(workbook->sheet(2).name() == "Estoque", "write_columns's name must reach the bytes");
CHECK(read_cells(*workbook, 0) == (std::vector<std::string>{"name|view", "widget|w", "gadget|g"}),
"write_sheet must write a header and every row");
CHECK(read_cells(*workbook, 1) == (std::vector<std::string>{"total"}), "the streamed sheet must sit between them");
CHECK(read_cells(*workbook, 2) == (std::vector<std::string>{"name|qty", "a|10", "b|20", "c|30", "d|40", "e|50"}),
"write_columns must write a header and every row in order");
}
}
return 0;
}

static int test_writer_handle_write_columns_fails_while_a_sheet_is_open()
{
const std::vector<int64_t> values{1};
const std::array<xl::ColumnRef, 1> columns{xl::i64_column("qty", values)};
std::vector<uint8_t> bytes;
auto handle = xl::WriterHandle::open(bytes, {.format = XL_FORMAT_XLSX});
CHECK(handle.has_value(), "WriterHandle::open over a vector must succeed");

CHECK(handle->start_sheet("Aberta").has_value(), "start_sheet must succeed");
CHECK(!handle->write_columns("Tabela", columns).has_value(), "write_columns must fail while a sheet is open");
CHECK(handle->end_sheet().has_value(), "end_sheet must succeed");
CHECK(handle->write_columns("Tabela", columns).has_value(), "write_columns must succeed once the sheet is ended");
CHECK(handle->finish().has_value(), "finish must succeed");

auto workbook = xl::Workbook::open(bytes, {.format = XL_FORMAT_XLSX});
CHECK(workbook.has_value() && workbook->sheet(1).name() == "Tabela", "the table must be the second sheet");
return 0;
}

static int test_writer_handle_writes_excel_tables_the_workbook_reads_back()
{
const std::vector<NamedRow> sales{{"widget", "w"}, {"gadget", "g"}};
const std::vector<int32_t> offsets{0, 1, 2, 3};
const std::vector<uint8_t> blob{'a', 'b', 'c'};
const std::vector<int64_t> values{10, 20, 30};
const std::array<xl::ColumnRef, 2> stock{xl::string_column("name", offsets, blob), xl::i64_column("qty", values)};

for (const int32_t format : {XL_FORMAT_XLSX, XL_FORMAT_XLSB})
{
std::vector<uint8_t> bytes;
auto handle = xl::WriterHandle::open(bytes, {.format = format, .degree_of_parallelism = 4, .batch_rows = 1});
CHECK(handle.has_value(), "WriterHandle::open over a vector must succeed");
CHECK(handle->start_sheet("Notas").has_value(), "start_sheet must succeed");
CHECK(handle->end_sheet().has_value(), "end_sheet must succeed");
CHECK(handle->write_sheet("Vendas", sales, xl::TableOptions{.name = "Sales"}).has_value(),
"write_sheet as a table must succeed");
CHECK(handle->write_columns("Estoque", stock, xl::TableOptions{.name = "Stock", .style = std::nullopt, .row_stripes = false})
.has_value(),
"write_columns as a table must succeed");
CHECK(handle->finish().has_value(), "finish must succeed");

auto workbook = xl::Workbook::open(bytes, {.format = format});
CHECK(workbook.has_value(), "the bytes finish wrote must open");
auto tables = workbook->tables();
CHECK(tables.has_value() && tables->size() == 2, "both tables must be listed");
CHECK((*tables)[0].name == "Sales" && (*tables)[1].name == "Stock", "tables keep their names and order");
const xl::TableInfo &first = (*tables)[0];
CHECK(first.index == 0 && first.sheet == 1 && first.first_row == 0 && first.last_row == 2 && first.first_column == 0 &&
first.last_column == 1 && first.header_row_count == 1 && first.totals_row_count == 0,
"the table's range must cover its header and both rows");

auto found = workbook->table_by_name("sales");
CHECK(found.has_value() && found->has_value(), "table_by_name must ignore case");
CHECK((*found)->name() == "Vendas", "a table's sheet reports the sheet holding it");
CHECK(read_cells(*workbook, (*found)->index()) == (std::vector<std::string>{"name|view", "widget|w", "gadget|g"}),
"the table must read its header and rows");
CHECK(read_cells(*workbook, workbook->table(1).index()) == (std::vector<std::string>{"name|qty", "a|10", "b|20", "c|30"}),
"table() must read the second table");

auto missing = workbook->table_by_name("missing");
CHECK(missing.has_value() && !missing->has_value(), "an unknown table name finds nothing");
CHECK(!workbook->table(2).rows().has_value(), "a table index past the end fails at the first read");
}

std::vector<uint8_t> xls;
auto handle = xl::WriterHandle::open(xls, {.format = XL_FORMAT_XLS});
CHECK(handle.has_value(), "WriterHandle::open over a vector must succeed");
CHECK(!handle->write_sheet("Vendas", sales, xl::TableOptions{.name = "Sales"}).has_value(), "XLS has no tables");
CHECK(handle->write_sheet("Vendas", sales).has_value(), "a plain sheet must still be written");
return 0;
}

static int test_writer_handle_class_rejects_auto_for_memory()
{
std::vector<uint8_t> bytes;
Expand Down Expand Up @@ -833,6 +965,18 @@ static int test_a_valid_empty_string_replaces_the_default()

int main()
{
if (test_writer_handle_writes_whole_sheets_beside_streamed_ones() != 0)
{
return 1;
}
if (test_writer_handle_write_columns_fails_while_a_sheet_is_open() != 0)
{
return 1;
}
if (test_writer_handle_writes_excel_tables_the_workbook_reads_back() != 0)
{
return 1;
}
if (test_narrow_field_rejects_a_value_it_cannot_hold() != 0)
{
return 1;
Expand Down
16 changes: 8 additions & 8 deletions docs/guide/reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ A callback that writes to a database or sends messages must be idempotent on a C

[`ExcelParseException.Row`](xref:ExcelReader.Core.Parser.ExcelParseException.Row) on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the row a sequential read reports, whether or not the read was partitioned.

See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 28 ms (prefetched, sequential) to about 17 ms, and on XLSB from 17 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 4 is ~1.8x faster than the typed path while allocating ~450x less, with zero garbage collections.
See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 24 ms (prefetched, sequential) to about 17 ms, and on XLSB from 17 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 4 is ~2.0x faster than the typed path while allocating ~630x less, with zero garbage collections.

Properties need setters. A get-only property is skipped during binding and silently receives nothing.

Expand Down Expand Up @@ -356,15 +356,15 @@ Measured across both read benchmarks (see [Real data reads](../performance/bench

| Workload | Default | `PrefetchDecompression = true` | Gain |
|---|---:|---:|---:|
| XLSX, real data | 41.2 ms | 27.8 ms | 32% |
| XLSM, real data | 43.5 ms | 28.4 ms | 35% |
| XLSB, real data | 25.8 ms | 16.7 ms | 35% |
| XLSX, string-heavy | 33.1 ms | 23.0 ms | 31% |
| XLSB, string-heavy | 32.6 ms | 19.7 ms | 40% |
| XLSX, real data | 37.2 ms | 23.7 ms | 36% |
| XLSM, real data | 38.0 ms | 23.1 ms | 39% |
| XLSB, real data | 24.7 ms | 16.6 ms | 33% |
| XLSX, string-heavy | 30.0 ms | 20.0 ms | 33% |
| XLSB, string-heavy | 33.6 ms | 19.5 ms | 42% |

The gain tracks how much of a read is decompression rather than parsing, so it is smallest
on XLSX real data (about a third) and largest on string-heavy XLSB (40%). Allocations rise from the producer task and the
pooled decompression buffers — on the real-data corpus, roughly 7 KB to 24 KB for XLSX
on XLSB real data and string-heavy XLSX (about a third) and largest on string-heavy XLSB (42%). Allocations rise from the producer task and the
pooled decompression buffers — on the real-data corpus, roughly 7 KB to 23 KB for XLSX
and 7 KB to 15 KB for XLSB — and neither path triggers a garbage collection.

Do **not** enable it for concurrent server workloads: a caller already reading many files
Expand Down
20 changes: 10 additions & 10 deletions docs/guide/writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,12 @@ await sheet.EndAsync();
[`XlsbWriterOptions`](xref:ExcelReader.Core.Writer.Xlsb.XlsbWriterOptions) has the same property. XLS and CSV are uncompressed, so
they have nothing to overlap and do not offer it.

Writing a 50,000-row workbook (`WriteBenchmark`, both figures from one run):
Writing a 50,000-row workbook (`WriteBenchmark`):

| Workload | Default | `PrefetchWrite = true` | Gain |
|---|---:|---:|---:|
| XLSX | 8.527 ms | 5.245 ms | 38% |
| XLSB | 5.786 ms | 3.234 ms | 44% |
| XLSX | 8.191 ms | 5.351 ms | 35% |
| XLSB | 5.462 ms | 3.282 ms | 40% |

Allocations are unchanged (4.02 MB either way) — the background writer hands over buffers the row
writer already owns rather than copying them.
Expand Down Expand Up @@ -328,13 +328,13 @@ Writing 1,000,000 rows of four columns (text, integer, date, number) to a `Memor

| Write | XLSX | XLSB |
|---|---:|---:|
| Serial | 171.57 ms | 115.39 ms |
| Serial, `PrefetchWrite` | 104.92 ms | 60.02 ms |
| Parallel, 1 batch in flight | 165.22 ms | 107.81 ms |
| Parallel, 2 | 89.69 ms | 57.94 ms |
| Parallel, 4 | 46.29 ms | 30.62 ms |
| Parallel, 8 | 30.56 ms | 21.04 ms |
| Parallel, 16 | 27.93 ms | 18.25 ms |
| Serial | 165.08 ms | 113.61 ms |
| Serial, `PrefetchWrite` | 102.18 ms | 62.39 ms |
| Parallel, 1 batch in flight | 164.23 ms | 114.03 ms |
| Parallel, 2 | 84.48 ms | 58.53 ms |
| Parallel, 4 | 44.27 ms | 30.04 ms |
| Parallel, 8 | 27.54 ms | 19.48 ms |
| Parallel, 16 | 21.64 ms | 15.37 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
Expand Down
Loading
Loading