Project

roo-oxide

0.0
The project is in a healthy, maintained state
An unofficial acceleration layer built on top of Roo. Add roo-oxide to the Gemfile; Roo is installed as a dependency and its spreadsheet API stays the same. A Rust extension speeds up XLSX and XLSM reads while preserving Roo's cell and value rules. Unsupported input uses Roo's own reader. Not affiliated with roo-rb.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.9.130
~> 4.0
 Project Readme

roo-oxide

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 too

Unofficial. roo-oxide is not affiliated with or endorsed by roo-rb or the roo maintainers.

Install

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.

Migrating from roo-calamine

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

Benchmarks

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.

Values only

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]
end

Each 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 }
end
Arguments and value rules
  • Arguments: a path or an IO; sheet: is a name or index (default: the first sheet); other keywords are Roo::Excelx.new options.
  • 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 nil where 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::Link for hyperlinks; error text such as "#DIV/0!".
  • With the reader off, or on JRuby, it reads through roo and yields the same rows.

Turning it off

Roo::Oxide.enabled = false # from the next sheet read on
ROO_OXIDE=0 bundle exec rails server # also: false, off

Either 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.

How it works

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::Cell is built only when something reads it.
  • Output: to_csv, to_yaml, to_xml, and to_matrix are 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.

Known differences

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::Excelx subclass's #cell or #each inside parse and each with options;
    • its #cell, #celltype, #empty?, or roo's private #cell_to_csv inside to_csv;
    • redefinitions of the single-cell reads in Roo::Excelx or Roo::Excelx::Sheet themselves (subclasses work);
    • under roo 4.0.0, an override of initialize on a Cell class or on Roo::Excelx::Coordinate;
    • under roo 4.0.0, Sheet#pad_cells redefined in Roo::Excelx::Sheet itself (subclasses and prepended overrides work).
Behavior and performance
  • Building Sheet#cells late 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_csv raise Encoding::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 an each block without options, a write straight into sheet_for(...).cells (not through set) does not show in later rows.
  • Broken XML. On XML that breaks partway, each_row_streaming raises the same Nokogiri::XML::SyntaxError as 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_files exist only once roo's own code has read them.

Development

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 --check

License

MIT. See LICENSE.