IronCalc for Ruby
Ruby bindings for IronCalc, a modern spreadsheet engine written in Rust. Create, read and manipulate xlsx files — manage sheets, set and read cell values, and evaluate formulas.
Built with magnus / rb-sys, and modeled on the IronCalc Python bindings.
Installation
Add this line to your application's Gemfile:
gem "ironcalc"On common platforms (Linux, macOS, Windows) a precompiled gem is installed, so no Rust toolchain is required. On other platforms the gem builds the IronCalc engine from source, which requires a Rust toolchain.
Usage
require "ironcalc"
# Raw API — call evaluate yourself.
model = IronCalc.create("model", "en", "UTC", "en")
model.set_user_input(0, 1, 1, "=6*7")
model.evaluate
model.get_formatted_cell_value(0, 1, 1) # => "42"
model.save_to_xlsx("out.xlsx")
# User API — auto-evaluates and tracks diffs for collaboration.
um = IronCalc.create_user_model("model", "en", "UTC", "en")
um.set_user_input(0, 1, 1, "=1+2")
um.get_formatted_cell_value(0, 1, 1) # => "3"
diffs = um.flush_send_queue # binary diff to send to peersCoordinates: sheet is a 0-based index; row and column are 1-based. Method
names and signatures are the engine's own. Engine structs, such as styles and
cell ranges, are plain Ruby Hashes keyed by their field names.
API overview
The API reference documents every method; it shows the latest release, and older versions are kept too.
-
IronCalc— module functions: the constructors (see Loading and saving) and utilities for cell references, such asnumber_to_columnandparse_reference_a1. -
IronCalc::UserModel— the recommended API. It recalculates after every change, supportsundo/redo, and records diffs for collaboration (flush_send_queue). Styles are set per property withupdate_range_style. -
IronCalc::Model— the raw API. You callevaluateyourself; in return it offers typed cell access, whole-style reads and writes, and row and column default styles.
Typed cell access
set_user_input parses what you give it, the way typing into a cell does — so
"007" becomes the number 7 and "=1+1" becomes a formula. The typed setters
skip that, and the typed reader gives you a Ruby object instead of a String:
model.update_cell_with_text(0, 1, 1, "007") # stays the string "007"
model.update_cell_with_number(0, 1, 2, 42.5)
model.update_cell_with_bool(0, 1, 3, true)
model.update_cell_with_formula(0, 1, 4, "=B1*2")
model.evaluate
model.get_cell_value_by_index(0, 1, 2) # => 42.5 (Float, not "42.5")
model.get_cell_value_by_ref("Sheet1!D1") # => 85.0
model.get_cell_formula(0, 1, 4) # => "=B1*2", or nil if not a formula
model.is_empty_cell(0, 9, 9) # => true
model.get_all_cells # => [{ index:, row:, column: }, ...]get_all_cells lists every cell the workbook has a record for (index is the
sheet index, the engine's field name). Clearing a
cell's contents keeps its record, so filter with is_empty_cell if you want
only cells that still hold a value.
Clearing works on a range, given as the engine's Area:
b2_c10 = {sheet: 0, row: 2, column: 2, width: 2, height: 9}
model.range_clear_contents(b2_c10) # keeps the style
model.range_clear_all(b2_c10) # contents and styleOn UserModel, range_clear_formatting does the opposite — style only.
Rows, columns and sheets
Rows and columns can carry a default style. As with set_cell_style, a style is
a complete structure, so read one, change a field, and pass it back:
style = model.get_style_for_cell(0, 1, 1)
style[:num_fmt] = "0.00"
model.set_column_style(0, 2, style)
model.get_column_style(0, 2) # => Hash, or nil if the column has no style
model.delete_column_style(0, 2)
model.copy_cell_style([0, 1, 1], [0, 5, 5]) # style only, works across sheets
model.insert_sheet("Summary", 0, nil) # position, rather than appending
model.set_sheet_state(1, "hidden") # or "visible" / "veryHidden"Bulk loading
UserModel recalculates after every action. When writing many cells, suspend
that and evaluate once:
um.batch do |m|
rows.each { |r, c, v| m.set_user_input(0, r, c, v) }
end # recalculated once, herebatch nests, and recalculates even if the block raises. The underlying
pause_evaluation / resume_evaluation pair is still available if you need to
manage it yourself.
Defined names
Named ranges, on both APIs. scope is a 0-based sheet index, or nil for a
workbook-scoped name. They survive both an xlsx round trip and
editing in Excel/LibreOffice, so they are also a place to keep your own
metadata about a sheet.
model.new_defined_name("answer", nil, "Sheet1!$C$1")
model.set_user_input(0, 1, 1, "=answer")
model.evaluate
model.get_formatted_cell_value(0, 1, 1) # => the value of C1
model.defined_names
# => [{ name: "answer", scope: nil, formula: "Sheet1!$C$1" }]
model.update_defined_name("answer", nil, "result", nil, "Sheet1!$D$1")
model.delete_defined_name("result", nil)valid_defined_name?(name, scope, formula) reports whether
new_defined_name would succeed; the name must be an identifier ("A1" is
rejected — it parses as a cell reference), unused in that scope, and the formula
must parse. is_valid_defined_name runs the same check but raises
IronCalc::Error with the reason. On UserModel all three mutations are
undoable.
Loading and saving
Model |
UserModel |
save with | |
|---|---|---|---|
| new workbook | create |
create_user_model |
|
| xlsx file | load_from_xlsx |
create_user_model_from_xlsx |
save_to_xlsx |
| xlsx in memory | load_from_xlsx_bytes |
create_user_model_from_xlsx_bytes |
to_xlsx_bytes |
| icalc file | load_from_icalc |
create_user_model_from_icalc |
save_to_icalc |
| icalc in memory | load_from_bytes |
create_user_model_from_bytes |
to_bytes |
icalc is IronCalc's own compact format; use xlsx for anything another
spreadsheet application should open. The constructors take
(name_or_path, locale, tz, language_id), except that the bytes variants take
the bytes first, and the icalc ones need only the language.
Utilities
IronCalc.number_to_column(28) # => "AB"
IronCalc.column_to_number("AB") # => 28
IronCalc.parse_reference_a1("$B3") # => {row: 3, column: 2, absolute_row: false, absolute_column: true}
IronCalc.quote_name("My Sheet") # => "'My Sheet'"Also parse_reference_r1c1, the is_valid_* checks, get_supported_locales
and get_all_timezones; see the IronCalc module.
Errors raised by the engine surface as IronCalc::Error.
Relationship to the Python bindings
ironcalc-ruby is a thin binding over the same Rust engine as the
IronCalc Python bindings.
Both are compiled native extensions (Python via pyo3, Ruby via magnus /
rb-sys) exposing a module named ironcalc backed by the IronCalc engine.
Both follow the shape of the engine, so the two APIs line up closely and most
Python examples translate directly. Where Python renamed an engine method, Ruby
keeps the engine's name: Python's clear_cell_contents(sheet, row, column) is
range_clear_contents(range) here, and Model.get_cell_style is
get_style_for_cell. They do not cover the same amount of it —
see Coverage compared to the Python bindings.
What's identical
-
Two APIs: a raw
Model(you callevaluateyourself) and a higher-levelUserModel(auto-evaluates and tracks diffs). -
Top-level constructors, same names and argument order
(name_or_path, locale, tz, language_id):create,load_from_xlsx,load_from_icalc,load_from_bytes,create_user_model,create_user_model_from_xlsx,create_user_model_from_icalc,create_user_model_from_bytes. -
Method names and signatures for everything both take from the engine
unchanged —
set_user_input,get_formatted_cell_value,evaluate,insert_rows,set_column_width,new_sheet,save_to_xlsx,to_bytes, … (Python'ssnake_caseis also Ruby's convention). -
Coordinates:
sheetis a 0-based index;rowandcolumnare 1-based. - Semantics: the same engine, so the same inputs produce the same results.
# Python
import ironcalc as ic
model = ic.create("model", "en", "UTC", "en")
model.set_user_input(0, 1, 1, "=6*7")
model.evaluate()
model.get_formatted_cell_value(0, 1, 1) # "42"# Ruby
require "ironcalc"
model = IronCalc.create("model", "en", "UTC", "en")
model.set_user_input(0, 1, 1, "=6*7")
model.evaluate
model.get_formatted_cell_value(0, 1, 1) # "42"Idiomatic Ruby adaptations
A few return and argument types follow Ruby conventions instead of being literal ports:
| Concern | Python | Ruby |
|---|---|---|
| Module access | ic.create(...) |
IronCalc.create(...) |
| Error type | WorkbookError |
IronCalc::Error |
| Cell style | typed Style / Font / … objects |
Hash with Symbol keys (get_style_for_cell / set_cell_style) |
| Cell type |
CellType enum |
Symbol (:number, :text, …) |
| Worksheet properties | list of SheetProperty objects |
array of Hashes (:name, :state, :sheet_id, :color) |
| Sheet dimensions | tuple (min_row, max_row, min_col, max_col)
|
4-element Array
|
Engine structs (Area, CellIndex) |
spread into separate arguments |
Hash keyed by the struct's field names |
| Engine tuples | tuple | Array |
| Binary data | bytes |
binary String
|
| Version | ironcalc.__version__ |
IronCalc::VERSION |
Rather than reconstruct Python's per-field style classes, Ruby exchanges styles
as plain Hashes keyed by the engine's field names. A method that shares a
name with an engine method takes the engine's parameters in the engine's order,
with nil for an Option; conveniences get names of their own. So a range is
one argument, update_range_style({sheet: 0, row: 2, column: 2, width: 2, height: 9}, "font.b", "true"), where Python spreads it into start and end
corners.
Coverage compared to the Python bindings
Both bindings wrap the same engine, so anything both expose behaves the same. They do not expose the same amount of it. Counted against IronCalc 0.9.0, the version this gem tracks, and accurate at the time of writing:
| Python | Ruby | |
|---|---|---|
Model |
81 | 62 |
UserModel |
119 | 52 |
Most of the difference is deliberate. Selection, scrolling, clipboard and
keyboard state belong to a UI rather than to a library, and are not exposed
here. Conditional formatting, named styles, themes, merged cells and
hyperlinks are a known backlog, listed explicitly in rake parity's
ENGINE_DEFERRED so they stay visible.
What Ruby adds is mostly layering rather than extra engine coverage: Hash-based
styles, batch, and xlsx in memory (to_xlsx_bytes / load_from_xlsx_bytes).
These numbers move with every engine release; rake parity reports the
current picture.
AI-assisted development
These bindings are written and maintained with substantial help from Claude Code, Anthropic's coding agent. A human maintainer directs the work, reviews every change and makes the releases.
Correctness doesn't rest on review alone. The test suite, run in CI on every
push, re-runs the engine's own formula regression fixtures through the binding.
rake parity checks the API surface and method signatures against the engine
and the Python bindings. The conventions agents follow here are in
AGENTS.md.
License
Dual-licensed under MIT or Apache-2.0, matching IronCalc.