This document covers the VS Code extension side of the workspace: the
docxwasm and gridwasm bridge crates and the offxy-vscode extension that
together open and edit Word .docx and Excel .xlsx files inside a VS
Code editor tab. For the terminal apps see the README; for the
spreadsheet and project sides see SPREADSHEET.md and
PROJECT.md.
offxy-vscode started as docxy-vscode (Word only) and was renamed to
offxy when Excel .xlsx editing was added as a second custom editor in
the same extension; the two editors are independent (separate wasm bridge,
separate webview) and share only the extension host's registration table.
docxy and xlsxy render a .docx/.xlsx faithfully onto a character
grid in the terminal — not a pixel-perfect Word/Excel page, but a readable,
editable view of the real document or workbook. A VS Code editor tab is also a
character grid. So the same render engines that paint the terminal can paint
an editor tab: the document/workbook reads like text in your editor, at the
editor's own font and size, honoring your color theme — and without a
ribbon, because VS Code already has the command palette, the editor title
bar, and keybindings.
The enabling fact: docxcore and gridcore — the
whole DOCX and XLSX engines (their own ZIP, DEFLATE, XML, document/workbook
model, renderer, editor, and lossless serializer) — are pure std with no
third-party crates. That compiles straight to wasm32-unknown-unknown and
runs inside a webview. There is no JavaScript docx/xlsx library involved; the
same Rust code that backs the terminal apps backs the extension.
The existing .docx/.xlsx extensions fall into two camps, and both leave a
gap:
- Read-only viewers render to HTML/canvas and can't edit.
- HTML/JS-editing extensions open the file, convert it to an intermediate form, let you edit that, then re-serialize it back — which silently degrades styles, numbering, tables, headers, or formulas on every save.
docxcore and gridcore edit the real OOXML model and preserve every
unmodeled part of the original package byte-for-faithful (bookmarks, fields,
content controls, section properties, charts, pivots, conditional
formatting, …). A document or workbook opened, edited, and saved comes back
structurally intact. That lossless round-trip — the terminal apps' headline
feature — is exactly what the crowded extension field lacks, and it carries
into the editor tab unchanged, for both formats.
| Piece | Language | Responsibility |
|---|---|---|
docxcore / gridcore |
Rust | the DOCX/XLSX engines (unchanged; shared with docxy/xlsxy) |
docxwasm / gridwasm |
Rust cdylib |
hand-written WebAssembly ABIs over docxcore/gridcore |
offxy-vscode/src/extension.ts |
TypeScript | the extension host: a registration table of binary CustomEditorProviders (offxy.docxEditor, offxy.gridEditor) |
offxy-vscode/media/webview.js |
JavaScript | the Word webview: docxwasm loader, grid painter, input handling |
offxy-vscode/media/grid.js |
JavaScript | the Excel webview: gridwasm loader, virtualized spreadsheet grid, formula bar, sheet tabs |
docxwasm and gridwasm deliberately use no wasm-bindgen — matching
the project's from-scratch ethos and keeping each artifact small and
auditable. docxwasm's seam is a handful of C-ABI exports:
docx_alloc(len) -> ptr docx_free(ptr, len)
docx_open(ptr, len) -> handle docx_close(handle)
docx_render(handle) -> result
docx_cmd(handle, ptr, len) -> result
docx_media(handle, ptr, len) -> result // raw bytes of the media for an rId
docx_save(handle) -> result
- Memory is shared by pointer. The host allocates a buffer with
docx_alloc, writes input into wasm memory, and frees it after the call. - Every result-returning export returns a pointer to a length-prefixed buffer
(
[u32 little-endian length][payload]), which the host reads and then frees — no 64-bit return values or BigInt on the JS side. - A document is an opaque
u32handle fromdocx_open; sessions live in a thread-local registry (wasm is single-threaded).
The interesting logic lives in docxwasm::bridge as plain, natively-testable
Rust (cargo test -p docxwasm); lib.rs is just marshalling. The render
channel serializes the engine's styled lines to compact JSON
({lines:[[{t,b,i,u,s,d,h,c,lnk}...]], caret, selection, dirty, width}); colors
map to VS Code terminal ANSI theme variables. The command channel is a
trivial tab-delimited string (insert\tX, move\tleft\t1, click\t3\t5\t0,
bold, undo, …), so no JSON parser is needed on the Rust side.
The view is a continuous flow (page_view = false): no pagination, headers,
or footers — the fidelity target is "text in an editor tab," not a Word page.
gridwasm's seam is smaller — it's viewport-based rather than full-render,
since a spreadsheet grid is virtualized:
grid_alloc(len) -> ptr grid_free(ptr, len)
grid_open(ptr, len) -> handle grid_close(handle)
grid_cmd(handle, ptr, len) -> result
grid_save(handle) -> result
grid_new() -> result // bytes of a fresh empty workbook (used host-side too)
Memory sharing and the length-prefixed result buffer follow the same
convention as docxwasm. The single tab-delimited grid_cmd channel carries
both queries and mutations — view\t… (fetch a viewport window), select,
set, clear, cut, paste, undo/redo, insrow/delrow/inscol/
delcol, and sheet\t(switch|add|rename)\t… — dispatched in
gridwasm::bridge (cargo test -p gridwasm), with the underlying edits and
recalculation delegated straight to gridcore::Engine.
The webview owns the live editing session (the wasm engine), so keystrokes
are local — no host round-trip per character. The extension host owns the file
lifecycle. They talk over a small postMessage protocol:
- On open, the host reads the file and sends the bytes; the webview instantiates the wasm, opens the document, and paints.
- Each mutating edit makes the webview post
{type:'edit'}; the host firesonDidChangeCustomDocument, which lights VS Code's dirty dot and registers an undo/redo entry whose callbacks post{type:'do', op:'undo'|'redo'}back to the webview. Because it's exactly one VS Code edit per webview edit, VS Code's undo stack stays in lockstep with the wasm editor's own — and host-driven undo must not echo anotheredit(verified). - On save / backup, the host requests bytes; the webview calls
docx_saveand replies. The host writes them withworkspace.fs. Because the provider is the binaryCustomEditorProvider(not the text variant), Save, Save As, revert, and hot-exit all route through it. - Clipboard is mediated through the host (
vscode.env.clipboard), since the webview's selection model is its own, not the DOM's.
The offxy.gridEditor follows the same split (webview owns the gridcore
session, host owns the file lifecycle) over the analogous protocol; its
clipboard payload is TSV, so ranges round-trip with Excel and other
spreadsheet apps through the same OS-clipboard mediation.
cargo test -p docxwasmexercises open → render → edit → save → reopen in plain Rust (the round-trip proves the edit survives lossless serialization).cargo test -p gridwasmdoes the same for the spreadsheet bridge (open → view → edit/structural-edit/sheet-op → save → reopen), on top ofcargo test -p gridcorefor the underlying engine.- The wasm is exercised end-to-end in a JS runtime (open a real
.docx, render, type, format, undo, save, reopen) and the webview client is smoke-tested under jsdom against the real.wasm— confirming the message protocol, the dirty/ undo bookkeeping, and thatdocx_savereturns valid ZIP (PK).docxbytes.
Working today: faithful rendering (runs, headings, lists, tables, links, and
embedded images), editing (typing, navigation, selection, B/I/U,
copy/cut/paste), a no-ribbon formatting toolbar + command palette (headings,
lists, alignment, font size), find (VS Code's find widget over the rendered
text) and replace (the engine's replace-all), and native dirty / undo-redo /
save / Save As / backup with lossless round-trip. Opening an empty (0-byte)
.docx offers to create a new document in its place.
Images ride the same overlay idea the terminal app uses: render_with_images
returns image boxes (grid position + size + relationship id) in the JSON
view, and a docx_media(rid) ABI export returns the raw media bytes. The webview
fetches each rid once, sniffs the format, and paints a data-URI <img> over the
placeholder box (raster + SVG); vector WMF/EMF, which browsers can't decode, fall
back to a labeled box — exactly the terminal app's fallback.
Bidi note: terminal docxy passes a Unicode bidi projector into docxcore.
The wasm bridge currently passes bidi: None, so Hebrew, Arabic, and
mixed-direction DOCX lines remain logical-order in offxy.docxEditor until a
wasm projector is added.
Markdown ⇄ docx conversion runs the same wasm in the extension host
(Node instantiates docxwasm too), via stateless exports docx_from_markdown
and docx_to_md — so Convert Markdown to Word (right-click a .md) and
Export to Markdown (from an open document) work without an open editor or a
webview round-trip. This is the one place the engine runs host-side rather than
in the webview.
Word next steps, roughly in order:
- Color / font pickers over the
colorandfontsizebridge commands. - Table structure editing (add/delete rows and columns).
Working today: a virtualized grid with sticky headers and a formula bar,
recalculation on edit via gridcore's dependency graph (a formula the engine
can't parse or evaluate keeps Excel's cached value untouched instead of
guessing — Excel-faithful for what it computes, conservative for what it
can't), cell editing (type-to-replace, F2, navigation, range selection),
TSV clipboard (copy/cut/paste round-tripping each cell's raw content,
including formula source, so ranges are interoperable with Excel and other
spreadsheet apps — there is no fill handle, and paste re-enters formula text
verbatim rather than translating relative references), structural edits
(insert/delete rows and columns, rewriting affected formulas), sheet tabs
(switch/add/rename), and native dirty / undo-redo / save / Save As / backup
with lossless round-trip. Opening an empty (0-byte) .xlsx offers to create
a new workbook in its place.
Excel next steps, roughly in order:
- Sheet delete (currently switch/add/rename only; when added it will need the same structural-snapshot undo treatment sheet-add now has, so the host's undo stack stays in lockstep).
- Formula-bar autocomplete / function help.
- Formatting surface (number formats, fills, borders) beyond what the source workbook already carries.