ExVEx
Elixir vs. Excel. 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.
{:ok, book} = ExVEx.open("inventory.xlsx")
# Read
ExVEx.sheet_names(book) #=> ["Sheet1", "Sheet2"]
ExVEx.get_cell(book, "Sheet1", "A1") #=> {:ok, "widget"}
ExVEx.get_cell(book, "Sheet1", "B2") #=> {:ok, 42}
ExVEx.get_cell(book, "Sheet1", "C3") #=> {:ok, ~D[2024-01-15]}
ExVEx.get_formula(book, "Sheet1", "D4") #=> {:ok, "=SUM(B2:B10)"}
ExVEx.get_style(book, "Sheet1", "A1") #=> {:ok, %ExVEx.Style{...}}
# Write
{:ok, book} = ExVEx.put_cell(book, "Sheet1", "D1", "Updated")
{:ok, book} = ExVEx.put_cell(book, "Sheet1", "D2", 3.14)
{:ok, book} = ExVEx.put_cell(book, "Sheet1", "D3", true)
{:ok, book} = ExVEx.put_cell(book, "Sheet1", "D4", ~D[2024-06-01])
{:ok, book} = ExVEx.put_cell(book, "Sheet1", "D5", {:formula, "=SUM(A1:A10)"})
# Coordinates accept both A1 refs and {row, col} integer tuples
ExVEx.get_cell(book, "Sheet1", {2, 3}) #=> same as ExVEx.get_cell(book, "Sheet1", "C2")
{:ok, book} = ExVEx.put_cell(book, "Sheet1", {10, 5}, "tuple write")
# Merge / unmerge
{:ok, book} = ExVEx.merge_cells(book, "Sheet1", "A1:B2")
{:ok, ["A1:B2"]} = ExVEx.merged_ranges(book, "Sheet1")
{:ok, book} = ExVEx.unmerge_cells(book, "Sheet1", "A1:B2")
# Save
:ok = ExVEx.save(book, "inventory.xlsx")Why
The Elixir ecosystem has Elixlsx (write-only) and xlsx_reader (read-only),
but no first-class story for editing existing spreadsheets. Every team that
needs this today reaches for Python (openpyxl) or a Rust NIF — which drags
a second runtime into the deployment.
ExVEx 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). 119 tests, zero credo issues, zero compile warnings.
What works
-
Open
.xlsxand.xlsmfiles - Round-trip identity on untouched content (unknown XML, custom parts, VBA macros all pass through byte-for-byte)
- Read cell values: strings (shared + inline), numbers, booleans, dates, date-times, formula results, cell errors
- Read cell styles: font, fill, border, alignment, number format
-
Write cell values: strings (through the SST with automatic dedup),
numbers, booleans,
nil(clear),Date,NaiveDateTime, formulas (with or without cached value) -
Sheet navigation:
sheet_names/1,sheet_path/2 -
Bulk reads:
cells/2(map),each_cell/2(stream in row-major order) -
Get cell formula:
get_formula/3 -
Merge / unmerge cells with configurable overlap and value-preservation
behaviour (
merge_cells/3,4,unmerge_cells/3,4,merged_ranges/2)
Not yet
- Style mutation (set bold, change font, etc.)
- Row/column insertion, defined names
- Charts, images, pivot tables, comments
Installation
def deps do
[{:ex_v_ex, "~> 0.1.0"}]
endDesign
Three commitments drive the design:
- Lazy raw parts. The archive is read into a flat
%{path => bytes}map. Parts you never touch are written back byte-identical on save. - Immutable functional API. Every mutating operation returns a new
%ExVEx.Workbook{}. No GenServers, no mutable references. - 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), ExVEx 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 ExVEx verifies in its test suite.
License
MIT. See LICENSE.