An office suite that opens fast, does the parts you actually use, and keeps your files in a format nobody owns.
Spreadsheets and text documents in OpenDocument — the real thing, not an import filter. The same document opens four ways: a command line, a terminal UI, a desktop window — GNOME's and Windows' own, each native rather than one toolkit pretending — and a browser tab that runs entirely on your own machine. One core underneath all of them, so they cannot disagree with each other.
This is software I wanted, built the way I want it. It is not a product, not a LibreOffice
replacement for anybody but me, and not a community effort taking feature requests. The feature
list is short on purpose, and doc/not-doing.md is the document that says
where it stops. If the thing you need is on that list, this will never do it — that is a
decision, not a gap. There is no guarantee it does what you need, and no guarantee that what
works today still works next month.
Take it, use it, fork it. Just do not expect it to fit your work because it fits mine.
How far along each part is, plainly:
| The spreadsheet | Furthest along by a long way, and the part I use daily. Formulas, number formats, styling, charts, named ranges, multiple sheets — in every one of the front ends. |
| The word processor | Early. It reads, writes and edits real documents — headings, lists, bookmarks, character formatting, images — and it has no page model: a document is one reflowed column. Plenty of people would call that a rich-text editor rather than a word processor, and today they are right. |
| Anything else | Presentations, drawings, a database: not built, not scheduled, and quite possibly never. The suite is shaped so a third document type could be added without rewriting the first two. That is the whole claim. |
LibreOffice is great. It is the reason OpenDocument is a real format and not a standards document, and it does everything — which is the problem. Thirty years of menus, four toolbars, dialogs inside dialogs, and somewhere in there the six things I actually do in a spreadsheet: type numbers, sum a column, format it so it reads properly, name a range, look at what a formula is doing, save it.
So this is those six things rebuilt from scratch, on my own terms:
- ODF-native, because free formats matter. Not "imports .ods" — the file on disk is the model. OpenFormula semantics, ODF's error values, ODF's number formats, ODF's references. Nothing is translated through somebody else's spreadsheet dialect on the way in or out, so nothing is lost in translation. Everything written validates against the OASIS schema.
- A feature list that ends. Legacy features almost nobody uses are simply not here: no macros, no extensions, no pivot tables, no change tracking, no OLE embedding, no solver, no scenarios, no sparklines. A spreadsheet you can hold in your head is the goal, not a milestone on the way to a bigger one.
- Small enough to trust. There is no scripting host and no extension API, so a document this opens cannot execute anything — not by design decision alone, but because the machinery does not exist and is not allowed to.
It is not a port of LibreOffice and contains none of its code. It implements the OASIS specifications, and uses LibreOffice as an oracle and a test corpus — every formula it evaluates is checked against LibreOffice's answer, and every file it writes is opened by LibreOffice and read back.
Nearly all of it is written with an AI agent, which is the other experiment: the rules live in checked-in documents, and tests fail the build when the code and the documents disagree.
Cells, types, and all 110 functions of OpenFormula's Small Group (plus ROW and COLUMN,
which earned their place) — grind sheet functions prints the list. Multiple sheets, named
ranges and named expressions, filters, undo/redo. Number formats, so a date prints as a date
and a German document's 1.234,50 stays 1.234,50. Cell styling — weights, colours,
borders, alignment. Column widths and row heights, hidden rows and columns. Charts — bar,
line and pie — that survive a round trip through LibreOffice like everything else.
CSV and TSV, in and out, which is the one non-ODF format here. The delimiter is read out of
the file rather than out of its name, so a comma file, a German semicolon file and a tab file
all just open; --locale de-DE reads 1.234,50 as a number. What a field means is the same
rule as typing it into a cell, with the guards a real file needs — 007 stays a product code
instead of becoming 7, NaN stays somebody's name, and a leading = stays text unless you ask
for a formula. Dates are ISO only and behind a flag, because 15/03/2026 and 03/15/2026 are
the same characters meaning two different days.
Not there yet: sort (it needs a locale-collation decision first), find/replace, freeze panes,
printing. Reading .xlsx is planned; writing it never is. Fonts are a named gap — nothing
here picks a typeface for you yet.
Paragraphs, headings and lists; bookmarks; outline navigation; images; find and replace; word count. Character formatting — bold, italic, underline, strike, family, size, colour, highlight — which round-trips through LibreOffice character by character.
Addressing is the unusual part, and it is what makes a document scriptable: a place is p12
(the twelfth block), #intro (a bookmark), or §2.1.3 (an outline path), optionally plus a
character offset. The last two keep meaning the same place after you edit somewhere else in the
document.
Page size, margins, headers, footers and page breaks are read, kept and written back untouched —
they are simply never drawn as pages. Line layout, though, lives in the shared core, so
j, k, Home and End mean exactly the same thing in every front end and the command line can
answer them too; each front end supplies only font metrics. What is missing beyond pages: tables,
footnotes, fields, style definitions, and right-to-left layout.
Every capability lives in one Rust core, and each front end is a window onto it that owns nothing. The rule — enforced by a test, not by good intentions — is that anything a window can do, the command line can do.
grind sheet new book.ods
grind sheet set book.ods A1 1
grind sheet set book.ods A2 2
grind sheet set book.ods A3 '=SUM([.A1:.A2])' # OpenFormula syntax, stored verbatim
grind sheet recalc book.ods
grind sheet format book.ods A3 currency --symbol '€' --grouping
grind sheet style book.ods A1 --bold --background '#dddddd'
grind sheet name book.ods total A1:A2 # a named range, so formulas say what they mean
grind sheet calculations book.ods # every computed cell, and what it calls
grind sheet view book.ods A1:A3 # tab-separated, pipes into anything
grind sheet import-csv book.ods data.csv # delimiter sniffed; "-" reads a pipe
grind sheet export-csv book.ods --out out.csv # what each cell shows, quoted where neededCells are addressed the way ODF references them, minus the brackets — A1, $B$7, Data.B2,
'Q3 Actuals'.A1:.C9. grind text is the same shape for documents: type, insert, style,
outline, find, replace, words. grind info, grind lint and grind convert work on
either kind and decide which by reading the file, never by its name.
--format json makes every command machine-readable, --dry-run applies a change and writes
nothing, and --session carries the spreadsheet's undo history across invocations — which
together make this a reasonable thing to point a script, a CI job or an agent at.
doc/cli-recipes-sheet.md has worked examples: CSV import, a PMT
model, a CI gate on error cells, git diffs of .ods files.
grind-tui book.ods # the spreadsheet
grind-tui report.fodt # the word processor
grind-tui --text # a new document, emptyOne binary, both document types, decided from the file's own bytes. No system packages needed.
Vi-style modes in both: Normal navigates (hjkl/arrows, g/G, Ctrl-f/Ctrl-b),
Visual (v) selects, Insert edits, : opens a command line. j and k move by
wrapped line in a document rather than by paragraph.
Formatting is typed as markdown and drawn as formatting. **bold**, *italic*,
__underline__ and ~~struck~~ become the document's own formatting as the closing marker
lands — the markers are erased, and what stays is bold, italic, underlined or struck in the
terminal's own attributes. `code` sets a monospace family, three backticks open a code
block, # makes a heading and - a list item. Over a selection each is a single key: *,
/, _, ~, -. The spreadsheet gets :align, :color, :fill, :format and the rest,
draws each cell's own styling, folds away rows a filter hides, and yanks a range as
tab-separated text. :help lists every key without leaving the document.
grind-sheet-gtk book.ods # or a .fods; with no file, an empty document
grind-text-gtk report.fodt # or a .odt; the word processorGTK 4 and libadwaita — a GNOME application, keyboard first. Two binaries rather than one with a mode, because that is how a desktop associates a file type with an application.
The spreadsheet window is the mature one: type in cells, type formulas in the A1 form you
already know (with point mode, autocomplete and a signature hint as you go), select, copy,
paste, undo, resize columns by dragging or double-clicking to fit, zoom with Ctrl and the wheel.
There is no ribbon: a header bar, one format bar over the selection, context menus, and
Ctrl+K for every other verb in the application. The word processor's window is deliberately
minimal — it opens, draws, moves by line, types, formats a selection, saves and undoes, with an
outline and a go-to box for p12 / #intro / §2.1.3.
grind-win32.exe book.ods # or a .fods
grind-win32.exe report.fodt # the word processor, same binary, same windowThe Windows client exists and is built out, planned in
doc/windows-shell.md, which is normative for it. Win32 and GDI directly,
through the windows crate — no toolkit, no runtime to install: the .exe links the C runtime
statically and imports nothing but DLLs Windows already ships, which CI checks by reading the
import table back off the binary. One binary holds both document types and picks the pane from
the file's own bytes, so opening a .fodt turns the grid into a document in the same window.
The spreadsheet pane is a grid with the document's own column widths and row heights, headers,
both scrollbars and the wheel, per-monitor DPI, and a theme read from the registry. It selects
(arrows, Ctrl+arrows, Home/End, PageUp/Down, Ctrl+A, drag, whole rows and columns from the
headers, F5 for a name box) and it edits: one editor serving as both the in-cell editor and
the formula bar, formulas typed in the A1 form you know and stored in ODF's, Delete, undo/redo,
F9, open/save/save-as, sheets added, renamed and deleted, and the system clipboard as
tab-separated text. Help while typing a formula is a band under the formula bar rather than a
popup — completion, the argument the caret is in, and a signature — and the formula bar can read
Sum(Number: B2:B7) where the file stores =SUM([.B2:.B7]), with Explain Formula and a browsable
list of all 110 functions beside it.
The text pane lays the document out with the same core the other shells use, and draws it with its
headings, bold, italic, underline, colour, highlight, lists and tabs. It has a real system caret,
a selection by keyboard, click and drag, typing, undo/redo and a clipboard; **bold** is read as
it is typed here too; Ctrl+B/I/U, a Format menu and a drawn format strip all reach the same core
call. It is also the first shell here that can turn a paragraph into a list item rather than
only draw one read from a file, and it has an outline dialog and go-to for p12 / #intro /
§2.1.3.
Where the GNOME window grows through Ctrl+K, this one grows through the menu bar — the platform's own surface — built from a table, so a verb in no menu fails a test and a verb with no handler fails the build, and the menus over a text document simply omit what a spreadsheet's verbs would say. Beside it a notice bar carries the state the document is in. There are context menus, a keyboard-shortcut list read out of the menus themselves, an icon and a version block Explorer reads, and the code view, the linter's findings and the view overlays that every other shell has.
What it does not have is written down rather than left to be discovered: no charts drawn, no
row auto-height, no point mode, no filter or find/replace UI, no recent-files list, no greying of
verbs that are unavailable right now, message boxes in the system's light colours under a dark
theme, and no text shaping — GDI measures and draws, which keeps the caret and the ink in one
engine at the cost of ligatures and the complex scripts. No installer, and therefore no registered
file associations. doc/windows-shell.md's "What it will not do" is the full list.
Everything here is developed on Linux, which is unusual and deliberate: the portable half — the
geometry, the key tables, the selection and editing models, the menus, the status bar and every
sentence the notice bar says — compiles and tests on any host, and cargo check --target x86_64-pc-windows-msvc type-checks the Windows source with no Windows at all. The shipped
executable is built and tested on windows-latest in CI and kept as an artifact; during
development it is linked with cargo-xwin and run under Wine, which is how most of its bugs were
found — every one of them was one glance at a screenshot and none was visible in review.
The macOS client has not been started. Nothing about it is written or scheduled.
The same core compiled to WebAssembly. No server, no upload, no account — the page is a static bundle and the document never leaves your machine, because there is nowhere to send it.
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version "$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock \
| sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)"
./ui_web/build.sh # writes ui_web/dist
python3 -m http.server --directory ui_web/dist 8000 # a module needs http, not file://To deploy it instead of serving it locally, that bundle is published as a container image, built
on every push to main for amd64 and arm64 alike. It is the same static files with
static-web-server's distroless image in front of them — no shell,
no package manager, and still nothing that stores a document (doc/web-shell.md's "Deployment"
section has the two stages):
docker pull ghcr.io/fwilhe2/grind-web:latest
docker run --rm -p 8080:80 ghcr.io/fwilhe2/grind-web:latest # http://localhost:8080/index.htmlPort, compression and the rest are static-web-server's own SERVER_* environment variables; the
image sets only SERVER_ROOT and SERVER_PORT. To build it yourself instead, the context must be
the repository root — grind-web depends on grind-core, grind-sheet and grind-text:
docker build -f ui_web/Dockerfile -t grind-web .
docker run --rm -p 8080:80 grind-webFor a deployment rather than a docker run, ui_web/deploy/ has a worked sample of each —
compose.yaml, and kubernetes.yaml with httproute.yaml (Gateway API) or ingress.yaml beside
it. All of them were run rather than drafted, and the comments in them are the measured parts: why
the port is 8080 and not 80, why /health needs turning on, why a distroless image can have a
Kubernetes probe but not a Compose health check, and what compression level costs in memory:
docker compose -f ui_web/deploy/compose.yaml up -d # http://localhost:8080
kubectl apply -f ui_web/deploy/kubernetes.yamlIt is a web page, not a window pretending to be one: one bar of verbs, one row of tools for
whichever document is open, and Ctrl+K for everything else — a searchable list of every
command, which doubles as the go-to box. Type B12, a sheet's name, a defined name or a heading
and it takes you there. Drop a file on the page to open it.
Both document types in one bundle. The spreadsheet has formatting, clipboard through the browser's own, the document's column widths and row heights, charts drawn as SVG, and sheets you can add, rename and delete. The word processor has a selection, character formatting, headings, its own pictures, and the outline in the palette.
Every front end can show you the document as plain text you can read — Ctrl+Shift+U in
either desktop window, :source in the terminal, Show the source in the browser,
grind sheet project on the command line:
grind spreadsheet
sheet Sales {
at A1 {
row Region Q1 Q2
row North 4200 4800
row South 3100 3300
}
cell B4 "=SUM([.B2:.B3])"
}That is not a preview. It is a third file format, .grind, beside the zipped .ods and the
flat .fods — the same document, spelled as text. Every front end opens one, grind convert
moves between all three, and it is bijective with the model: read it, edit it, write it back, and
nothing has moved. Edit one cell and one line of the file changes, with your comments and
your hand alignment still where you left them. A formula does not need its answer, so a model can
be written by hand without doing any of its arithmetic — grind sheet recalc fills the numbers
in afterwards.
Which makes two things possible that a binary spreadsheet cannot have. A change becomes
reviewable: a rate rising from £62 to £65 is one line of diff, and recalculating shows
exactly which seventeen cells moved. And a document becomes checkable in CI: grind lint
finds a cached total that disagrees with its own formula, a formula naming a deleted sheet, or
anything the plain-text form would not carry — and exits non-zero on an error.
The formula bar shows what a cell does, not what it stores:
=RATE(A1;-100;1000;0;0;0.05)
Interest Rate(Number Of Periods: A1; Payment: -100; Present Value: 1000; …)
Click the bar and it turns straight back into the real formula for editing. The file is never
touched, nothing is renamed, and the document stays exactly as compatible as it was. The ⓘ button
unfolds a nested formula one argument per line; grind sheet fmt --friendly does the same thing
in a terminal.
Ctrl+Shift+F lists every formula in the document, searchable by address, by formula text or
by function name, each one clicking through to its cell — including the arithmetic, because
=A1/2 is as much a calculation as =SUM(A1:A9) and it is usually the one you were looking for.
When the repetition gets tiring, a script writes the document instead of you:
$ grind build examples/timesheet.rhai -o month.fodsOne line per kind of cell rather than one per cell, out of a JSON file that somebody who has never read a line of code can edit. The language is small and deliberately caged: no filesystem beyond one data directory you name, no network, no clock, no randomness, and everything bounded, so a script that will not terminate is an error with a line number rather than a hang.
This is emphatically not macros. A generator is a source file that lives beside a document and runs when you type a command. Nothing that opens a document can evaluate anything — that is a rule with a test behind it, checked against every package in the workspace. The arrow points one way: a script produces a document and is never recovered from one.
Two guides, each built on a real example in this repository:
doc/projection-guide.md for writing a document by hand, and
doc/generator-guide.md for generating one, with
doc/editor-setup.md for editor support.
Writing changes as little XML as it can. Setting one cell in a 482-line LibreOffice file
changes one element and leaves every other byte alone, indentation included — so a .fods lives
in git the way a source file does, and opening a document just to look at it is not a commit. A
new file is thirteen lines rather than five hundred, because it carries only the boilerplate it
uses.
And that is the default, not a setting to hunt for: in doubt this suite writes the flat form,
because the property above is worth nothing if the file it applies to is a zip. Save dialogs lead
with .fods / .fodt, a new document is flat, and naming book.ods is how you ask for a
package. No document is ever converted behind your back.
Strictness on the way out, tolerance on the way in: everything written is valid ODF, and everything LibreOffice writes reads — unknown elements and attributes included, kept intact rather than dropped.
Correctness is not my opinion about it. Four checks run in CI, none of them grading their own homework:
- Every spreadsheet in LibreOffice's own test corpus loads — 359 of them, plus three it declines because they are password-protected. On the text side, 1755 Writer documents.
- Every formula in that corpus parses, prints back identically, and evaluates to the value LibreOffice cached for it, with a ratchet that can only go up.
- Whatever this writes, LibreOffice reads back unchanged — and the reverse.
- Formulas generated from the function catalogue are evaluated by both and compared, against a LibreOffice pinned by digest so the result means something.
Plus a set of documents that must always load, a set LibreOffice Writer wrote — in both forms of the same file, so the zipped reader and the flat reader are held to one answer — and a check that everything written validates against the OASIS RELAX NG schema. Those test documents are CC0: a fixture is data, and the point of one is that anybody can take it.
There are no releases yet. Build it, or take a CI artifact.
Install Rust via rustup rather than your distribution's package — this tracks current stable, which is what CI builds against. The GNOME windows also need GTK 4 and libadwaita development headers; nothing else does, and the Windows one needs nothing at all beyond the MSVC toolchain.
# Debian / Ubuntu
sudo apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev
# Fedora
sudo dnf install -y gtk4-devel libadwaita-develcargo install --path cli # the `grind` command line
cargo run -p grind-tui -- book.fods # the terminal UI
cargo run -p grind-sheet-gtk -- book.fods
cargo run -p grind-text-gtk -- report.fodtOn Windows, cargo run -p grind-win32 -- book.fods is the whole story — no headers, no
toolkit. From Linux the same crate can be type-checked, linted and half-tested without a Windows
machine anywhere:
rustup target add x86_64-pc-windows-msvc
cargo check -p grind-win32 --target x86_64-pc-windows-msvc # the Windows source, no Windows
cargo test -p grind-win32 # its portable half, on any host.deb and .rpm packages for all four Linux binaries are built on every push and kept as workflow
artifacts, as is a grind-win32.exe built and tested on Windows itself.
Two images are published to the GitHub container registry on every push to main — the command
line and the browser shell. Both are linux/amd64 and linux/arm64, built on a runner of each
architecture rather than under emulation, so latest is a manifest over the two and a pull picks
the right one:
docker pull ghcr.io/fwilhe2/grind:latest # the command line, on a distroless base
docker pull ghcr.io/fwilhe2/grind-web:latest # the browser shell and a static file serverThe CLI image is the grind binary and nothing else — no shell, so docker exec … sh will not
work and every invocation names the binary at the image's root. Documents come in as a mounted
directory, since the container has no filesystem of its own worth writing to:
docker run --rm -v "$PWD:/work" -w /work ghcr.io/fwilhe2/grind:latest /grind info book.fods
docker run --rm -v "$PWD:/work" -w /work ghcr.io/fwilhe2/grind:latest \
/grind sheet set book.fods A1 '=SUM([.B1:.B9])'Two things about that mount, neither of them specific to this image. On an SELinux host (Fedora,
RHEL) it needs :z — -v "$PWD:/work:z" — under docker exactly as much as under podman;
without it the container sees the directory and every read is a permission error. And a document
the container writes is owned by root unless you say otherwise, so:
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/work:z" -w /work \
ghcr.io/fwilhe2/grind:latest /grind sheet new book.fodsThe web image serves the bundle on port 80 and holds no state, so it scales by running more of them and needs no volume at all:
docker run --rm -p 8080:80 ghcr.io/fwilhe2/grind-web:latest # http://localhost:8080/index.htmlui_web/deploy/ deploys that image properly rather than by hand — compose.yaml,
kubernetes.yaml, and httproute.yaml or ingress.yaml to reach it from outside a cluster —
unprivileged, read-only and health-checked, with every setting's reason in a comment beside it
(doc/web-shell.md, "The two samples"). The Gateway API is the default of the two routing files
and the retirement of ingress-nginx is why; the Ingress API itself is frozen rather than
deprecated, so that file is kept and written in no controller's dialect. cli/tests/deploy.rs
holds all of them to the image container.yml actually publishes, the way cli/tests/packaging.rs
holds the workflow to the binaries.
Both are small, and in both the base image is the larger half: about 13 MB to pull the command
line, of which 3.6 MB is grind itself, and about 5 MB for the browser shell, of which 0.8 MB is
the page and its WebAssembly. Those numbers are measured on every build rather than claimed —
.github/scripts/image-size.sh writes them into the workflow's job summary, once for the image as
built (so a pull request sees a size change before it is merged) and again for the pushed
manifest, per architecture, where the figure is what a docker pull actually transfers.
The CLI image is what makes a document a build artifact, and
.github/workflows/documents.yml is the worked example: a GitHub
runner with nothing installed but Docker creates, edits, formats, converts and checks both document
types, generates a third from a JSON file, and uploads what it built. It is also where the two things
that catch everybody are written down — the image is distroless, so jobs.<id>.container: cannot be
used (Actions runs each step with a shell inside the container and there is none), and a document
the container writes is root-owned unless the run says -u. It runs weekly as well as on demand, so
a rebuild that broke any of that is caught here rather than by somebody's pipeline.
To see what a build can actually do, examples/sample-sheet.sh and examples/sample-text.sh
build a document out of every feature it has, through the command line and nothing else:
cargo build
GRIND=target/debug/grind examples/sample-sheet.sh /tmp/demo
cargo run -p grind-sheet-gtk -- /tmp/demo/sample.fodscargo test # everything but the GTK windows| Document | What |
|---|---|
doc/not-doing.md |
The feature line — never, not yet, and where what exists stops |
doc/cli-recipes-sheet.md |
Worked command-line scripts |
doc/projection-guide.md |
Writing a spreadsheet by hand, as plain text that reviews like code |
doc/generator-guide.md |
Generating one from a script, and from data nobody has to be a programmer to edit |
doc/editor-setup.md |
VS Code for both, and what each extension actually does |
doc/small-group.md |
The 110 functions, and where each is defined |
CONTRIBUTING.md |
The clean-room rule, and how to work on this |
The rest of doc/ is the design record — why each piece is shaped the way it is, written for
whoever works on the code. doc/plan.md is the map of it.
AGPL-3.0-or-later. Full text in LICENSES/.
If you modify this and offer it to others over a network, §13 requires you to publish your source. That is deliberate: a spreadsheet core is exactly the thing someone embeds in a hosted service, and plain GPL would ask nothing of them.
The repository is REUSE compliant — every file carries its copyright
and license, machine-readably (reuse lint).
The two OASIS specifications under doc/ are not AGPL and not open source. They are
redistributed verbatim under the OASIS IPR Policy, which permits copying but forbids modification
of any kind — including adding an SPDX header. They are marked with .license sidecar files for
that reason; do not annotate them in place.
LibreOffice is a registered trademark of The Document Foundation. OpenDocument and ODF are trademarks of OASIS. GNOME is a trademark of the GNOME Foundation.
This project is not affiliated with, endorsed by, or sponsored by any of them. LibreOffice is named here only to describe what this software does — which documents it reads, which implementation it is tested against, and how the two differ — and every such use is descriptive, never a claim of origin. No trademark is used in the name of this project, its binaries, its icon, or its packaging.