Skip to Content
PackagesWord Import & Export

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 download

Both 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). Leave dir alone: DOCX has no document direction, so the imported value is just the schema default.
  • Comments. The doc’s comment_open / comment_close anchors carry ids minted by the import, and result.comments holds the threads. Create the threads in your comments store and rewrite the anchors’ threadId to the store’s ids. With no store, strip the anchors.
  • Images. Pass uploadImage to upload embedded images and store their URLs. Without it, images become base64 data URLs. With inlineImageFallback: false (which collab mounts use), an image that can’t be uploaded is skipped and raises a missing_image warning. A failed upload always raises an image_upload_failed warning naming the cause; uploadTimeoutMs (default 10s) bounds the whole batch, and the first failure aborts the rest through the signal the hook receives.

What’s imported

FeatureImport support
Text formattingBold, italic, underline (all Word styles), strikethrough, superscript, subscript
Font propertiesFamily (theme fonts resolved), size, colour, highlight, caps / small caps. A highlight colour outside the editor palette becomes the run’s background colour
Paragraph formattingAlignment, 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
HeadingsLevels 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
Stylesstyles.xml, stored as deltas against the editor’s default stylesheet
Character stylesFlattened 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
ListsNumbered and bulleted, nested, with number formats and tab stops
Style-linked numberingLevel 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
SectionsPage size, orientation, margins, page numbering (start and format), line numbering, text direction
Headers & footersDefault, first-page and even-page variants
TablesBorders, colspan, rowspan, alignment, background colours, column widths, a repeating header row. Tables nested in tables or notes are flattened to paragraphs, with a warning
ImagesInline and floating (anchored) images, with wrap mode and side
Text boxesDrawingML 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
BordersParagraph and cell borders: style, width, colour
EquationsOMML becomes LaTeX-backed math nodes, inline and display
Footnotes & endnotesBodies, anchors, endnote numbering options
Cross-references & TOCA 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
FieldsPAGE / NUMPAGES become page-number nodes; HYPERLINK becomes a link; other fields keep their last displayed text
LinksExternal links, and internal links to a bookmark (they resolve to the block it wraps)
Track changesw: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
CommentsThreads, 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 blocksConsecutive HTMLPreformatted paragraphs become one code block
CalloutsA 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 breaksw:br w:type="page" becomes a page_break node

Fonts

The editor renders Word’s fonts through bundled open equivalents:

Word fontRendered as
ArialArimo (the document default)
CalibriCarlito
CambriaCaladea
Courier NewCousine
Times New RomanTinos
GeorgiaLora

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:

  1. 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, });
  2. A server action taking one argument that carries the JSON string, with serverActions.bodySizeLimit raised in next.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 exceeded once it has counted about 1e6 characters inside forked arrays. A single-argument call never forks, and a string is opaque to the count.

  3. Don’t pass the parsed doc object to a server action. Its nested content arrays 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 bodySizeLimit says. Self-hosted, the equivalent is your proxy’s limit (e.g. nginx client_max_body_size).
  • Check new Blob([contentJson]).size before sending and show your own message. Otherwise the user sees a truncated body as Unexpected 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

FeatureExport support
Text & paragraph formattingAll marks, fonts, alignment, spacing, indentation, direction, tab stops, borders
StylesCustom styles as w:style entries; edits to built-in styles as overrides
SectionsPage size, orientation, margins, page and line numbering, section and page breaks
Headers & footersDefault, first-page and even-page variants
TablesBorders, widths, alignment, backgrounds, spans, fixed column grids
ImagesInline and floating
Text boxesDrawingML text boxes (wps), without the legacy VML copy. A minimum height becomes Word’s “resize shape to fit text”
ListsNumber formats, nesting and tab stops. Styles keep their w:numPr, and the matching w:lvl/w:pStyle back-links are added. See Numbering
EquationsNative, editable OMML, not pictures
Footnotes & endnotesBodies and anchors; repeated references are linked; endnote options
CommentsThreads, replies and resolved state
Cross-references & TOCUpdatable 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 changesInsertions, deletions, moves and format changes, including on pictures, note anchors and fields
Code blocksOne HTMLPreformatted paragraph per line
CalloutsA quote as Quote-style paragraphs; other kinds as a tagged one-cell table with the kind’s fill, left rule and title. See Callouts
LinksExternal 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:

MethodDoes
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
warningsPush 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 .docm files 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, and REFs 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

Last updated on