Skip to Content
Import / ExportWord 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:

import { docxUIExtension } from "@stesura/docx-react"; <StesuraEditor uiExtensions={[docxUIExtension]} />

The editor’s default toolbar has no DOCX buttons without it. See @stesura/docx-react for what the buttons wire up (image uploads, comments, editor modes).

Or drive the engine yourself:

import { downloadDocx, generateDocxBuffer, importDocx, threadsToDocx } from "@stesura/docx"; const { doc, warnings, comments } = await importDocx(file, schema); const { warnings } = 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 keeps its unresolved placeholder src and raises a missing_image warning.

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
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
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 lives in @stesura/fonts.

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:

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.

    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?) saves the file in the browser; generateDocxBuffer(state, comments?) returns the bytes. 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
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
LinksExternal links; internal links as w:hyperlink w:anchor to a bookmark around the target

Limitations

Not imported:

  • SmartArt, charts, OLE objects, custom XML: skipped. Macros in .docm files are ignored.
  • 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