Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cc22ba6
refactor(core)!: rename the XLSX, XLSB and XLS readers to workbooks
GabrielMarquezMatte Oct 6, 2026
b1807c0
feat(core): add workbook and sheet types next to the reader surface
GabrielMarquezMatte Oct 6, 2026
ff96e67
feat(core): parse, infer, adapt and convert from a sheet
GabrielMarquezMatte Oct 6, 2026
530d285
refactor(cli): resolve a workbook and a sheet instead of positioning …
GabrielMarquezMatte Oct 6, 2026
4ab5ee5
refactor(native): keep the current sheet in the handle, over a workbook
GabrielMarquezMatte Oct 6, 2026
1b4887b
test: read through sheets in the benchmarks, the fuzz harness and the…
GabrielMarquezMatte Oct 6, 2026
8ec6147
test(core): read through sheets in the reader tests
GabrielMarquezMatte Oct 6, 2026
2d4f998
test(core): read through sheets in the per-format reader tests
GabrielMarquezMatte Oct 6, 2026
d47fa32
test(core): parse sheets in the parser tests
GabrielMarquezMatte Oct 6, 2026
aa75f68
test: read through sheets in the writer, crypto, arrow and native tests
GabrielMarquezMatte Oct 6, 2026
5a523d1
test(core): build the parser test enumerable from a sheet
GabrielMarquezMatte Oct 6, 2026
4791c31
feat(core)!: replace the row reader and its current sheet with workbo…
GabrielMarquezMatte Oct 6, 2026
b18263b
docs(core): carry the removed overloads' documentation to the sheet o…
GabrielMarquezMatte Oct 6, 2026
2629333
test(core): open and read sheets concurrently across every source kind
GabrielMarquezMatte Oct 6, 2026
a761fc4
docs: describe workbooks and sheets in the README and the architectur…
GabrielMarquezMatte Oct 6, 2026
ee86bf6
docs: tidy the architecture notes
GabrielMarquezMatte Oct 6, 2026
b0f956a
test(core): read content from an enumerator that outlives its workbook
GabrielMarquezMatte Oct 6, 2026
fbce601
docs(core): say a source must outlive every enumerator, not only the …
GabrielMarquezMatte Oct 6, 2026
8ce944c
ci: publish the Rust crate without the local native-library override
GabrielMarquezMatte Oct 6, 2026
ab2ee35
docs: guide the reader through workbooks and sheets, and the move to v6
GabrielMarquezMatte Oct 6, 2026
053f876
build: add IDE1006 to NoWarn list in Directory.Build.props
GabrielMarquezMatte Oct 6, 2026
95c095b
feat(native)!: pass a sheet index to typed, Arrow and schema reads
Oct 7, 2026
f3fee85
feat(native)!: read rows through a cursor handle opened per sheet
Oct 7, 2026
e28a01a
feat(native)!: fold xl_open_file_ex and xl_open_memory_ex into the op…
Oct 7, 2026
bb17a9c
test(native): read every sheet of one workbook handle in parallel
Oct 7, 2026
df66fbf
docs(native): describe the v6 exports and their migration from v5
Oct 7, 2026
032c595
fix(native): settle the last-error contract and bring the C smoke tes…
Oct 7, 2026
9eb7539
feat(cpp)!: read through sheets over C ABI v6
Oct 7, 2026
7832760
feat(rust)!: read through sheets over C ABI v6
Oct 7, 2026
64eaaab
feat(python)!: read through sheets over C ABI v6
Oct 7, 2026
c6e54d9
fix(cpp): migrate the package consumer and tighten a rows check
Oct 7, 2026
11ead80
test(rust): read every sheet of one workbook on its own thread
Oct 7, 2026
f67f5a1
test(python): make the release tests observe the native close
Oct 7, 2026
885a32f
docs(cpp): show sheets and parallel reads, and the move from v5
Oct 7, 2026
fea5c2e
docs(cpp): take the cursor's value in the sheet-by-name example
Oct 7, 2026
fa719aa
docs(rust): show sheets and parallel reads, and the move from v5
Oct 7, 2026
bd6d945
test(python): read every sheet of one workbook on its own thread
Oct 7, 2026
1c592b7
docs(python): show sheets and parallel reads, and the move from v5
Oct 7, 2026
567c289
fix(rust): let decoded rows and typed tables cross threads
Oct 7, 2026
0a803c5
fix(cpp): read a cursor's remaining rows, warm the runtime once, and …
Oct 7, 2026
47c5605
fix(python): type the sheet lookup, give sheets value equality, and w…
Oct 7, 2026
78228b5
fix(python): close a workbook once when several threads close it at once
Oct 7, 2026
504be89
feat(rust): infer types from text and parse a CSV in parallel
Oct 7, 2026
ba14afb
test(python): reproduce the concurrent-close race
Oct 7, 2026
0b7e42a
fix(cpp): build the comparison benchmarks against the v6 wrapper
Oct 7, 2026
d535ff3
test(python): let bench_read read any format its path names
Oct 7, 2026
92b6b37
perf(native): regenerate the static PGO profile for the v6 entry points
Oct 7, 2026
18ffdf7
build(cpp): configure the comparison benchmarks on CMake 4
Oct 7, 2026
2d351eb
Refactor code structure for improved readability and maintainability
Oct 7, 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
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,21 @@ jobs:
working-directory: rust/excelreader-derive
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --allow-dirty
run: |
if ! out=$(cargo publish --allow-dirty 2>&1); then
echo "$out"
grep -q 'already exists' <<<"$out" || exit 1
fi

