Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 5 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ root = true

[*]
charset = utf-8
end_of_line = crlf
# lf, not crlf: every tracked file is committed LF, and CI runs the `dotnet format whitespace`
# gate on Linux where nothing rewrites endings on checkout. Demanding crlf here only passes on
# Windows, where core.autocrlf converts on checkout and hides the mismatch. See .gitattributes,
# which pins eol=lf so no contributor's autocrlf setting can change what lands in a blob.
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

Expand Down
18 changes: 18 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Line endings are pinned here rather than left to each contributor's core.autocrlf, because
# CI runs `dotnet format whitespace --verify-no-changes` on Linux: if a blob's endings depend on
# who committed it, that gate passes or fails based on the author's machine instead of the code.
# Every tracked text file is already LF, and .editorconfig's end_of_line matches.
* text=auto eol=lf

# Spreadsheet fixtures are ZIP (xlsx/xlsm/xlsb) and CFB (xls) containers. Never let text=auto's
# heuristic touch them — a single LF/CRLF substitution inside a compressed stream or an OLE sector
# corrupts the file, and the parsers would then be tested against garbage that git created.
*.xlsx binary
*.xlsm binary
*.xlsb binary
*.xls binary

# The benchmark CSV fixture is deliberately left under the rule above rather than marked -text:
# its blob is already LF, and -text would freeze whatever a contributor's working tree happens to
# hold, which on a machine with core.autocrlf=true means silently rewriting it to CRLF. Tests that
# actually cover terminator handling build their own fixtures in code.
36 changes: 36 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: Bug report
about: Something isn't working as expected
title: ""
labels: bug
---

**Describe the bug**
A clear description of what's wrong.

**File format(s) involved**
XLSX / XLSB / XLS / CSV

**Reproduction**
Minimal code sample, and — if possible — a minimal file that reproduces the issue (strip any
sensitive data first). If the file can't be shared, describe its shape (row/column count, styles,
shared strings, etc.) as precisely as you can.

```csharp
// minimal repro here
```

**Expected behavior**
What you expected to happen.

**Actual behavior**
What actually happened — include the full exception message/stack trace if there is one.

**Environment**
- ExcelReader.NET version:
- .NET version (8/10):
- OS:

**Note on security issues:** if this bug is a potential vulnerability (crash, excessive
memory/CPU, or other issue triggerable by an untrusted file), please do **not** open a public
issue — see [SECURITY.md](../../SECURITY.md) for the private reporting channel instead.
22 changes: 22 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an addition or change to the library
title: ""
labels: enhancement
---

**What problem does this solve?**
Describe the use case — what are you trying to do that the library doesn't support today?

**Proposed API/behavior**
Sketch the API shape you'd expect, if you have one in mind.

```csharp
// proposed usage
```

**Alternatives considered**
Any workarounds you're using today, or other approaches you considered.

**Additional context**
Anything else — links, related issues, prior art in other libraries.
15 changes: 15 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## What does this change?

<!-- One or two sentences: what changed and why. -->

## Checklist

- [ ] `dotnet build ExcelReader.slnx --configuration Release` builds clean (warnings are errors)
- [ ] `dotnet test tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release` passes
- [ ] If this changes the public API: `PublicAPI.Unshipped.txt` updated for **both** `net8.0` and `net10.0`
- [ ] Tests added/updated for the behavior change
- [ ] One focused change — unrelated fixes are in a separate PR

## Test plan

<!-- How did you verify this works? -->
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ jobs:
- name: Build (Release)
run: dotnet build ExcelReader.slnx --configuration Release --no-restore -p:DeterministicSourcePaths=false

- name: Verify formatting (whitespace)
if: matrix.os == 'ubuntu-latest'
run: dotnet format whitespace ExcelReader.slnx --verify-no-changes

- name: Test (Release) + collect coverage
run: >-
dotnet test --project tests/ExcelReader.Tests/ExcelReader.Tests.csproj
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CodeQL

on:
push:
branches: [ master ]
branches: [ master, develop ]
pull_request:
branches: [ master ]
branches: [ master, develop ]
schedule:
# Weekly scan (Mondays 06:00 UTC) to catch newly published query updates.
- cron: '0 6 * * 1'
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ jobs:
-p:Version=${{ steps.version.outputs.value }}
--output ./artifacts

- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
path: ./artifacts
format: spdx-json
output-file: ./artifacts/excelreader.spdx.json

