Rust XLSX Compatibility Notes

September 14, 2026 · View on GitHub

简体中文

Goal

The Rust MVP implements the smallest useful MiniExcel-style XLSX read/write surface behind one MiniExcel facade. It uses a focused OOXML pull parser for bounded-memory path queries, calamine data and Serde conversion internally, and rust_xlsxwriter for workbook generation.

Dependency Baseline

DependencyLocked API lineRoleLicenseMSRV note
atomicwrites0.4Safe cross-platform atomic replacement for Windows path insertsMITWindows-only dependency; checked with Rust 1.85
async-channel / event-listener / futures-*2.5 / 5.4 / 0.3Optional runtime-neutral async query/Insert and cancellationMIT OR Apache-2.0Enabled only by the async feature; checked with Rust 1.85
fs20.4Cross-platform advisory locking for path insertsMIT OR Apache-2.0Checked with Rust 1.85
calamine0.35XLSX parsing and Serde row deserializationMIT0.35 declares Rust 1.83
clap4.6Local CLI argument parsingMIT OR Apache-2.04.6 declares Rust 1.85
rust_xlsxwriter0.96New XLSX workbook generation and Serde serializationMIT OR Apache-2.00.96 declares Rust 1.83
serde1.xTyped mappingMIT OR Apache-2.0Resolved by the workspace lockfile
chrono0.4Timezone-free Excel date/time valuesMIT OR Apache-2.0Resolved by the workspace lockfile
indexmap2.xStable dynamic column orderingMIT OR Apache-2.0Resolved by the workspace lockfile
quick-xml0.39Incremental OOXML parsingMITLocked and checked with Rust 1.85
serde_json1.xQuery plans, analytics/RAG output, parity contracts, and CLI JSONMIT OR Apache-2.0Checked with Rust 1.85
sha20.10Streaming SHA-256 source identity for RAG manifestsMIT OR Apache-2.0Checked with Rust 1.85
thiserror2.xPublic error compositionMIT OR Apache-2.0Resolved by the workspace lockfile
uuid1.xTyped threaded-comment, reply, person, and legacy-note identifiersMIT OR Apache-2.0Resolved by the workspace lockfile
zip7.2Incremental worksheet entry decompressionMITLocked and checked with Rust 1.85

The latest calamine 0.36 and rust_xlsxwriter 0.97 require Rust 1.88. The MVP pins the preceding API lines so the declared Rust 1.85 MSRV is executable rather than aspirational.

API Mapping