# The workspace config points EXCELREADER_NATIVE_LIB_DIR at a local build that this job never makes;
# without it the package verification downloads the native library from the release, as users do.
- name: Publish excelreader to crates.io
working-directory: rust/excelreader
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --allow-dirty
run: |
rm ../.cargo/config.toml
cargo publish --allow-dirty

promote-public-api:
name: Promote PublicAPI.Unshipped -> Shipped
Expand Down
65 changes: 39 additions & 26 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,42 @@ memory-footprint measurements, approaches that were tried and rejected. That liv

## The four format families

Each format has its own `Reader` and a writer implementing `IWorkbookWriter<TSheet>`
(`src/ExcelReader.Core/Writer/IWorkbookWriter.cs`):
Each format has its own workbook (`CsvReader` for CSV) and a writer implementing
`IWorkbookWriter<TSheet>` (`src/ExcelReader.Core/Writer/IWorkbookWriter.cs`):

| Format | Reader | Writer | Sheet/row writer |
| Format | Workbook | Writer | Sheet/row writer |
|---|---|---|---|
| XLSX | `XlsxReader` | `XlsxWorkbookWriter` | `XlsxSheetWriter`/`XlsxRowWriter` |
| XLSB | `XlsbReader` | `XlsbWorkbookWriter` | `XlsbSheetWriter`/`XlsbRowWriter` |
| XLS | `XlsReader` | `XlsWorkbookWriter` | `XlsSheetWriter`/`XlsRowWriter` |
| CSV | `CsvReader` | `CsvWorkbookWriter` | `CsvSheetWriter`/`CsvRowWriter` |
| XLSX | `XlsxWorkbook` | `XlsxWorkbookWriter` | `XlsxSheetWriter`/`XlsxRowWriter` |
| XLSB | `XlsbWorkbook` | `XlsbWorkbookWriter` | `XlsbSheetWriter`/`XlsbRowWriter` |
| XLS | `XlsWorkbook` | `XlsWorkbookWriter` | `XlsSheetWriter`/`XlsRowWriter` |
| CSV | `CsvReader` | `CsvWorkbookWriter` | `CsvSheetWriter`/`CsvRowWriter` |

CSV has one extra layer: `CsvWriter` is the low-level RFC4180 writer (buffered rows straight to the
stream, no sheets/styles/shared-strings machinery); `CsvWorkbookWriter` adapts it to the shared
`IWorkbookWriter<CsvSheetWriter>` contract, exposing exactly one sheet.

All four open through `Excel.Open`/`OpenAsync`, which take an optional `ExcelFileFormat`. The three
signed formats are detected from the file's first bytes; CSV carries no signature, so it is named
rather than detected, and its dialect rides along in `ExcelReaderOptions.Csv`. That one options
object is what the native ABI's `xl_open_options` maps onto, so `ReadApi.Open` dispatches formats
rather than reimplementing them.

On top of all four readers sits the typed-parsing layer (`src/ExcelReader.Core/Parser/`):
All four open through `Excel.Open`/`OpenAsync`, which return an `IExcelWorkbook` and take an
optional `ExcelFileFormat`. The three signed formats are detected from the file's first bytes; CSV
carries no signature, so it is named rather than detected, and its dialect rides along in
`ExcelReaderOptions.Csv`. That one options object is what the native ABI's `xl_open_options` maps
onto, so `ReadApi.Open` dispatches formats rather than reimplementing them.

A workbook, a sheet and an enumerator are three separate things. The workbook (`XlsxWorkbook` and its
siblings, `IExcelWorkbook`) owns the shared state: the file or stream, the sheet list, the shared
strings and styles. A sheet (`XlsxSheet` and its siblings, `IExcelSheet`) is a `readonly struct` of
workbook plus index; it holds no resources, and `workbook.Sheets`, `FirstSheet`, `SheetAt` and
`TryGetSheet` hand them out without opening anything. Iterating a sheet creates an enumerator
(`XlsxWorkbook.Enumerator`), the one place that streams rows and the only mutable part, used by one
thread at a time. Lifetime is reference counted (`ReaderLifetime`): the workbook holds one reference
and each live enumerator another, so disposing the workbook closes it to new sheets and enumerators but
releases the file, stream and pooled buffers only when the last enumerator is disposed. Shared state is
published lazily and race-safely, which is what lets sheets of one workbook be read in parallel.

