Pure-Elixir reader and editor for .xlsx / .xlsm workbooks.
Quick start
{:ok, book} = ExVEx.open("path/to/file.xlsx")
ExVEx.sheet_names(book) #=> ["Sheet1", "Sheet2"]
{:ok, "hello"} = ExVEx.get_cell(book, "Sheet1", "A1")
:ok = ExVEx.save(book, "path/to/output.xlsx")Design
ExVEx opens a workbook lazily: raw ZIP part bytes are kept in memory and
untouched parts are written back verbatim on save/2. This preserves
unknown content (custom XML, VBA macros, extension schemas) on round-trip
without the caller needing to opt in.
Summary
Functions
Appends a new empty sheet with the given name to the workbook.
Returns every populated cell on a sheet as a map of A1 refs to resolved values. Empty cells are omitted.
Releases the ETS tables backing a workbook's cell grid. Calling this is optional — tables are cleaned up when the owning process exits — but recommended in long-running processes that open many workbooks.
Streams every populated cell on a sheet as {a1_ref, value} pairs in
row-major order (row 1 before row 2; column A before column B within a
row).
Merges a rectangular range of cells on a sheet.
Returns the list of merged ranges on a sheet as A1-style range refs.
Returns a minimal blank workbook with a single empty sheet named
"Sheet1". Compose with add_sheet/2, rename_sheet/3,
remove_sheet/2, and put_cell/4 to build a template from scratch.
Removes a sheet and its worksheet part from the workbook.
Renames a sheet. Returns {:error, :unknown_sheet} if old is not found.
Removes a merged range from a sheet.
Types
@type cell_ref() :: String.t() | {pos_integer(), pos_integer()}
@type path() :: Path.t()
@type range_ref() :: String.t()
@type sheet_name() :: String.t()
Functions
@spec add_sheet(ExVEx.Workbook.t(), sheet_name()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
Appends a new empty sheet with the given name to the workbook.
Returns {:error, :duplicate_sheet_name} if the name is already in use.
@spec cells(ExVEx.Workbook.t(), sheet_name()) :: {:ok, %{required(cell_ref()) => term()}} | {:error, term()}
Returns every populated cell on a sheet as a map of A1 refs to resolved values. Empty cells are omitted.
@spec close(ExVEx.Workbook.t()) :: :ok
Releases the ETS tables backing a workbook's cell grid. Calling this is optional — tables are cleaned up when the owning process exits — but recommended in long-running processes that open many workbooks.
After close/1, the workbook is no longer usable; calls to get_cell,
put_cell, etc. will fail.
@spec each_cell(ExVEx.Workbook.t(), sheet_name()) :: {:ok, Enumerable.t({cell_ref(), term()})} | {:error, term()}
Streams every populated cell on a sheet as {a1_ref, value} pairs in
row-major order (row 1 before row 2; column A before column B within a
row).
@spec get_cell(ExVEx.Workbook.t(), sheet_name(), cell_ref()) :: {:ok, cell_value() | Date.t() | NaiveDateTime.t()} | {:error, term()}
@spec get_formula(ExVEx.Workbook.t(), sheet_name(), cell_ref()) :: {:ok, String.t() | nil} | {:error, term()}
@spec get_style(ExVEx.Workbook.t(), sheet_name(), cell_ref()) :: {:ok, ExVEx.Style.t()} | {:error, term()}
@spec merge_cells(ExVEx.Workbook.t(), sheet_name(), range_ref(), keyword()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
Merges a rectangular range of cells on a sheet.
Options
:preserve_values—false(default) to clear every non-anchor cell in the range (Excel's convention);trueto leave underlying cells untouched. Excel will still only display the anchor cell's value, butget_cell/3on a non-anchor cell will keep returning whatever was there.:on_overlap—:error(default) to refuse a range that overlaps an existing merge and return{:error, {:overlaps, existing_ref}};:replaceto remove the overlapping range(s) first;:allowto permit overlapping ranges (matches openpyxl's lenient behaviour).
@spec merged_ranges(ExVEx.Workbook.t(), sheet_name()) :: {:ok, [range_ref()]} | {:error, term()}
Returns the list of merged ranges on a sheet as A1-style range refs.
@spec new() :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
Returns a minimal blank workbook with a single empty sheet named
"Sheet1". Compose with add_sheet/2, rename_sheet/3,
remove_sheet/2, and put_cell/4 to build a template from scratch.
@spec open(path()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
@spec put_cell(ExVEx.Workbook.t(), sheet_name(), cell_ref(), cell_value()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
@spec remove_sheet(ExVEx.Workbook.t(), sheet_name()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
Removes a sheet and its worksheet part from the workbook.
Returns {:error, :unknown_sheet} if the name is not found, or
{:error, :last_sheet} if removing it would leave the workbook with
zero sheets (invalid per the OOXML spec).
@spec rename_sheet(ExVEx.Workbook.t(), sheet_name(), sheet_name()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
Renames a sheet. Returns {:error, :unknown_sheet} if old is not found.
@spec save(ExVEx.Workbook.t(), path()) :: :ok | {:error, term()}
@spec sheet_names(ExVEx.Workbook.t()) :: [sheet_name()]
@spec sheet_path(ExVEx.Workbook.t(), sheet_name()) :: {:ok, String.t()} | :error
@spec unmerge_cells(ExVEx.Workbook.t(), sheet_name(), range_ref(), keyword()) :: {:ok, ExVEx.Workbook.t()} | {:error, term()}
Removes a merged range from a sheet.
Options
:on_missing—:error(default) returns{:error, :not_merged}when the exact range is not currently merged;:ignoremakes the call a no-op in that case.