- Status: Accepted
- Date: 2026-01-11
Today we have three overlapping “engine” surfaces that are close enough to look interchangeable, but different enough to drift:
- Desktop (Tauri) workbook backend: row/col +
sheetIdcommands (e.g.get_range,set_cell) and returnsdisplay_valuestrings.- TypeScript client:
apps/desktop/src/tauri/workbookBackend.ts - Sync glue from
DocumentController→ Tauri:apps/desktop/src/tauri/workbookSync.ts
- TypeScript client:
- Web/WASM Worker engine: A1 address + “sheet” RPC (currently treated as a sheet name) returning scalar JSON (
CellScalar).- TS package:
packages/engine/(EngineClient,EngineWorker,protocol.ts)
- TS package:
- Formula text normalization drift:
formula-modelstores formulas without a leading'=', while UI/editor workflows generally use the display form with'='.- Canonical helpers:
crates/formula-model/src/formula_text.rs
- Canonical helpers:
Without a single source of truth, we end up duplicating conversions (row/col ↔ A1, sheetId ↔ sheet name, with/without '=') in multiple places and create subtle incompatibilities (especially around renames and round-trips).
At the boundary where JavaScript calls into “the engine” (Worker/WASM or a host adapter), cell and range addresses are represented as A1 strings:
address: "B3"range: "A1:C10"(inclusive range, Excel semantics)
Row/col coordinates remain the UI/internal representation and are converted at the boundary using shared helpers:
@formula/spreadsheet-frontend/a1(toA1,fromA1,range0ToA1)
Why A1
- Matches Excel’s user-visible reference style and the syntax embedded inside formulas.
- Avoids 0-based/1-based ambiguity leaking across protocol boundaries.
- Keeps the Worker RPC human-readable for debugging/telemetry.
- Aligns with the existing
@formula/engineAPI and shared web preview (packages/spreadsheet-frontend).
Normalization rule
- Engine RPC accepts A1 addresses/ranges with optional
$markers (e.g.$A$1), but call sites should prefer canonical non-absolute forms (A1,A1:B2) when addressing cells/ranges out-of-band from formula text.
We explicitly model two concepts:
sheetId: stable, opaque identifier used by the document model / collaboration / UI routing.sheetName: user-visible worksheet name (Excel semantics) used in:- formula text (
'My Sheet'!A1) - file formats (XLSX workbook sheet names)
- formula text (
Normalization rules
sheetId:- treated as opaque and case-sensitive
- must be unique within a workbook
- does not change on rename
sheetName:- trimmed for display (
name.trim()) - unique case-insensitively within a workbook (Excel behavior)
- when embedded in formulas, follows Excel quoting rules:
- quote with
'when needed - escape internal
'by doubling (O'Brien→'O''Brien')
- quote with
- trimmed for display (
Engine protocol rule (target state)
All engine RPC methods that take a sheet identifier take a sheetId (even if the field is currently named sheet in TS types). Engines must maintain a registry mapping:
sheetId -> sheetName
This registry is what the formula parser/evaluator uses to resolve SheetName!A1 references, and what the serializer uses when emitting formula text.
Important: current implementation constraint
The current WASM engine (crates/formula-wasm, backed by crates/formula-engine) is sheet-name keyed and does not yet implement a sheetId -> sheetName registry. Until that exists, web and desktop parity assumes sheetId === sheetName for any sheet that participates in cross-sheet formulas.
This is acceptable for the initial iteration because:
- imported workbooks currently derive ids from names (
apps/desktop/src-tauri/src/file_io.rs::ensure_sheet_ids), and - the web preview uses
Sheet1,Sheet2, … as both id and name.
Renaming sheets without rebuilding the engine state is explicitly deferred (see Non-goals).
We standardize formula text as follows:
| Layer | Stored form | Example |
|---|---|---|
| UI/editor + engine inputs | display form (leading '=') |
=SUM(A1:A3) |
formula-model (Rust) |
canonical form (no leading '=') |
SUM(A1:A3) |
XLSX SpreadsheetML <f> |
no leading '=' |
<f>SUM(A1:A3)</f> |
Where conversions happen
- Model boundary (Rust): use the canonical helpers:
formula_model::formula_text::normalize_formula_text(strip'=', trim, empty →None)formula_model::formula_text::display_formula_text(ensure leading'='for UI/engine)
- Desktop file I/O:
- On load, formulas from
formula-modelare converted to display form when building the in-memory app workbook (apps/desktop/src-tauri/src/file_io.rs). - On save, formulas must be converted back to canonical/no-
'='before writing<f>parts (owned byformula-xlsx+formula-model).
- On load, formulas from
- JS → engine adapters:
apps/desktop/src/tauri/workbookSync.tsandpackages/engine/src/documentControllerSync.tscurrently ensure a leading'='before calling an engine/backend.- Parity plan is to dedupe this logic so every platform applies exactly the same normalization.
Goal: shared UI code should talk to one engine-shaped API, independent of platform.
Chosen strategy: adapter layer, not mass call-site rewrites
- Keep
@formula/engine(packages/engine) as the canonical JS engine API (A1 + sheet selector + scalar JSON). - Add a desktop adapter that implements the same API on top of Tauri:
- Converts A1/range → row/col rectangles for
get_range/set_range. - Converts
sheetId→ backendsheet_idand uses workbook metadata for mapping when needed. - Bridges return types (see note below on display formatting).
- Converts A1/range → row/col rectangles for
Concrete touchpoints:
- Canonical API:
packages/engine/src/client.ts(EngineClient) - Desktop transport today:
apps/desktop/src/tauri/workbookBackend.ts(Tauriinvokecalls)apps/desktop/src/tauri/workbookSync.ts(DocumentController delta batching)
- Web transport today:
packages/engine/src/worker/*+crates/formula-wasm/
Note on display formatting parity
Desktop currently returns display_value strings from the host (apps/desktop/src-tauri/src/commands.rs), while the Worker/WASM engine returns typed scalar JSON.
The canonical protocol is typed scalar values; formatting into display strings is a separate concern (eventually shared via formula-format / WASM). In the first iteration the desktop adapter may temporarily surface formatted values as strings to preserve existing UI behavior, but new shared UI code should avoid baking in host-only display_value.
- Full XLSX OPC part preservation in the web target. The WASM engine can load
.xlsx/.xlsmbytes viacrates/formula-wasm::fromXlsxBytes, but it only imports the workbook model (values/formulas/basic metadata) and does not preserve arbitrary OPC parts on round-trip. - VBA execution / macro enablement in web.
- Chart/pivot fidelity parity between web and desktop engines.
- Sheet rename parity without engine rebuild (requires a sheet registry + formula rewrite plumbing).
- Introduce/standardize an engine adapter boundary:
- Desktop: wrap
apps/desktop/src/tauri/workbookBackend.tsbehind anEngineClient-shaped adapter that speaks A1. - Web: continue using
createEngineClient()frompackages/engine.
- Desktop: wrap
- Unify DocumentController → engine syncing:
- Consolidate the duplicated “delta batching + A1 conversion + formula normalization” logic from:
apps/desktop/src/tauri/workbookSync.tspackages/engine/src/documentControllerSync.ts
- into a single shared helper (location TBD) so both platforms produce identical engine inputs.
- Consolidate the duplicated “delta batching + A1 conversion + formula normalization” logic from:
- Make sheet identity explicit in the protocol:
- Rename TS fields from
sheet→sheetIdinpackages/engine/src/protocol.ts(and downstream types) once adapters exist. - Add a
sheet registrysync step so WASM can decouplesheetIdandsheetNamewithout requiring equality.
- Rename TS fields from
- Centralize formula text conversions:
- Replace ad-hoc JS
normalizeFormulaText()helpers with a shared implementation that matchescrates/formula-model/src/formula_text.rssemantics (including edge cases like bare"=").
- Replace ad-hoc JS