MiniExcel V2 conceptRust MVPNotes
OpenXML importerMiniExcelConcrete reader/parser types are internal
Dynamic QueryMiniExcel::query()Streams owned IndexMap<String, CellValue> rows with bounded buffering
Typed Query<T>MiniExcel::query_as<T>()Streams rows and applies Serde mapping one row at a time
Structure-preserving queryMiniExcel::query_structured()Streams sparse rows with one-based coordinates, formulas, style IDs, and number formats
Group/filter analyticsMiniExcel::analyze_with_options()Versioned Rust extension; streams rows and retains only bounded group/evidence state
RAG evidence exportMiniExcel::export_rag()Versioned Rust extension; streams addressed JSONL-ready chunks, enriched GFM Markdown, and a source manifest
QueryRangeReadOptions::with_start_cell() / with_end_cell()Inclusive A1 range for dynamic and typed reads
GetSheetNamesMiniExcel::get_sheet_names()Workbook order is preserved
GetSheetInformationsMiniExcel::get_sheet_info()Includes OOXML ID, order, name, type, visibility, and active state
GetSheetDimensionsMiniExcel::get_sheet_dimensions()Returns used ranges in workbook order with 1-based indices
GetColumnsMiniExcel::get_columns()Returns selected dynamic keys or an empty vector
QueryTablequery_table() / query_table_as() / byte and borrowed-reader variantsCase-insensitive table-name lookup, metadata headers, and inclusive table bounds
Exact-cell object mappingCellMap / read_mapped_as*()Ordered Serde field-to-A1 bindings; path, bytes, and borrowed readers
Retrieve comments and notesget_comments() / bytes / borrowed-reader variantsThread roots, replies, people, resolution/timestamps, and legacy notes
startCellReadOptions::with_start_cell()A1 start coordinate
IgnoreEmptyRowsReadOptions::with_ignore_empty_rows()Defaults to false for MiniExcel compatibility
FillMergedCellsReadOptions::with_fill_merged_cells()Defaults to false; applies to dynamic, typed, and byte queries
OpenXML exporterMiniExcel::save_as*()Concrete writer type is internal; creates new workbooks only
Dynamic exportsave_as() / save_as_with_schema()Map serialization is implemented internally
Typed exportsave_as_serialized<T>()Uses Serde mapping internally
Multi-sheet exportsave_as_sheets() / save_as_serialized_sheets()Preserves input sheet order and returns data-row counts
InsertSheet append/replaceinsert() / insert_with_schema() / insert_serialized() / borrowed reader-to-writer variantsPath APIs are atomic; separate borrowed streams require an empty sink and preserve package behavior without atomic commit
Async Insert producerinsert_with_schema_async*()Optional async feature; bounded producer channel with blocking XLSX work on a dedicated thread
Async path queryquery_async*() / query_as_async*()Optional async feature; bounded dynamic/Serde streams, cooperative cancellation, blocking XLSX workers
Async dynamic/Serde exportsave_as_with_schema_async*() / save_as_serialized_async*()Optional async feature; explicit or first-row-inferred schema, bounded producer, atomic destination, cooperative cancellation, data-cell progress
Async basic template path outputsave_as_template_async*()Optional async feature; scalar/list renderer, atomic destination, cooperative cancellation
MergeSameCellsmerge_same_cells() / merge_same_cells_bytes()@merge/@endmerge regions, optional @mergelimit, all worksheets, atomic source-to-destination path output
Per-sheet visibilityWriteOptions::with_sheet_visibility()Visible, hidden, and very hidden; first visible sheet is active
overwriteFileWriteOptions::with_overwrite_file()Defaults to false; existing paths require explicit opt-in
FreezeRowCount / FreezeColumnCountWriteOptions::with_freeze_row_count() / with_freeze_column_count()Defaults to one frozen row and zero frozen columns
AutoFilterWriteOptions::with_auto_filter()Defaults to true; covers the complete written range
RightToLeftWriteOptions::with_right_to_left()Defaults to false; changes worksheet view only
EnableAutoWidth / MinWidth / MaxWidthWriteOptions::with_auto_width() / with_min_width() / with_max_width()Fixed v1-style widths; defaults to disabled, 8.42857143, and 200
Per-column width/hiddenWriteOptions::with_column_width() / with_column_hidden()Final header-name mapping; explicit width seeds AutoWidth
WrapCellContentsWriteOptions::with_wrap_cell_contents()Defaults to false; wraps ordinary body values only
Body horizontal/vertical alignmentWriteOptions::with_horizontal_alignment() / with_vertical_alignment()Defaults to left/general and bottom; headers are separate
Header styleHeaderStyle / WriteOptions::with_header_style()Blue/white/thin-border v1 visual default with configurable wrap, RGB, and alignment
TableStyles.Default / NoneTableStyle::Default / NoneCell styling modes; None retains number formats and AutoFilter
Basic template fillsave_as_template() / save_as_template_bytes()Scalar placeholders and single-row array expansion; preserves package parts
Caller-owned XLSX inputvisit_*_from_reader() / metadata *_from_reader()Borrowed Read + Seek; synchronous visitor model
Caller-owned XLSX outputsave_as*_to_writer()Borrowed Write + Send; dynamic, schema, typed, and multi-sheet

MiniExcel is the only public behavior entry point. Reader, writer, parser, and concrete iterator types are crate-internal. Public supporting types are limited to row/cell values, structured provenance rows, options, errors/results, and Serde date/time helpers.

