JavaScript
Install React editors, framework-free cores, and locale strings from npm
DOCX, XLSX, and PPTX each provide a framework-free core, a React editor, and
locale strings under the @betteroffice scope.
npm install @betteroffice/docx-react @betteroffice/docx react react-domreact and react-dom (18 or 19) are peer dependencies of every -react
package.
Add a React editor
import { DocxEditor } from "@betteroffice/docx-react";
import "@betteroffice/docx-react/styles.css";
<DocxEditor documentBuffer={bytes} onSave={(saved) => upload(saved)} />;documentBuffer accepts an ArrayBuffer, Uint8Array, Blob, or File.
Without onSave, File > Save downloads the edited bytes. Configure
fonts before mounting the DOCX editor for consistent text
measurement and pagination.
The spreadsheet and presentation editors take a Uint8Array:
import { XlsxEditor } from "@betteroffice/xlsx-react";
import { PptxEditor } from "@betteroffice/pptx-react";
<XlsxEditor file={bytes} fileName="report.xlsx" />;
<PptxEditor file={bytes} fonts={[{ family: "Inter", bytes: fontBytes }]} />;PptxEditor requires at least one font face in fonts before it can lay out text.
Compose the DOCX toolbar
The DOCX editor's built-in controls share one command store, ref.commands.
Pass toolbar to replace the default chrome with public parts in your own order,
next to your own actions:
import {
DocxEditor,
EditorToolbar,
ToolbarButton,
ToolbarCommandButton,
ToolbarCommandSelect,
ToolbarGroup,
} from "@betteroffice/docx-react";
<DocxEditor
documentBuffer={bytes}
toolbar={
<EditorToolbar>
<EditorToolbar.Toolbar>
<ToolbarGroup label="Formatting">
<ToolbarCommandSelect id="paragraphStyle" />
<ToolbarCommandButton id="bold" />
</ToolbarGroup>
<ToolbarCommandButton id="undo" />
<EditorToolbar.Review />
<ToolbarButton title="Share" onClick={share}>Share</ToolbarButton>
</EditorToolbar.Toolbar>
</EditorToolbar>
}
/>;Omit toolbar for the default chrome, pass null for none, or render the same
parts outside the editor inside <DocxCommandProvider commands={commands}>, with
commands captured from the ref (it may be null until the editor mounts).
Controls keep their active and 'mixed' states, shortcuts, and read-only,
viewing and suggesting restrictions. A disabled command always states why
(disabledReason.code and a localized message), and execute(id, args) checks
availability again after preceding input, resolving to { ok: true, status }
or { ok: false, failure }. Dialogs a command opens apply to the
document and selection they opened with. At narrow widths, groups move into an
accessible More menu that keeps every built-in choice; wrap other host content
in ToolbarOverflow to give it an entry there.
Host DOCX editor plugins
Host-owned tools install through the plugins prop. A plugin created with
defineDocxPlugin can contribute a docked panel, an overlay, sidebar cards and
commands; it reads and edits through restricted clients, never the session. The
plugin API is experimental and may change in minor releases:
import { DocxEditor, defineDocxPlugin } from "@betteroffice/docx-react";
const review = defineDocxPlugin<{ count: number }>({
id: "acme.review",
createState: () => ({ count: 0 }),
async onEvent(context, event) {
if (event.type !== "load" && event.type !== "document-change") return;
const read = await context.read.readParagraphs({ view: "accepted" });
if (read.ok) context.setState({ count: read.paragraphs.length }, read.version);
},
panel: {
title: "Review",
placement: "right",
render: ({ context }) => <p>{context.state.count} paragraphs</p>,
},
commands: [
{
id: "mark",
label: "Mark reviewed",
mutatesDocument: true,
async execute(context) {
if (!context.edits) return { ok: false, failure: { code: "not-granted", message: "Needs write access" } };
const read = await context.read.readParagraphs({ view: "accepted" });
if (!read.ok) return read;
const first = read.paragraphs[0];
const result = await context.edits.applyEdits({
expectVersion: read.version,
steps: [{ op: "insertText", target: { kind: "paragraph", story: "body", paraId: first.paraId }, at: "end", text: " ✓" }],
});
return result.ok ? { ok: true, status: "executed" } : result;
},
},
],
toolbar: ["mark"],
});
<DocxEditor
documentBuffer={bytes}
plugins={[review]}
pluginGrants={{ "acme.review": { document: "write", editBatches: true } }}
onPluginError={(error) => console.error(error.pluginId, error.phase, error.error)}
/>;Lifecycle. When a document is ready each plugin gets fresh state, runs
initialize, receives one load event (loaded, replaced or attached), and
then its contributions appear; a document change the hook did not make itself
aborts it and delivers load again, and after ten such runs the plugin is
stopped and reported. document-change carries only the committed version, for
typing, remote edits, undo, commands and batches (a plugin's own included), and
never for a refusal or no-op; selection-change, mode-change (with the
effective readOnly), layout-change, proposal-change (host proposals or
their preview decisions changed, with previewVersion) and grants-change
follow. Notifications
describe current state, so several changes can arrive as one, and a newer one
aborts the hook still handling the last (context.signal), unless that hook's
own edit batch made it. Replacing the document,
removing the plugin, changing its revision, unmounting or a failure ends the
activation: signals abort, clients refuse, and each onCleanup disposer runs
once with the reason. Plugins are matched by id and revision, so new array
identities and reordering keep their state; React StrictMode's double setup
creates two distinct activations.
Stale results. setState(next, atVersion) returns false for a superseded
or ended context or when the document is no longer at atVersion, which
defaults to the version the context was created at. Use context.run(action) in
event handlers: it supplies a fresh context and isolates failures.
Grants. Without a grant a plugin can read, validate and navigate only.
Built-in commands need their id in commands, and mutating ones also
document: "write"; applyEdits needs document: "write" and editBatches,
and history: "none" also needs untrackedHistory. Grants, the editor mode and
document policy are checked again immediately before each change, so revoking a
grant, switching to viewing mode, readOnly, or replacing the document refuses
through clients obtained earlier too. Mutating built-in commands refuse plugins
with unsupported-policy until they have an authoritative lock policy; edit
batches refuse locked content in Rust. In suggesting mode each batch step needs
suggest.
Commands and toolbar. Contributed commands register as
plugin:<pluginId>/<id> on ref.commands and always run with their own
plugin's clients, whether the default toolbar, a shortcut or the host invokes
them. A command's failure uses the plugin's own code, and a refused edit batch
returned as-is reaches the caller unchanged. Plugin shortcuts must use Mod or
Alt, or a function key; built-in and clipboard shortcuts take precedence, and a
clashing plugin shortcut is reported and not bound. Keys
pressed in plugin chrome are the user's: built-in shortcuts act on the editor
there, and a plugin's text fields keep their own editing keys. Replacement chrome
shows toolbar contributions with
<DocxPluginToolbar />, and ToolbarCommandButton, ToolbarCommand,
useDocxCommand and useDocxCommandState accept contributed ids.
Coordinates. context.geometry exists only while a rendered layout shows
the current version. An overlay may render during a pending layout with that and
context.snapshot.layout null, its geometry prop then being the previous
layout's. geometry.dom (a RenderedDomContext) answers in
pages-container units divided by zoom, and geometry.toOverlayRect(rect)
converts such a rectangle into pixels of the unscaled overlay layer, or returns
null once that layout is no longer rendered. geometry.getPositionAtPoint
hit-tests client coordinates like the caret and returns the layout's version and
layoutId with a collapsed accepted-view target for edit batch steps, or null
likewise, while input is pending, and until the pages show that layout.
geometry.getAnchorGeometry(target) resolves a proposal, revision, paragraph,
search match or text range to overlay-layer pixels: every visible fragment with
its zero-based pageIndex, an anchor collapsed at the end of the last one (at
the boundary of a target the preview hides, else at its paragraph) and the
anchor's pageRect. It refuses with a typed failure rather than answer from a
stale or unpainted layout, and layout-change repeats once the pages have painted
a layout that arrived before its pixels; layout.previewVersion is the proposal
preview the pixels show.
geometry.dom is experimental
and may be replaced by a data-only facade. Overlays
ignore the pointer unless an element sets pointer-events: auto. Sidebar cards
anchor to { version, story, paraId } and appear only while that version is
current and the body paragraph is unique; a card's render receives its item,
so one component can draw every card and keep its state while that id stays.
navigation.scrollToParagraph
flushes input, waits briefly for a matching layout, keeps focus unless asked, and
reports layout-unavailable or unsupported instead of success when it cannot
scroll. In a narrow editor, side docks show tabs that open panels as drawers.
Failures. Each contribution renders behind its own error boundary and with
a command context restricted to its plugin. A throwing hook, renderer, sidebar
or command-state function, command or action stops only that plugin, runs its
cleanups and reaches onPluginError; other plugins and the editor continue.
Plugins share the page's JavaScript realm, so grants govern the supported API
rather than sandboxing code.
Migrating. pluginOverlays, pluginSidebarItems and
pluginRenderedDomContext remain as deprecated, unmanaged inputs;
onRenderedDomContextReady receives the editor's own context. The
snapshot-based EditorPluginCore contract in @betteroffice/docx/plugin-api
is deprecated; its geometry types remain.
Compose the PPTX toolbar
The PPTX editor's controls share one command store, api.commands, handed to
onReady. Pass toolbar to replace the default controls with public parts in
your own order, next to your own actions:
import {
EditorToolbar,
PptxEditor,
ToolbarButton,
ToolbarCommandButton,
ToolbarCommandSelect,
ToolbarGroup,
} from "@betteroffice/pptx-react";
<PptxEditor
file={bytes}
fonts={fonts}
toolbar={
<EditorToolbar mode="commands">
<EditorToolbar.Toolbar>
<ToolbarGroup label="Text">
<ToolbarCommandSelect id="fontFamily" />
<ToolbarCommandButton id="bold" />
</ToolbarGroup>
<ToolbarCommandButton id="undo" />
<ToolbarCommandButton id="slideshow" />
<ToolbarButton title="Share" onClick={share}>Share</ToolbarButton>
</EditorToolbar.Toolbar>
</EditorToolbar>
}
/>;Omit toolbar for the default controls or pass null for none;
showToolbar={false} hides the whole region. Supplied chrome also renders while
readOnly. Outside the editor, render the same parts inside
<PptxCommandProvider commands={commands}>, with commands captured from
onReady (it may be null until then). A disabled command always states why
(disabledReason.code and a localized message); mixed selections report
'mixed'. execute(id, args) runs after input accepted before it, such as a
picture still decoding, checks availability again, and resolves to
{ ok: true, status } or { ok: false, failure }. Commands enforce read-only
mode for the UI; api.handle stays direct host access. The prop-based
EditorToolbar and Toolbar remain supported but deprecated: without mode
they bind to their props, and useEditorToolbar() inside a command-mode toolbar
returns a view whose callbacks run the commands. At narrow widths, groups move
into an accessible More menu; wrap other host content in ToolbarOverflow to
give it an entry there. Keystrokes typed while earlier work is pending keep
their place through handle.anchorCaret() and handle.resolveCaretAnchor(),
caret anchors that later edits, undo and remote updates move along; hosts can
use them too.
Host PPTX editor plugins
PptxEditor installs host-owned tools through the same plugins,
pluginGrants and onPluginError props as the DOCX editor. A plugin created
with definePptxPlugin contributes a docked panel, an overlay on the slide and
commands, and works through restricted clients. The plugin API is experimental
and may change in minor releases:
import { PptxEditor, definePptxPlugin } from "@betteroffice/pptx-react";
const review = definePptxPlugin<{ slides: number }>({
id: "acme.review",
createState: () => ({ slides: 0 }),
async onEvent(context, event) {
if (event.type !== "load" && event.type !== "document-change") return;
const read = await context.read.readContent();
if (read.ok) context.setState({ slides: read.slides.length }, read.version);
},
panel: {
title: "Review",
placement: "right",
render: ({ context }) => <p>{context.state.slides} slides</p>,
},
commands: [
{
id: "mark",
label: "Mark reviewed",
mutatesDocument: true,
async execute(context) {
if (!context.edits) return { ok: false, failure: { code: "not-granted", message: "Needs write access" } };
const read = await context.read.readContent();
if (!read.ok) return read;
const slideId = context.snapshot.selection?.slideId;
if (!slideId) return { ok: true, status: "noop" };
const result = await context.edits.applyEdits({
expectVersion: read.version,
steps: [{ op: "setSlideNotes", target: { slideId }, text: "Reviewed" }],
});
return result.ok ? { ok: true, status: "executed" } : result;
},
},
],
toolbar: ["mark"],
});
<PptxEditor
file={bytes}
fonts={fonts}
plugins={[review]}
pluginGrants={{ "acme.review": { document: "write", editBatches: true } }}
onPluginError={(error) => console.error(error.pluginId, error.phase, error.error)}
/>;Lifecycle and grants follow the DOCX editor: fresh state, initialize and
one load event per presentation (repeated when a change the hook did not make
arrives first, and stopped after ten runs), then coalesced document-change,
selection-change, mode-change (with readOnly), layout-change and
grants-change events. document-change comes from the presentation's
committed versions, so typing, commands, batches (a plugin's own included), undo,
redo and remote updates each produce one, and refusals or no-ops none. Replacing the presentation,
removing the plugin, changing its revision, unmounting or a failure ends the
activation and runs its cleanups once. Without a grant a plugin reads
(read.version, readContent, findText, validateEdits) and navigates;
built-in commands need an allowlisted id, mutating ones refuse with
unsupported-policy, and applyEdits needs document: "write" and
editBatches. Grants and readOnly are checked again right before each change.
Each client call waits for pending input in order with it; plugin handlers run
outside that queue. A contributed command's failure uses the plugin's own code,
and a refused edit batch returned as-is reaches the caller unchanged.
Selection and navigation. snapshot.selection names the current slide
(session slideId and one-based slide) and a slide, shape or text target;
text keeps its anchor/focus direction. navigation.goToSlide,
selectShape and selectText take the version the target was read at, keep
keyboard focus unless focus: true, and refuse with stale-version,
missing-target, layout-unavailable or unsupported rather than retarget a
changed deck. Slide, shape and story ids are session-scoped.
Coordinates. context.geometry exists only while the canvas shows a
painted frame of the current version, and not during proposal review.
layout.width/height are the unzoomed slide in display-list pixels and
layout.zoom the resolved scale. geometry.toOverlayRect({ space, rect })
converts slide-emu (9,525 EMU per pixel) or slide-px rectangles into pixels
of the unscaled overlay layer on the slide, getShapeRect(shapeId) returns a
shape's rendered bounds including group descendants, and getPositionAtPoint
hit-tests client coordinates with version and layoutId. The layer
sits below selection handles and proposal controls and ignores the pointer
unless an element sets pointer-events: auto. Panels dock left, right or bottom
of the slide beside the thumbnail rail, and become drawers in a narrow editor.
Place <PptxPluginToolbar /> in replacement chrome to keep contributed toolbar
commands. Plugin shortcuts must use Mod or Alt, or a function key; built-in and
clipboard shortcuts take precedence, and a clashing plugin shortcut is reported
and not bound. Keys pressed in plugin chrome are the user's: built-in shortcuts act
on the editor there, and a plugin's text fields keep their own editing keys.
Plugins share the page's JavaScript realm, so grants govern the
supported API rather than sandboxing code.
Compose the XLSX toolbar
The spreadsheet editor's built-in controls share one command store,
api.commands, handed to onReady. Pass toolbar to replace the default
chrome with parts bound to it; EditorToolbar.FormulaBar keeps the name box and
formula input:
import {
EditorToolbar,
ToolbarButton,
ToolbarCommandButton,
ToolbarCommandSelect,
ToolbarGroup,
XlsxEditor,
} from "@betteroffice/xlsx-react";
<XlsxEditor
file={bytes}
toolbar={
<EditorToolbar mode="commands">
<EditorToolbar.Toolbar>
<ToolbarGroup label="Formatting">
<ToolbarCommandSelect id="numberFormat" />
<ToolbarCommandButton id="bold" />
</ToolbarGroup>
<ToolbarCommandButton id="undo" />
<ToolbarButton title="Share" onClick={share}>Share</ToolbarButton>
</EditorToolbar.Toolbar>
<EditorToolbar.FormulaBar />
</EditorToolbar>
}
/>;Omit toolbar for the default chrome (hidden when readOnly), pass null for
none, or set showToolbar={false}. Outside the editor, render the same parts
inside <XlsxCommandProvider commands={commands}>, with commands captured from
onReady (null until then). execute(id, args) runs in order with pastes
and cell entries accepted before it, writes the cell or formula text typed so
far, checks availability again, and resolves to { ok: true, status } or
{ ok: false, failure }; a disabled command
states why in disabledReason. The synchronous api.save() throws
XlsxSaveRefusedError (input-pending or input-failed) instead of returning
bytes without accepted input; execute("save", null) waits for it and saves.
The editor API's version, readCells, findText, validateEdits and
applyEdits wait for that input too, and reject with the exported
XlsxCommandAdmissionError when they cannot run: its code is input-failed
while a refused entry waits for correction, gesture-active during a chart drag
and document-replaced when the workbook is replaced meanwhile.
selectCells and clearSelection queue an open entry behind input that is
still waiting, so it may not have landed when they return. An open entry the
workbook refuses stays open and they change nothing; one it refused earlier
reopens at its own cell once they land on its sheet.
The store gates the editor's own UI, not direct
api.handle calls. Existing prop-based EditorToolbar and Toolbar usage keeps
working in the default legacy mode; mode="commands" rejects those props. Narrow
rails move groups into an accessible More menu; wrap other host content in
ToolbarOverflow to give it an entry there.
Host XLSX editor plugins
XlsxEditor installs host-owned tools through the same plugins,
pluginGrants and onPluginError props as the DOCX editor. A plugin
created with defineXlsxPlugin contributes a docked panel, an overlay on the
grid and commands, and works through restricted clients. The plugin API is
experimental and may change in minor releases:
import { XlsxEditor, defineXlsxPlugin } from "@betteroffice/xlsx-react";
const review = defineXlsxPlugin<{ sheets: number }>({
id: "acme.review",
createState: () => ({ sheets: 0 }),
async onEvent(context, event) {
if (event.type !== "load" && event.type !== "document-change") return;
const read = await context.read.readCells({ ranges: [] });
if (read.ok) context.setState({ sheets: read.sheets.length }, read.version);
},
panel: {
title: "Review",
placement: "right",
render: ({ context }) => <p>{context.state.sheets} sheets</p>,
},
commands: [
{
id: "mark",
label: "Mark reviewed",
mutatesDocument: true,
async execute(context) {
if (!context.edits) return { ok: false, failure: { code: "not-granted", message: "Needs write access" } };
const selection = context.snapshot.selection;
const version = await context.read.version();
if (!version.ok) return version;
if (!selection?.cells) return { ok: true, status: "noop" };
const { anchor, focus } = selection.cells;
const start = { row: Math.min(anchor.row, focus.row), col: Math.min(anchor.col, focus.col) };
const end = { row: Math.max(anchor.row, focus.row), col: Math.max(anchor.col, focus.col) };
const result = await context.edits.applyEdits({
expectVersion: version.version,
steps: [
{
op: "patchStyle",
target: { sheetId: selection.sheetId, range: { kind: "rowCol", start, end } },
patch: { fillColor: "#d9ead3" },
},
],
});
return result.ok ? { ok: true, status: "executed" } : result;
},
},
],
toolbar: ["mark"],
});
<XlsxEditor
file={bytes}
plugins={[review]}
pluginGrants={{ "acme.review": { document: "write", editBatches: true } }}
onPluginError={(error) => console.error(error.pluginId, error.phase, error.error)}
/>;Lifecycle and grants follow the DOCX editor: fresh state,
initialize and one load event per workbook (repeated when a change the hook
did not make arrives first, and stopped after ten runs), then coalesced
document-change, selection-change, mode-change (with readOnly),
layout-change and grants-change events. document-change comes from the
workbook's committed versions after recalculation, so typing, commands, batches
(a plugin's own included), undo, redo and remote updates each produce one, and
refusals or no-ops none. Replacing the workbook, removing the plugin, changing its revision,
unmounting or a failure ends the activation and runs its cleanups once. Without
a grant a plugin reads (read.version, readCells, findText,
validateEdits) and navigates; built-in commands need an allowlisted id,
mutating ones refuse with unsupported-policy, and applyEdits needs
document: "write" and editBatches. Grants and readOnly are checked again
right before each change. Each client call waits for pending input in order
with it; plugin handlers run outside that queue. A contributed command's failure
uses the plugin's own code, and a refused edit batch returned as-is reaches the
caller unchanged.
Selection and navigation. snapshot.selection names the active sheet
(session sheetId and zero-based sheetIndex), the selected cells with their
anchor/focus direction and a selected chart's id.
navigation.selectCells({ sheetId, selection }, { expectVersion }) activates
the sheet, selects the cells and reveals the focus cell;
navigation.scrollToCell({ sheetId, row, col }, { expectVersion, align })
reveals a cell (nearest, start or center) and keeps the selection, which
is cleared when the cell is on another sheet. Both keep keyboard focus unless
selectCells gets focus: true, and refuse with stale-version or
missing-target rather than retarget a changed workbook. Sheet ids and versions
are session-scoped.
Coordinates. context.geometry exists only while the canvas shows a
painted frame of the current version. layout names its sheetId, zoom and
painted viewport in unzoomed sheet pixels, and changes with every scroll,
zoom and sheet switch. geometry.getCellRect({ sheetId, row, col }) and
getRangeRect({ sheetId, range }) return pixels of the overlay layer, zoomed
and clipped to the visible grid, frozen panes included; a cell scrolled out of
view returns null, and merged areas are addressed as ranges.
getPositionAtPoint is null until the editor exposes pointer queries. The
overlay layer sits on the grid below the editor's selection and cell editor and
ignores the pointer unless an element sets pointer-events: auto. Panels dock
left, right or bottom of the grid, above the sheet tabs, and become drawers in a
narrow editor. Place <XlsxPluginToolbar /> in replacement chrome to keep
contributed toolbar commands. Plugin shortcuts must use Mod or Alt, or a
function key; built-in shortcuts win and a clashing plugin shortcut is reported. Keys pressed in plugin chrome are the user's:
built-in shortcuts act on the editor there, a plugin's text fields keep their
own editing keys, and no key or click in a contribution reaches the grid.
Plugins share the page's JavaScript realm, so grants govern the supported API
rather than sandboxing code, and they are not spreadsheet protection.
Use the JavaScript core
import { initWasm, openWorkbook, paintDisplayList } from "@betteroffice/xlsx";
await initWasm();
const workbook = openWorkbook(new Uint8Array(await file.arrayBuffer()));
const frame = workbook.displayList({ x: 0, y: 0, width: 800, height: 600 });
paintDisplayList(canvas.getContext("2d")!, frame, devicePixelRatio);
workbook.editCell(0, 9, 2, "=SUM(C1:C9)");
const saved = workbook.save();initWasm() fetches the packaged wasm asset once in browsers; pass wasm bytes or
a precompiled WebAssembly.Module in runtimes that cannot fetch that URL. The
core also provides hit-testing, viewport calculations, accessibility data, and
TSV clipboard helpers.
Edit DOCX in version-checked batches
A DOCX session reads paragraphs with the version they were read at, then applies a batch against that version: every step commits in one transaction and one undo step, or the batch returns a typed refusal and nothing changes.
import { repackDocx } from "@betteroffice/docx/docx";
import { createYrsSession, yrsToDocument } from "@betteroffice/docx/yrs";
const session = await createYrsSession();
session.openDocx(bytes, true);
const read = session.readParagraphs({ view: "accepted" });
if (!read.ok) throw new Error(read.failure.message);
const first = read.paragraphs[0];
const result = session.applyEdits({
expectVersion: read.version,
steps: [
{ op: "insertText", target: { kind: "paragraph", story: "body", paraId: first.paraId }, at: "end", text: " (revised)" },
{ op: "insertParagraphs", target: { story: "body", paraId: first.paraId }, at: "end", paragraphs: [{ text: "New clause" }] },
],
});
if (!result.ok) console.warn(result.failure.code);
const saved = await repackDocx(yrsToDocument(session, session.materializeDocx()!));Offsets are UTF-16 positions in the paragraph text, where each inline atom (hard
break, image, content control, note reference, field) is one U+FFFC. Steps insert,
replace and delete text within one paragraph, insert or delete complete
paragraphs, and apply paragraph styles; text steps can be recorded as tracked
changes. Versions belong to one session and change with every committed edit.
The React editor exposes the same readParagraphs, findText, validateEdits
and applyEdits on its ref, flushing pending input first. Paragraph and style
steps need a session opened from DOCX bytes, inline atoms cannot be replaced, and
list numbering is a v1 limitation: numbered paragraphs and styles that define or
inherit numbering refuse with unsupported until a follow-up retains numbering
definitions. Steps also refuse content-locked controls, existing tracked changes
they would touch (tracked run formatting included) and pending paragraph-mark
revisions; paragraph and style steps cannot be suggested, and an inserted
paragraph takes only its style's defaults, so it is never numbered. Deleting a
span that holds tables, controls, section breaks, opaque XML, fields' cached
results, or comments and bookmarks crossing it refuses, as does a step after
which saving would move an opaque XML block; a batch holds at most 128 steps,
1,048,576 inserted UTF-16 units and 1,024 new paragraphs.
Edit PPTX in version-checked batches
A presentation handle reads slides and story text with the version they were read at, then applies a batch against that version: every step commits in one transaction and one undo step, or the batch returns a typed refusal and nothing changes.
import { initWasm, openPresentation } from "@betteroffice/pptx";
await initWasm();
const deck = openPresentation(bytes);
const read = deck.readContent();
if (!read.ok) throw new Error(read.failure.message);
const story = read.stories[0];
const within = { slideId: story.slideId, shapeId: story.shapeId, storyId: story.storyId };
const result = deck.applyEdits({
expectVersion: read.version,
steps: [
{ op: "replaceText", target: { kind: "search", within, text: "Q3" }, text: "Q4" },
{ op: "setSlideNotes", target: { slideId: story.slideId }, text: "Updated for Q4" },
],
});
if (!result.ok) console.warn(result.failure.code);
const saved = deck.save();A story reads as its paragraphs joined by \n, with story-local UTF-16 offsets;
the read reports each paragraph's span, soft line breaks and field results.
Steps insert, replace and delete text within one paragraph, format and align
text, replace speaker notes, and set a top-level shape's rectangle, fill or
outline. Fields and soft line breaks are never replaced or deleted. Ids and
versions belong to one open session. PptxEditor exposes the same
readContent, findText, validateEdits and applyEdits on its editor API,
flushing pending input first. The Rust Presentation and the Python binding
take the same requests. A batch refuses when saving could turn a field into
plain text, which it rules out only while every field is non-empty and sits in
its paragraph's unchanged leading or trailing text; a paragraph's alignment and
text cannot change in one batch, and slides, shapes and paragraphs are neither
created nor removed. A batch holds at most 128 steps and 1,048,576 inserted
UTF-16 units, requests at most 16 MiB of JSON, and reads and searches return at
most 64 MiB, searches marking the cut with truncated.
PPTX structured export
exportStructured() returns the committed deck as JSON with the version it was
read at, and exportMarkdown() renders that same read as Markdown; neither
flushes input or changes anything. exportPptxStructured(bytes),
exportPptxMarkdown(bytes) and renderPptxMarkdown(content) do the same
headless, anchored to the returned snapshot.
import { exportPptxMarkdown } from "@betteroffice/pptx";
const read = deck.exportStructured({ includeNotes: true, includeComments: true });
if (!read.ok) throw new Error(read.failure.message);
const titles = read.content.slides.map((slide) => slide.shapes[0]?.name);
const { markdown, anchors } = await exportPptxMarkdown(bytes);Slides follow deck order and shapes the current shape tree, depth first; this
is the authored order, not one inferred from geometry. Paragraphs carry levels,
resolved list markers, runs with marks, links, line breaks and cached field
results; tables keep spans and merges; pictures, media, charts, SmartArt and
embedded objects become placeholders with their alternative text. Every record
carries an anchor (range anchors are batch text targets in readContent()
offsets) and, when read from the file, its source part, SHA-256 and element
path. Hidden slides and shapes, notes and comments are explicit options, layout
and master content is not exported, and every omission is a diagnostic.
maxBlocks and maxBytes stop the export at a whole record and set
truncated. Markdown marks each
block with <!-- pptx-export:N -->, mapped back to its source in anchors.
Edit XLSX in version-checked batches
A workbook reads cells with the version they were read at, then applies a batch against that version: 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:D3" } }],
});
if (!read.ok) throw new Error(read.failure.message);
const result = workbook.applyEdits({
expectVersion: read.version,
calculation: { nowSerial: Date.now() / 86_400_000 + 25_569 },
steps: [
{
op: "setCellInputs",
target: { sheetId: "sheet:0", range: { kind: "a1", a1: "B3" } },
inputs: [["120"]],
expect: { cells: [[{ displayText: read.ranges[0].cells[0][0].displayText }]] },
},
{ op: "setFormulas", target: { sheetId: "sheet:0", range: { kind: "a1", a1: "E3" } }, formulas: [["D3*1.2"]] },
{ op: "patchStyle", target: { sheetId: "sheet:0", range: { kind: "a1", a1: "B3:E3" } }, patch: { bold: true } },
],
});
if (!result.ok) console.warn(result.failure.code);Steps set cell inputs (parsed like typing, against each cell's current number
format), set formulas (stored as formulas whatever the format), set number
formats and patch styles. Targets name a sheet id from the current catalog and an
A1 range or zero-based corners; value matrices and guards match the target
exactly. Guards compare a cell's value, formula or display text before the
batch, and steps may combine content with formatting on the same cells but not
write one property twice. findText searches display text exactly and
case-sensitively. Volatile functions see only calculation.nowSerial. Versions
and sheet ids belong to one session. Batches do not insert or delete rows,
columns or sheets, merge cells or move charts, and refuse writes to merged-cell
followers, array-formula cells and protected sheets. The React editor's
onReady API exposes the same operations, committing pending drafts, nudges and
pastes first, and rejecting with XlsxCommandAdmissionError when that input
cannot be written or the workbook is replaced meanwhile.
Export DOCX as structured content
exportDocxStructured reads a DOCX into read-only JSON: ordered stories of
headings, paragraphs, list items, tables and content controls, with the location
of every block and inline and a diagnostic for everything omitted or not
represented. exportDocxMarkdown renders the same content as Markdown with a
marker per block.
import { exportDocxMarkdown, exportDocxStructured } from "@betteroffice/docx";
const content = await exportDocxStructured(bytes, { revisionView: "accepted" });
const { markdown, anchors } = await exportDocxMarkdown(bytes, {
revisionView: "markup",
stories: ["body", "footnotes", "comments"],
});revisionView is required: accepted and original project pending revisions,
markup keeps both with attribution. Only the body is exported unless stories
selects headers, footers, footnotes, endnotes or comments; omitted stories,
formatting and revision content are listed in diagnostics. Ranges use the edit
batch offsets (UTF-16, one U+FFFC per inline atom) in the view they name.
A list number its format cannot write, such as a Roman numeral past 3,999, is
drawn and exported without a marker, with an unsupported-numbering diagnostic.
Fields export their cached result and are never evaluated; images export alt
text and relationship metadata, not image data. On a live session,
session.exportStructured(options) and session.exportMarkdown(options) return
the content with the version its anchors belong to, without committing or
publishing anything, and session.headings(story) lists a story's headings
classified the same way. Exports stop at whole blocks when they reach maxBlocks or
maxBytes and report truncated. Markdown does not preserve Word pagination or
layout.
Page fragments
A paged export attaches a page map to the same structured content: which physical page, section and displayed page label shows each block and inline, and which header, footer or note occurrence it sits in. Ordinary exports never lay anything out; a paged export reads a layout and refuses one it cannot trust.
import { exportDocxStructuredWithPages, renderDocxMarkdownWithPages } from "@betteroffice/docx";
const paged = await exportDocxStructuredWithPages(
bytes,
{ revisionView: "markup", stories: ["body", "headers", "footnotes"] },
{ fonts: [{ key: "sans", data: fontBase64 }], defaultChain: ["sans"] },
);
const { markdown } = await renderDocxMarkdownWithPages(paged, { pageMarkers: true });The bytes export lays the document out in a private session with exactly the
fonts it is given, as base64 data, in a font store of its own (no system fonts are
looked up and no editor's fonts change) and returns a deterministic snapshot map.
Its layout options are plain JSON: measurementDefaults, renderEnvironment
(showHiddenText, defaultTabStopTwips) and compatibility; a font requirement
left with no font, including the default family text naming none falls back to, or
any text measured with stand-in metrics is refused as layout-unavailable. On a live session,
session.exportStructuredWithPages(options) reads the region layout the session
retains and never lays out, flushes or loads fonts; the React ref's
exportStructuredWithPages(options) flushes pending input and exports the
editor's layout of that version, laying the document out again or waiting for
fonts when the layout was computed from other fonts, measurement defaults, render
environment or pagination options. Page references describe that authoritative
layout at the returned version; a later edit may supersede it before it is
painted.
pageIndexcounts physical pages from zero, blank parity pages included;displayedNumberanddisplayedLabelfollow the section's PAGE numbering, which can restart, repeat or use Roman and letter formats. A format that cannot be written falls back to decimal withnumberingStatus: "fallback".- A header or footer part is exported once as a story and has an occurrence on every page that shows it; notes have occurrences on the pages their note areas are on, independent of the page of their reference mark.
- Fragments name the export node (
nodeId) and its block. Text slices are ranges in the export's own offsets; an atom such as a field, control or note mark is sliced whole, and markedpartialwith ananchor-onlydiagnostic when only part of its content is on the page. Tables list their row window with continuation and repeated-header flags, and every cell paragraph has fragments of its own for the lines its cell shows; a line the cell cuts through is listed with aclipped-contentdiagnostic. - Pages are laid out with revision markup.
acceptedandoriginalare refused withunsupported-revision-layoutwhile any laid-out story, exported or not, holds pending revisions. - A layout older than the document is refused with
stale-document; section, settings or note metadata that no longer describes the document, changed fonts, measurement options or a mismatchedexpectLayoutVersionwithstale-layout; no retained layout, or one missing a font the document needs, withlayout-unavailable; notes that did not settle withlayout-not-converged; a footnote the layout places away from its reference in a split table row withunsupported. A note too tall for its page's note area is diagnosedunsupported-note-layoutinstead of placed. includeGeometryadds rectangles in unzoomed CSS pixels (96 per inch) from the physical page's top-left corner.maxFragmentsandmaxLayoutBytesbound the map separately from the content and mark ittruncated; mapping stops a page past the limit, so a truncated map leaves out the diagnostics of later pages and of paragraphs no page shows.renderDocxMarkdownWithPageschecks the map belongs to the content; withpageMarkers, each block marker is followed by<!-- docx-pages: 0=i 1=ii -->and no page break is written into the text.- The map carries its own
schemaVersion(1): additive fields keep it, and a change existing readers would misread bumps it.
Rust EngineSession also exports page maps; Python exports do not.
Export XLSX content with anchors
XLSX exports bounded sparse worksheet content and Markdown with positional anchors, formulas, stored values, formatted text, explicit hidden-content options, and omission diagnostics. Export does not recalculate formulas.
import { exportXlsxMarkdown } from "@betteroffice/xlsx";
const result = workbook.exportStructured({
scope: [{ sheet: 0, range: "A1:H40" }],
includeHiddenRows: false,
maxCells: 20_000,
});
if (result.ok) {
const { version, content } = result;
const totals = content.sheets[0].cells.filter((cell) => cell.formula !== null);
}
const { markdown, anchors } = await exportXlsxMarkdown(bytes, {}, { maxRows: 100 });A live export returns { ok, version, content } read from the committed
workbook without flushing, recalculating or publishing anything; a refused scope
or option returns { ok: false, version, failure } with target naming the
sheet or null. exportXlsxStructured, exportXlsxMarkdown and
renderXlsxMarkdown need no session: bytes are read as stored, with no clock.
Cells carry a typed value, the formula without =, the engine's display text,
the number format and merge membership; formula results are stored values
marked unverified, missing (the file stored none and nothing has calculated
since), uncertain, cycle or limited. Sheets also list merges,
tables, hyperlinks, hidden row and column spans, and drawing objects as
placeholders; defined names are listed read-only. Comments, rich-text formatting
runs (the text is exported), sheet features such as conditional formatting, data
validation and pivot tables, and unreadable charts, which stay placeholders, are
diagnosed rather than exported. Anchors are sheet index, sheet
name and A1 in that version or snapshot, not identities that survive structural
edits. maxCells and maxBytes stop at a complete record with truncated.
Markdown renders one labelled grid per sheet (200 rows, 50 columns and 10,000
positions by default), escaped HTML tables for merges, and
<!-- xlsx-export:N --> markers whose anchors are returned beside it.
Fill DOCX content controls
listContentControls lists a session's content controls in document order with
their tag, alias, type, lock, placement, anchor and current text, and the version
they were read at. A setContentControlText step in an edit batch fills a plain-
or rich-text control by its controlId, or by a tag or ooxmlId (the authored
w:id) only one control carries.
const read = session.listContentControls();
if (!read.ok) throw new Error(read.failure.message);
const byTag = (tag: string) => read.content.controls.find((control) => control.tag === tag)!;
const result = session.applyEdits({
expectVersion: read.version,
steps: [
{ op: "setContentControlText", target: { kind: "id", controlId: byTag("customer.name").controlId }, text: "Ada Lovelace" },
{ op: "setContentControlText", target: { kind: "tag", tag: "customer.address" }, text: "12 Example Street\nLondon" },
],
});
if (!result.ok) console.warn(result.failure.code, result.failure.reason);The text replaces the control's content and clears its placeholder: CRLF becomes
LF, LF is a line break (a new paragraph in a rich-text block control), and a
plain-text control accepts it only with w:multiLine. The new text takes the
formatting of the control's first text run, or the control's own run properties
while it shows its placeholder. Every step commits or none does;
missing or duplicate tags and ooxmlIds, locked, data-bound and nested controls,
and content other than text, tabs and line breaks are refused as data with a
reason. findContentControls(query) returns every exact match of an id, tag,
ooxmlId or alias, and listDocxContentControls(bytes) and
findDocxContentControls read bytes without a session. Control ids belong to the
version they were read at and do not survive save and reopen; ooxmlId does, and
tags are the template author's names. Checkbox, dropdown and date controls keep
setContentControlValue; passing a string to it fills a text control the same
way. The React editor exposes listContentControls and findContentControls on
its ref. Filling needs a session opened from DOCX bytes; rich-text run input is
not supported yet. Controls the session does not hold as controls, such as those
in text boxes, are left out as a coverage gap (complete: false), and while one
exists, tag and ooxmlId fills refuse with provenance-unavailable.
Compare DOCX files into tracked changes
compareDocx compares the body paragraph text of an original and a revised DOCX
and returns the original with the differences as tracked insertions and
deletions, attributed to the author and date you pass. Everything else in the
package keeps the original's bytes.
import { compareDocx } from "@betteroffice/docx";
const result = await compareDocx(original, revised, {
author: "Contract review",
date: "2024-05-06T09:30:00+02:00",
});
if (result.ok) {
await save(result.docx);
for (const change of result.changes) console.log(change.kind, change.original.text, change.revised.text);
} else {
for (const diagnostic of result.diagnostics) console.warn(diagnostic.code, diagnostic.message);
}Only text inside body paragraphs whose structure is unchanged is compared.
Whole-paragraph additions, removals and moves, table and content-control
changes, formatting or style changes, field, hyperlink and object changes,
edits in paragraphs holding such content, existing tracked changes, and
differences outside the body return { ok: false, diagnostics } and never a
partial redline; unsupported: "report" collects every diagnostic instead of
stopping at the first. granularity reports differences by Unicode word
(the default) or grapheme cluster, and limits can tighten the documented v1
bounds. The result is reopened and checked against both inputs before it is
returned: accepting every change gives the revised text and formatting,
rejecting every change gives the original. Review in BetterOffice is tested;
Word validation is reported separately. Native Rust and Python comparison is
not available yet.
Operation profiles
XLSX provides editCellProfiled, applyOpsProfiled, and displayListProfiled.
The edit results include stage timings in profile; display profiling returns
{ displayList, profile }. PPTX provides layoutSlideProfiled and profiled
mutation/history methods such as insertTextProfiled and undoProfiled, returning
{ layout, profile } or { receipt, profile } respectively. Durations are in
milliseconds and describe engine stages; measure the surrounding browser
interaction separately when investigating input-to-paint latency.
DOCX paragraph anchors
A DOCX YrsSession addresses paragraphs by session keys, which are never saved as Word
paragraph IDs (w14:paraId); a session anchor resolves on every replica of one
collaborative session, and seeding opens without a generation start a new
session. saveYrsDocx(session) from @betteroffice/docx/yrs returns the
saved bytes and a persisted anchor for each saved paragraph that has a Word paragraph ID,
qualified by its package part. Resolve it after reopening:
import { createYrsSession, saveYrsDocx } from "@betteroffice/docx/yrs";
const saved = await saveYrsDocx(session);
const reopened = await createYrsSession();
reopened.openDocx(saved.bytes, true);
const result = reopened.resolveParagraphAnchor(saved.paragraphs[0].persisted);Paragraphs authored in the session always receive an ID. Source paragraphs without one
keep none until the host calls session.persistParagraphIds(), which covers every story
part, comments and note separators included, and refuses rather than guess at an
ambiguous comment reference. Results are found, missing, ambiguous, or
unsupported; table, cell, and content-control identities are not persisted.
The React editor's ref offers getParagraphIdentities() and
resolveParagraphAnchors(anchors), which returns the results in input order with
the version they were resolved at.
Package reference
| Package | What it is |
|---|---|
@betteroffice/docx | Framework-free DOCX core: OOXML parse and serialize, CRDT editing, version-checked atomic text, paragraph and content-control batches, content-control discovery, structured JSON and Markdown export with source anchors, text shaping, pagination, canvas rendering. Saving preserves untouched package parts. |
@betteroffice/docx-react | The DOCX editor and viewer as a React component: toolbar, paginated canvas, comments, tracked changes, a command store with composable toolbar parts, flushed version-checked edit batches and content-control discovery on its ref, and host-owned plugins with panels, overlays, sidebar items, lifecycle events, and explicitly granted commands and edit batches. |
@betteroffice/docx-i18n | UI locale strings and types for the DOCX React editor. |
@betteroffice/xlsx | Framework-free XLSX core: SpreadsheetML parse and serialize, formula recalculation, version-checked atomic cell batches, anchored JSON and Markdown export, grid display lists, hit-testing, PNG export, and reviewable agent proposals. |
@betteroffice/xlsx-react | The spreadsheet editor and viewer as a React component, with a command store and composable toolbar parts, flushed version-checked edit batches on its onReady API, and host-owned plugins with panels, overlays, lifecycle events, and explicitly granted commands and edit batches. |
@betteroffice/xlsx-i18n | UI locale strings and types for the XLSX React editor. |
@betteroffice/pptx | Framework-free PPTX core: parsing, editing, version-checked atomic edit batches, structured JSON and Markdown export with anchors, masters and layouts, rendering, comments, collaboration, and saving. Untouched slides retain their source bytes. |
@betteroffice/pptx-react | The slides editor and viewer as a React component, with a command store, composable toolbar parts, flushed version-checked edit batches on its editor API, and host-owned plugins with panels, overlays, lifecycle events, and explicitly granted commands and edit batches. |
@betteroffice/pptx-i18n | UI locale strings and types for the PPTX React editor. |
See Collaboration to connect editors through a shared relay.