Spreadsheet (spreadsheet v0.7.0)

Copy Markdown View Source

A fast Elixir library for reading spreadsheet files, powered by Rust and Calamine.

Features

  • 🚀 Fast Performance - Native Rust implementation via NIFs
  • 📁 Multiple Formats - Supports .xls, .xla, .xlsx, .xlsm, .xlam, .xlsb, and .ods files
  • 💾 Paths or Binaries - Parse from a file path or from content already in memory
  • 🗂️ Sheet Management - List and filter sheet names, including hidden sheets
  • 📅 Consistent Types - Numbers are floats, dates are NaiveDateTime, times are Time, durations are Duration, in every format
  • 🛡️ Bounded Memory - A per-call :max_cells budget rejects sparse or crafted files before they are expanded
  • 🔧 Simple API - {:ok, result} | {:error, reason} for anything wrong with the file

Usage

Getting Sheet Names

List all sheet names from a file:

iex> Spreadsheet.sheet_names("financial_data.xlsx")
{:ok, ["Q1 Revenue", "Q2 Revenue", "Summary"]}

Or from binary content using the :format option:

iex> content = File.read!("financial_data.xlsx")
iex> Spreadsheet.sheet_names(content, format: :binary)
{:ok, ["Q1 Revenue", "Q2 Revenue", "Summary"]}

Filtering Hidden Sheets

Exclude hidden (and very hidden) sheets from the results:

iex> Spreadsheet.sheet_names("workbook.xlsx", hidden: false)
{:ok, ["Visible Sheet"]}

Parsing Sheet Data

Parse a specific sheet by name from a file:

iex> Spreadsheet.parse("sales.xlsx", sheet: "Q1 Data")
{:ok, [
  ["Product", "Sales", "Date"],
  ["Widget A", 1500.0, ~N[2024-01-15 00:00:00]],
  ["Widget B", 2300.0, ~N[2024-01-20 00:00:00]]
]}

Parse all sheets from a file (returns a list of {sheet_name, rows} tuples in workbook order):

iex> Spreadsheet.parse("sales.xlsx")
{:ok, [
  {"Q1 Data", [
    ["Product", "Sales", "Date"],
    ["Widget A", 1500.0, ~N[2024-01-15 00:00:00]]
  ]},
  {"Q2 Data", [
    ["Product", "Sales", "Date"],
    ["Widget B", 2300.0, ~N[2024-04-15 00:00:00]]
  ]}
]}

Or from binary content using the :format option:

iex> content = File.read!("sales.xlsx")
iex> Spreadsheet.parse(content, sheet: "Q1 Data", format: :binary)
{:ok, [
  ["Product", "Sales", "Date"],
  ["Widget A", 1500.0, ~N[2024-01-15 00:00:00]],
  ["Widget B", 2300.0, ~N[2024-01-20 00:00:00]]
]}

Options