Compatibility Defaults

  • MiniExcel::query() with HeaderMode::Auto uses column letters and treats the first row as data.
  • MiniExcel::query_as() with HeaderMode::Auto consumes the first selected row as headers.
  • MiniExcel::query_structured() never consumes a header row and emits only cells explicitly represented in worksheet XML.
  • The first worksheet in workbook order is selected when no name is supplied.
  • Empty rows between the selected start and last used cell are retained by default.
  • Merged ranges expose only their physical top-left value unless fill_merged_cells is enabled. Structured queries never synthesize merged cells.
  • Typed header strings are trimmed by default. Dynamic headers follow the .NET behavior and retain non-blank text as stored.
  • Blank dynamic headers are omitted. Duplicate dynamic headers retain their first key position while later columns overwrite the value.
  • A missing dynamic cell is represented by CellValue::Empty, not by omission from a known schema.
  • Writer row counts exclude the header row.

Type Mapping

XLSX valueDynamic Rust value
EmptyCellValue::Empty
BooleanCellValue::Bool
Exact integral number in i64 rangeCellValue::Int
Other numberCellValue::Float
Shared/inline stringCellValue::String
Excel serial date/timeCellValue::DateTime
Excel durationCellValue::Duration
ISO date/timeDate, Time, or DateTime when parseable
Cell errorCellValue::Error
Formula through dynamic/typed queryCached result value only
Formula through structured queryRaw formula text and cached result value; no calculation

Typed conversions are delegated to calamine's Serde deserializer. The public serde_helpers module adds strict chrono helpers that convert an invalid value into the library's contextual Error::Deserialize path.

For typed writing, chrono values must use the matching MiniExcel helper (serialize_date_to_excel, serialize_datetime_to_excel, or serialize_time_to_excel) and a corresponding WriteOptions::with_column_format() entry. Otherwise standard chrono Serde behavior writes text rather than an Excel serial value.

Memory And I/O Model

MiniExcel::query() and query_as() use a dedicated path-streaming backend. A worker owns the ZIP archive, reads workbook relationships, styles, and shared strings, then processes worksheet XML with quick-xml. A bounded channel holds at most eight parsed rows. Dropping the public iterator disconnects the channel and joins the worker, so an early take or find stops further work.

Borrowed readers use the same two-pass parser synchronously through callbacks. They are never closed or consumed by the library; ZIP discovery may seek independently on every call, and the final reader position is unspecified. Callback false stops row delivery, while callback errors propagate unchanged. Borrowed writers are left usable, begin at their current position, and are not truncated by the library.

Path queries automatically store xl/sharedStrings.xml in indexed temporary files when its uncompressed size is at least 5 MiB. ReadOptions can disable the cache, change the threshold, or select an existing cache directory. The index uses fixed-width offset/length records, so lookup metadata does not grow in memory with string count. Normal completion, parser failure, and early iterator drop remove the files through worker-owned RAII cleanup. Byte/WASM queries remain memory-only because they do not own a native temporary-filesystem contract.

MiniExcel::query_structured() uses the same bounded pipeline and additionally retains metadata for explicit cells in the current row and channel. Sheet names are shared per row, and number-format strings are shared by style. Missing cells are not expanded into structured cell objects. Formula expressions are preserved exactly as stored, but shared formulas are not expanded and cached values can be stale.

Grouped analytics consume the dynamic row stream without retaining source rows. Memory additionally contains one aggregate state and bounded source-row evidence list per distinct group. QueryPlan::max_groups rejects the group that would exceed the configured limit. Result limits do not reduce group-state memory. Version 1 does not implement disk spill, sorted-input aggregation, or constant-memory high-cardinality grouping.

Path RAG exports retain parser state, repeated header context, and one output chunk. Their manifest hashes the source file through a separate bounded read. Markdown includes stream-level source/sheet provenance and chunk-local formula/style/number-format metadata without retaining prior chunks. Byte/WASM workflows avoid collecting source rows, but browser uploads inherently retain compressed XLSX bytes in WebAssembly memory; generated JSONL, Markdown, and Blob downloads also consume output-sized memory. Browser Lab runs these operations in a Web Worker for responsiveness, not as a claim of path-equivalent memory.

