# xlsxparser
[](https://github.com/MinamiyamaKotaro/xlsxparser/actions/workflows/rust-ci.yml)
[](https://github.com/MinamiyamaKotaro/xlsxparser/actions/workflows/docs.yml)
[](https://crates.io/crates/xlsxparser)
[](https://codecov.io/gh/MinamiyamaKotaro/xlsxparser)
[](LICENSE)
A lightweight, high-performance `.xlsx` (OOXML) parser library written in Rust.
## Motivation
`xlsxparser` aims to be a fast, low-memory `.xlsx` parser, purpose-built for
the kind of files common in Japanese business systems: sheets with an
extreme number of rows/columns ("方眼紙Excel") and heavy use of merged
cells. The
goal is to parse and analyze such files without loading a full in-memory
grid, and to expose the result as JSON that's easy to consume from a
frontend or another system.
## Status
Core implementation complete — every module in the planned architecture
below is implemented and tested against the design in `docs/design/`. The
public API (`parse_workbook`, `parse_workbook_reader`, `to_json_string`,
`to_json_writer`) is wired up in `src/lib.rs`.
```rust
let workbook = xlsxparser::parse_workbook("book.xlsx")?;
let json = xlsxparser::to_json_string(&workbook)?;
```
- [docs/requirement/requirements.en.md](docs/requirement/requirements.en.md) —
the functional requirements and the 5-phase pipeline summarized below
(also available in [Japanese](docs/requirement/requirements.md)).
- [docs/design/architecture.en.md](docs/design/architecture.en.md) — the
overall `src/` directory layout, module responsibilities, and design
principles (also available in [Japanese](docs/design/architecture.md)).
It links out to a per-module design doc for every file, covering
responsibility/scope, key types and function signatures, dependencies,
error handling policy, testing strategy, and open questions — each doc
written in both Japanese and English (`*.md` / `*.en.md`). Where
implementation diverged from a design doc's draft (an external API
detail settled differently than planned, a bug found while writing
tests, etc.), the doc was updated in place to record what changed and why.
## Input / Output
**Input**: a `.xlsx` file, via one of two entry points —
- `parse_workbook(path)` — the common case, reads from a filesystem path.
- `parse_workbook_reader(reader)` — from anything `Read + Seek` (an
in-memory buffer, a fully-read HTTP response body, ...), for callers that
don't go through the filesystem.
Both return `Result<Workbook, Error>`, a fully resolved in-memory
representation of every sheet (visible, hidden, and veryHidden alike). Each
has a `_with_limits` variant taking an explicit `SizeLimits` to override the
default Zip Bomb caps (512 MiB per ZIP entry, 2 GiB cumulative).
**Output**: `to_json_string(&workbook)` / `to_json_writer(&workbook,
writer)` serialize the resolved `Workbook` into JSON shaped like this
(real output, from `tests/fixtures/complex/houganshi_merged.xlsx` — a
sheet with a single merged region, `A1:C3`, holding one text cell):
```json
{
"sheets": [
{
"name": "Sheet1",
"visibility": "visible",
"maxRow": 3,
"maxCol": 3,
"defaultColumnWidth": null,
"columns": [],
"cells": [
{
"row": 1,
"col": 1,
"value": { "type": "text", "value": "houganshi" },
"rowSpan": 3,
"colSpan": 3
}
]
}
]
}
```
- `visibility` is `"visible"`, `"hidden"`, or `"veryHidden"` (from `<sheet
state="...">`).
- `maxRow`/`maxCol` are the sheet's bounding box (the highest populated or
merged coordinate) — not the OOXML `<dimension>` value, which isn't read
at all.
- `columns` is the sheet's `<cols>` ranges (`{"min", "max", "width"}`,
1-based and inclusive), each entry covering every column in that range —
not one `columnWidth` value duplicated onto every cell, since that would
multiply output size for no benefit (see
[Sparse merged-cell arrangements](#sparse-merged-cell-arrangements)
below for the same principle applied to merged cells). `defaultColumnWidth`
is `<sheetFormatPr defaultColWidth="..">`'s value, or `null` if the
workbook doesn't set one; a column not covered by any `columns` entry
falls back to it. Neither fixture used in these two examples declares
`<cols>`, so both show the empty/absent case.
- `cells` only contains populated coordinates: a blank cell in between is
simply absent, never emitted as a `null`/`"empty"` entry (see
[Motivation](#motivation)). Cell order is unspecified — the sheet is
backed by a `HashMap`, not sorted by row/col.
- Each cell's `value` is tagged by `type`:
`"number"` | `"text"` | `"boolean"` | `"error"` | `"dateTime"` |
`"empty"` (a cell with formatting only, or a value JSON can't
represent — `NaN`/`±Infinity`).
`"dateTime"` serializes as ISO 8601 with no timezone designator or
fractional seconds (e.g. `"2023-06-15T00:00:00"`; a date-only cell gets a
midnight time component, since Excel itself doesn't distinguish
date-only from date+time as a type).
- `rowSpan`/`colSpan` are present (and `> 1`) only on a merged region's
anchor cell; every other coordinate inside the region resolves to that
same anchor and is not emitted as a separate JSON cell.
- `style` is present only when the cell carries a resolved style at all
(omitted entirely otherwise, not emitted as `"style": {}`):
- `font`: `{"sizePt": 11.0, "bold": false}`.
- `wrapText`: boolean.
- `alignment`: the horizontal alignment as a string — `"general"` |
`"left"` | `"center"` | `"right"` | `"fill"` | `"justify"` |
`"centerContinuous"` | `"distributed"`. Always present (unlike
`numberFormat` below, `"general"` is itself a meaningful value, not
"nothing to report").
- `numberFormat`: the resolved format code as a string (e.g. `"0%"`,
`"yyyy-mm-dd"`), covering both the built-in numFmtId table
(ECMA-376 §18.8.30) and custom `<numFmt>` codes. Omitted when the
format is `"General"` (no special formatting to report).
A second real example — every `CellValue` variant in one row
(`tests/fixtures/normal/basic_types.xlsx`; cells re-ordered by column here
for readability, since actual order is unspecified):
```json
{
"sheets": [
{
"name": "Sheet1",
"visibility": "visible",
"maxRow": 1,
"maxCol": 7,
"defaultColumnWidth": null,
"columns": [],
"cells": [
{ "row": 1, "col": 1, "value": { "type": "text", "value": "日本語Text" } },
{ "row": 1, "col": 2, "value": { "type": "number", "value": 42.0 } },
{ "row": 1, "col": 3, "value": { "type": "number", "value": 19.99 } },
{
"row": 1, "col": 4,
"value": { "type": "dateTime", "value": "2023-06-15T00:00:00" },
"style": {
"font": { "sizePt": 11.0, "bold": false },
"wrapText": false,
"alignment": "general",
"numberFormat": "yyyy-mm-dd"
}
},
{ "row": 1, "col": 5, "value": { "type": "boolean", "value": true } },
{ "row": 1, "col": 6, "value": { "type": "boolean", "value": false } },
{ "row": 1, "col": 7, "value": { "type": "error", "value": "#N/A" } }
]
}
]
}
```
(Column 4 is a date cell — its `numberFormat` comes from the cell's
`<xf numFmtId="...">`, resolved against `xl/styles.xml`'s built-in/custom
`<numFmt>` table; `openpyxl`'s default date format is `"yyyy-mm-dd"`.)
A third real example — a sheet that does declare `<cols>`
(`tests/fixtures/normal.rs`'s `column_widths()`: `<col min="1" max="3"
width="12.5"/>`, `<col min="5" max="5" width="30"/>`, and
`<sheetFormatPr defaultColWidth="9.1"/>`):
```json
{
"maxRow": 1,
"maxCol": 5,
"defaultColumnWidth": 9.1,
"columns": [
{ "min": 1, "max": 3, "width": 12.5 },
{ "min": 5, "max": 5, "width": 30.0 }
],
"cells": [
{ "row": 1, "col": 1, "value": { "type": "number", "value": 1.0 } },
{ "row": 1, "col": 5, "value": { "type": "number", "value": 2.0 } }
]
}
```
Column 4 falls in the gap between the two `columns` ranges, so a cell there
(none exist in this example) would fall back to `defaultColumnWidth`
(9.1) rather than either range's `width`.
## Architecture
1. **Relationship resolution** — parse `_rels` parts to build a routing map
from sheet `r:id` to worksheet file path, then discard the intermediate
data immediately.
2. **Sanitization** — guard against zip bombs, zip-slip path traversal, and
XXE before any untrusted content is parsed.
3. **Streaming parse** — a SAX-style reader processes `<sheetData>` one
`<row>` at a time, without holding the sheet's full XML DOM in memory.
4. **Resolution** — shared strings (`t="s"`) and cell styles are resolved
against the SST/stylesheet, and `<mergeCells>` ranges are resolved
against the collected cells after the stream pass completes.
5. **JSON output** — the resolved data model is serialized to structured
JSON (including `row_span`/`col_span` for merged cells) for downstream
consumption, as a separate step from the primary `Workbook`-returning API.
Core requirements driving the design:
- **Sparse storage** — cells are kept in a coordinate-keyed map, never a
dense 2D array, so sparse "grid-paper" sheets stay cheap to hold in memory.
- **Merge-cell transparency** — any coordinate inside a merged range
resolves (via an O(1) bounding-box pre-check plus a geometric containment
scan over the sheet's merged regions) to the same value and merge
metadata as the range's anchor cell.
- **I/O and domain logic stay separated** — XML/ZIP handling (`container/`,
`parse/`) never mixes with the resolution logic (`resolve/`), which
operates purely on in-memory data and needs no I/O to unit test.
The module layout (see
[docs/design/architecture.en.md](docs/design/architecture.en.md) for the
full breakdown of each file's responsibility):
```text
src/
lib.rs # public API entry point (parse_workbook, parse_workbook_reader, to_json_string, ...)
error.rs # crate-wide error type
pipeline.rs # orchestrates the 5-phase pipeline and resource lifetimes
container/ # ZIP (OPC) extraction, zip-bomb/zip-slip guarding
parse/ # XML parsing (quick-xml usage is confined here), XXE mitigation
model/ # pure data structures (Workbook, Sheet, Cell, CellValue, ...)
resolve/ # shared-string/style/merge-cell resolution, I/O-independent
json.rs # serializes a resolved Workbook to JSON
```
## OOXML parts covered
- `xl/_rels/workbook.xml.rels`
- `xl/workbook.xml` (including `<workbookPr date1904="...">`, needed to
resolve a date/time cell's serial value under the 1900 vs. 1904 date
system)
- `xl/sharedStrings.xml` (rich-text run concatenation, `xml:space="preserve"`
handling, CDATA runs, and the `_x000D_` escape Excel uses for a literal CR)
- `xl/styles.xml` (font size/bold, horizontal alignment, wrap text, and
number format — both the built-in numFmtId table (ECMA-376 §18.8.30) and
custom `<numFmt>` codes)
- `xl/worksheets/sheetX.xml` (`<sheetData>`, `<mergeCells>`)
`[Content_Types].xml` is not read; fixed paths such as `xl/workbook.xml`
are accessed directly instead of being resolved through its Content-Type
declarations (see
[pipeline.en.md Open Question 3](docs/design/pipeline.en.md) for the
rationale and the strict-OPC-conformance tradeoff this makes).
## Benchmarks
The benchmarking was done using [`hyperfine`](https://github.com/sharkdp/hyperfine)
with `--warmup 3` on an `Apple M2 Pro` running `macOS 26.6.1`, comparing
`xlsxparser` (via `parse_workbook`) against
[`calamine`](https://github.com/tafia/calamine) `0.26.1` (via
`worksheet_range`) — a widely-used pure-Rust `.xlsx` reader — both built in
release mode, on `tests/fixtures/complex/extreme_sparse.xlsx`: a real,
openpyxl-authored file where only two cells are populated, `A1` and
`XFD1048576` (Excel's actual maximum: row 1,048,576, column 16,384) — the
sparse "grid-paper Excel" shape this library is purpose-built for (see
[Motivation](#motivation)).
```bash
xlsxparser
Time (mean ± σ): 3.0 ms ± 1.0 ms [User: 1.3 ms, System: 1.1 ms]
Range (min … max): 2.1 ms … 18.3 ms 410 runs
```
`calamine` isn't shown as a completed hyperfine run because it never
completed one: across repeated runs it was killed by the OS for excessive
memory use after roughly 23-24 seconds, having grown to multiple GB of
resident memory. The cause is structural, not a fluke: `calamine`'s
`Range<T>` (the type `worksheet_range` returns) always backs onto a single
dense `Vec<T>` sized to the *bounding box* of the populated cells —
`Range::from_sparse` (`calamine` `0.26.1`, `src/lib.rs`) computes
`cols * rows` from that bounding box and allocates `vec![T::default();
cols * rows]` regardless of how few cells are actually non-empty. Here the
two populated corners span the full sheet, so that bounding box *is*
1,048,576 x 16,384 = 17,179,869,184 elements, and the allocation attempt
is what gets the process killed.
`xlsxparser` doesn't hit this because cells are kept in a coordinate-keyed
`HashMap<CellRef, Cell>` (see [Architecture](#architecture) above) sized to
the number of populated cells, never to the sheet's addressable bounding
box — so `extreme_sparse.xlsx` costs `xlsxparser` exactly 2 map entries.
### Sparse merged-cell arrangements
A merge-heavy file could hit an unrelated cost even while respecting every
existing limit ([Issue #43](https://github.com/MinamiyamaKotaro/xlsxparser/issues/43)):
two 1x1 merges placed at opposite corners of a sheet stretch the merged-cell
bounding box to cover virtually the whole sheet, so every other cell fell
back to a linear scan over every merged region when resolving its origin —
turning a legitimate file into an O(cells × merged regions) cost during JSON
generation. `Sheet::finalize_merges` closes this with a single sweep-line
pass, independent of how the merges are arranged in space (see
[docs/design/model/sheet.md](docs/design/model/sheet.md)'s "修正:
`finalize_merges`" section for the full story).
Measured the same way as above (`hyperfine`, `--warmup 1`, same machine), on
a generated 838 KB file with 300,000 distinct populated cells and 20,000
merges (`resolve::merge::MAX_MERGE_REGIONS`, the current cap) arranged to
maximize the bounding box (`tests/fixtures/security.rs`'s
`sparse_merge_bounding_box_amplification`):
```bash
before (pre-#43 fix, v0.10.0)
Time (mean ± σ): 14.918 s ± 0.242 s 3 runs
after (this fix, v0.10.1)
Time (mean ± σ): 600.6 ms ± 7.9 ms 4 runs
```
## Security notes
- **Zip Bomb / Zip Slip / XXE**: guarded against at parse time (see
[Architecture](#architecture) above and
[docs/security/design-review.md](docs/security/design-review.md) for the
full analysis).
- **CSV / formula injection**: cell string values (including formula-computed
result strings) pass through unchanged, with no escaping at any stage —
this is safe as JSON output, but callers who re-export parsed values into
CSV or another spreadsheet format are responsible for their own
formula-injection mitigations (e.g. escaping a value that starts with `=`,
`+`, `-`, or `@`), since a `.xlsx` input is untrusted and this library
performs no rewriting of cell content.
## License
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file for details.
### Commercial Licensing
If you wish to use this software in a proprietary system or without the copyleft obligations of the AGPL-3.0, commercial licenses are available.
Please contact the author via [GitHub Profile](https://github.com/MinamiyamaKotaro) or open an inquiry on [GitHub Discussions](https://github.com/MinamiyamaKotaro/xlsxparser/discussions).