On top of all four workbooks sits the typed-parsing layer (`src/ExcelReader.Core/Parser/`):
`ExcelParser<T>`, built by `ExcelParser.FromAttributes` (reflection), `ExcelParser.Generated` (the
source-generated map) or `ExcelParser.Build` (a runtime-configured map). Its model may be a class, a struct or a `ref struct`;
a `ref struct` model binds directly to `Cell.Value` spans — zero allocation for the container and,
for span-typed columns, for the values too. It consumes `Row`/`Cell` from any reader uniformly.
for span-typed columns, for the values too. It consumes `Row`/`Cell` from any sheet uniformly.

## Parallel CSV parsing

Expand Down Expand Up @@ -166,7 +177,7 @@ turns one back into a stream the ordinary readers consume:
being materialized. One 4 KiB segment is cached, which is enough because ZIP reads are sequential
within an entry.

`XlsxReader` and `XlsbReader` are untouched by any of this: they receive a stream that happens to
`XlsxWorkbook` and `XlsbWorkbook` are untouched by any of this: they receive a stream that happens to
decrypt. Writing encrypted workbooks is a separate step, `Excel.EncryptPackage`/`EncryptPackageAsync`
in `PackageEncryptor.cs` — it wraps a plaintext package (written by any of the ordinary writers) in
an agile-encrypted CFB container, the inverse of `DecryptedPackageStream`. There is still no
Expand Down Expand Up @@ -197,28 +208,30 @@ failure in red through `ErrorConsole` on a terminal, or passes it through byte-f
Both live in `ExcelReader.Cli`, not `CliCommands.cs` - the interactive/plain decision is
`Console`-shaped state, exactly what that file's tests are built to never touch.

## Why readers are split into partial classes
## Why workbooks are split into partial classes

`XlsxReader` and `XlsbReader` are large enough that one file would be unwieldy, so each is split by
`XlsxWorkbook` and `XlsbWorkbook` are large enough that one file would be unwieldy, so each is split by
concern rather than by size:

- `XlsxReader.cs` / `XlsbReader.cs` — fields, constructors, sheet navigation, dispose.
- `XlsxWorkbook.cs` / `XlsbWorkbook.cs` — fields, constructors, the sheet list, dispose.
- `XlsbWorkbookParts.cs` (XLSB only) — parses `workbook.bin` for the sheet names, paths and visibility.
- `*.Loading.cs` (XLSX only) — one-time workbook-level XML parsing (sheets, shared strings, date1904).
- `*.Memory.cs` — the in-memory path: constructs directly over `ZipMemoryIndex`/`ZipPart` instead of
a `Stream`/`ZipArchive`, so it never suspends even under `await foreach`.
- `XlsxWorkbook.Memory.cs` (XLSX only) — the shared-string parse over an already-decompressed in-memory
part, so it never suspends even under `await foreach`.
- `*.Styles.cs` (XLSX only) — builds the cellXfs-index → is-date-style table.
- `*.Enumerator.cs` — the nested `Enumerator`: the actual streaming row/cell parser. By far the
largest file in each reader.
largest file in each workbook.