The backend makes two sequential, bounded-memory passes over the selected worksheet entry. The first records the used extent and compact merged-cell rectangles. This is required for MiniExcel-compatible stable dynamic schemas when legal files omit <dimension>, to preserve style-only row elements like the .NET reader, and to support opt-in merged-cell filling without expanding ranges into an address map. The second pass emits rows and retains only anchor values for currently active merged ranges. Worksheet XML and prior rows are never retained; memory consists primarily of in-memory or disk-indexed shared strings, styles, merge metadata, parser buffers, the current row, and the bounded channel.

The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept Read + Seek input and an empty Write + Seek output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer. Generated donor worksheet XML, shared-string conversion, style-ID rebasing, and ZIP insertion use temporary-file streams, so worksheet memory is independent of row count. Path Insert also uses advisory locking and a pre-commit source fingerprint to prevent lost concurrent updates. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows.

Test Sources

Rust integration tests reuse the repository's existing files under tests/data/xlsx, including:

  • Dynamic header and no-header files.
  • Center and self-closing empty rows.
  • Typed value and trimmed-header mapping.
  • Multiple worksheets.
  • Cells without explicit r attributes.
  • A typed conversion failure with a verified Excel row number.
  • Strict streaming A1 starts, empty-row filtering, dates, trimmed headers, and early typed errors.
  • Opt-in vertical and horizontal merged-cell filling across dynamic, typed, and byte queries.
  • Forced shared-string disk spill, indexed lookup, invalid-directory handling, memory-only byte queries, and early-drop cleanup.
  • Borrowed dynamic, typed, and structured readers; repeated metadata reads; callback stopping/errors; borrowed dynamic/schema/typed/multi-sheet writers.
  • Structured formula text, cached values, A1 addresses, style IDs, built-in/custom number formats, ranges, and early iterator drop.

Writer tests generate temporary workbooks through MiniExcel::save_as*() and read them back through MiniExcel::query*(), covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, explicit/hidden column layout, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, the collapsible and resizable control rail with its validated persisted layout, and desktop/mobile viewports.

TableStyle controls ordinary cell formats and is not an OOXML table abstraction. Neither mode creates xl/tables entries or worksheet tableParts.

Header background colors are RGB-only. MiniExcel v1 serializes the default as ARGB 284472C4; Rust emits the visually equivalent opaque FF4472C4 because the backend does not preserve arbitrary alpha for spreadsheet fills.

.NET Parity Contract

Behavior shared by .NET and Rust is defined in tests/data/contracts/xlsx-parity-v1.json. This file is the single expected-data source for:

Both adapters use their public APIs, query the same XLSX fixtures, normalize language-specific representations, and compare sheet order, row counts, column order, selected values, and common conversion-error context. Normalization maps null/empty cells, booleans, numbers, GUIDs, datetimes, durations, and strings to stable tagged text. In particular, integral .NET double and Rust CellValue::Int values compare as the same number, and ISO date strings compare with chrono date/time values.

Run both sides from the repository root:

cargo +1.85.0 test -p miniexcel --test parity_contract --locked
dotnet test ../MiniExcel/tests/MiniExcel.OpenXml.Tests/MiniExcel.OpenXml.Tests.csproj --framework net10.0 --filter "FullyQualifiedName~RustParityContractTests"

The Rust workflow runs the Rust contract on Linux and Windows. Its .NET parity job checks out the MiniExcel repository, copies this revision's contract into that checkout, and runs the .NET adapter on Linux. A compatibility change is complete only when the shared contract is updated deliberately and both adapters pass it.

The contract covers only the current common surface: dynamic/typed path queries, inclusive range queries, column-name discovery, header behavior, sheet selection/order, A1 starts, empty/style-only rows, inferred cell references, scalar/date/duration mapping, trimmed typed headers, and conversion-error row/value context. Structured provenance is a Rust research extension and is not a .NET parity claim. Async APIs are tested against the same fixtures but are not part of the shared contract; DataReader, templates, and writing parity also remain outside version 1.

