Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d1dcc2c
fix(zip): verify a sheet entry's CRC-32 when reading stops at the end…
GabrielMarquezMatte Oct 27, 2026
a9c2192
perf(writer): resolve date style variants without taking a lock
GabrielMarquezMatte Oct 27, 2026
700a4dc
perf(parser): resolve integral enum cells through a long lookup
GabrielMarquezMatte Oct 27, 2026
b829cc1
perf(reader): reuse boxed booleans in SheetDataReader.GetValue
GabrielMarquezMatte Oct 27, 2026
df8cb0c
perf(native): stop allocating a closure per written cell
GabrielMarquezMatte Oct 27, 2026
1f0e4a5
perf(python): decode row blobs from a memoryview with a prebuilt cell…
GabrielMarquezMatte Oct 27, 2026
83ab8c7
perf(python): pass written strings as bytes and dispatch cells on exa…
GabrielMarquezMatte Oct 27, 2026
09b1cb9
perf(xlsx): keep formula cells on the canonical cell path
GabrielMarquezMatte Oct 27, 2026
2f73e72
perf(xlsx): hand inline-string cells from the canonical path straight…
GabrielMarquezMatte Oct 27, 2026
b51be5f
fix(trim): clear IL2087 and IL2073 from the bug-hunt fixes
GabrielMarquezMatte Oct 27, 2026
1139329
Merge remote-tracking branch 'origin/master' into develop
GabrielMarquezMatte Oct 28, 2026
ecc5e26
perf(writer): hash a new shared string once, and skip the shards when…
GabrielMarquezMatte Oct 28, 2026
247a497
perf(generator): write a ReadOnlySpan<byte> member through WriteUtf8 …
GabrielMarquezMatte Oct 28, 2026
a0ccf7e
perf(writer): format runtime value types in reflected records straigh…
GabrielMarquezMatte Oct 28, 2026
ef831b1
perf(writer): keep a worker's own string in the shared-string shards …
GabrielMarquezMatte Oct 28, 2026
3938f74
refactor: apply ConfigureAwait(false) to asynchronous calls and impro…
Oct 28, 2026
19ee60b
refactor: improve code formatting and readability in Parsing, Reading…
Oct 28, 2026
6fbb0a6
perf(docs): update benchmark results for improved performance metrics…
Oct 28, 2026
2f7f1c6
refactor: streamline asynchronous resource management and improve cod…
Oct 28, 2026
d17287e
refactor: improve code readability by restructuring methods in encryp…
Oct 28, 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
56 changes: 28 additions & 28 deletions cpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,13 +526,13 @@ on Windows 10 (22H2), 16 logical CPUs @ 3.39 GHz, MSVC 19.51 (Release), Google B

| Benchmark | RealExcel.xlsb (100 rows) | 65K_Records_Data.xlsb (65,535 rows) |
|---|---:|---:|
| `open` (from memory) | 23.4 µs | 1.17 ms |
| `parse` (4 or 6 bound columns) | 44.8 µs | 23.3 ms |
| `parse_batches`, batches of 1,000 / 10,000 rows | - | 22.4 ms / 22.9 ms |
| `infer_schema` (sample 100 / 1,000 rows) | 68.8 µs | 572.7 µs |
| `open` (from memory) | 22.7 µs | 1.23 ms |
| `parse` (4 or 6 bound columns) | 44.6 µs | 23.8 ms |
| `parse_batches`, batches of 1,000 / 10,000 rows | - | 22.6 ms / 23.1 ms |
| `infer_schema` (sample 100 / 1,000 rows) | 72.4 µs | 607.0 µs |

