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 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 keeps its unresolved placeholdersrcand raises amissing_imagewarning.
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 |
| 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 |
| 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 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:
-
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, }); -
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?) 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
| 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 |
| 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 |
| Links | External 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
.docmfiles 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, 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