- name: NuGet login (Trusted Publishing / OIDC)
uses: NuGet/login@v1
id: nuget-login
Expand Down
109 changes: 109 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Architecture

A map of how the codebase fits together, not a manual. Start here, then follow the file/type names
into the source — the code comments carry the detailed reasoning.

## The four format families

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

| Format | Reader | Writer | Sheet/row writer |
|---|---|---|---|
| XLSX | `XlsxReader` | `XlsxWorkbookWriter` | `XlsxSheetWriter`/`XlsxRowWriter` |
| XLSB | `XlsbReader` | `XlsbWorkbookWriter` | `XlsbSheetWriter`/`XlsbRowWriter` |
| XLS | `XlsReader` | `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.

On top of all four readers sits the typed-parsing layer (`src/ExcelReader.Core/Parser/`):
`ExcelParser<T>` (reflection/attribute-driven, allocates a model per row) and `RefParser`
(binds a `ref struct` model directly to `Cell.Value` spans — zero allocation for the container and,
for span-typed columns, for the values too). Both consume `Row`/`Cell` from any reader uniformly.

## Shared plumbing

Reader internals that would otherwise be duplicated four times over live in one place:

- **`CellAccumulator`** — pooled per-row cell storage (raw decoded UTF-8 text + a `CellDesc[]`
describing each cell's column/type/style/offset). Used by every format's enumerator. Also hosts
the shared BIFF numeric-error-code → text lookup (`#DIV/0!` etc.) for XLS/XLSB.
- **`PooledStreamRowEnumerator`** — abstract base centralizing the pooled-buffer lifecycle
(`BufferedStreamCursor` + `CellAccumulator`, `Fill`/`FillAsync`/`Ensure` wrappers) that every
format's enumerator subclasses. `MoveNext`/`MoveNextAsync` stay concrete per format — this only
removes the buffer-lifecycle boilerplate, not the parsing itself.
- **`BufferedStreamCursor`** — the refill/compact-or-grow cursor behind the XLSX/XLSB/CSV
forward-only stream enumerators. Has a second constructor for the in-memory-ZIP path that wraps an
already-fully-decompressed `ReadOnlyMemory<byte>` instead of a `Stream` (`Eof = true` immediately,
no refills).
- **`WorkbookLookups`** — small lookups that were once duplicated identically across readers: sheet
name→index, sheet-index bounds checks, date-style flags, shared-string offsets, and (ZIP formats
only) worksheet entry resolution plus prefetch/limit-counting stream composition. Takes arrays as
parameters rather than requiring a shared interface, since each format's backing arrays differ in
shape.
- **`LimitChecks`** — the DoS/resource-limit guards (`ExcelReaderOptions`/`CsvReaderOptions`), including
the single buffer-growth-cap function (`NextBufferSize`) every pooled buffer in the stack grows
through, so one limit policy governs all of them consistently.

## Why readers are split into partial classes

`XlsxReader` and `XlsbReader` 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.
- `*.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`.
- `*.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.

All partials of one reader 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).

## The sync/async twin convention

Hot-path search/refill primitives (e.g. `IndexOf`/`IndexOfAsync`/`IndexOfSlowAsync`,
`EnsureRowBuffered`/`...Async`/`...SlowAsync` in `XlsxReader.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
check returning an already-completed `ValueTask`, so no async state machine is allocated on the
hot path.
3. A separate `...SlowAsync` method holding the actual `await`-in-a-loop, split out so the rare
awaiting branch doesn't bloat the fast path's IL/JIT inlining.

Once a row is fully buffered, parsing it (`ParseRow`) has no async twin at all — a fully-buffered
span never needs to await, so both `MoveNext` and `MoveNextAsync` call the same synchronous parse.

A parity test suite (`tests/ExcelReader.Tests/SyncAsyncParityTests.cs`) asserts identical cell
snapshots across sync / async-open / `GetAsyncEnumerator` for all four formats, guarding against the
twins drifting apart.

## The `Row`/`Cell` ref-struct lifetime model

`Row` and `Cell` are `public readonly ref struct`s: zero-allocation views aliasing the reader's own
pooled buffers (the accumulator's cell/value arrays, the shared-string buffer, or — for XLSX's bare
`<v>` fast path — the live read buffer directly). Being `ref struct` lets them hold `ReadOnlySpan<T>`
fields, and the compiler physically prevents a caller from doing anything that would outlive them:
they can't be boxed, stored in a field, captured in a closure, or crossed over an `await`/`yield`.

