Linen

A pure-Elixir library for reading and editing existing .xlsx and .xlsm files with round-trip fidelity — no Rust, no Python, no NIFs, no second runtime in your deployment.

# Create from scratch
{:ok, book} = Linen.new()
{:ok, book} = Linen.rename_sheet(book, "Sheet1", "Summary")
{:ok, book} = Linen.add_sheet(book, "Data")
{:ok, book} = Linen.put_cell(book, "Data", "A1", "alpha")
:ok = Linen.save(book, "new.xlsx")

# Or open an existing workbook
{:ok, book} = Linen.open("inventory.xlsx")

# Read
Linen.sheet_names(book)                           #=> ["Sheet1", "Sheet2"]
Linen.get_cell(book, "Sheet1", "A1")              #=> {:ok, "widget"}
Linen.get_cell(book, "Sheet1", "B2")              #=> {:ok, 42}
Linen.get_cell(book, "Sheet1", "C3")              #=> {:ok, ~D[2024-01-15]}
Linen.get_formula(book, "Sheet1", "D4")           #=> {:ok, "=SUM(B2:B10)"}
Linen.get_style(book, "Sheet1", "A1")             #=> {:ok, %Linen.Style{...}}

# Write
{:ok, book} = Linen.put_cell(book, "Sheet1", "D1", "Updated")
{:ok, book} = Linen.put_cell(book, "Sheet1", "D2", 3.14)
{:ok, book} = Linen.put_cell(book, "Sheet1", "D3", true)
{:ok, book} = Linen.put_cell(book, "Sheet1", "D4", ~D[2024-06-01])
{:ok, book} = Linen.put_cell(book, "Sheet1", "D5", {:formula, "=SUM(A1:A10)"})

# Coordinates accept both A1 refs and {row, col} integer tuples
Linen.get_cell(book, "Sheet1", {2, 3})            #=> same as Linen.get_cell(book, "Sheet1", "C2")
{:ok, book} = Linen.put_cell(book, "Sheet1", {10, 5}, "tuple write")

# Merge / unmerge
{:ok, book} = Linen.merge_cells(book, "Sheet1", "A1:B2")
{:ok, ["A1:B2"]} = Linen.merged_ranges(book, "Sheet1")
{:ok, book} = Linen.unmerge_cells(book, "Sheet1", "A1:B2")

# Excel tables
{:ok, book} = Linen.add_table(book, "Sheet1", "A1:C10", name: "Sales")
{:ok, %Linen.Table{columns: ["Region", "Qty", "Amount"]}} = Linen.table(book, "Sales")
{:ok, book} = Linen.append_table_rows(book, "Sales", [["West", 4, 12.5]])
{:ok, book} = Linen.put_table_totals_row(book, "Sales", Amount: :sum)
{:ok, rows} = Linen.table_records(book, "Sales")  #=> [%{"Region" => "North", ...}, ...]

# Save
:ok = Linen.save(book, "inventory.xlsx")

Why

The Elixir ecosystem has Elixlsx (write-only) and xlsx_reader (read-only), but nothing that edits existing spreadsheets. Every team that needs this today reaches for Python (openpyxl) or a Rust NIF — which drags a second runtime into the deployment.

Linen fills that gap in pure Elixir.

Status

v0.1 — pre-alpha. Core read/write/round-trip is solid and externally validated against umya-spreadsheet (Rust). 346 tests, zero credo issues, zero compile warnings.

What works

Not yet

Installation

def deps do
  [{:linen, "~> 0.3"}]
end

Linen was published as ex_v_ex (ExVEx) up to 0.2.2. To move over, replace {:ex_v_ex, ...} with {:linen, "~> 0.3"} and ExVEx with Linen in module names.

Design

Three commitments drive the design:

  1. Lazy raw parts. The archive is read into a flat %{path => bytes} map. Parts you never touch are written back byte-identical on save.
  2. Immutable functional API. Every mutating operation returns a new %Linen.Workbook{}. No GenServers, no mutable references.
  3. Preserve over re-serialize. When a part is mutated (a worksheet with a written cell; the styles.xml when a date write adds an xf; the sharedStrings.xml when a new string is interned), Linen surgically edits just the changed sub-tree and leaves the surrounding XML alone — namespaces, unknown attributes, and extension elements survive.

This is the same strategy used by umya-spreadsheet (Rust), and stronger than edit-xlsx's full-deserialize-reserialize approach. It's also the only way to preserve .xlsm VBA binaries byte-for-byte, which Linen verifies in its test suite.

License

MIT. See LICENSE.