Project

ironcalc

0.0
The project is in a healthy, maintained state
Ruby bindings for the IronCalc spreadsheet engine. Create, read and manipulate xlsx files: manage sheets, set and read cell values, and evaluate formulas.
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
 Project Readme

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 peers

Coordinates: 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.

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 style

On 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, here

batch 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 call evaluate yourself) and a higher-level UserModel (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's snake_case is also Ruby's convention).
  • Coordinates: sheet is a 0-based index; row and column are 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.