Native acceleration for Roo, powered by Rust.
roo-oxide is built on top of Roo and installs it as a dependency. It replaces
Roo's busiest .xlsx and .xlsm read paths with a native engine, while you
keep using Roo::Spreadsheet, Roo::Excelx and the same cell/value APIs.
On the measured large sheets, reads are 16–36× faster with 2–31× less
memory. Your existing Roo code stays the same.
# Gemfile
gem "roo-oxide"xlsx = Roo::Spreadsheet.open("orders.xlsx") # or Roo::Excelx.new("orders.xlsx")
xlsx.each_row_streaming { |row| ... } # read by the native reader
xlsx.parse(headers: true) # this tooUnofficial. roo-oxide is not affiliated with or endorsed by roo-rb or the roo maintainers.
Add only gem "roo-oxide" to your Gemfile. Bundler installs Roo automatically
and loads the acceleration layer on Bundler.require (as Rails does).
Without Bundler, install roo-oxide and require "roo/oxide"; it loads Roo
for you.
Replace gem "roo-calamine" with gem "roo-oxide". Roo is installed as a
dependency, so a separate gem "roo" line is optional.
Use require "roo/oxide" and Roo::Oxide in new code. The old
require "roo/calamine", version require and Roo::Calamine constant remain
available as compatibility aliases in the new gem. Load one plugin gem at a
time; loading the old plugin before roo-oxide raises a migration error.
ROO_CALAMINE still works when ROO_OXIDE is unset. When both are set,
ROO_OXIDE takes precedence.
| Ruby | CRuby 3.3, 3.4, 4.0 |
| roo | ~> 4.0 |
| Precompiled | Linux x86_64/aarch64 (glibc, musl), macOS x86_64/arm64, Windows x64 |
| Other platforms | the source gem compiles with Rust 1.86 or later |
| JRuby | installs without Rust and stays inert: roo reads every file itself |
100,000 rows × 20 mixed columns, Apple M5 Pro, Ruby 3.4.12. Time and peak memory include opening the workbook.
| roo 4.0.0 | roo + roo-oxide | Faster | |
|---|---|---|---|
each_row_streaming |
13.49 s · 119 MB | 0.59 s · 51 MB | 23× |
parse(headers: true) |
19.61 s · 3300 MB | 0.75 s · 282 MB | 26× |
each |
18.81 s · 3282 MB | 0.62 s · 174 MB | 30× |
10,000 random cell(row, col) |
17.25 s · 3272 MB | 0.48 s · 104 MB | 36× |
to_csv |
21.72 s · 3369 MB | 0.77 s · 118 MB | 28× |
to_yaml |
24.26 s · 3430 MB | 1.28 s · 322 MB | 19× |
to_xml |
50.81 s · 3991 MB | 1.04 s · 711 MB | 49× |
to_matrix |
19.97 s · 3287 MB | 0.65 s · 216 MB | 31× |
each_row_values |
13.43 s · 118 MB | 0.44 s · 47 MB | 31× |
On the other test files it is 18–43× faster with shared strings, 16–33× with unique strings, and 2.6–10× on a 100-row sheet. See bench/README.md for every file, the method, and how to run it. bench/RESULTS.md times every public roo read. It ties roo only where roo reads the file itself: CSV, and XML roo-oxide hands back.
If you only need values, Roo::Oxide.each_row_values skips roo's cell
objects. It is roo-oxide's own API, not roo's, and using it changes nothing
in what roo does.
Roo::Oxide.each_row_values("orders.xlsx", sheet: "Orders") do |values|
values # => [1001, "Ada", #<Date: 2024-05-01>, 19.5, nil, true]
endEach row equals, value for value, what this roo code yields:
Roo::Excelx.new("orders.xlsx").each_row_streaming(sheet: "Orders", pad_cells: true) do |cells|
cells.map { |cell| cell&.value }
endArguments and value rules
- Arguments: a path or an IO;
sheet:is a name or index (default: the first sheet); other keywords areRoo::Excelx.newoptions. - Return: an Enumerator without a block. It closes the workbook when it is done.
- Rows: one entry per column from A to the row's last cell, with
nilwhere there is no cell. - Values follow roo's rules: Integer or Float by the written text and
number format; Date, DateTime and Time by the style; roo's shared strings
(HTML for rich text);
Roo::Linkfor hyperlinks; error text such as"#DIV/0!". - With the reader off, or on JRuby, it reads through roo and yields the same rows.
Roo::Oxide.enabled = false # from the next sheet read onROO_OXIDE=0 bundle exec rails server # also: false, offEither one makes roo read exactly as it does without the gem.
Roo::Oxide.enabled? tells whether the next read uses the native reader,
and Roo::Oxide.available? whether the extension is loaded.
roo-oxide prepends modules to roo's classes. A read such as
each_row_streaming reaches roo-oxide first, which either does the work in
a Rust extension and returns the objects roo would return, or hands the call
back to roo.
- Opening: worksheets stay in the zip until they are read.
- Parsing: worksheet XML is streamed with quick-xml instead of loaded into a Nokogiri document.
- Lazy cells: the sheet lives in compact native records, and a
Roo::Excelx::Cellis built only when something reads it. - Output:
to_csv,to_yaml,to_xml, andto_matrixare written from those records. - Fallback: XML the reader cannot reproduce exactly the way Nokogiri does goes back to roo.
Styles, number formats, comments, images, and every open option stay roo's code. roo is the test oracle: roo 4.0.0's own suite, every roo fixture, and the edge-case suites must give the same results with and without roo-oxide. docs/how-it-works.md has the details and the test coverage.
These are by design.
Overrides of roo's private methods
- Under roo 4.0.0, overrides of roo's private shared-string parsing
(
SharedStrings#extract_shared_strings,#extract_html,#fix_invalid_shared_strings,#create_html) are bypassed while the reader is on. Other roo versions build their shared strings themselves. - Overrides of roo's private cell parsing (
SheetDoc#cell_from_xml,#extract_cells,#extract_hyperlinks,#expand_merged_ranges,Cell.cell_class) are bypassed while the reader is on. So are:- a
Roo::Excelxsubclass's#cellor#eachinsideparseandeachwith options; - its
#cell,#celltype,#empty?, or roo's private#cell_to_csvinsideto_csv; - redefinitions of the
single-cell reads in
Roo::ExcelxorRoo::Excelx::Sheetthemselves (subclasses work); - under roo 4.0.0, an override of
initializeon aCellclass or onRoo::Excelx::Coordinate; - under roo 4.0.0,
Sheet#pad_cellsredefined inRoo::Excelx::Sheetitself (subclasses and prepended overrides work).
- a
Behavior and performance
- Building
Sheet#cellslate costs more. Building the whole hash (#set, for example) after a read that does not need it (a single-cell read,#last_row,#row,#each,#parse) costs more than building it from the start, because the sheet is parsed before any cell is built rather than alongside. - Non-UTF-8 text set by hand. A cell value set to non-ASCII text in an
encoding other than UTF-8 can make
to_csvraiseEncoding::CompatibilityError, where roo writes it, when its line also holds non-ASCII UTF-8 text. Cells read from a file are always UTF-8. - Writes straight into
cells. On a sheet built whole, inside aneachblock without options, a write straight intosheet_for(...).cells(not throughset) does not show in later rows. - Broken XML. On XML that breaks partway,
each_row_streamingraises the sameNokogiri::XML::SyntaxErroras roo but yields a few more rows before it. - Errors come when a sheet is read. A workbook leaves its worksheets in
the zip, so a worksheet whose compressed data is broken, or larger than the
zip says, raises roo's error when it is read rather than in
Roo::Excelx.new. The paths in#sheet_filesexist only once roo's own code has read them.
bundle install
bundle exec rake compile # build the extension
bundle exec rake test # unit, fixture, and edge-case tests
bundle exec rake parity # roo 4.0.0's own suites, with and without roo-oxide
bundle exec rubocop
cargo test && cargo clippy --all-targets -- -D warnings && cargo fmt --checkMIT. See LICENSE.