`open` here is the byte-span overload, which copies the caller's buffer before returning (the caller may free
it afterwards), so it scales with file size: 1.17 ms is mostly copying the 3.7 MB fixture. Opening by
it afterwards), so it scales with file size: 1.23 ms is mostly copying the 3.7 MB fixture. Opening by
path reads only the package directory and stays nearly flat — see the Rust suite below.
`parse` scales linearly with rows × columns, at roughly 360 ns/row here. Draining the same
sheet through the batched typed reader costs the same as one whole-sheet `parse` while holding
Expand All @@ -549,12 +549,12 @@ sides decode every cell into an owned value (`std::string` for text, not the zer

| Library | Mean |
|---|---:|
| ExcelReader (`parse<FullRow>`) | 61.3 ms |
| DuckDB (`read_xlsx`, summing every column in SQL) | 413.8 ms |
| xlsxio (`xlsxioread_sheet_next_cell_*`) | 497.4 ms |
| xlnt (`worksheet::rows()` + `cell::to_string()`/`value<double>()`) | 2,335.0 ms |
| ExcelReader (`parse<FullRow>`) | 58.0 ms |
| DuckDB (`read_xlsx`, summing every column in SQL) | 417.8 ms |
| xlsxio (`xlsxioread_sheet_next_cell_*`) | 515.5 ms |
| xlnt (`worksheet::rows()` + `cell::to_string()`/`value<double>()`) | 2,495.0 ms |

ExcelReader is ~6.7x faster than DuckDB, ~8.1x faster than xlsxio and ~38x faster than xlnt on this
ExcelReader is ~7.2x faster than DuckDB, ~8.9x faster than xlsxio and ~43x faster than xlnt on this
workload. xlsxio is a
lean, purpose-built C streaming reader — the same abstraction level as ExcelReader's own native
core — so it was the strongest of the two competitors tested, though still well behind.
Expand Down Expand Up @@ -594,11 +594,11 @@ Measured on the same machine as above, GCC 16.2.0 (MSYS2 UCRT64, `-O3 -mavx2` fo

| Workload | ExcelReader | zsv `compat` | zsv `fast` |
|---|---:|---:|---:|
| Typed, 14 columns into `FullRow` | 27.4 ms | 22.6 ms | 20.0 ms |
| Cells, byte count | 11.6 ms | 5.8 ms | 3.5 ms |
| Typed, 14 columns into `FullRow` | 28.1 ms | 24.0 ms | 21.2 ms |
| Cells, byte count | 11.8 ms | 5.8 ms | 3.5 ms |

**zsv is faster on both.** Typed, it is ~1.2x (`compat`) and ~1.4x (`fast`) faster than ExcelReader.
Counting cells, it is ~2.0x and ~3.3x faster.
**zsv is faster on both.** Typed, it is ~1.2x (`compat`) and ~1.3x (`fast`) faster than ExcelReader.
Counting cells, it is ~2.0x and ~3.4x faster.

The cells gap is the C ABI, not the CSV parser. `xl_rows_next_view` copies every cell's value into a
native row the cursor owns, one call per row, and that alone costs ~10.6 ms here (calling it without
Expand Down Expand Up @@ -635,11 +635,11 @@ is handed buffers that are already columnar, while `BM_WriteSheet` starts from a

| Benchmark | Time | Rows/s |
|---|---:|---:|
| `BM_WriteColumns` (pre-transposed) | 33.2 ms | 1.98 M/s |
| `BM_WriteSheet` (from `std::vector<Row>`) | 40.3 ms | 1.63 M/s |
| `BM_WriteColumns` (pre-transposed) | 34.0 ms | 1.94 M/s |
| `BM_WriteSheet` (from `std::vector<Row>`) | 39.9 ms | 1.64 M/s |

The transpose costs ~21% here. It is not free, but it is not the dominant cost of producing the
file — see the comparison below, where the same two cases over 14 columns land within ~14–16%.
The transpose costs ~17% here. It is not free, but it is not the dominant cost of producing the
file — see the comparison below, where the same two cases over 14 columns land within ~16–17%.

`excelreader_cpp_write_compare_benchmarks` (under `-DEXCELREADER_BUILD_BENCHMARKS_COMPARE=ON`) puts
that against xlnt, xlsxio and [DuckDB](https://github.com/duckdb/duckdb)'s `excel` extension, all
Expand All @@ -656,12 +656,12 @@ competitor.

| Library | Wall | CPU |
|---|---:|---:|
| ExcelReader (`xl::write_columns`, pre-transposed) | 64.2–66.9 ms | 63.9–68.2 ms |
| ExcelReader (`xl::write_sheet<FullRow>`) | 74.8–76.0 ms | 74.7–76.4 ms |
| DuckDB (`COPY ... TO ... WITH (FORMAT xlsx)`) | 831.4–831.6 ms | 828.1 ms |
| libxlsxwriter (`worksheet_write_string`/`_number`) | 1,206.7 ms | 1,187.5 ms |
| xlsxio (`xlsxiowrite_add_cell_*`) | 2,405.1 ms | 1,125.0 ms |
| xlnt (`worksheet::cell().value()` + `save()`) | 5,525.3 ms | 5,515.6 ms |
| ExcelReader (`xl::write_columns`, pre-transposed) | 65.3–65.7 ms | 65.3–66.8 ms |
| ExcelReader (`xl::write_sheet<FullRow>`) | 76.2–76.5 ms | 76.4 ms |
| DuckDB (`COPY ... TO ... WITH (FORMAT xlsx)`) | 834.6–834.9 ms | 828.1–843.8 ms |
| libxlsxwriter (`worksheet_write_string`/`_number`) | 1,200.4 ms | 1,203.1 ms |
| xlsxio (`xlsxiowrite_add_cell_*`) | 2,441.4 ms | 859.4 ms |
| xlnt (`worksheet::cell().value()` + `save()`) | 5,554.0 ms | 5,546.9 ms |

Same machine as above; Google Benchmark's own iteration counts, no `--benchmark_repetitions` (each
iteration writes a whole 65,535-row file, so the slower cases run once or a handful of times). xlnt
Expand All @@ -672,7 +672,7 @@ sampling within one run.

`write_sheet` — the matched-work number, since it starts from the same `std::vector<FullRow>` every
competitor is handed — is ~11x faster than DuckDB, ~16x faster than libxlsxwriter, ~32x
faster than xlsxio, and ~74x faster than xlnt on wall time.
faster than xlsxio, and ~73x faster than xlnt on wall time.

### Known issue: xlsxio + libxlsxwriter cannot share a binary

Expand Down Expand Up @@ -709,10 +709,10 @@ Caveats, none of them optional when quoting these:

- **xlsxio's CPU time is not stable across runs.** It read 843.8–906.3 ms before the fix (some
runs missing the workbook.xml entry), 1,171.9 ms and 687.5 ms in the two runs after it,
1,015.6 ms and 843.8 ms in the next two, 953.1 ms in the one after and 1,125.0 ms in the latest, while its wall time stayed at ~2.4 s throughout. Its wall time is dominated by I/O wait —
1,015.6 ms and 843.8 ms in the next two, 953.1 ms in the one after, 1,125.0 ms in the next and 859.4 ms in the latest, while its wall time stayed at ~2.4 s throughout. Its wall time is dominated by I/O wait —
CPU is well under half of wall, against every other case here being CPU-bound (wall ≈ CPU) — so
quote the wall-time ratio, which is what a caller waits. Against this run's CPU time the gap from
`write_sheet` to xlsxio is ~15x, not ~32x.
`write_sheet` to xlsxio is ~11x, not ~32x.
- **ExcelReader does slightly more work here**, not less: it attaches a number format to the two
date columns so Excel shows a date, while every competitor case writes those as bare serial
numbers. That difference favours the competitors.
Expand All @@ -724,6 +724,6 @@ Caveats, none of them optional when quoting these:
a full analytical query engine doing far more than any Excel-writing library here; this measures
one narrow slice of it, not "DuckDB" as a whole.

The two ExcelReader cases land within ~14–16% of each other. The transpose's share grew as producing
The two ExcelReader cases land within ~16–17% of each other. The transpose's share grew as producing
the file got cheaper, but it is still a minority of the cost. Reach for
`write_columns` when your data is already columnar, but `write_sheet` is not the slow path.
5 changes: 3 additions & 2 deletions docs/guide/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ using ExcelReader.Core.Writer;
using ExcelReader.Core.Writer.Xlsx;

using var plain = new MemoryStream();
await using (var workbook = XlsxWorkbookWriter.Create(plain, leaveOpen: true))
var workbook = XlsxWorkbookWriter.Create(plain, leaveOpen: true);
await using (workbook.ConfigureAwait(false))
{
// write sheets and rows as usual
}
plain.Position = 0;

using var destination = File.Create("secret.xlsx");
await Excel.EncryptPackageAsync(plain, destination, "hunter2");
await Excel.EncryptPackageAsync(plain, destination, "hunter2").ConfigureAwait(false);
```

The package stream must be seekable (it is read twice, so the container never has to be buffered in
Expand Down
12 changes: 8 additions & 4 deletions docs/guide/untrusted-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@ catch (Exception ex) when (IsRejectedFile(ex))
Console.WriteLine($"Rejected: {ex.Message}");
}

static bool IsRejectedFile(Exception ex) =>
ex is InvalidDataException or EndOfStreamException or NotSupportedException
static bool IsRejectedFile(Exception ex)
{
return ex is InvalidDataException or EndOfStreamException or NotSupportedException
or ExcelEncryptionException or ExcelParseException
|| ex.GetType() == typeof(ArgumentException)
|| ex.GetType() == typeof(InvalidOperationException);
}
```

`LimitName` is the option's name (or `Columns`, `ArrayMaxLength`); `Limit` and `Actual` are in that
Expand Down Expand Up @@ -140,8 +142,10 @@ file in a spreadsheet program, neutralize those fields yourself:

<!-- snippet: untrusted-input-csv-injection -->
```csharp
static string Neutralize(string text) =>
text.Length > 0 && "=+-@\t\r".Contains(text[0]) ? "'" + text : text;
static string Neutralize(string text)
{
return text.Length > 0 && "=+-@\t\r".Contains(text[0]) ? "'" + text : text;
}

row.Write(Neutralize(comment));
```
Expand Down
20 changes: 10 additions & 10 deletions docs/performance/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,23 +447,23 @@ zero-copy advantage the other can't take:

| Comparison | Format | ExcelReader | Competitor | Ratio |
|---|---|---:|---:|---:|
| vs. [calamine](https://github.com/tafia/calamine) (Rust) | XLSX | 72.3 ms | 271.2 ms | ~3.8x faster |
| vs. calamine (Rust) | XLSB | 46.3 ms | 82.9 ms | ~1.8x faster |
| vs. [DuckDB](https://github.com/duckdb/duckdb) `read_xlsx` (C++) | XLSX | 61.3 ms | 413.8 ms | ~6.7x faster |
| vs. [xlsxio](https://github.com/brechtsanders/xlsxio) (C) | XLSX | 61.3 ms | 497.4 ms | ~8.1x faster |
| vs. [xlnt](https://github.com/tfussell/xlnt) (C++) | XLSX | 61.3 ms | 2,335.0 ms | ~38x faster |
| vs. [zsv](https://github.com/liquidaty/zsv) `fast` engine (C) | CSV | 27.4 ms | 20.0 ms | ~1.4x **slower** |
| vs. [calamine](https://github.com/tafia/calamine) (Rust) | XLSX | 69.5 ms | 276.4 ms | ~4.0x faster |
| vs. calamine (Rust) | XLSB | 47.8 ms | 87.0 ms | ~1.8x faster |
| vs. [DuckDB](https://github.com/duckdb/duckdb) `read_xlsx` (C++) | XLSX | 58.0 ms | 417.8 ms | ~7.2x faster |
| vs. [xlsxio](https://github.com/brechtsanders/xlsxio) (C) | XLSX | 58.0 ms | 515.5 ms | ~8.9x faster |
| vs. [xlnt](https://github.com/tfussell/xlnt) (C++) | XLSX | 58.0 ms | 2,495.0 ms | ~43x faster |
| vs. [zsv](https://github.com/liquidaty/zsv) `fast` engine (C) | CSV | 28.1 ms | 21.2 ms | ~1.3x **slower** |

Writing the same 14 columns × 65,535 rows from row-shaped data, the C++ suite measures
`write_sheet` at 74.8–76.0 ms against DuckDB's 831 ms (~11x), libxlsxwriter's 1,207 ms (~16x),
xlsxio's 2,405 ms (~32x) and xlnt's 5,525 ms (~74x). The Rust suite measures 40.7 ms against
rust_xlsxwriter's 322.0 ms (~7.9x) over 7 columns. Caveats for both are in the binding READMEs.
`write_sheet` at 76.2–76.5 ms against DuckDB's 835 ms (~11x), libxlsxwriter's 1,200 ms (~16x),
xlsxio's 2,441 ms (~32x) and xlnt's 5,554 ms (~73x). The Rust suite measures 40.8 ms against
rust_xlsxwriter's 338.5 ms (~8.3x) over 7 columns. Caveats for both are in the binding READMEs.

DuckDB, xlsxio and xlnt do not read `.xlsb`, so those comparisons are XLSX-only. calamine is a fast,
well-optimized reader in its own right — the gap there is real but not the order of magnitude seen
against the C/C++ competitors.

zsv is the one competitor ahead. The CSV row is the typed workload, where both sides build the same
14-column rows with owned strings. Just counting cell bytes, zsv is ~3.3x faster, and that gap is
14-column rows with owned strings. Just counting cell bytes, zsv is ~3.4x faster, and that gap is
ExcelReader's C ABI, which serializes every row into a buffer, not its CSV parser. That suite is
built with GCC (zsv does not compile under MSVC). Its caveats are in the C++ README.
5 changes: 3 additions & 2 deletions docs/snippets/Encryption.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ internal static async Task Run()
{
#region encryption-write
using var plain = new MemoryStream();
await using (var workbook = XlsxWorkbookWriter.Create(plain, leaveOpen: true))
var workbook = XlsxWorkbookWriter.Create(plain, leaveOpen: true);
await using (workbook.ConfigureAwait(false))
{
// write sheets and rows as usual
}
plain.Position = 0;

using var destination = File.Create("secret.xlsx");
await Excel.EncryptPackageAsync(plain, destination, "hunter2");
await Excel.EncryptPackageAsync(plain, destination, "hunter2").ConfigureAwait(false);
#endregion
}
}
Expand Down
12 changes: 8 additions & 4 deletions docs/snippets/UntrustedInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ internal static void Run(Stream upload, ExcelReaderOptions uploadOptions)
Console.WriteLine($"Rejected: {ex.Message}");
}

static bool IsRejectedFile(Exception ex) =>
ex is InvalidDataException or EndOfStreamException or NotSupportedException
static bool IsRejectedFile(Exception ex)
{
return ex is InvalidDataException or EndOfStreamException or NotSupportedException
or ExcelEncryptionException or ExcelParseException
|| ex.GetType() == typeof(ArgumentException)
|| ex.GetType() == typeof(InvalidOperationException);
}
#endregion
}
}
Expand All @@ -59,8 +61,10 @@ internal static class Snippet
internal static void Run(CsvRowWriter row, string comment)
{
#region untrusted-input-csv-injection
static string Neutralize(string text) =>
text.Length > 0 && "=+-@\t\r".Contains(text[0]) ? "'" + text : text;
static string Neutralize(string text)
{
return text.Length > 0 && "=+-@\t\r".Contains(text[0]) ? "'" + text : text;
}

row.Write(Neutralize(comment));
#endregion
Expand Down
Loading
Loading