**Validity window:** exactly one enumeration step. `MoveNext`/`MoveNextAsync` resets the accumulator
and may compact or resize the buffer, so a `Row`/`Cell` from step *N* is invalidated the instant step
*N+1* starts.

**Escape hatch:** `Cell.GetString()` materializes the value as a real `string` (deduplicated for
repeated shared-string cells via an internal cache), `TryFormat` copies raw text into a caller-owned
buffer without allocating a `string`, and `TryGetDouble`/`TryParse<T>`/`TryGetDateTime` extract plain
value types — all safe to keep past the row's lifetime since none of them are spans.

## Further reading

- [`SECURITY.md`](SECURITY.md) — supported versions and how to report a vulnerability.
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — build expectations and how to submit a change.
48 changes: 48 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our community a
harassment-free experience for everyone, regardless of age, body size, visible or invisible
disability, ethnicity, sex characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race, religion, or sexual
identity and orientation.

## Our Standards

Examples of behavior that contributes to a positive environment:

- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing for mistakes, and learning from the experience

Examples of unacceptable behavior:

- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting

## Enforcement Responsibilities

Maintainers are responsible for clarifying and enforcing these standards, and will take appropriate
and fair corrective action in response to any behavior deemed inappropriate, threatening, offensive,
or harmful.

## Scope

This Code of Conduct applies within all project spaces (issues, pull requests, discussions) and when
an individual is officially representing the project in public spaces.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the
maintainers via GitHub's private reporting channel on this repository. All complaints will be
reviewed and investigated promptly and fairly.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.1.
52 changes: 52 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Contributing

Thanks for considering a contribution. This project takes small, focused pull requests over large
rewrites — see [ARCHITECTURE.md](ARCHITECTURE.md) for the shape of the codebase before diving in, and
[STYLEGUIDE.md](STYLEGUIDE.md) for the code style, which the analyzers only partly enforce.

## Build expectations

- **Warnings are errors.** `Directory.Build.props` sets `TreatWarningsAsErrors`, with a curated
`AnalysisMode=All` analyzer set (Sonar, Meziantou, Roslynator, AsyncFixer, and more). A PR that
doesn't build clean locally won't build clean in CI either — run a full build before pushing:

```bash
dotnet build ExcelReader.slnx --configuration Release
```

- **Public API changes require a `PublicAPI.Unshipped.txt` entry.** `Microsoft.CodeAnalysis.PublicApiAnalyzers`
is active (arrives transitively via `Roslyn.Diagnostics.Analyzers`) and fails the build on any
unrecorded public member. If you add, change, or remove anything public, update **both**
`src/ExcelReader.Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt` and
`src/ExcelReader.Core/PublicAPI/net10.0/PublicAPI.Unshipped.txt`. A bot promotes `Unshipped` →
`Shipped` automatically after each release — don't edit `Shipped.txt` by hand.

- **Tests are required for behavior changes.** Run the suite before opening a PR:

```bash
dotnet test tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release
```

Untrusted-input paths (the CFB/OLE, BIFF8, BIFF12, and ZIP parsers) get extra scrutiny — new
parsing code should have a corresponding limit/fuzz-safety test in
`tests/ExcelReader.Tests/ReaderLimitTests.cs` or `FuzzTests.cs` where relevant. Read
[STYLEGUIDE.md § Untrusted Input](STYLEGUIDE.md#untrusted-input) before touching a parser: every
length, offset, and size read from the file must be bounded before it drives an allocation.

## Pull requests

- One focused change per PR — don't batch unrelated fixes into one commit or one PR.
- If a change is user-visible (new API, behavior change, performance claim), mention it in the PR
description; the README's benchmark tables and changelog are updated separately, not as part of
every PR.
- CI runs on Linux, Windows, and macOS across .NET 8 and .NET 10 — a change that only builds on one
OS/TFM combination isn't ready to merge.

## Reporting bugs / requesting features

Use GitHub Issues for bugs and feature requests. For suspected security vulnerabilities, do **not**
open a public issue — see [SECURITY.md](SECURITY.md) for the private reporting channel.

## Code of conduct

This project follows the [Code of Conduct](CODE_OF_CONDUCT.md).
Loading
Loading