DOCX Import & Export
@stesura/docx is the framework-free engine; @stesura/docx-react adds the toolbar buttons.
The quickest setup is the toolbar group. It adds Import and Export buttons to the Export tab:
// docs: no-verify (an excerpt: the other StesuraEditor props are omitted)
import { docxUIExtension } from "@stesura/docx-react";
<StesuraEditor uiExtensions={[docxUIExtension]} />The editor’s default toolbar has no DOCX buttons without it. To configure the buttons (export only, custom nodes), use createDocxUIExtension({ showImport?, exportOptions?, nodeTransform?, importCleanup? }) instead. importCleanup turns Import into a split button with “Import and clean up”, which runs the given transaction builder on the import’s transaction (one undo step); pass applyLosslessFormatFixesTr from @stesura/format-lint to apply the format cleanup’s lossless fixes. See @stesura/docx-react for what the buttons wire up (image uploads, comments, editor modes).
Or drive the engine yourself:
// docs: no-verify (file and threads come from your app)
import { downloadDocx, generateDocxBuffer, importDocx, threadsToDocx } from "@stesura/docx";
const { doc, warnings, comments } = await importDocx(file, schema);
const { warnings: exportWarnings } = await downloadDocx(state, threadsToDocx(threads, state.schema), "report.docx");
const { buffer } = await generateDocxBuffer(state); // bytes, no downloadBoth directions return warnings for anything that could not be represented. Show them to the user, or content loss goes unnoticed. Import warnings are typed objects (ImportWarning); export warnings are strings.
Import
importDocx(file, schema, options?) takes a File or ArrayBuffer. It throws DocxImportError, with a user-readable message, when the file isn’t a readable DOCX. The same error covers files over 50 MB, over 200 MB decompressed, or with more than 5,000 archive entries.
Loading the result into an editor
The toolbar button does the following for you; a headless import has to do it itself:
- Doc attrs. List configs, the stylesheet and endnote options live in
doc.attrs. Replacing only the content drops them: also set each attr on the transaction (tr.setDocAttribute). Leavediralone: DOCX has no document direction, so the imported value is just the schema default. - Comments. The doc’s
comment_open/comment_closeanchors carry ids minted by the import, andresult.commentsholds the threads. Create the threads in your comments store and rewrite the anchors’threadIdto the store’s ids. With no store, strip the anchors. - Images. Pass
uploadImageto upload embedded images and store their URLs. Without it, images become base64 data URLs. WithinlineImageFallback: false(which collab mounts use), an image that can’t be uploaded is skipped and raises amissing_imagewarning. A failed upload always raises animage_upload_failedwarning naming the cause;uploadTimeoutMs(default 10s) bounds the whole batch, and the first failure aborts the rest through thesignalthe hook receives.
What’s imported
| Feature | Import support |
|---|---|
| Text formatting | Bold, italic, underline (all Word styles), strikethrough, superscript, subscript |
| Font properties | Family (theme fonts resolved), size, colour, highlight, caps / small caps. A highlight colour outside the editor palette becomes the run’s background colour |
| Paragraph formatting | Alignment, spacing before/after, contextual spacing, indentation, line height (exact and at-least), direction, keep-with-next / keep-lines / widow control / page break before, tab stops with leaders |
| Headings | Levels 1–9, from the style or w:outlineLvl. Style-numbered styles are the exception: they keep the outline level for the TOC but stay paragraphs, because legal templates use it for navigation only |
| Styles | styles.xml, stored as deltas against the editor’s default stylesheet |
| Character styles | Flattened to direct formatting, except HTMLCode (and its VerbatimChar / SourceCode aliases) and Hyperlink. Those become the code and link marks and keep their appearance as a type: "character" style. See Stylesheets |
| Lists | Numbered and bulleted, nested, with number formats and tab stops |
| Style-linked numbering | Level w:pStyle back-links, a style’s own w:numPr and w:numStyleLink become stylesheet numbering bindings. w:startOverride restarts are applied; a num that redefines a level gets its own config and a warning. See Numbering |
| Sections | Page size, orientation, margins, page numbering (start and format), line numbering, text direction |
| Headers & footers | Default, first-page and even-page variants |
| Tables | Borders, colspan, rowspan, alignment, background colours, column widths, a repeating header row. Tables nested in tables or notes are flattened to paragraphs, with a warning |
| Images | Inline and floating (anchored) images, with wrap mode and side |
| Text boxes | DrawingML and VML text boxes in the body and in table cells become text boxes, with size, position, wrap, outline, fill and inner margins. Rectangles without text become empty boxes. Boxes in headers, footers, notes, groups or other boxes become paragraphs where they are anchored, with a warning; a table in a box is flattened to its paragraphs |
| Borders | Paragraph and cell borders: style, width, colour |
| Equations | OMML becomes LaTeX-backed math nodes, inline and display |
| Footnotes & endnotes | Bodies, anchors, endnote numbering options |
| Cross-references & TOC | A REF field becomes a cross-reference to the block its bookmark wraps. A TOC field or TOC content control becomes a toc node, regenerated from the headings |
| Fields | PAGE / NUMPAGES become page-number nodes; HYPERLINK becomes a link; other fields keep their last displayed text |
| Links | External links, and internal links to a bookmark (they resolve to the block it wraps) |
| Track changes | w:ins / w:del and moves become insertion / deletion marks, including on pictures, note anchors and fields. w:rPrChange and w:pPrChange become format-change suggestions |
| Comments | Threads, replies, resolved state and durable ids. The original author, initials, date and durable id go in metadata.docx, because the store creates threads as the current user |
| Code blocks | Consecutive HTMLPreformatted paragraphs become one code block |
| Callouts | A one-cell table described stesura:callout:<kind> becomes that callout (a bold-only first paragraph is its title), wherever it sits. A run of Quote-style paragraphs becomes a quote callout. See Callouts |
| Page breaks | w:br w:type="page" becomes a page_break node |
Fonts
The editor renders Word’s fonts through bundled open equivalents:
| Word font | Rendered as |
|---|---|
| Arial | Arimo (the document default) |
| Calibri | Carlito |
| Cambria | Caladea |
| Courier New | Cousine |
| Times New Roman | Tinos |
| Georgia | Lora |
Most of these are metric-compatible; Georgia → Lora is only a close match. There are also stand-ins for Tahoma, Verdana, Trebuchet MS, Impact, Comic Sans MS, Aptos, Arial Black and Palatino Linotype. The full roster is FONT_FAMILIES in @stesura/core/constants.
Fonts outside that roster are not dropped. They keep their name in the document and export unchanged. On screen they render through a substitute: a curated match (Helvetica → Arimo, Garamond → Crimson Text, Consolas → Cousine, …), else a serif/sans/mono/script guess from the name. Each one raises a style_fallback warning. Only body text and the stylesheet are scanned, so a font used only in a header or footer is substituted without a warning.
Persisting the imported document
The import runs in the browser, so the document has to be sent to your server. Compact it first:
// docs: no-verify (file comes from your app)
import { compactDocJson } from "@stesura/core/helpers";
const { doc } = await importDocx(file, schema);
const contentJson = JSON.stringify(compactDocJson(doc.toJSON(), schema));A style-heavy import writes a dozen mostly-default attrs on every textStyle mark. compactDocJson drops them losslessly and typically halves the payload.
Transport options, best first:
-
A route handler, with the JSON string as the raw body. No React Flight serialization is involved, and the body limit is yours to set.
// docs: no-verify (contentJson is the string built above) await fetch("/api/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: contentJson, }); -
A server action taking one argument that carries the JSON string, with
serverActions.bodySizeLimitraised innext.config.ts:"use server"; export const importDocument = async ({ title, contentJson }: { title: string; contentJson: string; }) => { /* JSON.parse(contentJson) server-side */ };Both details matter. Flight’s decoder throws
Maximum array nesting exceededonce it has counted about 1e6 characters inside forked arrays. A single-argument call never forks, and a string is opaque to the count. -
Don’t pass the parsed doc object to a server action. Its nested
contentarrays fork, so the count covers the whole document. It works until the document grows, then fails with a decoder error.
Limits you can’t configure away:
- Vercel caps serverless request bodies at about 4.5 MB, whatever
bodySizeLimitsays. Self-hosted, the equivalent is your proxy’s limit (e.g. nginxclient_max_body_size). - Check
new Blob([contentJson]).sizebefore sending and show your own message. Otherwise the user sees a truncated body asUnexpected end of JSON input. - Past the limit, gzip the body client-side (
CompressionStream; JSON compresses 5–10×), or upload the JSON to object storage and send only its path.
The collab backend applies its own cap on top: see maxContentBytes.
Export
downloadDocx(state, comments?, fileName?, options?) saves the file in the browser; generateDocxBuffer(state, comments?, options?) returns the bytes. options handles nodes outside the core schema (see Custom nodes). Build comments from your threads with threadsToDocx(threads, schema). Images are fetched first, because docx-js needs their bytes up front; an image that fails to load is left out, with a warning.
After packing, a post-pack stage writes what docx-js can’t express:
- comment thread structure (
commentsExtended.xml) - endnote options (
w:endnotePr) - section direction (
w:bidi) - style→level numbering back-links
The file is then validated. Instead of producing a file Word would reject or repair, the export throws DocxValidationError, and its problems list what’s wrong and where.
generateDocx skips post-pack
generateDocx returns the unpacked docx-js Document. Packing it yourself with Packer loses everything above: comment threads and resolved state, endnote options, section direction, style→level links, and validation. Use downloadDocx or generateDocxBuffer unless you need the Document itself.
What’s exported
| Feature | Export support |
|---|---|
| Text & paragraph formatting | All marks, fonts, alignment, spacing, indentation, direction, tab stops, borders |
| Styles | Custom styles as w:style entries; edits to built-in styles as overrides |
| Sections | Page size, orientation, margins, page and line numbering, section and page breaks |
| Headers & footers | Default, first-page and even-page variants |
| Tables | Borders, widths, alignment, backgrounds, spans, fixed column grids |
| Images | Inline and floating |
| Text boxes | DrawingML text boxes (wps), without the legacy VML copy. A minimum height becomes Word’s “resize shape to fit text” |
| Lists | Number formats, nesting and tab stops. Styles keep their w:numPr, and the matching w:lvl/w:pStyle back-links are added. See Numbering |
| Equations | Native, editable OMML, not pictures |
| Footnotes & endnotes | Bodies and anchors; repeated references are linked; endnote options |
| Comments | Threads, replies and resolved state |
| Cross-references & TOC | Updatable Word REF fields pointing at bookmarks around their targets, and a Word TOC field. The reference kind and separator are written as field switches (\r, \w, \p, \d) and survive re-import |
| Track changes | Insertions, deletions, moves and format changes, including on pictures, note anchors and fields |
| Code blocks | One HTMLPreformatted paragraph per line |
| Callouts | A quote as Quote-style paragraphs; other kinds as a tagged one-cell table with the kind’s fill, left rule and title. See Callouts |
| Links | External links; internal links as w:hyperlink w:anchor to a bookmark around the target |
Custom nodes
Export skips a node type it has no handler for, with a warning; import only produces core nodes.
A package that adds a node brings its own DOCX support as DocxExportOptions, as
@stesura/mermaid does with mermaidDocx:
import type { DocxExportOptions } from "@stesura/docx";
export const chartDocx: DocxExportOptions = {
// Optional: bytes to fetch before the (synchronous) serialization, keyed as
// the handler will ask for them.
preloadExtra: async (doc) => new Map([["chart:sales", await renderChartPng("sales")]]),
nodes: {
chart(state, node) {
const key = `chart:${node.attrs.chartId}`;
if (state.options.hasImageBuffer?.(key)) {
state.pushImage(state.options.getImageBuffer(key), {
width: 400,
height: 240,
altText: { name: "Chart", description: `chart:${node.attrs.chartId}` },
});
} else {
state.text("[chart]");
}
state.closeParagraph(node);
},
},
};Pass it as the last argument of downloadDocx, generateDocxBuffer or generateDocx, or as
exportOptions of createDocxUIExtension. Handlers are merged over the defaults, so a key that
names a core node replaces its export. A handler receives the serializer state; the useful
methods:
| Method | Does |
|---|---|
text(value, runOptions?) | Adds a run (tracked when the node is) |
pushRun(run) | Adds any docx-js paragraph child |
pushImage(buffer, { width, height, altText? }) | Adds an in-line picture, scaled down to fit the column and the page’s text height. False for unreadable bytes |
closeParagraph(node, paragraphOptions?) | Wraps the pending runs into a paragraph |
renderChildren(node) | Renders the node’s children with their own handlers |
renderIsolated(fn) | Runs fn in a clean context and returns the blocks it produced, e.g. to put them in a table cell |
warnings | Push a string to report something lost |
On import, importDocx(file, schema, { nodeTransform }) calls nodeTransform on every imported
node; a non-null result replaces it. Recognize your export’s marker (a picture description, a
table description, a style) and rebuild your node from it. A picture consumed this way is not
uploaded. With the toolbar, pass it as createDocxUIExtension({ nodeTransform }).
Limitations
Not imported:
- SmartArt, charts, OLE objects, custom XML: skipped. Macros in
.docmfiles are ignored. - Shapes other than rectangles: dropped, with one warning per kind. A shape holding text becomes a rectangular text box. Linked text boxes import one by one, and note references inside a box are removed.
- Hidden text (
w:vanish): omitted, with a warning. - Other features, warned once each: column breaks (become line breaks), double strikethrough, raised/lowered text, character spacing and scale, automatic and line-based paragraph spacing, character-unit indents, distributed alignment.
- Bookmarks are read only as cross-reference targets. Standalone bookmarks aren’t kept.
PAGEREF, andREFs to table captions (captions aren’t a feature yet), keep their displayed text.
Not exported:
- File nodes: dropped, with a warning naming the file. Word has no equivalent.
- Floating image horizontal offset for square and tight wrap: OOXML allows an alignment or an offset, so the side alignment is kept and the offset is lost. Front and behind wraps keep both offsets. Tight wrap exports as square.
Round-trip fidelity
A round trip keeps structure, formatting and content, not pixel-identical layout:
- Word breaks lines with its own algorithm, so text can reflow.
- Fonts rendered through a substitute have different metrics.
- Twips ↔ px conversion can shift spacing slightly.
For content both directions support, one export→import cycle is a fixed point: a second cycle gives the same document, so repeated round trips don’t drift.
Next steps
- Schema: the nodes and marks DOCX maps to
- Numbering: list configs and style-linked numbering
- Track Changes: suggestions, exported as Word revisions
- Reality check : why 1:1 Word parity is impossible