OptionFunctionsDefaultDescription
:formatparse/2, sheet_names/2:filename:filename (the first argument is a path) or :binary (it is the file's content).
:hiddenparse/2, sheet_names/2trueWhen false, hidden and very hidden sheets are skipped. nil means the default. Ignored by parse/2 when :sheet is given.
:sheetparse/2nilName of the sheet to parse. nil parses every sheet. sheet_names/2 accepts and ignores it, so one options list works for both.
:max_cellsparse/2, sheet_names/250_000_000Cell budget of the call: the rows × columns (from A1) of every sheet built, summed. :infinity disables it. See "Limiting Memory" below.

Rows Are Anchored at A1

Rows always start at the top-left corner of the sheet: rows |> Enum.at(r) |> Enum.at(c) is the cell in spreadsheet row r + 1, column c + 1, wherever the data starts. Leading empty rows and columns are padded with nil, and every row has the same length. A sheet whose data occupies C3:D4 returns:

{:ok, [
  [nil, nil, nil, nil],
  [nil, nil, nil, nil],
  [nil, nil, "name", "value"],
  [nil, nil, "a", 1.0]
]}

Empty sheets, chart sheets and other sheets without cells return []. They are still listed by sheet_names/2. The exception is .xls chart sheets, which can carry the chart's data and return it as rows.

Data Types

Cell.xlsx, .xlsm, .xlam, .xlsb, .xls.ods
Emptynilnil
Number (including whole numbers)float, e.g. 1234.0float
TextString.t()String.t()
Booleantrue / falsetrue / false
Date or date-time%NaiveDateTime{} (a date alone is midnight; milliseconds)%NaiveDateTime{} (a date alone is midnight; sub-second precision as written in the file, up to 6 digits)
Time of day%Time{} (serial in [0, 1) without a duration format)%Duration{} (see below)
Duration (e.g. [h]:mm:ss)%Duration{} with hour, minute, second, microsecond%Duration{} (sub-second precision as written in the file, up to 6 digits)
Date outside years 0–9999, or a negative serialthe raw serial number as a floatthe raw ISO 8601 string
Formula error (#DIV/0!, #N/A, …){:error, "#DIV/0!"}"" (see Known Limitations)
Formulaits cached result, typed as aboveits cached result

Notes:

  • Numbers are always floats, whatever the format stores (.xls and .xlsb store some numbers as integers internally). Compare with ==, or convert with trunc/1 when you need integers.
  • Durations keep their sign: -1:30:00 is %Duration{hour: -1, minute: -30}. Hours are not folded into days, so 36:00:00 is %Duration{hour: 36}.
  • ODS cannot tell a time of day from a duration (both are office:time-value), so ODS time cells are always %Duration{}. An ODS duration with day, week, month or year parts keeps them in the matching %Duration{} fields.
  • Sub-second precision: Excel formats store milliseconds; values with a fractional second get microsecond: {value, 3}, others {0, 0}, so they compare equal to sigils such as ~N[2024-01-15 10:00:00]. ODS values keep the precision written in the file (up to microseconds).
  • Values that cannot be represented are returned as found rather than as nil: Excel date serials that are negative or fall outside years 0–9999 as the serial (a float), and ODS date or time values that do not parse as the original string.
  • Excel errors are {:error, code} tuples so they never look like text.

Errors and Exceptions

parse/2 and sheet_names/2 return {:error, reason} (a string) for anything wrong with the input file:

  • the file does not exist or cannot be read ("could not open \"x.xlsx\": No such file or directory");
  • the first argument looks like file content but :format is :filename (the message suggests format: :binary), or vice versa;
  • the content is not a supported spreadsheet, or calamine cannot parse it;
  • the requested sheet does not exist ("sheet \"Q3\" not found");
  • the sheets exceed :max_cells, or an .xls or .ods file is malformed in a way that would make the parser allocate without bound or loop forever;
  • :format is neither :filename nor :binary (kept as an error tuple for backwards compatibility).

They raise ArgumentError for programming errors: unknown options, a :sheet that is not a string or nil, a :hidden that is not a boolean or nil (only checked when it is used), or a :max_cells that is not a positive integer or :infinity. Duplicate options do not raise: the first one wins.

Limiting Memory with :max_cells

Rows are fully built in memory, and padding to A1 means a single cell in XFD1048576 produces a 1,048,576 × 16,384 rectangle. :max_cells is a cell budget for one call: every sheet the call builds costs its rows × columns, counted from A1, and a sheet that does not fit in what is left returns an error before anything is allocated for it. The default of 50 million fits, for example, one sheet of 1,048,576 rows × 47 columns, or 500,000 rows × 100 columns:

iex> Spreadsheet.parse("sparse.xlsx")
{:error, "sheet \"Data\" has 200000 rows × 16384 columns (3276800000 cells), above the :max_cells limit of 50000000"}

iex> Spreadsheet.parse("big.xlsx", max_cells: 100_000_000)
{:ok, [...]}

As a guide, each cell of the result takes about 17 bytes when it is nil, 33 bytes for a float and 57 bytes for typical mixed data (short strings, dates, floats), so the default allows a result of roughly 1–3 GB. Lower it for untrusted uploads.

  • .xlsx and .xlsb sheets are checked against their actual cells while they are read (the file's declared dimensions are ignored). A sheet that repeats cell positions far beyond its rectangle is rejected as malformed, whatever the limit.
  • .xls and .ods workbooks are decoded in full when they are opened, so they are scanned before decoding and every sheet of the workbook counts against the budget, even when you parse one sheet or only call sheet_names/2. For .ods the budget also bounds the cells repeat attributes expand to (table:number-columns-repeated).
  • Malformed files are rejected whatever the limit, including with max_cells: :infinity: .xls compound file headers or sector chains that would make the parser allocate without bound or loop forever, .xls DIMENSIONS records beyond 65,536 rows × 256 columns, and .ods <text:s> space runs above 100 million spaces per workbook.

Only raise or disable the limit for files you trust.

Known Limitations

  • .xls float precision: some .xls numbers stored in Excel's compact RK form decode with a binary rounding difference, e.g. 0.12119999999999999 instead of 0.1212. This comes from calamine's decoding.
  • ODS error cells: LibreOffice stores formula errors in .ods files as empty text with a private calcext:value-type="error" attribute, which calamine reads as "".
  • Time-only cells at serial 0: Excel formats only tell calamine whether a cell is a date-time or a duration, not whether its format shows a date. A date-formatted cell whose serial is in [0, 1) (e.g. 1904-01-01 in a 1904-epoch workbook) is returned as a %Time{}.
  • .xls and .ods memory: calamine decodes every sheet of these formats when the file is opened, and copies the requested sheet, so parsing one sheet still costs memory for the whole workbook.
  • Not limited: :max_cells bounds cells, not the size of individual strings or shared-string tables.

For details on the underlying parser, see the Calamine documentation.

Summary

Types

A cell value: nil, a float, a string, a boolean, a NaiveDateTime, a Time, a Duration, or {:error, code} for a formula error. See "Data Types" in the module documentation.

A sheet's rows, anchored at A1 and padded with nil.

Functions

Parses one sheet, or every sheet, of a spreadsheet.

Parses a specific sheet from spreadsheet binary content.

Returns the names of the sheets in a spreadsheet, in workbook order.

Returns a list of sheet names from spreadsheet binary content.

Types

cell()

@type cell() ::
  nil
  | float()
  | String.t()
  | boolean()
  | NaiveDateTime.t()
  | Time.t()
  | Duration.t()
  | {:error, String.t()}

A cell value: nil, a float, a string, a boolean, a NaiveDateTime, a Time, a Duration, or {:error, code} for a formula error. See "Data Types" in the module documentation.

rows()

@type rows() :: [[cell()]]

A sheet's rows, anchored at A1 and padded with nil.

Functions

parse(path_or_content, opts \\ [])

@spec parse(binary(), keyword()) ::
  {:ok, rows() | [{String.t(), rows()}]} | {:error, String.t()}

Parses one sheet, or every sheet, of a spreadsheet.

With the :sheet option, returns that sheet's rows. Without it (or with sheet: nil), returns {sheet_name, rows} tuples for every sheet in workbook order.

Rows are lists of cells anchored at A1: row 0 is spreadsheet row 1 and column 0 is column A, with leading empty rows and columns padded with nil. See "Data Types" in the module documentation for the value of each cell.

Options

  • :sheet - the name of the sheet to parse. Defaults to nil (all sheets).
  • :format - :filename (default) when the first argument is a path, or :binary when it is the file's content.
  • :hidden - when false, excludes hidden and very hidden sheets from all-sheets parsing. Defaults to true (nil also means the default). Ignored, and not validated, when :sheet is given: naming a sheet always parses it.
  • :max_cells - the cell budget of the call, or :infinity. Defaults to 50000000. Each sheet parsed costs its rows × columns, counted from A1, and the sheets of one call must fit in the budget together (1,048,576 rows × 47 columns fits the default). A sheet that does not fit returns an error before it is expanded. .xls and .ods files are decoded in full when opened, so for them every sheet of the workbook counts, even when one sheet is requested.

Returns {:error, reason} when the file cannot be read or parsed, the sheet does not exist or the sheets exceed :max_cells, and raises ArgumentError for unknown or invalid options. With duplicate options, the first one wins.

Examples

# Parse a specific sheet from a file path
Spreadsheet.parse("sales.xlsx", sheet: "Q1 Data")
{:ok, [
  ["Product", "Sales", "Date"],
  ["Widget A", 1500.0, ~N[2024-01-15 00:00:00]]
]}

# Parse all sheets from a file path
Spreadsheet.parse("sales.xlsx")
{:ok, [
  {"Q1 Data", [
    ["Product", "Sales", "Date"],
    ["Widget A", 1500.0, ~N[2024-01-15 00:00:00]]
  ]},
  {"Q2 Data", [
    ["Product", "Sales", "Date"],
    ["Widget B", 2300.0, ~N[2024-04-15 00:00:00]]
  ]}
]}

# Parse all visible sheets from binary content
content = File.read!("sales.xlsx")
Spreadsheet.parse(content, format: :binary, hidden: false)
{:ok, [{"Q1 Data", [...]}, {"Q2 Data", [...]}]}

# Allow a larger sheet
Spreadsheet.parse("big.xlsx", sheet: "Data", max_cells: 100_000_000)
{:ok, [[...]]}

parse_from_binary(content, sheet_name)

This function is deprecated. Use parse/2 with sheet: and format: :binary options instead.
@spec parse_from_binary(binary(), binary()) :: {:ok, rows()} | {:error, String.t()}

Parses a specific sheet from spreadsheet binary content.

This function is deprecated. Use parse/2 with sheet: and format: :binary options instead.

Returns the sheet's rows, as parse/2 does.

sheet_names(path_or_content, opts \\ [])

@spec sheet_names(binary(), keyword()) :: {:ok, [String.t()]} | {:error, String.t()}

Returns the names of the sheets in a spreadsheet, in workbook order.

Options

  • :format - :filename (default) when the first argument is a path, or :binary when it is the file's content.
  • :hidden - when false, excludes hidden and very hidden sheets. Defaults to true (nil also means the default).
  • :sheet - accepted and ignored, so the same options can be passed to parse/2 and sheet_names/2.
  • :max_cells - the cell budget of the call, or :infinity. Defaults to 50000000. Listing sheets only reads cells for .xls and .ods files, which are decoded in full when opened: every sheet's rows × columns, counted from A1, must fit in the budget together. Other formats are not affected here. See parse/2.

Returns {:error, reason} when the file cannot be read or parsed, and raises ArgumentError for unknown or invalid options. With duplicate options, the first one wins.

Examples

# From a file path (default)
Spreadsheet.sheet_names("workbook.xlsx")
{:ok, ["Sheet1", "Sheet2"]}

# From binary content
content = File.read!("workbook.xlsx")
Spreadsheet.sheet_names(content, format: :binary)
{:ok, ["Sheet1", "Sheet2"]}

# Exclude hidden sheets
Spreadsheet.sheet_names("workbook.xlsx", hidden: false)
{:ok, ["Sheet1"]}

sheet_names_from_binary(content, opts \\ [])

This function is deprecated. Use sheet_names/2 with format: :binary instead.
@spec sheet_names_from_binary(binary(), keyword()) ::
  {:ok, [String.t()]} | {:error, String.t()}

Returns a list of sheet names from spreadsheet binary content.

This function is deprecated. Use sheet_names/2 with format: :binary instead.

Options

  • :hidden - When false, excludes hidden sheets. Defaults to true.
  • :max_cells - See sheet_names/2.