All notable changes to this project will be documented here. The format follows Keep a Changelog and the project adheres to Semantic Versioning.
[Unreleased]
Changed (performance — second round)
Elixir bulk-write throughput now beats Python (openpyxl) on the comparative benchmark and runs in ~1/4 of the previous time.
- Map-backed editable sheet (
ExVEx.OOXML.Worksheet.Editable). The SimpleForm tree's nested lists are replaced at edit time with a%{{row, col} => cell_tuple}map.put_cell/get_cell/merge_cells/unmerge_cellsnow work in O(log N) instead of O(rows + cols) per call. - ETS-backed cell grid and shared-string table. Cells live in an
ETS
:settable (O(1) ops). The shared-string table (SharedStrings) has moved from a tuple to two ETS tables (by_index,by_string) with O(1) intern. Read + write share state across workbook references derived from the sameopen/new, which matches how cells are shared — there is no "some-sharing-but-not-string-sharing" corner case. ExVEx.close/1. Releases the backing ETS tables. Optional in short-lived processes (BEAM cleans up on exit), recommended in long-running servers.
Benchmark (Apple M3 Max, 12-sheet template, 24,800 cells per create, 48,000 cells per edit):
| before (list) | after (ETS) | speedup | |
|---|---|---|---|
| Elixir create | 481 ms | 145 ms | 3.3× |
| Elixir edit (rotations) | 943 ms avg | 245 ms avg | 3.8× |
| Python edit | ~300 ms | ~300 ms | (ref.) |
The API is unchanged. Shared-state is documented in the
ExVEx.OOXML.Worksheet.Editable and ExVEx.OOXML.SharedStrings
moduledocs — {:ok, b2} = put_cell(b1, ...) may leave b1 observing
b2's mutation. In practice this doesn't affect the common flow
(open → mutate → save); if you need a snapshot, save and re-open.
Added (workbook creation)
ExVEx is now a complete xlsx library — it can create workbooks from scratch, not just edit existing ones. No Python or Rust dependency needed for template generation.
ExVEx.new/0— returns a minimal blank workbook with a single empty sheet named"Sheet1". No fixture file needed; the skeleton parts are embedded in the library.ExVEx.add_sheet(book, name)— appends a new empty sheet.{:error, :duplicate_sheet_name}if the name is already in use.ExVEx.rename_sheet(book, old, new)— changes a sheet's name in place.{:error, :unknown_sheet}or{:error, :duplicate_sheet_name}as appropriate. Same-name is a no-op.ExVEx.remove_sheet(book, name)— drops a sheet and its worksheet part, plus the matching Content Types Override and workbook relationship.{:error, :last_sheet}guards against producing an invalid zero-sheet workbook.
All four coordinate workbook.xml, workbook.xml.rels, and
[Content_Types].xml together on every change. A new
OOXML.Workbook.serialize_into/2 rewrites the <sheets> section while
preserving surrounding elements at the SimpleForm level.
Example — build a multi-sheet template from zero:
{:ok, book} = ExVEx.new()
{:ok, book} = ExVEx.rename_sheet(book, "Sheet1", "Summary")
{:ok, book} = ExVEx.add_sheet(book, "Data")
{:ok, book} = ExVEx.add_sheet(book, "Formulas")
{:ok, book} = ExVEx.put_cell(book, "Data", "A1", "alpha")
{:ok, book} = ExVEx.put_cell(book, "Formulas", "A1", {:formula, "=SUM(Data!A1:A10)"})
:ok = ExVEx.save(book, "template.xlsx")Changed (performance)
Bulk writes are now ~20× faster with ~60× less memory churn.
- Sheet tree cache.
%ExVEx.Workbook{}now caches the parsedSaxy.SimpleFormtree for every worksheet on first access. Subsequentget_cell,put_cell,merge_cells,unmerge_cells,merged_ranges,get_formula,get_style,cells, andeach_cellcalls reuse the cached tree instead of re-parsing the XML.save/2re-serializes only dirty sheet trees once at flush time. - Shared-string interns are O(1).
ExVEx.OOXML.SharedStringsnow stores strings in two maps (by_index,by_string) instead of a tuple. Interning a new string was O(N) per call (tuple copy); it is now O(1).
Benchmark: 500 put_cell + save dropped from 142 ms / 348 MB to 7 ms /
6 MB. 1000 unique string interns + save dropped from 761 ms / 1.8 GB to
36 ms / 16 MB. See bench/results/README.md for the full report and
instructions on reproducing.
[0.1.0] — 2026-04-17
First release. Pre-alpha — API may evolve.
Added
ExVEx.open/1andExVEx.save/2— byte-preserving round-trip on every part in the archive that the caller has not explicitly mutated, including.xlsmVBA binaries, custom XML, and unknown content types.ExVEx.sheet_names/1andExVEx.sheet_path/2for sheet navigation.ExVEx.get_cell/3— reads strings (shared & inline), numbers, booleans, dates (asDate), date-times (asNaiveDateTime), formula results, and cell errors.ExVEx.put_cell/4— writes:- strings (deduplicated through the shared-string table when present; falls back to inline strings when the workbook has none)
- numbers (integers and floats)
- booleans
nil(clears the cell){:formula, "..."}and{:formula, "...", cached_value}DateandNaiveDateTime(converted to Excel serial numbers; an xf with the matching date numFmtId is added toxl/styles.xmlif one isn't already present)
ExVEx.get_formula/3— reads the formula string from a formula cell.ExVEx.get_style/3— resolves a cell's style into a flat%ExVEx.Style{}with font, fill, border, alignment, and number-format sub-records dereferenced from the stylesheet.ExVEx.cells/2— returns every populated cell on a sheet as a%{ref => value}map.ExVEx.each_cell/2— streams every populated cell in row-major order.ExVEx.merge_cells/3,4,ExVEx.unmerge_cells/3,4,ExVEx.merged_ranges/2— merged-cell management with Excel-faithful defaults (clears non-anchor cells on merge; exact-match required on unmerge). Options::preserve_values(false|true),:on_overlap(:error|:replace|:allow),:on_missing(:error|:ignore). Ranges are stored as<mergeCells><mergeCell ref="A1:B2"/></mergeCells>in the worksheet XML, inserted in the correct position in the schema's element order.- OOXML parsers:
Packaging.ContentTypes,Packaging.Relationships,OOXML.Workbook,OOXML.Worksheet,OOXML.SharedStrings,OOXML.Styles. - Style model:
ExVEx.Style+Font,Fill,Border,Side,Alignment,Color. - Coordinate utilities:
ExVEx.Utils.Coordinate— A1 ↔{row, col}, Excel's bijective base-26 column labels.
Formula freshness on save
When a workbook is mutated, ExVEx invalidates the calculation chain cache
so Excel recomputes formulas on open instead of showing stale #N/A
placeholders. On save of a dirty workbook:
xl/calcChain.xmlis dropped from the archive.- Its entry is removed from
[Content_Types].xmlandxl/_rels/workbook.xml.rels. <calcPr fullCalcOnLoad="1">is set onxl/workbook.xml.
No-op saves (open → save without mutation) leave every part byte-identical.
Coordinate addressing
Every cell-addressing function (get_cell/3, put_cell/4, get_formula/3,
get_style/3) accepts either A1-notation ("B2") or a 1-indexed
{row, col} integer tuple ({2, 2}). Useful when porting from openpyxl
or iterating by numeric coordinates. ExVEx.Utils.Coordinate.to_string/1
is also public if you need to convert explicitly.
Quality gates
- 125 ExUnit tests, all passing.
mix compile --warnings-as-errors,mix format --check-formatted,mix credo --strict— all clean.- GitHub Actions CI runs the above plus dialyzer on every push / PR.
- Output produced by
put_cell/4+save/2is successfully read back by umya-spreadsheet (Rust) — a strong proxy for "Excel accepts this" without requiring Excel in CI.