.NET Coverage Boundary

.NET surfaceRust statusShared contract
Dynamic and typed XLSX queryImplementedYes
Exact-cell Serde object mappingImplementedRust path/bytes/borrowed parity tests; scoped .NET FluentMapping baseline
QueryRange with A1 coordinatesImplementedYes
GetSheetNames and GetColumnsImplementedYes
GetSheetInformations ID/index/name/type/visibility/activeImplementedRust tests against .NET fixtures
GetSheetDimensionsImplementedRust tests against .NET fixtures
Named OpenXML QueryTableImplementedRust and .NET focused tests against TestQueryTable.xlsx
Threaded comments and legacy notesImplementedRust and .NET focused tests against TestCommentsAndNotes.xlsx
CSV dynamic/typed query, save, append, and columnsImplementedRust tests plus pinned .NET CSV fixtures/baseline tests
New-workbook SaveAs, including multiple sheetsImplemented and roundtrip-testedNot yet
Basic SaveAsTemplate scalar/list fillImplemented and roundtrip-testedNot yet
Enumerable-cell conditional template blocksImplemented for string/number/bool comparisonsRust sync/async tests; scoped .NET conditional test
Enumerable grouped template blocksImplemented for JSON arrays with adjacent header suppressionRust sync/async tests; scoped .NET sync/async group tests
$= formula templatesImplemented for ordinary and single-row enumerable expansionRust formula/address/calcChain tests; scoped .NET formula/calcChain tests
Byte-array query/write for WASMImplementedRust/browser tests
Versioned grouped analyticsRust research extensionNo
Addressed JSONL/Markdown/manifest RAG exportRust research extensionNo
Async Insert producerImplemented behind optional featureRust cancellation tests; not shared internals
Async dynamic/typed path queryImplemented behind optional featureRust parity, cancellation, error, and cleanup tests
Async dynamic/Serde path exportImplemented behind optional featureRust explicit/inferred schema, rollback, cancellation, progress, and cleanup tests; pinned .NET async-enumerable/progress baseline
Async basic template path outputImplemented behind optional featureRust success, rollback, cancellation, and cleanup tests; scoped pinned .NET async template baseline
DataReader and broader stream ownershipDeferredNo
Append worksheet to existing .xlsx workbookImplemented and atomically committedRust tests; shared parity contract not yet extended
Strict worksheet replacementImplemented for plain targets and supported target-owned closuresRust tests; stale calcChain removed and full recalculation requested
Atomic worksheet renameImplemented for existing .xlsx pathsRust package-preservation tests; pinned .NET AlterSheet baseline
Atomic worksheet visibility mutationImplemented for existing .xlsx pathsRust invariant/rollback tests; pinned .NET AlterSheet baseline
Atomic worksheet reorderImplemented for existing .xlsx pathsRust positional-reference/rollback tests; pinned .NET AlterSheet baseline
Legacy .xls, .xlsb, and .ods formatsDeferredNo
Atomic workbook copy-and-addImplemented for dynamic/schema/Serde path APIsRust source/destination rollback tests; pinned .NET CopyAndAddSheet baseline
Marker-driven MergeSameCellsImplementedExact Rust/.NET fixtures and scoped sync/async .NET baselines
Advanced templates and picturesDeferredNo

This matrix is the coverage claim: Rust does not yet provide complete API parity with the current .NET packages.

Deferred Work

SQL text parsing, HAVING, ORDER BY, joins, windows, pivots, disk-spill aggregation, vector indexing, model calls, old Excel formats, nested/logical template conditions, grouped/conditional formulas and sheet cloning, image authoring, merged-cell APIs, formula calculation/dependency expansion, general formula authoring, general styling, async template streams, inferred/typed async export sources, async borrowed readers/writers, and borrowed XLSX lazy readers require separate design and acceptance milestones. CSV DataReader/DataTable adapters are intentionally replaced by Rust iterators, and a one-call CSV/XLSX converter is not exposed; callers compose query and save APIs. See the Insert migration guide for supported workflows and deliberate differences.