All partials of one reader share one field set (C# partial classes are one type), so e.g.
All partials of one workbook share one field set (C# partial classes are one type), so e.g.
`.Loading.cs`'s shared-string parse populates fields the nested `Enumerator` in `.Enumerator.cs`
reads back. `XlsReader` follows a reduced version of the same split (no `.Memory.cs` — the OLE
compound-file container has no in-memory-ZIP equivalent).
reads back. `XlsWorkbook` follows a reduced version of the same split (`.Strings.cs` for its string
tables, no `.Loading.cs`/`.Memory.cs`). The sheet values live in their own small files
(`XlsxSheet.cs`, `XlsbSheet.cs`, `XlsSheet.cs`, `CsvSheet.cs`).

## The sync/async twin convention

Hot-path search/refill primitives (e.g. `IndexOf`/`IndexOfAsync`/`IndexOfSlowAsync` in
`XlsxReader.Enumerator.cs`) come in three tiers, not one generic async method:
`XlsxWorkbook.Enumerator.cs`) come in three tiers, not one generic async method:

1. A blocking sync loop for the sync caller.
2. An async method whose common case — the data is already in the buffered window — is a synchronous
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
new overload later can rebind existing callers). Several intentional overloads (e.g.
Excel.Open) already accept this tradeoff for ergonomics; fixing it means redesigning
those APIs, not a PublicAPI.txt entry, so it stays suppressed. -->
<NoWarn>$(NoWarn);CA1062;RS0026;CA1819;CA1515;MA0048</NoWarn>
<NoWarn>$(NoWarn);CA1062;RS0026;CA1819;CA1515;MA0048;IDE1006</NoWarn>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>high</NuGetAuditLevel>
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ dotnet add package ExcelReader.NET
```csharp
using ExcelReader.Core.Reader;

using IExcelRowReader reader = Excel.Open("book.xlsx");
foreach (Row row in reader)
using IExcelWorkbook workbook = Excel.Open("book.xlsx");
foreach (Row row in workbook.FirstSheet)
{
foreach (RowCell cell in row.Cells)
{
Expand All @@ -40,6 +40,7 @@ foreach (Row row in reader)
- [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, and prefetch compression.
- [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.
- [Migrating to v6](docs/guide/migrating-to-v6.md) — porting v5 reader code to workbooks and sheets, and reading sheets in parallel.
- [Benchmarks](docs/performance/benchmarks.md) — throughput and allocation against Sep, Sylvan, OfficeIMO and SpreadCheetah. [Live results](https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/).

## Command line
Expand Down Expand Up @@ -73,8 +74,9 @@ errors to stderr, so `convert` is safe to pipe.

- Reads `.xlsx`, `.xlsb` (BIFF12), `.xls` (BIFF8), and `.csv`; writes `.xlsx`, `.xlsb`, `.xls`, and `.csv`.
- `Excel.Open`/`OpenAsync` auto-detect XLSX/XLSB/XLS from the file signature. CSV has none, so pass `ExcelFileFormat.Csv` to open delimited text through the same entry point; `ExcelReaderOptions.Csv` carries the dialect, and `CsvReaderOptions.SniffDialect` infers it from the source.
- Reads one sheet at a time (XLSX/XLSB/XLS); use `MoveToSheet(index)` or `TryMoveToSheet(name)` to switch sheets. CSV has no sheets.
- `SheetVisibility`/`SheetVisibilityAt(index)` report whether a sheet is hidden (`Visible`, `Hidden`, `VeryHidden`). Hidden sheets still enumerate their rows — filter on the value if you want them skipped. The writers take the same value via `AddSheet(name, visibility)`, and reject a workbook whose sheets would all be hidden.
- A workbook hands out its sheets: `FirstSheet`, `Sheets[index]` or `SheetAt(index)`, and `TryGetSheet(name)`. CSV is a workbook with one unnamed sheet. Coming from v5? See [Migrating to v6](docs/guide/migrating-to-v6.md).
- Sheets of one workbook can be read in parallel, one enumerator per thread.
- `sheet.Visibility` reports whether a sheet is hidden (`Visible`, `Hidden`, `VeryHidden`). Hidden sheets still enumerate their rows — filter on the value if you want them skipped. The writers take the same value via `AddSheet(name, visibility)`, and reject a workbook whose sheets would all be hidden.
- Missing cells in sparse rows are exposed as empty cells.
- 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.
Expand Down Expand Up @@ -106,7 +108,7 @@ XLSX, XLSB, XLS and CSV without a .NET runtime installed.
from excelreader import open_workbook

with open_workbook("book.xlsx") as workbook:
for row in workbook.rows():
for row in workbook.sheets[0].rows():
print([cell.value for cell in row])
```

Expand All @@ -128,9 +130,9 @@ write_sheet("out.xlsx", XL_FORMAT_XLSX, &rows, None)?;
auto written = xl::write_sheet("out.xlsx", rows); // format inferred from the extension
```

Row-by-row decoded reads are available from all three bindings — Python as `Workbook.rows()`, C++
as `xl::Workbook::rows()`, Rust as `Workbook::rows()`. Python additionally exposes
`read_all_columnar()` over `xl_read_all_blob`, which the other two do not wrap.
Row-by-row decoded reads are available from all three bindings — Python as `Sheet.rows()`, C++
as `xl::Sheet::rows()`, Rust as `Sheet::rows()`. Python additionally exposes
`read_all_columnar()` over `xl_rows_read_all_blob`, which the other two do not wrap.

The Arrow export is available from Python
(`to_arrow`/`to_record_batch`), C++ (`xl::parse_arrow<T>`, in the separate `<xl/excelreader_arrow.hpp>`
Expand Down
6 changes: 3 additions & 3 deletions STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ Every method, property getter/setter, and constructor must use a block body with

```csharp
// NOT OK
public string SheetName => _sheets[_current].Name;
public int SheetCount => _sheets.Length;
private static string Foo() => Bar();

// OK
public string SheetName
public int SheetCount
{
get
{
return _sheets[_current].Name;
return _sheets.Length;
}
}
private static string Foo()
Expand Down
Loading
Loading