@betteroffice/xlsx
v0.3.0
Published
Excel-faithful XLSX spreadsheet editor and viewer core — full OOXML fidelity, real-time human + AI agent collaboration. Framework-free (Rust/Wasm).
Maintainers
Readme
@betteroffice/xlsx
Framework-free core for the BetterOffice XLSX editor — the Rust engine (parse, calc, render) compiled to WebAssembly, plus display-list, viewport, hit-test, and accessibility helpers.
bun add @betteroffice/xlsxMost apps want the turnkey React component in
@betteroffice/xlsx-react.
Reach for this package directly to render a spreadsheet onto your own canvas or
to drive a workbook headlessly.
Open, render, edit, save
import { initWasm, openWorkbook, paintDisplayList } from "@betteroffice/xlsx";
await initWasm();
const workbook = openWorkbook(new Uint8Array(await file.arrayBuffer()));
const ctx = canvas.getContext("2d")!;
const dpr = devicePixelRatio;
canvas.width = 800 * dpr;
canvas.height = 600 * dpr;
const frame = workbook.displayList({ x: 0, y: 0, width: 800, height: 600 });
paintDisplayList(ctx, frame, dpr);
workbook.editCell(0, 9, 2, "=SUM(C1:C9)"); // recalcs dependents
const bytes = workbook.save();initWasm() fetches the packaged wasm asset once in browsers; other runtimes
pass wasm bytes or a precompiled WebAssembly.Module.
Around the handle, the package exports the helpers a custom grid needs:
cellAtPoint / cellRect / rangeRect (hit-testing), the viewport math,
buildA11yGrid (accessibility tree), and toTsv / fromTsv (clipboard). The
handle itself covers styling (patchRangeStyle, setNumberFormat), undo/redo,
and PNG export (renderPng / renderRangePng; guard with
isPngExportAvailable).
Print a range
workbook.printDisplayList(sheet, range, metrics, gridlines) renders a range
without changing workbook data, the active sheet, or screen geometry. Pass
PrintMetrics measured from the workbook's normal font: dpi, maxDigitWidth,
fontAscent, and fontDescent use the layout device's pixels; fontSizePt and
defaultRowHeightPt use points. fontFamily supplies the default face, and
optional defaultColumnWidth uses the stored OOXML character width.
The result uses 96-DPI logical coordinates. Paint it with paintDisplayList(ctx,
frame, scale, { x, y }); the optional origin uses backing-store pixels, so page
margins need no intermediate image. Cell ranges, paper size, and pagination are
chosen by the caller. See the Office comparison harness.
AI agents / human-in-the-loop
An agent stages edits as a proposal instead of applying them; a human reviews
per-cell before/after previews and accepts or rejects. Accepting applies the
proposal as one undo step and recalcs dependents, and throws
StaleProposalError if the workbook drifted under the proposal since it was
staged.
import { isProposalsAvailable } from "@betteroffice/xlsx";
if (isProposalsAvailable()) {
const proposal = workbook.propose("copilot", "add totals", [
{ sheet: 0, row: 9, col: 2, input: "=SUM(C1:C9)" },
]);
workbook.listProposals(); // pending proposals, oldest first
workbook.acceptProposal(proposal.id); // or workbook.rejectProposal(proposal.id)
}The React editor paints pending proposals as in-cell tracked-change ghosts with
an accept/reject panel. isProposalsAvailable() reports proposal availability.
StaleProposalError.targets names each drifted cell's sheet beside cells.
Version-checked edit batches
Read cells with the version they were read at, then apply a batch against it. Every step commits as one recalculated change and one undo step, or the batch returns a typed refusal and nothing changes:
const read = workbook.readCells({
ranges: [{ sheetId: "sheet:0", range: { kind: "a1", a1: "B3" } }],
});
if (!read.ok) throw new Error(read.failure.message);
const result = workbook.applyEdits({
expectVersion: read.version,
steps: [
{
op: "setCellInputs",
target: { sheetId: "sheet:0", range: { kind: "a1", a1: "B3" } },
inputs: [["120"]],
expect: { cells: [[{ value: read.ranges[0].cells[0][0].value }]] },
},
{ op: "setNumberFormat", target: { sheetId: "sheet:0", range: { kind: "a1", a1: "B3" } }, format: "currency" },
],
});
if (!result.ok) console.warn(result.failure.code); // e.g. "stale-version"- Steps:
setCellInputs(parsed like typing, against each cell's current number format),setFormulas(source without=, stored as formulas whatever the format),setNumberFormatandpatchStyle. Content and formatting may combine on the same cells; writing one property twice refuses withoverlapping-steps. - Targets name a sheet id from the current catalog (
sheet:{index}standalone, the replica's sheet keys in collaboration) and an A1 range or zero-based corners. Matrices and guards match the target's shape exactly. Guards compare a cell's value, formula (nullfor none) or display text before the batch. validateEditsstages a batch without changing anything;findTextsearches display text exactly and case-sensitively. Update listeners run once, beforeapplyEditsreturns, and see the recalculated state and its new version.changedSheetsnames every sheet the batch or its recalculation changed.- Requests over 16 MiB and results over 64 MiB refuse with
limit-exceeded; calculation diagnostics stop at 10,000 cells per list and settruncated. history: "none"keeps a batch out of undo; standalone undo still replays older steps over its cells.sourcerecords provenance only. Volatile functions see onlycalculation.nowSerial.- Versions and sheet ids are session-scoped; standalone sheet ids are positional, so each is valid only for the version it was read at.
Structured export
XLSX exports bounded sparse worksheet content and Markdown with positional anchors, formulas, stored values, formatted text, explicit hidden-content options, and omission diagnostics. Export reads stored values:
import { exportXlsxStructured } from "@betteroffice/xlsx";
const read = workbook.exportStructured({ scope: [{ sheet: 0, range: "A1:D20" }] });
if (read.ok) console.log(read.content.sheets[0].cells);
const markdown = workbook.exportMarkdown({}, { maxRows: 100 });
const fromBytes = await exportXlsxStructured(bytes); // no session, no clock- Each sheet lists its stored cells in row-major order (formula cells and styled empty cells included, empty positions skipped) with value, formula, display text, number format and merge membership, plus merges, tables, hyperlinks, hidden row and column spans, and charts, pictures and shapes as placeholders with alt text. Defined names are listed read-only.
- Anchors are
{ sheet: { sheetId, index, name }, a1 }positions in the exported version (anchorScope: "session") or bytes snapshot ("snapshot"); neither follows later row, column or sheet edits.sheetIdis the id edit batches take in that session (sheet:{index}for bytes), so a cell or range anchor's{ sheetId: anchor.sheet.sheetId, range: { kind: "a1", a1: anchor.a1 } }is its batch target at the exported version. Retained drawings carry asourcePartprovenance with the part's SHA-256. - Formula results are the stored values (
calculation.policy: "asStored",freshness: "unverified"); cells mark results the file did not store asmissing(uncertainonce anything has calculated, or where it cannot be traced) and the last calculation'scycleandlimitedcells. Live exports reflect whatever calculation already ran, and the same version and options always export the same content;exportXlsxStructuredreads bytes without recalculating. - Hidden sheets, rows, columns and names are excluded unless requested
(
includeHiddenSheets,includeHiddenRows,includeHiddenColumns,includeHiddenNames), with ahidden-content-excludeddiagnostic. A sheet whose visibility is unknown, such as one from a model handed in without its package, counts as hidden. maxCells(default 100,000) andmaxBytes(default 8 MiB) stop at a complete record withtruncated: trueand atruncateddiagnostic; an absent cell is empty only before that point. Scope refusals (invalid-scope,invalid-options,limit-exceeded) return{ ok: false, version, failure }.- Markdown renders one grid per sheet labelled with its A1 columns and row
numbers (200 rows, 50 columns and 10,000 positions by default), using an
escaped HTML table where merges need spans, and
<!-- xlsx-export:N -->markers whose anchors come back inanchors. Document text is escaped and kept on one line, and hyperlinks become Markdown links only forhttp,httpsandmailtodestinations.renderXlsxMarkdownrenders content you already hold, refusing content that does not validate.
Collaboration
Open a collaborative replica, then connect it to any reliable binary transport:
import { initWasm, openWorkbook } from "@betteroffice/xlsx";
import {
CollaborationProvider,
type CollaborationTransport,
} from "@betteroffice/xlsx/collaboration";
await initWasm();
const workbook = openWorkbook(bytes, { collaborative: true });
const transport: CollaborationTransport = createTransport();
const provider = new CollaborationProvider(workbook, transport, {
user: { name: "Ada" }, // identity for this peer's selection flag
});
provider.connect();
function dispose() {
provider.destroy();
workbook.dispose();
}The provider handles Yjs sync-v1 and awareness. Room routing, authentication,
WebRTC signaling, and reconnection remain transport concerns; document updates
flow directly into the Rust/WASM Yrs replica.
After a close, the transport may reopen itself or the caller may invoke
provider.connect() for another connection attempt.
Call provider.destroy() before discarding its transport or workbook.
Collaborative sessions synchronize cell content, formulas, styles, column widths, and row heights, with Yrs-backed undo and redo. Structural operations throw.
Development
The generated .wasm binary is intentionally not committed. From the repository
root, install wasm-pack 0.15.0 and binaryen, then run
bun run build:xlsx-wasm. Package builds, tests, demo startup, and CI run this
step automatically.
JavaScript guide · Changelog · Apache-2.0.
Operation profiles
editCellProfiled, applyOpsProfiled, and displayListProfiled perform the same
operations as their unprofiled counterparts and return engine stage durations
in milliseconds. Edit results include profile (validate, apply, recalc, result);
display results contain { displayList, profile } (build, encode). The normal
entry points do not read profiling clocks. See the
corpus and browser tests for usage and timing limits.
