xlsxparser 0.10.1

A lightweight, high-performance .xlsx (OOXML) parser library
Documentation
xlsxparser-0.10.1 has been yanked.

xlsxparser

Rust CI Docs xlsxparser on crates.io codecov 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.

let workbook = xlsxparser::parse_workbook("book.xlsx")?;
let json = xlsxparser::to_json_string(&workbook)?;
  • 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 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):

{
  "sheets": [
    {
      "name": "Sheet1",
      "visibility": "visible",
      "maxRow": 3,
      "maxCol": 3,
      "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.
  • cells only contains populated coordinates: a blank cell in between is simply absent, never emitted as a null/"empty" entry (see 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, or — for now — any date/time value, since DateTimeValue doesn't carry real calendar data yet; see docs/design/model/cell.en.md Open Question 4).
  • 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.

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):

{
  "sheets": [
    {
      "name": "Sheet1",
      "visibility": "visible",
      "maxRow": 1,
      "maxCol": 7,
      "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": "empty" } },
        { "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 — it currently serializes as "empty" rather than "dateTime", per the placeholder-DateTimeValue note above.)

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 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.rels
  • xl/workbook.xml
  • xl/sharedStrings.xml (including xml:space="preserve" handling)
  • xl/styles.xml
  • 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 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).

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 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 836 KB file with 300,000 populated cells and 20,000 merges (resolve::merge::MAX_MERGE_REGIONS, the current cap) arranged to maximize the bounding box:

before (pre-#43 fix)
  Time (mean ± σ):     2.016 s  ±  22 ms      5 runs

after (this fix)
  Time (mean ± σ):     511.8 ms ±  48.9 ms    5 runs

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 .xlsx input is untrusted and this library performs no rewriting of cell content.

License

MIT — see LICENSE.