xlsxparser
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.
let workbook = parse_workbook?;
let json = to_json_string?;
- docs/requirement/requirements.en.md — the functional requirements and the 5-phase pipeline summarized below (also available in Japanese).
- docs/design/architecture.en.md — the
overall
src/directory layout, module responsibilities, and design principles (also available in Japanese). 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 anythingRead + 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):
visibilityis"visible","hidden", or"veryHidden"(from<sheet state="...">).maxRow/maxColare the sheet's bounding box (the highest populated or merged coordinate) — not the OOXML<dimension>value, which isn't read at all.cellsonly contains populated coordinates: a blank cell in between is simply absent, never emitted as anull/"empty"entry (see Motivation). Cell order is unspecified — the sheet is backed by aHashMap, not sorted by row/col.- Each cell's
valueis tagged bytype:"number"|"text"|"boolean"|"error"|"dateTime"|"empty"(a cell with formatting only, or a value JSON can't represent —NaN/±Infinity, or — for now — any date/time value, sinceDateTimeValuedoesn't carry real calendar data yet; see docs/design/model/cell.en.md Open Question 4). rowSpan/colSpanare 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.
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):
(Column 4 is a date cell — it currently serializes as "empty" rather than
"dateTime", per the placeholder-DateTimeValue note above.)
Architecture
- Relationship resolution — parse
_relsparts to build a routing map from sheetr:idto worksheet file path, then discard the intermediate data immediately. - Sanitization — guard against zip bombs, zip-slip path traversal, and XXE before any untrusted content is parsed.
- Streaming parse — a SAX-style reader processes
<sheetData>one<row>at a time, without holding the sheet's full XML DOM in memory. - 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. - JSON output — the resolved data model is serialized to structured
JSON (including
row_span/col_spanfor merged cells) for downstream consumption, as a separate step from the primaryWorkbook-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 for the full breakdown of each file's responsibility):
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.relsxl/workbook.xmlxl/sharedStrings.xml(includingxml:space="preserve"handling)xl/styles.xmlxl/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 for the
rationale and the strict-OPC-conformance tradeoff this makes).
Benchmarks
The benchmarking was done using hyperfine
with --warmup 3 on an Apple M2 Pro running macOS 26.6.1, comparing
xlsxparser (via parse_workbook) against
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).
)
)
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 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):
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'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):
)
)
)
)
Security notes
- Zip Bomb / Zip Slip / XXE: guarded against at parse time (see Architecture above and 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.xlsxinput is untrusted and this library performs no rewriting of cell content.
License
MIT — see LICENSE.